1. 二分查找算法基础解析
二分查找(Binary Search)是计算机科学中最基础且高效的搜索算法之一,它能在O(log n)的时间复杂度内完成有序数组的查找操作。这个算法的核心思想就像我们查字典时的翻页策略——不会从第一页开始逐页查找,而是根据字母顺序快速定位到大概位置,然后不断缩小范围。
1.1 算法原理与特性
二分查找之所以高效,是因为它利用了数据有序这一特性。每次比较都能排除掉一半的无效数据,使得搜索范围呈指数级缩小。具体来说,算法包含以下几个关键要素:
- 有序性前提:输入数组必须是有序的(升序或降序)
- 边界指针:使用low和high两个指针标记当前搜索范围
- 中间点计算:mid = low + (high - low) / 2(防止整数溢出)
- 比较决策:根据nums[mid]与target的比较结果调整搜索边界
注意:很多初学者会直接使用(low+high)/2计算mid,这在极端情况下可能导致整数溢出。使用low + (high - low)/2是更安全的写法。
1.2 标准二分查找实现
下面是一个标准的二分查找C++实现模板:
cpp复制int binarySearch(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 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;
}
}
return -1;
}
这个模板有几个关键点需要注意:
- 循环条件是
left <= right而不是left < right - 边界更新是
mid + 1和mid - 1而不是直接赋值为mid - 当元素不存在时返回-1
2. 力扣中的二分查找题目分类
力扣(LeetCode)上的二分查找题目大致可以分为以下几类,每种类型都有其独特的解题思路和变体。
2.1 基础二分查找题
这类题目直接考察标准二分查找的实现,是最基础的练习:
-
- 二分查找(标准实现)
-
- 搜索插入位置(查找或插入位置)
-
- 猜数字大小(交互式二分)
以35题为例,它要求在有序数组中查找目标值,如果不存在则返回应该插入的位置。这需要我们对标准二分查找进行微小调整:
cpp复制int searchInsert(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 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;
}
}
return left; // 关键变化在这里
}
2.2 边界查找问题
这类问题要求查找目标值的起始和结束位置,或者满足条件的边界:
-
- 在排序数组中查找元素的第一个和最后一个位置
-
- 第一个错误的版本
-
- 寻找峰值
以34题为例,我们需要找到目标值的左右边界。这需要两次二分查找:
cpp复制vector<int> searchRange(vector<int>& nums, int target) {
if (nums.empty()) return {-1, -1};
int left = findLeft(nums, target);
int right = findRight(nums, target);
return {left, right};
}
int findLeft(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
return nums[left] == target ? left : -1;
}
2.3 旋转排序数组问题
这类问题处理的是经过旋转的有序数组,需要特别处理旋转点:
-
- 搜索旋转排序数组
-
- 搜索旋转排序数组 II
-
- 寻找旋转排序数组中的最小值
以33题为例,我们需要在旋转后的有序数组中查找目标值:
cpp复制int search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
if (nums[left] <= nums[mid]) { // 左半部分有序
if (nums[left] <= target && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
} else { // 右半部分有序
if (nums[mid] < target && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
return -1;
}
3. 二分查找的高级应用与技巧
3.1 二分答案法
二分查找不仅可以用于搜索,还可以用于解决最优化问题。这类问题通常具有以下特征:
- 答案在一个确定的范围内
- 存在某种单调性,可以将问题转化为判定性问题
- 容易验证某个候选答案是否满足条件
典型题目包括:
-
- x的平方根
-
- 寻找重复数
-
- 分割数组的最大值
以69题为例,我们可以在0到x的范围内二分查找平方根:
cpp复制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;
}
3.2 二维矩阵中的二分查找
有些问题需要在二维矩阵中应用二分查找,如:
-
- 搜索二维矩阵
-
- 搜索二维矩阵 II
这两种题目有不同的矩阵特性,解法也有所不同。74题的矩阵可以视为展开的一维数组:
cpp复制bool searchMatrix(vector<vector<int>>& matrix, int target) {
if (matrix.empty() || matrix[0].empty()) return false;
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 val = matrix[mid / n][mid % n];
if (val == target) return true;
else if (val < target) left = mid + 1;
else right = mid - 1;
}
return false;
}
3.3 二分查找的变种与边界处理
二分查找的边界条件处理是容易出错的地方。常见的变种包括:
- 查找第一个大于等于target的元素
- 查找最后一个小于等于target的元素
- 查找最接近target的元素
以查找第一个大于等于target的元素为例:
cpp复制int lower_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
4. 力扣Hot100中的二分查找题目精讲
力扣Hot100收录了最经典的100道题目,其中二分查找相关的题目尤其值得深入研究。
4.1 力扣4. 寻找两个正序数组的中位数
这是一道难度为Hard的题目,要求在两个有序数组中找出中位数。最优解法需要巧妙运用二分查找:
cpp复制double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
if (nums1.size() > nums2.size()) {
return findMedianSortedArrays(nums2, nums1);
}
int m = nums1.size(), n = nums2.size();
int left = 0, right = m;
while (left <= right) {
int partitionX = (left + right) / 2;
int partitionY = (m + n + 1) / 2 - partitionX;
int maxLeftX = (partitionX == 0) ? INT_MIN : nums1[partitionX - 1];
int minRightX = (partitionX == m) ? INT_MAX : nums1[partitionX];
int maxLeftY = (partitionY == 0) ? INT_MIN : nums2[partitionY - 1];
int minRightY = (partitionY == n) ? INT_MAX : nums2[partitionY];
if (maxLeftX <= minRightY && maxLeftY <= minRightX) {
if ((m + n) % 2 == 0) {
return (max(maxLeftX, maxLeftY) + min(minRightX, minRightY)) / 2.0;
} else {
return max(maxLeftX, maxLeftY);
}
} else if (maxLeftX > minRightY) {
right = partitionX - 1;
} else {
left = partitionX + 1;
}
}
return 0.0;
}
4.2 力扣33. 搜索旋转排序数组
这道题是二分查找的经典变种,需要处理旋转后的有序数组:
cpp复制int search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
if (nums[left] <= nums[mid]) {
if (nums[left] <= target && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
} else {
if (nums[mid] < target && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
return -1;
}
4.3 力扣34. 在排序数组中查找元素的第一个和最后一个位置
这道题要求找到目标值的左右边界,是边界查找的典型应用:
cpp复制vector<int> searchRange(vector<int>& nums, int target) {
if (nums.empty()) return {-1, -1};
int left = findBound(nums, target, true);
if (left == -1) return {-1, -1};
int right = findBound(nums, target, false);
return {left, right};
}
int findBound(vector<int>& nums, int target, bool isLeft) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
if (isLeft) {
if (mid == left || nums[mid - 1] != target) {
return mid;
}
right = mid - 1;
} else {
if (mid == right || nums[mid + 1] != target) {
return mid;
}
left = mid + 1;
}
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
5. 二分查找的常见错误与调试技巧
5.1 常见错误类型
在实现二分查找时,容易犯以下几种错误:
- 边界条件错误:循环条件是
left < right还是left <= right? - 边界更新错误:是
right = mid还是right = mid - 1? - 整数溢出:计算mid时使用
(left + right) / 2可能导致溢出 - 死循环:在某些边界条件下可能导致无限循环
- 返回值错误:找不到目标时应该返回什么?
5.2 调试技巧
为了正确实现二分查找,可以采用以下调试方法:
- 打印日志:在循环中打印left、right和mid的值
- 测试边界情况:空数组、单元素数组、两元素数组等
- 验证不变式:确保每次迭代后搜索范围都缩小
- 使用已知实现对比:与标准实现对比结果
提示:当二分查找出现问题时,可以尝试在纸上手动模拟算法执行过程,这往往能快速定位问题所在。
5.3 二分查找的测试用例设计
设计全面的测试用例对验证二分查找实现至关重要。一个好的测试集应该包含:
- 目标值存在于数组中的情况
- 目标值不存在于数组中的情况
- 目标值小于数组所有元素的情况
- 目标值大于数组所有元素的情况
- 空数组的情况
- 单元素数组的情况
- 重复元素的情况
- 大数组测试(测试性能)
例如,对于标准二分查找,可以设计如下测试用例:
cpp复制vector<pair<vector<int>, int>> testCases = {
{{}, 1}, // 空数组
{{1}, 1}, // 单元素存在
{{1}, 2}, // 单元素不存在
{{1,3,5,7}, 1}, // 查找第一个元素
{{1,3,5,7}, 7}, // 查找最后一个元素
{{1,3,5,7}, 4}, // 查找中间不存在的元素
{{1,1,3,3,5,5}, 3}, // 重复元素
{{1,2,3,4,5,6,7,8,9,10}, 6} // 多元素测试
};
6. 二分查找的性能优化与进阶思考
6.1 性能优化技巧
虽然二分查找已经是O(log n)的高效算法,但在实际应用中还可以进一步优化:
- 循环展开:减少循环次数,每次迭代进行多次比较
- 缓存友好:优化内存访问模式,提高缓存命中率
- 分支预测:减少条件分支,提高CPU流水线效率
- SIMD指令:利用现代CPU的并行计算能力
例如,下面是一个展开两次的二分查找实现:
cpp复制int binarySearchUnrolled(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (right - left >= 2) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
if (nums[left] == target) return left;
if (nums[right] == target) return right;
return -1;
}
6.2 二分查找的数学原理
二分查找的高效性背后有着深刻的数学原理。它本质上是在利用信息论中的决策树模型,每次比较都能获得1 bit的信息量,将不确定性减半。
对于一个大小为n的有序数组,最坏情况下需要的比较次数为⌈log₂n⌉。这是因为:
- 每次比较将搜索空间减半
- 最多需要k次比较才能将n个元素的空间缩小到1
- 即n/2ᵏ ≤ 1 ⇒ k ≥ log₂n
6.3 二分查找的变种与扩展
二分查找的思想可以扩展到许多其他场景:
- 实数范围内的二分:用于求解方程根或优化问题
- 三分查找:用于寻找凸函数的极值点
- 指数搜索:用于无界数组的搜索
- 插值搜索:根据数据分布调整分割点
以实数范围内的二分为例,可以用来求解方程的根:
cpp复制double solveEquation(function<double(double)> f, double left, double right, double eps) {
while (right - left > eps) {
double mid = (left + right) / 2;
if (f(mid) * f(left) <= 0) {
right = mid;
} else {
left = mid;
}
}
return (left + right) / 2;
}
在实际编程竞赛和面试中,二分查找是最常考的算法之一。掌握其各种变体和应用场景,对于提高解题能力至关重要。建议从标准实现开始,逐步练习各种变种题目,形成对不同模式的快速识别能力。
