二分搜索详解
零、二分查找框架
int binarySearch(vector<int>& nums, int target) {
int left = 0, right = ...;
while (...) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
...;
} else if (nums[mid] < target) {
ledt = ...;
} else if (nums[mid] > target) {
right = ...;
}
}
return ...;
}
- 分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。
- 计算
mid
时需要防止溢出,代码中left + (right - left) / 2
就和(left + right) / 2
的结果相同,但是有效防止了left
和right
太大,直接相加导致溢出的情况。
一、寻找一个数
即搜索一个数,如果存在,返回其索引,否则返回 -1。
int binarySearch(vector<int>& nums, int target) {
int left = 0, right = nums.size(); // 注意 开区间[)
while(left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1; // 注意
} else if (nums[mid] > target) {
right = mid; // 注意
}
}
return -1;
}
1、为什么 while 循环条件是 <= 或是 < ?
while(left<=right)
对应的是闭区间[left,right]
,且right
赋值为nums.size()-1
。中止条件为[right+1,right]
,区间为空。
while(left<right)
对应的是开区间[left,right)
,且right
赋值为nums.size()
。中止条件为[right,right)
,区间为空。
2、为什么 left=mid+1,right=mid-1,有的代码是 right=mid,left=mid ?
算法的搜索区间是闭的,即[left,right]
,当mid
不是要找的target
时,应该搜索[left,mid-1]
或者[mid+1,right]
,因为mid
已经搜索过了应被去除。
二、寻找左侧边界
int left_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size(); // 对应开区间[left, right)
while (left < right) {
// 中止条件为[right, right), 区间为空
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // 因为`right`是开区间, 不用去除
}
}
if (left == nums.size()) return -1;
return nums[left] == target ? left : -1;
}
1、为什么 while 循环条件是 < 或是 <= ?
while(left<=right)
对应的是闭区间[left,right]
,且right
赋值为nums.size()-1
。中止条件为[right+1,right]
,区间为空。
while(left<right)
对应的是开区间[left,right)
,且right
赋值为nums.size()
。中止条件为[right,right)
,区间为空。
2、为什么left=mid+1
, right=mid
?
因为搜索区间[left,right)
是左闭右开的,当nums[mid]
被检测后,下一步应该去mid
的左侧或者右侧区间搜索,即[left, mid)
或[mid+1, right)
。
三、寻找右侧边界
int right_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size(); // 左闭右开[)
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
left = mid + 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
if(left - 1 < 0) return -1;
return nums[left - 1] == target ? (left - 1) : -1;
}
1、为什么最后返回 left - 1
而不像左侧边界的函数,返回 left
?
因为当nums[mid] == target
时,left = mid + 1
,所以最后的结果一定比右边界大于1。
四、逻辑统一
最基本的二分查找算法
因为我们初始化 right = nums.length - 1
所以决定了我们的「搜索区间」是 [left, right]
所以决定了 while (left <= right)
同时也决定了 left = mid+1 和 right = mid-1
因为我们只需找到一个 target 的索引即可
所以当 nums[mid] == target 时可以立即返回
寻找左侧边界的二分查找
因为我们初始化 right = nums.length
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left < right)
同时也决定了 left = mid + 1 和 right = mid
因为我们需找到 target 的最左侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧右侧边界以锁定左侧边界
寻找右侧边界的二分查找
因为我们初始化 right = nums.length
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left < right)
同时也决定了 left = mid + 1 和 right = mid
因为我们需找到 target 的最右侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧左侧边界以锁定右侧边界
又因为收紧左侧边界时必须 left = mid + 1
所以最后无论返回 left 还是 right,必须减一
刷题
0034. 在排序数组中查找元素的第一个和最后一个位置(剑Ⅰ0053)
给定一个按照升序排列的整数数组 nums
,和一个目标值 target
,找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值 target
,返回 [-1, -1]
。
示例 1:
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
解题思路:
- 二分搜索的难点就在于如何搜索左侧边界和右侧边界。
代码:
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
vector<int> res;
int left = left_bound(nums, target);
int right = right_bound(nums, target);
res.push_back(left);
res.push_back(right);
return res;
}
int left_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size(); // [...)
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
// ==:(right = mid)
right = mid;
}
}
if (left == nums.size()) return -1;
return nums[left] == target ? left : -1;
}
int right_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size(); // [...)
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] <= target) {
left = mid + 1;
} else {
// ==:(left = mid + 1)
right = mid;
}
}
if (left - 1 < 0) return -1;
return nums[left - 1] == target ? (left - 1) : -1;
}
};
0035.(剑Ⅱ0068). 搜索插入位置[简单]
题目:
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
请必须使用时间复杂度为 O(log n) 的算法。
示例:
输入: nums = [1,3,5,6], target = 5
输出: 2
输入: nums = [1,3,5,6], target = 2
输出: 1
思路:
当目标元素 target
不存在数组 nums
中时,搜索左侧边界的二分搜索的返回值可以做以下几种解读:
- 返回的这个值是
nums
中大于等于target
的最小元素索引。 - 返回的这个值是
target
应该插入在nums
中的索引位置。 - 返回的这个值是
nums
中小于target
的元素个数。
本题中采用的第二种说法,所以使用基本的搜索左边界就可以了。
代码:
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
// ==:->(right = mid)
right = mid;
}
}
return left;
}
};
0074. 搜索二维矩阵
题目:
编写一个高效的算法来判断 m x n
矩阵中,是否存在一个目标值。该矩阵具有如下特性:
- 每行中的整数从左到右按升序排列。
- 每行的第一个整数大于前一行的最后一个整数。
示例:
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3
输出:true
思路:
- 二维的坐标
(i, j)
可以映射成一维的index = i * n + j
,相反一维index
也可反解出i = index / n
,j = index % n
。 - 实现一个
get
函数,把二维坐标抽象为一维,然后二分搜索即可。
代码:
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
int m = matrix.size(), n = matrix[0].size();
int left = 0, right = m * n; // [left, right)
while (left < right) {
// 二分搜索
int mid = left + (right - left) / 2;
if (get(matrix, mid) < target) {
left = mid + 1;
} else if (get(matrix, mid) > target) {
right = mid;
} else {
return true;
}
}
return false;
}
/* 得到索引为 index 的二维数组对应元素 */
int get(vector<vector<int>>& matrix, int index) {
int m = matrix.size(), n = matrix[0].size(); // 总行数列数
int i = index / n, j = index % n; // index 二维对应坐标
return matrix[i][j];
}
};
0354. 俄罗斯套娃信封问题
题目:
给你一个二维整数数组envelopes
,其中envelopes[i] = [wi, hi]
,表示第i
个信封的宽度和高度。
当另一个信封的宽度和高度都比这个信封大的时候,这个信封就可以放进另一个信封里。
请计算最多能有多少个信封能组成一组“俄罗斯套娃”信封?
示例:
输入:envelopes = [[5,4],[6,4],[6,7],[2,3]]
输出:3
解释:最多信封的个数为 3, 组合为: [2,3] => [5,4] => [6,7]。
思路
本题相当于在二维求解最长递增子序列。
先对宽度w
进行升序排序,如果w
相同,按照高度h
降序排序。
class Solution {
public:
int maxEnvelopes(vector<vector<int>>& envelopes) {
int n = envelopes.size();
sort(envelopes.begin(), envelopes.end(), [](vector<int>& a, vector<int>& b){
return a[0] == b[0] ? a[1] > b[1] : a[0] < b[0];
});
vector<int> nums(n);
for (int i = 0; i < n; i++) {
nums[i] = envelopes[i][1];
}
return lengthOfLIS(nums);
}
int lengthOfLIS(vector<int>& nums) {
int len = nums.size();
vector<int> tail;
tail.push_back(nums[0]);
int res = 0;
for (int i = 1; i < len; i++) {
if (nums[i] > tail[res]) {
tail.push_back(nums[i]);
res++;
} else {
int left = 0, right = res;
while (left < right) {
int mid = left + (right - left) / 2;
if (tail[mid] < nums[i]) {
left = mid + 1;
} else {
right = mid;
}
}
tail[left] = nums[i];
}
}
return res + 1;
}
};
0392. 判断子序列(##滑动指针??##)
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
示例:
输入:s = "abc", t = "ahbgdc"
输出:true
解题思路:
- 与其将
left<s.size()
写在循环内,将其写在循环外的m=s.size();left<m;
会使速度更快。 - 利用双指针
i, j
分别指向s, t
,一边前进一边匹配子序列。 - 之后把所有的高度
h
计算最长上升子序列LIS
的长度就是答案。
代码:
class Solution {
public:
int maxEnvelopes(vector<vector<int>>& envelopes) {
int n = envelopes.size();
// 一维升序二维降序排序
sort(envelopes.begin(), envelopes.end(), [](vector<int>& a, vector<int>& b){
return a[0] == b[0] ? a[1] > b[1] : a[0] < b[0];
});
// 将值赋给一维数组
vector<int> nums(n);
for (int i = 0; i < n; i++) {
nums[i] = envelopes[i][1];
}
return lengthOfLIS(nums);
}
int lengthOfLIS(vector<int>& nums) {
int len = nums.size(); // 当前卡牌总数
vector<int> tail; // 记录宽度为i的LIS尾数
tail.push_back(nums[0]); // 开始时宽度1的tail为nums[0]
int res = 0; // tail索引
for (int i = 1; i < len; i++) {
// 开始加牌
if (nums[i] > tail[res]) {
// 如果牌大于当前最大值
tail.push_back(nums[i]); // 将其新添加到tail尾端
res++; // 宽度加一
} else {
// 如果牌小于当前最大值,寻找合适的插入位置
int left = 0, right = res; // 二分法查询位置
while (left < right) {
int mid = left + (right - left) / 2;
if (tail[mid] < nums[i]) {
left = mid + 1;
} else {
right = mid;
}
}
tail[left] = nums[i]; // 替换
}
}
return res + 1; // 返回数量,为索引加一
}
};
代码:
class Solution {
public:
bool isSubsequence(string s, string t) {
// 利用双指针
int left = 0, right = 0;
int m = s.size(), n = t.size();
while (left < m && right < n) {
if (s[left] == t[right]) {
left++;
}
right++;
}
return left == m;
}
};
0704. 二分查找
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
示例:
输入:nums = [-1,0,3,5,9,12], target = 2
输出:-1
解释:2 不存在 nums 中因此返回 -1
解题思路:
- 只适用与只有一个目标值的情况。
代码:
class Solution {
public:
int search(vector<int>& nums, int target) {
return binarySearch(nums, target);
}
int binarySearch(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target){
right = mid;
} else {
return mid;
}
}
return -1;
}
};
0793. 阶乘函数后K个零
f(x)
是 x!
末尾是 0 的数量。回想一下 x! = 1 * 2 * 3 * ... * x
,且 0! = 1
。
例如,f(3) = 0
,因为 3! = 6
的末尾没有 0 ;而 f(11) = 2 ,因为 11!= 39916800 末端有 2 个 0 。
给定 k,找出返回能满足 f(x) = k 的非负整数 x 的数量。
示例 1:
输入:k = 0
输出:5
解释:0!, 1!, 2!, 3!, 和 4! 均符合 k = 0 的条件。
解题思路:
- 搜索有多少个
n
满足trailingZeroes(n) == K
,其实就是在问,满足条件的n
最小是多少,最大是多少,最大值和最小值一减,就可以算出来有多少个 n 满足条件了
代码:
class Solution {
public:
int preimageSizeFZF(int k) {
return (int)(right_bound(k) - left_bound(k) + 1);
}
// 判断数字 n! 末尾有几个零 (n 有多少个含5的因式)
// 如:100/5=20 + 20/5=4 = 24
long trailingZeros(long n) {
long res = 0;
for (long d = n; d / 5 > 0; d = d / 5) {
res += d / 5;
}
return res;
}
/* 探索 trailingZeros(n)==K 的左边界 */
long left_bound(int target) {
long left = 0, right = 10e9;
while (left < right) {
long mid = left + (right - left) / 2;
if (trailingZeros(mid) < target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
/* 探索 trailingZeros(n)==K 的右边界 */
long right_bound(int target) {
long left = 0, right = 10e9;
while (left < right) {
long mid = left + (right - left) / 2;
if (trailingZeros(mid) <= target) {
left = mid + 1;
} else {
right = mid;
}
}
return left - 1;
}
};
0852. 山脉数组的峰顶索引(剑Ⅱ0069)
符合下列属性的数组 arr 称为 山脉数组 :
arr.length >= 3
- 存在
i(0 < i < arr.length - 1)
使得:arr[0] < arr[1] < ... arr[i-1] < arr[i]
且arr[i] > arr[i+1] > ... > arr[arr.length - 1]
给你由整数组成的山脉数组arr
,返回任何满足arr[0] < arr[1] < ... arr[i - 1] < arr[i] > arr[i + 1] > ... > arr[arr.length - 1]
的下标i
。
示例 1:
输入:arr = [0,1,0]
输出:1
解题思路:
- 在寻找峰值的过程中,通过
arr[mid]
和arr[mid+1]
来判断位于峰值左边还是右边。
代码:
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr) {
// 确定左右边界
int left = 1, right = arr.size() - 2;
while (left < right) {
int mid = left + (right - left) / 2;
// 通过 `arr[mid]` 和 `arr[mid+1]` 来判断位于峰值左边还是右边。
if (arr[mid] < arr[mid+1]) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
};
0875. 爱吃香蕉的珂珂(剑Ⅱ0073)
珂珂喜欢吃香蕉。这里有 n
堆香蕉,第 i
堆中有 piles[i]
根香蕉。警卫已经离开了,将在 h
小时后回来。珂珂可以决定她吃香蕉的速度 k
。每个小时,她将会选择一堆香蕉,从中吃掉 k
根。如果这堆香蕉少于 k
根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。 返回她可以在 h
小时内吃掉所有香蕉的最小速度 k
。
示例 1:
输入:piles = [3,6,7,11], h = 8
输出:4
解题思路:
- 二分法的套路为:遇到问题时确定
x
,f(x)
,target
分别是什么,并写出单调函数f
的代码。 - 本题中吃香蕉的速度为
x
,吃完所需要的时间为f(x)
,吃香蕉的时间限制为target
。
代码:
class Solution {
public:
int minEatingSpeed(vector<int>& piles, int h) {
// 确定二分法边界
int left = 1;
int right = *max_element(piles.begin(), piles.end());
while (left < right) {
int mid = left + (right - left) / 2;
if (f(piles, mid) <= h) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
// 吃香蕉速度 x 与 吃完所需时间 f(x) 的函数
int f(vector<int>& piles, int x) {
int hours = 0;
for (int i = 0; i < piles.size(); i++) {
// 每堆香蕉需要吃几次
if (piles[i] % x > 0) {
hours++;
}
hours += piles[i] / x;
}
return hours;
}
};
1011. 在 D 天内送达包裹的能力
传送带上的第 i
个包裹的重量为 weights[i]
。每一天,我们都会按给出重量(weights
)的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。
示例 1:
输入:weights = [1,2,3,4,5,6,7,8,9,10], days = 5
输出:15
解释:
船舶最低载重 15 就能够在 5 天内送达所有包裹,如下所示:
第 1 天:1, 2, 3, 4, 5
第 2 天:6, 7
第 3 天:8
第 4 天:9
第 5 天:10
解题思路:
- 如果遇到一个算法问题,尝试确定
x
,f(x)
,target
分别是什么,并写出单调函数f
的代码。 - 船的运载能力是
x
,运输天数D
与运载能力x
呈反比,target
为运输天数D
,我们要在f(x) == D
的约束下,求出x
。
tip:
*max_element(weights.begin(), weights.end()); // 求得最大值
accumulate(weights.begin(), weights.end(), 0); // 初始值为0,求和
代码:
class Solution {
public:
int shipWithinDays(vector<int>& weights, int days) {
// left求最大的值,right求和
int left = *max_element(weights.begin(), weights.end());
int right = accumulate(weights.begin(), weights.end(), 0);
while (left < right) {
int mid = left + (right - left) / 2;
if (f(weights, mid) <= days) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
/* 当运载能力为 x 时,需要 f(x) 天运完货物 */
int f(vector<int>& weights, int x) {
int days = 0;
for (int i = 0; i < weights.size();) {
// 尽可能地多装货物
int cap = x;
while (i < weights.size()) {
if (cap < weights[i]) {
break;
} else {
cap -= weights[i];
}
i++;
}
days++;
}
return days;
}
};
1201. 丑数 Ⅲ
给你四个整数:n
、a
、b
、c
,请你设计一个算法来找出第 n
个丑数。
丑数是可以被 a
或 b
或 c
整除的 正整数 。
示例 1:
输入:n = 3, a = 2, b = 3, c = 5
输出:4
解释:丑数序列为 2, 3, 4, 5, 6, 8, 9, 10... 其中第 3 个是 4。
解题思路:
- 可以抽象出一个单调递增的函数
f:f(num,a,b,c)
计算[1..num]
中,能够整除a
或b
或c
的数字的个数,则f
返回值的个数是随着num
的增加而增加的。 - 也就是说需要找到一个
num
,使得f(num,a,b,c)==n
。 - 在
[1..num]
中,把能整除a
的数字归为集合A
,把能整除b
的数字归为集合B
,把能整除c
的数字归为集合C
,那么len(A) = num/a
,len(B) = num/b
,len(C) = num/c
。 A∩B=n / lcm(a,b)
,其中lcm
是最小公倍数,lcm(a,b) = a * b / gcd(a,b)
。其中gcd
是最大公因数,可用辗转相除法求得。- 最后的结果为
res = A + B + C - A∩B - A∩C - B∩C + A∩B∩C
代码:
class Solution {
public:
/* 二分搜索求解 */
int nthUglyNumber(int n, int a, int b, int c) {
// 初始化搜索区间
int left = min(min(a,b),c); // 下边界显然是a、b、c中最小者
int right = left * n; // 上边界是这个最小者的n倍
// 左边界的二分搜索,f()返回相同的值时找最小的那个边界
while (left < right) {
int mid = left + (right - left) / 2;
if (f(mid, a, b, c) < n) {
// 元素不足n,目标在右侧
left = mid + 1;
} else {
// 元素大于n,目标在左侧
right = mid;
}
}
return left;
}
/* 计算 [1..num] 之间有多少个能够被 a 或 b 或 c 整除的数字 */
long f(int num, int a, int b, int c) {
long setA = num / a, setB = num / b, setC = num / c;
long setAB = num / lcm(a, b);
long setBC = num / lcm(b, c);
long setAC = num / lcm(a, c);
long setABC = num / lcm(lcm(a, b), c);
// 集合论定理:A + B + C - A ∩ B - A ∩ C - B ∩ C + A ∩ B ∩ C
return setA + setB + setC - setAB - setAC - setBC + setABC;
}
/* 辗转相除法求最大公因数 */
long gcd(long a, long b) {
if (a < b) {
return gcd(b, a);
}
if (b == 0) {
return a;
}
return gcd(b, a % b);
}
/* 求最小公倍数 */
long lcm(long a, long b) {
return a * b / gcd(a, b);
}
};