摘要
算法和数据结构是编程能力的基础。很多性能问题并不是由语言本身造成的,而是因为没有根据数据规模选择合适的数据结构,或者忽略了算法的时间和空间复杂度。
本文从复杂度分析开始,介绍数组、动态数组和链表的基本结构、访问与插入特点,并通过 Python 实现简单的数据结构和常见操作,为后续学习栈、队列、树、图和排序算法建立基础。
一、背景与问题
同一个功能可以有不同实现。例如,从一组数据中判断某个元素是否存在:
python
items = ["A", "B", "C", "D"]
target = "D"
print(target in items)
当数据量很小时,不同实现之间的差异不明显;当数据量达到百万甚至更大时,查找、插入和删除的成本会直接影响响应时间。
常见问题包括:
- 只关注代码能否运行,不分析数据规模。
- 对列表头部频繁插入和删除,导致性能下降。
- 用线性查找处理本可以使用哈希结构的数据。
- 递归深度、内存占用和最坏情况被忽略。
- 复杂度分析停留在术语层面,没有联系实际操作。
算法学习的第一步不是背诵公式,而是理解数据结构如何组织数据,以及每个操作需要移动、比较或访问多少元素。
二、核心概念
1. 时间复杂度
时间复杂度描述输入规模增长时,算法执行步骤如何增长。常见复杂度如下:
| 复杂度 | 典型场景 |
|---|---|
O(1) |
通过索引访问数组元素 |
O(log n) |
有序数组二分查找 |
O(n) |
遍历数组 |
O(n log n) |
高效比较排序 |
O(n²) |
双重循环比较所有元素 |
复杂度通常关注增长趋势,不强调常数项和低阶项。例如 3n + 10 通常记为 O(n)。
2. 空间复杂度
空间复杂度描述算法额外使用的内存。输入数据本身占用的空间通常不计入额外空间,但复制数组、递归栈和辅助哈希表需要计入。
时间和空间经常需要权衡:
text
使用更多内存建立索引
→ 查询速度更快
减少额外内存
→ 可能需要重复扫描数据
3. 数组
数组将元素存放在连续或逻辑连续的位置,并通过下标访问:
text
index: 0 1 2 3
value: 10 20 30 40
数组的特点:
- 按下标访问通常是
O(1)。 - 尾部追加通常成本较低。
- 中间插入和删除需要移动元素。
- 适合随机访问和批量遍历。
4. 动态数组
Python 的 list 是动态数组。当容量不足时,运行时会申请更大的空间并复制元素。扩容策略通常让连续追加的均摊复杂度接近 O(1),但单次扩容可能需要 O(n)。
5. 链表
链表由节点组成,每个节点保存数据和下一个节点的引用:
text
head
│
▼
[10 | next] → [20 | next] → [30 | None]
链表不要求节点连续存储。已知节点位置时,插入和删除可以只修改引用;但按下标查找需要从头遍历,通常是 O(n)。
三、工作原理
1. 操作复杂度对比
| 操作 | 动态数组 | 单向链表 |
|---|---|---|
| 按下标访问 | O(1) |
O(n) |
| 头部插入 | O(n) |
O(1) |
| 尾部追加 | 均摊 O(1) |
O(n),有尾指针时可为 O(1) |
| 中间插入 | O(n) |
找位置 O(n),修改引用 O(1) |
| 按值查找 | O(n) |
O(n) |
| 删除已知位置 | 移动元素,O(n) |
修改引用,O(1) |
表格中的复杂度描述的是典型情况,实际结果还会受到缓存、内存布局和实现细节影响。
2. 为什么数组访问是 O(1)?
如果数组首地址为 base,每个元素占用 size 字节,那么第 i 个元素的地址可以近似计算为:
text
address(i) = base + i × size
因此不需要从第一个元素逐个查找。
3. 为什么链表按下标访问是 O(n)?
单向链表只有当前节点指向下一个节点的引用。要找到第 i 个节点,通常必须从头节点开始逐个跳转,最多访问 i + 1 个节点。
4. 均摊复杂度
动态数组偶尔需要扩容,但大多数追加操作不需要移动已有元素。把一系列操作的总成本平均到每次操作上,就得到均摊复杂度。
理解均摊复杂度后,可以解释为什么 Python 列表连续 append 通常表现良好,但在头部频繁 insert 仍然不适合。
四、实战示例
1. 数组和列表操作
python
numbers = [10, 20, 30, 40]
print(numbers[2])
numbers.append(50)
numbers[1] = 25
print(numbers)
通过下标访问和修改不需要遍历整个列表。
2. 头部操作的差异
python
from collections import deque
items = [2, 3, 4]
items.insert(0, 1)
print(items)
queue = deque([2, 3, 4])
queue.appendleft(1)
print(queue)
如果需要频繁从两端插入和删除,deque 通常比列表更合适。数据结构选择应由操作模式决定。
3. 实现单向链表
python
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Node:
value: int
next: Node | None = None
class SinglyLinkedList:
def __init__(self) -> None:
self.head: Node | None = None
self.tail: Node | None = None
self.size = 0
def append(self, value: int) -> None:
node = Node(value)
if self.head is None:
self.head = self.tail = node
else:
assert self.tail is not None
self.tail.next = node
self.tail = node
self.size += 1
def values(self) -> list[int]:
result: list[int] = []
current = self.head
while current is not None:
result.append(current.value)
current = current.next
return result
保存尾指针后,链表尾部追加可以避免每次从头遍历。
4. 删除第一个匹配节点
python
def remove_first(self, value: int) -> bool:
previous: Node | None = None
current = self.head
while current is not None:
if current.value == value:
if previous is None:
self.head = current.next
else:
previous.next = current.next
if current is self.tail:
self.tail = previous
self.size -= 1
return True
previous = current
current = current.next
return False
查找目标节点需要 O(n),找到后修改引用本身是 O(1)。
5. 线性查找与二分查找
python
def linear_search(values: list[int], target: int) -> int:
for index, value in enumerate(values):
if value == target:
return index
return -1
def binary_search(values: list[int], target: int) -> int:
left, right = 0, len(values) - 1
while left <= right:
middle = (left + right) // 2
if values[middle] == target:
return middle
if values[middle] < target:
left = middle + 1
else:
right = middle - 1
return -1
二分查找要求数据已经有序。它通过每次排除一半候选区间,将查找复杂度从 O(n) 降低到 O(log n)。
6. 复杂度测试
python
from timeit import timeit
values = list(range(100_000))
linear_time = timeit(
lambda: linear_search(values, 99_999),
number=100,
)
binary_time = timeit(
lambda: binary_search(values, 99_999),
number=100,
)
print("linear:", linear_time)
print("binary:", binary_time)
基准测试只能说明当前实现、数据和机器上的表现,不能代替复杂度分析,但可以帮助发现实现错误和明显的性能差异。
7. 使用集合优化存在性判断
python
allowed_users = {"u001", "u002", "u003"}
user_id = "u002"
if user_id in allowed_users:
print("allowed")
集合通常使用哈希结构,平均情况下成员判断接近 O(1)。如果只需要判断是否存在,不必每次在线性列表中扫描。
五、常见问题与实践建议
1. O(1) 是否表示一定很快?
不一定。复杂度描述增长趋势,不代表常数开销为零。一个常数很大的 O(1) 操作,在小数据和特定硬件上可能慢于简单的 O(n) 操作。
2. 为什么列表中间插入较慢?
因为插入位置后面的元素通常需要整体向后移动,为新元素腾出空间,移动数量随列表长度增长。
3. 什么时候使用链表?
链表适合需要频繁在已知节点位置插入和删除、且不依赖随机访问的场景。实际 Python 业务中,很多队列场景使用 deque 已经足够,不需要手写链表。
4. 二分查找为什么返回错误结果?
优先检查:
- 输入是否已经按同一规则排序。
left和right的边界是否一致。- 找到目标后是否及时返回。
- 更新边界时是否排除了已经检查过的中间位置。
5. 复杂度分析需要考虑最坏情况吗?
通常需要。平均复杂度有助于描述常见表现,但权限、支付、任务调度等关键路径更应该关注最坏情况和资源上限。
六、进阶思考
1. 数据结构选择应从操作开始
先列出核心操作,再选择结构:
text
需要按下标随机访问
→ 数组 / 动态数组
需要两端进出
→ deque
需要快速判断是否存在
→ set
需要键值映射
→ dict
需要优先处理最小或最大元素
→ heap
不要因为某个结构"更高级"就使用它,操作模式才是选择依据。
2. 理论复杂度与实际性能
真实性能还会受缓存局部性、内存分配、解释器开销、数据分布和并发影响。连续数组通常更利于缓存访问,链式结构则可能因为节点分散而产生额外开销。
3. 不变量
实现数据结构时,要为每个操作维护不变量。例如单向链表需要保证:
- 空链表的
head和tail状态一致。 tail.next始终为None。size与实际节点数量一致。- 删除最后一个节点后,
head和tail都正确更新。
不变量比记住某段代码更重要,因为它能指导边界情况处理。
4. 为算法增加测试
至少测试:
- 空数组或空链表。
- 只有一个元素。
- 目标在头部、中间和尾部。
- 目标不存在。
- 重复值。
- 删除后结构为空。
算法代码短,并不代表边界条件少。
结论
算法学习的基础是理解复杂度和数据结构操作成本。数组适合随机访问,链表适合已知节点位置的连接调整,列表、deque、集合和字典分别对应不同的操作模式。
后续可以继续学习栈与队列、哈希表、递归与回溯、树和图、排序与动态规划,并在每个主题中坚持分析时间复杂度、空间复杂度和边界条件。
参考资料
- Python 官方文档:https://docs.python.org/3/
- CPython
list数据结构文档:https://docs.python.org/3/tutorial/datastructures.html - Introduction to Algorithms:https://mitpress.mit.edu/9780262046305/introduction-to-algorithms/