资讯动态

数据结构查找全解析:从折半查找到B+树与散列表的选型指南

发布时间:2026/10/20 18:56:25 来源:尧图企业网站定制
1. 为什么查找这一章总让人学完就忘很多人学《数据结构》到第七章查找的时候都会有一种奇怪的感觉前面线性表、树、图都啃下来了偏偏这一章看起来最简单却最容易学成一锅粥。翻开书顺序查找、折半查找、分块查找、二叉排序树、平衡二叉树、B树、B树、散列表一堆名词堆在一起每个都好像会合上书又说不清楚它们之间到底什么关系。我自己当年第一次学这一章的时候就是典型的背了公式忘了场景。折半查找的判定树能画出来ASL平均查找长度公式能默写但真让我说为什么有了二叉排序树还要搞平衡二叉树为什么数据库索引偏爱B树而不是红黑树我答不上来。后来做项目、带新人、反复回头啃这一章才慢慢把这条线捋顺。这一章的核心其实就一句话在什么样的数据组织方式下用什么策略去定位一个目标记录代价最小。所有的查找结构本质上都是在查找速度和维护成本插入、删除、空间之间做权衡。你把这个权衡想明白了整章就活了。这篇总结我打算按从业者复盘的方式来写不照搬教材目录而是把这一章拆成几条真正能记住的线索静态查找和动态查找的分野、有序性带来的折半思想、树形结构如何把查找压到对数级、散列为什么能做到接近常数级、以及B树家族为什么统治了磁盘存储场景。每一块我都会补上教材里往往一笔带过、但实际理解时最卡人的细节比如判定树怎么画、ASL怎么算、平衡旋转怎么判断、散列冲突到底怎么处理。适合谁看如果你正在准备考试、需要把这一章的知识点串成体系或者你工作几年了想回头把基础补扎实这篇都合适。我会尽量用生活化的类比把抽象概念讲透同时保留必要的公式和推导让你既能应付笔试也能在真实工程里判断这里到底该用哪种查找结构。2. 静态查找与动态查找先分清你面对的是哪种问题2.1 一条被很多人忽略的分界线教材开篇通常会给查找下一个定义在数据集合中寻找满足某种条件的数据元素。但真正决定你该选哪种查找方法的不是查找这个动作本身而是这个数据集合会不会变。这就是静态查找表和动态查找表的分界线。静态查找表的特点是数据集合建好之后主要操作就是查偶尔才整体重建插入和删除极少甚至没有。典型场景比如一份已经定稿的字典、一张配置表、一个历史归档的日志索引。这种情况下你可以放心地为了查得快去付出一次性的预处理代价比如排序、建索引。动态查找表则相反查找、插入、删除是混在一起高频发生的。比如一个实时维护的用户在线表、一个不断增删的订单索引。这时候你不能只顾查找快还得考虑插入删除之后结构会不会退化、维护成本高不高。我见过不少初学者一上来就背折半查找效率高结果在一个频繁插入的场景里硬用有序数组加折半查找每次插入都要移动大量元素整体性能反而惨不忍睹。这就是没分清静态和动态的后果。2.2 平均查找长度ASL衡量查找效率的统一尺子要比较不同查找方法得有个统一的度量。这个度量就是平均查找长度Average Search LengthASL定义为查找过程中关键字的比较次数的期望值ASL Σ (Pi × Ci)其中 Pi 是查找第 i 个元素的概率Ci 是找到第 i 个元素所需的比较次数。当所有元素等概率查找时Pi 1/n公式简化为 ASL (1/n) × Σ Ci。这里有个特别容易踩的坑查找成功和查找失败的ASL是分开算的。很多题目只问查找成功的ASL但实际系统里查找失败也就是查一个不存在的值同样消耗资源尤其是散列表和树结构失败路径的长度往往和成功路径不一样。算ASL的时候一定要看清楚题目问的是成功还是失败两者公式里的Ci含义不同。举个具体例子。对n个元素做顺序查找查找成功时第i个元素需要比较i次所以 ASL成功 (1/n)(12...n) (n1)/2。查找失败时无论查什么不存在的值都要比较n次才能确认没有所以 ASL失败 n。你看成功和失败差了将近一倍这个区别在评估真实系统性能时非常关键。2.3 三种静态查找方法的横向对比把静态查找的三种基本方法放在一起看逻辑会清楚很多。查找方法前提条件查找成功ASL查找失败ASL主要代价顺序查找无(n1)/2n查找慢但插入删除方便折半查找有序顺序存储约log₂(n1)-1约log₂(n1)查找快但插入删除要移动元素分块查找块间有序、块内无序索引块内查找类似折中方案兼顾查找和插入顺序查找的优点是百搭对数据没有任何要求链表也能用。缺点是n大了就线性变慢。折半查找把复杂度压到对数级但代价是必须有序且顺序存储插入删除要挪元素。分块查找是个聪明的折中把数据分成若干块块之间有序第一块的最大值小于第二块的最小值块内可以无序。查找时先用索引定位到块再在块内顺序查找。分块查找的ASL可以拆成两部分ASL L索引 L块内。假设n个元素分成b块每块s个元素n b×s用顺序查找定位索引块则 ASL ≈ (b1)/2 (s1)/2 (n/s s)/2 1。这个式子对s求最小值当 s √n 时ASL最小约为 √n 1。这就是分块查找最优块大小是√n这个结论的由来考试常考但很多人只记结论不理解推导稍微变个问法就懵。提示分块查找的索引表本身也可以有序如果对索引表再用折半查找ASL还能进一步降到 log₂b s/2 量级。教材里默认索引表用顺序查找但实际工程里索引表通常很小用折半甚至直接哈希都行。3. 折半查找判定树才是理解它的钥匙3.1 折半查找的执行逻辑与前提折半查找也叫二分查找的思想非常直观每次拿中间位置的元素和目标比较相等就命中目标小就往左半边找目标大就往右半边找每次排除一半。前提是数据必须有序且顺序存储能随机访问下标。为什么必须顺序存储因为折半查找每次都要跳到中间位置链表做不到O(1)定位中间节点。为什么必须有序因为往左还是往右这个决策完全依赖有序性。这两个前提缺一不可考试里经常出链表能不能折半查找这种判断题答案是不能。代码层面折半查找有两种常见写法一种是递归一种是迭代。迭代版本更常用也更省栈空间def binary_search(arr, target): low, high 0, len(arr) - 1 while low high: mid (low high) // 2 if arr[mid] target: return mid elif arr[mid] target: low mid 1 else: high mid - 1 return -1这段代码看着简单但边界条件极其容易写错。low high还是low highmid 1还是mid这些细节决定了会不会死循环、会不会漏掉边界元素。我个人的经验是统一用闭区间 [low, high] 的写法循环条件是 low high更新时 low mid 1、high mid - 1这样不容易出错。开区间写法虽然也能对但边界处理更绕新手容易翻车。3.2 判定树把查找过程画成一棵树折半查找真正的精髓在于判定树也叫比较树。把每次比较的中间元素作为根左子树对应左半区间右子树对应右半区间递归画下去就得到一棵二叉判定树。这棵树有几个非常重要的性质理解了它们ASL的计算就是送分题判定树是一棵二叉排序树中序遍历得到的就是有序序列。树中每个圆形节点代表一次成功的比较即数组中的元素方形节点外部节点/失败节点代表查找失败的位置。查找某个元素所需的比较次数等于该节点在树中的层数。查找失败所需的比较次数等于对应失败节点父节点的层数或到外部节点的路径长度。对于n个元素的折半查找判定树的高度约为 ⌈log₂(n1)⌉。当n 2^h - 1满二叉树时树高为h此时查找成功的ASL (1/n) × Σ(层数×该层节点数)。以n7为例判定树是三层满二叉树第一层1个节点比较1次第二层2个节点比较2次第三层4个节点比较3次ASL成功 (1×1 2×2 3×4)/7 17/7 ≈ 2.43。3.3 折半查找ASL的近似推导当n很大时判定树近似一棵满二叉树可以推导出ASL的近似公式。设树高 h log₂(n1)则ASL成功 ≈ log₂(n1) - 1这个结论考试常直接考。比如n1000时ASL成功 ≈ log₂1001 - 1 ≈ 10 - 1 9也就是说平均比较不到10次就能在1000个元素里找到目标而顺序查找平均要500次。这就是对数级和线性级的巨大差距。查找失败的ASL略大一些约为 log₂(n1)因为失败路径要走到底部的外部节点。注意折半查找的ASL公式只在n较大、判定树接近满二叉树时才是精确的近似。n较小时比如n2、n3判定树形状不规则必须老老实实画树数层数不能直接套公式。这是考试里最常见的失分点之一。3.4 折半查找在工程里的真实位置折半查找看起来很美但工程里用的时候有几个现实约束。第一它要求数据有序而维护有序的代价在动态场景下很高。第二它对缓存不友好——每次跳到一个新位置可能触发一次缓存未命中实际性能未必比顺序扫描好。第三它只适合等值查找和范围查找的简单场景复杂条件查询用不上。所以真实系统里折半查找更多出现在静态有序数据的场景比如配置文件里的有序键值、编译期常量表。一旦数据是动态的大家更倾向于用树结构或散列表。这也自然引出了下一节动态查找为什么要用树。4. 二叉排序树与平衡二叉树动态查找的第一次进化4.1 二叉排序树让插入和查找共存静态查找的痛点是插入删除要挪元素。二叉排序树Binary Sort Tree也叫二叉查找树BST的思路是用链式的树结构存有序数据查找时像折半一样往左往右走插入删除时只改指针不挪元素。二叉排序树的定义很简单左子树上所有节点的值都小于根右子树上所有节点的值都大于根左右子树本身也是二叉排序树。查找时从根开始比根小往左走比根大往右走相等就命中。它的查找效率取决于树的形状。理想情况下树是平衡的高度为 log₂n查找复杂度O(log n)。但问题来了如果插入的序列本身有序比如依次插入1、2、3、4、5树会退化成一条右斜的链高度变成n查找退化成O(n)和顺序查找没区别。这就是二叉排序树最大的软肋它的性能完全取决于输入顺序而输入顺序往往不可控。我见过一个真实案例某系统用BST存按时间递增的日志ID结果树退化成链表查询越来越慢最后不得不重构。这个坑的本质就是没考虑有序输入导致退化。4.2 平衡二叉树AVL用旋转换稳定为了解决退化问题平衡二叉树AVL树登场了。AVL树在BST的基础上加了一个约束任意节点的左右子树高度差平衡因子的绝对值不超过1。平衡因子 左子树高度 - 右子树高度取值只能是-1、0、1。一旦插入或删除导致某个节点的平衡因子变成±2就要通过旋转来恢复平衡。旋转分四种情况LL型左子树的左子树插入导致失衡做一次右旋。RR型右子树的右子树插入导致失衡做一次左旋。LR型左子树的右子树插入导致失衡先对左子树左旋再对根右旋。RL型右子树的左子树插入导致失衡先对右子树右旋再对根左旋。判断旋转类型有个口诀看失衡节点到新插入节点的那条路径前两步的方向决定类型。比如路径是左、左就是LL左、右就是LR以此类推。这个口诀比死记四种情况靠谱得多。旋转之后AVL树的高度始终保持在 O(log n)所以查找、插入、删除都是 O(log n)。代价是每次插入删除都可能触发旋转维护成本比普通BST高。4.3 一道旋转题的完整推演光说理论容易飘我们走一个具体例子。假设依次插入关键字 30、20、40、10、25、35、50再插入 22观察AVL树的变化。前七个插入后树是平衡的根30左20左10、右25右40左35、右50。此时插入2222比30小往左比20大往右比25小往左挂在25的左孩子上。这时节点25的平衡因子变成1左高节点20的平衡因子变成-1右高节点30的平衡因子变成1左高都还在范围内没有失衡。但如果继续插入一个让某节点失衡的值就要旋转。假设我们在上述基础上插入55挂在10的左边导致10的平衡因子变120的平衡因子变2左子树高度3右子树高度1失衡。从20到5的路径是左到10、左到5属于LL型对20做一次右旋。旋转后10上升为子树根左孩子5右孩子2020的右孩子25恢复平衡。这个推演过程建议你亲手在纸上画一遍比看十遍文字都管用。旋转的本质是把中间节点提上来当根两边的节点重新分配理解了这一点四种旋转就是同一件事的不同方向。4.4 AVL树的工程取舍AVL树查找效率极高但它的平衡条件太严格高度差不超过1导致插入删除时旋转频繁。在写多读少的场景里AVL的维护开销可能得不偿失。所以工程里更常见的是红黑树——它放宽了平衡条件最长路径不超过最短路径的两倍旋转次数更少综合性能更好。很多语言的标准库有序容器比如某些Map实现底层就是红黑树。这里要建立一个认知没有绝对最优的查找结构只有最适合当前读写比例的。读多写少AVL或红黑树都行写特别多可能跳表或散列更合适。这个判断力比记住某个结构的定义重要得多。5. B树与B树当数据大到内存装不下5.1 从内存查找走向磁盘查找前面讲的折半、BST、AVL都默认数据在内存里访问任何一个节点代价差不多。但真实系统里数据量一大就得放磁盘。磁盘访问和内存访问的代价差了不止一个数量级——内存访问是纳秒级磁盘随机访问是毫秒级差了大约十万倍。这个差距彻底改变了查找结构的设计目标。在内存里我们关心比较次数在磁盘上我们关心磁盘I/O次数也就是树的高度。因为每往下走一层就可能是一次磁盘读取。所以磁盘上的查找结构核心目标变成了降低树高。怎么降树高让每个节点存更多的关键字分出更多的叉。这就是多路查找树B树和B树都是它的代表。5.2 B树的定义与查找过程一棵m阶B树满足以下条件每个节点最多有m棵子树m-1个关键字。根节点至少有2棵子树除非它本身就是叶子。除根和叶子外每个节点至少有 ⌈m/2⌉ 棵子树。所有叶子节点在同一层不带信息可以看作查找失败的外部节点。节点内的关键字有序排列且满足子树i的所有关键字 关键字i 子树i1的所有关键字。查找时从根节点开始在节点内部用顺序或折半查找定位关键字命中就返回没命中就沿着对应的子树指针往下走直到叶子。B树的关键在于矮胖一个节点能存几百个关键字树高通常只有3到4层意味着查找一个记录最多三四次磁盘I/O。这就是它统治磁盘存储的根本原因。5.3 B树为什么数据库索引偏爱它B树是B树的变体区别主要有三点所有数据都出现在叶子节点非叶子节点只存索引关键字不存实际数据。叶子节点之间用指针串成有序链表支持高效的范围查询。非叶子节点的关键字会在叶子中重复出现作为路标。这三点差异带来了两个决定性优势。第一非叶子节点不存数据同样大小的磁盘块能存更多关键字树更矮I/O更少。第二叶子链表让范围查询比如查所有年龄在20到30之间的用户变得极其高效——定位到起点后顺着链表扫就行不用反复回到上层。这就是为什么主流关系型数据库的索引几乎都用B树。等值查询它不输B树范围查询它碾压B树而范围查询恰恰是数据库最常用的操作之一。对比项B树B树数据存放位置所有节点都可能存数据只在叶子节点存数据叶子节点连接无有链表连接范围查询需要中序遍历效率低顺着叶子链表扫效率高树高相对较高相对较矮典型应用文件系统数据库索引5.4 B树插入删除的分裂与合并B树的插入删除比BST复杂因为要维护节点关键字数量在 [⌈m/2⌉-1, m-1] 之间这个约束。插入时先找到应该插入的叶子节点直接插入。如果插入后节点关键字数超过m-1就要分裂把节点从中间劈开中间关键字上移到父节点左右两半成为两个新节点。如果父节点也因此超限继续向上分裂最坏情况会一直裂到根此时树高加一。删除时如果删除后节点关键字数低于下限就要借位或合并先看兄弟节点有没有多余关键字有就借一个过来同时调整父节点的分隔关键字兄弟也紧张就和兄弟合并父节点少一个关键字可能继续向上合并。这套机制保证了B树始终平衡所有叶子在同一层。理解分裂和合并的最好方式还是画图推演建议找一道3阶B树依次插入若干关键字的题一步步画出来比看定义强十倍。提示考试里B树的阶m和节点关键字数量的关系经常被搞混。记住m阶B树节点最多m-1个关键字、m棵子树最少⌈m/2⌉-1个关键字、⌈m/2⌉棵子树根除外。这个最多减一、最少取半减一的规律要刻在脑子里。6. 散列表用空间换时间的极致6.1 散列的核心思想与哈希函数前面所有查找方法本质上都依赖比较——通过一次次比较缩小范围。散列表哈希表跳出了这个框架它通过一个哈希函数直接计算出关键字对应的存储位置理想情况下一次就能定位不需要比较。哈希函数把关键字映射到地址空间addr H(key)。设计哈希函数的目标是让关键字均匀分布减少冲突。常见方法有除留余数法H(key) key mod pp通常取小于表长的最大质数。这是最常用的方法因为简单且分布较均匀。直接定址法H(key) a×key b适合关键字连续的情况。数字分析法分析关键字各位的分布取分布均匀的几位作为地址。平方取中法取key平方后的中间几位。为什么除留余数法的p要取质数因为如果p有小的因子那么关键字中能被该因子整除的部分会集中映射到某些地址造成分布不均。取质数能最大程度避免这种规律性冲突。这个细节教材往往一笔带过但它是理解为什么这样设计的关键。6.2 冲突处理开放定址与链地址哈希冲突是不可避免的——不同的key可能算出同一个地址。处理冲突主要有两大类方法。开放定址法发生冲突时按某种探测序列找下一个空位。常见的有线性探测挨个往后找、二次探测按平方跳跃、双重散列用第二个哈希函数决定步长。线性探测简单但容易产生堆积——连续一片位置被占满后续查找要探测很长。二次探测和双重散列能缓解堆积但实现稍复杂。链地址法把所有哈希到同一地址的元素串成一个链表数组的每个槽存一个链表头。这种方法不会堆积删除也方便是工程里最常用的方案。很多语言的标准库哈希表底层就是链地址法或它的变体比如链表过长时转成红黑树。冲突处理方法优点缺点适用场景线性探测实现简单、缓存友好易堆积、删除麻烦装填因子低、数据量小二次探测缓解堆积可能探测不到空位中等规模双重散列分布均匀计算开销大对性能要求高链地址法删除方便、不易堆积有指针开销通用场景、工程首选6.3 装填因子与ASL的关系散列表的性能高度依赖装填因子 α 表中元素数 / 表长。α越大表越满冲突越多查找越慢。α越小冲突越少但空间浪费越多。以线性探测为例查找成功的ASL约为 (1/2)(1 1/(1-α))查找失败的ASL约为 (1/2)(1 1/(1-α)²)。当α0.5时成功ASL约1.5当α0.9时成功ASL飙升到约5.5。你看装填因子从0.5涨到0.9查找代价涨了近四倍。这就是为什么工程里哈希表通常会把装填因子控制在0.75左右超过就扩容。链地址法在α1时成功ASL约为 1 α/2性能比开放定址更平稳这也是它更受欢迎的原因之一。注意散列表的ASL公式都是期望值前提是哈希函数均匀、关键字等概率。实际性能还受哈希函数质量、关键字分布影响。工程里评估哈希表性能最靠谱的办法是压测而不是套公式。6.4 散列表的工程实践心得散列表用起来爽但有几个坑必须知道。第一哈希函数要选好尤其是自定义对象做key时要保证相等的对象哈希值相同、不相等的对象哈希值尽量分散。第二扩容有代价扩容时要重新哈希所有元素如果表很大这个操作可能造成明显卡顿所以很多实现采用渐进式扩容。第三散列表不保证顺序需要有序遍历的场景不能用它。还有一个容易被忽略的点散列表的O(1)是平均复杂度最坏情况所有key都冲突会退化成O(n)。虽然概率极低但在安全敏感场景下攻击者可能构造大量冲突的key来拖垮系统这就是所谓的哈希碰撞攻击。防御办法是给哈希函数加随机种子让攻击者无法预测。7. 把整章串起来一张选型决策图学完这一章最重要的不是记住每个结构的定义而是建立面对一个查找需求我该选哪个的判断力。我把这个判断逻辑整理成几条经验规则。第一先看数据是静态还是动态。静态且有序折半查找静态且无序顺序查找或先排序再折半动态考虑树或散列。第二看数据规模。小规模几百以内顺序查找就够了别过度设计。中等规模且内存装得下BST/AVL/红黑树或散列表。大规模且要落磁盘B树/B树。第三看操作类型。只要等值查找散列表最快。需要范围查询或有序遍历树结构更合适。读写比例悬殊时读多选平衡树写多考虑散列或跳表。第四看对最坏情况的要求。散列表平均快但最坏慢平衡树能保证最坏也是O(log n)。实时系统、安全敏感场景更看重最坏情况倾向树结构。需求特征推荐结构理由静态、有序、内存折半查找对数级查找实现简单动态、内存、需有序平衡二叉树/红黑树查找插入删除都O(log n)动态、内存、只要等值散列表平均O(1)最快海量、磁盘存储B树树矮I/O少支持范围查询数据量小顺序查找简单无维护成本这张表不是死规矩而是思考的起点。真实工程里往往是多种结构组合使用比如数据库里B树做索引、哈希做等值加速、跳表做有序集合。理解每种结构的本质权衡才能在组合时做出正确判断。8. 几个反复踩的坑和应试技巧最后分享几个我在学习和带人过程中反复见到的坑以及对应的破解办法。坑一ASL算错成功和失败。破解办法是每次算ASL前先问自己题目问的是成功还是失败然后明确Ci的定义。成功时Ci是找到目标节点的比较次数失败时Ci是走到外部节点的比较次数。坑二判定树画错。折半查找的判定树中间节点取的是 mid (lowhigh)//2不同取法向上取整还是向下取整会得到不同的树。考试时按题目或教材的约定来别自己发明。画树时从整个区间开始逐层二分标清每个节点的比较次数。坑三AVL旋转类型判断错。记住从失衡节点到新插入节点的路径前两步方向定类型。LL右旋、RR左旋、LR先左后右、RL先右后左。多画几遍就形成肌肉记忆了。坑四B树阶数和关键字数搞混。m阶B树节点关键字数范围是 [⌈m/2⌉-1, m-1]。分裂时中间关键字上移左右各分一半。删除时先借后合并。这些操作建议手动画三遍以上。坑五散列装填因子和ASL公式记混。线性探测和链地址的公式不一样别混用。记不住就记趋势装填因子越大ASL越大且开放定址比链地址恶化得更快。坑六以为散列表永远O(1)。最坏情况是O(n)工程里要防哈希碰撞攻击考试里要会算最坏情况的ASL。应试之外我个人最大的体会是这一章的知识点看似零散其实都围绕查找代价这一个中心。你把每个结构想象成一种用不同的组织方式降低查找代价的方案理解它们各自的取舍整章就再也不会学成一锅粥了。真正拉开差距的不是背下多少公式而是能不能在具体场景里说出为什么选它、为什么不选另一个。这个能力考试有用工作更有用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