区间开闭决定一切:何为“循环不变量”?
给定一个升序整型数组 nums 和一个目标值 target。写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
写二分查找的第一步,永远不是敲 while,而是在心中定义好你的查找区间类型。定义一旦确定,后续每一步的代码都必须严格维护这个定义(即“循环不变量”):
- 区间初始:
left = 0, right = numsSize - 1,两端均指向有效可访问的数组元素; - 循环终止:当
left == right时,区间[left, left]依然包含 1 个尚未核验的候选数字!因此循环条件必须是while (left <= right),只有当left > right区间为空时才算彻底搜空; - 边界推进:当中点
nums[mid] != target时,已知mid绝对不是答案,必须严格将边界推进一位:left = mid + 1或right = mid - 1,彻底将mid排除出下一轮区间,避免死循环!
双指针夹逼推演,观察搜索空间折半。
推演中观察 L(左界)、R(右界)与 M(探针中点)。每一步比对后,被排除的半边数组将被置为半透明灰阶,展现搜索规模以几何级数对数骤减的过程。
- 当前候选区间 [L, R]
- 当前中点 M (mid)
- 已排除区域
0
5
—
1 / 1
初始化闭区间 [left, right]。
每次比对必将 mid 排除出下一轮范围,严格保证区间单调收缩。
三个关键细节:防整型溢出、循环条件与边界步进
- 01
为什么不能写
(left + right) / 2?在 C/C++ 等强类型语言中,
int通常为 32 位带符号整数(上限约 $2.14 \times 10^9$)。当left和right都很大(例如在海量数据检索或二分答案时),两数相加left + right会直接整型溢出翻转为负数,导致mid成为负数并引发内存段错误!必须写成left + (right - left) / 2,先减后加,数学等价且在数学上永远不会发生上溢。 - 02
为什么闭区间必须是
left <= right?如果是左闭右开
[left, right),那么当left == right时区间[left, left)为空,条件应该写left < right。但如果使用的是双闭区间[left, right],当left == right时,该位置上的元素还没有被核验过!例如单元素数组[5]找5,初始left = 0, right = 0。如果误写为left < right,循环根本不会进入,直接错误返回-1。因此双闭区间必须写<=。 - 03
必须加一减一:
left = mid + 1与right = mid - 1许多初学者偷懒写
left = mid或right = mid。由于整数除法是向下取整,当区间只剩两个元素时(例如left = 0, right = 1),mid = 0 + 0 / 2 = 0。若此时nums[0] < target且你执行left = mid,left依然为 0!区间范围完全没有缩小,程序陷入无限死循环。既然nums[mid]已经确认不是target,必须坚决将其移出下一轮考察范围。
真实 C 语言实现节选
选自 CSP 练习记录中已通过评测的源码。纯粹的逻辑,严格的防溢出与边界控制。
int search(int *nums, int numsSize, int target) {
int left = 0;
int right = numsSize - 1; // 1. 采用双闭区间 [left, right]
int mid;
while (left <= right) { // 2. 当 left == right 时区间依然有效
mid = left + (right - left) / 2; // 3. 防整型加法溢出写法
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1; // 4. 严格跳过 mid,防止死循环
} else {
right = mid - 1; // 4. 严格跳过 mid,防止死循环
}
}
return -1; // 搜索区间为空,目标不存在
}
对数复杂度的威力与极限
N 为数组长度。每次比对直接斩去一半候选元素。即便面对 40 亿($4 \times 10^9$)个有序数字,二分查找最多只需约 32 次比较即可精准定夺!
仅需常数级别的指针与临时变量(left, right, mid),无需任何额外辅助数组或递归调用栈,内存开销极小。