🔍 查找算法详解:二分查找
二分查找是最高效的查找算法之一,时间复杂度仅为O(log n),但前提是数据必须有序。
一、二分查找概述
1.1 什么是二分查找?
二分查找(Binary Search) ,也称为折半查找,是一种在有序数组中查找特定元素的搜索算法。
核心思想:每次将搜索区间减半,通过比较中间元素与目标值,决定继续在左半部分还是右半部分查找。
1.2 二分查找的优势
| 算法 | 时间复杂度 | 空间复杂度 | 适用条件 |
|---|---|---|---|
| 线性查找 | O(n) | O(1) | 无序数组 |
| 二分查找 | O(log n) | O(1) | 有序数组 |
| 哈希查找 | O(1) | O(n) | 需要额外空间 |
💡 O(log n) 有多快?
- 100万个元素,最多只需要20次比较
- 10亿个元素,最多只需要30次比较
二、二分查找框架
2.1 基本模板
c
int binarySearch(int nums[], int n, int target) {
int left = 0;
int right = n - 1; // 注意
while (...) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
// ...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}
🎯 分析技巧 :不要出现
else,而是要把所有情况用else if写清楚,这样可以展现所有的细节。
2.2 mid 计算技巧
c
// 方式1:可能溢出
int mid = (left + right) / 2;
// 方式2:防止溢出(推荐)
int mid = left + (right - left) / 2;
// 方式3:位运算(更高效)
int mid = left + ((right - left) >> 1);
📌 为什么方式2更好?
当 left 和 right 都很大时,
left + right可能溢出。使用left + (right - left) / 2可以避免这个问题。
三、基本二分搜索
3.1 寻找一个数
场景:在有序数组中搜索一个数,如果存在,返回其索引,否则返回-1。
c
int binarySearch(int nums[], int n, int target) {
int left = 0;
int right = n - 1; // 注意:两端都闭
while (left <= right) { // 注意:<=
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1; // 注意:mid已经搜索过
} else if (nums[mid] > target) {
right = mid - 1; // 注意:mid已经搜索过
}
}
return -1;
}
3.2 while条件分析
问题1:为什么 while 循环的条件是 <=,而不是 <?
因为初始化 right 的赋值是 n - 1,即最后一个元素的索引,而不是 n。
两种初始化方式的区别:
| 初始化 | 含义 | 搜索区间 |
|---|---|---|
right = n - 1 |
两端都闭 | [left, right] |
right = n |
左闭右开 | [left, right) |
终止条件分析:
while (left <= right) 的终止条件是 left = right + 1
搜索区间形式:[right+1, right],例如 [3, 2]
这时搜索区间为空,没有数字既大于3又小于2,while循环正确终止
while (left < right) 的终止条件是 left = right
搜索区间形式:[right, right],例如 [2, 2]
这时搜索区间非空,还有一个数字2,但while循环终止了
这意味着区间 [2, 2] 被遗漏,索引2没有被搜索!
3.3 left/right更新分析
问题2:为什么 left = mid + 1,right = mid - 1?
因为本算法的搜索区间是两端都闭区间 [left, right],当我们发现索引 mid 不是要找的 target 时,需要确定下一步的搜索区间。
如果 nums[mid] < target:
target 在 [mid+1, right] 中
所以 left = mid + 1
如果 nums[mid] > target:
target 在 [left, mid-1] 中
所以 right = mid - 1
📌 注意 :
mid已经搜索过,应该从搜索区间中剔除!
3.4 算法的局限性
问题3:此算法有什么缺陷?
比如说给你有序数组 nums = [1, 2, 2, 2, 3],target = 2,此算法返回的索引是 2,没错。
但是如果:
- 我想得到
target的左侧边界,即索引 1 - 或者我想得到
target的右侧边界,即索引 3
这样的需求,此算法是无法处理的。
四、寻找左侧边界
4.1 算法实现
c
int left_bound(int nums[], int n, int target) {
if (n == 0) return -1;
int left = 0;
int right = n; // 注意:左闭右开
while (left < 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; // 注意:不是 mid - 1
}
}
return left;
}
4.2 细节分析
细节1:为什么 while (left < right) 而不是 <=?
因为初始化 right = n 而不是 n - 1,因此每次循环的搜索区间是 [left, right) 左闭右开。
while (left < right) 终止的条件是 left = right,此时的搜索区间是 [left, left) 恰巧为空,所以可以正确终止。
细节2:为什么 nums[mid] == target 时,right = mid?
我们要找的是左侧边界,当找到 target 时,不要立即返回,而是收缩右边界,继续在左半部分查找。
nums = [1, 2, 2, 2, 3]
target = 2
第一次:mid = 2,nums[mid] = 2
找到target,但不返回,right = mid = 2
继续在 [0, 2) 中查找
第二次:mid = 1,nums[mid] = 2
找到target,right = mid = 1
继续在 [0, 1) 中查找
第三次:left = right = 1,循环结束
返回 left = 1,即左侧边界
细节3:为什么 nums[mid] > target 时,right = mid 而不是 mid - 1?
因为搜索区间是 [left, right) 左闭右开,mid - 1 可能会遗漏元素。
4.3 返回值分析
返回 left 的含义:
- 如果
target存在:返回第一个等于target的索引 - 如果
target不存在:返回第一个大于target的索引
改进版本(处理target不存在的情况):
c
int left_bound(int nums[], int n, int target) {
if (n == 0) return -1;
int left = 0;
int right = n;
while (left < 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;
}
}
// 检查越界和是否找到
if (left >= n || nums[left] != target) {
return -1;
}
return left;
}
五、寻找右侧边界
5.1 算法实现
c
int right_bound(int nums[], int n, int target) {
if (n == 0) return -1;
int left = 0;
int right = n; // 左闭右开
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 (right - 1 < 0 || nums[right - 1] != target) {
return -1;
}
return right - 1; // 注意:返回 right - 1
}
5.2 细节分析
为什么返回 right - 1 而不是 right?
因为当找到 target 时,我们执行 left = mid + 1,这意味着最终 left 会多走一步。所以返回时要减1。
nums = [1, 2, 2, 2, 3]
target = 2
最终 left = right = 4
返回 right - 1 = 3,即右侧边界
六、实际应用场景
6.1 二分查找的应用
| 应用场景 | 说明 |
|---|---|
| 查找元素 | 基本二分查找 |
| 查找边界 | 左边界、右边界 |
| 查找插入位置 | 左边界变体 |
| 查找最接近的元素 | 变体应用 |
| 数值计算 | 求平方根、方程求根 |
| 旋转数组查找 | 变体应用 |
6.2 经典题目示例
例1:在排序数组中查找元素的第一个和最后一个位置
c
// 返回 [left_bound, right_bound]
int* searchRange(int nums[], int n, int target, int* returnSize) {
int* result = (int*)malloc(sizeof(int) * 2);
*returnSize = 2;
result[0] = left_bound(nums, n, target);
result[1] = right_bound(nums, n, target);
return result;
}
例2:x的平方根
c
int mySqrt(int x) {
if (x <= 1) return x;
int left = 1, right = x;
while (left <= right) {
int mid = left + (right - left) / 2;
if (mid == x / mid) {
return mid;
} else if (mid < x / mid) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return right;
}
6.3 旋转数组查找
c
int searchRotated(int nums[], int n, int target) {
int left = 0, right = n - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
}
// 左半部分有序
if (nums[left] <= nums[mid]) {
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// 右半部分有序
else {
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
return -1;
}
七、常见问题与技巧
7.1 二分查找的常见错误
| 错误类型 | 错误示例 | 正确做法 |
|---|---|---|
| 死循环 | left = mid,right = mid |
left = mid + 1,right = mid - 1 |
| 遗漏元素 | while (left < right) 配合闭区间 |
根据区间选择正确的条件 |
| 越界访问 | 不检查返回值 | 检查 left 或 right 是否越界 |
| 溢出 | (left + right) / 2 |
left + (right - left) / 2 |
7.2 二分查找的统一模板
c
// 统一模板(闭区间)
int binarySearch(int nums[], int n, int target) {
int left = 0, right = n - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
// 找到了,可以返回或继续收缩
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// 未找到,left 是插入位置
return -1; // 或 return left
}
7.3 调试技巧
打印中间结果:
c
while (left <= right) {
int mid = left + (right - left) / 2;
printf("left=%d, mid=%d, right=%d, nums[mid]=%d\n",
left, mid, right, nums[mid]);
// ...
}
八、总结
| 二分查找类型 | 搜索区间 | 返回值 | 适用场景 |
|---|---|---|---|
| 基本二分 | [left, right] |
索引或-1 | 查找单个元素 |
| 左边界 | [left, right) |
左边界索引 | 查找第一个等于target的 |
| 右边界 | [left, right) |
右边界索引 | 查找最后一个等于target的 |
二分查找的关键点:
- ✅ 明确搜索区间:闭区间还是左闭右开
- ✅ 正确更新边界 :
mid已搜索过,需要剔除 - ✅ 防止溢出 :使用
left + (right - left) / 2 - ✅ 处理边界情况:检查返回值是否越界