
1. 数组的短板与链表的出现逻辑在用Python做开发的时候很多人第一反应是列表List不是够用了吗为什么还要折腾链表这种想法我太理解了因为Python的列表确实太好用了什么都能往里塞还能切片、拼接。但如果你深入业务底层或者面试算法题被问到链表反转、环形链表检测你就会发现对链表的理解程度往往直接影响你写代码时的底气。先说说数组/列表的痛点。Python的列表虽然是动态数组但它在内存里是一段连续的存储空间这带来两个问题第一当列表扩容时需要重新分配一整块更大的连续内存然后把旧数据全部拷贝过去这个操作的代价是O(n)的第二在列表头部插入元素时所有后续元素都要整体后移一位同样是O(n)的操作。在高频插入、删除的业务场景里这两个O(n)累积起来性能损耗非常可观。链表则是用一种空间换时间的思路彻底绕开了这个问题。链表里的每个元素节点不要求内存地址连续它的存储方式是每个节点除了保存自己的数据还保存了指向下一个节点的引用在Python里就是对象引用。就像一条珠子串成的项链每颗珠子都知道下一颗珠子在哪整条链子不需要整块的存储空间也不需要在中间插入珠子时把后面的珠子全部重排只需要把相邻两颗珠子的连接关系改一下就行。我在实际项目中遇到过这样一个场景某个业务需要维护一个频繁在头部添加、尾部删除的任务队列一开始用Python列表实现上线后QPS一高头部插入的性能瓶颈立刻暴露GC压力也变大。后来换成链表思路重写头部插入变成O(1)整体性能提升非常明显。这个例子让我明白了一个道理——数据结构不是教科书上的名词而是实打实影响系统性能的底层能力。所以这篇文章我会从链表的最基础概念开始一步一步用Python手写实现再深入到反转、环检测、合并等高频考点最后分享一些我在实战中总结的经验和踩过的坑。不管你是刚学数据结构的入门者还是准备面试的求职者都能从中找到有价值的东西。2. 手写链表的地基节点定义与基本骨架2.1 Python里的指针到底怎么理解很多从C语言转过来的同学在学Python链表时最纠结的一个问题是Python没有指针怎么写链表这个问题我当年也想了好久但其实Python的变量本质就是指向对象的引用你只需要明白a node这行代码的含义——让a这个变量名指向node对象所在的内存区域这本质上就是一种指针。用个生活化的类比C语言的指针是你拿着门牌号直接去找房间而Python的引用是你在笔记本上记下那个房间在哪里。两者最终达到的效果一样只是Python帮你封装了地址操作。所以写Python链表时你不需要关心next变量存的是地址还是引用只需要知道current.next可以让你走到下一个节点。class Node: def __init__(self, data): self.data data self.next None这是最基础的节点定义。data存数据next存下一个节点的引用。None就相当于C语言里的NULL表示链表走到了尽头。2.2 单链表的完整骨架从头到尾搭起来有了节点就可以搭链表了。我建议第一次写的时候不要急着加各种高级方法先把最小可用的骨架搭出来能创建空链表、能往头部加节点、能往尾部加节点、能遍历打印。这四个操作能跑通说明你对链表的基本内存模型已经理解了。class LinkedList: def __init__(self): self.head None self.size 0 def is_empty(self): return self.head is None def add_first(self, data): new_node Node(data) new_node.next self.head self.head new_node self.size 1 def add_last(self, data): new_node Node(data) if self.is_empty(): self.head new_node else: current self.head while current.next is not None: current current.next current.next new_node self.size 1 def display(self): current self.head elements [] while current is not None: elements.append(str(current.data)) current current.next print( - .join(elements))需要注意两个细节。第一add_first里有个经典操作new_node.next self.head然后self.head new_node。顺序不能反如果先把self.head指向新节点就找不到原来的头节点了链表就断了。这个顺序问题很多初学者都会搞错我建议在脑子里想象一下串珠子的动作新珠子先勾住原来的第一颗珠子再把自己变成第一颗。第二add_last需要遍历到链表的倒数第二个节点所以判断条件是while current.next is not None而不是while current is not None否则遍历结束时你就站在None上了没法再往前修改上一个节点的next。2.3 为什么维护size字段很重要我见过不少人写链表时不维护size字段觉得需要长度时遍历一下就行。这个想法在链表长度很短的时候没问题但一旦节点数上了万级每次求长度都遍历一遍就是明显的浪费。所以我的建议是在增删操作里同步维护self.size求长度直接返回字段值时间复杂度O(1)。这本身就是一种用空间一个整数换时间O(n)遍历的策略。当然维护size也有代价——你必须在每个修改链表结构的方法里都记得更新它。我在自己项目里就踩过这个坑写了一个remove_node方法删除了节点但忘了self.size - 1结果后面用size做判断时空链表还认为自已有元素导致逻辑错误。所以记住凡是在链表类里修改节点个数的方法都必须同步更新size。3. 链表的增删查改核心操作的心法拆解3.1 插入头插、尾插、指定位置插入头插和尾插上面已经实现了这里重点讲指定位置插入。很多书上会把指定位置插入写成一大段if-else但我觉得真正的解题思路是找前驱节点。要在第index个位置插入新节点假设index从0开始数核心操作是找到第index-1个节点也就是新节点的前驱。然后执行两步新节点的next指向前驱的next前驱的next指向新节点。两步的顺序同样是先搭新节点和后续节点的关系再断开原有的连接防止链表断裂。def insert_at(self, index, data): if index 0 or index self.size: raise IndexError(Index out of range) if index 0: self.add_first(data) return if index self.size: self.add_last(data) return new_node Node(data) prev self.head for _ in range(index - 1): prev prev.next new_node.next prev.next prev.next new_node self.size 1这里有个边界条件的细节index 0时会退化成头插index size时会退化成尾插。我建议在写代码时把这两个边界条件显式列出来而不是试图用通用逻辑去覆盖这样可以减少很多心智负担。3.2 删除想清楚找目标节点还是找前驱节点删除操作有两种常见写法一种是找到目标节点然后删另一种是找到目标节点的前驱然后删。我推荐后一种因为删除的本质就是让前驱跳过目标节点。def remove_at(self, index): if index 0 or index self.size: raise IndexError(Index out of range) if index 0: removed_data self.head.data self.head self.head.next self.size - 1 return removed_data prev self.head for _ in range(index - 1): prev prev.next removed_node prev.next removed_data removed_node.data prev.next removed_node.next self.size - 1 return removed_data删除头节点时要特别小心直接把self.head指向第二个节点即可。在Python里被删除的节点如果没有其他引用会自动被垃圾回收机制回收不需要像C语言那样手动释放内存。这也是Python写链表比C语言舒服的一个重要原因。有的同学可能会问删除指定值的节点怎么写思路是遍历链表找到第一个值匹配的节点记录它的前驱然后执行删除。这个操作的时间复杂度是O(n)因为链表不支持像数组那样的随机访问只能顺序遍历。3.3 查找与修改链表的短板在这里暴露链表的查找和修改都是O(n)的这一点和数组的O(1)随机访问相比是天壤之别。原因很简单数组可以通过下标直接计算出内存地址而链表只能从头节点开始一个一个顺着引用往后走。def get(self, index): if index 0 or index self.size: raise IndexError(Index out of range) current self.head for _ in range(index): current current.next return current.data如果你需要在业务里频繁按下标查找元素链表可能不是最合适的数据结构哈希表或者数组会是更好的选择。这就是为什么写代码之前一定要想清楚需求——不是链表不好而是它不适配随机访问这个场景。3.4 哨兵节点的妙用让边界处理变成常规操作我在写了大量链表代码后最大的觉悟就是引入哨兵节点dummy node。所谓哨兵节点就是在链表头部前面放一个不存业务数据的占位节点它的next指向真正的头节点。有了哨兵节点之后所有针对头节点的特殊处理都可以省掉。头插、删除头节点这些操作和中间节点的操作完全一样不需要额外的if判断。这个技巧在算法题里尤其常用我后面讲反转链表的时候还会再用到它。提示哨兵节点的核心思想是用一个永远存在的占位节点消除边界分支。如果你在处理链表问题时觉得if太多了先想想能不能加个哨兵。4. 链表的高频进阶题反转、环检测与合并两个有序链表链表的基础操作学会之后真正拉开差距的是下面这些进阶操作。它们不仅面试常考在实际项目里也有应用场景比如反转链表可以用于实现浏览器前进后退的某种序列翻转逻辑环检测则可以用于排查数据库连接池里的循环引用问题。4.1 反转链表的三指针法到底是怎么想出来的反转链表的题目刷过的人都知道但很多人是背代码背下来的一旦换个问法比如反转前N个节点、按K个一组反转就懵了。我觉得要真正掌握反转链表得理解它的核心矛盾单向链表只能往前走不能回头所以当你想让当前节点指向前一个节点时必须先保存下一个节点的位置否则链表就断了。三指针法的操作步骤我用文字拆解一下初始化prev为Nonecurr为headnext_temp用于暂存curr.next。每一轮循环里先把curr.next保存到next_temp防止丢失然后把curr.next指向prev完成反向接着prev移动到curr的位置curr移动到next_temp的位置。当curr走到None时prev正好指向反转后的新头节点。def reverse(self): prev None curr self.head while curr is not None: next_temp curr.next curr.next prev prev curr curr next_temp self.head prev用个具体例子模拟一下链表1 - 2 - 3 - None。第一次循环保存21的next改为Noneprev变成1curr变成2第二次循环保存32的next改为1prev变成2curr变成3第三次循环保存None3的next改为2prev变成3curr变成None。循环结束head指向3链表变成3 - 2 - 1。这个模拟过程建议你自己在纸上画一遍比看十遍代码都管用。我当年就是在纸上画了三遍之后才真正理解了为什么需要三个指针。4.2 快慢指针检测环形链表为什么快指针走两步而不是三步环形链表的经典检测方法是用快慢指针快指针每次走两步慢指针每次走一步如果链表有环两个指针最终会相遇。这个思路很多人知道但没想过为什么快指针走两步。假设链表有环环的长度为L。当慢指针进入环的时候快指针已经在环里走了若干步。假设此时快指针和慢指针之间的距离是d在环上顺着前进方向量那么每轮循环快指针比慢指针多走一步所以距离d每轮减少1。只要d是有限的正整数经过d轮之后两指针必然相遇。如果快指针走三步两者距离每轮减少2但当距离不是2的倍数时可能会跳过相遇点导致永远碰不上。所以快指针走两步这个设定刚好保证每次快慢指针的相对速度差为1必然会相遇。def has_cycle(self): slow self.head fast self.head while fast is not None and fast.next is not None: slow slow.next fast fast.next.next if slow is fast: return True return False注意while循环里的条件是fast is not None and fast.next is not None这个顺序不能反。如果fast本身就是None访问fast.next会直接报错。先判断fast is not None再判断fast.next is not None是符合短路求值逻辑的安全写法。4.3 合并两个有序链表递归写法和迭代写法怎么选合并两个升序链表是另一个高频题。递归解法代码非常短但理解起来稍微费劲迭代解法稍微长一点但逻辑直白。我先展示递归解法因为它最能体现链表天然递归结构这一个特性。def merge_two_lists(l1, l2): if l1 is None: return l2 if l2 is None: return l1 if l1.data l2.data: l1.next merge_two_lists(l1.next, l2) return l1 else: l2.next merge_two_lists(l1, l2.next) return l2递归的核心逻辑是每次只比较两个当前节点的大小把较小的节点作为结果链表的第一个节点然后把它next指向剩余部分合并后的链表头。这种把问题缩小到一个子问题的思路配合链表的递归结构代码会非常优雅。但递归有个潜在问题链表太长时递归深度过大会导致栈溢出。在实际项目中如果链表长度可能超过几千我会用迭代解法def merge_two_lists_iter(l1, l2): dummy Node(0) tail dummy while l1 is not None and l2 is not None: if l1.data l2.data: tail.next l1 l1 l1.next else: tail.next l2 l2 l2.next tail tail.next tail.next l1 if l1 is not None else l2 return dummy.next这里用到了前面说的哨兵节点dummy作为新链表的头tail作为尾指针每轮循环把较小节点接到tail后面然后逐步向后移动。循环结束后把剩余的非空链表直接接在tail后面即可。因为剩余部分本来就是有序链表所以这一步的复杂度是O(1)。5. 双链表的进阶实现从单向到双向要做什么改造5.1 为什么需要双链表它解决了什么问题单链表最大的痛点是只能从头往尾走。假设你要删除某个节点的前驱节点在单链表里你只能从头遍历找到前驱的前驱很麻烦。但在双链表里每个节点既有next指针又有prev指针既可以往前也可以往后很多操作直接O(1)。比如删除某个已知节点在单链表里你只知道它的下一个节点不知道它的上一个节点所以必须从头遍历而在双链表里直接通过node.prev.next node.next和node.next.prev node.prev就能完成删除不需要遍历。LRU缓存淘汰算法就用双链表来管理访问顺序增删操作都是O(1)非常典型。5.2 双链表的节点定义与关键操作class DNode: def __init__(self, data): self.data data self.prev None self.next None双链表的插入和删除操作比单链表多了一个prev方向的维护。以在节点prev_node后面插入新节点为例def insert_after(self, prev_node, data): if prev_node is None: return new_node DNode(data) new_node.next prev_node.next new_node.prev prev_node if prev_node.next is not None: prev_node.next.prev new_node prev_node.next new_node关键的第四步如果prev_node.next原本不为None需要把原后继节点的prev指向新节点。很多初学者写双链表插入时容易漏掉这一步导致链表从后往前遍历时出现断裂。我在调试时发现的问题多半都是这个原因——往前走的链路是对的往后走的链路是断的。删除节点时同理要注意被删节点的next.prev和prev.next都要更新同时处理好头节点和尾节点的边界情况。5.3 双链表的优势是双向的代价也是双重双链表不是免费的午餐。每个节点多了一个prev引用内存会翻倍多一点。在节点数目很大的场景下内存开销不可忽视。而且维护双方向的引用链代码写起来比单链表更繁琐出bug的概率也更高。所以选型时的建议是如果业务逻辑里需要频繁访问某个节点的前驱或者需要一个可逆的遍历序列双链表值得用如果只是简单的队列、栈这一类场景单链表完全够用没必要给自己加复杂度。6. 链表实战中的常见问题与性能对比6.1 一个真实的例子用链表维护高频插入的任务队列前面提到的任务队列场景我再展开细说一下。当时的需求是一个实时任务调度模块任务不断从头部插入从尾部取出执行中间还有按优先级调整位置的逻辑。用Python列表实现时头部插入的时间复杂度是O(n)当队列里有几十万个任务时每次插入都要移动几十万个元素的位置性能直接崩了。换成链表实现后头部插入变成O(1)尾部删除如果维护一个尾指针也是O(1)。即使调整位置时需要O(n)查找整体性能也能接受。这个项目最终上线后任务吞吐量提升了数倍GC压力也下降了。我拿这个例子想说明的是链表在频繁在中间/头部增删元素的场景下有结构性优势这不是微优化而是数据结构的本质差异。6.2 链表 vs Python列表 vs deque到底选哪个写Python的朋友可能会问标准库里有collections.deque它底层就是用双向链表实现的为什么还要自己手写链表我承认在很多场景下直接用deque是更聪明的选择毕竟Python社区的理念是不要重复造轮子。但手写链表的价值在于知其所以然。当你理解了链表的内存模型、节点连接关系、边界处理再去用deque你会更清楚它为什么在头部插入是O(1)为什么在某些操作上比列表快。而且面试、算法竞赛、或者写一个对内存布局有特殊要求的基础库时手写链表往往是必需的能力。两者不矛盾日常开发用现成的关键时刻才看得出真正功底。我用一张简单的对比表来总结三个常见结构的特点操作/复杂度Python列表collections.deque手写单链表头部插入O(n)O(1)O(1)尾部插入O(1) 均摊O(1)O(n)无尾指针随机访问O(1)O(n)O(n)内存开销低中较高每个节点有引用适用场景静态数据、按下标访问队列、双端队列高频插入删除、面试练手6.3 写链表代码最常见的三个坑第一个坑是忘记边界条件。比如插入时没考虑空链表删除时没考虑头节点遍历时没考虑链表为None的情况。解决方法是先把边界条件显式列出来再写通用逻辑。第二个坑是修改next顺序反了。我在前面反复强调插入节点时一定是先让新节点指向后驱节点再让前驱节点指向新节点顺序反了链表就断。第三个坑是忘记同步维护size字段。这个我踩过也提醒各位务必在增删操作中同步更新。还有一个容易被忽视的坑是写递归操作时没有考虑递归深度。Python默认递归深度上限是1000左右如果你用递归反转一个上万节点的链表程序会直接栈溢出报错。这时要用迭代写法。7. 链表的实战延伸从基础到进阶还缺哪些拼图7.1 双指针技巧不止用于环检测双指针在链表题里非常常见除了环形检测还有寻找链表中点和寻找倒数第K个节点这两个经典变体。寻找链表中点可以用快慢指针快指针走两步慢指针走一步快指针到达尾部时慢指针正好在中点。这个技巧在排序链表、分治算法里经常用到。寻找倒数第K个节点则是让一个指针先走K步然后两个指针同步走第一个指针走到尾部时第二个指针停的位置正好是倒数第K个节点。这种先拉开距离再同步平移的思路在滑动窗口类问题里也很常见。7.2 链表排序为什么归并排序比快速排序更适合链表数组排序时快速排序通常是默认选择但链表排序时归并排序才是更自然的方案。因为链表不支持随机访问快速排序的选基准、分区操作需要频繁访问不同位置的元素每步都是O(n)的遍历整体效率不理想。而归并排序只需要顺序遍历合并两个有序链表天然适配链表的单向前进结构而且不需要额外的大块连续内存。我遇到过实际的代码库里用链表的归并排序来处理几十万节点的情况稳定性很好写起来也不算复杂。如果链表长度很小插入排序之类的简单排序其实也够用不必一上来就归并代码复杂度和可读性都需要权衡。7.3 从手写链表到理解更复杂的数据结构链表其实是一座桥梁。理解了节点 引用这个基本模型后面学树、图、跳表、LRU等结构时会顺畅很多。树的每个节点有左右孩子引用图可以用邻接表本质上是一组链表来表示跳表则是多层链表叠加索引。如果你能手写链表并能说清楚它的时间复杂度再去啃这些高级数据结构起点就比别人高很多。我经常和身边的同事说一句话数据结构学到后面拼的不是背多少代码而是能不能把引用关系的变化在脑子里可视化成一条链。链表正是训练这个能力的最佳起点。最后分享一个我自己反复使用的小习惯写完链表代码后一定会手动模拟一遍空链表、单节点链表、正常多节点链表三种情况。这三条用例能帮你挡住绝大部分边界bug。如果时间允许再考虑一下循环链表这种特殊情况很多隐藏在正常路径里的问题就会自己浮现出来。希望这篇关于链表以及Python实现的经验分享能帮你在数据结构的路上少走一些弯路。剩下的功夫就是把每一个操作自己动手写一遍、画一遍、跑一遍了。