浅谈二分
二分查找
前言
视频链接:二分查找入门_哔哩哔哩
本质
逐步缩小搜索区间,减治思想,排除法
如何使用二分查找
将求值问题转化为判断问题
前提
使用二分查找的前提:
- 访问的值具有随机访问特性(如数组可以根据下标 \(O(1)\) 的时间复杂度访问元素值,而链表需要遍历)
- "有序"(不是有序的数组也可以使用 二分查找,这一类题是可以根据某一位置的值,来推测出两侧元素的性质,进而缩小搜索区间,被称作二分答案)
基本思路
- 确定 查找区间范围 \([left,right]\)。任何时刻 \([left,right]\) 都有可能是目标结果。
- 确定 \(while\) 循环条件,它代表着循环可以继续的条件:
- 当 \(while\) 循环条件为
while(left<right)
时,循环退出 \(left=right\),它们指向同一个元素,即目标元素 - 当 \(while\) 循环条件为
while(left<=right)
时,循环退出 \(left>right\),它们指向不同的元素,此时无对应结果
- 当 \(while\) 循环条件为
- 循环体内是两个判断还是三个判断?见疑问。
- 确定对应判断如何缩小搜索区间,即确定下一搜索区间(\(mid\) 位置是否应该划入下一搜索区间)。
- 根据下一搜索区间判断 \(mid\) 取值是否要 \(+1\),即上取整。
- \(mid\) 是否会整型溢出。若会整型溢出,则将其改为 \(mid=left+\left\lfloor\dfrac{right-left}{2}\right\rfloor\) 和 \(mid=left+\left\lfloor\dfrac{right-left+1}{2}\right\rfloor\)。
Java语言可以使用int mid = left + right >>> 1
无符号右移来规避
疑问
判断条件个数
问:什么时候循环体内写成三个判断,什么时候写成两个?
- 三个判断(如折半查找):
- 可以在循环体内返回结果(有一定概率程序提前终止)
- 循环体内可以分为三种情况讨论。\(mid\) 左半部分,\(mid\) 部分, \(mid\) 右半部分。
- 两个判断(如求某个值第一次出现或最后一次出现的位置):
- 必须要退出循环后才可以返回结果(因为无法设计出一个条件 表示 第一次或最后一次出现)
- 只把区间分成两个部分,这是因为:只有这样,退出循环的时候才有 \(left\) 与 \(right\) 重合,我们才敢说,找到了问题的答案。
mid 是否上取整
问:\(mid\) 取值何时要 \(+1\) (即上取整)?
结论:我们只需要在编写完二分查找,判断区间元素剩余 \(2\) 个时是否会死循环,如果死循环就将其调整为上取整。
- 当区间内个数有偶数个元素时,\(mid=\left\lfloor\dfrac{right+left}{2}\right\rfloor\) 只能取到位于左边的中位数,想要取到右边的中位数就需要 \(mid=\left\lfloor\dfrac{right+left+1}{2}\right\rfloor\)
- 为什么要取右边的中位数?这是因为当区间之中只有 \(2\) 个元素时,把区间划分成 \([left,mid-1]\) 和 \([mid,right]\), 对于 \(mid=\left\lfloor\dfrac{right+left}{2}\right\rfloor\) 取左边中位数并不能缩小搜索区间
- 上取整的目的:只是为了 避免死循环。
上取整的正确性:
求 \(mid\) 是为了求中位数:
- 对于区间内元素个数为奇数个,中位数个数唯一,则上取整与下取整相同。
- 对于区间内元素个数为偶数个,中位数个数有两个,选择左边中位数与右边中位数均可。
例题
34. 在排序数组中查找元素的第一个和最后一个位置
题目链接:https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array/
class Solution {
// 查找元素的第一个位置
int findFirstPosition(int[] nums, int left, int right, int target) {
while (left < right) {
int mid = left + right >>> 1;
if (nums[mid] >= target){
// 区间向左收缩,且mid可能为答案
right = mid;
} else {
// 区间向右收缩,且mid不可取
left = mid + 1;
}
}
return left;
}
// 查找元素的最后一个位置
int findLastPosition(int[] nums, int left, int right, int target) {
while (left < right) {
// 会死循环,选择上取整
int mid = left + right + 1 >>> 1;
if (nums[mid] > target){
right = mid - 1;
}else {
left = mid;
}
}
return left;
}
public int[] searchRange(int[] nums, int target) {
int len = nums.length;
if (len == 0) return new int[]{-1, -1};
int start = findFirstPosition(nums, 0, len-1, target);
if (nums[start] != target) return new int[]{-1, -1};
int end = findLastPosition(nums, 0, len-1, target);
return new int[]{start, end};
}
}
35. 搜索插入位置
题目链接:https://leetcode.cn/problems/search-insert-position/
class Solution {
public int searchInsert(int[] nums, int target) {
int left = 0, right = nums.length;// 可能插入到length位置
while (left < right) {
int mid = left + right >> 1;
if (nums[mid] == target) {
//找到了插入位置
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;// mid 位置可能为结果
}
}
return left;
}
}
69. x 的平方根 - 力扣
题目链接:https://leetcode.cn/problems/sqrtx/description/
class Solution {
public int mySqrt(int x) {
if (x <= 1) return x;
int left = 1, right = x / 2;
while (left < right) {
int mid = left + right + 1 >>> 1;
// 如果mid*mid大于x,则下一个搜索区间为[left,mid-1]
// 因为乘法可能溢出,使用除法计算
if (mid > x / mid) right = mid - 1;
// 如果如果mid*mid小于等于x,则下一个搜索区间为[mid,right]
else left = mid;
}
return left;
}
}
162. 寻找峰值
题目链接:https://leetcode.cn/problems/find-peak-element/
public class Solution {
public int findPeakElement(int[] nums) {
int left = 0;
int right = nums.length - 1;
// 在 nums[left..right] 中查找峰值
while (left < right) {
int mid = left + right >>> 1;
if (nums[mid] < nums[mid + 1]) {
// 上升,右侧一定有峰值
// 下一轮搜索的区间 [mid + 1..right]
left = mid + 1;
} else {
// 下降,左侧一定有峰值,且 mid 可能为峰值
// 下一轮搜索的区间 [left..mid]
right = mid;
}
}
return left;
}
}
278. 第一个错误的版本- 力扣
题目链接:https://leetcode.cn/problems/first-bad-version/description/
/* The isBadVersion API is defined in the parent class VersionControl.
boolean isBadVersion(int version); */
public class Solution extends VersionControl {
public int firstBadVersion(int n) {
int left = 1, right = n, mid;
while (left < right) {
mid = left + right >>> 1;
if (isBadVersion(mid)) right = mid;
else left = mid + 1;
}
return left;
}
}
287. 寻找重复数
题目链接:https://leetcode.cn/problems/find-the-duplicate-number/
class Solution {
public int findDuplicate(int[] nums) {
// 查找[left,right]范围内的重复的数
int left = 1, right = nums.length - 1;
while (left < right) {
int mid = left + right >>> 1;
// 抽屉(鸽巢)原理:n个抽屉,n+1个苹果,一定有一个抽屉放了不止一个苹果
int sum = 0;
for (int v : nums) {
if (v <= mid) {
++sum;
}
}
// [1...mid]个数 大于 mid
// 则说明 [left,mid] 内有重复数
if (sum > mid) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
}
367. 有效的完全平方数
题目链接:https://leetcode.cn/problems/valid-perfect-square/
class Solution {
public boolean isPerfectSquare(int num) {
if(num == 1) return true;
int left = 1, right = num / 2;
// 找第一个平方大于等于num的整数
while (left < right) {
int mid = left + right >>> 1;
if (mid >= num / mid) {
right = mid;
} else {
left = mid + 1;
}
}
return (long)left * left == num;
}
}
374. 猜数字大小
题目链接:https://leetcode.cn/problems/guess-number-higher-or-lower/
/**
* Forward declaration of guess API.
*
* @param num your guess
* @return -1 if num is higher than the picked number
* 1 if num is lower than the picked number
* otherwise return 0
* int guess(int num);
*/
public class Solution extends GuessGame {
public int guessNumber(int n) {
//[left,right]猜测主持人选择的数字
int left = 1, right = n;
while (left <= right) {
int mid = left + (right - left) / 2;
int res = guess(mid);
if (res == 0) {
return mid;
} else if (res == -1) {
//下一轮搜索区间为[left,mid-1]
right = mid - 1;
} else {
//下一轮搜索区间为[mid+1,right]
left = mid + 1;
}
}
return -1;
}
}
441. 排列硬币 - 力扣
题目链接:https://leetcode.cn/problems/arranging-coins/
求使得 \(1+2+3+...+x\leq n\) 的最大的正整数 \(x\)
化简得:\(\dfrac{x\times (1+x)}{2}\leq n\)
class Solution {
public int arrangeCoins(int n) {
//由于n大于1,所以最少能有一行
int left = 1, right = n;
while (left < right) {
int mid = left + right + 1 >>> 1;
//数值太大,使用long类型
long sum = (1l + mid) * mid >> 1;
if (sum > n) right = mid - 1;
else left = mid;
}
return left;
}
}
540. 有序数组中的单一元素
题目链接:https://leetcode.cn/problems/single-element-in-a-sorted-array/
设仅出现一个数的下标为 \(x\),则其左边的数均出现了两次,右边的数也是如此
由于数组下标从 \(0\) 开始,则 \(x\) 一定为偶数
- 对于 \(x\) 左边的数,两个相邻且相等的数的下标为 偶+奇
- 对于 \(x\) 右边的数,两个相邻且相等的数的下标为 奇+偶
class Solution {
public int singleNonDuplicate(int[] nums) {
int len = nums.length;
// if (len == 1) return nums[0]; 当只有一个元素时可归为下列情况
int left = 0, right = len - 1;
// 最终结果一定位于偶数位置
while (left < right) {
int mid = left + right >> 1;
if (mid % 2 == 0){
//两个相邻且相等的数的下标为 偶+奇 则 结果在右侧
if (mid + 1 < len && nums[mid] == nums[mid + 1]) left = mid + 2;
else right = mid; // mid 可能为答案所在位置
} else {
//两个相邻且相等的数的下标为 奇+偶 则 结果在左侧
if (mid + 1 < len && nums[mid] == nums[mid + 1]) right = mid - 1;
else left = mid + 1; // mid 不可能为答案所在位置
}
}
return nums[left];
}
}
744. 寻找比目标字母大的最小字母
题目链接:https://leetcode.cn/problems/find-smallest-letter-greater-than-target/
class Solution {
public char nextGreatestLetter(char[] letters, char target) {
int left = 0, right = letters.length - 1;
while (left < right){
int mid = left + right >>> 1;
if (letters[mid] > target) {
right = mid;
} else {
// 字符小于等于target,mid不可取
left = mid + 1;
}
}
return letters[left] > target ? letters[left] : letters[0];
}
}
852. 山脉数组的峰顶索引
题目链接:https://leetcode.cn/problems/peak-index-in-a-mountain-array/
class Solution {
public int peakIndexInMountainArray(int[] arr) {
// 题目保证为山脉数组,则顶点在[1,len-2]
int left = 1, right = arr.length - 2;
while (left < right) {
int mid = left + right >>> 1;
if (arr[mid] > arr[mid+1]) {
// 下降趋势,顶点在左侧,mid可能为顶点
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
}
参考资料
8-1 「二分查找」的基本思想 - liweiwei - 哔哩哔哩