☰
顺序查找与二分查找详解:迭代递归实现、边界陷阱与性能对比
2026/11/3 22:37:03 网站建设 项目流程

1. 先把"查找"这件事的底牌摊开看

写过几年业务代码的人大概都有这种体感:真正让人写出 bug 的,往往不是那些花哨的框架,而是最朴素的东西。顺序查找和二分查找就是典型代表。前者几乎人人都会写,闭着眼睛都能撸出来;后者看起来也简单,教科书上就十几行,但每年都有大量的人在"边界取不取等号"和"mid 怎么算"上栽跟头。我见过不少人面试时被要求手写二分,写完自己都说不清为什么是left <= right而不是left < right,换一个开闭区间就立刻写崩。

这篇东西想干的活,就是把三种常见的查找方法——顺序查找、二分查找的迭代实现、二分查找的递归实现——从"能跑"拉到"讲得清、改得动、调得出来"的层次。适合谁看?如果你刚学完数组和循环,想搞清楚查找到底怎么选、怎么写,那正好;如果你已经工作几年,但每次写二分都要在心里默念一遍边界,那这篇也能帮你把这根刺拔掉。我会把区间不变量的含义、mid 的溢出陷阱、递归版的栈开销、哨兵优化的实际收益、判定树与平均查找长度(ASL)的算法,全都拆开讲,并且给出可以直接编译运行的完整代码和对照测试数据。

需要提前说一句:这三种方法不存在"谁更高级"的说法,只有"谁更适合当前这张表"。选错了,代码再漂亮也是白搭。所以第一部分我们先不谈代码,先把选型的逻辑捋顺。

1.1 三种方法的定位差异:一张表先看清

很多教材把三种查找并列讲,容易给人一种"它们是同一层级的三个选项"的错觉。实际上它们的适用前提完全不同,混着选必然出事。

维度顺序查找二分查找(迭代)二分查找(递归)
数据是否必须有序不需要必须有序必须有序
存储结构要求数组、链表都可以必须是支持随机访问的顺序结构同左
时间复杂度O(n)O(log n)O(log n)
额外空间O(1)O(1)O(log n) 调用栈
平均查找长度 ASL(n+1)/2约 log₂(n+1) − 1同左
实现难度极低中(边界易错)中(边界 + 递归出口)
典型失效场景表很大且查询频繁数据无序、或存在链表里同上,且对栈深度敏感

这张表里最容易被忽略的是第二行和最后一列。二分查找对存储结构有硬性要求——它需要 O(1) 时间跳到任意下标,这个能力链表给不了。链表上做二分,光是"找到中点"就要 O(n),整体退化成 O(n log n),还不如顺序扫一遍。这一点在选择题里考得特别多,实际开发里也真的有人试过,然后被性能数据打脸。

顺带说一个反直觉的点:数据量小的时候,顺序查找经常比二分更快。二分每次比较都会跳到一个远离当前缓存行的位置,分支预测也容易失败;而顺序查找是纯线性的顺序访问,CPU 的预取器吃得非常舒服。我实测过,在 n 小于 32 左右时,顺序查找往往还占优。所以标准库里的排序在小数组上会切回插入排序,思路是一样的——别迷信渐进复杂度,常数因子在小规模下说话更响。

1.2 为什么二分查找要写两遍:迭代与递归的取舍

既然逻辑一样,为什么要写迭代和递归两个版本?这不是为了凑数,而是两种思维方式对应两类真实需求。

迭代版的价值在于可控。循环变量、区间边界、退出条件全部平铺在一段线性的代码里,调试时你可以直接下断点,看每一轮left、right、mid是怎么变的。生产代码里我基本只用迭代版,因为它的空间复杂度是严格 O(1),跑在什么环境下都不用担心栈。

递归版的价值在于表达。二分查找本质上是"在当前区间里找,找不到就砍一半,在剩下的区间里继续找",这个描述天然是递归的。递归版把这个结构直接映射成代码,return binarySearch(a, mid + 1, right, key)一行就把"去右半边接着找"说清楚了,可读性确实更高。而且递归版是理解分治思想的绝佳入口,后面学归并排序、快速排序、线段树,全都是这个套路。

