【算法】【学习笔记】二分查找
二分查找
二分查找(Binary Search),又称折半查找,是一种在有序数组中查找特定元素的高效算法。它通过不断将搜索范围缩小一半,实现 \(O(\log N)\) 的时间复杂度。
核心前提:一定要是单调的数据,才可以使用。
基本思路
以递增数据作为例子
使用双指针的方法(左指针left,右指针right),一个指首,一个指尾,来维护查询区间:
- 获取中间索引(
mid = left + ((right - left) >> 1),这个方法避免left和right直接相加可能导致的溢出); - 左闭右闭[left, right]时:(
left = 0, right = n-1)- 比较中间索引的值和目标值
target:- 相等:则已经找到目标索引,跳出循环;
- 小于目标值:说明中间值偏小,目标值在右区间,将左指针
left往右收缩为mid+1; - 大于目标值:说明中间值偏大,目标值在左区间,将右指针
right往左收缩为mid-1;
- 若左指针
left > right,说明没有找到目标,返回-1。
- 比较中间索引的值和目标值
- 左闭右开[left, right)时:(
left = 0, right = n)- 比较中间索引的值和目标值
target:- 相等:则已经找到目标索引,跳出循环;
- 小于目标值:说明中间值偏小,目标值在右区间,将左指针
left往右收缩为mid+1; - 大于目标值:说明中间值偏大,目标值在左区间,将右指针
right往左收缩为mid;
- 若左指针
left >= right,说明没有找到目标,返回-1。
- 比较中间索引的值和目标值
- 左开右闭(left, right]时:(
left = -1, right = n-1)- 比较中间索引的值和目标值
target:- 相等:则已经找到目标索引,跳出循环;
- 小于目标值:说明中间值偏小,目标值在右区间,将左指针
left往右收缩为mid; - 大于目标值:说明中间值偏大,目标值在左区间,将右指针
right往左收缩为mid-1;
- 若左指针
left >= right,说明没有找到目标,返回-1。
- 比较中间索引的值和目标值
边界处理要点
区间定义:左闭右闭、左闭右开、左开右闭会影响 mid 的更新方式。
溢出预防:使用 mid = left + ((right - left) >> 1)而非 mid = (right + left) >> 1)。
循环终止条件:left > right 说明无法找到。
代码示例
下为使用TypeScript写的左右闭合的区间,并且单调递减的例子。
function binarySearch(nums:number[], target:number){
// nums是单调递减的
const n = nums.length;
let left = 0, right = n-1, res = -1;
while(left <= right){
let mid = left + ((right - left) >> 1);
if(nums[mid] < target){
// 太小了,说明在左边大的数中
right = mid-1;
}else if(nums[mid] > target){
// 太大了,说明在右边小的数中
left = mid+1;
}else{
res = mid;
break;
}
}
return res;
}
优缺点
- 优点:可以快速处理大量数据
- 缺点:
- 一定要有序
- 数据结构需要支持 \(O(1)\) 随机访问(如数组)。
如果是单向链表,即使有序,也无法快速通过索引拿到中间值 mid,时间复杂度会退化成 \(O(N)\)。
题目
1. 山脉数组的峰顶索引
解题思路
可以观察得出,山脉数组本质上是一个左侧严格递增、右侧严格递减的数组,满足局部单调性,因此可以使用二分查找定位最高点。
1. 比较逻辑简化
不需要同时比较 mid-1、mid、mid+1 三个位置,只需比较相邻的 arr[mid] 与 arr[mid + 1]:
- 若
arr[mid] < arr[mid + 1]:处于上坡段,峰顶必在mid右侧 \(\rightarrow\)l = mid + 1 - 若
arr[mid] > arr[mid + 1]:处于下坡段或峰顶,峰顶在mid或其左侧 \(\rightarrow\)r = mid
2. 边界安全性证明
- 防越界:取
mid时向下取整,在l < r的循环条件下,mid最大只能取到 \(N - 2\),因此mid + 1最高为 \(N - 1\),访问arr[mid + 1]绝对安全。 - 防死循环:
l = mid + 1确保左边界严格右移,r = mid确保右边界缩小,两者配合向下取整的mid绝对不会死循环。
实现
public class Solution {
public int PeakIndexInMountainArray(int[] arr) {
int l = 0, r = arr.Length - 1;
while (l < r) {
int mid = l + ((r - l) >> 1);
// 比较 mid 和 mid + 1
if (arr[mid] < arr[mid + 1]) {
l = mid + 1; // 上坡:峰顶在右侧
} else {
r = mid; // 下坡或峰顶:峰顶在左侧(包含 mid)
}
}
// 循环结束时 l == r,正好停在峰顶索引上
return l;
}
}
function peakIndexInMountainArray(arr: number[]): number {
let l = 0, r = arr.length-1;
while(l < r){
let mid = l + ((r - l) >> 1);
if(arr[mid] < arr[mid+1]){
l = mid+1;
}else{
r = mid;
}
}
return r;
};
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr) {
int l = 0, r = arr.size()-1;
while(l < r){
int mid = l + ((r - l) >> 1);
if(arr[mid] < arr[mid+1]){
l = mid+1;
}else{
r = mid;
}
}
return r;
}
};
复杂度分析
- 时间复杂度: \(O(\log N)\)
- 空间复杂度: \(O(1)\)
2. 二分查找
解题思路
双分支二分查找模板(排除法):
- 向下取整取
mid,判断nums[mid] < target:- 若成立:说明
mid及其左侧元素一定不是目标,目标在右侧 \(\rightarrow\)l = mid + 1 - 若不成立:说明
mid可能是目标,或者目标在mid左侧 \(\rightarrow\)r = mid
- 若成立:说明
- 防死循环与边界保证:
mid向下取整搭配l = mid + 1和r = mid,区间严格收缩,绝对不会死循环。 - 退出循环时
l == r,只需在最后校验nums[r] == target即可判定是否存在。
在这个题目还可以使用三分支,提前跳出循环。
实现
function search(nums: number[], target: number): number {
const n = nums.length;
let l = 0, r = n-1;
while(l < r){
const mid = l + ((r-l) >> 1);
if(nums[mid] < target){
l = mid+1;
}else{
r = mid;
}
}
return nums[r] === target ? r : -1;
};
public class Solution {
public int Search(int[] nums, int target) {
int n = nums.Length;
int l = 0, r = n-1;
while(l < r){
int mid = l + ((r - l) >> 1);
if(nums[mid] < target){
l = mid+1;
}else{
r = mid;
}
}
return nums[r] == target ? r : -1;
}
}
// 三分支法
class Solution {
public:
int search(vector<int>& nums, int target) {
int n = nums.size();
int l = 0, r = n-1;
while(l <= r){
int mid = l + ((r - l) >> 1);
if(nums[mid] < target){
l = mid+1;
}else if(nums[mid] == target){
return mid;
}else{
r = mid-1;
}
}
return -1;
}
};
复杂度分析
- 时间复杂度: \(O(\log N)\)
- 空间复杂度: \(O(1)\)
3. 平方数之和
解题思路
我们可以知道,取值范围在 \([0, \sqrt{target}]\) 。
- 双指针法
这个简单,只要左右指针寻找就好,左指针初始值是 \(0\) ,右指针初始值是 \(\lfloor\sqrt{c}\rfloor\),
- 当 \(l^2 + r^2 \lt c\) 时,说明太小了, \(l\) 往右移;
- 当 \(l^2 + r^2 \gt c\) 时,说明太大了, \(r\) 往左移;
- 当 \(l^2 + r^2 = c\) 时,说明找到目标;
- 枚举a+二分法
毕竟这是二分中的题,还是用二分法吧。
由取值范围可知,对于每一个 \(a\),目标值就是 \(target = c - a^2\)。
问题就变成了:在 \([0, \sqrt{target}]\) 的范围内二分查找,看是否存在一个整数 \(b\),使得 \(b^2 == target\)。
实现
public class Solution {
public bool JudgeSquareSum(int c) {
// 两个未知数欸,怎么办呢
// pow(x, 2) + pow(y, 2) = c
// 这咋搞啊
int big = Sqrt(c);
for(int a = 0; a <= big; a++){
long target = c - a * a;
if(BinarySearch(0, target, target)){
return true;
}
}
return false;
}
private bool BinarySearch(long l, long r, long target){
while(l < r){
long mid = l + ((r - l) >> 1);
if(mid*mid < target){
l = mid+1;
}else{
r = mid;
}
}
return l*l == target ? true : false;
}
private int Sqrt(int x){
// 二分法
if(x < 2) return x;
int l = 1, r = x / 2;
while(l < r){
int mid = l + ((r - l + 1) >> 1);
if(mid <= x / mid){
// 说明mid 小了或者就是答案
l = mid;
}else{
r = mid-1;
}
}
return l;
}
}
public class Solution {
public bool JudgeSquareSum(int c) {
// 两个未知数欸,怎么办呢
// pow(x, 2) + pow(y, 2) = c
// 这咋搞啊
long l = 0, r = (long)Math.Sqrt(c);
while(l <= r){
long cur = l * l + r * r;
if(cur < c){
l++;
}else if(cur > c){
r--;
}else{
return true;
}
}
return false;
}
}
function judgeSquareSum(c: number): boolean {
let l = 0, r = Sqrt(c);
while(l <= r){
const sum = l*l+r*r;
if(sum < c){
l++;
}else if(sum > c){
r--;
}else{
return true;
}
}
return false;
};
function Sqrt(num: number):number{
if(num < 2) return num;
let l = 1, r = num >> 1;
while(l < r){
const mid = l + ((r - l + 1) >> 1);
if(mid <= num / mid){
l = mid;
}else{
r = mid-1;
}
}
return r;
}
class Solution {
public:
bool judgeSquareSum(int c) {
long l = 0, r = sqrt(c);
while(l <= r){
long sum = l*l+r*r;
if(sum == c){
return true;
}else if(sum < c){
l++;
}else{
r--;
}
}
return false;
}
};
复杂度分析
- 时间复杂度:
- 双指针:\(O(\sqrt{c})\),双指针最多走 \(\sqrt{c}\) 步。
- 二分法:\(O(\sqrt{c} \log c)\)
- 空间复杂度:
- 双指针:\(O(1)\)
- 二分法:\(O(1)\)
拓展
原来开方也是可以用二分法来解出噢。
int Sqrt(int x){
if(x < 2) return x;
int l = 1, r = x / 2;
while(l < r){
int mid = l + ((r - l + 1) >> 1);
if(mid <= x / mid){
// 说明mid 小了或者就是答案
l = mid;
}else{
r = mid-1;
}
}
return l;
}
4. 搜索旋转排序数组
解题思路
旋转的数组是部分有序的,可以使用二分查找。
一开始我在想一边使用二分查找找原数组结尾,一边找target,但这个思路不太对。
可以假设仅一边有序,
- 那么当左侧有序时,看target是否在左侧(判断方式根据递增特性),假如是,右侧进行收缩范围,否则左侧进行收缩范围;
- 当右侧有序时,看target是否在右侧(判断方式根据递增特性),假如是,左侧进行收缩范围,否则右侧进行收缩范围。
实现
public class Solution {
public int Search(int[] nums, int target) {
int n = nums.Length;
int l = 0, r = n-1;
while(l <= r){
int mid = l + ((r - l) >> 1);
if(nums[mid] == target) return mid;
// 判断左半是否有序
if(nums[l] <= nums[mid]){
if(nums[l] <= target && target < nums[mid]){
// 如果target在左侧有序范围(递增)
// 去左侧
r = mid-1;
}else{
// 去右侧
l = mid+1;
}
}else{
// 否则右侧有序
if(nums[r] >= target && target > nums[mid]){
// 如果target在右侧有序范围
// 去右侧
l = mid+1;
}else{
// 去左侧
r = mid-1;
}
}
}
return -1;
}
}
function search(nums: number[], target: number): number {
const n = nums.length;
let l = 0, r = n-1;
while(l <= r){
const mid = l + ((r - l) >> 1);
if(nums[mid] == target) return mid;
if(nums[l] <= nums[mid]){
if(nums[l] <= target && target < nums[mid]){
r = mid-1;
}else{
l = mid+1;
}
}else{
if(nums[r] >= target && target > nums[mid]){
l = mid+1;
}else{
r = mid-1;
}
}
}
return -1;
};
class Solution {
public:
int search(vector<int>& nums, int target) {
int n = (int)nums.size();
if (!n) {
return -1;
}
if (n == 1) {
return nums[0] == target ? 0 : -1;
}
int l = 0, r = n - 1;
while (l <= r) {
int mid = (l + r) / 2;
if (nums[mid] == target) return mid;
if (nums[l] <= nums[mid]) {
//如果最左小于中间,等于在有序空间
if (nums[l] <= target && target < nums[mid]) {
//假如最左小于目标,且中间也大于目标,说明要缩小右侧,在最左到中间的区间
r = mid - 1;
} else {
l = mid + 1;
}
} else {
//在无序空间(左边部分大于右边部分)
if (nums[r] >= target && target > nums[mid]) {
//假如最右侧大于目标,中间也小于目标,说明就在右侧升序区间
l = mid + 1;
} else {
r = mid - 1;
}
}
}
return -1;
}
};
复杂度分析
- 时间复杂度: \(O(\log N)\)
- 空间复杂度: \(O(1)\)
拓展
-
查找边界:查找第一个/最后一个大于等于
target的元素位置(左/右边界)——此时要在每次满足条件时时刷新res=mid; -
二分答案:在单调的解空间中猜答案(如:求满足条件的最小值/最大值)。
小知识:高中数学就学习了二分查找的思路,笔者完全没印象!
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。


浙公网安备 33010602011771号