☰
单链表操作详解:建表、插入、删除与逆序
2026/9/29 16:11:21 网站建设 项目流程

很多朋友第一次接触数据结构,第一个觉得“有点意思”的东西就是单链表。我当年学C语言的时候,写一个带插入、删除的单链表,能把全班一半人劝退。后来用Python重新把这些操作写了一遍,才发现核心逻辑其实就那么几条:找前驱、改指针、防断链。单链表是后面学习栈、队列、图、哈希表的基石,很多进阶数据结构里都藏着“节点+指针(引用)”的影子。这篇文章我想把单链表从建表到清空、从逆序到循环链表的所有常见操作,按我自己实践的路数重新讲一遍,重点放在边界处理、内存释放和调试技巧上。不论你是刚接触数据结构的在校生,还是刷算法题准备面试的开发者,又或者只是工作中偶尔需要用Python封装一个简单的链表结构,这篇文章都适合你,而且我会尽量用让人听得懂的话把每一步的“为什么”讲清楚。

1. 先从底层逻辑看单链表:为什么它值得认真学

1.1 数组在插入和删除时的“搬砖”困境

很多人学链表之前都在用数组,数组的优点是连续内存、随机访问快,按下标拿元素是O(1)时间。但它最大的短板就是插入和删除太贵。你可以想象一排座位坐满了人,有人想坐到第三排中间,那么后面所有人都得站起来挪一个位置。数组的插入就是这种“挪位置”的操作:在中间插入一个元素,后续元素全部后移;删除中间元素,后续元素全部前移。数据量小的时候无所谓,几万个元素的时候,一次插入就可能引发大量内存拷贝,性能瞬间变差。

链表就是用来解决这个问题的。它不要求元素在内存里连续存放,而是每个节点自己带着“下一个节点的地址”,像一串珠子一样,用线串起来。这样在中间插入或删除时,只需要改动前后两个节点的指针,其他节点完全不动。这就是链表最核心的价值:用牺牲随机访问的代价,换来插入删除的高效率。

1.2 节点的基本组成与“头”的三种角色

单链表的最小单位叫节点,在C语言里通常用结构体定义,在Python里可以用类或者简单的两个属性来模拟。每个节点只有两部分:数据域和指针域。数据域存实际内容,指针域存下一个节点的引用(地址)。最后一个节点的指针指向空,表示链表结束。

这里必须把三个容易混的概念讲清楚:头指针、头结点、首元结点。头指针是指向链表第一个节点的变量,它是链表存在的标志,头指针为None就表示空链表。头结点是在首元结点之前额外添加的一个虚拟节点,它不存实际数据,或者只存链表长度等辅助信息。首元结点则是链表中第一个真正存数据的节点。很多人一开始分不清“头结点”和“首元结点”,其实只要记住:头结点可有可无,有了它,空链表和非空链表在处理上可以统一,代码写起来会少很多if分支。

1.3 带头结点和不带头结点的单链表怎么选

不带头结点的写法是最直白的:头指针直接指向第一个数据节点,空链表时头指针为None。插入到第一个位置时,必须特殊处理,因为要修改头指针本身;删除第一个节点时也一样,得把头指针往后移动一个节点。这些特殊处理容易忘,忘一次就出现空指针异常。

带头结点的写法则是额外创建一个dummy节点,让它作为头结点,真正的第一个数据节点是dummy.next。这样“插入到第一个位置”就变成了“在dummy之后插入”,和其他位置的操作完全一致;删除第一个节点也变成了“删除dummy的下一个节点”,逻辑统一。代价是多用一个节点,但对代码清晰度的提升非常明显。我个人在做算法题时,几乎都给单链表加一个虚拟头结点,这里分享一个小结论:大多数链表修改操作,虚拟头结点都能帮你省掉一半的边界判断。

对比项不带头结点带头结点
空链表状态head为Nonehead指向头结点,头结点.next为None
插入头位置需要修改head在头结点后插入即可
删除头位置需要修改head删除头结点的后继即可
存储开销少一个节点多一个节点,可存辅助信息
代码复杂度分支多分支少,更统一

这两种写法在实际项目中都很常见,Python中由于没有指针,通常用“类模拟节点”的方式,同样可以灵活选择带不带虚拟头结点。我建议入门阶段两种都写一遍,能加深理解。

2. 基本操作拆解:建表、遍历、插入、删除