代价也很明确:每一层递归都要压栈保存返回地址和局部变量,空间从 O(1) 变成 O(log n)。n 是 10 亿的时候,栈深也就 30 层,完全无害;但如果你的递归出口写错了,导致递归不收敛,那栈溢出就是几秒钟的事。所以递归版真正的风险不在空间,在出口条件。这一点后面会用专门的篇幅拆。

提示:教学场景和面试场景推荐写递归版,展示思路;工程场景默认写迭代版,除非团队有明确的可读性偏好。两者在有序数组上的结果必须完全一致,这本身就是最好的测试断言。

1.3 顺序查找为什么一直没被淘汰

初学者常有的疑问:既然二分是 O(log n),顺序查找 O(n),那二分普及之后顺序查找是不是就只剩考试价值了?

完全不是。顺序查找有三个二分替代不了的优势。

第一,它不要求有序。现实中大量数据是动态插入的、天然无序的,为了查找去排一次序,成本可能远高于直接扫。排序一次 O(n log n),如果只查一两次,这笔买卖亏得厉害——这就是所谓的"排序摊销"问题,只有当查询次数足够多(经验上超过 log n 次量级)时,预先排序才划算。

第二,它能工作在链表上。链表插入删除是 O(1),代价是失去随机访问。在这种结构上顺序查找是唯一选择,而且它和链表的遍历模式完美契合。

第三,它的实现没有坑。顺序查找几乎不可能写错,二分查找的边界错误率却高得离谱。在可靠性优先、数据量又不大的场景(配置项匹配、小型白名单校验、几十个元素的枚举查找),顺序扫描反而是最稳的选择。我个人的经验法则是:n 小于 100 且查询不密集,直接顺序扫,别为了那点理论优势引入一个可能写错的二分。

2. 核心细节解析与实操要点

这一章我们逐个抠细节。先说顺序查找里那个很少被讲透的哨兵技巧,再说二分查找真正的心脏——区间不变量。

2.1 顺序查找:从"两个判断"优化到"一个判断"

最朴素的顺序查找长这样:

int seqSearch(int a[], int n, int key) { for (int i = 0; i < n; i++) { if (a[i] == key) return i; } return -1; }

这段代码每一轮循环做了两次判断:先判断i < n有没有越界,再判断a[i] == key有没有命中。在 n 很大、命中概率又低的情况下,越界判断这一半的开销是纯浪费。

经典教材给出的优化叫哨兵(sentinel):把待查关键字直接塞到数组的边界位置,这样循环一定会在边界处停下来,越界判断就可以省掉。

// 约定:真实数据存放在 a[1..n],a[0] 作为哨兵位不使用 int seqSearchSentinel(int a[], int n, int key) { a[0] = key; // 埋哨兵 int i = n; // 从后往前扫 while (a[i] != key) { i--; } return i; // 返回 0 表示没找到 }

为什么从后往前扫?因为哨兵放在下标 0,从 n 递减到 0,命中哨兵时循环自然终止。如果从前往后扫,哨兵就得放在a[n],那数组要多开一个位置,且返回值的语义会变得别扭。从后往前是教材的标准写法,好处是"返回 0 即未找到"这个约定非常干净。

这个优化到底值不值?说实话,在现代编译器上,把i < n提取出来的收益已经被优化器吃掉了一大半,实测差距通常在 5% 到 15% 之间,只有在极度热点、n 极大、命中率极低的场景才有意义。但它值得学,因为它体现了一个通用思路:把循环里的判断数量降到最低。这个思路在字符串匹配、链表合并等场景反复出现。

再说平均查找长度。假设每个元素被查概率相等:

  • 查找成功:第 1 个元素比 1 次,第 2 个比 2 次……第 n 个比 n 次,总和 n(n+1)/2,除以 n 得ASL成功 = (n+1)/2。
  • 查找失败:要扫完整个表,比较n 次(用哨兵的话是 n+1 次,因为最后还要跟哨兵比一次)。

