ARTICLE DETAIL

资讯详情

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

链表核心操作实战:从数组痛点到大厂面试高频考点

链表核心操作实战:从数组痛点到大厂面试高频考点 最近做项目时遇到一个典型的存储问题数据流实时到达数量不可控插入和删除操作非常频繁一开始用数组方案实现一扩容就要整体搬迁数据量大时卡顿肉眼可见。后来把所有存储改成链表实现插入删除只改两个指针问题一下子简化了大半。这也让我想好好聊聊链表这个话题——它是数据结构课程里最基础的一环也是面试笔试、考研复习里出现频率极高的一块内容但说句实话能把链表真正写对的人远比想象中少。不是大家不会写而是对指针或者说引用的语义、边界条件、内存管理理解得不够透。这篇内容适合三类人正在学数据结构的学生尤其是寒假要交实验报告、期末要复习的准备考研或者刷算法题的链表的题看似简单但坑极多以及写C/C/嵌入式底层代码的工程师链表在系统代码里出现的频率比想象中高得多。读完你会理解链表到底是什么、为什么需要它、带头结点和不带头结点的单链表该怎么选以及创建、遍历、插入、删除、逆置这些核心操作怎么写才不容易出错。1. 链表的本质数组做不到的事它来补位1.1 从数组的痛点说起数组大家都很熟它在内存里是一段连续空间下标访问是O(1)这让随机读取非常爽。但它的缺点也很鲜明第一长度基本固定动态数组比如C的vector扩容时要申请新空间再拷贝旧数据这个拷贝成本在某些场景下很高第二在数组中间插入或删除一个元素后面的元素全得往后挪或往前挪最坏情况是O(n)第三频繁地申请和释放大块连续内存时间久了容易产生内存碎片极端情况下明明总内存够用却分配不出一块大的连续区域。用一个生活里的类比来理解链表和数组的区别数组像电影院里一排固定座位座位是连续的你按座位号找位置最快但人坐满了你就得换一个更大的厅中途有人想插队坐进来整排人都要挪。链表则像一群小朋友玩传话游戏每个小朋友只记住下一个小朋友是谁你手里没有名单想找到第5个人必须从第1个人开始一个一个问过去但好处是随时一个电话就能让任意一个人插到队伍中间只需要改前后两个人的“记忆”就行。这就是链表存在的意义用牺牲随机访问性能的代价换来插入删除的高效率和内存空间的灵活性。它不是要替代数组而是在数组不适配的场景里补位。1.2 链表的内存布局与“指针”在指什么链表的基本单位是节点每个节点由两部分组成数据域和指针域。数据域保存真正的值指针域保存下一个节点的地址C语言里就是指针Java/Python里叫引用本质也是地址。多个节点通过指针一个接一个串起来就形成了链表。用快递柜来类比可能更好理解每个柜子节点里放着一份包裹数据柜门上贴着一张便签next指针便签上写的是下一个柜子的编号。你去看第3个柜子的时候不是你直接知道它在哪而是先看第1个柜子的便签找到第2个再看第2个的便签找到第3个。这就是链表遍历的本质——顺着指针走。C语言里节点结构体通常这么定义typedef struct Node { int data; struct Node *next; } Node;注意这里的next是指向struct Node的指针也就是说next里存的是另一个节点的地址。务必区分“节点的地址”和“节点本身”写代码的时候脑子里始终清醒这一点很多指针错误都能避免。1.3 复杂度对比链表不是万能的把常用操作的时间复杂度列出来一眼就能看出链表的优势和软肋操作数组链表按下标随机访问O(1)O(n)头部插入/删除O(n)全部后移O(1)尾部插入有尾指针O(1)O(1)尾部插入无尾指针O(1)O(n)中间插入/删除O(n)搬移元素O(1)前提是已经定位到位置内存分配方式连续大块分散小块逐个申请表格里最值得关注的是中间插入删除那一行链表的插入删除本身确实是O(1)但“定位到目标位置”这个过程是O(n)的所以很多人说链表“插入快”其实是片面的要看前提。这也是面试里常考的细节答的时候别张口就说O(1)先说明是否已经持有目标节点的指针。另一个不得不提的点是缓存友好性。数组是连续内存遍历时CPU缓存命中率很高链表节点分散在堆里每次访问下一个节点大概率是缓存未命中所以实际性能往往比理论复杂度差不少。描述算法时大家都用大O真正做工程时还要把常数因子和缓存行为考虑进去。2. 链表家族带头结点、不带头结点、双向、循环怎么选2.1 单链表是地基所有链表的花样本质上都是从单链表演化出来的。单链表每个节点只有一个next指针只能从前往后遍历想找前驱节点必须从头再来一遍。虽然能力有限但正因为结构最简单它成了学习指针操作最好的训练场。写单链表时脑子里要有一幅图一个头指针head指向链表的第一个节点每个节点指向下一个最后一个节点的next为NULL这是链表结束的标志。空链表就是head NULL没有节点可遍历。这幅图看起来简单但其中隐藏着大量边界条件的坑后面第3章会详细说。2.2 带头结点和不带头结点的区别我踩过的坑这是非常多人搞混的点也是热词里反复出现的“带头结点的单链表”和“不带头结点的单链表”。两者的核心区别在于不带头结点时head直接指向第一个真实数据节点带头结点时head指向一个额外的、不存有效数据的头结点也叫哑结点真正的数据节点从头结点的next开始。不带头结点的特点是代码更精简但处理“删除第一个节点”和“空表插入第一个节点”时很麻烦。因为这两个操作会改变head本身的值C语言里要么用二级指针Node **head要么让函数返回新的head。很多新手在这里卡壳写出来的删除函数在删除头节点时会丢链表。带头结点的做法是给链表增加一个永远存在但数据域不用的哑结点。这样空表时head不为NULL只是head-next NULL无论删除哪个节点都是对某个prev节点的next做操作代码完全统一不需要额外分支判断“是不是第一个”。工程界普遍更推荐这种方式比如Linux内核链表就是用类似的思路。我的个人建议非常明确如果是为了写实验报告、应付考试、或者做工程代码优先选带头结点如果纯粹是为了练指针细节或者面试题明确说不带头结点那也要会。两者都不难难的是你写之前没想清楚到底用的是哪种半吊子代码最容易出bug。2.3 双向链表和循环链表场景驱动双向链表是在单链表基础上给每个节点增加一个prev指针指向它的前驱。好处是已知某个节点时可以O(1)删除它自己不需要从头找前驱坏处是每个节点多了一个指针内存开销变大插入删除时要维护的指针从一个变成两个代码复杂度上升。应用场景也很典型浏览器的前进后退、文本编辑器的撤销重做、LRU缓存淘汰算法这些都需要频繁地前后移动或快速删除节点没双向链表会很难受。循环链表则是把最后一个节点的next从NULL改为指向头节点或第一个数据节点形成一个环。它的核心价值是从任意节点出发都能遍历整个链表。最经典的案例是约瑟夫环问题一群人围成一圈报数出列用循环链表模拟再自然不过。操作系统里的时间片轮转调度、音频播放器里的循环播放列表本质也是循环链表的逻辑。还有一个组合形态双向循环链表每个节点有prev和next首尾相连。Java里的LinkedList底层实现就是这种结构既有双向链表的删除灵活性又有循环链表的环形遍历能力。学到这里你会发现数据结构里的形态都不是凭空设计的每个字段都有它服务的具体场景。嵌入式方向的朋友还会接触到一种更特殊的“侵入式链表”比如Linux内核的list_head结构。普通链表是节点里包含数据侵入式链表是结构体里内嵌链表节点通过container_of宏再从链表节点找到整个结构体。这种设计让同一份链表代码可以管理任意类型的对象非常灵活是内核代码里最常见的链表形态。刚学链表时不用深入但知道有这回事以后看内核源码不至于懵。2.4 选型建议先想场景再选结构使用场景推荐结构选择理由频繁头部插入无双向需求不带头结点单链表头插O(1)代码最简写实验报告、工程代码逻辑要稳带头结点单链表空表/非空表操作统一bug少需要O(1)删除已知节点双向链表有prev指针可直接改前驱环形调度、约瑟夫问题循环单链表尾节点指针直接指向头天然成环缓存淘汰LRU、撤销重做双向链表哈希表新数据插头旧数据删尾哈希表O(1)定位选链表结构时先问自己三个问题需不需要双向遍历需不需要环形访问空表逻辑是希望统一写还是分开处理三个问题想清楚结构基本就定了。不要在写代码的过程中反复摇摆那是最消耗心力的。3. 核心操作一步步写出来创建、遍历、插入、删除、逆置3.1 节点定义与三种创建链表的方式先看C语言的节点定义这在前面已经给过了。创建链表常见有三种方式头插法、尾插法、维护尾指针的尾插法。头插法很好理解新节点永远插在链表最前面插入时把新节点的next指向当前头节点再更新头指针指向新节点。注意这个“先改新节点再改头指针”的顺序很多人反着写结果丢掉了原来的链表。代码长这样Node* headInsert(Node *head, int val) { Node *newNode (Node *)malloc(sizeof(Node)); if (newNode NULL) { return head; } newNode-data val; newNode-next head; return newNode; } // 调用示例 Node *list NULL; for (int i 1; i 5; i) { list headInsert(list, i); } // 循环结束后链表顺序是 5-4-3-2-1注意头插法有个隐藏特性插入顺序和最终链表顺序相反。如果输入1、2、3输出就是3、2、1。这个特性有时很好用比如逆置链表可以用头插法重新建表。尾插法就是新节点永远追加到链表末尾顺序和输入一致但每次插入都要从头遍历到尾时间复杂度O(n)。如果知道当前链表的尾节点在哪可以专门维护一个tail指针每次直接在尾部追加插入就变成O(1)void tailInsert(Node *tail, int val) { Node *newNode (Node *)malloc(sizeof(Node)); if (newNode NULL) return; newNode-data val; newNode-next NULL; tail-next newNode; }这里tail指向原来的尾节点只需要更新tail-next然后让tail tail-next就行。很多人写尾插法时每次都从头遍历不是说不对而是没有利用已有的信息白白多了O(n)开销。头指针、尾指针、当前位置指针这些信息在写链表时要时刻问自己我手里已经有什么能不能少走一段路。3.2 链表遍历最容易写错的地方遍历是最基础的操作但恰恰是最多人写错的地方。新手最常见的两个错误第一个是循环条件写错用while (p-next ! NULL)却想打印每一个节点结果最后一个节点永远打印不到第二个是遍历时直接移动了头指针head遍历完整个链表找不回头了。什么时候用while (p ! NULL)什么时候用while (p-next ! NULL)判断依据是你想对当前节点做什么。打印、访问、统计数据这些用while (p ! NULL)循环体内处理p每次循环让p p-next。查找尾节点、在最后一个节点后面插入这些用while (p-next ! NULL)循环结束后p正好停在尾节点上。遍历时也建议这样写void traverse(Node *head) { Node *p head; while (p ! NULL) { printf(%d , p-data); p p-next; } printf(\n); }用一个独立的遍历指针p而不是直接移动head。这个习惯极其重要——链表操作里经常需要多次遍历如果head被弄丢了整个链表就再也找不回来了。我见过太多排查半天最后发现head指针被改没了的案例全是因为随手用head去遍历。3.3 插入与删除画图比写代码更重要链表的插入删除画图比写代码重要十倍。先说插入在prev节点后面插入newNode核心就两步先把newNode-next指向prev-next再把prev-next指向newNode。顺序绝对不能被反过来如果先改了prev-next原来的后续节点就丢了。为什么顺序这么重要因为prev-next是唯一的“线索”它指向原来的下一个节点。先把这个线索存到newNode-next里再切断原来的连接新的连接才建立得起来。这个逻辑用代码写出来非常短int insertAfter(Node *prev, int val) { if (prev NULL) { return -1; } Node *newNode (Node *)malloc(sizeof(Node)); if (newNode NULL) { return -1; } newNode-data val; newNode-next prev-next; prev-next newNode; return 0; }删除操作同理要删除prev后面的节点先用临时指针tmp把它记下来然后把prev-next指向tmp-next最后释放tmp。删除时最容易忘的是free(tmp)在C语言里这意味着那个节点占的内存永远无法被回收了程序跑久了就是内存泄漏。int deleteAfter(Node *prev) { if (prev NULL || prev-next NULL) { return -1; } Node *tmp prev-next; prev-next tmp-next; free(tmp); return 0; }写插入删除时还有一个经验每次写完都拿笔在纸上把链表的每个节点和箭头画一遍。比如1-2-3-NULL在2后面插入4先画4指向3再画2指向4结果就是1-2-4-3-NULL。画一遍就通透了靠脑补很容易漏掉某条线的断开。3.4 单链表逆序面试高频题单链表逆序是面试笔试里出现频率最高的链表题因为它足够考察对指针操作的理解。核心思路是三指针法用prev指向已逆序部分的头cur指向当前正在处理的节点next保存cur原来的下一个节点。每一步做三件事记录next把cur-next改为prev然后prev和cur整体前进。Node* reverseList(Node *head) { Node *prev NULL; Node *cur head; while (cur ! NULL) { Node *next cur-next; cur-next prev; prev cur; cur next; } return prev; }为什么需要一个临时变量next因为当cur-next被改成prev之后cur原来的下一个节点就丢了如果不提前保存后面就断线了。三指针的本质就是“在断开旧连接之前先把新的路标记住”。我们逐步走一遍初始链表1-2-3-NULLprev NULLcur 1。第一步next 2然后1-next NULLprev 1cur 2第二步next 32-next 1prev 2cur 3第三步next NULL3-next 2prev 3cur NULL。循环结束返回prev也就是3新的链表就是3-2-1-NULL。测试时建议一定试三种输入空链表、只有一个节点的链表、有两个节点的链表。这三种情况看着简单恰恰是最容易写出bug的地方。很多人在一个节点的链表上返回了NULL或者在空链表上解引用空指针都是因为只按普通长度的例子推演没考虑边界。3.5 Python版换个语言思路更清晰用Python写链表逻辑和C语言完全一样差别只在指针换成了引用。Python里对象的引用天然就是地址的概念不需要显式声明二级指针写起来更干净。class Node: def __init__(self, data): self.data data self.next None class LinkedList: def __init__(self): self.head None def insert(self, data): new_node Node(data) new_node.next self.head self.head new_node def traverse(self): cur self.head while cur: print(cur.data, end - ) cur cur.next print(None) def reverse(self): prev None cur self.head while cur: next_node cur.next cur.next prev prev cur cur next_node self.head prev你看三指针逆置在Python里基本就是C语言版本的直译。但我经常看到有人用Python写链表时犯一个错误误以为cur self.head然后cur cur.next就能修改self.head本身。不能cur只是一个局部变量修改它只是让它指向别的节点影响不到self.head。想真正修改链表头必须显式写self.head prev。这个误区很常见本质是对“引用是拷贝的”理解不透彻。Python中的字符串、列表、字典处理得多玩链表反而有种新鲜感。如果你想专门练链表用Python刷题是最舒服的不用考虑malloc和free把全部注意力放在指针引用变换上练会了再去C语言里补内存细节事半功倍。4. 常见问题与排查技巧实录4.1 空指针与野指针报错最多的两种链表的崩溃十个里有八个是空指针解引用剩下两个是野指针。NULL-next崩溃。NULL-data崩溃。malloc失败返回NULL后没判断还是崩溃。解决办法是每次使用前检查指针是否为NULL尤其是传入函数的head、prev这类参数写防御性判断是好习惯。野指针则更隐蔽。C语言里一个节点被free之后那块内存还在但已经不属于你了再通过残留的指针访问它读到的可能是脏数据写则可能破坏其他数据程序表现千奇百怪。规避办法有两个free之后立刻把指针置为NULL不让它变成悬垂指针设计好节点的“所有权”每个节点在同一时刻只能有一个明确的管理者删除操作明确由谁来释放。我之前带新人时就遇到过这么个bug删除链表节点后代码里某个地方还保存了那个被删除节点的地址下次遍历时通过这个地址去读取数据结果数值一会儿对一会儿错。排查了好久才发现是“用悬垂指针访问已释放内存”的问题。从此我要求所有删除操作的代码free之后一律补一句ptr NULL这样即使后期误用也会立刻崩溃而不是静默出错——崩溃可比静默错误好定位多了。4.2 链表成环死循环的元凶链表遍历的循环条件是while (p ! NULL)如果链表里意外出现一个环p永远不会变成NULL程序就死循环了。成环的原因通常是某个节点的next被错误地指向了它前面的节点或者循环链表构造逻辑写错了却用普通遍历去访问。怎么判断链表有没有环经典解法是快慢指针也叫“龟兔赛跑”slow每次走一步fast每次走两步如果链表有环二者必然在环内相遇如果没环fast会先到达NULL。int hasCycle(Node *head) { if (head NULL) { return 0; } Node *slow head; Node *fast head; while (fast ! NULL fast-next ! NULL) { slow slow-next; fast fast-next-next; if (slow fast) { return 1; } } return 0; }为什么快指针要走两步而不是三步因为两步能保证在环内一定“追上”而不是“跳过”慢指针。从数学上看只要它们的速度差是1一步 vs 两步每走一圈距离差就减少1格最终必然会相遇如果速度差是2就可能出现快指针越过慢指针而没碰到的情形判断就不可靠了。链表判环还能延伸出“找到环入口”的进阶题那就是快慢指针相遇后再双方各走一步相遇点就是环起点这个结论推导起来也很有意思面试时可以直接用。4.3 内存泄漏free头指针不等于销毁链表写C语言链表时很多人销毁链表只写了一句free(head)以为就完了。实际上free(head)只释放了头节点一个节点的内存后面所有节点全泄漏了。正确做法是逐个节点地释放用一个指针cur从头开始每次用一个tmp记录下一个节点free掉当前节点再继续。void destroyList(Node *head) { Node *cur head; while (cur ! NULL) { Node *tmp cur-next; free(cur); cur tmp; } }这里同样用到“先保存下一个节点再释放当前节点”的思路和逆置时“先保存next再改指针”如出一辙。凡是涉及“断开连接”的操作都有一个共同原则保存退路再动手。4.4 边界条件写代码前先列三个用例我给新人的建议永远是一样的链表代码动手之前先心里默念三个用例——空表、单节点表、双节点表。然后针对每个操作问自己我的代码在这三种输入下分别是什么行为比如删除节点时删除的是唯一一个节点删除后head应该变成NULL如果用不带头结点的写法而忘了更新head这个节点删除后链表还是指向一块已释放内存下一次访问就崩了。再比如逆置空表返回什么单节点表返回什么双节点表返回什么这三个用例跑对了边界问题基本就过去了。刷题的时候我习惯把边界用例写在草稿纸上代码写完第一个动作就是拿这三个用例在脑子里推演一遍推演通过再考虑编译运行。这个习惯帮我拦下了大量本可以避免的bug也成了我带人时反复强调的铁律。4.5 调试技巧学会看三个指针链表调试和普通逻辑调试不太一样光靠print大法很容易晕因为数据一多打印出来的顺序和预期对不上你不知道是逻辑错了还是打印本身就错了。我的推荐做法是写一个dump函数把链表每个节点的地址、数据值、next地址都打出来像这样void dumpList(Node *head) { Node *p head; int index 0; while (p ! NULL) { printf([%d] addr%p data%d next%p\n, index, p, p-data, p-next); p p-next; index; } }这一步的好处是把链表变成可视化的一行行信息节点地址、值、下一个节点地址全都暴露出来。一旦出现断链你立刻能看到明明这个节点的next应该指向下一个节点打出来却指向NULL或者指向了错误地址。调试链表还有一个很实用的心法把注意力集中在prev、cur、next这三个指针上尤其是逆置和删除类操作。每走一步都问自己prev是谁cur是谁next是谁三者之间应该是什么关系代码跑出来的实际关系又是什么把这三个指针的值打印出来逻辑就清晰了。别试图一次调一堆逻辑链表问题永远是“一次只改一条链接”。写了很多次链表代码最大的感触是链表题的难度不在“懂”而在“稳”。画图人人都会代码一写就容易在细节上翻车。多做几道题就会发现规律很固定——保存next再改指针、检查NULL、处理空表和单节点表这三点反复出现。理解了这三板斧再遇到任何链表变种合并有序链表、找中间节点、删除倒数第k个节点思路都会顺畅很多。最后再分享一个小技巧练习时把带头结点和不带头结点两种写法都自己实现一遍然后对比删除操作的代码差异这个对比做完你对链表头指针的理解会上一个台阶。
返回列表