2.1 建立单链表:头插法和尾插法的取舍

建立单链表最常用的方式有两种:头插法和尾插法。头插法是每次把新节点插到链表头部,也就是让新节点的next指向当前head,再把head指向新节点。这种写法的好处是时间复杂度O(1),不需要遍历链表找尾节点;但缺点是最终链表顺序和输入顺序相反。比如依次输入1、2、3,头插法构建后访问顺序是3、2、1。

尾插法则是每次把新节点接到链表末尾,需要先走到链表尾部再接入,因此普通实现是O(n)的时间。如果数据量很大,可以额外用一个尾指针来记录最后一个节点,这样也能做到O(1)插入。尾插法得到的链表顺序和输入顺序一致,更符合我们的直觉。

用Python实现一个不带头结点的尾插法,代码如下:

class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def build_linked_list(arr): if not arr: return None head = ListNode(arr[0]) cur = head for val in arr[1:]: cur.next = ListNode(val) cur = cur.next return head

代码里用cur这个“移动指针”来代表当前链表的尾部,每接入一个新节点就把cur移动到新节点上。这个方法我建议所有初学者手写十遍,因为后面所有链表遍历操作的基础都是这套“从头走到尾”的思路。

2.2 在指定位置插入一个节点:最容易翻车的边界处理

“在指定位置插入建立单链表”是很多实验课必做的题目,也是热词里出现频率最高的一项。这里的“指定位置”一般有两种理解:一种是指定下标(从0开始),另一种是指定某个节点之后。我们在算法题里最常见的是前者。

插入的核心思想是:找到位置index处的前驱节点prev,然后让新节点new_node.next指向prev.next,再把prev.next指向new_node。顺序千万别写反,如果把prev.next先改了,原来的后继节点就丢了,这个错误我第一次写的时候也犯过,调试了很久。

下面是带虚拟头结点的Python实现,它可以避免插入头位置时修改head的额外分支:

def insert_at_index(head, index, val): dummy = ListNode(0, head) prev = dummy # 移动prev到index位置的前一个节点 for _ in range(index): if prev.next is None: raise IndexError("Index out of range") prev = prev.next new_node = ListNode(val) new_node.next = prev.next prev.next = new_node return dummy.next

这个函数里,如果index为0,那么pre指向的就是dummy,插入之后新节点成为真正的新头结点。如果index大于链表长度,则进入循环时prev.next为None,抛出异常。边界处理的关键点就是我们循环终止时prev停在哪里,以及在修改指针时是否保存了原后继节点。我通常在草稿纸上把“当前链表状态”画出来,再写代码,可以极大概率避免翻车。

2.3 删除节点:找到前驱比找到节点本身更重要

删除操作和插入操作很像,也需要先找到目标节点的前驱节点。如果要删除的是下标为index的节点,实际上就是“跳过”这个节点,让前驱的next直接指向目标节点的next。在Python中,被跳过的节点如果没有其他引用,Python的垃圾回收机制会自动回收;但如果是在C语言里,必须手动free,否则就内存泄漏了。

def delete_at_index(head, index): dummy = ListNode(0, head) prev = dummy for _ in range(index): if prev.next is None: raise IndexError("Index out of range") prev = prev.next if prev.next is None: raise IndexError("Index out of range") # 要删除的节点是 prev.next prev.next = prev.next.next return dummy.next

有一个很容易被忽略的坑:删除最后一个节点时,prev.next是最后一个节点,prev.next.next是None,执行prev.next = None是正常操作;但如果删除倒数第二个节点,prev.next.next指向最后一个节点,执行后链表长度减一,正确。问题往往出在“删头”和“删尾”两个边界上,删头时如果没有虚拟头结点,就必须单独处理head的更新;删尾时要注意索引越界的判断。用虚拟头结点之后,两种情况都被统一了,这也是我在所有删除代码里都加dummy的原因。

2.4 查找、修改与链表长度的统计

查找操作同样需要遍历。最典型的场景就是“判断某个值是否在链表中”以及“返回第一个匹配节点的下标”。注意链表的查找复杂度是O(n),没有数组那样的随机访问能力,这是它的固有缺点。

查找代码很简单,但要提醒一点:遍历时循环条件用cur is not None,而不是用cur.next is not None,否则你就漏掉了最后一个节点。很多人写查找时犯这种错,结果最后一个元素永远找不到。

