前言
二分法的思想:将一段区间(代码中往往指一个数组)一分为二,进而排除掉不符合条件的一半,选择符合条件的一半继续进行,直至寻找到正确答案(或无元素可供查找)
时间复杂度:因为每次都能排除一半的区间,因此时间复杂度为log(n)
适用条件:一般都要求数组有序,或间接有序(后面有例题)
二分查找法的思想在 1946 年就被提出,但是第 1 个没有 Bug 的二分查找法在 1962 年才出现。
《计算机程序艺术设计 · 第三卷》
如上,思想虽然简单,但是往往在边界条件的处理上让人头疼。在比赛或者面试过程中,没有太多时间
模板
步骤
二分法的处理步骤:
- 查看数组是否有序或间接有序,即每次将数组分成2部分时,是否能够确认目标值在哪一部分
- 构造判断条件
- 处理边界条件(由于基本都是
(left + right) / 2
,每次缩减一半,因此通常只需分析当数组长度为2时的特殊情况即可)
1. 常用模板
给定一个有序数组nums,再给定一个target,查找target在数组中的下标,没有则返回-1
nums = [1, 2, 3 ,5 ,6 ,7 ,8] target = 5
ans = 3
// 常见二分法
int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
int mid = 0;
while(left <= right) {
mid = (left + right) / 2;
if (target == nums[mid]) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
- 数组有序
- 构造判断条件,即当
target == nums[mid]
时,返回下标。当nums[mid] < target
时,说明左半部分区间均< target
,因此可以排除左半区间,left = mid + 1
继续查找。反之同理 - 当待搜索的数组长度为2时,假设搜索[4, 5],此时
left = 0 right = 1
,则mid = 0
,因此先搜索4,4 < 5
排除左半区间,left = mid + 1 = 1
,则下次搜索只剩[5],得出正确答案。如还未搜索到,此时left
再+1
,left > right
跳出循环,返回-1
2. 左边界模板
给定一个有序数组nums,再给定一个target,查找target在数组中的下标,没有则返回-1
nums = [1, 2, 3 ,3 ,3 ,7 ,8] target = 3
ans = 2
// 左边界
public int searchLeft(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
int mid = 0;
while(left < right) {
mid = (left + right) / 2;
if(nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
return nums[left] == target ? left : -1;
}
- 数组有序
- 判断条件,上一题中当
nums[mid] == target
时,直接return
。此时由于寻找的是左边界,因此当nums[mid] == target
,依旧需要向左继续查找 - 与上一题边界条件不同,此时如果依旧写
while(left <= right)
,上一题中当nums[mid] == target
时,直接return
。而本题由于没有return
,而是right = mid
,因此将会陷入死循环
。例如:nums = [5],target = 5,left = 0,right = 0,mid = 0,此时如果条件while(left <= right)
将会陷入死循环。因此在边界条件的处理上,我们将==
条件独立出来判断
为了与下方右边界模板统一,我们也可以将左边界模板的右指针初始话成right = nums.length
,但是由此带来的问题就是。当target大于数组内所有数时,left
会不断右移,直到left = right = nums.length
,此时就会发生数组越界。因此需要特殊判断
public int searchLeft(int[] nums, int target) {
int left = 0;
int right = nums.length;
int mid = 0;
while(left < right) {
mid = (left + right) / 2;
if(nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
if(left == nums.length) return -1;
return nums[left] == target ? left : -1;
}
3. 右边界模板
给定一个有序数组nums,再给定一个target,查找target在数组中的下标,没有则返回-1
nums = [1, 2, 3 ,3 ,3 ,7 ,8] target = 3
ans = 4扫描二维码关注公众号,回复: 11259607 查看本文章
// 右边界
public int searchRight(int[] nums, int target) {
int left = 0;
int right = nums.length;
int mid = 0;
while(left < right) {
mid = (left + right) / 2;
if(nums[mid] <= target) {
left = mid + 1;
} else {
right = mid;
}
}
if(left == 0) return nums[left] == target ? left : -1;
return nums[left - 1] == target ? left - 1 : -1;
}
- 数组有序
- 判断条件,此时由于寻找的是右边界,因此当
nums[mid] == target
,依旧需要向右继续查找 - 值得注意的是,
int right = nums.length
,此时的右指针不再指向最右侧节点,而是最右侧+1节点。其实跟左边界最大的区别在于(0 + 1) / 2 = 0
,由于向下取整的缘故,每次除都是向左靠齐的。而现在搜索的是右边界,使用了left = mid + 1;
,其实left
是偏向右一位去进行尝试的,实际可能出现正确答案其实在[left-1]
,同时还需要考虑特殊情况,即target在最左侧的情况if(left == 0) return nums[left] == target ? left : -1;
,否则left - 1
越界
力扣真题
力扣35(easy) 0ms
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
示例 1:
输入: [1,3,5,6], 5
输出: 2
示例 2:
输入: [1,3,5,6], 2
输出: 1
此题是典型的二分法应用题,通过二分法搜索插入位置,数组搜索范围只剩1个元素时,如果 该元素 <= target
时,则返回当前位置。否则返回target
下一个位置
class Solution {
public int searchInsert(int[] nums, int target) {
if(nums.length == 0) return 0;
int left = 0;
int right = nums.length - 1;
int mid = 0;
// 此时没有等号,因为当数组搜索只剩一个元素,即left == right时,我们停止,自行判断
while(left < right) {
mid = (left + right) / 2;
if(nums[mid] == target) {
return mid;
} else if(nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if(nums[left] == target) {
return left;
}else if(nums[left] < target) {
return left + 1;
}else {
return left;
}
}
}
该代码只是为了方便读者理解,其实最后一段代码是没有必要的,完全可以合并到循环中,因此最终代码为
class Solution {
public int searchInsert(int[] nums, int target) {
if(nums.length == 0) return 0;
int left = 0;
int right = nums.length;
int mid = 0;
while(left < right) {
mid = (left + right) / 2;
if(nums[mid] == target) {
return mid;
} else if(nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
}
力扣34(mid) 0ms
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
你的算法时间复杂度必须是 O(log n) 级别。
如果数组中不存在目标值,返回 [-1, -1]。
示例 1:
输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]
这题很明显就是搜索左右边界问题,左边界我们有2套模板,右边界一套,直接代入即可
class Solution {
public int[] searchRange(int[] nums, int target) {
if(nums.length == 0) return new int[]{-1, -1};
int[] ans = new int[2];
ans[0] = searchLeft(nums, target);
ans[1] = searchRight(nums, target);
return ans;
}
public int searchLeft(int[] nums, int target) {
int left = 0;
int right = nums.length;
int mid = 0;
while(left < right) {
mid = (left + right) / 2;
if(nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
if(left == nums.length) return -1;
return nums[left] == target ? left : -1;
}
public int searchRight(int[] nums, int target) {
int left = 0;
int right = nums.length;
int mid = 0;
while(left < right) {
mid = (left + right) / 2;
if(nums[mid] <= target) {
left = mid + 1;
} else {
right = mid;
}
}
if(left == 0) return nums[left] == target ? left : -1;
return nums[left - 1] == target ? left - 1 : -1;
}
}
力扣33(mid) 0ms
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。
你可以假设数组中不存在重复的元素。
你的算法时间复杂度必须是 O(log n) 级别。
示例 1:
输入: nums = [4,5,6,7,0,1,2], target = 0
输出: 4
解法1:
这题呼应上面所说,数组应该有序或间接有序。如示例1,假设此时nums[mid] = 1
而target = 4
,通常当nums[mid] < target
时,我们需要往右半部分
查找,但是此时因为数组倒置,也就是4 > nums[nums.length - 1]
,所以实际我们应该往左半部分查找。由此会有以下伪代码。这是一种做法,分析出所有可能,笔者认为稍微复杂了一些
if(nums[mid] < target && taget >= nums[nums.length - 1]) {
// 查找左半区
}
if(nums[mid] < target && taget < nums[nums.length - 1]) {
// 查找右半区
}
...
解法2:
如果我们能知道数组在具体的哪个点处被倒置,那么我们剩下的工作就简单多了,只需要把target
与nums[0]
做一下比较,就可以知道应该在前半段还是在后半段进行搜索。而寻找倒置点的工作也恰好时一个二分法的过程。
class Solution {
public int search(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length - 1;
// 寻找倒置点,如果数组没倒置则返回-1
int mid = searchMid(nums);
if (mid == -1) {
// 数组没倒置吗,直接搜索全数组
return searchTarget(nums, target, left, right);
} else if (target >= nums[left]) {
// 数组倒置,但是target大于nums[0],所以在左半部分找
return searchTarget(nums, target, left, mid);
} else {
// 在右半部分找
return searchTarget(nums, target, mid + 1, right);
}
}
public int searchMid(int[] nums) {
if (nums.length <= 1) {
return -1;
}
int left = 0;
int right = nums.length - 1;
int mid = 0;
while (left <= right) {
mid = (left + right) / 2;
// 如果mid数大于mid+1,说明改点就是倒置点
if (mid + 1 < nums.length && nums[mid] > nums[mid + 1]) {
return mid;
} else if (nums[mid] >= nums[left]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
// 常用的二分法搜索模板
public int searchTarget(int[] nums, int target, int left, int right) {
int mid = 0;
while (left <= right) {
mid = (left + right) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] > target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return -1;
}
}
力扣4(hard) 2ms
给定两个大小为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。
请你找出这两个正序数组的中位数,并且要求算法的时间复杂度为 O(log(m + n))。
你可以假设 nums1 和 nums2 不会同时为空。
示例 1:
nums1 = [1, 3]
nums2 = [2]
则中位数是 2.0
这道题当真有点难,首先看到2个数组时有序的,大概可以想到二分法。而这道题的解题关键则在于需要找到二分法的目标,即最终目标是找到第K小的数
当nums1与nums2的数组长度和为奇数时,则求第 (m + n + 1) / 2小的数。如果为偶数,则需要求第(m + n + 1) / 2小与(m + n + 2) / 2小的数的平均值。又因为当长度和为奇数时(m + n + 1) / 2等于(m + n + 2) / 2,所以其实问题可以简化(该思想可大量用在奇偶数的例题中)
// findK为寻找第k小的数
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
int n = nums1.length;
int m = nums2.length;
int k1 = (m + n + 1) / 2;
int k2 = (m + n + 2) / 2;
return (findK(nums1, 0, nums2, 0, k1) + findK(nums1, 0, nums2, 0, k2)) / 2.0;
}
// findk...
所有问题都落到findK
的求解上。我们要求第K小的数,那么我们从nums1和nums2中分别取第k / 2
下标的数比较大小,例如此时nums1较小,那么我们就可以排除nums1中k / 2
左区间的所有数,同时,继续调用findK寻找第K - (k / 2)
小数。
当然,由于我们数组是固定的,因此我们用i, j分别代表nums1和nums2正在搜索的下标
class Solution {
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
int n = nums1.length;
int m = nums2.length;
int k1 = (m + n + 1) / 2;
int k2 = (m + n + 2) / 2;
return (findK(nums1, 0, nums2, 0, k1) + findK(nums1, 0, nums2, 0, k2)) / 2.0;
}
public int findK(int[] nums1, int i, int[] nums2, int j, int k){
// 如果i大于nums1的长度,则直接返回nums[2]的第k小数
if(nums1.length == i) return nums2[j + k - 1];
if(nums2.length == j) return nums1[i + k - 1];
// 如果k为1,则直接返回小的元素
if(k == 1) {
return nums1[i] > nums2[j] ? nums2[j] : nums1[i];
}
// mid1代表nums1往下k/2个数的下标
int mid1 = i + k / 2 - 1;
// 如果下标超过当前数组,那就以最后一个元素为比较点
if (mid1 > nums1.length - 1) mid1 = nums1.length - 1;
int mid2 = j + k / 2 - 1;
if (mid2 > nums2.length - 1) mid2 = nums2.length - 1;
if(nums1[mid1] >= nums2[mid2]) {
// nums1[mid]大,nums2[mid2]小,则说明可以排除nums2[mid2]左半区间的数
// 因此nums2从mid2 +1开始搜索
// mid2 - j + 1是此次排除掉的元素个数,下一次寻找则应该是第k - (mid2 - j -1)小的数
return findK(nums1, i, nums2, mid2 + 1, k - mid2 + j - 1);
} else {
return findK(nums1, mid1 + 1, nums2, j, k - mid1 + i - 1);
}
}
}
``