ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数据结构链表详解:从C语言实现到Python逆序实战

数据结构链表详解:从C语言实现到Python逆序实战 1. 先搞明白链表到底在解决什么问题1.1 数组的短板在哪里数据结构这门课里链表大概是最先给人下马威的内容。很多人卡在这不是因为代码量大而是因为没搞懂一件事我们明明已经有数组了为什么还要链表我先说结论数组和链表是两种相反的内存组织方式。数组是连续存储链表是离散存储。数组的硬伤有两个。第一个是插入和删除的效率问题。比如一个长度为100的数组要在第3个位置插一个数那第3到第100的位置全部都要往后挪最坏情况下你要搬动99个元素。删除同理。第二个是空间容量问题。数组一旦声明了长度要么浪费要么不够用。想扩容只能再找一块更大的连续内存把旧数据整体复制过去。链表就不管这套。它的每一个元素叫节点是分开存放在内存各处的节点之间通过指针串起来像一个手拉手的队伍。插入一个节点只要把前后两个节点的指针掰一下就行删除也只要绕过去让前一个节点直接指向下一个节点。这就是用空间换时间、用指针换灵活的典型思路。所以链表解决的核心问题是在需要频繁插入、删除的场景下避免大量数据搬运。比如操作系统的任务队列、GraphQL规范实现里的邻接表、LRU缓存淘汰、文本编辑器的撤销链这些底层都有链表的身影。你伸手抓谁它就是谁。1.2 链表家族单链表、双链表、循环链表链表不是只有一种考试和面试里最常碰到的有三个变体单链表、双链表和循环链表。单链表最简单每个节点只有一个指针域指向后继节点。头节点存第一个元素最后一个节点的next指向NULL。正因为只有单向指针单链表的遍历只能从头走到尾想回头不行。删除某个节点时你得先找到它的前驱节点然后让前驱的next绕过它。双链表在节点里多了一个prior指针指向前驱节点。这样既能往后走也能往前走。代价是每个节点要多存一个指针大约多8个字节64位系统下。双链表的删除操作不需要再从头找前驱因为当前节点自己就知道前驱是谁。循环链表则把链表的尾巴卷起来最后一个节点的next不指向NULL而是指向第一个节点。这样做最大的好处是从任何一个节点出发都能遍历到全部节点。循环链表特别适合环形模型比如约瑟夫问题、循环队列、交通路口的信号灯轮询。这三种不是互相替代的关系而是各有适用场景。单链表胜在简单、省空间适合只负责从头往后的线性表双链表胜在灵活适合频繁增删且需要反向遍历的场景循环链表胜在无头无尾适合轮转类逻辑。1.3 带头结点是灵魂不是累赘很多新手在理解带头结点和不带头结点的单链表时会被绕晕。我先说个结论考试时你可能需要知道两者的区别但真正写项目带头结点几乎是默认选项。带头结点的单链表最前面有一个哨兵节点它不存实际数据。真正第一个存数据的节点是头结点下一个节点。不带头结点的话链表为空时head就是NULL插入第一个元素、删除最后一个元素时你必须借助二级指针或返回值才能更新头节点。这会在代码里产生大量特判。带头结点之后空链表和非空链表的处理逻辑就完全统一了。无论链表是不是空的插入操作都是同样的代码路径新节点的next指向p-nextp-next指向新节点。你不用再写如果头节点为空怎么办这类的分支判断。这就是为什么我说带头结点是灵魂——它用多一个空节点换取了代码逻辑的高度统一。\textbf{方便理解}带头结点的链表就像火车头后面挂着一个不载货的缓冲车厢装卸货时无论后面有多少节车厢你的操作手法都一样不带头结点第一节约等于空手操作车头各种情况都得判断。2. 结构体链表基本语法与三种建表方式2.1 链表节点的长相C语言里链表节点最基本的样子就一个结构体这个结构体的关键就是自引用typedef struct Node { int data; // 数据域 struct Node *next; // 指针域指向下一个节点 } Node;注意结构体内部的指针类型是struct Node *不是Node *。因为在结构体还没定义完的时候Node这个typedef别名还不存在就必须写全称。这是新手最容易报错的地方之一。typedef的妙处在于后面声明变量和函数参数时能省很多事。你可以写Node *head; // 声明一个节点指针 Node node; // 声明一个节点变量如果不加typedef上面就要写成struct Node *head;。代码一旦多了这个区别很影响观感。到了C里你可以用类的写法把节点做得更优雅一点struct ListNode { int val; ListNode *next; ListNode() : val(0), next(nullptr) {} ListNode(int x) : val(x), next(nullptr) {} ListNode(int x, ListNode *next) : val(x), next(next) {} };构造函数直接在创建节点时把val和next初始化好这样后面写new ListNode(5)就自动得到一个值为5、指针为空的干净节点不用再手动赋值。这个模式在很多刷题平台的代码模板里都能看到建议直接背下来。2.2 头插、尾插、指定位置插入建立单链表主要有三种方式头插法、尾插法、在指定位置插入。这三种方式代表了全部链表插入操作的原型。头插法把新节点插到链表最前面。核心就两步s-next L-next; // 先让新节点指向老的第一个节点 L-next s; // 再让头结点指向新节点这两步的顺序非常关键。如果先执行L-next s;那老节点的地址就丢了后面的节点全找不回来链表就断了。我见过太多人在这一步翻车先牢记这个口诀先接新节点再改旧头指针。头插法的特点是输出顺序是输入的反序你输入1、2、3链表里存的是3、2、1。有些逆序生成场景会用到它。尾插法把新节点接到链表末尾。实现时需要一个指针从head一路沿着next走到NULL的位置Node *p L; while (p-next ! NULL) { p p-next; } // 现在p就是最后一个节点 s-next NULL; p-next s;尾插法保持了输入顺序但每次都要从头遍历到尾建一个长度为n的链表时间复杂度是O(n²)。改进办法是额外维护一个尾部指针tail直接定位到末尾把复杂度降到O(n)。如果面试里让你一次遍历建好顺序链表思路就是维护tail。在指定位置插入这是最综合的一个操作。先找到第pos-1个节点然后执行插入。int InsertAt(Node *L, int pos, int val) { if (pos 1) return 0; Node *p L; int i 0; while (p ! NULL i pos - 1) { p p-next; i; } if (p NULL) return 0; // 位置无效 Node *s (Node*)malloc(sizeof(Node)); s-data val; s-next p-next; p-next s; return 1; }为什么数到pos-1而不是pos因为单链表的指针结构决定了要给第pos个位置插入节点你必须知道的是它前一个节点的地址然后在后面怼进去。这个思路和数组完全不同数组是就地搬动链表是穿针引线。2.3 遍历、清空、销毁的边界条件遍历链表是最基础也最容易出错的操作。关键在于循环条件的写法// 方式一遍历所有节点 Node *p L-next; while (p ! NULL) { printf(%d , p-data); p p-next; } // 方式二遍历到最后一个节点为止 Node *p L; while (p-next ! NULL) { p p-next; }方式一和方式二的区别是方式一能访问到每一个节点的数据适合打印、统计方式二停在最后一个节点不进去适合找尾节点、插尾操作。新手最喜欢犯的错就是把这两种混着写比如在方式一里写p-next ! NULL结果最后一个节点永远访问不到或者反过来死循环。清空和销毁是两个不同的概念很多教材和实验报告喜欢把它们放到一块讲但实际含义完全不同。清空ClearList是把所有数据节点释放掉但保留头结点。清空之后链表是一个空表还可以继续插入使用。关键代码是Node *p L-next; while (p ! NULL) { Node *q p-next; // 先把下一个节点的地址存下来 free(p); // 再释放当前节点 p q; // 这才能继续走 } L-next NULL;为什么要先存再free因为free之后p指向的内存已经归还给系统里面存放的next指针内容理论上还存在但已经不被保证有效了。继续使用它就等于访问了一个悬空指针这在严格场景下会出问题。这不仅是清空函数也是任何一个释放节点函数的通用思路。销毁DestroyList则是在清空的基础上再把头结点也一并free掉最后把链表头指针置为NULL整个链表就彻底不存在了。3. 实操手写一个完整的可运行单链表3.1 从初始化到插入删除的完整代码光看片段难以建立整体感我把一个带头结点的单链表完整写成一套C语言代码包含初始化、三种插入、删除、查找、遍历、清空、销毁。这套代码我实测过可以直接当成实验报告的底稿也可以作为刷题前的手写模板。#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } Node, *LinkList; // 初始化带头结点的空链表 int InitList(LinkList *L) { *L (Node*)malloc(sizeof(Node)); if (*L NULL) { return 0; } (*L)-next NULL; return 1; } // 头插法建立单链表 int HeadInsert(LinkList L, int val) { Node *s (Node*)malloc(sizeof(Node)); if (s NULL) return 0; s-data val; s-next L-next; L-next s; return 1; } // 尾插法建立单链表 int TailInsert(LinkList L, int val) { Node *p L; while (p-next ! NULL) { p p-next; } Node *s (Node*)malloc(sizeof(Node)); if (s NULL) return 0; s-data val; s-next NULL; p-next s; return 1; } // 在指定位置pos插入pos从1开始 int InsertAt(LinkList L, int pos, int val) { if (pos 1) return 0; Node *p L; int i 0; while (p ! NULL i pos - 1) { p p-next; i; } if (p NULL) return 0; Node *s (Node*)malloc(sizeof(Node)); if (s NULL) return 0; s-data val; s-next p-next; p-next s; return 1; } // 删除第pos个节点 int DeleteAt(LinkList L, int pos) { if (pos 1) return 0; Node *p L; int i 0; while (p-next ! NULL i pos - 1) { p p-next; i; } if (p-next NULL) return 0; // 说明第pos个节点不存在 Node *q p-next; p-next q-next; free(q); return 1; } // 打印所有节点的值 void Traverse(LinkList L) { Node *p L-next; while (p ! NULL) { printf(%d , p-data); p p-next; } printf(\n); } // 清空链表但保留头结点 void ClearList(LinkList L) { Node *p L-next; while (p ! NULL) { Node *q p-next; free(p); p q; } L-next NULL; } // 销毁链表连头结点一起释放 void DestroyList(LinkList *L) { ClearList(*L); free(*L); *L NULL; } int main() { LinkList L; InitList(L); // 尾插法建立 1 2 3 4 5 for (int i 1; i 5; i) { TailInsert(L, i); } Traverse(L); // 输出1 2 3 4 5 // 在第3个位置插入99 InsertAt(L, 3, 99); Traverse(L); // 输出1 2 99 3 4 5 // 删除第2个节点 DeleteAt(L, 2); Traverse(L); // 输出1 99 3 4 5 DestroyList(L); return 0; }这里有两个细节我需要强调。第一InitList(L)必须传二级指针。因为你要在函数内部修改L本身的值给它分配内存只传LinkList L的话外部L依然是NULL或者随机地址。这就是传值和传引用的区别。C语言里函数形参的修改无法直接影响实参所以要传指针二级指针。第二DeleteAt为什么循环条件是p-next ! NULL而不是p ! NULL因为我们要检查的是第pos个节点是否存在p是第pos-1个节点的地址判断p-next是否为NULL就是在判断第pos个节点是否存在。这个条件一旦写错删除最后一个节点时就会出现空指针访问。3.2 单链表的清空和销毁的坑位我再说说清空函数里那两个指针的戏法因为这也是实验报告里最容易写崩的地方。如果写这样一段清空代码Node *p L-next; while (p ! NULL) { free(p); // 错误free之后p-next还能用吗 p p-next; }我第一次写的时候就是这个版本看起来逻辑上没问题先释放当前节点再取它的next继续走。但实际这个行为是不确定的。free(p)之后p指向的内存块已经交还给堆管理器此刻p-next的值可能还在也可能被系统改写了。很多场景下它恰好没变程序能侥幸跑对但这不是规范做法。稍微一发极端情况比如编译器做了内存复用就会读到垃圾指针程序直接崩溃。正确写法我之前已经给过了用q暂存下一个节点地址。这套先存后free的写法叫安全释放记住它在任何涉及链表的C语言项目里都适用。另外清空和销毁往往成对出现在实验里有个习惯我建议你养成每次free之后马上把指针置为NULL。比如free(q); q NULL;。这不是强迫症而是为了防止可能出现的二次free错误。如果不置NULL后续如果不小心又调用了一次free(q)就属于双重释放轻则程序崩溃重则引发内存管理器的安全漏洞。3.3 编程题实训-链表应用怎么组织更高效很多学校的实训或者实验课会让你完成类似单链表的基本操作实验这时候如果盲目上手敲代码很容易写半天还跑不通。我自己的经验是哪怕再简单也要先画图。画图是理解链表操作的万能钥匙。每个节点画成两个格子左边data右边next。插入就是把一条线的指向改一下。删除就是让前一个节点绕过后一个节点再释放它。我记得当时批量刷链表题的时候遇到复杂一点的逆序、合并一定是先在草稿纸上画三步以上的指针变化画完了再写代码一遍过。不要觉得画图浪费时间真正费时间的是以为逻辑对了编译运行后发现指针断链又找不到bug在哪。组织实验报告可以从三个维度来写功能设计、编码实现、测试结果。测试不要只测正常路径还要测边界空表插入、删除不存在的节点、插入位置为0、链表为空时遍历。把这些边界情况写进实验记录报告会非常有说服力更重要的是这些边界case才是你真正理解链表逻辑的证据。后面调试的时候边界测试也是你能最快发现指针边界问题的常规手段。4. 双链表、循环链表与Python单链表逆序实战4.1 双链表多一个指针少很多奔波双链表的学习重点在于它解决的是单链表找前驱困难的问题。单链表删除节点为什么繁琐因为你要从头遍历才能找到前一个节点。如果一个场景需要频繁地从后向前访问单链表就非常吃力。双链表节点多了个prior指针指向它的前驱代价是每个节点多8字节空间。在内存紧张的场景用双链表是否划算需要权衡。不过在大多数应用场景里多出来的这个指针带来的灵活度远高于它的存储成本。双链表插入删除的核心顺序我用一个口诀记先改新节点的刀再接前峰后浪。具体展开是这样的在p节点后插入s节点s-next p-next; // 第1步s的next指向p的后继 s-prior p; // 第2步s的prior指向p if (p-next ! NULL) { // 第3步如果p有后继把后继的prior指向s p-next-prior s; } p-next s; // 第4步p的next指向s为什么第3步要判空因为如果p是尾节点p-next是NULL那p-next-prior就变成对NULL指针的赋值直接崩溃。这个判空很容易漏。删除p的后继节点qq p-next; p-next q-next; if (q-next ! NULL) { q-next-prior p; } free(q);熟悉吗和单链表唯一的差别就是多了处理prior的那一行。所以我的建议是先把单链表彻底搞熟练双链表只是单链表加一个prior方向的维护不要当成全新的知识。4.2 循环单链表的经典场景与实现循环单链表就是把链表尾巴连回头部。典型应用之一就是约瑟夫问题n个人围成一圈从第k个人开始报数数到m的人出列然后从下一个人接着数直到全部出列。这种围圈报数的模型用循环单链表来表达最自然因为链表本身就是环形的。我写一个核心的循环链表删除场景假设有一个循环链表每个节点代表一个人。当前节点p报数m次后需要把p后面的第m-1个节点移除。因为链表是环形的所以不需要担心走到末尾没法回头的边界问题只要循环m-1次让p p-next然后删除p-next即可。实现约瑟夫问题的核心代码片段// 假设循环链表只有头指针节点数n从编号k开始报数到m出列 Node *p cur; // cur初始为第k个节点 while (p-next ! p) { // 还剩多于1人 for (int i 1; i m; i) { // 报数到m移动m-1次 p p-next; } Node *q p-next; // 出列节点 printf(%d , q-data); p-next q-next; free(q); } // 最后剩下一个人 printf(%d , p-data); free(p);这个代码的终止条件是p-next ! p含义是当前节点后面只有一个自己也就是只有一个节点了。写循环链表时终止条件不再是p NULL而是回到自己这点要对齐思路。循环链表还有一个容易被忽略的好处如果你始终维护一个尾指针而不是头指针那么在已知尾指针的情况下头节点的访问复杂度是O(1)——因为尾指针的next就是头。因此循环链表用在频繁从尾部插入、头部弹出的队列场景效率非常高。4.3 Python单链表逆序三种写法一次讲透Python虽然没有指针语法但引用传递的本质和C语言的指针是相通的。Python单链表节点通常这么写class ListNode: def __init__(self, val0, nextNone): self.val val self.next next链表的逆序是面试常考题目也是编程题实训-链表应用里的高频题型。我按从易到难把三种写法写全。迭代法三指针是最推荐的第一思路def reverse_list(head): prev None curr head while curr: next_node curr.next # 暂存后继 curr.next prev # 把箭头反过来 prev curr # 前进 curr next_node return prev画一下过程就懂了prev是已经翻好部分的头curr是当前要处理的节点next_node防止断链。核心是每次循环只处理一个节点的next。头插法从原链表不断弹出头节点再插入到新链表的头部。这种方式逻辑更直观因为它在建立新链表的过程里复用了老节点的空间辅助空间O(1)def reverse_list2(head): dummy ListNode() # 新链表的哨兵节点 p head while p: next_node p.next p.next dummy.next dummy.next p p next_node return dummy.next这个写法和C语言里头插法建表就是表兄弟关系可见链表的核心思想在各语言里是通用的。递归法代码最短但最烧脑def reverse_list3(head): if head is None or head.next is None: return head new_head reverse_list3(head.next) head.next.next head head.next None return new_head理解递归逆序的钥匙是reverse_list3(head.next)已经帮我们把后面整条链表逆序好了现在要做的只是把head节点接到这条新链表的末尾。而head的下一个节点head.next正好是逆序后新链表的末尾所以执行head.next.next head让原来末尾的下一个指向head再把head的next置空。这个思路我第一次学的时候也绕了半天后来发现如果你画不出递归栈就直接把递归当黑盒——假设它处理好了子问题你只需要处理当前节点的对接。5. 常见问题与排查技巧实录5.1 新手翻车率最高的五个坑做链表实验和刷题时有一些错误是极其高频的我整理成一个速查表完全可以当避坑清单用。问题现象根因对策空指针解引用程序运行时崩溃没判断p为NULL就访问p-data循环和操作前先判空断链链表只打印出部分节点修改next顺序错误地覆盖了原指针先改新节点的next再接旧指针悬空指针遍历free后的内存结果不可预期free(p)后又访问p-next用临时指针保存后继再free死循环遍历卡住不结束循环条件写错比如使用p ! head但链表非循环确认终止条件匹配链表类型头结点丢失插入第一个节点后链表无法访问初始化时忘了为头结点分配内存初始化后立刻断言L ! NULL其中先改新节点的next再接旧指针这条值得再强调它不仅是插入操作的核心也是链表所有指针操作的基本原则任何修改指针的语句都不能丢失当前还需要的访问入口。5.2 一招解决80%的调试难题打印大法很多人在链表出bug后第一反应是打开IDE一遍遍断点调试折腾一上午。说实话链表调试最快的方式就是打印大法。做法非常简单在关键操作前和后加一行打印代码把当前节点地址和值打出来。比如printf([debug] p%p, p-data%d, p-next%p\n, p, p-data, p-next);配合遍历函数打印出每一步执行完的链表内容。你会发现错误的位置立刻浮出水面要么是插入后少了个节点要么是某个节点的next指向了NULL导致断开。打印出来的指针地址也能帮你判断两个节点到底有没有被正确串起来——如果两个相邻节点的next前后对不上就说明中间指针被改错了。这个技巧虽然原始但恰恰是工程里最有效率的定位手段。很多用调试器半天找不到的bug打几行print一眼就看得出来。5.3 链表怎么学才值考点与继续扩展方向链表不只是考试题面试和实训里翻来覆去的几个经典问题其实都有套路合并两个有序链表双指针从头比较小的接上。找链表中间节点快慢指针快指针走两步慢指针走一步。判断链表是否有环快慢指针如果相遇就说明有环。这也是快慢指针的经典应用。链表的逆序就是我上面说的那三种写法。这些题的核心都在一个能力上指针操作的顺序意识。你什么时候保存现场、什么时候更新指针、什么时候判断空指针这决定了代码的正确性。刷这些题时我建议你用笔和纸把每一步都画出来。尤其是快慢指针这类稍复杂的场景画图会让你规避大部分思维盲区。等到画图成了习惯你的指针直觉就建立起来了不少难题也就变成了体力活。我认为链表学习最有价值的部分恰恰是它逼着你在内存布局和指针生命周期上建立直觉。这套直觉以后学树、图、LRU缓存、操作系统内核的双向链表、甚至Rust的智能指针都会反复用到。所以说链表现在多花的时间都是在给后面的数据结构攒底子。
返回列表