
先抛个问题你写程序的时候在一个数组里找一个数第一反应是不是直接for循环一个个比这个操作看着简单但它就是数据结构里最经典的线性表查找问题。等数据量上去了你会发现这循环慢得离谱这时候才会意识到顺序查找、折半查找、分块查找这三个基础算法才是真正决定程序性能的分水岭。这篇博文把线性表上的三种查找方法从头到尾捋一遍原理、手写实现、时间复杂度推导、适用场景、考试和面试里常见的问法还有我在实际项目和写题过程中踩过的坑争取让你看完就能直接从“会用”变成“会选、会写、会讲”。1. 查找问题到底在解决什么1.1 查找的本质是数据组织方式的外化很多人觉得查找就是一个“遍历数组找元素”的动作其实不是。查找的背后是数据的组织方式。同一个数据集合你用无序数组存、用有序数组存、用带索引的块来存对应能用的查找方法完全不同查找成本也差了数量级。所以理解三种查找方法本质上是理解三种数据组织方式。线性表是数据结构里最基础的结构它的查找需求也最典型给一个关键字key在表里找到对应记录并返回位置。这里的关键字可以是学号、身份证号、订单号原则是能唯一标识一条记录。查找成功返回位置查找失败则返回一个约定的空标记比如-1。这个问题的难点从来不在于“怎么写一个循环”而在于当数据规模变大、数据形态变化、增删操作频繁时你选的查找策略还能不能扛住。1.2 ASL衡量查找算法效率的核心指标评价一个查找算法的好坏不能只算“最好情况”或者“最坏情况”。实际工程里一个查找操作会被调用成千上万次真正有意义的是平均代价。数据结构里把这个指标叫做 ASLAverage Search Length平均查找长度它表示查找过程中“关键字和表中元素比较次数的平均值”。公式长这样ASL Σ(Pi × Ci)i 从 1 到 n其中 Pi 是查找第 i 个元素的概率Ci 是找到第 i 个元素需要的关键字比较次数。如果不做特别说明通常认为每个元素被查找的概率相等即 Pi 1/n。提示ASL 分成“查找成功”和“查找失败”两种情况算。考试和面试里经常只问成功的但工程上你需要同时关心失败的代价——一个不存在的 key 被反复查询如果失败路径代价很高性能一样会崩。1.3 三种查找方法一句话定位三种方法放在一起看其实代表了三种典型的算法设计思路顺序查找暴力遍历。不要求有序不挑存储结构无脑比较时间复杂度 O(n)。折半查找充分利用“数据有序”这个先验条件每次砍掉一半搜索范围时间复杂度降到 O(logn)。分块查找折中方案。要求数据“块间有序、块内无序”用索引表加速定位时间复杂度 O(sqrt(n))同时支持高效的插入删除。换句话说顺序查找解决的是“有没有”的问题折半查找解决的是“快不快”的问题分块查找解决的是“又要快又得灵活变通”的问题。2. 顺序查找把暴力遍历做到极致的方案2.1 基础实现和哨兵优化顺序查找的思想一句话就能说完从表的一端开始逐个把元素和key比较直到找到或者整张表扫完。先看最朴素的写法C 语言数组下标从 0 开始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这个判断每次循环都要执行。当表很大、查找很频繁时这个判断是能省的。教科书里的优化方案是“哨兵”也叫“监视哨”int seqSearchSentry(int a[], int n, int key) { a[0] key; // 先把 key 存到 a[0] 作为哨兵 int i n; // 从表尾往前查 while (a[i] ! key) { i--; } return i; // 如果返回 0说明没找到 }核心逻辑是把key复制到数组下标 0 的位置然后从后往前查。循环里只需要判断a[i] ! key不需要再判断 i 有没有越界。因为就算整张表都不存在key扫描到下标 0 时也必然会命中哨兵循环一定可以退出。这个优化虽然不能改变时间复杂度 O(n)但常数因子变小了。在数据规模百万级、被反复调用时差别是实打实的。注意用了哨兵之后返回 0 代表查找失败。但是下标从 1 开始的数组里返回 0 又可能是合法的数据位置所以用哨兵时数组的有效数据通常从下标 1 开始放。这是很经典的“空间换常数”技巧面试时主动提出来是加分项。2.2 ASL 推导为什么是 (n1)/2这一步值得手推一遍考试和面试都喜欢问。假设表中 n 个元素查找概率相等均为 1/n。查找第 1 个元素比较 1 次第 2 个元素比较 2 次……第 n 个元素比较 n 次。于是ASL成功 (12...n) / n (n1) / 2所以顺序查找成功情况下的时间复杂度是 O(n)。如果查找失败最坏情况下要比较 n1 次带哨兵时多比一次哨兵失败情况下的 ASL 就是 n1。这个结果说明一个事顺序查找只适合小数据量和无序数据。数据量一旦过万平均要比较几千次在要求高吞吐的系统里是很难接受的。2.3 顺序查找的真正适用场景尽管顺序查找笨但它有两个不可替代的优点第一对存储结构零要求。数组可以链表也可以。折半查找要求随机存取在链表上根本没法用但是顺序查找可以。所以单链表、无索引的流式数据想去里面找一个元素顺序查找是唯一选择。第二对数据的有序性零要求。数据不需要提前排序不破坏原数据顺序插入新元素直接加在末尾代价 O(1)。我在实际工作中遇到过一种场景一批配置项总共就几十条每次请求要根据 key 匹配一条配置。这种量级直接上顺序查找就行完全没有必要维护有序结构、写二分查找。代码简单、逻辑清晰、出 bug 的概率低本身就是性能的一部分。很多人一上来就优化其实对于这种规模for循环遍历已经是最优解了。3. 折半查找有序数据上的王牌算法3.1 使用前提和核心思想折半查找也叫二分查找、Binary Search的思路很朴素——小时候猜数字游戏都玩过1 到 100 猜一个数每次报一个数对方提示“大了”还是“小了”最聪明的玩法就是每次猜中间值。折半查找就是这套逻辑的程序化。但要注意它的严格前提条件必须采用顺序存储结构也就是数组不能是链表。因为折半查找需要根据下标直接跳到中间位置链表无法做到 O(1) 的随机访问。表中元素必须按关键字有序排列从小到大或者从大到小都行。折半查找的核心是维护搜索区间[low, high]每次把mid指向的元素和key比较相等则命中key比a[mid]小说明只可能在左半区间把high调到mid - 1否则说明在右半区间把low调到mid 1。如此反复区间不断减半直到找到或者区间为空。3.2 迭代实现的边界控制细节直接给一个经过反复验证的 C 语言实现int binarySearch(int a[], int n, int key) { int low 0, high n - 1; while (low high) { int mid low (high - low) / 2; // 等价于 (lowhigh)/2但避免溢出 if (a[mid] key) { return mid; } else if (a[mid] key) { high mid - 1; } else { low mid 1; } } return -1; }这里有几个边界设计的细节新手经常出错第一个循环条件必须是low high不能是low high。当low high时区间里还有最后一个元素没比较如果此时low high为假循环直接退出就会漏掉这个唯一的元素。第二个mid的计算不要写成(low high) / 2。当low和high都很大比如接近 INT_MAX时两者相加可能整数溢出变成负数程序直接迷路。low (high - low) / 2是安全写法这个坑在 LeetCode 讨论区里出现过无数次。第三个区间收缩必须写成high mid - 1和low mid 1不能写成high mid和low mid。如果a[mid] ! keymid这个位置已经排除掉了不需要再包含进下一个搜索区间。不排除会造成死循环。Python 版本更简洁直接参考def binary_search(arr, key): low, high 0, len(arr) - 1 while low high: mid low (high - low) // 2 if arr[mid] key: return mid elif arr[mid] key: high mid - 1 else: low mid 1 return -13.3 时间复杂度推导与判定树折半查找为什么快因为每比较一次搜索范围缩小一半。初始区间大小为 n比较 k 次后区间大小缩小到 n / 2^k。当区间缩小到 1 时停止所以最大比较次数 k 满足n / 2^k 1 k log2(n)也就是说最坏情况下只需要比较约 log2(n) 次就能锁定结果。n 100 万时顺序查找平均要比较 50 万次折半查找最坏只要 20 次。这就是算法设计的力量。如果画出折半查找过程的“判定树”你会发现它本质上就是一棵平衡二叉树根节点是第一次比较的mid左右子树分别是左右区间的查找过程。树的高度就是最大比较次数而 ASL 满足下面的近似公式ASL成功 ≈ log2(n1) - 1这个公式在 n 比较大时非常准面试里可以直接用。3.4 折半查找最容易踩的坑我在现实代码里见到过几个典型的错误单独列一下。第一个是重复元素的返回位置不稳定。如果数组里有多个相同的关键字不同的二分写法可能返回最左边的、最右边的、或者随机某一个。业务上如果要求“返回第一个出现的下标”比如统计有序数组里某个值出现的次数就需要在命中之后继续往左收缩。标准写法可以参考 Java 的lowerBound、Python 的bisect_left这些都是二分边界的变种。第二个是在链表上强行用二分。有人觉得可以用快慢指针找到链表中点模拟折半的过程。理论上可以但找中点本身就 O(n)每次递归都要找中点整体复杂度退化到 O(nlogn)还不如顺序查找。链表存数据就该用链表的查找方式比如跳表Skip List而不是把数组的算法硬套过来。第三个是动态数据维护成本没算进去。折半查找要求有序数组这本身没错但有序数组的插入和删除是 O(n) 的。如果你维护一个频繁插入、删除的有序数组每次操作都要移动大量元素整体算下来并不快。遇到这种场景要么用分块查找这种折中方案要么直接上平衡二叉树。4. 分块查找用索引换时间的折中智慧4.1 分块思路块间有序、块内无序分块查找也叫索引顺序查找这是一个非常“工程化”的思路把整个大表分成若干块块与块之间按关键字有序块内元素不要求有序。举个例子假设有一批学生成绩数据你把它分成 4 块第 1 块最大成绩是 200 分假设满分 300第 2 块最大成绩是 250 分第 3 块最大成绩是 280 分第 4 块最大成绩是 300 分。每一块的“最大成绩”构成一张索引表。现在要查找成绩为 260 的记录你可以先查索引表发现 260 在 250 和 280 之间因此只可能在第 3 块接下来只需要在第 3 块内部做顺序查找即可。这个设计的好处很明显插入新元素时只需要先通过索引找到它应该属于的块然后块内随便插因为块内无序不像有序数组那样需要大范围移动元素。这正是动态查找场景下分块查找的核心价值。4.2 索引表设计与完整查找流程分块查找的存储结构由两部分构成主表分成若干块每块内元素个数基本一致最后一块可能少一些块间按最大关键字升序排列。索引表每一项记录两块信息——本块的最大关键字、本块的起始地址数组下标。查找流程分两步走第一步在索引表中定位“目标块”。索引表本身是有序的所以这一步可以用顺序查找也可以用折半查找。第二步在目标块内部进行顺序查找。比如利用前面讲的哨兵技巧或者直接遍历。这里我写一个 C 语言简化版索引表直接用最大关键字和块下标表示#define MAX_BLOCK 100 #define BLOCK_SIZE 5 typedef struct { int maxKey; int startIndex; } BlockIndex; int blockSearch(int a[], int n, BlockIndex idx[], int blockNum, int key) { // 1. 在索引表中定位块这里用顺序查找数据多可用折半 int blockPos -1; for (int i 0; i blockNum; i) { if (key idx[i].maxKey) { blockPos i; break; } } if (blockPos -1) { return -1; // key 比所有块的最大值都大肯定不存在 } // 2. 在目标块内顺序查找 int start idx[blockPos].startIndex; int end (blockPos 1 blockNum) ? idx[blockPos 1].startIndex : n; for (int j start; j end; j) { if (a[j] key) { return j; } } return -1; }实际工程中索引表不一定只建一级。数据量特别大的时候还可以建“索引的索引”这就是多级索引的雏形数据库 B 树的思路和这个是一脉相承的。查一次大区块定位到小区块再定位到行这种层级结构在磁盘存储里特别常见。4.3 块大小怎么定ASL 最优推导分块查找的 ASL 由两部分构成ASL L索引 L块内一开始先用顺序查找索引表索引表有 b 块则查找索引的平均比较次数是(b1)/2每个块内假设每块长度 s用顺序查找平均比较次数是(s1)/2。所以总的ASL (b1)/2 (s1)/2设总元素个数为 n并且 n b × s。把这个代入公式可以做一个简单的算术ASL (b s) / 2 1 ≈ (n/s s) / 2 1要使 ASL 最小可以让 b 和 s 尽量接近。根据均值不等式当b s √n时b s取得最小值此时ASL最小 ≈ √n 1这就是为什么教科书上会说分块查找的时间复杂度是 O(√n) 的原因。这个推导一定要自己动手写一遍面试时考察的就是你对“为什么块大小取根号”的理解。如果你在索引表上用折半查找而不是顺序查找那索引部分的查找代价降为O(log b)总的 ASL 变成ASL ≈ log2(b1) - 1 (s1)/2此时最优块大小就不是 √n 了而需要重新找一个平衡点。一般而言索引表有序时优先用折半更快。4.4 分块查找在动态数据场景中的优势这是分块查找最容易被人忽略的价值点。想想折半查找的痛数组必须有序一旦插入一个元素所有后续元素都要后移维护成本 O(n)。顺序查找没有有序性要求插入倒是方便了但查找慢到没法用。分块查找把这两者做了巧妙的平衡删除元素先在索引里定位块再在块内顺序查找并删除。如果是数组存储块内数据少移动代价小如果块内用链表那删除就是真正的 O(1) 修改指针。插入元素先定位块然后直接追加到块内末尾只要块的最大关键字仍然维持有序就不需要移动任何其他数据。只有当某一块太大时才考虑“块分裂”把一块拆成两块更新索引表。我曾在某个数据导入工具里用过类似思路一批一批地写入数据每批内部不做排序但批次之间保证有序然后建一个批次索引。查询时先在批次索引里用二分定位到具体批次再到批内线性扫描。效果非常好既避免了全量排序的启动时间又让每个查询的耗时保持稳定。分块查找这个思想其实就在我们身边。比如一本书的目录就是典型的索引表加块内扫描比如图书馆按字母分区再在区域内找书也是分块查找。5. 三种查找横向对比怎么选、怎么考、怎么答5.1 存储结构与效率对比表三种方法放一起看维度差异非常清楚查找方法存储结构要求数据有序性要求平均查找长度插入删除难度顺序查找顺序表 / 链表均可无要求(n1)/2容易折半查找必须顺序表数组必须有序log2(n1)-1困难元素移动分块查找顺序表 / 链表 索引表块间有序、块内无序√n 1容易索引更新选型时的决策顺序我一般这样走如果数据不是有序的、而且不好排序那就顺序查找。如果数据几乎不变、查询极其频繁、又能承担插入删除的代价那就排序后上折半查找。如果数据量很大、又在持续做插入删除、要求查询不能太慢那就优先考虑分块查找。如果再加一层数据量达到百万千万级那线性表上的查找策略都不够了应该转向二叉树、跳表、哈希表去考虑。5.2 面试和考试中的高频问题数据结构的“查找”这块面试官和阅卷老师翻来覆去其实只问几类问题我把常见问法和答题要点列一下第一类折半查找为什么比顺序查找快但又有前提条件回答要点折半查找通过每次比较排除一半数据搜索空间指数级萎缩比较次数只与 log2(n) 成正比前提是数据有序 随机存取结构。顺序查找没有前提因为它是线性的穷举。第二类分块查找什么时候比顺序查找更值得用回答要点当数据量大并且有动态增删需求时。顺序查找的优势是每次插入 O(1)但没有索引加速分块查找本质上就是“用空间换时间”引入一个索引表将查找代价从 O(n) 压到 O(√n)同时保留了插入删除的灵活性。第三类折半查找的 ASL 怎么推导回答要点画判定树。比较次数就是树的高度最坏比较次数是 ⌊log2(n)⌋ 1平均成功查找长度约等于 log2(n1) - 1。推导过程可以说“每次比较淘汰一半对应的基本操作频度是对数级的”。第四类为什么链表不适合折半查找回答要点因为链表不支持随机存取。获取链表中点需要从头遍历一次 O(n) 访问直接让“减半”的优点荡然无存整体复杂度反升为 O(nlogn)。数据结构设计的第一原则永远是“存储结构决定算法”。第五类在实际业务中怎么决定块的大小回答要点可以用 ASL 公式推算。索引和块内都是顺序查找时取块大小 s ≈ √n 比较合适索引用折半时s 要更大一点具体要看读多写多还是写多读多写多就块小一点减少每块内部维护成本读多就块大一点降低索引遍历次数。5.3 从查找角度看数据组织方式的演化把顺序查找、折半查找、分块查找放回整个数据结构的知识体系里你会发现一条清晰的主线查找效率的每一次提升靠的都是给数据附加新的结构约束。不附加任何约束只能线性扫描 → O(n)。附加有序约束可以利用跳转快速排除 → O(logn)。附加索引约束用空间换时间 → O(√n)。再往后二叉搜索树用树形结构维持动态有序红黑树、AVL 树解决插入删除后的平衡问题哈希表直接把查找变成数学计算平均 O(1)但不支持范围查询跳表用多级索引做概率平衡这其实就是分块查找的多级化延伸。这也是“数据结构 算法”这门课听起来简单、实际很深的原因。每一种查找方法都对应一类存储形态选不选得好决定的是系统在大规模数据下的生死。明白这条演化主线之后你再去看数据库的索引原理、Redis 的跳表实现、HashMap 的哈希设计都会有一种“原来如此”的通透感。6. 实操中的经验与避坑清单6.1 我在工程和考试中踩过的具体坑第一个坑是二分查找溢出。早年我在一个后台服务里写了个二分直接用(left right) / 2结果当数组长度超过几千万时left 和 right 相加直接超过 int 上限mid 变成负数循环直接死循环。当时排查了很久最后发现是这个婚礼现场级别的低级错误。后来我所有二分都一律写left (right - left) / 2这个习惯建议大家直接养成。第二个坑是哨兵把原数据改了。有次我图省事直接在一个已经初始化好的数组上做顺序查找用了哨兵法把a[0]覆盖了。查询结束后忘记恢复后续逻辑读到a[0]时数据已经变了导致一个非常隐蔽的 bug。用哨兵之前一定要确认这个位置可以覆盖或者干脆把哨兵放在数组末尾并做好空间预留不要在函数内部直接污染调用方的数据。第三个坑是重复元素问题。产品需求是“返回第一个等于 key 的索引”我一开始直接套标准的非递归折半结果遇到大量重复值时返回了随机的一个位置。后来改写成类似bisect_left的版本命中后继续把 high 往左收缩才符合预期。所以涉及重复元素时先想清楚到底要哪个位置再动手写。第四个坑是块大小凭感觉定。分块查找里索引表块大小直接决定性能但我见过很多人拍脑袋定了一个大块结果索引表查起来很快块内扫描慢到爆炸。更合理的做法是拿出来运行数据统计一下查询频率和插入频率按 ASL 公式估算一个初始值再根据压测结果调整。6.2 工程化建议什么时候用什么查找给一个相对通用的选择参考数据量小几百条以内且无序直接用顺序查找代码最好维护。数据量中等几千到几十万且基本不变排序后上折半查找。数据量大、插入删除频繁、但查询不能太慢优先分块查找索引表必要时用折半。数据量百万级以上、追求极致查询速度考虑哈希表和平衡搜索树。数据需要范围查询不要用哈希用有序结构二分、跳表、B 树。工程上还有一个容易忽略的问题是数据局部性。数组是连续内存顺序查找和折半查找对 CPU 缓存非常友好链表或者索引结构跳来跳去缓存命中率低实际耗时比理论模型高很多。所以就算理论上 O(logn) 的算法在小规模数据上也可能打不过 O(n) 的线性扫描——这就是为什么很多库函数在小数组里直接用线性查找比如 Java 的Arrays.binarySearch对小数组排序时采用插入排序而不是快排思路是一样的常数小也是一种优势。6.3 从线性查找延伸到复杂查找如果你已经把三种查找吃透了下一个值得挑战的点是哈希表和二叉搜索树。哈希表的核心是“通过一个函数直接算出存储位置”平均 O(1)但是有哈希冲突、扩容等问题二叉搜索树在动态有序场景下几乎是万能解但退化风险需要平衡树来解决。这些内容又是另一个大话题了等你有空再展开。我个人在实际学习和工程里体会最深的一点是算法题不是背出来的是推出来的。折半查找的边界条件、分块查找的块大小推导只要你愿意在纸上多画几棵树、多写几组数据很快就内化成自己的东西了。反之如果只看不练过几天必忘。最后再分享一个小技巧面试或者考试前把三种查找的 ASL 推导过程、代码实现、适用场景各写一遍然后问自己三个问题——“为什么这个算法能快”“它牺牲了什么”“如果数据变了它还会快吗”这三个问题答清楚了你就真的懂了。