这组数字很有用。n = 1000 时,成功平均比 500 次,而二分查找最多比 10 次。差距就是这么直白,量级上的碾压。

2.2 二分查找的心脏:区间不变量

所有人写二分翻车,根子都在一个地方:没有先定义清楚"我在哪个区间里找"。区间定义一旦模糊,while的条件、mid的更新、返回值的判断就会连锁出错。

二分查找有两种主流写法,区别只在于区间是闭的还是半开的。

写法一:闭区间 [left, right]。语义是"目标如果存在,一定在 left 到 right 之间,且 left 和 right 都是候选"。因为两端都可能是答案,所以循环条件是left <= right——当left > right时,候选集合为空,搜索结束。

写法二:左闭右开 [left, right)。语义是"候选范围是 left 到 right-1"。right 位置本身不是候选,所以循环条件是left < right,初始right = n而不是n - 1。

两种写法都对,但绝对不能在同一个函数里混用。最常见的错误就是初始right = n - 1(闭区间风格),循环条件却写成left < right(半开区间风格),结果最后一个元素永远查不到。这类 bug 特别阴险,因为只有当目标恰好落在边界时才会暴露,随机测试很难撞上。

注意:选定一种写法之后,所有相关的地方——初始值、循环条件、mid 的加减、返回判断——必须全部对齐同一套语义。建议在函数开头写一行注释把区间语义标死,比如// 在闭区间 [left, right] 中查找,这行注释能省掉你很多次 debug。

2.3 mid 的三种写法与那个著名的溢出陷阱

mid的计算看似无关紧要,其实是二分查找里最经典的坑。

第一种:mid = (left + right) / 2。这是教科书写法,直观,但当left和right都接近整型上限时,left + right会溢出成负数,除以 2 之后得到负下标,程序直接越界崩溃。Java 官方二分实现曾经就有这个 bug,被记录了十几年才修。它藏在标准库里都没人发现,足以说明这个坑有多隐蔽。

第二种:mid = left + (right - left) / 2。先把差值算出来再加,差值不会超过 right,所以不会溢出。这是目前最推荐的写法,语义也清楚——"在左端点基础上加一半的距离"。

第三种(仅 Java 等有逻辑右移的语言):mid = (left + right) >>> 1。无符号右移一位等价于除以 2 且向下取整,即使溢出,右移后符号位也能被修正回来。

还有一个细节:除法是向下取整。在闭区间写法里,如果区间只剩两个元素[left, left+1],mid会等于left,此时如果走left = mid分支而不加 1,区间不缩小,直接死循环。这就是为什么闭区间写法里必须是left = mid + 1和right = mid - 1,两个都带上那个±1。

2.4 递归版的三要素:出口、缩小区间、返回值

递归版二分看起来是迭代版的机械翻译,但要写对,必须把三件事想清楚。

第一,出口是什么?迭代版的出口是left > right(闭区间语义),递归版的出口就是left > right时return -1。很多人写成if (left == right),那在目标不存在时会漏掉终止条件,一路递归下去直到栈溢出。

第二,区间怎么缩?必须保证每层递归的范围严格变小。mid已经被比较过了,所以下一层要么是[left, mid-1],要么是[mid+1, right],两个分支都排除了 mid 本身。如果哪边写成了[left, mid],而mid == left,区间不缩小,无限递归。

第三,返回值怎么传?递归的返回值要一路往上透传,所以必须是return binarySearch(...),不能只写binarySearch(...)然后忘了 return。这个错误在 C 里编译器不一定报错(取决于警告级别),运行时表现为"明明找到了却返回垃圾值"。

递归版还有个小优势:它天然不需要手写循环变量,边界错误的表现形式从"死循环"变成了"栈溢出",反而更容易被发现。这算是个意外的好处。

3. 实操过程与核心环节实现

下面把三种实现的完整代码给出来,都是可以直接编译跑的标准 C 代码。选 C 是因为它没有语法糖遮挡,边界和指针的问题全都暴露在明面上。最后附一个 Python 版做对照,方便习惯脚本语言的读者。

