写代码久了你会发现,真正天天在用的反而不是那些花哨的框架,而是几个最基础的数据结构:栈、队列、set、map、list。很多性能问题、代码可读性问题,本质上是数据结构用错了。今天我把这几个容器的常用方法、适用场景、还有我踩过的坑一次性捋清楚,全程用Python演示,最后附上Java/C++的关键差异,保证你在任何一个语言里都能快速迁移。
1. 先从底层世界观聊起:容器三兄弟的分类逻辑
很多人学数据结构觉得枯燥,是因为把每个容器当成孤立的API在背。其实你只需要记住一件事:所有的容器本质上是换着花样解决“怎么存、怎么取”的问题。
我的理解框架是这样的:
- 线性存储:数据排成一条线,list、栈、队列都属于这一类。区别在于允许从哪头进、从哪头出。
- 哈希查找:set和map的核心,用哈希表实现,查找复杂度平均是O(1),牺牲了顺序换速度。
- 有序变体:TreeSet、TreeMap或者Python里的SortedList,用树结构实现,换来的是“有序”这个能力,但操作复杂度退化为O(log n)。
这个分类底下,选择容器的逻辑其实很简单:你需要“顺序”,还是需要“速度”?你需要“唯一”,还是需要“映射关系”?你需要“先进先出”,还是“后进先出”?
下面我把每一个容器掰开揉碎,从常用方法到源码特性,再到实战场景,逐个过一遍。
2. 栈常用方法:后进先出是它的命根子
2.1 栈的场景直觉
栈的经典口号叫“后进先出”(LIFO)。什么时候能想到用栈?我自己的经验是三个字:可回溯。
比如浏览器的后退按钮,你访问的顺序是 A -> B -> C,后退的时候先回到B再回到A,这不就是栈吗?再比如文本编辑器的Undo(撤销),你打得越多,撤销的顺序越逆着来,也是栈。
另外,函数调用本身就是在用栈。你写递归的时候,系统一直在帮你做入栈和出栈的操作,只是你自己没察觉。理解这一点,你就能理解为什么递归太深会栈溢出,因为系统栈的空间是有限的。
2.2 Python里的三种栈写法
Python自带类型里没有专门的Stack类,但list天然就能当栈用,Python官方文档也推荐这么做。
# 最直接的栈用法:list + append + pop stack = [] stack.append(1) # 入栈,等价于 push stack.append(2) stack.append(3) top = stack[-1] # 查看栈顶,不弹出 pop_value = stack.pop() # 出栈,弹出栈顶这里有个细节值得注意:stack[-1]取栈顶元素时,列表为空会报错IndexError。所以实战中我建议封装一个类,把判空逻辑包进去:
class Stack: def __init__(self): self._items = [] def push(self, item): self._items.append(item) def pop(self): if self.is_empty(): raise IndexError("pop from empty stack") return self._items.pop() def peek(self): if self.is_empty(): raise IndexError("peek from empty stack") return self._items[-1] def is_empty(self): return len(self._items) == 0 def size(self): return len(self._items)Java里用Stack类可以直接push、pop、peek,C++里是stack<T>,都有native支持。不过如果你在一些面试场景里,Stack类其实是Vector的遗产,性能上有同步开销,Java官方后来更推荐用ArrayDeque来实现栈。这也是一个典型的“用什么容器”比“用什么API”更重要的例子。
2.3 经典场景:括号匹配与函数调用
括号匹配是面试高频题,但现实中写编译器、做模板解析、校验配置语法都会遇到。核心思路就是用栈:
def is_valid_brackets(s: str) -> bool: stack = [] pairing = {')': '(', ']': '[', '}': '{'} for ch in s: if ch in pairing.values(): # 左括号入栈 stack.append(ch) elif ch in pairing: # 右括号检查配对 if not stack or stack.pop() != pairing[ch]: return False # 其他字符忽略 return not stack这段代码看起来简单,但有一个坑很多人没注意到:判断for ch in s时,如果字符串里带有引号包裹的括号字符,比如字符串字面量里的括号不应该参与匹配。我在写过一个SQL解析器的时候,就因为没有跳过字符串内容,导致括号配对逻辑被引号里的内容干扰。真正的实现里必须加一个状态机去识别是否处于字符串中。
2.4 栈使用的三个经验
- 优先用小数组模拟:很多语言里栈底层是数组,初始化时如果预估数据量,可以先分配空间,避免频繁扩容。
- 入栈操作要关注“引用别变”:入栈的是对象引用,后续修改外部对象会影响栈里的数据,这是个隐蔽Bug。该用不可变对象或拷贝时不要偷懒。
- 遍历栈不要直接用下标:栈的语义就是只能从栈顶操作。如果你发现自己需要遍历栈,说明你的数据结构选错了,应该改用一个list有序处理。
3. 队列常用方法:先进先出的任务流水线
3.1 什么场景必须用队列
队列的语义是“先进先出”(FIFO),最典型的场景就是排队。消息队列、网络请求调度、线程池任务排队,底层都是这个思想。
我自己的区分经验是这样:需要公平、需要顺序、需要削峰时用队列;需要回溯、需要回退时用栈。你写一个请求限流系统,进来的请求就得按到达顺序一个个处理,第一个来的不能被后来的插队,这必须用队列。
3.2 Python的deque才是最优解
很多人用list模拟队列,我劝你别这么干:
# 错误示范:list模拟队列 queue = [] queue.append(1) # 入队 item = queue.pop(0) # 出队,但是时间复杂度是O(n)pop(0)会把后面所有元素往前挪一位,列表一长,效率极其低下。正确的做法是用collections.deque,双向队列,两端操作都是O(1):
from collections import deque queue = deque() queue.append(1) # 从右边入队 queue.append(2) left_item = queue.popleft() # 从左边出队,也就是队首如果需要限定队列长度,可以用deque(maxlen=3),当队列满了之后再append,最老的元素会自动被挤出去。这在做“最近N条日志”这种场景非常好用——它就是你的滑动窗口。
如果说deque是双端队列,那就意味着它同时支持两端的append和pop,这在实现某些算法时非常灵活。比如你既需要从队首取值,又需要往队尾塞数据,deque一把梭。
3.3 优先队列与普通队列的区别
说完普通队列,必须讲讲优先队列。因为在实际工作中,最常见的队列其实是带优先级的。
业务上的典型场景:订单有VIP等级,VIP用户的请求要插队到普通用户前面,这种就不能用普通队列,要用优先队列(PriorityQueue/Heap)。
import heapq # 最小堆做优先队列,数字越小优先级越高 tasks = [] heapq.heappush(tasks, (2, "普通任务")) # 第二个元素是任务内容 heapq.heappush(tasks, (1, "VIP任务")) heapq.heappush(tasks, (3, "后台任务")) while tasks: priority, task = heapq.heappop(tasks) print(task, "优先级:", priority)优先队列的底层是堆结构,入队出队都是O(log n),它和普通队列的“绝对顺序”不同,它保证的是“按优先级顺序”。如果你希望高优先级的任务先被处理,但同级的还要保证先后顺序,这里有个小技巧:插入时加一个计数器,(priority, seq, task),避免两个相同优先级的任务互相比较时报错,同时保证先到的先出。
我在某公司做消息网关时遇到过一个问题:直接(priority, task)推送给不同的任务对象时,Python会遇到类型无法比较的TypeError。加上计数器做一个递增序号,就能完美解决。
3.4 队列实现的取舍细节
- Python的
queue.Queue是线程安全的,但多线程环境下它内部有锁,单线程环境下性能反而不如deque。如果你只是普通业务代码里做在中转,建议用deque就行。 - 如果你在做广度优先搜索(BFS),队列就是天然的遍历容器。网格类路径规划、树的层序遍历,出队一个节点、把相邻节点入队,整个搜索过程就是这样简单。
deque切片是逐段操作的,不要试图对整个deque做queue[1:3]这种切片操作,它不支持。需要时先转成list再说。
4. set常用方法:去重和集合运算的效率霸主
4.1 为什么查“是否存在”一定要用set
一个最常见的错误:用list去查一个元素是否存在。
# 慢:list查找是O(n) user_ids = [101, 102, 103, 104] if 103 in user_ids: print("存在") # 快:set查找是O(1) user_ids_set = {101, 102, 103, 104} if 103 in user_ids_set: print("存在")当数据量到达万级别的时候,list的in操作在性能上的劣势就开始明显了;到百万级别,差异甚至能到上千倍。set底层是哈希表,通过哈希值直接定位,和list的线性扫描完全是两个维度。
我习惯用一句话记:判断“存在与否”用set,需要“保持顺序”才用list。
4.2 set的增删查与集运算
set的常用方法很多,但归类起来就是增删查、集合运算、集合关系三大类。
# 创建与增删 s = {1, 2, 3} s.add(4) # 添加元素 s.discard(2) # 删除元素,不存在也不会报错 s.remove(1) # 删除元素,不存在会抛KeyError s.pop() # 随机删除并返回一个元素 # 判断 3 in s # O(1)复杂度 # 集合运算 a = {1, 2, 3} b = {2, 3, 4} print(a & b) # 交集 {2, 3} print(a | b) # 并集 {1, 2, 3, 4} print(a - b) # 差集 {1} print(a ^ b) # 对称差集 {1, 4}这中间有一个非常隐蔽的坑:remove和discard的区别。如果你不确定元素是否存在,用discard更安全;但如果你需要知道元素是否存在,应该在remove前面加一次in判断,而不是catch异常。写Python时尽量遵循“与其请求原谅,不如事先检查”(EAFP vs LBYL),但在set操作上,提前判断更能保证代码可读性。
集合运算再深入一点,还涉及子集和超集的判断:
a = {1, 2} b = {1, 2, 3} print(a.issubset(b)) # True,a是b的子集 print(b.issuperset(a)) # True,b是a的超集这个在权限校验上非常好用,比如判断用户拥有的权限是否包含访问某个接口所需要的一组权限,一条子集判断搞定了,不需要写循环遍历。
4.3 set的内部机制决定了它的限制
set有两个天然限制,大家容易忽略:
第一,元素必须是可哈希的。list、dict这类可变类型不能放进set。我见过有人想存一个坐标列表,直接用set([[1,2],[3,4]]),结果直接TypeError。解决办法是把list转tuple再放进去。
coords = [(1, 2), (3, 4)] s = set(coords) # tuple可以哈希,list不行第二,set是无序的。你以为输出顺序和插入顺序一致,在小数据量时Python 3.7+的实现确实有点像顺序,但这只是哈希决定的偶然表现,不是语言保证的行为。如果你需要“去重且有序”,要么用Python 3.7+的dict.fromkeys技巧,要么用SortedContainer这类第三方库,要么手动转list + sort。
# 去重保持顺序的经典写法 items = [3, 1, 3, 2, 1, 4] unique_ordered = list(dict.fromkeys(items)) print(unique_ordered) # [3, 1, 2, 4]这个写法我经常用,本质是拿dict的键天然唯一且保持插入序的特性做文章,比set输出后你无法保证顺序要好得多。
4.4 frozenset:不可变set的另类用处
set本身可变,所以不能作为另一个set的元素,也不能作为dict的键。但如果你需要“集合的集合”这种场景,比如判断一个用户是否已经访问过某个权限组合,就需要frozenset。
fs1 = frozenset({1, 2}) fs2 = frozenset({2, 3}) outer = {fs1, fs2}我自己写一个权限去重模块时就用到了frozenset,因为要判断若干个权限集合是否重复出现,直接放进set里自动去重,完美符合需求。
5. map常用方法:键值映射的百搭神器
5.1 什么时候想到用map
说白了,map就是字典,存储键值对。什么时候用map?你遇到“根据某样东西查另一样东西”的场景,就该用map。
- 根据用户ID查用户详情
- 根据配置项名称查配置值
- 根据单词统计出现次数
- 根据路由路径查处理函数
在我做的很多后端项目里,map是最常用的容器,没有之一。缓存、状态机、枚举映射、多条件分发,全都靠在map上做文章。
5.2 Python dict的常用方法盘点
data = {"name": "张三", "age": 20, "city": "北京"} # 读取与写入 value = data["name"] # 键不存在,抛KeyError,不推荐 value = data.get("name", "默认值") # 键不存在时返回默认值,推荐 data["age"] = 21 # 修改已有键 data["gender"] = "男" # 新增键 # 遍历 for key in data: print(key, data[key]) for key, value in data.items(): print(key, value) for key in data.keys(): pass for value in data.values(): pass # 删除 popped = data.pop("city", None) # 删除键并返回值,不存在时返回默认 del data["age"] # 直接删除,键不存在抛KeyError data.clear() # 清空所有内容 # 合并更新 data.update({"email": "x@example.com", "age": 22})这里面最常用的自然是.get,因为它安全。我在实际开发里几乎不会直接用data["key"],因为一旦键不存在,整个流程就崩掉了。.get配合默认值写起来很顺手。
另一个高频操作是统计次数。很多人会用if key not in dict的写法判断,但其实有更简洁的方法:
# 传统写法 counter = {} word_list = ["a", "b", "a", "c", "a"] for word in word_list: if word in counter: counter[word] += 1 else: counter[word] = 1 # 简洁写法 counter = {} for word in word_list: counter[word] = counter.get(word, 0) + 1 # 更优雅的defaultdict from collections import defaultdict counter = defaultdict(int) for word in word_list: counter[word] += 1defaultdict(int)的用法很妙,键不存在时自动用int()初始化成0,直接加一,相当于你少写了一个判断。defaultdict(list)也很常用,存“一个键对应多个值”的映射时特别方便,不需要手动判断键存不存在再append。
5.3 有序的map需求:用OrderedDict还是普通dict
Python 3.7之后,普通dict也保持插入顺序,这一点让OrderedDict的存在感降低了很多。但如果你需要做“移动键到末尾”这种操作,OrderedDict依然有用:
from collections import OrderedDict od = OrderedDict({"a": 1, "b": 2, "c": 3}) od.move_to_end("a") # 把a移动到末尾 print(list(od.keys())) # ['b', 'c', 'a']这个操作在实现一个简单的LRU缓存时很关键。每次访问某个键,就把它move_to_end,淘汰的时候直接从头部popitem,就能实现一个最简LRU。
from collections import OrderedDict class LRUCache: def __init__(self, capacity): self.capacity = capacity self.cache = OrderedDict() def get(self, key): if key in self.cache: self.cache.move_to_end(key) return self.cache[key] return -1 def put(self, key, value): if key in self.cache: self.cache[key] = value self.cache.move_to_end(key) else: if len(self.cache) >= self.capacity: oldest_key = self.cache.popitem(last=False)[0] self.cache[key] = value这个LRU实现我很喜欢,因为代码量少,也能清楚地展示OrderedDict的价值。
5.4 dict的合并与解包技巧
Python 3.9之后,dict支持用|合并,这个写起来比update更直观:
a = {"x": 1, "y": 2} b = {"y": 3, "z": 4} merged = a | b # {'x': 1, 'y': 3, 'z': 4}另外解包方式也能实现同样效果:
merged = {**a, **b}5.5 map的键类型限制
dict的键和set元素一样,必须是可哈希的。经常有人想在Python里用dict当键做缓存记忆化,然后报错。解决办法是用frozenset或者自定义类的__hash__方法。不过一个原则性建议:键尽量用字符串、数字、tuple这类不可变基础类型,这样既安全又清晰。
6. list常用方法:最灵活的序列容器
6.1 list能做的远比你想象的多
list是Python中使用频率最高的容器。它既能当数组用,又能当栈用,还能在部分场景下当队列用,但前面说过队列性能要小心。
常用方法一次性捋清:
arr = [3, 1, 2] arr.append(4) # 末尾追加 arr.insert(0, 0) # 指定位置插入,O(n)复杂度 arr.extend([5, 6]) # 合并另一个列表 arr.remove(1) # 删除第一个匹配值,不存在抛ValueError item = arr.pop() # 删除并返回末尾元素 item = arr.pop(0) # 删除并返回指定位置元素 arr.index(3) # 查找第一个匹配值的索引 arr.count(2) # 统计某个值的出现次数 arr.reverse() # 原地反转 arr.sort() # 原地升序排序 sorted_arr = sorted(arr, reverse=True) # 返回新排序列表 # 切片 sub = arr[1:3] # 左闭右开区间 reverse_view = arr[::-1] # 反转的拷贝6.2 排序与自定义键的套路
list.sort()可以传key函数,这是一个非常强大但很多人用不熟的点:
people = [ {"name": "小明", "age": 22}, {"name": "小红", "age": 20}, {"name": "小刚", "age": 25}, ] # 按年龄升序 people.sort(key=lambda p: p["age"]) # 按年龄降序,再按名字升序 people.sort(key=lambda p: (-p["age"], p["name"]))我在实际项目中经常要“按某个业务字段排序,同时为空值沉底”。这里有一个坑,None无法和数字比较大小。解决办法是用一个超大数或超小数的替代key:
# 把None映射到无穷大,排序时沉底 people.sort(key=lambda p: (p.get("age") is None, p.get("age") or 9999))这样处理过的逻辑在排行榜、分页列表里都非常实用。
6.3 遍历中删除元素的大坑
这是list操作中最经典的坑,我敢说到今天还有人在犯。
| 错误做法 | 后果 |
|---|---|
| 正序遍历并删除元素 | 索引错位,漏删元素 |
| 循环中改变列表长度 | 越界或早退出 |
| 用del删除正在迭代的元素 | 迭代器行为不可预知 |
典型错误示例:
# 想删除所有偶数,结果删不干净 nums = [1, 2, 4, 5, 7, 6, 8] for num in nums: if num % 2 == 0: nums.remove(num) print(nums) # [1, 4, 5, 7, 8],4和8还留着为什么会这样?因为删除元素后,列表的索引自动前移,循环指针却继续前进,于是跳过了紧接着的下一个元素。正确的做法是倒序遍历,或者创建新列表:
# 方案一:倒序遍历删除 nums = [1, 2, 4, 5, 7, 6, 8] for i in range(len(nums) - 1, -1, -1): if nums[i] % 2 == 0: del nums[i] print(nums) # 方案二:列表推导筛选(更推荐) nums = [1, 2, 4, 5, 7, 6, 8] nums = [num for num in nums if num % 2 != 0]列表推导式不仅是写法简洁的问题,它在底层是直接生成新列表,完全避开索引错位问题,性能也很高。
6.4 list与array的区别
如果数据量非常大且都是同类型数值,list的性能和内存占用都不占优,因为list里每个元素都是独立对象,加上Python的list本质上是引用数组。数值密集计算场景,建议用array.array或者numpy.ndarray。
from array import array # 同类型数值数组,内存占用远小于list nums = array('i', [1, 2, 3, 4, 5])不过99%的业务场景里list已经够用,不用盲目追求array。
7. 栈、队列、set、map、list的横向对比与选型决策
7.1 一张表搞定核心差异
| 容器 | 底层实现 | 有序性 | 唯一性 | 查找复杂度 | 插入删除复杂度 | 常见用途 |
|---|---|---|---|---|---|---|
| 栈 | 数组/链表 | 后进先出 | 不要求 | 一般不查中间 | O(1)(栈顶) | 递归回溯、撤销、括号匹配 |
| 队列 | 数组/链表/堆 | 先进先出(或优先级) | 不要求 | 一般不查中间 | O(1)(两端) | 任务调度、BFS、消息缓冲 |
| set | 哈希表 | 无序 | 是 | O(1) | O(1) | 去重、成员判断、集合运算 |
| map/dict | 哈希表 | 插入序(Python) | 键唯一 | O(1) | O(1) | 映射查询、缓存、计数 |
| list | 动态数组 | 完全有序 | 不要求 | O(n) | 末尾O(1),头部O(n) | 序列存储、遍历、栈模拟 |
从这表里能直接看出选型思路:一个数据如果既需要保持顺序,又需要频繁按键查找,那dict就是答案;如果需要顺序且可重复,就是list;如果只需要去重,就是set;如果既要先来后到又要有序处理,就是队列。
7.2 一个场景跑通所有容器
我在某电商后台做一个订单处理流程时,这套容器几乎全用上了:
- 订单数据从外部系统拉下来,先用list按时间顺序缓存,方便分页展示。
- 待处理的订单进入一个队列,按创建时间依次推送处理引擎。
- 已经处理完的订单ID存入set,用来判断新来的订单是否重复处理(幂等)。
- 每个订单ID映射对应的处理状态,用dict存储,方便快速查询当前进度。
- 如果处理流程出错了需要回滚,就把之前经过的操作记录压入栈中,逐层弹出撤销。
一套流程下来,list负责顺序展示、队列负责调度、set负责唯一性校验、dict负责状态查询、栈负责回滚。你把这个思路吃透,在任何系统里都能画出类似结构。
8. 高频笔试/面试题里这些容器是怎么被套用的
8.1 用栈解决“下一个更大元素”
经典面试题:对数组中的每个元素,找出它右边第一个比它大的数,没有就是-1。暴力O(n^2),但用一个栈可以O(n)。
思路是把数组的下标依次压入栈,如果当前元素比栈顶下标对应元素大,就说明栈顶找到了“下一个更大元素”,弹出栈顶并记录结果,然后把当前下标入栈,继续循环。
def next_greater(nums): result = [-1] * len(nums) stack = [] for i in range(len(nums)): while stack and nums[i] > nums[stack[-1]]: idx = stack.pop() result[idx] = nums[i] stack.append(i) return result print(next_greater([2, 1, 4, 3, 5])) # [4, 4, 5, 5, -1]这个题型的核心就是:栈里存的是“还没找到答案”的待匹配项,每次来一个新元素,把符合条件的都弹出来“结算”掉。
8.2 用队列做BFS层序遍历
树的层序遍历是队列最典型的应用:
from collections import deque def level_order_traversal(root): if not root: return [] result = [] q = deque([root]) while q: level_size = len(q) current_level = [] for _ in range(level_size): node = q.popleft() current_level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) result.append(current_level) return result注意这个level_size变量的处理:先记录当前队列长度,再在这个范围内循环,这样能保证每次循环处理的是同一层的所有节点,这个细节直接区分了“层序遍历”和“普通BFS”。
8.3 用set/map搞定“两数之和”
经典的双数求和问题:给一个目标值,找出数组里两个数之和等于它。如果用dict记录每个数是否出现过:
def two_sum(nums, target): seen = {} for i, num in enumerate(nums): complement = target - num if complement in seen: return [seen[complement], i] seen[num] = i return [] print(two_sum([2, 7, 11, 15], 9)) # [0, 1]这里用的就是map的O(1)查找。如果用list存“已见过的数”,复杂度就退化成O(n^2)了。
8.4 用list切片与排序解决问题
需要保持前K个最大的元素,你可能有三种思路:直接全排序O(n log n)、手动维护长度为K的list每次冒泡插入O(nk)、或使用堆O(n log k)。数据规模一大,全排序就不是最优解了。这种题目考查的既是数据结构知识,也是算法复杂度的意识。
9. 常见问题速查与避坑清单
9.1 问题定位表
| 典型问题 | 根本原因 | 解决方式 |
|---|---|---|
| list.pop(0)性能差 | 底层数组整体拷贝 | 改用deque.popleft() |
| 遍历list时删除数据漏项 | 索引错位 | 倒序遍历或列表推导 |
dict["key"]抛KeyError | 键不存在 | 用dict.get()设置默认值 |
| set/list元素类型无法哈希 | 使用了list/dict作为元素 | 转为tuple/frozenset |
| set得到的数据顺序和预期不同 | 哈希表天然无序 | 用dict.fromkeys保持插入顺序 |
| 队列在多线程性能差 | 不必要的锁竞争 | 单线程场景改用deque |
| 递归层级过深导致程序退出 | 系统栈空间不足 | 改写成循环,用显式栈模拟 |
| defaultdict误用导致多了一个键 | defaultdict(int)访问不存在的键会创建 | 用普通dict+get或先判断 |
9.2 我踩过的一个list坑
有一次我做一个接口的分页逻辑,从数据库取了一批数据放进list,然后想边过滤边保留原始顺序。代码长这样:
for item in items: if item["status"] == "expired": items.remove(item)结果列表中连续两个expired状态的item只删掉了一个。我排查了半天,最后才意识到是索引错位。从此之后我给自己立了一条规矩:不修改正在迭代的容器,需要过滤就生成新列表。这不只是美观问题,而是关乎正确性。
9.3 关于“内存占用”的提醒
set和dict的哈希表结构,比list占用更多内存。如果你只需要判断“存在性”且数据量极大,可以考虑用bitmap或者布隆过滤器,而不是无脑用set。像我做某推荐系统的黑白名单时,一台机器内存有限,set存百万级样本很吃力,布隆过滤器虽然有小概率误判,但换来了几十倍的省内存。
10. 不同语言下的方法对应参考
很多人会在Python、Java、C++之间切换。不同语言的API不一致,但数据结构思想是通的。这里整理一份常用对照表:
| 操作 | Python | Java | C++ |
|---|---|---|---|
| 入栈 | stack.append() | stack.push() / deque.push() | s.push() |
| 出栈 | stack.pop() | stack.pop() / deque.pop() | s.pop() |
| 栈顶查看 | stack[-1] | stack.peek() / deque.peek() | s.top() |
| 入队 | deque.append() | queue.offer() | q.push() |
| 出队 | deque.popleft() | queue.poll() | q.pop() |
| 队首查看 | deque[0] | queue.peek() | q.front() |
| set添加 | s.add(x) | set.add(x) | s.insert(x) |
| set删除 | s.discard(x) | set.remove(x) | s.erase(x) |
| map赋值 | data[key] = value | map.put(key, value) | mp[key] = value |
| map取值 | data.get(key, default) | map.getOrDefault(key, default) | mp[key] |
| list追加 | list.append(x) | list.add(x) | v.push_back(x) |
记住这个对照表,跨语言开发时你就不用再查文档了。
10.1 各语言容器的性能差异
Java的Stack类继承自动Vector,所有方法都带锁,所以性能比ArrayDeque差,官方文档自己都建议用ArrayDeque优先替代。C++的std::stack默认底层是deque,如果数据量很大,可以考虑换成vector作为底层容器降低开销。
Python的list本质是引用数组,元素扩容时有时会轻微浪费内存。set和dict底层是开放寻址法,删除元素时不会立即缩容,如果有大量的增删交替且内存宽度敏感,注意重建容器来释放容量。
这些差异我们日常开发不太容易察觉,但在做高并发、高吞吐的模块时影响明显。我的原则是:先用对的数据结构,再做性能优化;性能优化的前提是有什么地方慢,不能盲目把数据结构和容器换来换去。
11. 从源码层面看容器的设计逻辑
11.1 为什么Python dict这么灵活
CPython的dict底层是稀疏哈希表,每个槽位存指向键和值的指针。因为键和值分成两块存储,所以dict天然支持混合类型键值。比如{"name": "张三", 1: "数字键"}就是合法的。
但哈希表在扩容时,会重新计算所有现有键的哈希值并重新分配位置,这意味着如果你持有某个dict的视图经过大规模的insert之后再遍历,与添加时的顺序可能完全不一致(这里针对旧版本Python,3.7+保证插入序,扩容后相对顺序依然保持)。
11.2 为什么集合运算能快成那样
集合的交并差运算,底层是反复调用哈希表的查找、插入、删除。因为每次操作都是O(1),所以两个set的交集复杂度是O(min(len(a), len(b))),远快于嵌套循环的O(n*m)。我在处理几十万用户ID比对时,set的交集操作跑一次只需要几十毫秒,而用列表嵌套循环可能要几分钟。
11.3 deque的内部结构
CPython的deque底层由多个定长数组块组成,形成一个双向链表式的结构。它不像list那样有“容量”的概念,也不需要整体移动元素。正因为如此,deque成为了BFS和滑动窗口的标配。
12. 这些数据结构将来会怎么演进
不同语言对容器的改进都在朝一个方向走:让代码更安全、性能更好、写起来更简单。
Python这边,3.7把dict的插入顺序变成了语言规范,3.9新增dict |操作,3.10的match-case更是把dict模式匹配做进了语言层,很多分发代码连if-elif都不需要了。
Java这边,Stream API让list、set、map的过滤、映射、聚合操作几乎全变成了链式调用。C++20引入的std::ranges对容器的操作也有很大的简化。
但底层数据结构本身变化不大——哈希表还是哈希表,数组还是数组。只要你的基本功扎实,语言版本再怎么升级,你都能快速适应。
我个人在实际开发中最大的体会是:用好容器不是背API,而是根据场景的“读写比例、顺序要求、唯一性要求”三个维度去选型。你每次写代码前,花十秒钟想一想这三个问题,踩坑的概率就会小很多。
另外,疫情期间我居家办公,远程会议多,就用自己写的这几套数据结构搭过一个小工具:消息队列负责排队处理会议纪要、set去重标记已参加会议、map记录每个参会的发言时间、list做时间线展示、栈做回滚撤销。这件事让我确信,数据结构是那种生活中真真切切用得上、不只是面试题的基本功。
最后再分享一个小技巧:源码里多留注释。给容器变量起名时带出它的用途,比如pending_queue、seen_set、status_map,我在排查旧代码时看到这些名字就能立刻读懂意图,比任何设计文档都直观。代码不只是写给自己看的,更多是写给三个月后的自己看的。
希望这篇能把栈、队列、set、map、list这五个容器的常用方法和选型思路讲透。如果你现在正被某个容器的问题卡住,回去看看第9章的问题速查表,多半能找到答案。