多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python数据结构操作实战:从内置容器到底层原理

Python数据结构操作实战:从内置容器到底层原理 很多人学Python会卡在一个奇怪的位置语法都会list和dict也天天在用可一提到“数据结构”三个字就本能地往后躲。我整理这份《韦奇-python 数据结构操作》笔记起因就是自己曾经在量化回测里用list维护一个滑动窗口数据量一到几十万就肉眼可见地慢换成deque之后不仅代码简洁了耗时还直接降了一个数量级。后来我又把这套操作思路用在爬虫任务、自动化报表和公司内部的数据对接上才真正意识到Python数据结构操作值钱的不是记住了多少个API而是搞清楚每种容器在增删改查上的代价。这份整理后来被不少朋友拿去参考问得最多的几类问题反而很集中deque到底什么时候该用、dict和set为什么快、图和邻接矩阵在Python里怎么落地、pandas的DataFrame怎么从原生容器转换过去还有时间空间复杂度这种“期末考试背完就忘”的东西到底怎么用到实战里。所以今天这篇不打算再罗列一遍官方文档而是把我实际操作里验证过、也踩过坑的部分挑出来讲清楚。适合正在学Python基础的人、准备数据结构考试却想用Python练手的人以及已经在写爬虫、数据分析或量化脚本、发现代码越来越慢却说不清瓶颈在哪的人。1. 整理这套“韦奇-python 数据结构操作”笔记的初衷和边界1.1 为什么它值得从零开始梳理一遍先交代一下背景。刚接触Python的时候我也和大多数人一样对着教程把list、tuple、dict、set的增删改查过了一遍感觉没什么难的。可真到了两个场景里我才知道自己只是“会用”远没到“会选”。第一个场景是量化行情数据的滑动窗口。我需要维护最近N个价格每来一个新价格就丢掉最老的那个。当时我图省事直接用了listprices.append(new_price)然后prices.pop(0)。数据量小的时候没感觉一回测几千只股票反复pop(0)导致list整体搬移速度慢到让人抓狂。后来换成deque(maxlenN)一行初始化往里append就行老数据自动被挤出去耗时直接掉了接近一个数量级。第二个场景是爬虫任务里维护一个待抓取URL队列。同样是队列用list加pop(0)会越跑越慢而deque的popleft()是常量时间几十万条URL跑下来差距非常明显。这两件事让我意识到Python的很多性能问题并不是“Python本身慢”而是容器选错了。只有把每种数据结构的操作代价搞清楚才能在写代码的第一时间就选对工具。所以我才决定从底层逻辑重新梳理一遍Python数据结构操作而不是继续靠碎片化的记忆堆API。1.2 这套笔记的内容边界与主线这份《韦奇-python 数据结构操作》的整理主线只有一条从“数据怎么组织”出发而不是从“函数怎么调用”出发。内容大致分成四块内置容器list、tuple、dict、set以及collections模块里deque、defaultdict、namedtuple、Counter等高频工具的操作细节。链式结构与图结构用Python的类与引用去模拟链表、二叉树、图重点讲清楚和C语言实现的差异。与数据处理的衔接pandas的Series、DataFrame结构怎么从原生Python容器创建以及在数据分析、报表、量化场景里怎么做容器选型。复杂度分析不光是背大O记号而是通过实测基准直观地看到不同操作的增长曲线。不包含的内容我也说一下这不涉及Python基础语法教学也不涵盖算法竞赛的全部内容。它更像是一份“已经知道Python但想把数据结构用得更明白”的实战型笔记。2. 内置容器的操作要诀list、tuple、dict、set与deque2.1 list与tuple可变/不可变不只是“能不能改”list和tuple在Python里都被当成“数组”用但它们的定位差别很大。list是可变对象可以随时增删改tuple是不可变对象创建后就不能再修改。很多人对tuple的认知停留在“用来装不可变的数据”但它的真正价值有两个一个是作为字典的key或者集合里的元素因为只有不可变对象才能被哈希另一个是在函数返回值和解包时保持结构的稳定。举一个非常常见的坑把list塞进set或者当dict的key会直接报TypeError: unhashable type: list。这时候你需要的其实是一个tuple。比如你要给坐标点(x, y)去重不能这样points [[1, 2], [1, 2], [3, 4]] unique_points set(points) # TypeError应该先把坐标转成tuplepoints [(1, 2), (1, 2), (3, 4)] unique_points set(points) # {(1, 2), (3, 4)}list操作里最想提醒的是append、extend和insert的差别。append是把一个元素加到尾部如果这个元素本身是list就会形成嵌套extend是把一个可迭代对象的元素逐个拼进来。这两个用错是新手最常见的Bug之一。lst [1, 2] lst.append([3, 4]) # [1, 2, [3, 4]] lst.extend([5, 6]) # [1, 2, [3, 4], 5, 6]insert(0, x)这种头部插入操作不管数据量多大都是O(n)因为list是连续内存的数组头部插入意味着后面所有元素都要挪位。如果你经常需要在头部插入或删除优先考虑deque后面会专门讲。tuple里面还有一个非常好用的工具叫namedtuple它让不可变结构拥有类似对象的属性访问方式同时在内存开销上又比自定义类低很多from collections import namedtuple Stock namedtuple(Stock, [symbol, price, volume]) s Stock(BTC, 42000, 100) print(s.symbol, s.price) # BTC 42000做数据回测或者读行情记录时用namedtuple代替普通元组代码可读性会好很多。2.2 dict与set哈希表的威力与秩序真相dict和set在Python里的底层都是哈希表所以它们有个共同特点查找、插入、删除的平均时间复杂度都是O(1)。这也就是为什么“去重用set”“映射关系用dict”能比list快那么多的原因。但哈希表有一个副作用普通dict从Python 3.7起按插入顺序保存但set仍然无序。很多人写代码时以为set和list一样可以按下标访问这是不行的。set只支持成员判断和集合运算不支持索引。如果你想既保住set去重能力又希望结果是首次出现的顺序可以用dict.fromkeys()这个技巧data [3, 1, 4, 1, 5, 9, 2, 6, 5] unique_ordered list(dict.fromkeys(data)) print(unique_ordered) # [3, 1, 4, 5, 9, 2, 6]dict的使用上get、setdefault和defaultdict是三个能极大减少代码量的方法。新手喜欢写一堆if排查key是否存在counter {} for item in items: if item in counter: counter[item] 1 else: counter[item] 1用defaultdict可以简化成from collections import defaultdict counter defaultdict(int) for item in items: counter[item] 1defaultdict(int)会在key不存在时自动赋值为0所以 1不需要判断存在性。同理构造一个“值都是列表”的字典时defaultdict(list)极其方便。2.3 deque、Counter与collections的“即插即用”collections模块是Python数据结构操作里的隐藏宝箱。deque双端队列是最值得优先掌握的一个因为它在队列和栈的场景里几乎是无脑最优解。from collections import deque dq deque(maxlen5) dq.append(1) # 右边入队 dq.appendleft(2) # 左边入队 dq.pop() # 右边出队 dq.popleft() # 左边出队maxlen参数尤其有用一旦deque达到最大长度再往里加元素会自动从另一端挤出旧元素。做滑动窗口、日志滚动、历史记录时这一行代码能顶掉十行手动管理逻辑prices deque(maxlen10) for p in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]: prices.append(p) print(prices) # deque([2, 3, 4, 5, 6, 7, 8, 9, 10, 11], maxlen10)Counter是另一个高频使用的工具。统计出现次数这种任务用Counter一行就能完成from collections import Counter word_counts Counter(hello world) print(word_counts.most_common(3)) # [(l, 3), (o, 2), (h, 1)]Counter还支持加减、合并在统计文本词频、日志错误码分布时非常好用。这些都说明一个道理不要重复造轮子Python标准库已经帮你把最常用的数据结构操作封装好了。3. 链式结构与图结构的Python表达从C语言思维到Python思维3.1 用类与引用实现链表、树没有指针但本身就是引用学数据结构的时候教科书几乎都是用C语言讲的链表节点里存一个struct再存一个指向下一个节点的指针。到了Python里很多人会纠结Python没有指针链表怎么写其实Python的变量本身就是引用这种机制天然适合链式结构。定义一个节点类里面的next字段直接指向下一个节点对象就行class ListNode: def __init__(self, val0, nextNone): self.val val self.next next head ListNode(1) head.next ListNode(2) head.next.next ListNode(3)这个思路可以无缝迁移到二叉树class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right我见过不少自学Python的人一直在找“Python里的指针”实际上你不需要显式操作地址。理解了“引用即指针”这层关系链表题的递归写法就顺理成章了。比如反转链表这个经典操作Python里用递归非常直观def reverse_list(head): if not head or not head.next: return head new_head reverse_list(head.next) head.next.next head head.next None return new_head这里的最大坑是忘记把head.next置空导致形成环。我自己调试这种问题的时候习惯用id()函数打印每个节点的地址可以非常直观地看到引用关系node head while node: print(id(node), node.val) node node.next3.2 从邻接矩阵到邻接表图结构的关键抉择“数据结构408 图和数组”是个高频搜索词说明很多人在复习考研数据结构时图和数组的结合让他们头疼。在Python里图和数组的关系最典型的体现就是邻接矩阵。假设有n个节点创建一个n×n的二维列表adj[u][v] 1表示u和v之间有边n 5 adj [[0] * n for _ in range(n)] edges [(0, 1), (1, 2), (2, 3), (3, 0), (1, 4)] for u, v in edges: adj[u][v] 1 adj[v][u] 1 # 无向图需要对称赋值这里有一个高频坑初始化二维列表时千万别用[[0] * n] * n。因为Python里的乘法对列表是浅复制这会让每一行都指向同一个列表对象改一个元素整列全部跟着变。正确写法是列表推导式[[0] * n for _ in range(n)]。我见过太多人在这个细节上翻车一查图多了一堆莫名其妙的边半天找不到原因。邻接矩阵和邻接表的选择逻辑本质上是空间换时间这里放一张对比表特性邻接矩阵邻接表空间复杂度O(n^2)O(n e)判断任意两点是否有边O(1)需要遍历邻居列表平均O(度)遍历所有邻居O(n)O(度)适用场景稠密图稀疏图当图的规模变大比如几万个节点矩阵会非常浪费。这时候更合适的是邻接表对每个节点只存储和它相连的节点列表。Python里用dict加list就能轻松实现from collections import defaultdict graph defaultdict(list) for u, v in edges: graph[u].append(v) graph[v].append(u)在这个结构上做深度优先和广度优先遍历都很自然。DFS用递归或显式栈BFS用dequedef dfs(node, visitedNone): if visited is None: visited set() visited.add(node) for neighbor in graph[node]: if neighbor not in visited: dfs(neighbor, visited) def bfs(start): visited {start} queue deque([start]) while queue: node queue.popleft() for neighbor in graph[node]: if neighbor not in visited: visited.add(neighbor) queue.append(neighbor)做算法题或者实现图算法前先判断数据规模再选结构能避免很多不必要的内存开销。3.3 排序算法内置sort虽好原理依然要懂搜索词里频繁出现“数据结构排序算法”“数据结构与算法”说明排序是数据结构绕不开的坎。在Python实际项目中直接用内置的sort()或sorted()就行它们底层的Timsort结合了归并排序和插入排序的优点稳定且高效data [5, 2, 9, 1, 5, 6] data.sort() # 原地排序 print(data) # [1, 2, 5, 5, 6, 9] new_list sorted(data, reverseTrue)但面试和考试会要求你手写快排、归并、冒泡。我建议至少认真实现一遍冒泡排序和快速排序理解它们的交换逻辑和复杂度差异。很多人写完冒泡发现自己实现版本慢得离谱原因往往是没加“提前退出”机制。改进版是这样def bubble_sort(arr): arr arr[:] # 复制一份避免影响原列表 n len(arr) for i in range(n): swapped False for j in range(n - 1 - i): if arr[j] arr[j 1]: arr[j], arr[j 1] arr[j 1], arr[j] swapped True if not swapped: break return arr如果序列本身接近有序这个版本会提前退出效率提升不少。另一个常见误区是arr arr[:]这种切片复制。面试题里它是安全的但如果你直接arr arr然后在原地交换原数据会被改掉容易导致测试用例互相污染。4. pandas数据结构操作与数据场景中的容器选用4.1 Series与DataFrame从原生容器到表格的转换搜索词里“pandas数据结构创建”出现频率很高说明很多人在学完原生Python数据结构后下一站就是pandas。pandas最核心的两个结构是Series和DataFrame。Series可以理解成一个带索引的一维数组DataFrame则是带行索引和列名的二维表格。创建方式里最基础也最常见的是从dict创建DataFrameimport pandas as pd df pd.DataFrame({ 日期: [2024-01-01, 2024-01-02, 2024-01-03], 收盘: [100, 101, 102], 成交量: [1000, 1200, 900] })另一种常见情况是从列表的列表创建比如从数据库捞出来的原始记录rows [ [1, BTC, 42000], [2, ETH, 3000], ] df pd.DataFrame(rows, columns[id, symbol, price])还有从numpy数组创建这在量化分析里非常常见import numpy as np arr np.random.randn(100, 3) df pd.DataFrame(arr, columns[a, b, c])Series的索引不一定从0开始你可以指定任意index做时间序列时会非常方便。但要注意一旦index不是默认的0到n-1用df.loc和df.iloc的差别就很关键loc按索引标签取iloc按整数位置取。列名或索引一旦设置错了这两者会拿到完全不同的结果这也是pandas新手最容易踩的坑之一。4.2 groupby、merge、rolling别再手动循环“拉表”很多人在从纯Python过渡到pandas时还没摆脱用循环处理的习惯。比如统计每天各品种的成交量总和新手会写一层for循环维护一个老字典。实际上pandas里一句groupby就搞定daily df.groupby(日期).agg({收盘: mean, 成交量: sum})数据表的拼接用merge或concat不需要自己拿dict手工对齐。滚动窗口这种操作更是pandas的强项量化里常用的移动平均就是df[ma5] df[收盘].rolling(window5).mean()如果你在跑数据量很大的自动化报表任务应该尽量把逻辑写成向量化的pandas操作而不是循环逐行处理。pandas底层许多操作基于numpy的向量化实现批量计算速度远快于Python层循环。这也是“自动拉表”这类需求里最值得关注的一点先把数据拉到DataFrame再用pandas做汇总最后导出Excel或写回数据库整个链条会比逐行处理顺滑得多。4.3 容器选型爬虫、量化、报表各用什么同样的数据在不同场景里合适的容器完全不一样。以爬虫为例待抓取URL一般用先进先出的队列set存放已访问URL用于去重偶尔需要用dict做页面解析结果的临时缓存。这三类需求分别对应deque、set、dict几乎不用犹豫。量化策略里行情序列天然适合用pandas的Series/DataFrame因为带时间索引、支持rolling计算但如果你只需要维护一个滑动窗口的最新状态deque反而比DataFrame轻快得多。自动报表场景里源数据进DataFrame做聚合、透视表最终输出几乎全程pandas。不要什么数据都塞进DataFrame。一个常见误区是因为pandas很强大就把所有中间计算都先转成DataFrame。可如果只是一些简单的计数、去重、分组统计原生的dictsetCounter可能比pandas更快也更省内存。选容器之前先问自己三个问题数据量多大需要什么操作后续要对接什么接口想清楚这三件事容器就不容易选错。5. 时间空间复杂度的实测对照与避坑记录5.1 复杂度不是背出来的是测出来的搜索词里“数据结构与算法 空间复杂度”“数据结构实验报告”反复出现说明复杂度概念是很多人的痛点。我的经验是别光背大O记号自己写几段对比代码实测一次比背十遍都有用。我用一个很小的实验体会到list头部插入和deque头部插入的区别。list的insert(0, x)是O(n)deque的appendleft是O(1)。数据量小的时候可能感觉不出来但一旦数据量变大增长曲线完全不同import time from collections import deque lst list(range(20000)) dq deque(range(20000)) start time.perf_counter() for i in range(2000): lst.insert(0, i) print(list insert(0):, time.perf_counter() - start) start time.perf_counter() for i in range(2000): dq.appendleft(i) print(deque appendleft:, time.perf_counter() - start)在我机器上list版本要几百毫秒deque版本几乎瞬间完成。你不需要背下每个操作的大O但至少要感知哪些操作是常量级、哪些是线性级。在写循环嵌套的时候这种感知能帮你提前避开灾难级的时间复杂度。5.2 常见效率陷阱与操作习惯列几个自己踩过、也看别人反复踩的效率陷阱列表推导式 vs for循环Python里尽量用列表推导式、生成器表达式它们比for循环append快因为少了函数查找和append方法调用的步骤。判断成员用set不用listx in list是O(n)x in set是O(1)。数据量一大这个差距就是天壤之别。字符串拼接不要用字符串是不可变对象会不断创建新字符串。正确做法是收集到list最后用.join()。字典默认值用defaultdict或setdefault别写一堆if排查key是否存在。不要在不必要的地方反复排序如果每次都要取最大或最小用heapq而不是反复sort。这几个习惯背后其实都指向同一套复杂度逻辑我整理成一张速查表操作平均复杂度推荐容器尾部增删O(1)list头部增删O(1)deque任意位置增删O(n)list少用插入按值查找成员O(1)set / dict按值查找成员O(n)list取最小/最大O(1) 或 O(logn)heapq最后一条我多说一句。很多人不知道Python标准库里的heapq模块它可以维护一个最小堆让你在动态插入数据的同时以O(1)拿到最小元素插入和弹出都是O(log n)。在“持续有新数据进来始终要取最小/最大”的场景比如实时行情里的极值它比每次都sort高效得多import heapq heap [] for x in [3, 1, 4, 1, 5, 9, 2]: heapq.heappush(heap, x) print(heap[0]) # 最小值1 heapq.heappop(heap) # 弹出15.3 空间换时间的一次实例复杂度分析里还有一个重要维度空间复杂度。典型例子是“用哈希表把O(n^2)降成O(n)”。比如两数之和问题暴力解法是两层循环时间复杂度O(n^2)用dict记录已经见过的数时间复杂度降到O(n)代价是额外的O(n)空间def two_sum(nums, target): seen {} for i, num in enumerate(nums): if target - num in seen: return [seen[target - num], i] seen[num] i return []数据量小的时候两种做法都没差别但数据一旦上万暴力解法可能直接超时。空间换时间不是万能的如果内存本身吃紧还得权衡。但绝大多数Python脚本场景里数据规模还没到需要用空间换时间的极端你更需要担心的是时间爆炸而不是内存溢出。6. 给正在学Python数据结构的人几句不喊口号的经验6.1 从“会调用API”到“能选对容器”的练习方法这条路线的终点不是“把每种数据结构都背下来”而是“看到一个问题天然就能想到该用什么结构去组织数据”。我自己觉得最有用的练习方法是把刷题时遇到的每一题强制自己先说出它的操作是什么、需要什么时间复杂度、选什么容器再动手写代码。说错了没关系多错几次就会形成条件反射。具体操作可以做成一个可复用的小模板拿到问题之后先圈出核心动作。如果是“频繁取最大最小”就往heapq想如果是“先进先出”就往deque想如果是“去重 判断存在”就往set想如果是“键值对映射然后要按插入顺序输出”就往dict想。先定容器再想算法写出来的代码会干净很多。6.2 手写实现一遍核心结构到底值不值很多初学者纠结一个问题Python里已经有现成的dict和list了为什么还要自己实现哈希表、链表、二叉搜索树我的回答是工程里不需要重复造轮子但在学习阶段手写实现的价值在于让你真正理解底层机制。比如你自己实现一个简化版哈希表时会亲身感受到哈希函数、冲突处理、扩容这些环节的取舍自己实现二叉树时会体会到递归遍历为什么高效、为什么右子树和左子树顺序不同结果不同。这些手感是看文档和背代码完全替代不了的。但记住一个分寸学习阶段手写是为了懂原理工程阶段用现成库是为了效率和可靠两者并不矛盾。6.3 一份十行的个人速查表最后我建议每个人建立自己的“数据结构操作速查表”不用多长几条就够list随机访问、尾部增删、切片操作。tuple不可变、可哈希、用于dict的key和固定结构。dict键值映射、去重保序、defaultdict解决默认值。set去重、集合运算、成员判断O(1)。deque队列、栈、滑动窗口两端操作O(1)。heapq动态取最值优先队列。Counter计数统计、频率分布。pandas Series/DataFrame带标签的多维数据、聚合分析。这张表会随着你的项目经验不断更新最终变成一种本能。我在整理《韦奇-python 数据结构操作》时做的最有价值的事就是把这种本能变成了可以传给别人的文字。现在翻回去看自己当初用list硬撑滑动窗口的代码感触还挺深的数据结构操作这件事说到底就是“在合适的时机把数据放进合适的容器”一旦想通了这一点后续学什么算法都会顺手很多。
返回列表