1. 二分查找算法基础与COCI题目解析
二分查找(Binary Search)是一种在有序数组中查找特定元素的高效算法,时间复杂度为O(log n)。这个算法通过不断将搜索区间减半来快速定位目标元素,相比线性搜索的O(n)时间复杂度,在处理大规模数据时优势明显。
在C++中,标准模板库(STL)提供了三种常用的二分查找相关函数:
- binary_search:检查元素是否存在
- lower_bound:查找第一个不小于目标值的位置
- upper_bound:查找第一个大于目标值的位置
以题目P9029 [COCI 2022/2023 #1] Čokolade为例,这是一道典型的二分查找应用题。题目大意是:给定n块巧克力,每块有不同的甜度值,需要将它们分成k组,使得每组甜度之和的最大值最小化。这正是经典的"最大值最小化"问题,非常适合用二分查找解决。
2. 二分查找的STL实现详解
2.1 binary_search函数使用
binary_search函数的基本形式为:
cpp复制bool found = binary_search(arr.begin(), arr.end(), target);
这个函数在有序区间[first, last)内查找target,返回bool值表示是否存在。需要注意的是:
- 容器必须是有序的(通常为升序)
- 对于自定义类型,需要提供比较函数或重载<运算符
- 只返回是否存在,不返回具体位置
典型应用场景:
cpp复制vector<int> nums = {1, 3, 5, 7, 9};
bool hasFive = binary_search(nums.begin(), nums.end(), 5); // true
bool hasSix = binary_search(nums.begin(), nums.end(), 6); // false
2.2 lower_bound和upper_bound
这两个函数返回的是迭代器(指针),需要特别注意:
cpp复制// lower_bound: 第一个不小于target的元素位置
auto it = lower_bound(arr.begin(), arr.end(), target);
int pos = it - arr.begin();
// upper_bound: 第一个大于target的元素位置
auto it = upper_bound(arr.begin(), arr.end(), target);
int pos = it - arr.begin();
实际应用示例:
cpp复制vector<int> nums = {1, 2, 2, 3, 4, 4, 4, 5};
// 查找第一个不小于3的元素
auto lb = lower_bound(nums.begin(), nums.end(), 3);
cout << *lb; // 3
cout << lb - nums.begin(); // 3
// 查找第一个大于4的元素
auto ub = upper_bound(nums.begin(), nums.end(), 4);
cout << *ub; // 5
cout << ub - nums.begin(); // 7
2.3 自定义比较函数
对于复杂数据结构,可以自定义比较函数:
cpp复制struct Chocolate {
int sweetness;
// 其他属性...
};
vector<Chocolate> chocs = {...};
// 按甜度排序
sort(chocs.begin(), chocs.end(),
[](const Chocolate& a, const Chocolate& b) {
return a.sweetness < b.sweetness;
});
// 查找特定甜度的巧克力
auto it = lower_bound(chocs.begin(), chocs.end(), targetSweetness,
[](const Chocolate& c, int val) {
return c.sweetness < val;
});
3. 手写二分查找实现
虽然STL提供了方便的二分查找函数,但理解其实现原理非常重要。以下是两种常见实现方式:
3.1 迭代实现
cpp复制int binarySearch(const 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)/2避免整数溢出
- 每次比较后调整左右边界
3.2 递归实现
cpp复制int binarySearchRec(const vector<int>& nums, int left, int right, int target) {
if (left > right) return -1;
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
return binarySearchRec(nums, mid + 1, right, target);
} else {
return binarySearchRec(nums, left, mid - 1, target);
}
}
递归实现的优点是逻辑清晰,但需要注意栈空间消耗,对于极大数组可能不是最佳选择。
4. COCI题目P9029的二分查找解法
回到题目P9029,我们需要将n块巧克力分成k组,使得最大组的甜度和最小。这是一个典型的二分答案问题。
4.1 问题分析
-
确定搜索范围:
- 最小可能值:最大单块巧克力的甜度(至少得包含这一块)
- 最大可能值:所有巧克力甜度之和(全部分在一组)
-
检查函数:
对于给定的最大值mid,检查是否可以将巧克力分成不超过k组,每组甜度和不超过mid -
二分策略:
- 如果当前mid可行,尝试更小的值
- 如果不可行,需要增大mid
4.2 代码实现
cpp复制#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
bool canDivide(const vector<int>& chocolates, int k, long long maxSum) {
long long currentSum = 0;
int groups = 1;
for (int sweet : chocolates) {
if (currentSum + sweet > maxSum) {
groups++;
currentSum = sweet;
if (groups > k) return false;
} else {
currentSum += sweet;
}
}
return true;
}
long long minMaxSweetness(vector<int>& chocolates, int k) {
long long left = *max_element(chocolates.begin(), chocolates.end());
long long right = accumulate(chocolates.begin(), chocolates.end(), 0LL);
long long answer = right;
while (left <= right) {
long long mid = left + (right - left) / 2;
if (canDivide(chocolates, k, mid)) {
answer = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return answer;
}
int main() {
int n, k;
cin >> n >> k;
vector<int> chocolates(n);
for (int i = 0; i < n; ++i) {
cin >> chocolates[i];
}
cout << minMaxSweetness(chocolates, k) << endl;
return 0;
}
4.3 复杂度分析
- 时间复杂度:O(n log S),其中S是所有巧克力甜度之和
- 空间复杂度:O(1),仅使用常数额外空间
5. 二分查找的常见陷阱与优化
5.1 边界条件处理
二分查找容易在边界条件上出错,常见问题包括:
-
循环条件应该是left <= right还是left < right?
- 使用left <= right可以确保检查所有元素
- 使用left < right可能会漏掉最后剩下的元素
-
中间值计算:
- (left + right) / 2可能导致整数溢出
- 应该使用left + (right - left) / 2
-
边界更新:
- 找到目标后是否继续搜索?
- 如何确保找到的是第一个或最后一个匹配项?
5.2 寻找上下界的手写实现
有时需要实现类似lower_bound和upper_bound的功能:
cpp复制// 类似lower_bound的实现
int findFirst(const vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
int result = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
result = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return result;
}
// 类似upper_bound的实现
int findLast(const vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
int result = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] > target) {
right = mid - 1;
} else {
left = mid + 1;
result = mid;
}
}
return result;
}
5.3 浮点数二分查找
二分查找也可以用于浮点数,通常用于求解方程根或优化问题:
cpp复制double binarySearchDouble(double low, double high, double precision) {
while (high - low > precision) {
double mid = (low + high) / 2;
if (checkCondition(mid)) {
high = mid;
} else {
low = mid;
}
}
return (low + high) / 2;
}
关键点:
- 使用精度作为终止条件而非固定次数
- 注意浮点数比较的精度问题
- 可能需要设置最大迭代次数防止无限循环
6. 二分查找的变种与应用
6.1 旋转数组中的搜索
在部分有序的旋转数组中搜索:
cpp复制int searchInRotatedArray(const 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;
}
6.2 寻找峰值元素
在无序数组中寻找任意一个峰值元素(比相邻元素大):
cpp复制int findPeakElement(const vector<int>& nums) {
int left = 0, right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < nums[mid + 1]) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
6.3 在无限序列中搜索
假设序列是无限的(或非常大的),如何高效搜索:
cpp复制int searchInfiniteSequence(const vector<int>& nums, int target) {
int low = 0, high = 1;
// 先找到包含target的区间
while (nums[high] < target) {
low = high;
high *= 2;
}
// 然后在确定的区间内二分搜索
return binarySearch(nums, low, high, target);
}
7. 二分查找在算法竞赛中的应用技巧
7.1 最大化最小值/最小化最大值问题
这类问题通常可以转化为二分答案问题,如:
- 分配问题(将资源公平分配)
- 调度问题(最小化最大完成时间)
- 分割问题(将序列分成若干连续子序列)
解题模板:
- 确定搜索范围
- 编写检查函数(判断给定解是否可行)
- 二分搜索最优解
7.2 配合其他算法使用
二分查找常与其他算法结合:
- 与前缀和结合处理区间问题
- 与贪心算法结合解决分配问题
- 与动态规划结合优化状态转移
7.3 调试技巧
调试二分查找时:
- 打印每次迭代的left, mid, right值
- 检查边界条件是否处理正确
- 使用小规模测试用例验证
- 注意整数溢出问题
cpp复制// 调试示例
while (left <= right) {
int mid = left + (right - left) / 2;
cout << "left=" << left << ", mid=" << mid << ", right=" << right << endl;
// ... 其余代码
}
7.4 性能优化
对于时间要求严格的问题:
- 使用位运算代替除法:mid = (left + right) >> 1
- 减少函数调用,内联关键代码
- 预先计算并存储常用值
- 使用更高效的检查函数实现
8. 实际编程中的注意事项
8.1 容器选择
根据数据特点选择合适的容器:
- vector:随机访问高效,适合静态数据
- array:固定大小,栈上分配
- deque:两端操作高效,但二分查找性能略低
8.2 自定义比较
对于复杂数据,正确实现比较逻辑:
cpp复制struct Interval {
int start, end;
bool operator<(const Interval& other) const {
return start < other.start;
}
};
vector<Interval> intervals;
sort(intervals.begin(), intervals.end());
// 查找第一个start >= target的区间
auto it = lower_bound(intervals.begin(), intervals.end(), Interval{target, 0});
8.3 错误处理
健壮的二分查找应考虑:
- 空输入容器的情况
- 未排序输入的检测
- 目标值超出范围的情况
- 多个匹配项的处理
8.4 测试用例设计
全面的测试用例应包括:
- 空序列
- 单元素序列
- 所有元素相同
- 目标值在开头/中间/结尾
- 目标值不存在但位于范围内
- 目标值小于最小值/大于最大值
- 大规模随机数据测试
cpp复制void testBinarySearch() {
vector<int> empty;
assert(binarySearch(empty, 1) == -1);
vector<int> single = {5};
assert(binarySearch(single, 5) == 0);
assert(binarySearch(single, 3) == -1);
vector<int> duplicates = {1,1,1,2,2,2,2,3,3};
assert(binarySearch(duplicates, 2) >= 3 && binarySearch(duplicates, 2) <= 6);
// 更多测试用例...
}
二分查找看似简单,但要写出完全正确且高效的实现需要深入理解其原理并积累实践经验。在算法竞赛和实际编程中,掌握二分查找的各种变体和应用场景将大大提高解题效率。
