资讯详情

资讯详情

C语言链表从入门到实操:指针、内存管理与增删实现

写这篇文章的起因有点现实——我见过太多初学者把链表当成面试背题却在真正需要它的时候手足无措。链表是 C 语言里绕不开的核心数据结构它和数组一起构成理解更复杂数据结构栈、队列、树、图的两根拐杖。但奇怪的是很多人能默写出插入节点的代码却说不清楚为什么链表插入能到 O(1)、为什么 malloc 之后必须自己负责释放、为什么删除节点时要格外小心头指针。这篇文章不打算复述教科书而是从链表到底解决了什么问题讲起带着你一步步把单链表写出来、跑起来再把实际编程里最容易踩的坑一个个填平。如果你正在学 C 语言、准备应对考试或者单纯想补一补数据结构的地基这篇内容应该对你有用。写链表代码几年之后回头看我对它最深的体会是链表不是一个高级知识点而是一面照妖镜。你平时对指针、内存、函数传参的理解是不是真透彻写一遍链表就全暴露出来了。所以这篇文章里我会把那些教科书角落里有、但从来没人强调的细节全都翻出来配合可以直接运行的代码尽量讲透。1. 数组的痛点与链表的本质先搞清楚它凭什么存在1.1 顺序存储的代价插入和删除为什么像地震数组在内存里是一段连续的地址空间这个概念大家都很熟悉。连续存储带来一个直接的好处给定下标就能通过基地址 下标 × 元素大小直接算出目标地址所以数组的随机访问是 O(1)这是它的王牌。但天下没有免费的午餐连续性同时锁死了插入和删除这两项操作。想象一个长度为 n 的数组你要在位置 i 插入一个元素。为了腾出位置位置 i 以及它后面的所有元素都得整体后移一位。最坏情况下在头部插入元素整个数组 n 个元素全部要挪动时间复杂度 O(n)。删除同理要往前补位。这个整体挪动的开销小数据量时感觉不到但当数组长度到了百万级、千万级哪怕只是频繁在中间插入后续元素搬迁的耗时也会迅速吞掉整个程序的性能预算。这还不是最麻烦的。数组的长度在大多数静态场景下是固定的如果数据量动态增长你就得自己实现扩容——重新申请一块更大的内存、把旧数据整体拷贝过去、再释放旧内存。这个拷贝过程同样要摊还 O(n) 的开销。我之前写过一个需要不断追加条目的模拟程序用数组实现时每扩容一次就明显卡顿一下后来换成链表才把问题压下去。当然这不是说链表就一定更快扩容用摊还分析实际上也能做到均摊 O(1)但它暴露了顺序存储的另一个短板你永远在为可能用到的位置预留内存而链表是按需生长的。1.2 链表的本质把零散内存用指针串成一条线链表换了一种思路它不再要求元素在物理上相邻。每个元素节点除了保存自己的数据还额外保存一个指向下一个节点的指针。通过这个指针零散在堆上的内存块被串成一条逻辑上的线性表。这意味着两件事。第一插入和删除不再需要搬动任何元素。只要找到目标位置前后的两个节点改一下它们的指针指向就能在 O(1) 时间内完成操作——因为数据本身没有移动动的只是指针。第二内存空间不再需要预先规划。链表是按需分配节点的每个新元素进来才 malloc 一块内存不存在预留一整块的问题。这对数据量不确定、且需要频繁增删的场景天然友好。我常给初学者打一个比方数组就像电影院里的连排座位座位号固定你想加一个人就得让一整排人往边上挪链表就像一队人伸手搭肩排队新加入的人只要找到前面那个人的手搭上去就行队伍里其他人完全不用动。这个比方虽然朴素却把物理连续和逻辑连续的区别讲得很清楚。理解了这一点你就明白了链表存在的根本理由它把线性表这个逻辑概念从必须连续内存这个物理约束里解放了出来。1.3 链表 vs 数组一张表看清各自的主场这里我把两种结构最关键的差异整理成一张表方便对照对比维度数组链表内存布局连续离散节点散落在堆上随机访问O(1)通过下标直接定位O(n)必须从头遍历头/尾插入尾插入均摊 O(1)头插入 O(n)头插入 O(1)尾插入需遍历到尾部 O(n) 或加尾指针 O(1)中间插入/删除O(n)元素需整体移动O(n) 定位 O(1) 改指针空间占用元素本身大小可能有空洞每个节点额外存 1 个指针单链表或 2 个指针双链表容量变化静态固定或需扩容拷贝按需分配天然动态这张表最大的信息量在于链表并非全面优于数组它赢在增删和动态扩容上输在随机访问和空间开销上。选哪个取决于你的核心操作是什么。如果程序以查询为主、插入删除极少数组几乎总是更好的选择如果数据量不可预知、且频繁增删链表才真正发光。后面第六节我会结合实际性能再展开说那里有一个重要的现实问题理论复杂度和实测表现往往不是一回事。2. 节点结构体与内存布局链表的物理真相2.1 自引用结构体C 语言里一个奇特的设计链表的最小单元是节点Node。在 C 语言里节点的定义长这样typedef struct Node { int data; struct Node *next; } Node;注意一个细节结构体内部有一个指向同类型结构体的指针。这是 C 语言中非常典型的自引用结构。有些初学者会问这个指针为什么必须写成struct Node *而不是Node *原因在于 typedef 的生效时机。typedef struct Node { ... } Node;这一整行还没有执行完时Node这个别名尚未产生所以结构体内部只能使用完整的struct Node来指代自身。这个细节不搞清楚你在写类似代码时就会遇到莫名其妙的编译错误。data字段我这里用的是int实际应用中它可以是任意类型——一个结构体、一个字符串甚至另一个链表的头指针。数据字段是什么都不影响链表本身的组织逻辑指针字段才是链表结构的灵魂。理解了这一点后面写出的增删查改函数就可以通用于任意数据类型的节点。这也是为什么很多底层库会把链表节点设计成只含指针、不含数据把数据区留给使用方扩展但单链表学习阶段把 data 直接写死在节点里反而更容易看清结构。2.2 动态内存分配malloc 之后的世界节点创建时需要从堆上申请内存Node* createNode(int data) { Node *node (Node*)malloc(sizeof(Node)); if (node NULL) { fprintf(stderr, 内存分配失败\n); return NULL; } node-data data; node-next NULL; return node; }这里要注意sizeof(Node)不能用sizeof(Node*)代替。前者是节点结构体这个果盘本身占据的空间后者只是指向果盘的标签所占的空间——在 64 位系统上指针通常占 8 字节而节点结构体至少是 16 字节8 字节数据 8 字节指针一旦写错malloc 分配的内存就不够装下一个完整节点后续写入会踩到相邻内存产生极其隐蔽的内存越界 bug。这类 bug 通常不在原地报错而是过一段时间在另一个毫不相关的地方崩溃非常难排查。申请完内存一定要检查返回值。堆内存耗尽时 malloc 返回 NULL如果不检查就直接node-data data就等于往地址 0 上写数据程序会立即段错误。这不是理论上可能发生的边界情况而是每个长期运行的 C 程序都有机会遇到的现实问题。我见过不少同学在写链表练习时觉得检查 NULL 太啰嗦、考试又不考结果一跑压力测试就崩回头查半天才发现在极端情况下分配会失败。养成检查返回值的习惯成本很低收益很大。2.3 头指针的定位为什么它决定整条链表的生死链表本身没有一个容器结构来管理所有节点除非你自己定义一个 list 结构体它完全靠一个头指针来标识。头指针指向第一个节点只要头指针还在整条链表就还在一旦头指针丢失或被覆盖链表上所有节点都变成无法访问的孤儿内存泄漏就此发生。这个特点让头指针成为链表操作中最需要保护的全局状态。插入到头部时正确的做法是新节点的 next 指向旧头再更新头指针为新节点。很多人写反了先更新头指针新节点就找不着旧头了链表后面那一大串节点全部丢失。这不是夸张的比喻而是我实际排障时遇到过的最高频 bug 之一。后续第三节我会给出完整代码并且每次都强调头指针更新的顺序这里先把这个意识立起来头指针是链表的命脉所有涉及头指针赋值的地方都必须像过马路一样左右看两遍再动手。3. 单链表增删查改完整实现从空指针到一条完整链表3.1 头插法与尾插法构建链表的两条路线有了 createNode就可以开始构建链表。最简单的方式是头插法每次把新节点放在链表最前面。核心就三句void insertAtHead(Node **head, int data) { Node *newNode createNode(data); if (newNode NULL) return; newNode-next *head; *head newNode; }这里必须用二级指针Node **head原因很简单我们要修改调用方手里的头指针变量本身而不仅仅是修改它指向的内容。C 语言函数传参是值传递如果形参写成Node *head在函数内部对head的重新赋值是改不了外部变量的改完外部头指针还是原来的值——链表等于没插上。这个二级指针的门槛卡住过很多初学者我的建议是把Node **head理解成头指针变量的地址你要改变一个变量的值就必须传入它的地址。反过来看如果函数只修改节点内容、不修改头指针指向比如遍历打印那就只需要一级指针Node *head。尾插法稍微麻烦一点如果链表为空新节点就是头如果不为空就要先找到当前最后一个节点再把新节点接上去void insertAtTail(Node **head, int data) { Node *newNode createNode(data); if (newNode NULL) return; if (*head NULL) { *head newNode; return; } Node *cur *head; while (cur-next ! NULL) { cur cur-next; } cur-next newNode; }每次尾插都要遍历整条链表到尾部时间复杂度 O(n)。如果程序里频繁在尾部追加、而链表又很长可以额外维护一个尾指针tail让尾插变成 O(1)。但注意尾指针和头指针一样需要小心维护删除尾部节点时要回退尾指针——单链表没法从最后一个节点直接回退到前一个节点所以要么遍历到倒数第二个节点要么用双向链表。这也是为什么很多实际项目更愿意用双向链表的原因之一第五节我会展开讲。3.2 指定位置插入双指针追击模板在任意位置插入节点的思路可以一分为二先找位置再改指针。找位置需要从头遍历找到目标位置的前一个节点prev新节点就插在 prev 和 prev-next 之间。这段代码里我最看重的是遍历过程中始终保留 prev 指针这个写法它是所有链表中间操作的基础模板void insertAtPos(Node **head, int pos, int data) { if (pos 0) return; if (pos 0) { insertAtHead(head, data); return; } Node *cur *head; int index 0; while (cur ! NULL index pos - 1) { cur cur-next; index; } if (cur NULL) { printf(位置超出链表长度插入失败\n); return; } Node *newNode createNode(data); if (newNode NULL) return; newNode-next cur-next; cur-next newNode; }循环结束后cur恰好是目标位置的前一个节点。接下来的两步赋值顺序值得推敲先让newNode-next cur-next新节点先握住后面那段链表再让cur-next newNode前一个节点松开旧连接改指向新节点。这个顺序不能反过来。如果先执行cur-next newNode那么后面那段链表就和新节点断开了你手里只有个孤零零的新节点后面的节点全部丢失。这个先接后断的准则在删除操作里同样重要我会在 3.3 节继续强调。3.3 删除节点前驱还是后继想清楚再动删除操作是链表里的高频考点也是初学者犯错最集中的地方。按位置删除核心是找到目标位置的前一个节点 prev让prev-next cur-next跳过待删节点再 free 掉 curvoid deleteAtPos(Node **head, int pos) { if (*head NULL) return; Node *cur *head; if (pos 0) { *head cur-next; free(cur); return; } Node *prev NULL; int index 0; while (cur ! NULL index pos) { prev cur; cur cur-next; index; } if (cur NULL) { printf(位置超出链表长度删除失败\n); return; } prev-next cur-next; free(cur); }注意这里和插入不同插入要找目标位置的前一个节点而删除第一个节点时是没有前驱的必须特殊处理——直接更新头指针。这个头节点删除必须单独分支的细节很多教科书角落里有写但不会强调实际写代码时最容易漏。漏掉的后果是删除第一个节点后头指针还是指向那块已释放的内存下一次访问就会变成悬空指针操作轻则读到垃圾数据重则段错误。你还要注意 free 的顺序——先取cur-next再 free跟插入的先接后断逻辑完全对称。3.4 遍历、查找与释放三个必须写正确的函数遍历打印是最简单的链表函数却最能检验你对指针的理解void printList(Node *head) { Node *cur head; while (cur ! NULL) { printf(%d - , cur-data); cur cur-next; } printf(NULL\n); }查找某个值是否存在逻辑类似只是多一个位置统计int searchList(Node *head, int target) { Node *cur head; int pos 0; while (cur ! NULL) { if (cur-data target) { return pos; } cur cur-next; pos; } return -1; }释放整条链表是链表版块里最少被提起、却在真实程序中最重要的函数。很多人遍历时写着直接 free 当前节点结果只释放了当前节点就丢了下一个节点的地址——相当于一边拆房子一边把下一栋房子的地址烧了后面的房间全泄漏void freeList(Node **head) { Node *cur *head; while (cur ! NULL) { Node *temp cur-next; free(cur); cur temp; } *head NULL; }关键在于在 free 当前节点之前先把cur-next保存到临时变量里。这样即使当前节点被释放你仍然能通过 temp 找到下一个节点的地址继续向后清理。函数最后把*head置为 NULL 也是好习惯——用完一块内存随手把指针置空能有效避免悬空指针。这个 freeList 的写法本质上就是边拆边留地址和前面先接后断是同一个思想在不同场景下的运用。3.5 一个可以直接跑的最小示例把上面的函数拼起来就是一个完整的链表测试程序。我建议你亲手敲一遍而不是直接复制。亲手敲一遍你会体会到一个微妙之处函数之间的调用关系、指针在参数之间传递的流向、以及整个程序在逻辑上的组织顺序。以下是完整代码的组织方式#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } Node; // 前文已实现的函数 // Node* createNode(int data); // void insertAtHead(Node **head, int data); // void insertAtTail(Node **head, int data); // void insertAtPos(Node **head, int pos, int data); // void deleteAtPos(Node **head, int pos); // int searchList(Node *head, int target); // void printList(Node *head); // void freeList(Node **head); int main() { Node *head NULL; insertAtHead(head, 10); insertAtHead(head, 20); insertAtTail(head, 30); insertAtPos(head, 1, 25); printList(head); // 预期输出: 20 - 25 - 10 - 30 - NULL int pos searchList(head, 10); printf(10 的位置: %d\n, pos); deleteAtPos(head, 2); printList(head); // 预期输出: 20 - 25 - 30 - NULL freeList(head); return 0; }提示判定这段程序是真正理解还是抄对了有一个很有效的检验方法——把 insertAtPos 改成在插入后打印链表的长度然后连续插入大量节点观察内存是否稳定。编译时开-fsanitizeaddress可以直接揪出很多肉眼看不出的内存问题。4. 指针悬空、内存泄漏和头指针丢失三个最容易翻车的现场4.1 现场一free 之后继续访问悬空指针的连锁事故有个很典型的错误场景删除节点时代码长这样free(cur); prev-next cur-next; // 错误cur 已经被释放还在读 cur-nextfree之后cur指针变成悬空指针。虽然操作系统大概不会立刻把这堆内存清空或者拿去干别的但在多线程、高分配率或者使用调试版内存分配器的环境下这块内存随时可能被重新分配并写入新数据。你在这时候读取cur-next读到的有可能已经是被改写过的内容。这个 bug 最阴险的一点是它往往不会马上崩溃甚至可能在你的测试环境里运行得非常正常直到生产环境数据量大了才突然爆炸。正确顺序是先取cur-next保存在临时变量再 free再改连接——也就是 3.3 节代码里的写法。除了访问已释放内存还有一种更隐蔽的悬空指针场景对同一块内存调用两次 free。C 标准明确说这是未定义行为表现可能是立即崩溃、被分配器检测到并 abort也可能毫无反应地让堆管理结构被破坏造成后续 malloc/free 的一系列诡异错误。防御性写法是每次 free 之后立刻把指针置 NULL因为对 NULL 调用 free 是安全的、什么都不做。这个习惯成本极低收益却很大。4.2 现场二节点的内存泄漏比想象中容易得多内存泄漏最常见的来源是只有头指针、中途某个指针被错误跳过导致的一段链表再也访问不到。典型的错误是把删除头节点写成了头指针后移但不 free或者反过来free 了头节点但没有保存新头// 错误示例头节点丢失 Node *temp *head; free(temp); // 释放了旧头 *head temp-next; // 错误temp 已释放且旧头保存的下一个地址也丢了另一种泄漏场景是用头指针构建链表后直接在函数结束处 free 了头指针。很多人以为 free 一个指针就等于释放了整条链表实际上 free 只会释放这一个节点对应的内存块其余节点依然挂在堆上只是没有指针能找到它们了。它们不会被自动回收只要程序不退出这块内存就永久占用。对 C 程序来说这种问题在小型练习里看不出来但放到需要连续运行很长时间的服务进程里就是内存只涨不跌最终被系统杀掉。要验证自己的程序是否泄漏在 Linux 环境可以开 AddressSanitizer编译时加-fsanitizeaddress它会报告LeakSanitizer输出精确指出泄漏的内存是在哪个 malloc 调用里分配的。我用这个工具帮人调试过一个批量插入程序每次插入上千个节点后内存涨几百 MB一查就是删除时漏了 free 节点。定位到具体那一行问题几秒钟就清楚了。4.3 现场三头指针被悄悄改掉整条链表只剩一个节点还有一个错误我习惯叫它消失的链表在某次插入操作后链表长度莫名其妙变成 1后面的节点全部人间蒸发。最常见的原因是头插法里把赋值顺序写返了// 错误示例 *head newNode; newNode-next oldHead; // 此时 oldHead 已经丢了等于把新节点指向未知内存更隐蔽的版本发生在把链表作为函数参数传入接收方试图修改头指针但用了错误的一级指针。修改只在函数内部生效外部头指针保持原样看起来链表什么都没变。排查这类问题我的经验是一旦发现链表行为诡异第一时间在关键操作前后打印头指针地址和链表长度。如果发现头指针地址在某个调用前后没有按预期变化就基本可以断定是指针层级传错了。另外还有一种情况是画蛇添足导致的头指针丢失有人觉得头指针每次都用*head太绕于是在函数里自作主张复制一份Node *h *head;然后回去改h改完忘了写回。严格说这不算链表的问题而是局部变量和指针别名的联系没有建立起来但它在链表操作中暴露得特别明显。每次看到这类 bug我都会提醒对方在涉及头指针修改的链表函数里不要用局部变量去代理头指针除非你能保证最终一定有赋值把修改写回去。4.4 调试手链表的实用工具与技巧给初学者一个实用的调试组合编译时开-Wall -g把警告全开运行用 AddressSanitizer 抓内存问题打印链表时带上门牌号地址会更清楚。例如遍历时同时打印printf(节点 %p, 值 %d, next%p\n, (void*)cur, cur-data, (void*)cur-next);这样你能亲眼看到每两个节点之间的链接关系很多指针指歪了的问题一眼就能看出来。还有一个我一直在用的方法画图。别笑链表的指针操作在脑内模拟很容易出错但画在纸上就非常直观。我可以负责任地说多数链表 bug 通过画出插入前后的指针状态能在三十秒内定位。把每个节点画成方框指针画成箭头操作前后各画一张对比一下哪里多了一个箭头、哪里少了一个箭头正确答案自己就浮现出来了。相比对着 gdb 里密密麻麻的内存地址硬看一张纸反而更高效。5. 双向链表与哨兵节点多花一个指针换来的操作自由度5.1 双向链表的节点设计单链表最大的软肋是只能往前走。删除任意节点时你必须从头遍历找到它的前驱节点才能完成前驱的 next 指向后继这个动作所以即使你手里已经持有了目标节点的指针删除它仍然是 O(n)。双向链表用多存一个前驱指针解决这个问题typedef struct DNode { int data; struct DNode *prev; struct DNode *next; } DNode;每个节点同时知道自己的前驱和后继。给定任意一个节点你可以在 O(1) 时间内删除它让node-prev-next node-next再让node-next-prev node-prev。当然这里要小心边界——node 是头节点时node-prev是 NULLnode 是尾节点时node-next是 NULL都需要分支处理。但无论如何双向链表把删除已知节点这项操作的复杂度从 O(n) 降到了 O(1)很多容器库内部都因此采用双向链表。代价是明显的每个节点多一个指针字段64 位系统上每个节点多占 8 字节插入和删除时要多维护一组 prev 指针出错的概率也随之增加。在内存紧张、或者链表里每个节点都很小的情况下这个额外开销不能忽视。我见过有人为省内存硬用单链表实现 LRU结果删除节点时每次都从头遍历性能反而不如多花点内存换成双链表。省内存还是省时间永远是一个根据场景做权衡的题目没有绝对答案。5.2 双向链表的头尾高效操作有了 prev 指针头尾两端的操作都变得优雅。尾插不再需要遍历只要维护一个尾指针 tail新节点接在 tail 后面然后 tail 更新为新节点即可。删除尾节点也只需要拿到 tail 的前驱 tail-prev把它的 next 置空再移动尾指针。整个操作不依赖链表长度全部 O(1)。这种两端都能高效增删的能力正是实现队列、双端队列这类数据结构所需要的。我写过一个用双向链表实现的简易 LRU 缓存哈希表负责 O(1) 查找节点地址双向链表负责维护访问顺序每次访问一个节点就把它从链表当前位置摘下来头插到链表最前面缓存满时直接删除尾部节点。这个结构里双向链表每次摘下任意节点再插回头部的操作是 O(1) 的换成单链表至少要 O(n)性能差距在缓存命中率低的场景下非常明显。如果你以后读到LRU 缓存的标准实现这类内容会发现它几乎总是和双向链表绑定出现原因就在这里——它不是巧合而是两种数据结构的特性刚好互补。5.3 哨兵节点用虚拟头消灭所有空指针分支写链表代码时大量if (head NULL)分支让人疲惫而且每个分支都可能是 bug 的温床。哨兵节点也叫哑节点、虚拟头节点专门解决这件事在链表最前面放一个不存业务数据的固定节点真正的数据从哨兵的下一个节点开始。这样空链表不是 NULL而是一个只含哨兵的链表任何插入删除都不再需要头节点是否为空的特殊判断。哨兵节点让插入和删除代码显著变短。比如在哨兵 双向链表的组合里头插等于在哨兵后面插删除任意节点就是常规的双向摘除不再有目标是不是头节点的分支。很多底层库的头节点设计都采用了这种思路写代码的人省心出错的概率也降低。这里我给一个简单的插入示意哨兵节点占 0 号位置真正的数据从 1 号位置开始所有操作都以哨兵为锚点边界判断被吞进了循环条件里。第一次接触哨兵节点时我一度觉得它是曲线救国不如老老实实写分支判断。但实际用了一个项目后就回不去了——当链表操作密集、插入删除路径非常多时哨兵节点把边界情况的工作量几乎整个抹平了。建议初学者先理解不带哨兵的朴素写法理解每个分支为什么存在再去体会哨兵节点的价值。直接学最优解却不理解它为什么省事反而不容易真正掌握。6. 复杂度分析之外的真相链表的缓存表现与真实选型6.1 理论 O(1) 与实测开销先看看数据在物理上去了哪这是我想重点强调的一个现实。教科书告诉你链表插入是 O(1)这个结论在只看算法步骤数的模型下成立。但现代 CPU 有多级缓存内存访问不是均匀的——从 L1 缓存读数据和从主存读数据延迟可以差一个数量级以上。数组是连续内存遍历时下一个元素大概率已经在缓存里所以即使理论复杂度相同实际遍历速度往往远超链表。链表节点散落在堆上malloc 出来的内存块地址不保证连续大概率分散在每个内存页里。每访问一个节点都可能触发一次缓存未命中也就是要等几百个时钟周期去主存取数据。我曾经在本地用一个 500 万个节点的链表做遍历测试耗时是同样数据量的数组遍历的数倍到数十倍差距之大让人重新审视链表更高效这句话。所以当你听到链表插入 O(1) 优于数组的 O(n)时请先问一个问题数据规模有多大如果链表只有几千个节点插入删除的差距根本感觉不出来如果节点本身是大结构体、而你要频繁按位置增删链表的优势才真正体现。分析复杂度只是第一步真实性能是缓存行为、分配开销、数据规模共同作用的结果。6.2 场景驱动的选型建议什么时候别用链表什么时候必须用基于这些现实我给出一套比较实用的选型倾向以随机访问为主、很少插入删除用数组。下标访问的 O(1) 不可替代。数据量很大但主要在尾部添加用动态数组或循环数组均摊 O(1) 且缓存友好。需要频繁在头部插入删除链表合适数组在这里是 O(n) 的灾难。需要频繁在任意位置插入删除、且删除往往按已知节点操作链表合适尤其是双向链表。数据总量无法预估、又不想频繁扩容拷贝链表按需分配省去扩容的心智负担。对内存占用极敏感、每个节点数据量很小且元素极多链表每个节点额外 8 字节单链或 16 字节双链的指针开销会非常可观要慎重。真实程序里很多需求其实是混合型的最常见也是我最推荐的做法不要只用一种结构而是把数组和链表组合起来。比如哈希表的拉链法就是数组 每条链一条链表的经典组合再比如上面的 LRU 缓存哈希表提供 O(1) 查找双向链表提供 O(1) 顺序调整两个结构各取所长。这种组合思维比盲目迷信某一种数据结构重要得多。6.3 从链表出发下一步应该掌握什么链表是你接触的第一种非连续数据结构它带来的思维升级远比代码本身重要。它至少给你种下了三种意识数据结构的物理组织方式会直接影响操作复杂度指针是把逻辑关系映射到物理内存的桥梁内存是你要亲手管理而不是交给垃圾回收器的资源。这三种意识在你学树、图、栈、队列等更复杂的数据结构时会反复用到。如果接下来想继续深入我建议按这个顺序推进先把单链表的所有操作做到不看书能默写且能说清楚每一步为什么然后实现双向链表再实现循环链表最后用链表实现一个真正有点实际价值的小工具——比如用单链表做多项式加法、用双向链表模拟队列调度。把这些做完你对链表背后的一整套思考方式才算真正消化了。就我个人的经验来说链表值得认真手写三遍以上第一遍照着书抄第二遍合上书自己写第三遍从头设计并补充边界测试。三遍之后的收获比背二十道链表面试题都大。每次重写你都会发现上一遍忽略的细节——可能是某个边界分支可能是某处释放顺序这些细节才是链表真正想教会你的东西。希望这篇分享能帮你把链表吃透也祝你写出不泄漏、不悬空、跑起来稳稳当当的链表代码。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →