1. 二分算法基础:从理论到实践
二分查找算法是计算机科学中最基础也最强大的算法之一。我第一次真正理解二分查找是在大学的数据结构课上,当时教授用"猜数字"游戏来演示——在1到100之间猜一个预设的数字,每次猜测后被告知是大了还是小了。这个简单的例子完美诠释了二分查找的核心思想:通过不断缩小搜索范围来快速定位目标。
1.1 算法原理与时间复杂度分析
二分查找的核心在于"分而治之"的策略。对于一个有序数组,算法首先比较中间元素与目标值:
- 如果中间元素等于目标值,搜索完成
- 如果中间元素小于目标值,则在右半部分继续搜索
- 如果中间元素大于目标值,则在左半部分继续搜索
这种每次将问题规模减半的方法带来了O(log n)的时间复杂度,相比线性搜索的O(n)有质的飞跃。在实际应用中,这意味着对于一个包含10亿个元素的有序数组,二分查找最多只需要30次比较就能确定目标是否存在(因为2^30 ≈ 10亿)。
注意:二分查找的前提是数据必须是有序的。如果数据无序,需要先进行排序,这会增加O(n log n)的时间成本。
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,这确保了当left和right指向同一个元素时仍会进行检查 mid = left + (right - left) / 2的写法避免了(left + right)/2可能导致的整数溢出- 每次迭代都会明确地排除mid位置,将搜索范围减半
1.3 边界条件与常见错误
在实际编码中,二分查找容易出现各种边界错误。以下是一些常见陷阱:
-
无限循环:当
left = mid而不是left = mid + 1时,如果left和right相邻,可能导致mid始终等于left,形成无限循环。 -
遗漏元素:错误地调整边界可能跳过目标元素。例如,在寻找第一个出现的元素时,不能简单地找到目标就返回,而需要继续向左搜索。
-
整数溢出:如前所述,
(left + right)/2在left和right都很大时可能溢出。现代编译器通常能优化left + (right - left)/2为无溢出版本。 -
初始范围错误:right初始值应为
nums.size()-1还是nums.size()取决于具体实现。错误的初始范围会导致越界访问或遗漏元素。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二分查找的变种与应用场景
掌握了标准二分查找后,我们会发现很多问题不能直接套用标准模板。这时就需要理解二分查找的各种变种及其适用场景。
2.1 寻找第一个/最后一个匹配元素
在实际问题中,我们经常需要找到第一个或最后一个等于目标值的元素。例如,在数组[1,2,2,2,3]中查找2,可能需要第一个2的位置(1)或最后一个2的位置(3)。
查找第一个等于目标的元素:
cpp复制int firstEqual(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 - 1;
} else {
left = mid + 1;
}
}
return (left < nums.size() && nums[left] == target) ? left : -1;
}
查找最后一个等于目标的元素:
cpp复制int lastEqual(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) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return (right >= 0 && nums[right] == target) ? right : -1;
}
这两种变体的关键在于:
- 查找第一个时,当
nums[mid] == target,我们仍需继续向左搜索 - 查找最后一个时,当
nums[mid] == target,我们仍需继续向右搜索
2.2 寻找第一个大于/小于目标的元素
另一类常见问题是寻找第一个大于(或大于等于)目标的元素,或第一个小于(或小于等于)目标的元素。这类问题在插入位置、范围查询等场景中很常见。
查找第一个大于等于目标的元素:
cpp复制int firstGreaterOrEqual(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 - 1;
} else {
left = mid + 1;
}
}
return left; // 注意可能返回nums.size()
}
这个实现与查找第一个等于目标的元素几乎相同,只是最后不需要检查nums[left] == target。实际上,标准库中的lower_bound函数就是这样实现的。
2.3 旋转数组中的搜索
一个有趣的变种是在旋转有序数组中搜索。旋转有序数组是指将一个有序数组从某个点旋转,如[4,5,6,7,0,1,2]是从[0,1,2,4,5,6,7]旋转得到的。
cpp复制int searchInRotatedArray(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;
}
这个算法的关键在于每次都能确定哪一半是有序的,然后在有序的那一半中判断目标是否存在。时间复杂度仍然是O(log n)。
3. 二分查找在解题中的应用模式
理解了二分查找的各种变种后,我们可以总结出几种常见的应用模式,这些模式在力扣等平台的题目中反复出现。
3.1 在答案范围内二分查找
有一类问题,它们的答案有明确的范围,并且对于给定的候选答案,我们可以快速判断它是否太大或太小。这类问题非常适合二分查找。
典型例题:875. 爱吃香蕉的珂珂
题目大意:珂珂要在H小时内吃完N堆香蕉,每堆香蕉的数量为piles[i]。珂珂每小时最多吃K根香蕉,如果一堆香蕉少于K根,她吃完这堆后不会继续吃其他香蕉。求K的最小值。
cpp复制int minEatingSpeed(vector<int>& piles, int H) {
int left = 1, right = *max_element(piles.begin(), piles.end());
while (left < right) {
int mid = left + (right - left) / 2;
if (canFinish(piles, H, mid)) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
bool canFinish(vector<int>& piles, int H, int K) {
int time = 0;
for (int p : piles) {
time += (p + K - 1) / K; // 向上取整
}
return time <= H;
}
这个问题的关键在于:
- 答案K的范围是已知的:[1, max(piles)]
- 对于给定的K,我们可以很容易计算出所需时间
- 如果时间<=H,说明K可能可以更小;否则需要增大K
3.2 最大化最小值或最小化最大值
另一类常见问题是要求最大化最小值或最小化最大值,这类问题通常可以通过二分查找解决。
典型例题:410. 分割数组的最大值
题目大意:将数组分成m个连续子数组,使得这些子数组的和的最大值最小。
cpp复制int splitArray(vector<int>& nums, int m) {
long left = *max_element(nums.begin(), nums.end());
long right = accumulate(nums.begin(), nums.end(), 0L);
while (left < right) {
long mid = left + (right - left) / 2;
if (canSplit(nums, m, mid)) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
bool canSplit(vector<int>& nums, int m, long maxSum) {
int count = 1;
long currentSum = 0;
for (int num : nums) {
currentSum += num;
if (currentSum > maxSum) {
currentSum = num;
count++;
if (count > m) return false;
}
}
return true;
}
这个问题的解法框架与前一个类似:
- 确定答案范围:[max(nums), sum(nums)]
- 对于给定的候选最大值maxSum,判断是否能将数组分成m份,每份和不超过maxSum
- 如果可以,尝试更小的maxSum;否则需要增大maxSum
3.3 在二维矩阵中的应用
二分查找也可以应用于二维矩阵中,特别是当矩阵具有某种有序性时。
典型例题: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;
}
这个解法巧妙地将二维矩阵视为一维数组进行处理,利用了矩阵的特殊有序性。时间复杂度仍然是O(log(mn))。
4. 高级应用与竞赛题目解析
在掌握了基本应用后,我们来看一些更高级的应用场景和竞赛题目,这些题目往往需要更巧妙的二分查找应用。
4.1 浮点数二分查找
二分查找不仅适用于整数,也适用于浮点数。这种情况下,我们通常需要设置一个精度阈值。
典型例题:求平方根
实现一个函数计算x的平方根,精度为1e-6。
cpp复制double mySqrt(double x) {
if (x < 0) return -1; // 错误处理
double left = 0, right = x;
if (x < 1) right = 1; // 对于x<1的情况,平方根比x大
while (right - left > 1e-6) {
double mid = left + (right - left) / 2;
if (mid * mid < x) {
left = mid;
} else {
right = mid;
}
}
return left;
}
浮点数二分查找的关键点:
- 确定合适的初始范围(如x<1时,sqrt(x)>x)
- 设置合理的精度阈值(如1e-6)
- 注意浮点数比较时可能需要考虑精度(如不用==而用差值小于阈值)
4.2 二分答案与贪心结合
很多问题可以结合二分答案和贪心算法来解决,这种组合在竞赛中非常常见。
典型例题:D. Bank Security Unification (Gym102956D)
题目大意:从给定的数组中选择若干数,使得相邻两个数的按位与的总和最大。
这个问题可以通过二分答案结合贪心来解决:
- 二分查找可能的最大总和
- 对于每个候选值,使用贪心算法验证是否可达
cpp复制// 伪代码框架
long long solve() {
long long left = 0, right = maximum_possible;
while (left < right) {
long long mid = (left + right + 1) / 2;
if (canAchieve(mid)) {
left = mid;
} else {
right = mid - 1;
}
}
return left;
}
bool canAchieve(long long target) {
// 实现贪心算法验证是否可以达到target
}
4.3 二分查找与动态规划结合
有些问题需要将二分查找与动态规划结合起来解决,通常用于优化时间复杂度。
典型例题:最长递增子序列(LIS)的O(n log n)解法
传统的DP解法是O(n^2)的,但结合二分查找可以达到O(n log n)。
cpp复制int lengthOfLIS(vector<int>& nums) {
vector<int> tails; // tails[i]表示长度为i+1的LIS的最小末尾
for (int num : nums) {
auto it = lower_bound(tails.begin(), tails.end(), num);
if (it == tails.end()) {
tails.push_back(num);
} else {
*it = num;
}
}
return tails.size();
}
这个解法的关键在于维护一个tails数组,使得tails[i]存储长度为i+1的递增子序列的最小末尾元素。对于每个新元素,我们使用二分查找确定它可以扩展的最长子序列。
5. 实战技巧与常见错误
在长期解题和竞赛实践中,我积累了一些二分查找的实用技巧,也踩过不少坑。这里分享一些最有价值的经验。
5.1 循环不变量的理解与选择
二分查找的正确性很大程度上依赖于循环不变量的维护。常见的循环不变量有两种形式:
-
左闭右闭区间[left, right]:
- 初始化:left = 0, right = n-1
- 循环条件:left <= right
- 边界更新:left = mid + 1 或 right = mid - 1
-
左闭右开区间[left, right):
- 初始化:left = 0, right = n
- 循环条件:left < right
- 边界更新:left = mid + 1 或 right = mid
选择哪种形式取决于个人习惯和具体问题,但重要的是在整个实现中保持一致。混合使用两种形式是常见错误来源。
5.2 避免死循环的技巧
二分查找容易出现死循环,特别是在边界条件处理不当时。以下技巧可以帮助避免:
- 对于
left < right的情况,使用mid = left + (right - left)/2和left = mid + 1的组合 - 对于
left <= right的情况,确保每次迭代都能缩小搜索范围 - 在查找左边界时,可以使用
right = mid而不是right = mid - 1,配合mid = left + (right - left + 1)/2来避免死循环
5.3 调试二分查找的方法
当二分查找出现问题时,可以采取以下调试策略:
- 打印每次迭代的left, mid, right值,观察搜索范围的变化
- 对于小规模输入,手动模拟算法执行过程
- 检查循环条件和边界更新是否匹配(如使用
left <= right时是否同时有left = mid + 1和right = mid - 1) - 特别注意输入为空、只有一个元素、所有元素相同等边界情况
5.4 模板选择与记忆
虽然理解原理最重要,但记住几个常用模板可以提高解题速度。以下是三个最常用的模板:
模板1:标准二分查找
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;
}
模板2:寻找左边界
cpp复制int leftBound(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;
}
模板3:寻找右边界
cpp复制int rightBound(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] <= target) left = mid + 1;
else right = mid;
}
return left - 1;
}
记住这些模板的关键特征,并根据具体问题选择合适的模板或进行调整。
