思想

二分查找也是有一套模板的,可以用如下的步骤总结:

  1. 使用前提:元素在某种程度上有序;
  2. 明确下标的含义与边界;
  3. left, right 赋值与计算中间 mid
  4. 当决策边界合法时,进行判断;
  5. 根据条件剪枝,并得到新的边界;
  6. 边界非法,跳出循环,得到结果。

这 6 步具体怎么实施,从下面的几道题来讨论。

题目

4. Median of Two Sorted Arrays

先从一道 Hard 开始,它要求我们找到两个有序数组中的中位数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
public:
double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
int m = nums1.size(), n = nums2.size();

if (m > n) {
return findMedianSortedArrays(nums2, nums1);
}

int left = 0, right = m;

while (left <= right) {
int i = left + (right - left) / 2;
int j = (m + n + 1) / 2 - i;

int Aleft = (i == 0 ? INT_MIN : nums1[i - 1]);
int Aright = (i == m ? INT_MAX : nums1[i]);
int Bleft = (j == 0 ? INT_MIN : nums2[j - 1]);
int Bright = (j == n ? INT_MAX : nums2[j]);

if (Aleft <= Bright && Bleft <= Aright) {
if ((m + n) % 2) {
return max(Aleft, Bleft);
}
else {
return (max(Aleft, Bleft) + min(Aright, Bright)) / 2.0;
}
}
else if (Aleft > Bright) {
right = i - 1;
}
else {
left = i + 1;
}
}

return 0;
}
};

按照前面的分析,我们走一遍这 6 步。

  1. 有序吗?有序。两个数组都是有序的,在某种程度上,可以根据有序的性质来剪枝;
  2. 下标的含义是?下标的含义是,在 idx(从 0 开始) 前开始切割;
  3. 赋值左右,中间有两个,数组 1 的中间和数组 2 的中间;
  4. 当左边 <= 右边时,进行循环;
  5. 什么时候满足条件?当 (数组 1 左 <= 数组 2 右) 且 (数组 2 左 <= 数组 1 右)。剪枝的选择是,如果数组 1 左 > 数组 2 右,说明右边范围太大,反之同理;
  6. 最后肯定会找到结果。

这道题的难点是,要考虑两个中点。一个中点是传统意义上的,另外一个中点是由中位数的性质引出来的被动中点。由于两个中点的出现,要在最前方加一个条件判断,确保数组 1 的长度比数组 2 的小。

为什么呢?因为根据两个中点的计算方式,数组 2 的中点可能需要做出一些额外贡献,必须保证 i + j 是数组总数的一半(+ 1 )。极端的例子是,数组 2 为空,此时数组 2 最大的合法中点是 0,不可能根据 (m + n + 1) / 2 - i 补足一半。

至于说为什么循环的判断是 left <= right,我认为这和剪枝的方式有关系。如果每次剪枝都是严格的范围减小,那么可以保留等号;否则,有可能出现跳不出循环的情况。

这题就是严格的范围变化,每当中点不满足时,left 或者 right 考虑中点的下一个位置。为什么不赋值 left = mid 或者 right = mid?因为这次判断已经说明了 mid 是非法选择,因此可以考虑下一个。之后我们会看到一个题目,while 内是严格的不等号。

33. Search in Rotated Sorted Array

这题要求我们在经过了循环位移后的数组中找是否存在某个元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:
int search(vector<int>& nums, int target) {
int n = nums.size();
int left = 0, right = n - 1;

while (left <= right) {
int mid = left + (right - left) / 2;

if (nums[mid] == target) {
return mid;
}

if (nums[mid] < nums[right]) {
if (nums[mid] < target && target <= nums[right]) {
left = mid + 1;
}
else {
right = mid - 1;
}
}
else {
if (nums[left] <= target && target < nums[mid]) {
right = mid - 1;
}
else {
left = mid + 1;
}
}
}

return -1;
}
};

这题是比较经典的模板了,但是稍微有些变形,体现在剪枝并不是无脑地划分一半,而是需要经过一些小巧思的条件判断。

注意到,当数组的左半边递增时,如果目标在左半边,则右半边剪枝;否则左半边剪枝。对于右半边递增的情况,同理。

