2、数据结构与算法面试:列表与元组的底层实现、字典的哈希表原理、集合的应用场景、常见排序算法手写
2.1 列表与元组:看似相似,底层天差地别
面试时我经常问一个问题:「列表和元组有什么区别?」
大部分人会回答「列表可变,元组不可变」。嗯,没错,但这只是表面。真正拉开差距的,是底层实现。
列表的底层是动态数组。什么意思?
Python 的列表在 C 层面是一个 PyObject** 指针数组。它维护了三个核心字段:
ob_item:指向元素数组的指针allocated:已分配的内存槽位数ob_size:实际存储的元素个数
当你要往列表里追加元素时,如果 ob_size == allocated,就会触发扩容。扩容策略是:
- 新容量 ≈ 原容量的 1.125 倍 + 一些额外空间
- 重新申请内存,把旧数据拷贝过去
我遇到过一个问题:有个同事在循环里不断往列表头部插入元素(list.insert(0, item)),结果数据量到几万时程序直接卡死。为什么?因为每次头部插入都要把后面所有元素往后挪一位,时间复杂度 O(n)。
collections.deque,它的底层是双向链表,头尾操作都是 O(1)。
元组的底层更轻量。它也是 PyObject** 数组,但没有 allocated 字段,也不支持扩容。元组一旦创建,内存大小就固定了。所以元组可以缓存复用——Python 会缓存一些小元组(比如长度为 0 的空元组),避免重复分配。
我个人习惯:能用元组的地方绝不用列表。比如函数返回多个值时,用元组更安全,也暗示调用方「别改我」。
2.2 字典的哈希表原理:为什么键必须是不可变对象?
字典是 Python 里最核心的数据结构之一。它的底层是哈希表,说白了就是一个数组 + 哈希函数。
当你执行 d['key'] = value 时,Python 做了这几步:
- 调用
hash('key')计算哈希值 - 用哈希值对数组长度取模,得到索引位置
- 如果该位置为空,直接存入
- 如果冲突了(哈希碰撞),用开放寻址法找下一个空位
你想想看,为什么键必须是不可变对象?因为如果键变了,它的哈希值也会变。字典就再也找不到这个键了。我见过有人用列表当字典键,结果程序跑着跑着就报 TypeError: unhashable type: 'list'。
__hash__ 和 __eq__ 方法的一致性。如果两个对象相等,它们的哈希值也必须相等。
Python 3.6 之后,字典还有一个重要优化:保持插入顺序。底层改成了「数组 + 稀疏索引」的结构,既保留了哈希表的 O(1) 查找,又记住了元素的插入顺序。这个改动让我在写配置解析时省了不少心。
2.3 集合的应用场景:去重、交集、差集
集合的底层和字典几乎一样,只是没有 value。所以集合也要求元素是可哈希的。
我常用的场景有三个:
| 场景 | 代码示例 | 说明 |
|---|---|---|
| 去重 | list(set(items)) |
注意会丢失顺序,需要保持顺序用 dict.fromkeys() |
| 快速成员检查 | if x in s |
O(1) 时间复杂度,列表是 O(n) |
| 集合运算 | a & b、a | b、a - b |
交集、并集、差集,常用于权限校验 |
我曾经在做一个用户标签系统时,需要找出「同时拥有 A 标签和 B 标签的用户」。如果用列表,得写两层循环。用集合,一行 users_a & users_b 搞定,性能还快了一个数量级。
set(a) & set(b) 比嵌套循环快得多。数据量越大,差距越明显。
2.4 常见排序算法手写:面试必考
排序算法是面试手写代码的常客。我一般要求候选人至少能写出快速排序和归并排序。
快速排序:选一个基准值,把小于它的放左边,大于它的放右边,递归处理。
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
left = [x for x in arr if x < pivot]
middle = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quick_sort(left) + middle + quick_sort(right)
这个写法虽然简洁,但空间复杂度高。面试时我建议你写出原地排序版本,更能体现功底。
归并排序:分治思想,先分后合。
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
我记得有一次面试,候选人把归并排序写成了死循环。原因是他递归时传入了同一个数组的引用,导致无限分割。嗯,这里要注意:切片要生成新列表,或者用索引范围。
各排序算法对比:
| 算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
我个人建议:面试时优先写快速排序,因为它实现简单、平均性能好。但如果面试官要求稳定排序,那就用归并排序。
sorted() 用的是 Timsort——一种结合了归并排序和插入排序的混合算法。它在实际数据上表现极好,尤其是部分有序的数据。
好了,这一章的内容就这些。数据结构与算法是后端面试的基石,理解底层原理比背代码更重要。下一章我们会聊 IO 模型和并发编程,敬请期待。