修改操作通常结合查找来做:先找到节点,再改value。这里不需要修改指针,只需要给数据域赋值,相对简单。计算链表长度时,可以用循环计数,也可以用递归。我强烈建议用循环,因为递归写的长度计算在链表很长时很容易触发Python的递归深度上限,而在C语言中也可能导致栈溢出。工程应用中,链表节点达到十万级并不稀奇,递归不是好选择。

3. 高频进阶操作:清空、逆序、循环单链表

3.1 清空链表:别一上来就断掉头指针

清空链表这个操作,看起来简单,做起来有讲究。最直白但最危险的做法就是直接head = None。在Python里,如果整个链表没有其他引用,确实会被垃圾回收,看起来效果也不错;但这会掩盖一个重要问题:如果链表节点还被其他变量引用,或者你是在C语言里写,那么这些节点就永久泄漏了。

正确的清空思路是遍历链表,逐个断开引用。在C语言里要逐个free节点,在Python中至少要保证所有节点不再被引用。如果你正在用类封装链表,类成员变量还保存着head,那么把head置为None就会让整条链失去根引用,进而被回收,但这依赖GC机制,并不适合用来训练对内存管理的认识。

我推荐的做法是:如果需要清空一个单链表,用一个指针cur遍历,保存下一个节点,然后断开当前节点的next,直到链表结束。虽然Python里是“多此一举”,但这能帮你建立正确的内存管理意识,将来写C、C++或者Rust时会感谢现在这些练习。在一个自定义的LinkedList类中,清空后还要记得把size重置为0。

3.2 Python单链表逆序的三板斧

“python单链表逆序”是搜索热度非常高的关键词,也是面试手撕代码的高频题。核心要求是把链表的指针方向全部反转,也就是原来的第一个节点变成最后一个节点,最后一个节点变成第一个节点。注意这里不能重新建一条新链表,否则空间复杂度就变成了O(n),违背了原题通常要求的O(1)空间。

第一个方法:迭代反转,最推荐。用三个指针prev、cur、next_temp,遍历链表时先保存cur.next,再把cur.next指向prev,然后三个指针整体后移。最终head指向原来的尾节点。代码如下:

def reverse_list(head): prev = None cur = head while cur is not None: next_temp = cur.next cur.next = prev prev = cur cur = next_temp return prev

这个方法需要记住一个关键点:next_temp = cur.next必须在修改cur.next之前完成,顺序不能变。我正式面试时遇到过好几个人在这一点上卡住,一紧张就把next_temp忘了。

第二个方法:递归反转。递归版本代码特别短,但理解起来需要绕一下。它的思路是“先反转后面所有的节点,再把当前节点接到反转结果的末尾”。不过递归在链表很长时有栈溢出风险,面试时可以用,工程中要谨慎。

第三个方法:栈辅助反转。先遍历链表,把所有节点压入栈中,再逐一弹出并重新链接。这个办法最简单,但空间复杂度O(n),只适合对空间不敏感的场景。实战中我优先使用迭代法,因为时间O(n)、空间O(1),逻辑也是最直观的。

3.3 循环单链表:环形结构让边界问题消失一半

循环单链表是指链表最后一个节点的next不再指向None,而是指向第一个节点,形成一个环。这种结构非常适合那些需要“周而复始”访问的场景,比如操作系统的进程调度轮转、约瑟夫环问题。

在循环单链表中,最大的变化是遍历的终止条件。普通单链表用cur is None判断结尾;循环链表不行,你得记录起始节点,当cur再次回到起始节点时停止。因此,循环链表一般保留头指针或尾指针,尾指针指向最后一个节点,这样最后一个节点访问第一个节点就很方便,插入到末尾也直接通过尾指针O(1)完成。

循环单链表的插入删除操作和普通单链表相似,但要注意不能把链表遍历到None。如果你在某个节点后面插入了一个新节点,需要判断该节点是否是最后一个节点,是的话还要更新尾指针。删除操作也一样,如果删除的是尾节点,别忘了把尾指针往前移一个节点。很多初学者第一次写循环链表时,都会因为循环条件写错而出现死循环。我的经验是:先画出链表结构,标出头和尾,再写代码,基本不会错。

下面是一个简单的循环链表构造示例:

def build_circular_linked_list(arr): if not arr: return None head = ListNode(arr[0]) cur = head for val in arr[1:]: cur.next = ListNode(val) cur = cur.next cur.next = head # 尾节点指向头结点,形成循环 return head