这里要注意, targetnums[left]nums[right] 比较时,需要挂上等号。虽然我觉得,如果真的相等,可以提前终止,但,为了保证二分查找的规范性,还是这样吧。而与 nums[mid] 对应的一端不必挂等,因为上面判断过二者肯定不等。

34. Find First and Last Position of Element in Sorted Array

这题要求我们在有序数组中,找到目标元素的开始下标和结束下标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Solution {
private:
int searchLeft(vector<int>& nums, int target) {
int n = nums.size();

int left = 0, right = n - 1;
int ans = -1;

while (left <= right) {
int mid = left + (right - left) / 2;

if (nums[mid] >= target) {
right = mid - 1;
if (nums[mid] == target) ans = mid;
}
else {
left = mid + 1;
}
}

return ans;
}
int searchRight(vector<int>& nums, int target) {
int n = nums.size();

int left = 0, right = n - 1;
int ans = -1;

while (left <= right) {
int mid = left + (right - left) / 2;

if (nums[mid] <= target) {
left = mid + 1;
if (nums[mid] == target) ans = mid;
}
else {
right = mid - 1;
}
}

return ans;
}
public:
vector<int> searchRange(vector<int>& nums, int target) {
return {searchLeft(nums, target), searchRight(nums, target)};
}
};

这题的方法是两次二分,我第一次做,还是考虑的可能不剪枝,同时考虑两边,一下把所有的解都找到,但其实不是最好的。关于二分查找,我认为还是要想办法剪枝,尽量剪枝,符合规范。

至于说这题有什么不同的地方吗?可能需要记录一下 ans,以及反应过来是两次二分。没别的了。

35. Seach Insert Position

这题要求我们找到一个元素在有序数组中的下标,如果没有,则返回插入后在什么位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int n = nums.size();

int left = 0, right = n - 1;

while (left <= right) {
int mid = left + (right - left) / 2;

if (nums[mid] == target) {
return mid;
}

if (nums[mid] > target) {
right = mid - 1;
}
else {
left = mid + 1;
}
}

return left;
}
};

很基础的二分查找。值得一提的是关于元素插入时,下标的计算。最后返回的是 left。我觉得可以进行一个简单的推理:

最终,肯定 left == right == mid,分成两种可能。如果 mid 对应的值比 target 小,那么 left + 1,说明元素应该插在当前元素后;反过来,right - 1,但是 left 不变,表示元素插入在当前元素前,并且插入后下标就是当前元素的下标。

74. Search a 2D matrix

这题给了我们一个二维数组,有趣的是,这个二维数组的每一行如果首尾相接,是单调递增的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 - 1;

while (left <= right) {
int mid = left + (right - left) / 2;
int row = mid / n;
int col = mid % n;

if (matrix[row][col] == target) {
return true;
}
else if (matrix[row][col] > target) {
right = mid - 1;
}
else {
left = mid + 1;
}
}

return false;
}
};

这题是个很基础的二分,和一维的相比,唯一要考虑的是思维的转变:如果把二维数组展平成一维的,那就是很简单的查找了。

124. Binary Tree Maximum Path Sum

这题要求我们找到二叉树中,最大的路径和。这题并不是二分查找,因为不存在有序这个特征。应该放到二叉树专题下,这里不讨论。

153. Find Minimum in Rotated Sorted Array

这题要求我们在一个循环位移后的数组中,找到最小值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int findMin(vector<int>& nums) {
int n = nums.size();

int left = 0, right = n - 1;

while (left < right) {
int mid = left + (right - left) / 2;

if (nums[mid] < nums[right]) {
right = mid;
}
else {
left = mid + 1;
}
}

return nums[left];
}
};

这里,终止条件是严格小于,为什么不能挂等呢?因为当 nums[mid] < nums[right] 时,说明右半边递增,此时 mid 仍然有可能是最小值,所以 right = mid,而不是 mid - 1。此时边界并不是严格减小。

试想,最终必然出现 left == right 的情况,此时已经找到最小值了。如果再挂等,left += 1,反而不是所求。

总之,我还是没太熟稔关于边界是否挂等的判断。但目前的认识是,如果每次范围严格缩小,则需要挂等,因为新的范围严格更小,之前没有探索过,必须再次判断;而如果有至少一边的范围不是严格缩小,则不用挂等,结束条件是收敛到一点,这一点就是答案。