数据结构这门课,我前前后后翻过不下五遍,每次重看线性表都有新体会。第一遍学的时候觉得顺序表和链表不就是数组和指针嘛,能有多难;第二遍做题才发现,真正卡人的从来不是"怎么存",而是"什么时候该用哪种存法、边界条件怎么处理、时间复杂度怎么算才不心虚"。这篇笔记对应的是王道数据结构课程的第二部分,核心落在线性表上——顺序表、单链表、双链表、循环链表、静态链表这几块。它解决的是"数据元素之间一对一关系如何组织"这个最基础也最容易被轻视的问题,适合正在准备专业课考试的同学,也适合工作几年后想回头把底层结构重新捋一遍的开发者。下面我按自己实际学习和做题的顺序来展开,重点讲清楚每种结构的设计动机、操作细节和那些文档里不会明说但考试和实战都会踩的坑。
1. 线性表到底在解决什么问题
1.1 从"一对一"这个约束说起
线性表是数据结构里最朴素的一种逻辑结构,定义很干脆:n 个相同类型数据元素的有限序列。关键词是"有限"和"序列"——有限意味着长度确定,序列意味着除了第一个和最后一个,每个元素都有且仅有一个前驱和一个后继。这个"一对一"的约束看起来简单,但它直接决定了后面所有操作的边界:插入只能在某个位置前后挪动,删除必须补上空位,查找只能顺着关系走。
很多人学的时候会跳过这段定义直接看代码,结果做题时对"位序"和"数组下标"混着用,插入位置写错、删除越界这类错误反复出现。我自己的经验是,先把逻辑结构(线性表)和存储结构(顺序存储、链式存储)这两层彻底分开,后面就不会乱。逻辑结构讲的是元素之间的关系,存储结构讲的是这种关系在内存里怎么落地。同一个线性表,可以用连续的内存块存(顺序表),也可以用散落的节点加指针串起来(链表),逻辑上它们是等价的,但操作代价完全不同。
这里有个容易被忽略的点:线性表要求元素类型相同。这不是随便定的,类型相同意味着每个元素占用的空间一致,顺序存储时才能用"首地址 + 下标 × 元素大小"直接算出任意元素的地址,实现 O(1) 的随机访问。如果元素大小不一,这个公式就失效了,顺序存储的优势也就没了。理解这一层,你才能明白为什么后面讲链表时,节点里除了数据域还要专门留一个指针域——那是用额外空间换取灵活性的代价。
1.2 顺序存储与链式存储的本质取舍
顺序表和链表的对比,几乎所有教材都会列一张表,但表格背后的"为什么"才是关键。顺序表用一段连续空间存元素,逻辑相邻的元素物理上也相邻,好处是随机访问快、存储密度高(不用额外存指针),坏处是插入删除要移动大量元素、容量固定不易扩展。链表用节点加指针,逻辑相邻物理上不一定相邻,好处是插入删除只改指针、容量按需分配,坏处是不能随机访问、每个节点都要额外空间存指针。
我习惯用一个类比来记:顺序表像电影院里连号的座位,你想找第 5 排第 3 座,直接走过去就行,但如果中间要加一个人,后面所有人都得往后挪;链表像一列手拉手的人,你想找第 5 个人必须从第 1 个开始一个个数过去,但要在某两个人之间插一个新人,只要让前后两个人改牵谁的手就行,其他人不受影响。
这个取舍直接决定了选型:查多改少用顺序表,改多查少用链表。但实际做题时还有更细的判断,比如"经常在表尾插入"这种场景,顺序表其实也不慢(只要不扩容),而链表如果只维护头指针,每次尾插都要遍历到末尾,反而退化成 O(n)。所以真正做题时要看操作集中在哪个位置,不能一概而论。下面这张表是我做题时总结的对照,比教材那张更贴近实际判断:
| 操作场景 | 顺序表代价 | 链表代价 | 我的选型倾向 |
|---|---|---|---|
| 按位置随机读取 | O(1) | O(n) | 顺序表 |
| 表头插入/删除 | O(n) | O(1) | 链表 |
| 表尾插入 | O(1)(不扩容) | O(n)(仅头指针) | 顺序表 |
| 按值查找 | O(n) | O(n) | 看是否需要频繁改动 |
| 频繁扩容 | 需整体搬迁 | 按需分配 | 链表 |
1.3 学习顺序为什么是先顺序表后链表
王道课程的编排是先讲顺序表再讲链表,这个顺序不是随意的。顺序表的实现直接对应数组,大家对数组最熟悉,理解"下标访问""连续存储"几乎没有门槛,可以先把线性表的抽象操作(初始化、插入、删除、查找)的语义搞清楚。等这些操作的语义在心里扎根了,再引入链表,你关注的焦点就自然从"这个操作是什么意思"转移到"这个操作在链式存储下怎么实现",认知负担被拆开了。
我第一遍学的时候没意识到这一点,顺序表草草看过就直奔链表,结果链表插入时指针怎么改总是画错图。后来回头把顺序表的插入删除老老实实手写了一遍,把"第 i 个位置"和"下标 i-1"的关系彻底理清,再看链表才发现两者在边界处理上的思路是一致的——都是先判断位置合法性,再定位,再改动。所以如果你现在正卡在链表,不妨先回去把顺序表的手写代码默一遍,很多困惑会自己消解。
2. 顺序表:连续内存带来的快与痛
2.1 用数组模拟顺序表时那几个必须想清楚的设计点
顺序表的实现本质是用数组加一个长度变量。听起来简单,但真正动手写之前有几个设计决策必须先定下来,否则代码写到一半会反复返工。
第一个决策是容量怎么表示。常见做法是定义一个最大容量常量MaxSize,数组开这么大,再用一个length记录当前实际元素个数。这里要区分"数组容量"和"表长"两个概念:容量是数组能装多少,表长是当前装了多少。很多初学者直接用表长当循环边界去访问数组,一旦表长等于容量再插入就越界了。我习惯在插入前先判断length == MaxSize,满了就拒绝或扩容,这个判断不能省。
第二个决策是位序从 1 开始还是从 0 开始。教材和考试里说的"第 i 个元素"通常指位序,从 1 开始;而数组下标从 0 开始。这两者差 1,是所有边界错误的根源。我的做法是:对外接口一律用位序(符合人的直觉和题目描述),进入函数第一件事就是把它转成下标,之后内部全用下标,不再混用。这样至少能保证错误只可能出现在转换那一行,排查范围小。
第三个决策是插入和删除时元素怎么移动。插入要把第 i 个位置及之后的元素整体后移一位,删除要把第 i 个位置之后的元素整体前移一位。移动方向很关键:插入必须从后往前移(否则前面的元素会覆盖后面的),删除必须从前往后移(否则后面的元素会覆盖前面的)。这个方向我当年死记硬背过,后来想明白了——移动的本质是"把空位腾出来"或"把空位填上",顺着空位移动的方向就不会错。
// 顺序表插入:在第 i 个位置(位序,从1开始)插入元素 e bool ListInsert(SqList *L, int i, int e) { if (i < 1 || i > L->length + 1) return false; // 位置合法性 if (L->length >= MaxSize) return false; // 容量检查 for (int j = L->length; j >= i; j--) { // 从后往前移 L->data[j] = L->data[j - 1]; } L->data[i - 1] = e; // 位序转下标 L->length++; return true; }上面这段代码里,i > L->length + 1这个上界容易被写成i > L->length,差别在于能不能在表尾之后追加。位序length + 1表示插在最后一个元素后面,是合法的,所以上界必须是length + 1。这个细节考试特别爱考,选择题里经常拿它做干扰项。
2.2 插入删除的平均移动次数是怎么算出来的
顺序表插入删除的时间复杂度是 O(n),但"平均移动多少次"这个结论很多人只记结果不会推。推导其实不难,理解了之后对复杂度分析会有质的提升。
假设表长为 n,插入位置 i 可以取 1 到 n+1 共 n+1 种可能。在第 i 个位置插入时,需要移动的元素个数是 n - i + 1(从第 i 个到第 n 个都要后移)。假设每个位置插入的概率相等,都是 1/(n+1),那么平均移动次数就是:
$$\frac{1}{n+1}\sum_{i=1}^{n+1}(n-i+1) = \frac{1}{n+1}\cdot\frac{n(n+1)}{2} = \frac{n}{2}$$
也就是说平均要移动一半的元素。删除同理,表长 n,删除位置 i 取 1 到 n,移动 n - i 个元素,平均移动 (n-1)/2 个。这两个结论要能自己推出来,而不是背"平均 n/2"。
我踩过一个坑:有次做题问"在长度为 n 的顺序表中删除第 i 个元素,需要移动多少个元素",我条件反射写了 n-i+1,结果错了。正确是 n-i,因为删除第 i 个之后,第 i+1 到第 n 个前移,一共 n-i 个。插入是 n-i+1,删除是 n-i,差的就是被操作的那个元素本身。这个区别我在草稿纸上画了个小例子才彻底记住:表 [a,b,c],删除第 2 个 b,只需把 c 前移一位,移动 1 个,n-i = 3-2 = 1,对上了。
2.3 顺序表实操中最容易翻车的三个边界
第一个边界是空表删除。表长为 0 时任何删除都非法,判断条件i < 1 || i > L->length里,i > 0在 length 为 0 时恒成立,能正确拦截。但如果只写i < 1忘了上界,空表删除就会访问非法内存。
第二个边界是表满插入。容量检查必须在移动元素之前做,如果先移动再检查容量,一旦满了数组已经越界写坏了。这个顺序不能反。
第三个边界是位序与下标的转换点。我见过不少代码在循环里一会儿用data[i]一会儿用data[i-1],自己都绕晕了。统一原则:函数入口转换一次,内部只用下标。比如删除操作:
bool ListDelete(SqList *L, int i, int *e) { if (i < 1 || i > L->length) return false; *e = L->data[i - 1]; // 取出被删元素 for (int j = i; j < L->length; j++) { // 从前往后移 L->data[j - 1] = L->data[j]; } L->length--; return true; }注意删除的循环从j = i开始(i 是位序,对应下标 i-1 的后一个),到j < L->length结束,把data[j]往前挪到data[j-1]。这个循环边界我建议每次写之前先在纸上画三个元素的例子跑一遍,比死记靠谱。
提示:顺序表的所有边界错误,本质都是"位序、下标、长度"三者的换算没统一。养成"入口转换、内部统一"的习惯,能消掉一大半低级错误。
3. 单链表:指针操作的分水岭
3.1 带头结点还是不带头结点,这个选择影响所有代码
单链表第一个绕不开的决策就是带不带头结点。头结点是放在第一个实际元素之前的一个额外节点,它的数据域通常不存有效数据(可以存表长等信息),指针域指向第一个实际元素。带不带头结点,直接决定了插入删除代码要不要特判"在第一个位置操作"。
不带头结点时,如果在第一个位置插入或删除第一个元素,必须修改头指针本身,而头指针是函数的入参,C 语言里要改它就得传二级指针或者返回新头指针,代码里到处是if (i == 1)的特判。带头结点后,第一个位置的操作和其他位置完全一样,因为头指针永远指向头结点不变,变的只是头结点的指针域。这就是头结点的核心价值——把"边界情况"变成"普通情况",让代码统一。
我一开始觉得头结点是多余的空间浪费,后来写了几遍不带头结点的插入删除,被那些特判折磨得够呛,才彻底转向带头结点。考试里两种都可能考,但实际写代码我强烈建议带头结点。下面是不带头结点插入第一个位置的典型写法,你能感受到那种别扭:
// 不带头结点,在第1个位置插入,需要改头指针 bool InsertFirst(LNode **head, int e) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = e; s->next = *head; // 新节点指向原第一个 *head = s; // 头指针改指向新节点 return true; }而带头结点后,这段逻辑被吸收进通用的"在第 i 个位置插入"里,不需要单独的函数。这个统一性在链表代码量大的时候价值巨大。
3.2 指针改动的顺序:为什么必须先连后断
单链表插入的核心操作是改指针,而指针改动顺序错了会导致链断裂或内存泄漏。以在节点 p 之后插入新节点 s 为例,正确顺序是:
s->next = p->next;(新节点先指向 p 原来的后继)p->next = s;(再让 p 指向新节点)
如果反过来先执行p->next = s,那么 p 原来的后继就丢了,s 的 next 还没设置,整条链从 s 往后就断了。这个"先连后断"的原则,本质是在断开旧连接之前,先把新连接建立好,保证任何时刻都不会有节点失去引用。
删除也是同理。要删除 p 的后继节点 q,正确顺序是:
p->next = q->next;(让 p 跳过 q)free(q);(再释放 q)
如果先 free(q) 再改 p->next,那 p->next 就成了悬空指针,访问它行为未定义。这个顺序错误在考试的手写代码题里扣分很狠,因为它是"逻辑对但实现错"的典型。
我记这个顺序的口诀是"先接后断,先断后放"——插入先接新链再断旧链,删除先断引用再释放内存。口诀不优雅但管用,尤其是考试紧张的时候。
3.3 头插法与尾插法建表:一个细节决定元素顺序
建立单链表有两种常见方式:头插法和尾插法。头插法是每次把新节点插在头结点之后,尾插法是每次插在链表末尾。两者的代码差别很小,但建出来的链表元素顺序完全相反。
头插法建表,如果按 1、2、3 的顺序插入,最终链表是 3、2、1,因为每次新节点都跑到最前面。尾插法按 1、2、3 插入,链表就是 1、2、3。这个差异在做"链表逆置"类题目时特别有用——头插法天然就是一个逆序工具。
尾插法的关键是要维护一个尾指针r,每次插入后更新r指向新的末尾。如果不维护尾指针,每次尾插都要从头遍历到末尾,建一个长度为 n 的链表就退化成 O(n²)。这个优化点很多人第一次写尾插法时会忽略:
// 尾插法建表,维护尾指针 r void CreateListTail(LNode *head, int arr[], int n) { LNode *r = head; // r 始终指向当前末尾 for (int i = 0; i < n; i++) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = arr[i]; r->next = s; // 接到末尾 r = s; // 更新尾指针 } r->next = NULL; // 末尾置空,别忘 }最后那句r->next = NULL是必须的,否则链表末尾的 next 是野指针,遍历时会越界。这个收尾动作我在第一次手写时漏过,调试了半天才发现遍历停不下来。
3.4 按位查找与按值查找:遍历的两种终止条件
单链表查找分两种:按位查找(找第 i 个节点)和按值查找(找第一个值为 e 的节点)。两者都是从头遍历,但终止条件和返回值不同,容易混。
按位查找的终止条件是"走到第 i 个"或"走到 NULL"。如果 i 超过表长,遍历会走到 NULL,此时应返回失败。代码里循环条件通常是while (p != NULL && j < i),j 从 0 开始(头结点算第 0 个),走完 j == i 时 p 就是第 i 个节点。
按值查找的终止条件是"找到值相等"或"走到 NULL"。找到就返回节点指针,走完没找到返回 NULL。
这里有个性能细节:单链表按位查找第 i 个节点是 O(n),因为必须从头数。这也是为什么链表不适合"频繁按位置访问"的场景。而如果题目要求"找倒数第 k 个节点",正统做法是用两个指针,一个先走 k 步,然后两个一起走,前面的到末尾时后面的正好在倒数第 k 个。这个双指针技巧是链表题的常客,本质是用两个指针的相对距离代替了"先求总长再算位置"的两次遍历。
4. 双链表与循环链表:把单向关系补全
4.1 双链表多出来的 prior 指针解决了什么
单链表只能单向遍历,想找某个节点的前驱必须从头再走一遍,代价 O(n)。双链表在每个节点里加一个prior指针指向前驱,把"找前驱"从 O(n) 降到 O(1)。这个改动的代价是每个节点多一个指针的空间,以及插入删除时要多维护一组指针。
双链表插入的关键是四步指针改动,顺序不能乱。以在节点 p 之后插入 s 为例:
s->next = p->next;s->prior = p;p->next->prior = s;(如果 p 有后继)p->next = s;
第 3 步有个陷阱:如果 p 是最后一个节点,p->next是 NULL,直接访问p->next->prior会崩溃。所以必须先判断p->next != NULL。这个空指针判断是双链表代码里最常见的漏写点,我当年考试就栽在这。
删除节点 q(q 是 p 的后继)时,要改两处:p->next = q->next;和q->next->prior = p;,同样要判断q->next是否为空。双链表的代码量比单链表多,但逻辑更对称,写熟了反而不容易错,因为前后两个方向都要维护,少改一个立刻能通过遍历发现。
4.2 循环链表:把"末尾"这个概念消掉
循环链表是把单链表的尾节点 next 指回头结点(或第一个节点),形成一个环。这样做的好处是从任意节点出发都能遍历整个链表,不存在"走到末尾就停"的问题。判断遍历结束的条件从p != NULL变成p != head(或p->next != head,看具体实现)。
循环链表最实用的场景是"频繁在表头表尾操作"。普通单链表如果只维护头指针,尾插要 O(n);循环链表如果维护的是尾指针rear,那么rear->next就是头结点,头插和尾插都能 O(1) 完成。这个技巧在实现队列时特别常用——用循环链表加尾指针实现队列,入队出队都是 O(1)。
判断循环链表遍历结束有个经典坑:如果循环条件是p != NULL,那永远停不下来,因为环里没有 NULL。必须用p != head或者计数到表长。我见过有人调试循环链表时程序卡死,就是因为沿用了单链表的while (p != NULL)。
4.3 静态链表:没有指针时怎么模拟链式结构
静态链表是用数组模拟链表。数组的每个元素是一个节点,节点里存数据和"下一个节点的数组下标"(叫游标或 next 域),用下标代替指针。它存在的意义是:在一些不支持指针的编程环境里,仍然能实现链式存储的灵活性。
静态链表的关键是区分"数组下标"和"链表顺序"。数组里元素的物理顺序和逻辑顺序无关,逻辑顺序靠 next 域串起来。比如数组第 0 个元素存的是逻辑上第 3 个节点,完全正常。初始化时通常把 next 域串成一个"空闲链表",插入时从空闲链表取一个节点,删除时把节点还回空闲链表。
静态链表的插入删除和单链表逻辑一致,只是把p = p->next换成p = next[p],把malloc换成"从空闲链表取"。考试里静态链表考得不多,但偶尔出选择题问"静态链表的 next 域存的是什么",答案是数组下标而不是地址,这个要记清。
| 结构类型 | 找前驱 | 找后继 | 随机访问 | 典型适用场景 |
|---|---|---|---|---|
| 顺序表 | O(1)(下标-1) | O(1) | O(1) | 查多改少 |
| 单链表 | O(n) | O(1) | O(n) | 频繁插入删除 |
| 双链表 | O(1) | O(1) | O(n) | 需要双向遍历 |
| 循环链表 | O(n) | O(1) | O(n) | 环形队列、轮转调度 |
| 静态链表 | O(n) | O(1) | O(n) | 无指针环境 |
5. 做题时真正拉开差距的几个思维习惯
5.1 画图永远比空想快
链表题不画图,等于闭着眼睛改指针。我养成的习惯是:拿到任何链表题,先在纸上画三到五个节点,标上头结点、数据域、next 箭头,然后每改一次指针就在图上划掉旧箭头画新箭头。这个动作看起来慢,实际上能省掉大量调试时间。
画图还有个隐藏好处:能直观看出边界情况。比如"在第一个节点前插入",画图时你会发现头结点前面没有节点,自然就想到要不要特判;"删除最后一个节点",画图时看到它的 next 是 NULL,自然就想到空指针判断。很多边界错误不是想不到,而是没画图所以没"看见"。
我备考时专门准备了一本草稿本,每道链表题都画图,画完再写代码。后来发现那些反复出错的题,几乎都是画图时漏画了某个箭头导致的。这个习惯看起来笨,但性价比极高。
5.2 复杂度分析要落到"基本操作"上
很多人分析复杂度时凭感觉,觉得"有个循环就是 O(n)"。更靠谱的做法是数基本操作的执行次数。顺序表插入的移动次数、链表遍历的指针跳转次数,都是可以精确数出来的。数清楚了,O(n) 还是 O(n²) 一目了然。
举个容易错的例子:在单链表中删除值为 x 的所有节点。如果每删一个就从头重新找,那是 O(n²);如果一次遍历边找边删,是 O(n)。区别就在于"遍历"这个基本操作执行了几遍。分析时把"遍历一遍链表"当成一个基本单位,看它执行几次,就不会错。
还有个小技巧:分析空间复杂度时,看有没有"随 n 增长而增长的额外空间"。链表操作里如果只用了几个指针变量,空间是 O(1);如果新建了一个和原表等长的链表,空间是 O(n)。这个判断标准比死记结论可靠。
5.3 手写代码时先写边界判断再写主逻辑
我见过太多人写链表代码时先写主循环,最后补边界判断,结果边界判断和主逻辑对不上,改来改去。更稳的顺序是:函数一进来先把所有非法情况挡掉(空表、位置越界、指针为空),挡完之后剩下的就是"一定能正常执行"的主逻辑,写起来心里踏实。
以"删除单链表第 i 个节点"为例,入口先判断:i 是否小于 1、i 是否大于表长、链表是否为空。这三个判断做完,后面的定位和删除就不会出意外。这种"防御式编程"的习惯,在考试手写代码时能显著降低出错率,因为阅卷老师最看重的就是边界处理是否完整。
注意:边界判断不是越多越好,要判断"真正可能发生"的情况。比如带头结点的链表,头指针本身不会为空(除非初始化失败),就不用判断头指针为空,判断头结点的 next 为空即可。判断冗余了反而显得没想清楚。
5.4 把"逆置""找中间""判环"这类套路题吃透
链表题里有一批经典套路,掌握了能覆盖一大片题目。逆置用头插法或三指针法;找中间节点用快慢指针(快指针走两步慢指针走一步);判环用快慢指针相遇;找倒数第 k 个用双指针保持 k 的距离。这些套路的共同点是用指针的相对运动代替额外的遍历或存储。
拿判环来说,快慢指针如果相遇说明有环,这个结论的证明用到了"在环里快指针每步追近慢指针一个节点,必然追上"的朴素道理。理解了这个道理,就不会纠结"为什么快指针走两步而不是三步"——走两步保证每次追近一个,走三步虽然也能追上但可能跳过,分析起来麻烦。
这些套路我建议不要死记代码,而是记住"用几个指针、各自怎么动、终止条件是什么"这三要素,代码现场推。死记的代码换个题面就不会用了,理解了三要素才能迁移。
6. 那些教材不会明说但考试会考的细节
6.1 头结点的数据域到底存不存东西
头结点的数据域,教材一般说"不存有效数据"或"可存表长等信息"。考试里如果问"头结点的作用",标准答案是"统一插入删除操作、方便处理空表和非空表"。但如果题目问"头结点的数据域能否存储有效数据",答案是能,只是通常不这么做,因为一旦存了有效数据,头结点就变成了实际节点,很多"头结点不算表长"的约定就被打破了。
我个人的做法是头结点数据域留空或存表长,绝不存业务数据。这样所有"从第一个实际节点开始"的遍历逻辑都统一,不会因为头结点特殊而多写分支。
6.2 链表长度是否包含头结点
这个问题考试爱挖坑。带头结点的单链表,表长指的是实际数据节点的个数,不包含头结点。所以一个只有头结点的链表,表长是 0,是空表。判断空表的条件是head->next == NULL,而不是head == NULL。
这个约定如果不清楚,做题时"求表长"的循环边界就会写错。正确写法是从head->next开始计数,每走一步计数加一,走到 NULL 停止。如果从头结点开始数,结果会多 1。
6.3 顺序表扩容时为什么通常扩到两倍
顺序表容量满了要扩容,常见策略是扩到原来的 2 倍(或 1.5 倍)。为什么不是每次加固定长度?因为如果每次只加 1,插入 n 个元素总共要搬迁 1+2+...+n = O(n²) 次;而按倍数扩容,搬迁次数是等比数列求和,总共 O(n),均摊到每次插入是 O(1)。
这个"均摊分析"的思想很重要,它解释了为什么动态数组(很多语言里的可变长数组)的追加操作平均是 O(1)。扩到 2 倍还是 1.5 倍是个工程权衡:2 倍扩容次数少但可能浪费较多空间,1.5 倍空间利用率高但扩容更频繁。考试一般不深究倍数,但"按倍数扩容使均摊 O(1)"这个结论要能说清楚。
6.4 删除节点后 free 的顺序和野指针
删除链表节点时,正确顺序是先把前驱的 next 指向被删节点的后继,再 free 被删节点。如果先 free 再改指针,前驱的 next 就成了野指针。更隐蔽的坑是:free 之后没有把指针置 NULL,后续如果误用这个指针,行为未定义。
我的习惯是 free 之后立刻把局部指针变量置 NULL,虽然对已经 free 的内存置 NULL 不能挽回什么,但能防止后续代码误用。这个习惯在调试时特别有用,因为野指针导致的崩溃往往位置飘忽,置 NULL 后至少能快速定位到"用了已释放的指针"。
6.5 循环链表判空和判满的特殊性
循环链表的判空和普通链表不同。带头结点的循环单链表,空表条件是head->next == head(头结点的 next 指回自己),而不是head->next == NULL。这个差别是因为循环链表没有 NULL 结尾,末尾指回头部。
循环队列的判空判满更经典:为了区分空和满,通常牺牲一个存储单元,队空条件是front == rear,队满条件是(rear + 1) % MaxSize == front。这个"牺牲一个单元"的设计是为了避免"空和满都是 front == rear"的歧义。理解了这个动机,就不会记混判空判满的条件。
7. 从笔记到实战:我建议的练习路径
学完线性表这一章,光看笔记不够,必须动手。我给自己定的练习路径是这样的:先把顺序表的初始化、插入、删除、查找四个操作手写一遍,不看书,写完对照检查边界;再把单链表的头插建表、尾插建表、按位插入、按位删除、按值删除各写一遍;然后双链表的插入删除写一遍;最后做五到十道综合题,比如链表逆置、找中间节点、合并两个有序链表、判断回文、删除倒数第 k 个。
这个路径的关键是先单结构后综合。单结构的代码写熟了,综合题才有底气。我见过有人一上来就刷综合题,结果每道题都卡在基础的指针操作上,效率极低。基础操作就像乘法口诀,背熟了才能做应用题。
另外强烈建议用纸笔手写代码,而不是在 IDE 里敲。IDE 有补全和报错,会掩盖你对细节的不确定;纸笔写出来的代码,哪里不确定一目了然。考试也是纸笔,提前适应这个环境很重要。我备考后期专门用空白纸默写各种链表操作,默完再上机验证,这个"纸笔加验证"的组合效果最好。
最后分享一个我自己的检查方法:写完任何链表代码,用三个元素的例子手动跑一遍,重点看第一个元素和最后一个元素的操作。第一个元素考验头结点的处理,最后一个元素考验 NULL 的判断,这两个位置对了,中间的通常不会错。这个方法帮我抓出过无数边界 bug,比盯着代码看有效得多。