注意这个head是首元结点,不是虚拟头结点。循环链表里你同样可以使用dummy节点,但使用时要小心,dummy节点也在环里,遍历时需要跳过。

4. 单链表基本操作实验设计:从零到可运行的测试

4.1 实验的题目设计与模块划分

很多学校的“单链表的基本操作实验”,一般是要求你实现初始化、插入、删除、查找、遍历、清空等功能,并写一个菜单程序来演示。这里我建议不要只写一个main函数堆到底,而是把操作封装成类或者独立函数,再写一个测试模块。

我常用的做法是定义一个LinkedList类,包含head指针和size计数,提供append、insert、delete、search、reverse、clear等方法。然后写一个简单的单元测试函数,每次操作后都打印当前链表内容和长度。如果你用pytest,直接写几个test用例更好,但初学者阶段用普通断言加打印也够了。

一个典型的实验流程可以是这样的:

  1. 从数组初始化链表并打印。
  2. 在头部、中间、尾部各插入一个节点,打印验证。
  3. 删除头部、中间、尾部的节点,打印验证。
  4. 查找一个存在和一个不存在的值,打印结果。
  5. 反转链表并打印。
  6. 清空链表,查验长度是否变成0。

这套流程几乎涵盖了所有基本操作,做完一遍,单链表的理解基本就到位了。实验代码不求花哨,但每操作一步都要能看到链表的状态变化,这是调试自己写的链表最好的方式。

4.2 新手的五个典型错误

第一个错误:插入时先把前驱的next指向新节点,再去设置新节点的next,导致原后继节点丢失。因为前驱的next已经被改变,你再也没有办法拿到原来的后继了。正确的顺序是先把新节点和后继连起来,再让前驱指向新节点。

第二个错误:删除时没有判断链表为空。在空链表上执行删除,prev.next是None,访问None的next属性就直接抛异常。所以任何删除操作前都要考虑空链表的情况,最好用防御性写法。

第三个错误:遍历循环条件多写一个等号或漏掉最后节点。比如用while cur.next is not None作为循环条件,会漏掉最后一个节点;用while cur is not None则正好。写遍历时把这两种条件在脑海里过一遍,能少踩一半坑。

第四个错误:反转链表时丢失后续节点。很多人知道反转是“指回头”,但只顾着把cur指向前驱,忘了保存原来的后继,结果链就断了。这就是我前面反复强调的next_temp必须先保存的原因。

第五个错误:索引和计数边界差一。比如删除第index个节点,循环应该走index步还是index-1步,取决于前驱的初始位置。最好的解决方式是在纸上画出dummy节点、前驱、目标节点、后继节点,用箭头标出走几步。画图不是浪费时间,是真正高效的debug方式。

4.3 调试单链表的高效率工具与技巧

调试链表最怕的就是“脑子里跑代码”。哪怕经验再丰富,链表指针一复杂,脑子也会打结。我的方法是给链表写一个打印函数,输出类似1 -> 2 -> 3 -> None的格式,并且在每一步修改操作前后都调用打印函数。别嫌麻烦,它能在几分钟内帮你定位问题。

另外,刷题网站和IDE里通常支持断点调试,你要善用“watch”面板,观察prev、cur、next_temp三个变量的值。尤其是反转链表这种多指针操作,你可以在每次循环后把三个变量的值记录下来,找出出错的那一步。

还有一个实用的技巧:用最小测试用例验证。比如插入操作,测试空链表插入、在头插入、在尾插入、在中间插入,这四类用例覆盖了所有边界。删除操作同理,删除头、删除尾、删除中间节点,加上空链表和越界情况。你把这些用例都跑一遍,代码的边界条件基本就没有bug了。

我个人在实际操作中的体会是,单链表最想训练的不是“背代码”,而是建立一种对“引用/指针”修改的直觉。凡是涉及链表的修改操作,你先在纸上把前驱和后继画出来,再动手写代码,基本不会出错;调试时再用打印函数把每一步的链表状态输出来,有问题也藏不住。这套方法不仅对单链表有效,后面学习双向链表、循环链表、二叉树时,一样能用得上。最后再分享一个小技巧:给链表类加上一个length字段,时刻维护它的大小,而不是每次现算,这样很多判断越界的逻辑会简单一个量级。希望这篇单链表入门能帮你把基础打得扎实一点,少走一些我当年走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询