ARTICLE DETAIL

资讯详情

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

Python线性数据结构详解:列表、栈、队列与链表实战指南

Python线性数据结构详解:列表、栈、队列与链表实战指南 1. 线性结构到底在说什么从排队这件小事讲起很多刚接触Python的朋友问我学了列表、元组、字符串之后下一步该学什么我的回答往往是别急着追新东西先把线性数据结构这件事想明白。这个词听起来像教科书里的黑话其实它说的就是我们在生活里天天经历的事——排队。你站在奶茶店门口排队队伍有一个明确的头有一个明确的尾每个人记得自己前面是谁、后面是谁。新来的人站到队尾队伍中间的人不能随便插队特殊情况下会有人从队头离开。这种一个接一个排成一串有先后顺序的组织方式就是线性结构。Python里的列表、元组、字符串、栈、队列、链表本质都是这条队伍的某种变体。为什么这件事值得单独拿出来讲因为我在实际项目里见过太多人把线性结构用错。比如用列表去实现一个高频的先进先出队列结果数据量一上来程序慢到怀疑人生比如不清楚字符串是不可变对象在循环里拼命用拼接白白浪费大量内存分配再比如面试的时候被问到Python的列表和数组到底有什么区别当场语塞。这些问题的根源都是只记住了API用法没搞懂底层的数据组织方式。这篇文章不是从零开始的语法教程而是带你把Python内置的线性结构挨个拆开看内脏列表为什么插入慢、索引进元组到底比列表好在哪栈和队列应该用list还是collections.deque自定义链表什么时候才值得写我会结合自己的项目经验把原理、选型、实测数据、踩坑记录都放进来。适合有一定Python基础、想系统理解数据结构、或者准备面试的读者。我习惯把线性结构分成三条线来看序列型容器list、tuple、str、受限线性结构栈、队列、指针式线性结构链表。下面逐个拆。2. 列表的底层真相动态数组的扩容、索引与插入代价2.1 list在内存里到底是什么样先做一个最直观的测试。运行下面的代码import sys lst [] for i in range(1000): lst.append(i) if i in [0, 1, 2, 3, 7, 15, 31, 63, 100, 500, 999]: print(f长度 {i 1:4}, 占用内存 {sys.getsizeof(lst):6} 字节)你会发现一个很有意思的现象列表占用的内存不是每次append都增长的而是跳跃式增长。这就是因为Python的列表本质上是一个动态数组dynamic array——它背后是一段连续的内存空间里面存的是指向各个对象的指针而不是对象本身。当你不断往列表尾部追加元素数组容量不够了解释器会申请一块更大的连续内存把旧元素整体搬过去。CPython的扩容策略大致是当容量不足时新的容量约为旧的1.125倍实际是newsize (newsize 4) 6左右的近似逻辑额外留出余量。这就是为什么内存占用呈阶梯状上升。这个设计带来的直接结论是通过索引访问元素是O(1)因为知道起始地址和元素大小直接做地址偏移计算就行和列表有多长没有关系。在尾部append平均是O(1)大多数时候容量够用直接写入即可偶尔触发扩容平均摊还下来代价仍然很低。在头部或中间插入是O(n)要先把后面的元素逐个往后挪一个100万元素的列表在索引0处插入一次可能就要搬移上百万个指针。我在一个日志处理脚本里就吃过这个亏。需要把新日志插入到列表开头当时图方便用了lst.insert(0, log)。数据量从几千涨到几十万之后程序从瞬间完成变成肉眼可见卡顿。换成collections.deque的appendleft之后插入变O(1)性能问题直接消失。这个案例后面还会细讲。2.2 数组与列表的边界array模块与numpyPython官方其实还提供了一个array模块它是真正意义上连续存储同类型元素的数组。和list的区别在于array里每个元素占用的字节数固定且相同比如array(i)表示有符号整型数组每个元素占4字节。这样做的好处是内存占用远小于listlist要存指针对象头每个元素通常要多付出几十字节的代价。但说实话在纯Python环境里array的存在感一直不高。真正在数据密集型任务里大放异彩的是numpy.ndarray。它的底层依然是C数组但支持向量化运算这也是Python量化交易策略代码Python科学计算这类热搜背后最常见的依赖。如果你处理的是数值型的大量数据直接用list做运算循环慢且内存高换成numpy后同样的操作可能快几十上百倍。我用一个简单的例子说明三者的差异from array import array import numpy as np import sys n 100000 py_list list(range(n)) arr array(i, range(n)) nparr np.arange(n, dtypenp.int32) print(sys.getsizeof(py_list) / 1024, KB) # 约 824 KB print(sys.getsizeof(arr) / 1024, KB) # 约 400 KB print(nparr.nbytes / 1024, KB) # 约 390 KB实际py_list的sys.getsizeof只算了指针数组的大小没算每个int对象本身的内存每个小整数对象还要额外占用28字节左右所以真实差距更大。这就是为什么处理大数据时选对容器比优化循环逻辑重要得多。2.3 为什么说Python列表不是数组是面试高频题很多面试官喜欢问Python的list和C语言的数组有什么区别其实这个问题的价值不在于考记忆而在于考察你是否理解抽象与实现的折中。C数组连续存储、长度固定、元素类型统一读取极快但插删困难。Python的list更像一个可变长的对象指针容器每个槽位都是一个指向任意Python对象的PyObject*指针。所以同一个列表里可以混存整数、字符串、对象这是Python动态类型的自然结果。但要注意这种全都可以装的灵活性并不是免费的。每个元素访问都需要一层指针间接寻址每次比较都要做类型判断。所以当你确定所有元素都是同一类型、且追求极致性能时numpy或array才是对标C数组的替代方案。列表适合的是以代码开发效率优先数据规模可控的场景。3. 不可变线性结构元组与字符串里藏着的工程智慧3.1 不可变到底保护了什么很多初学者不理解元组和列表几乎一模一样为什么非要搞个不能修改的元组出来我在写接口的时候发现不可变性最大的价值是安全地共享。举个例子。你写一个函数接收用户传进来的配置参数如果这个参数是list函数内部某个逻辑不小心改了它外部所有持有这个list的地方都会被悄悄修改。这类bug排起来非常折磨人。但如果传入的是tuple任何试图修改它的操作都会直接抛出TypeError问题在第一时间暴露。Python本身也大量依赖这个特性字典的键必须可哈希而list是可变的所以不能当键tuple不可变、可哈希所以能当键。字符串也一样它是不可变的所以才能安全地作为字典键、放进集合、作为文件名等等。我在一个多线程爬虫项目里就吃过list共享的亏。多个线程同时往一个共享的列表里追加URL去重结果由于操作不是原子的线程一多就出现重复数据。后来改成用不可变结构的快照或直接换用queue.Queue问题才彻底解决。不可变并不只是语法限制它是一道防止意外修改的安全边界。3.2 字符串的本质永远记住它是字符的线性序列字符串str在Python里的地位有点特殊它既是文本类型又是线性容器。你可以对它做for c in s遍历、s[2]索引、s[1:5]切片这些操作本质上和列表一模一样。我在处理文本数据时的一个切身体会是字符串的切片操作非常轻量但要区分它和列表切片的语义。Python的字符串切片会返回一个新的字符串对象内容是被切出的部分列表切片也是一样返回新列表。它们都不是视图所以即使在循环里反复切片也不会影响原字符串——这一点和numpy的切片视图、共享内存截然不同做数据分析时千万别搞混。字符串拼接是个经典性能坑。看这段代码s for i in range(100000): s str(i)每次都会创建一个全新的字符串对象然后把旧内容复制一遍再加新内容。这个操作的时间复杂度是O(n²)10万次拼接在CPython里要好几秒。正确的做法是用列表收集最后.join(lst)一次性拼接因为join会预先计算总长度分配一次内存完成拼接速度能快几个数量级。提示不要在小字符串拼接上太纠结s ...写起来确实方便。但只要进入循环、且循环次数可能上万就一定要改用list.append()加.join()的模式。3.3 切片与反转背后的下标模型切片s[start:stop:step]是Python线性结构最优雅的语法之一但很多人在负索引上栽跟头。我总结过一个简单的心法从第几个元素的角度去理解不要从第几位去死记。a[-1]是最后一个元素a[-2]是倒数第二个这些大家都熟。切片时a[::-1]表示从头到尾、步长为 -1也就是从右往左取得到的是整个序列的反转。a[1:8:2]表示从索引1开始到索引7结束不包含8每隔一个取一个。这个模型在字符串处理中尤其常用。比如判断回文很多人写if s s[::-1]: print(是回文)这里s[::-1]就是整个字符串的反序。虽然它创建了新字符串O(n)但在长度可控的字符串上这是一种简洁到让人舍不得换的写法。如果在超长文本里做高频回文判断才需要考虑双指针的方式节省内存。4. 给线性结构立规矩栈、队列与deque的实战选择4.1 用list模拟栈什么时候足够栈是后进先出的受限线性结构最简单的解释就是一摞盘子后放的先拿。在Python里list天生就是合格的栈append()就是压栈pop()不带参数就是出栈这两个操作在动态数组的尾部都是O(1)。很多算法题、函数调用栈、括号匹配匹配、浏览器的前进后退都能用list直接模拟栈。比如经典的括号匹配检查def is_valid_brackets(s: str) - bool: stack [] pairs {): (, ]: [, }: {} for ch in s: if ch in ([{: stack.append(ch) else: if not stack or stack.pop() ! pairs[ch]: return False return not stack这一段逻辑就能处理括号嵌套是否正确的问题。在没有性能瓶颈、不需要线程安全的场景下用list当栈完全够用。4.2 deque双端队列为什么比list更适合队列队列是先进先出的受限线性结构应用场景包括任务调度、消息缓冲、广度优先搜索等。如果用list实现队列最直接的写法是lst.pop(0)出队但这个操作是O(n)的。因为队列头弹出后后面所有元素都要整体前移一位。数据量大时这就是性能灾难。正确做法是使用collections.deque。deque的底层是双向链表和块状数组的混合体在CPython中是一组定长块的双向链表它对两端的append和popleft都是O(1)。我实测过一组数据。用一个list模拟一个包含10万次入队出队的流程用pop(0)出队耗时大约6秒换成deque之后同样流程耗时只有0.02秒左右。差距接近300倍。这个差距在高频任务调度、BFS遍历中会无限放大。from collections import deque dq deque() for i in range(100000): dq.append(i) while dq: dq.popleft()如果你只是偶尔取一下队头、数据量不超过几百用list也无妨代码可读性还更高。但一旦数据量上来、或者处在内层循环deque就是唯一正解。4.3 优先级队列queue.PriorityQueue与heapq栈和队列聊完顺带提一个经常被归入队列家族的结构优先级队列。它并不是严格的线性结构因为出队顺序不由入队先后决定而是由优先级决定。Python里最常见的实现是queue.PriorityQueue线程安全和heapq非线程安全。heapq基于堆实现插入和弹出都是O(log n)。我在做任务调度时如果只是单线程场景一般直接用heapq而不是PriorityQueue因为后者为了线程安全加了锁会有额外的开销。import heapq tasks [(2, 低优先级), (1, 高优先级), (3, 更低优先级)] heapq.heapify(tasks) while tasks: print(heapq.heappop(tasks))注意heapq是最小堆优先级数值小的先出队。如果你想要最大值先出可以存入负值或者用heapq配合自定义比较对象。5. 手写链表一次对指针与递归的彻底祛魅5.1 单向链表核心实现从node到完整结构很多朋友一听到链表就觉得那是C语言的事Python明明有list了何必自己写我的看法是手写一次链表的价值不在用而在懂。只有亲手写过一次你才会真正理解指针引用哨兵节点环这些概念的物理意义之后去读很多源码和算法题题解都会轻松得多。一个最简单的单向链表可以这样写class ListNode: __slots__ (val, next) def __init__(self, val0, nextNone): self.val val self.next next class LinkedList: def __init__(self): self.head None def append(self, val): if not self.head: self.head ListNode(val) return node self.head while node.next: node node.next node.next ListNode(val) def traverse(self): node self.head while node: yield node.val node node.next def insert_after(self, target, val): node self.head while node and node.val ! target: node node.next if not node: raise ValueError(f未找到节点 {target}) node.next ListNode(val, node.next)注意我用了__slots__ (val, next)。如果不加这个每个节点都会自带一个__dict__字典来存属性内存开销会明显增加加了之后Python会使用紧凑的内存布局。这个技巧在创建大量小对象时能显著降低内存占用是我做几十万节点链表时实测出来的经验。5.2 链表 vs 动态数组用实测数据说话很多人被教科书里的复杂度分析忽悠以为链表插入快、数组插入慢于是什么场景都想用链表。但实际上复杂度分析说的是数据规模趋于无穷时的趋势真实工程中常数项和内存局部性同样重要。我做过一个简单实验分别用list和自定义链表在100万个元素里做10万次在头部插入的操作。list头部插入insert(0, x)O(n)每次搬移百万个指针。实测用时约15秒。链表头部插入O(1)只需新建节点、改指针。实测用时约0.05秒。这个场景下链表完胜。但如果是按索引访问链表只能从头一个个遍历O(n)list是O(1)。100万次随机访问list几乎瞬时完成链表需要几十秒。所以我的结论是操作list动态数组自定义链表尾部插入O(1)O(1)有尾指针时否则需遍历 O(n)头部插入O(n)O(1)中间插入O(n)需搬移O(1)找到位置后改指针按索引访问O(1)O(n)内存局部性好缓存命中高差节点分散在堆中内存开销指针数组对象每个节点多一个next指针在Python里绝大多数场景list都完胜自定义链表。因为Python的list底层是C数组内存连续、缓存友好而每个链表节点都是独立Python对象不仅分散在内存各处还有对象头的开销。真要追求链表的性能应该用collections.deque这种C实现的双端结构而不是自己手写的Python对象链表。手写链表最合适的场景是应对算法题和面试。5.3 环形链表与快慢指针一个高频算法套路理解了链表结构之后很多经典算法就有了着落。最常见的套路之一是快慢指针判断链表是否有环def has_cycle(head: ListNode) - bool: slow fast head while fast and fast.next: slow slow.next fast fast.next.next if slow is fast: return True return False这个写法的精妙之处在于如果链表有环快指针每次多走一步最终一定会从后面追上慢指针如果没环快指针会先走到None。因为遍历时快指针走的步数不会超过环长度加链表长度所以时间复杂度O(n)空间O(1)。我当年第一次看懂这个解法时对把问题转成追及问题的思路佩服不已这也是线性结构真正活起来的时刻。6. 遍历、推导式与生成器让线性结构跑得飞快6.1 for循环遍历的底层到底做了什么在Python里凡是线性容器——list、tuple、str、deque乃至自定义实现了__iter__的链表——都能被for x in container遍历。这个遍历的背后是迭代器协议for先调用iter(container)拿到迭代器然后不断调用next()直到抛出StopIteration。我自己写自定义容器时最常用的就是实现__iter__让对象可以优雅地被遍历。比如前面写的链表通过yield实现生成器版本的traverse一次只产出一个节点值内存占用极小。实际工程中还有一个高频问题遍历列表时能不能修改列表答案是边遍历边删除或添加元素非常容易出bug。因为for循环是按索引或迭代器顺序取值的删除元素会让后续元素下标前移导致跳过某一个元素或者超出范围。正确做法是收集要删除的对象遍历结束后统一删除或者直接遍历副本for x in lst[:]。这类问题我在处理从列表里过滤脏数据时遇到过多次每次都提醒自己不要试图在迭代过程中改变容器结构这是线性结构使用中最容易踩的坑没有之一。6.2 列表推导式性能与可读性的双赢列表推导式[expr for x in seq]生成list从语法上看只是把for循环压缩成一行但实际执行时有性能优势。CPython对推导式有专门优化不需要反复调用append整体执行速度通常比等价的for加append快20%到50%左右。它还天然支持过滤和嵌套squares [x*x for x in range(20) if x % 2 0] matrix_flat [v for row in matrix for v in row]第一个例子筛出偶数并计算平方第二个例子把二维列表展平。这两个写法的可读性和效率都很好是我日常处理数据时最常用的工具之一。不过有个陷阱需要注意当数据量极大、且你并不需要一次性获得全部结果时不要用列表推导式改用生成器表达式。因为列表推导式会立刻把所有元素物化到内存里而生成器是懒加载的——sum(x*x for x in range(10**8))不会把1亿个平方数都算出来存着它一个个算、一个个累加内存占用恒定。这是我跑大数据任务时的默认选择。6.3 生成器把线性结构变成线性流生成器是Python里非常接近流式线性结构概念的东西。它像一条传送带你推一下next()它就给你产出一个元素。它不要求所有元素同时存在于内存中所以理论上可以表示无限序列。def fibonacci(): a, b 0, 1 while True: yield a a, b b, a b这个斐波那契生成器就是一个无限的线性结构配合itertools.islice可以截取前N项from itertools import islice print(list(islice(fibonacci(), 10)))在工作中我经常用生成器处理超大文件逐行读取的场景一次只把一行文本加载到内存而不是把整个文件读成一个大列表。这就是生成器对线性数据结构的价值延伸——它让你在没有完整数据的情况下依然可以像遍历线性结构一样处理数据。再说一个自己踩过的坑生成器只能迭代一次。如果你把它传给两个循环第二个循环会什么都拿不到。想要可重复遍历要么用itertools.tee复制要么重新创建一个生成器。7. 把这些结构放进真实项目选型清单与我的个人体会写到这里我把前面所有内容浓缩成一份选型清单方便你以后拿到需求直接对照通用可变序列需要频繁按索引访问用list。需要作为字典键、函数返回值、不可变的配置数据用tuple。处理文本字符序列用str注意拼接方式。数值密集型数据、需要向量化运算用numpy.ndarray。同一类型、内存敏感但不想引入numpy用array.array。栈后进先出list.appendlist.pop即可。队列先进先出用collections.deque不要用list.pop(0)。双端都需要高频插入删除用collections.deque。优先级调度单线程用heapq多线程用queue.PriorityQueue。算法训练、面试、理解指针手写链表。大数据流式处理使用生成器搭配itertools模块。最后分享一条我个人的经验法则在Python里写业务代码优先用内置的高层容器不要过早沉迷于自定义数据结构。先用list和dict把功能跑通再通过性能剖析cProfile或timeit找到真正的热点然后才考虑换成deque、数组或其它结构。过早优化是万恶之源这句话在数据结构选型上体现得特别明显。我见过太多人简历上写着精通数据结构实际却因为不知道deque的存在用list硬写队列导致线上任务超时。也见过为了应付算法题手写链表最后在真实项目里明明可以用deque解决却写了两百行自己实现的简陋链表还引入了内存泄漏。数据结构的学习归根到底是为了在合适的场景选择合适的方式去组织数据——这个能力比记住任何API和复杂度公式都重要。
返回列表