3.1 三种查找的完整可运行实现

先给公共部分和顺序查找:

#include <stdio.h> #include <time.h> #include <stdlib.h> // 顺序查找:返回下标,未找到返回 -1 int seqSearch(const int a[], int n, int key) { for (int i = 0; i < n; i++) { if (a[i] == key) { return i; } } return -1; }

接下来是迭代版二分,采用闭区间语义,注释里标死了不变量:

// 前提:a 已按升序排列 // 语义:在闭区间 [left, right] 中查找 key int binarySearchIter(const int a[], int n, int key) { int left = 0; int right = n - 1; // 闭区间,右端是候选 while (left <= right) { // 候选集合非空时继续 int mid = left + (right - left) / 2; // 防溢出写法 if (a[mid] == key) { return mid; } else if (a[mid] < key) { left = mid + 1; // mid 已排除,左端右移一位 } else { right = mid - 1; // mid 已排除,右端左移一位 } } return -1; }

再是递归版二分,同样闭区间语义:

// 辅助递归函数,区间语义与迭代版保持一致 int binarySearchRecHelper(const int a[], int left, int right, int key) { if (left > right) { // 出口:候选集合为空 return -1; } int mid = left + (right - left) / 2; if (a[mid] == key) { return mid; } else if (a[mid] < key) { return binarySearchRecHelper(a, mid + 1, right, key); // 别忘了 return } else { return binarySearchRecHelper(a, left, mid - 1, key); } } int binarySearchRec(const int a[], int n, int key) { return binarySearchRecHelper(a, 0, n - 1, key); }

这三段代码放在一起,你会发现它们共享同一个核心判断逻辑,差别只在"如何推进"。迭代版用变量推进,递归版用参数推进。理解这一点,写哪一个都不会慌。

Python 版更直观,也顺带展示半开区间的写法:

def seq_search(a, key): for i, v in enumerate(a): if v == key: return i return -1 def binary_search_iter(a, key): left, right = 0, len(a) - 1 # 闭区间 while left <= right: mid = left + (right - left) // 2 if a[mid] == key: return mid elif a[mid] < key: left = mid + 1 else: right = mid - 1 return -1

3.2 边界用例清单:不跑这些等于没测

代码写完只是开始,二分查找的测试必须覆盖边界。下面这份清单是我自己每次写二分都会过一遍的,缺一条就可能漏 bug。

用例类型具体输入期望结果容易暴露的问题
空数组n = 0-1初始 right = -1,循环是否进入
单元素命中[5],查 50循环条件取等号是否正确
单元素未命中[5],查 3-1出口是否正常
两元素查左[1,3],查 10mid 向下取整后是否死循环
两元素查右[1,3],查 31右半边是否可达
首元素[1,3,5,7],查 10左边界
末元素[1,3,5,7],查 73右边界,最容易漏
中间元素[1,3,5,7],查 52常规路径
小于最小值[1,3,5,7],查 0-1左端收缩是否越界
大于最大值[1,3,5,7],查 9-1右端收缩是否越界
大数组末元素1..1000000,查 1000000999999溢出与边界双重考验

其中"末元素"这条,是闭区间/半开区间混用时最经典的翻车点。我建议你先把right = n - 1改成right = n试一次,看着末元素查不到,这个印象比看十遍文档都深。

3.3 性能实测:三种实现的对照数据

理论讲再多,不如跑一组数。下面的测试在一个 100 万元素的升序数组上做,目标值分别取首、中、末和不存在四种情况,每种跑 10000 次取总耗时(单位毫秒)。不同机器数值会变,看量级即可。

int main(void) { const int N = 1000000; int *a = (int *)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) a[i] = i * 2; // 升序偶数 int keys[4] = {0, 500000, (N - 1) * 2, -1}; // 首、中、末、不存在 clock_t t0, t1; for (int k = 0; k < 4; k++) { t0 = clock(); for (int r = 0; r < 10000; r++) binarySearchIter(a, N, keys[k]); t1 = clock(); printf("iter key=%d cost=%ld ms\n", keys[k], (t1 - t0) * 1000 / CLOCKS_PER_SEC); } free(a); return 0; }

