严蔚敏数据结构第八章查找习题精解:从折半查找到哈希表实战
1. 为什么你需要这份习题答案:从“会做题”到“会思考”的跨越
如果你正在啃严蔚敏老师的《数据结构(C语言版)》,并且翻到了第八章,那你大概率正处在一个关键的爬坡期。这本书的习题,尤其是第八章的题目,向来以“概念深、综合性强、代码实现细节多”著称。很多同学拿到题目,要么感觉无从下手,要么写出的代码漏洞百出,调试半天也找不到原因。这时候,一份靠谱的习题答案,其价值远不止于“对答案”那么简单。它更像是一位经验丰富的“陪练”,在你独立思考和尝试之后,为你提供解题思路的验证、代码实现的参考,以及更重要的——帮你理解题目背后数据结构设计的精妙之处,完成从“看懂书”到“会做题”,再到“会思考”的质变。
第八章“查找”,是整个数据结构课程中承上启下的核心章节。它不像线性表、栈队列那样直观,也不如图、排序那样复杂,但它将前面学到的线性结构、树形结构(如二叉排序树)与算法效率分析(时间复杂度、空间复杂度)紧密结合,是检验你是否真正理解数据结构“为何存在”以及“如何应用”的试金石。本章的习题,往往不是让你简单调用一个search函数,而是要求你深入理解顺序查找、折半查找、分块查找、二叉排序树、平衡二叉树(AVL树)、B树、B+树以及哈希表等各种查找结构的内在逻辑、适用场景、性能边界和实现细节。
因此,这份“最详细”的答案,其目标不是给你一个可以照抄的代码文本,而是致力于为你拆解每一道题目的设计意图、核心考点、实现难点以及常见的思维陷阱。我会假设你已经对课本内容有了基本了解,但可能在将理论转化为代码、处理边界条件、进行效率优化时遇到了障碍。接下来,我们将不按简单的题号顺序罗列,而是根据知识模块和难度进阶,重新组织这些习题,带你进行一场深度的“查找”专题训练。
2. 静态查找表:算法思想与效率分析的基石
第八章开篇的习题多围绕静态查找表(即查找过程中数据集合不变)展开,重点是吃透不同查找算法的思想,并能够严谨地分析其性能。这是后续学习更复杂动态查找结构的基础。
2.1 折半查找的递归与非递归实现对比
课本介绍了折半查找的非递归算法,但习题中常要求实现其递归版本,并比较两者优劣。
题目示例:编写折半查找的递归算法。
核心考点:递归思想的运用、函数参数的设计(需要传递查找区间的上下界)、递归终止条件的准确把握。
详细答案与解析:
// 假设数据存储在整型数组 a 中,下标从 1 到 n(与严蔚敏书中约定一致),key 为待查找值 int BinSearch_Recursive(int a[], int low, int high, int key) { // 递归终止条件:查找区间无效 if (low > high) { return 0; // 返回 0 表示查找失败(书中常用 0 作为无效下标) } int mid = (low + high) / 2; // 计算中间位置 if (key == a[mid]) { return mid; // 查找成功,返回元素位置 } else if (key < a[mid]) { // 关键点:在左半区间继续查找,区间变为 [low, mid-1] return BinSearch_Recursive(a, low, mid - 1, key); } else { // 在右半区间继续查找,区间变为 [mid+1, high] return BinSearch_Recursive(a, mid + 1, high, key); } }调用方式:int pos = BinSearch_Recursive(a, 1, n, key);
为什么这样设计?
- 参数
low和high:这是递归的核心。每一次递归调用,查找区间都在缩小,必须通过参数明确传递当前的区间范围。如果只传数组和key,递归将无法进行。 - 终止条件
low > high:这表示搜索区间已为空,没有元素可查。这是折半查找失败的唯一标准条件,必须放在函数开头进行判断。 - 时间复杂度与空间复杂度分析:
- 时间复杂度:与非递归版本一样,都是O(log₂n)。因为每次递归都将问题规模减半。
- 空间复杂度:递归版本需要系统栈来保存每一层递归的调用信息(参数、返回地址等)。在最坏情况下(查找失败到最深层),递归深度为树的高度,即O(log₂n)。这是与非递归版本(空间复杂度O(1))的主要区别。对于极大的
n,递归可能导致栈溢出,而非递归版本则无此风险。
- 实战心得:在真正写代码时,要特别注意
mid的计算是否会导致整数溢出。当low和high都是很大的整数时,(low + high)可能会超出整型范围。更安全的写法是:int mid = low + (high - low) / 2;。虽然课本习题数据量通常不大,但养成这个习惯在工程实践中至关重要。
2.2 判定树与平均查找长度(ASL)的计算
这是必考的理论计算题,要求根据给定的查找算法或数据特性,画出判定树,并计算成功和不成功情况下的平均查找长度。
题目示例:对长度为 n 的有序表进行折半查找,试求其成功和不成功时的平均查找长度(分别用 ASLsucc 和 ASLunsucc 表示)。
核心考点:判定树的概念、二叉树的性质、查找长度定义的理解。
详细答案与解析: 折半查找的判定树是一棵平衡二叉树(注意,不是AVL树,但形态上是平衡的)。对于有 n 个结点的判定树,树高 h = ⌈log₂(n+1)⌉。
成功ASL (ASLsucc):等于每个结点查找成功所需的比较次数(即该结点在树中的层数)乘以该结点被查找的概率,然后对所有结点求和。假设每个元素被查找的概率相等(均为 1/n)。
- 第 i 层上的结点数最多为 2^(i-1) 个。
- 查找第 i 层上的结点恰好需要 i 次比较。
- 因此,ASLsucc ≈ (1/n) * Σ( i * 第 i 层结点数 )。当 n 很大时,可以近似为ASLsucc ≈ log₂(n+1) - 1。更精确的公式需要根据 n 的具体值构造判定树后累加计算,课本中有详细推导。
不成功ASL (ASLunsucc):折半查找失败的过程,最终会停留在判定树的空指针域(即叶子结点的子结点)上。这些空指针域有 n+1 个。假设失败落在每个空指针域的概率相等。
- 需要计算从根结点到每个失败结点(空指针域)的路径长度(比较次数)。
- 对于树高为 h 的判定树,失败结点只可能出现在第 h 层或第 h-1 层。
- ASLunsucc = (1/(n+1)) * Σ( 失败结点的父结点所在层数 )。通常,ASLunsucc 也近似于 O(log₂n)。
避坑指南:
- 混淆结点:一定要区分“数据结点”和“失败结点”。失败结点不是实际存在的数据,而是查找路径的终点。
- 概率假设:计算ASL的前提是“等概率”查找。如果题目未说明,通常默认为等概率。若概率不等,则需要使用加权求和。
- 实战技巧:对于具体的 n(比如12),最快的方法是亲手画出一棵对应的判定树,在结点上标出对应的数组下标(或值),在空指针处标出失败区间。然后数一数每一层有几个结点/失败点,直接套公式计算。这个过程能极大地加深你对折半查找过程的理解。
3. 动态查找结构:二叉排序树与平衡化实战
从二叉排序树(BST)开始,查找结构进入“动态”领域,即查找过程中可以方便地插入和删除元素。这里的习题开始涉及复杂的指针操作和树形结构的维护。
3.1 二叉排序树的插入、删除与性能分析
题目示例:编写算法,在二叉排序树中查找值为 key 的结点,并删除之。
核心考点:二叉排序树的性质、删除操作的三种情况分类讨论、指针修改的细节。
详细答案与解析: 删除二叉排序树中的结点,是本章的难点之一,必须分情况处理:
- 待删除结点是叶子结点:直接将其父结点对应的指针置为NULL,然后释放该结点。
- 待删除结点只有左子树或只有右子树:让其父结点指向它的左孩子或右孩子,然后释放该结点。这相当于“绕过”了该结点。
- 待删除结点既有左子树又有右子树:这是最复杂的情况。为了保证删除后仍保持二叉排序树的性质,需要找到该结点的直接前驱或直接后继来替代它。
- 直接前驱:是其左子树中的最大结点(即左子树中最右下角的结点)。
- 直接后继:是其右子树中的最小结点(即右子树中最左下角的结点)。
- 通用策略:常用直接前驱替代。即用直接前驱的值覆盖待删除结点的值,然后问题转化为删除那个直接前驱结点。由于直接前驱结点至多只有一个左孩子(因为它已经是最大的了),所以删除它又回到了情况1或情况2,变得简单。
代码框架与关键点:
typedef struct BSTNode { int data; struct BSTNode *lchild, *rchild; } BSTNode, *BSTree; // 删除二叉排序树中值为key的结点 Status DeleteBST(BSTree *T, int key) { if (!*T) return FALSE; // 树空或未找到 else { if (key == (*T)->data) { // 找到,执行删除操作 return DeleteNode(T); } else if (key < (*T)->data) { // 在左子树中继续查找 return DeleteBST(&(*T)->lchild, key); } else { // 在右子树中继续查找 return DeleteBST(&(*T)->rchild, key); } } } // 删除结点p,并重接其左右子树 Status DeleteNode(BSTree *p) { BSTree q, s; if (!(*p)->lchild && !(*p)->rchild) { // 情况1:叶子结点 free(*p); *p = NULL; } else if (!(*p)->rchild) { // 情况2:只有左子树 q = *p; *p = (*p)->lchild; free(q); } else if (!(*p)->lchild) { // 情况2:只有右子树 q = *p; *p = (*p)->rchild; free(q); } else { // 情况3:左右子树均存在 // 寻找直接前驱:即左子树的最右下结点 q = *p; s = (*p)->lchild; while (s->rchild) { q = s; // q记录s的父结点 s = s->rchild; } // 此时s指向被删结点的直接前驱 (*p)->data = s->data; // 用前驱的值覆盖待删除结点的值 if (q != *p) { // 说明直接前驱不是待删结点的直接左孩子 q->rchild = s->lchild; // 重接q的右子树 } else { // 说明直接前驱就是待删结点的左孩子(即左孩子没有右子树) q->lchild = s->lchild; // 重接q的左子树 } free(s); } return TRUE; }为什么指针参数是BSTree *T?因为删除操作可能需要修改根结点的指针(例如删除根结点本身)。使用二级指针(或C++中的引用BSTree &T)可以在函数内部直接修改调用者传来的树指针。这是C语言处理这类问题的常见技巧。
性能分析与实战心得: 二叉排序树的查找、插入、删除操作的时间复杂度,高度依赖于树的形态。在最好情况(树完全平衡)下,时间复杂度为O(log n)。但在最坏情况(树退化成一条链,例如插入有序序列)下,时间复杂度会退化到O(n)。这正是引入平衡二叉树(AVL树)的根本原因。在习题中,如果要求你分析一组特定序列构成的BST的性能,一定要先画出这棵树,直观判断其平衡程度。
3.2 平衡二叉树(AVL树)的旋转操作理解
AVL树是BST的优化,通过旋转操作保持树的平衡。习题通常要求画出插入/删除某个结点后,AVL树如何通过旋转重新平衡。
题目示例:依次将关键字序列 {16, 3, 7, 11, 9, 26, 18, 14, 15} 插入到一棵初始为空的AVL树中,画出每插入一个关键字后的AVL树形态。
核心考点:平衡因子的计算、四种旋转类型(LL, RR, LR, RL)的判断与实现。
详细答案与解析: 这道题是经典的AVL树构建练习。解题的关键在于每插入一个结点后,从该结点向根回溯,找到第一个失去平衡的祖先结点(记为A),然后根据A的平衡因子以及导致不平衡的插入位置,判断旋转类型。
旋转类型判断口诀:
- LL型(右单旋):在A的左孩子(L)的左子树(L)插入导致不平衡。解决:对A进行一次右旋。
- RR型(左单旋):在A的右孩子(R)的右子树(R)插入导致不平衡。解决:对A进行一次左旋。
- LR型(先左后右双旋):在A的左孩子(L)的右子树(R)插入导致不平衡。解决:先对A的左孩子进行左旋(转化为LL型),再对A进行右旋。
- RL型(先右后左双旋):在A的右孩子(R)的左子树(L)插入导致不平衡。解决:先对A的右孩子进行右旋(转化为RR型),再对A进行左旋。
逐步插入过程(简述关键步骤):
- 插入16,3,7:插入7后,根结点16的平衡因子为-2(左子树高),且是左孩子(3)的右子树插入,属于LR型。需先左旋3,再右旋16。
- 插入11:此时树是平衡的。
- 插入9:插入9后,结点7的平衡因子为-2,是左孩子(3)的右子树插入(注意,这里的“左孩子”是相对于失衡结点7而言的,其左孩子是3),但3的右子树是11,9插在11的左子树上?这里需要仔细画图。实际上,插入9导致结点16失衡(平衡因子为-2),且是左孩子(7)的左子树插入?不对,7的右子树有11,9插在11的左边。所以对于结点16,失衡是由左孩子(7)的右孩子(11)的左子树插入引起的,属于LR型(对16而言)。需要从下往上找到第一个失衡结点。
- ...(后续插入类似分析)
避坑指南与心得:
- 回溯找A:一定要从新插入的结点开始,沿着父指针向上,计算每个祖先结点的平衡因子,找到第一个
|bf| > 1的结点,它就是失衡结点A。不要凭感觉猜。 - 判断类型:确定A后,看导致A失衡的子树是A的哪个孩子(L还是R),再看新结点是插在这个孩子的哪个子树(L还是R)。组合起来就是LL, LR, RL, RR。
- 画图!画图!画图!:这是解决所有AVL树习题最有效的方法。在纸上一步步画,旋转前后对比,理解指针是如何改变的。光靠脑子想很容易乱。
- 代码实现的心得:AVL树的旋转代码并不长,但指针操作极其容易出错。在写代码时,建议先用纸笔画好旋转前后的拓扑图,标出需要修改的指针(通常是3-4个),然后按照固定顺序(例如先处理子结点,再处理父结点)编写代码,并立刻进行简单的测试(如手动模拟一个LL或RR情况)。
4. 哈希表:冲突处理与性能估算的工程思维
哈希表是另一种重要的查找结构,它通过哈希函数将关键字映射到存储地址,理想情况下能达到O(1)的查找时间。本章习题重点考察哈希函数的构造、冲突处理的方法以及查找效率的分析。
4.1 哈希函数构造与冲突处理算法实现
题目示例:设哈希表长为11,哈希函数为 H(key) = key % 11,采用线性探测再散列处理冲突。试在0~10的散列地址空间中,对关键字序列 {22, 41, 53, 46, 30, 13, 01, 67} 构造哈希表,并求在等概率情况下查找成功和不成功的平均查找长度。
核心考点:哈希函数计算、线性探测法、ASL的计算(哈希表下的ASL计算与树不同)。
详细答案与解析:
步骤一:构造哈希表
| 关键字 | 22 | 41 | 53 | 46 | 30 | 13 | 01 | 67 |
|---|---|---|---|---|---|---|---|---|
| H(key) | 0 | 8 | 9 | 2 | 8 | 2 | 1 | 1 |
- 插入22:H(22)=0,地址0空,放入。
- 插入41:H(41)=8,地址8空,放入。
- 插入53:H(53)=9,地址9空,放入。
- 插入46:H(46)=2,地址2空,放入。
- 插入30:H(30)=8,地址8已被41占用,发生冲突。采用线性探测:检查地址9(已被53占),地址10(空),所以30放入地址10。探测次数为2(检查了8和9,最后放在10)。
- 插入13:H(13)=2,地址2已被46占用,冲突。线性探测:地址3(空),所以13放入地址3。探测次数为2。
- 插入01:H(01)=1,地址1空,放入。
- 插入67:H(67)=1,地址1已被01占用,冲突。线性探测:地址2(被46占),地址3(被13占),地址4(空),所以67放入地址4。探测次数为3。
最终哈希表如下(/表示空):
| 地址 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 关键字 | 22 | 01 | 46 | 13 | 67 | / | / | / | 41 | 53 | 30 |
步骤二:计算成功时的平均查找长度(ASLsucc)查找成功时,需要计算找到表中每个已有关键字所需的比较次数(即探测次数)。
- 22:H(22)=0,一次命中,比较1次。
- 41:H(41)=8,一次命中,比较1次。
- 53:H(53)=9,一次命中,比较1次。
- 46:H(46)=2,一次命中,比较1次。
- 30:H(30)=8,冲突,比较了地址8、9,最后在10找到,比较3次。
- 13:H(13)=2,冲突,比较了地址2、3,在3找到,比较2次。
- 01:H(01)=1,一次命中,比较1次。
- 67:H(67)=1,冲突,比较了地址1、2、3、4,在4找到,比较4次。
总比较次数 = 1+1+1+1+3+2+1+4 = 14 ASLsucc = 总比较次数 / 关键字个数 = 14 / 8 = 1.75
步骤三:计算不成功时的平均查找长度(ASLunsucc)查找不成功时,意味着待查关键字不在表中。我们需要计算对于每个哈希地址,按照冲突解决策略,需要比较多少次才能确定“查找失败”。这是哈希表ASL计算的难点。 对于线性探测法,查找失败时,从哈希地址开始,一直向后探测,直到遇到一个空位置,才确认失败。注意:比较次数包括最后与空位置的比较。
假设哈希函数值域为0~10(表长11),我们计算每个地址H(key)下查找失败的比较次数:
- 地址0:探测0,若为空则失败(1次);若不为空(是22),则继续探测1。探测1,若为空则失败。所以需要一直探测到空位。从地址0开始:0(22)->1(01)->2(46)->3(13)->4(67)->5(空)。比较了6次才遇到空位(地址5)。
- 地址1:从1开始:1(01)->2(46)->3(13)->4(67)->5(空)。比较了5次。
- 地址2:从2开始:2(46)->3(13)->4(67)->5(空)。比较了4次。
- 地址3:从3开始:3(13)->4(67)->5(空)。比较了3次。
- 地址4:从4开始:4(67)->5(空)。比较了2次。
- 地址5:从5开始:5(空)。比较了1次。
- 地址6:从6开始:6(空)。比较了1次。
- 地址7:从7开始:7(空)。比较了1次。
- 地址8:从8开始:8(41)->9(53)->10(30)->0(22)->1(01)->2(46)->3(13)->4(67)->5(空)。比较了9次。(注意线性探测是循环的,从8走到表尾10后,回到表头0继续)
- 地址9:从9开始:9(53)->10(30)->0(22)->1(01)->2(46)->3(13)->4(67)->5(空)。比较了8次。
- 地址10:从10开始:10(30)->0(22)->1(01)->2(46)->3(13)->4(67)->5(空)。比较了7次。
总失败比较次数 = 6+5+4+3+2+1+1+1+9+8+7 = 47 ASLunsucc = 总失败比较次数 / 哈希函数值域大小 = 47 / 11 ≈ 4.27
为什么ASLunsucc这么高?这是因为线性探测法容易产生“堆积”现象,即冲突的记录会连成一片。一旦发生冲突,后续的探测路径会变长,极大地影响了失败查找的性能。这也说明了为什么在实际工程中,当哈希表填充因子(元素数/表长)较大时,线性探测的性能会急剧下降,通常需要扩容(rehashing)。
实战心得与不同冲突处理方法对比:
- 线性探测:实现简单,但容易产生堆积,ASLunsucc可能很高。
- 平方探测:可以缓解堆积,但表长必须为4k+3型的素数时,才能探测到所有位置。
- 再哈希法:需要设计多个哈希函数,计算开销稍大。
- 链地址法:这是最常用且稳定的方法。将冲突的记录放在同一个链表中。ASLsucc ≈ 1 + α/2(α为装载因子),ASLunsucc ≈ α + e^(-α)。性能优于开放定址法,且处理删除操作更简单。在习题中,如果采用链地址法,ASL的计算就变成了计算在每个链表中查找的平均长度。
5. 综合应用与算法设计:从理论到代码的桥梁
第八章的最后部分,习题往往更具综合性,可能要求你设计一个结合多种查找思想的算法,或者解决一个实际背景的查找问题。这部分最能体现你对本章知识的融会贯通能力。
5.1 利用B树/B+树特性设计文件索引系统
题目示例(思想延伸):简述为何在数据库索引和文件系统中大量使用B树或B+树,而不是二叉排序树或AVL树?
核心考点:B树/B+树与内存查找树的本质区别、磁盘I/O特性对数据结构选择的影响。
详细答案与解析: 这是一个经典的面试题和思考题。其核心原因在于计算机存储系统的层次结构和磁盘I/O的高成本。
- 磁盘I/O与数据局部性:磁盘(尤其是机械硬盘)的读写以“页”(Page,通常为4KB或更大)为单位,且随机访问速度极慢(寻道时间+旋转延迟)。相比之下,内存访问速度极快。因此,减少磁盘I/O次数是设计外存(磁盘)数据结构的第一要务。
- 二叉树的“瘦高”问题:二叉排序树或AVL树每个结点通常只存储一个关键字和两个指针。对于海量数据(比如10亿条记录),树会变得非常“高”。查找一个关键字可能需要访问树高(O(log₂n))个结点。如果每个结点存储在不同的磁盘页中,就意味着需要数十次磁盘I/O,这是无法接受的。
- B树的“矮胖”优势:B树的一个结点可以存储多个关键字(和对应的指针)。一个结点的大小正好设计成一个磁盘页的大小。这样,一次磁盘I/O就可以读入一个包含大量关键字的结点。虽然B树的结点内可能需要顺序或折半查找,但这发生在内存中,成本可忽略不计。由于每个结点的分支数(阶数m)很大,所以B树非常“矮胖”,树高很低(O(logₘn))。对于10亿条记录,如果B树的阶数m=200,树高可能只有4-5层,这意味着最多只需要4-5次磁盘I/O就能找到目标,性能提升是数量级的。
- B+树相对于B树的优势:B+树在B树基础上做了优化:
- 非叶子结点仅存索引:不存实际数据记录,只存关键字和子指针。这使得一个结点能容纳更多的关键字,进一步降低树高。
- 叶子结点链表串联:所有叶子结点包含全部关键字信息,并且按大小顺序链接成一个链表。这使得范围查询(如查找20到100之间的所有记录)效率极高,只需要找到起始点,然后顺着链表遍历即可。而在B树中,范围查询可能需要在不同层级的结点间反复跳跃,效率低下。
- 更稳定的查询效率:B+树任何查找都必须走到叶子结点,路径长度相同,查询性能稳定。
代码设计启示:虽然课后习题不要求实现完整的B树,但理解其插入、删除、分裂、合并的过程至关重要。在实现时,关键结构体可能如下:
#define M 5 // B树的阶,根据磁盘页大小和关键字大小计算得出 typedef struct BTreeNode { int keyNum; // 结点中当前关键字个数 int keys[M]; // 关键字数组,实际使用[0..keyNum-1] struct BTreeNode *children[M+1]; // 孩子指针数组,比关键字多一个 bool isLeaf; // 是否为叶子结点 } BTreeNode;插入一个关键字时,如果结点已满(keyNum == M-1),就需要进行分裂。这是B树实现中最复杂的部分,需要仔细处理关键字的上移和孩子指针的重新分配。
5.2 哈希表与链地址法处理重复关键字
题目示例:假设某查找系统允许关键字重复,请设计一个基于哈希表(链地址法)的查找算法,要求能够返回所有匹配的关键字记录。
核心考点:链地址法的实际应用、如何处理冲突链表中的多个相同关键字。
详细答案与解析: 在标准链地址法中,冲突的关键字被链接在同一个哈希地址的链表上。如果允许重复,那么链表上就可能存在多个结点的key值相同。我们的查找算法需要找出所有匹配的结点。
数据结构设计:
typedef struct Record { int key; // ... 其他数据字段 ... struct Record *next; } Record, *RecordList; RecordList hashTable[TABLESIZE]; // 哈希表,每个元素是一个链表头指针查找算法设计:
// 查找所有关键字为key的记录,并将它们存入结果数组result[]中,返回找到的记录数 int SearchAll(RecordList hashTable[], int key, Record *result[], int maxResults) { int count = 0; int addr = Hash(key); // 计算哈希地址 Record *p = hashTable[addr]; // 指向该地址的链表头 while (p != NULL && count < maxResults) { if (p->key == key) { result[count++] = p; // 找到一条记录,存入结果数组 } p = p->next; } return count; // 返回找到的记录数量 }为什么这样设计?
- 返回所有结果:算法遍历整个冲突链表,将所有
key匹配的结点指针收集起来。这是与不允许重复的查找(找到第一个就返回)的核心区别。 - 使用结果数组:通过参数
result数组和maxResults上限,将找到的记录返回给调用者。这种方式比在函数内部动态分配内存更安全、接口更清晰。调用者需要预先分配足够大的数组。 - 时间复杂度:平均情况下,ASL仍然接近O(1+α),但最坏情况(所有关键字都冲突)会退化到O(n)。对于重复关键字的查找,平均需要遍历α个结点(链表平均长度),并在其中找出所有匹配项。
进阶思考:插入与删除
- 插入:允许重复时,插入操作变得简单,直接采用头插法或尾插法将新记录添加到对应链表的头部或尾部即可,无需判断是否已存在。
- 删除:删除操作则需要小心。如果要删除所有关键字为
key的记录,就需要遍历整个链表进行删除。如果只删除其中一个,则需要指定额外的条件(如时间戳、唯一ID等)。
这份针对严蔚敏《数据结构》第八章习题的深度解析,旨在穿透“答案”本身,揭示每一类题目背后的数据结构思想、算法逻辑和实现细节。真正的掌握,不在于记住这些代码和步骤,而在于理解每一步“为什么这么做”,以及“换一种条件该怎么做”。当你再遇到新的查找问题时,能够清晰地判断该选用顺序查找、折半查找、二叉排序树、AVL树、B树还是哈希表,并清楚每一种选择背后的代价与收益,那才是真正学懂了这一章。
