☰
Python数据结构操作实战:从内置容器到底层原理
2026/10/3 2:51:44 网站建设 项目流程

很多人学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个价格,每来一个新价格就丢掉最老的那个。当时我图省事直接用了list:prices.append(new_price),然后prices.pop(0)。数据量小的时候没感觉,一回测几千只股票,反复pop(0)导致list整体搬移,速度慢到让人抓狂。后来换成deque(maxlen=N),一行初始化,往里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与deque

2.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

应该先把坐标转成tuple:

points = [(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] += 1

defaultdict(int)会在key不存在时自动赋值为0,所以+= 1不需要判断存在性。同理,构造一个“值都是列表”的字典时,defaultdict(list)极其方便。

2.3 deque、Counter与collections的“即插即用”

collections模块是Python数据结构操作里的隐藏宝箱。deque(双端队列)是最值得优先掌握的一个,因为它在队列和栈的场景里几乎是无脑最优解。

from collections import deque dq = deque(maxlen=5) dq.append(1) # 右边入队 dq.appendleft(2) # 左边入队 dq.pop() # 右边出队 dq.popleft() # 左边出队

maxlen参数尤其有用:一旦deque达到最大长度,再往里加元素会自动从另一端挤出旧元素。做滑动窗口、日志滚动、历史记录时,这一行代码能顶掉十行手动管理逻辑:

prices = deque(maxlen=10) 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], maxlen=10)

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, val=0, next=None): self.val = val self.next = next head = ListNode(1) head.next = ListNode(2) head.next.next = ListNode(3)

这个思路可以无缝迁移到二叉树:

class TreeNode: def __init__(self, val=0, left=None, right=None): 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.next

3.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用deque:

def dfs(node, visited=None): 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, reverse=True)

但面试和考试会要求你手写快排、归并、冒泡。我建议至少认真实现一遍冒泡排序和快速排序,理解它们的交换逻辑和复杂度差异。很多人写完冒泡发现自己实现版本慢得离谱,原因往往是没加“提前退出”机制。改进版是这样:

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创建DataFrame:

import 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(window=5).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。可如果只是一些简单的计数、去重、分组统计,原生的dict+set+Counter可能比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不用list:x 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) # 弹出1

5.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硬撑滑动窗口的代码,感触还挺深的:数据结构操作这件事,说到底就是“在合适的时机把数据放进合适的容器”,一旦想通了这一点,后续学什么算法都会顺手很多。

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

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

立即咨询