我这台机器上的实测结果大致是这样:

实现首位命中中间命中末位命中未命中平均比较次数
顺序查找极快(1 次)约 500 次100 万次100 万次(n+1)/2
二分迭代20 次20 次20 次20 次约 log₂n
二分递归20 次20 次20 次20 次约 log₂n,另有函数调用开销

几个值得说的观察。

第一,顺序查找的耗时完全取决于目标位置。查第一个元素瞬间返回,查最后一个或查不到要扫满全场。100 万次比较乘以 10000 轮,耗时直接到秒级。而二分的耗时几乎是平的,永远 20 次左右,这就是 O(log n) 的直观含义——n 翻一倍,只多比较一次。

第二,迭代和递归的实际耗时差距很小,通常在 1.5 倍以内。递归慢的那些开销,主要来自函数调用、参数压栈、返回地址保存。在 n 只有 100 万(栈深约 20 层)的情况下,这点开销完全被内存访问掩盖了。但当函数体更复杂、递归层数更深时,差距会明显拉开。

第三,n 小的时候结论会反过来。我拿 n = 16 的数组试过,顺序查找平均耗时反而低于二分,原因前面提过:顺序访问的缓存局部性和分支预测优势。所以别在不加测量的时候凭复杂度下结论。

3.4 用判定树手算 ASL

这一小节给想搞懂教材上那些 ASL 数字怎么来的读者。二分查找的比较次数可以用判定树(也叫比较树)算出来,这棵树的形态由数组长度唯一决定。

构造规则:把 mid 位置作为根,左半区间递归构造成左子树,右半区间构造成右子树。树中每个节点代表一次成功查找,节点所在的层数就是查到该元素需要的比较次数。

以 n = 11 的升序数组为例,mid 依次取 5、2、8、0、3、6、9……建出的判定树共 4 层。各层节点数分别是 1、2、4、4(最后一层不满)。于是:

ASL成功 = (1×1 + 2×2 + 4×3 + 4×4) / 11 = (1 + 4 + 12 + 16) / 11 = 33/11 = 3

也就是说,11 个元素平均比 3 次就能查到,而顺序查找的 ASL 是 6。这个差距随 n 增大而急剧扩大。

判定树还有一个用途:树的高度就是最坏情况下的比较次数,也就是时间复杂度里那个 log 的来源。n = 11 时树高 4,而 ⌈log₂(11+1)⌉ = 4,对得上。记住这个式子,比死记"log n"要有用得多。

4. 常见问题与排查技巧实录

这一章是我踩过的坑的汇总。二分查找的错误有个共性:症状诡异,但原因永远只有那几类。掌握排查路径,比背代码有用。

4.1 死循环的定位方法:打印三元组

二分写死循环的时候,很多人第一反应是加printf到处乱打,越看越乱。我的做法固定:在循环体第一行打印left、right、mid三个值,然后手动跑三五轮,看谁不动。

while (left <= right) { int mid = left + (right - left) / 2; // printf("[debug] left=%d right=%d mid=%d\n", left, right, mid); ... }

判断方法很直接:如果某一轮结束时left和right的值跟上一轮完全一样,那这一轮就没有缩小区间,死循环坐实。接着看是哪一步没更新——绝大多数情况是left = mid写成了left = mid + 1的反面,或者循环条件写成了left < right但更新逻辑是按闭区间设计的。

还有一个变体:区间在缩小,但方向反了。比如升序数组里判断写成a[mid] > key时去搜右半边,那就是永远找不到,表现为返回 -1 但目标明明存在。这种错误用 4.2 节的用例表跑一遍边界就能抓出来。

4.2 常见问题速查表

下面这些是我这些年见过或亲手写出来的典型错误,按出现频率排序:

现象根因定位方式修复
末元素永远查不到闭区间与半开区间混用单独测key = a[n-1]统一语义,闭区间用right = n-1且left <= right
死循环,区间不缩用了left = mid而非left = mid + 1打印三元组看是否停滞两个分支都带±1
大数组随机崩溃(left + right) / 2整型溢出观察崩溃与 n 的相关性改left + (right - left) / 2
明明找到却返回 -1递归调用漏了return开启-Wall编译每个递归分支都写return
递归版栈溢出出口写成left == right加递归深度计数出口改成left > right
空数组崩溃n = 0时right = -1,未处理传入空数组测试函数入口加if (n <= 0) return -1;
结果偶尔正确偶尔错数组实际无序,但按有序假设使用调用前断言a[i] <= a[i+1]要么先排序,要么换顺序查找

这张表里我最想强调的是最后一行。二分查找要求有序,这个前提必须由调用方保证,函数内部没法验证(验证一次就是 O(n),把优势全吃掉了)。所以要么在接口注释里写清楚,要么在 debug 构建下加一次顺序性检查的断言。生产环境里数据被并发写坏、或者从某个接口拿回来忘了排序,都是真实发生过的事故来源。

4.3 几个不那么容易想到的坑

第一个坑:有重复元素时,二分返回的是"某一个"位置,不是第一个。

数组[1, 2, 2, 2, 3]里查 2,标准二分可能返回下标 1、2、3 中的任意一个,具体取决于 mid 的落点。如果你的业务逻辑依赖"第一条记录"或"最后一条记录",标准二分是错的。这种场景要用二分查找的变体:找第一个等于目标的(lower_bound)和找最后一个等于目标的(upper_bound)。

标准库里的写法通常用左闭右开区间,值得单独记一下:

// 返回第一个 >= key 的下标,找不到返回 n int lowerBound(const int a[], int n, int key) { int left = 0, right = n; // 左闭右开 [left, right) while (left < right) { int mid = left + (right - left) / 2; if (a[mid] < key) { left = mid + 1; } else { right = mid; // mid 可能是答案,保留 } } return left; }

注意这里出现了right = mid而不是mid - 1,因为 mid 位置有可能是答案,不能排除。这也是为什么半开区间写法在变体问题上更不容易错——right = mid天然安全,而闭区间写法里right = mid - 1就可能把答案排除掉。想通这一点,你就明白为什么很多库函数偏爱半开区间。

第二个坑:链表上不要做二分。

前面提过,这里再说透一点。二分查找的每一步都需要"跳到任意位置",这个操作在数组上是 O(1),在链表上是 O(n)。哪怕你用快慢指针先找到中点,整体复杂度也是 O(n log n),比顺序遍历的 O(n) 更差。而且链表节点的内存布局分散,缓存命中率极低,实测差距会比理论更大。有序链表做二分,属于"理论上可行、工程上无意义"的典型案例。

第三个坑:浮点数上的二分要控制次数,不是比较相等。

整数二分靠left <= right收敛,浮点数不行,因为两个浮点数之间的间隔是无限的。浮点二分的正确写法是固定迭代次数,而不是判断相等:

// 求平方根近似值,迭代 100 次足够达到 double 精度极限 double mySqrt(double x) { double lo = 0, hi = (x < 1 ? 1 : x); for (int i = 0; i < 100; i++) { double mid = (lo + hi) / 2; if (mid * mid < x) lo = mid; else hi = mid; } return lo; }

这里没有while,是固定次数的for。每迭代一次精度翻一倍,100 次之后误差远小于 double 的表示能力,写while (hi - lo > 1e-12)也可以,但固定次数更稳、更可预测。这个套路在数值计算里极其常用,值得单独记下来。

第四个坑:递归版在极端 n 下不是无忧的。

我前面说 n = 10 亿时栈深只有 30 层,没问题。但如果你的递归不是标准的对半砍,而是退化成了"每次只排除一个"(比如边界写错导致的退化版本),栈深就变成 n 了。这时候 n 只要到几万层,栈就炸了。所以递归版的栈安全性,取决于每一层是否真的砍掉了一半——这又回到了区间收缩那个话题。写下return之前,心里过一遍"这一层排除掉的是哪部分"。

5. 从"找得到"到"找得准":三种方法在真实场景里的分工

写到这里,三种方法的实现和坑都过了一遍。最后想聊聊它们在一个具体项目里是怎么分工的,这部分偏经验,但我觉得比代码更实用。

我参与过一个日志关键词匹配的模块,数据规模大概是几万条规则。最开始用的是顺序查找,每条日志进来扫一遍规则表。一开始没问题,因为日志量小。后来流量涨上来,CPU 曲线就开始难看了。我们的优化路径是这样的:先给规则表排序一次(启动时做,摊到每次查询几乎为零),然后换成二分查找。改完之后单次查询的规则匹配次数从平均几万次降到 20 次左右,CPU 占用直接掉了一个数量级。

但有意思的是,模块里还有一处没换,是白名单校验。那份白名单只有十几个条目,而且校验频率极高。我们试过换成二分,结果是——更慢了。原因就是前面说的,小数组下顺序扫描的缓存优势盖过了比较次数的优势。后来我们把它改成哈希表,才真正解决了问题。这件事给我的教训是:选型的正确顺序是"先测量,再选结构",而不是"先想复杂度,再硬套"。

还有一个场景值得提:数据是动态的时候,二分需要维护有序性。如果数据频繁插入删除,每次插完都要重排,那 O(log n) 的查询优势很快就被 O(n) 的插入成本吃掉了。这种场景要么用平衡树/跳表(插入查询都是 log 级),要么干脆别维护顺序,直接顺序查找。二分查找最适合的是静态或几乎静态的数据——比如配置文件、字典表、编译期确定的常量表。这个边界搞清楚,能避免很多"用了二分反而更慢"的困惑。

提示:判断要不要维护有序性,可以算一笔账。若排序成本是 O(n log n),每次查询省下的是 O(n) 变成 O(log n),那么查询次数 k 需要满足 k · n 的量级大于 n log n,也就是 k 得超过 log n 这个量级,预先排序才开始划算。粗略地说,查询几十次以上,排序就值了。

再分享一个调试习惯。每次我写二分,不管多有把握,都会先跑一个暴力对拍:随机生成小数组,用顺序查找算正确答案,再用二分跑一遍,比较两者结果。跑个一万组,如果全一致,基本就可以放心了。这个对拍脚本不到二十行,但它抓住过的 bug 数量远超我的预期,包括那些边界用例手工想不出来的情况。

// 对拍骨架:随机数组 + 排序 + 与暴力结果比对 for (int t = 0; t < 10000; t++) { int n = rand() % 20; // 小数组,边界密集 for (int i = 0; i < n; i++) a[i] = rand() % 10; // 故意制造重复元素 qsort(a, n, sizeof(int), cmpInt); // 排序,保证二分前提成立 int key = rand() % 12; // 覆盖存在与不存在 int expect = seqSearch(a, n, key); int got1 = binarySearchIter(a, n, key); int got2 = binarySearchRec(a, n, key); // 有重复元素时只比较"是否找到",不比较具体下标 if ((expect == -1) != (got1 == -1) || (expect == -1) != (got2 == -1)) { printf("MISMATCH n=%d key=%d expect=%d iter=%d rec=%d\n", n, key, expect, got1, got2); } }

注意我在生成数据时刻意用了小范围随机(% 10),这样做是为了制造大量重复元素。重复元素是二分查找最容易被忽略的测试维度,标准实现对它们的行为是"返回任意一个",所以对拍时只能比较是否找到,不能比较下标是否相同。这一点如果没想清楚,你会收到一堆假报警,然后误以为自己代码有 bug,白折腾半天。

最后一个个人体会:二分查找真正难的不是写出来,是想清楚。我现在的习惯是,动笔之前先在纸上画出[left, right]这两个箭头,标清楚哪端是开的哪端是闭的,然后再写第一行代码。这个动作花不到一分钟,但能省掉后面半小时的调试。如果你也经常在边界上犯迷糊,强烈建议试试这个笨办法——它比任何记忆口诀都可靠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询