第一章:算法的度量衡 —— 时空复杂度分析与Python性能陷阱
在踏上算法探索的征途之前,我们必须先锻造好我们的度量工具。没有度量,就无法比较;没有比较,就无法选择;没有选择,就无法优化。在算法的世界里,这个度量衡就是“时空复杂度”。
1.1 为何需要复杂度分析?—— “跑一下代码看看”的局限性
一个初学者在比较两个算法(例如,两种不同的排序方法)的优劣时,最直观的想法可能是:“我把这两个算法都实现出来,然后用同一个大列表去跑一下,看看哪个花的时间短。”
这是一种基于经验的测试方法,它在某些情况下有用,但作为一种严格的评判标准,它存在着致命的缺陷:
环境依赖性太强:
数据规模的迷惑性:
数据状态的偶然性:
因此,我们需要一种与具体硬件、环境无关,能够描述算法效率与数据规模之间增长关系的理论工具。这个工具,就是复杂度分析。它不关心算法执行的绝对时间(例如“跑了0.2秒”),而是关心算法执行时间(或占用空间)的增长趋势。
1.2 时间复杂度:代码执行时间的增长趋势
时间复杂度,全称为“渐进时间复杂度”(Asymptotic Time Complexity),它描述的是当输入数据规模n
趋向于无穷大时,算法执行所需时间步数的增长级别。我们用**大O表示法(Big O Notation)**来表示。
让我们从一个最简单的例子开始,理解大O是如何推导出来的。
代码示例:计算列表中所有元素的和
def sum_list(data_list):
"""计算一个列表中所有数字的和。"""
total = 0 # 执行1次:赋值操作
for num in data_list: # 执行n次:其中n是data_list的长度
total += num # 执行n次:加法和赋值操作
return total # 执行1次:返回操作
推导过程:
数出基本操作次数:
total = 0
执行了1次。for
循环本身,连同其内部的total += num
,对于一个长度为n
的列表,总共会执行n
次。我们粗略地将循环的每次迭代记为2个操作步(一次迭代判断,一次加法赋值)。return total
执行了1次。T(n) = 1 + 2*n + 1 = 2n + 2
。T(n)
代表了代码执行的总时间步数与输入规模n
的函数关系。关注增长趋势,忽略常数、低阶项和系数:
n
变得非常大时(比如10亿),2n + 2
中的那个+2
变得微不足道,完全可以忽略。公式简化为 T(n) ≈ 2n
。2n
的增长趋势和n
的增长趋势是同一种类型——线性增长。当n
翻倍时,执行时间也大致翻倍。因此,我们把这个系数2
也忽略掉。O(n)
。大O表示法的核心:它抓住了一个函数中“最主要”的部分,也就是当n
趋于无穷时,增长最快的那个项,并忽略掉所有“修饰性”的常数和系数。
1.2.1 常见的时间复杂度量级(从优到劣)
理解并能识别出这些常见的复杂度量级,是算法学习的第一步。
1. O(1) - 常数时间复杂度 (Constant Time)
这代表算法的执行时间不随输入数据规模n
的变化而变化。无论n
是10还是10亿,执行时间都保持在一个常数水平。
def get_first_element(data_list):
"""获取列表的第一个元素。"""
if not data_list: # 执行1次:检查列表是否为空
return None # 在列表为空时执行1次:返回None
return data_list[0] # 在列表不为空时执行1次:通过索引获取元素
data_list
有多长,这个函数都只执行一次索引操作data_list[0]
。它的执行时间是一个固定的、与n
无关的常数。Python中字典(dict
)和集合(set
)的查找、插入、删除操作,在平均情况下的时间复杂度也是O(1),这是哈希表数据结构的威力所在。2. O(log n) - 对数时间复杂度 (Logarithmic Time)
这是非常优秀的一种复杂度。当n
增大时,执行时间步数增长得非常缓慢。典型的O(log n)算法,每一步处理都会将待解决问题的规模缩减一个量级(例如,减半)。
经典案例:二分查找
def binary_search(sorted_list, target):
"""在一个排好序的列表中使用二分查找来寻找目标值。"""
low = 0 # 执行1次:初始化low指针
high = len(sorted_list) - 1 # 执行1次:初始化high指针
while low <= high: # 循环的次数是关键,我们称之为k次
mid = (low + high) // 2 # 执行k次:计算中间位置
guess = sorted_list[mid] # 执行k次:获取中间值
if guess == target: # 执行k次:比较操作
return mid # 最多执行1次:找到目标,返回索引
if guess > target: # 执行k次:比较操作
high = mid - 1 # 执行k次:缩小查找范围的上界
else: # 执行k次:比较操作
low = mid + 1 # 执行k次:缩小查找范围的下界
return None # 最多执行1次:未找到目标,返回None
O(log n)
?假设列表长度为n
。
n/2
。n/4
。k
次查找后,剩余的查找范围是 n / 2^k
。n / 2^k = 1
。n = 2^k
,进而得到 k = log₂(n)
。k
与n
的对数成正比。底数是2还是10在Big O表示法中被忽略,统一记为O(log n)
。3. O(n) - 线性时间复杂度 (Linear Time)
这是非常常见的一种复杂度,意味着算法的执行时间与输入规模n
成正比。我们最开始的sum_list
例子就是O(n)
。
代码示例:线性查找
def linear_search(data_list, target):
"""在列表中进行线性查找。"""
for i in range(len(data_list)): # 循环会执行n次
if data_list[i] == target: # 比较操作会执行n次(最坏情况下)
return i # 如果找到,则提前返回
return None # 如果循环结束仍未找到,返回None
n
个元素。4. O(n log n) - 线性对数时间复杂度 (Log-Linear Time)
这是算法领域的一个“黄金标准”,尤其是在排序领域。许多最高效的、基于比较的排序算法(如归并排序、快速排序的平均情况)都具有这个复杂度。它比O(n^2)
快得多,但比O(n)
慢。
n
个元素中的每一个都执行一次O(log n)
的操作。那么总的复杂度就是 n * O(log n)
,即 O(n log n)
。log n
层),在每一层,它都需要对总共n
个元素进行合并操作。因此,总复杂度是 O(n log n)
。我们将在排序章节详细实现它。5. O(n²) - 平方时间复杂度 (Quadratic Time)
当n
较大时,这种复杂度的算法会变得非常慢。它通常涉及到对数据集的嵌套循环。
代码示例:找出列表中的重复元素(朴素方法)
def find_duplicates_naive(data_list):
"""使用嵌套循环来查找列表中的重复元素。"""
duplicates = [] # 执行1次:初始化结果列表
n = len(data_list) # 执行1次:获取列表长度
for i in range(n): # 外层循环执行n次
for j in range(i + 1, n): # 内层循环执行的次数与i相关
# 当i=0时,内层循环n-1次
# 当i=1时,内层循环n-2次
# ...
# 总次数约等于 (n-1)+(n-2)+...+1 = n*(n-1)/2 = 0.5n² - 0.5n
if data_list[i] == data_list[j]: # 比较操作
if data_list[i] not in duplicates: # 检查是否已记录
duplicates.append(data_list[i]) # 添加到结果列表
return duplicates # 返回结果
n²/2
。根据大O表示法,我们忽略系数1/2
和低阶项-0.5n
,得到时间复杂度为O(n²)
。6. O(2ⁿ) - 指数时间复杂度 (Exponential Time)
这是一种非常糟糕的复杂度,算法的执行时间会随着n
的增加而爆炸式增长。这类算法通常涉及到对问题所有可能子集的蛮力搜索。
经典案例:斐波那契数列的朴素递归实现
def fibonacci_recursive(n):
"""使用朴素递归计算斐波那契数列的第n项。"""
if n <= 1: # 递归的基线条件
return n
# 关键问题在这里:每次调用都会产生两个新的递归调用
return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)
fibonacci_recursive(5)
会触发fib(4)
和fib(3)
。fib(4)
又会触发fib(3)
和fib(2)
。你会发现fib(3)
被重复计算了。这种递归调用的总次数大致是2^n
的量级,形成一个巨大的、充满重复计算的递归树。7. O(n!) - 阶乘时间复杂度 (Factorial Time)
这是最差的一类复杂度,只在n
非常非常小的情况下才可用。它通常出现在需要计算所有排列组合的问题中。
经典案例:旅行商问题(TSP)的蛮力解法
n
个城市,每个城市只访问一次,最后回到出发城市。如何找到最短的路线?n
个城市的排列组合有 (n-1)!
种。这是一个O(n!)
级别的算法。当城市数量达到20个时,计算量就已经是天文数字了。1.3 空间复杂度:算法占用的额外内存
空间复杂度(Space Complexity)衡量的是算法在运行过程中,除了存储输入数据本身所占用的空间之外,还需要额外占用的内存空间与输入规模n
之间的增长关系。
1. O(1) - 常数空间复杂度
算法所需的额外空间是一个固定的常数,不随n
的变化而变化。
def reverse_list_in_place(data_list):
"""原地反转一个列表。"""
left = 0 # 需要1个变量空间
right = len(data_list) - 1 # 需要1个变量空间
while left < right:
# 下面的交换操作只在已有的列表空间内进行,没有创建新列表
temp = data_list[left] # 需要1个临时变量空间
data_list[left] = data_list[right]
data_list[right] = temp
left += 1
right -= 1
data_list
有多长,我们都只需要left
, right
, temp
这几个固定数量的变量。因此,额外空间是O(1)
。这种不产生额外数据副本的算法被称为“原地算法”(In-place Algorithm)。2. O(n) - 线性空间复杂度
算法所需的额外空间与输入规模n
成正比。
def create_copy_and_reverse(data_list):
"""创建一个新的反转后的列表。"""
new_list = [None] * len(data_list) # 创建了一个和输入规模n一样大的新列表
n = len(data_list)
for i in range(n):
new_list[i] = data_list[n - 1 - i]
return new_list
new_list
,它的大小与data_list
完全相同。如果data_list
的长度是n
,那么额外空间就是O(n)
。3. O(n²) - 平方空间复杂度
算法所需的额外空间与n
的平方成正比。
代码示例:创建一个邻接矩阵来表示图
def create_adjacency_matrix(n, edges):
"""为n个顶点的图创建邻接矩阵。"""
# 创建一个 n x n 的二维列表,所有元素初始化为0
matrix = [[0] * n for _ in range(n)] # 这里分配了 n*n 的空间
for u, v in edges:
matrix[u][v] = 1
matrix[v][u] = 1 # 假设是无向图
return matrix
n
个顶点的图,邻接矩阵需要n x n
的空间来存储任意两个顶点之间是否存在边。因此,空间复杂度是O(n²)
。1.4 最好、最坏与平均情况复杂度
同一个算法,在处理不同状态的输入数据时,其性能表现可能大相径庭。
n
的数据上,运行时间步数的上界。这是我们最关心、最常讨论的,因为它提供了一个性能保证。无论输入数据多么“不友好”,算法的性能都不会比这个更差。以线性查找为例:
O(n)
。目标元素在列表的最后一个位置,或者根本不存在。O(1)
。目标元素恰好是列表的第一个。O(n)
。平均来说,需要查找 n/2
次。根据大O表示法,忽略系数1/2
,仍然是O(n)
。以快速排序为例:
O(n²)
。当每次选择的“基准元”都是当前子数组的最大或最小值时发生,这常见于处理一个已排序的列表。O(n log n)
。O(n log n)
。实践证明,通过随机选择基准元等方法,可以极大概率地避免最坏情况的发生,使得快速排序在平均情况下表现极为出色。1.5 必须警惕的Python性能陷阱
Python的语法简洁优美,但这种简洁有时会掩盖底层操作的真实成本。不了解这些,很容易写出表面优雅但性能低下的代码。
陷阱一:列表的 +
拼接与 append
# list_concatenation_vs_append.py
import time
n = 100000
# 使用 + 拼接
start_time = time.time()
result_plus = []
for i in range(n):
result_plus = result_plus + [i] # 每次拼接都会创建一个新的列表
end_time = time.time()
print(f"使用 '+' 拼接 {
n} 次耗时: {
end_time - start_time:.4f} 秒")
# 使用 append
start_time = time.time()
result_append = []
for i in range(n):
result_append.append(i) # append是原地修改,效率高得多
end_time = time.time()
print(f"使用 'append' {
n} 次耗时: {
end_time - start_time:.4f} 秒")
result = result + [i]
: 看起来简单,但+
操作符在Python列表中意味着创建一个全新的列表,将result
的所有元素复制到新列表中,然后再把[i]
的元素也复制过去。这个操作的成本与result
的当前长度成正比。在循环中,这个操作的复杂度近似于 1 + 2 + 3 + ... + n
,这是一个O(n²)
的过程!result.append(i)
: append
方法是原地修改。Python的列表是动态数组,它会预分配一些额外空间。大多数情况下,append
只是在末尾填入一个值,是O(1)
操作。即使偶尔空间不足需要重新分配和复制整个数组,其“均摊时间复杂度”依然是O(1)
。整个循环是O(n)
。陷阱二:in
操作符在列表和字典/集合中的天壤之别
# in_list_vs_in_dict.py
import time
n = 100000
data_list = list(range(n))
data_dict = {
i: True for i in range(n)} # 创建一个字典,键是0到n-1
target = n - 1 # 要查找的目标
# 在列表中查找
start_time = time.time()
is_in_list = target in data_list # 'in' 对于列表是线性扫描
end_time = time.time()
print(f"在 {
n} 个元素的列表中查找: {
'找到' if is_in_list else '未找到'}, 耗时: {
end_time - start_time:.6f} 秒")
# 在字典中查找
start_time = time.time()
is_in_dict = target in data_dict # 'in' 对于字典是哈希查找
end_time = time.time()
print(f"在 {
n} 个元素的字典中查找: {
'找到' if is_in_dict else '未找到'}, 耗时: {
end_time - start_time:.6f} 秒")
target in data_list
: 为了判断target
是否在列表中,Python必须从头到尾逐个检查元素,直到找到匹配项或遍历完整个列表。这是一个O(n)
的线性查找。target in data_dict
: 字典的in
操作利用了哈希表。它会直接计算target
的哈希值,通过哈希值几乎可以立即定位到target
应该在的位置,然后进行一次比较即可。这是一个平均O(1)
的操作。set
)或字典(dict
)的键,其性能远超列表。陷阱三:字符串的拼接
这与列表的+
拼接陷阱非常相似。Python的字符串是**不可变(immutable)**的。
# string_concatenation.py
# 坏方法:使用 + 在循环中拼接
def bad_string_join(words):
result = ""
for word in words:
result += word + " " # 每次都会创建新字符串,O(n²)
return result
# 好方法:使用 join 方法
def good_string_join(words):
return " ".join(words) # join方法会先计算总长度,一次性构建字符串,O(n)
result += word
: 因为字符串不可变,所以这行代码的背后是:创建一个新的、足够大的字符串,将result
的旧内容复制过去,再将word
的内容复制过去,然后让result
变量指向这个全新的字符串。在循环中,这是一个O(n²)
的灾难。" ".join(words)
: join
方法是专门为此优化的。它会先遍历一次words
列表,计算出最终字符串所需的总长度,然后只分配一次内存,最后将所有单词复制到这个新空间中。这是一个高效的O(n)
操作,其中n
是所有单词的总长度。第二章:线性数据结构 —— 序列、链条与栈队的艺术
在构建了坚固的复杂度分析基石之后,我们开始搭建算法大厦的第一层——线性数据结构。所谓“线性”,指的是数据元素之间存在着一对一的、线性的前后关系,就像一串珍珠项链,每个珍珠后面只跟着另一颗珍珠。这种结构虽然简单,却是构建更复杂数据结构和算法的基础。
2.1 数组与Python列表:看似简单,内有乾坤
数组(Array)是几乎所有编程语言都内置的最基本的数据结构。它是一块连续的内存空间,用于存储一系列相同类型的元素。
数组的核心特性:
a[0], a[1], a[2]
等元素在物理内存中是紧挨着存放的。address(a[i]) = base_address + i * element_size
,在O(1)
的常数时间内直接计算出任何一个索引i
的元素的内存地址,并立即访问它。这是数组最大的优势。Python中的list
:动态数组的实现
Python的list
类型,虽然用法上像一个可以存储任何类型、长度可变的“超级数组”,但其底层实现是一个动态数组(Dynamic Array)。它保留了传统数组“连续内存”和“随机访问”的核心优势,同时又巧妙地解决了传统数组长度固定的问题。
动态数组的内部机制:
list
时,Python解释器并不会只分配你当前需要的空间。它会额外预留一些空间,以备未来的append
操作。这被称为“超额分配”(Over-allocation)。append
操作: 当你append
一个新元素时,如果预留空间足够,Python会直接在末尾的空闲位置放入新元素。这是一个O(1)
的操作。append
操作会触发一次“扩容”。这个过程包括:这次扩容操作本身是O(n)
的,其中n
是列表的当前大小。但由于它不是每次append
都发生,而是随着列表长度的增长,其发生的频率越来越低,因此,经过均摊(Amortized)分析,append
操作的均摊时间复杂度依然是O(1)
。
2.1.1 列表的基本操作复杂度分析
理解Python列表的底层实现,对于分析其各种操作的性能至关重要。
操作 | 例子 | 平均时间复杂度 | 最坏时间复杂度 | 解释 |
---|---|---|---|---|
索引访问 | L[i] |
O(1) |
O(1) |
基于连续内存的地址计算,始终是常数时间。 |
索引赋值 | L[i] = v |
O(1) |
O(1) |
同上,直接定位并修改。 |
末尾追加 | L.append(v) |
O(1) |
O(n) |
均摊为O(1)。最坏情况发生在需要扩容时。 |
末尾弹出 | L.pop() |
O(1) |
O(1) |
只是移动内部的“size”指针,通常不涉及缩容,极快。 |
插入元素 | L.insert(i,v) |
O(n) |
O(n) |
在索引i 处插入,需要将i 之后的所有元素向右移动一位。 |
删除元素 | del L[i] , L.pop(i) |
O(n) |
O(n) |
删除索引i 的元素,需要将i 之后的所有元素向左移动一位。 |
成员检查 | v in L |
O(n) |
O(n) |
线性扫描,从头到尾逐个比较。 |
切片 (读取) | L[i:j] |
O(k) |
O(k) |
k=j-i 。需要创建新列表并复制k 个元素。 |
切片 (删除) | del L[i:j] |
O(n-j) |
O(n-j) |
需要将被删除部分之后的所有元素向左移动。 |
列表拼接 | L1 + L2 |
O(m+n) |
O(m+n) |
创建新列表,并复制L1 和L2 的所有元素。m, n 为长度。 |
代码示例:insert
和 del
的成本演示
# list_insertion_deletion_cost.py
import time
n = 100000
# 在列表头部插入
start_time = time.time()
l_head = []
for i in range(n):
l_head.insert(0, i) # 每次都在索引0处插入,每次都移动所有已有元素
end_time = time.time()
print(f"在列表头部插入 {
n} 个元素耗时: {
end_time - start_time:.4f} 秒") # 这是一个O(n²)的操作
# 在列表尾部插入 (使用 append)
start_time = time.time()
l_tail = []
for i in range(n):
l_tail.append(i) # 均摊 O(1)
end_time = time.time()
print(f"在列表尾部追加 {
n} 个元素耗时: {
end_time - start_time:.4f} 秒") # 这是一个O(n)的操作
# 从列表头部删除
l_to_delete = list(range(n))
start_time = time.time()
for _ in range(n):
l_to_delete.pop(0) # 每次都从索引0处删除,每次都移动所有剩余元素
end_time = time.time()
print(f"从列表头部删除 {
n} 个元素耗时: {
end_time - start_time:.4f} 秒") # 这是一个O(n²)的操作
这个实验的结果会非常清晰地告诉你:对Python列表的头部进行频繁的插入和删除,是极其低效的行为! 这也是为什么我们需要队列(Queue)这种专门为头尾操作优化的数据结构。
2.2 链表:非连续内存的自由舞者
与数组将所有元素紧凑地存放在一块连续内存中不同,链表(Linked List)采取了一种完全不同的、更“自由”的存储策略。
链表的核心思想:
None
(或NULL
),表示链表的结束。2.2.1 从零开始构建一个单向链表
为了深刻理解链表的运作机制,我们必须亲手实现它。
# linked_list_implementation.py
class Node:
"""定义链表的节点类。"""
def __init__(self, data, next_node=None):
self.data = data # 数据域,存储节点的值
self.next = next_node # 指针域,存储下一个节点的引用
class SinglyLinkedList:
"""定义一个单向链表类,并实现其核心操作。"""
def __init__(self):
"""初始化一个空链表。"""
self._head = None # 初始化头节点为None,表示链表为空
def is_empty(self):
"""检查链表是否为空。"""
return self._head is None # 如果头节点是None,则链表为空
def prepend(self, data):
"""在链表头部添加一个新节点 (头插法)。O(1)操作。"""
# 创建一个包含新数据的新节点
# 这个新节点的 next 指针指向当前的头节点
new_node = Node(data, self._head) # 新节点的next指向旧的头节点
# 将链表的头节点更新为这个新创建的节点
self._head = new_node # 将链表的头指针更新为新节点
def append(self, data):
"""在链表尾部添加一个新节点 (尾插法)。O(n)操作。"""
new_node = Node(data) # 创建一个新节点,其next默认为None
if self.is_empty(): # 如果链表是空的
self._head = new_node # 新节点就是头节点
return
# 如果链表不为空,需要遍历到最后一个节点
last_node = self._head # 从头节点开始
while last_node.next: # 只要当前节点的next不为None,就继续向后移动
last_node = last_node.next
# 循环结束后,last_node就是最后一个节点
last_node.next = new_node # 将原来的最后一个节点的next指针指向新节点
def traverse(self):
"""遍历链表并打印所有节点的数据。"""
print("遍历链表: ", end="") # 打印前缀
current = self._head # 从头节点开始
while current: # 只要当前节点不为None
print(f"{
current.data} -> ", end="") # 打印当前节点的数据
current = current.next # 移动到下一个节点
print("None") # 链表末尾打印None
def find(self, target):
"""在链表中查找一个值,如果找到则返回True。O(n)操作。"""
current = self._head # 从头节点开始
while current: # 遍历
if current.data == target: # 如果找到了目标值
return True # 返回True
current = current.next # 移动到下一个节点
return False # 如果遍历完都没找到,返回False
def remove(self, target):
"""从链表中删除第一个匹配目标值的节点。O(n)操作。"""
if self.is_empty(): # 如果链表为空,直接返回
return
# Case 1: 要删除的是头节点
if self._head.data == target: # 如果头节点就是要删除的目标
self._head = self._head.next # 直接将头指针指向第二个节点即可
return
# Case 2: 要删除的是中间或尾部的节点
prev_node = self._head # prev_node 指向当前节点的前一个节点
current_node = self._head.next # current_node 指向当前要检查的节点
while current_node: # 遍历
if current_node.data == target: # 如果找到了要删除的节点
# “跳过”这个节点
# 将前一个节点的next指针,直接指向当前节点的下一个节点
prev_node.next = current_node.next # 实现删除
return
# 如果没找到,两个指针都向后移动一位
prev_node = current_node
current_node = current_node.next
# --- 使用我们自己实现的链表 ---
my_list = SinglyLinkedList() # 创建一个空链表实例
print(f"链表是否为空? {
my_list.is_empty()}") # 检查是否为空
print("\n在头部添加 10, 20, 30...") # 打印操作描述
my_list.prepend(10) # 链表: 10 -> None
my_list.prepend(20) # 链表: 20 -> 10 -> None
my_list.prepend(30) # 链表: 30 -> 20 -> 10 -> None
my_list.traverse() # 遍历并打印
print(f"链表是否为空? {
my_list.is_empty()}") # 再次检查
print("\n在尾部添加 5...") # 打印操作描述
my_list.append(5) # 链表: 30 -> 20 -> 10 -> 5 -> None
my_list.traverse() # 遍历并打印
print(f"\n查找值 20: {
my_list.find(20)}") # 查找存在的值
print(f"查找值 100: {
my_list.find(100)}") # 查找不存在的值
print("\n删除值 20...") # 打印操作描述
my_list.remove(20) # 删除中间节点
my_list.traverse() # 遍历并打印
print("\n删除值 30 (头节点)...") # 打印操作描述
my_list.remove(30) # 删除头节点
my_list.traverse() # 遍历并打印
print("\n删除值 5 (尾节点)...") # 打印操作描述
my_list.remove(5) # 删除尾节点
my_list.traverse() # 遍历并打印
这个实现过程,能让你深刻理解链表的插入和删除操作是如何通过改变节点的next
指针来完成的。尤其是删除操作,需要一个prev_node
来“记住”前一个节点,这是链表操作中的一个经典模式。
2.2.2 数组 vs. 链表:世纪对决
现在,我们可以对这两种核心的线性结构进行一次全面的性能对比。
特性 | 数组 (Python list ) |
链表 (我们实现的) | 优胜者 & 原因 |
---|---|---|---|
内存布局 | 连续 | 分散 | 数组: 内存连续性带来了缓存友好性,CPU在访问一个元素后,很可能已经把它的邻居也加载到了高速缓存中,后续访问更快。 |
随机访问 | O(1) |
O(n) |
数组: 绝对优势。链表要访问第i 个元素,必须从头节点开始,一步一步地跳i 次。 |
头部插入/删除 | O(n) |
O(1) |
链表: 绝对优势。链表的头插/头删只需要修改头指针,是常数时间操作。数组需要移动所有元素。 |
尾部插入 | O(1) (均摊) |
O(n) (朴素实现) |
数组: append 是O(1)的。我们实现的链表尾插需要遍历到末尾,是O(n)。 |
中间插入/删除 | O(n) |
O(n) |
平手: 两者都需要O(n) 。数组慢在移动元素,链表慢在查找要操作的位置。 |
空间开销 | 较小 | 较大 | 数组: 只需要存储数据本身。链表每个节点都需要额外的空间来存储next 指针。 |
优化链表的尾部插入:
我们上面实现的链表,尾部插入是O(n)
,这是一个痛点。我们可以通过在链表类中额外维护一个_tail
指针,始终指向最后一个节点,来将尾部插入的复杂度优化到O(1)
。
代码示例:带尾指针的优化链表
class OptimizedSinglyLinkedList:
"""一个带有头尾指针的单向链表,优化了尾部操作。"""
def __init__(self):
self._head = None # 头指针
self._tail = None # 尾指针
self._size = 0 # 维护一个size属性,获取长度是O(1)
def __len__(self):
"""让链表支持len()函数。"""
return self._size
def append(self, data):
"""优化的尾部插入方法。O(1)操作。"""
new_node = Node(data) # 创建新节点
if self._tail: # 如果链表不为空 (即尾指针存在)
self._tail.next = new_node # 将当前的尾节点的next指向新节点
self._tail = new_node # 更新尾指针为这个新节点
else: # 如果链表为空
self._head = new_node # 新节点既是头也是尾
self._tail = new_node
self._size += 1 # 尺寸加一
# prepend, traverse, find, remove 等方法也需要相应地更新_tail和_size
# (此处为简化省略,但这是实现完整功能所必需的)
结论与应用场景:
选择数组 (Python list
):
list
都是一个足够好、足够快的选择。选择链表:
2.2.3 链表的变体:双向链表与循环链表
双向链表 (Doubly Linked List):
next
指针指向下一个节点,还有一个prev
指针指向上一个节点。node_to_delete
,你不需要像单向链表那样从头遍历来找到它的前一个节点。你可以直接通过node_to_delete.prev
来找到前驱,然后执行 node_to_delete.prev.next = node_to_delete.next
和 node_to_delete.next.prev = node_to_delete.prev
来完成删除。这使得在给定节点引用下的删除操作是O(1)
的。prev
指针,空间开销更大。插入和删除操作需要同时维护next
和prev
两个指针,逻辑稍微复杂一点。代码片段:双向链表节点定义
class DoublyNode:
def __init__(self, data, prev_node=None, next_node=None):
self.data = data
self.prev = prev_node # 指向前一个节点的指针
self.next = next_node # 指向后一个节点的指针
循环链表 (Circular Linked List):
next
指针不再指向None
,而是指向头节点,形成一个环。2.3 栈:后进先出 (LIFO) 的哲学
栈(Stack)是一种特殊的线性数据结构,它遵循**后进先出(Last-In, First-Out, LIFO)**的原则。你可以把它想象成一摞盘子:你最后放上去的盘子,总是第一个被拿走。
栈的核心操作:
push(item)
: 将一个元素压入栈顶。pop()
: 从栈顶弹出一个元素,并返回它。peek()
(或 top()
): 查看栈顶元素,但不弹出。is_empty()
: 检查栈是否为空。所有这些核心操作的时间复杂度都应该是O(1)
。
2.3.1 Python中的栈实现
在Python中,有多种方式可以实现一个栈。
方式一:使用Python list
Python的list
类型提供了append()
和pop()
方法,它们都作用于列表的尾部,并且都是O(1)
(均摊)的。这完美地契合了栈的LIFO特性。因此,使用list
来模拟栈是最简单、最直接、也最常见的方式。
# stack_using_list.py
stack = [] # 用一个空列表来代表栈
# Push 操作
stack.append('A') # 'A'入栈
stack.append('B') # 'B'入栈
stack.append('C') # 'C'入栈
print(f"当前栈 (列表表示): {
stack}") # 打印栈内容
# Peek 操作 (查看栈顶)
# 列表的最后一个元素就是栈顶
top_element = stack[-1] # 获取列表的最后一个元素
print(f"栈顶元素是: {
top_element}") # 打印栈顶元素
# Pop 操作
popped_item = stack.pop() # 从列表尾部弹出一个元素
print(f"弹出的元素是: {
popped_item}") # 打印弹出的元素
print(f"Pop操作后的栈: {
stack}") # 打印操作后的栈
popped_item = stack.pop() # 再次弹出
print(f"弹出的元素是: {
popped_item}")
print(f"Pop操作后的栈: {
stack}")
# is_empty 操作
is_empty = not stack # 一个空列表在布尔上下文中是False,取反即为True
print(f"栈现在是否为空? {
is_empty}") # 打印是否为空
方式二:使用collections.deque
collections.deque
(双端队列,Double-ended Queue)是一个专门为在两端进行快速添加和删除操作而设计的序列类型。它的append()
(右端添加)和pop()
(右端弹出)操作都是严格的O(1)
,没有list
扩容时那种最坏情况的性能波动。
对于一个严格要求性能稳定性的栈实现,或者当栈的规模可能非常巨大,deque
是比list
更好的选择。
# stack_using_deque.py
from collections import deque
stack = deque() # 使用deque创建一个栈
# Push 操作 (使用 append)
stack.append('X')
stack.append('Y')
stack.append('Z')
print(f"当前栈 (deque表示): {
stack}")
# Peek 操作
top_element = stack[-1] # deque也支持索引访问最后一个元素
print(f"栈顶元素是: {
top_element}")
# Pop 操作
popped_item = stack.pop()
print(f"弹出的元素是: {
popped_item}")
print(f"Pop操作后的栈: {
stack}")
方式三:使用queue.LifoQueue
queue
模块提供的是线程安全的数据结构,用于在多线程编程中安全地传递数据。LifoQueue
就是一个后进先出的队列,也就是一个线程安全的栈。如果你是在单线程环境中使用,它会比list
或deque
慢,因为有额外的锁开销。但如果你在多线程程序中需要一个共享的栈,这应该是你的首选。
2.3.2 栈的经典应用场景
栈的LIFO特性,使得它在计算机科学中无处不在。
应用一:函数调用栈
这是栈最底层、最核心的应用。当你调用一个函数时,操作系统会把这个函数的返回地址、参数、局部变量等信息打包成一个“栈帧(Stack Frame)”,然后压入一个叫做“调用栈”的内存区域。当函数返回时,再从栈顶弹出它的栈帧,恢复到调用前的状态。递归函数之所以能工作,就是依赖于调用栈。如果递归深度太深,会导致调用栈溢出(Stack Overflow)。
应用二:括号匹配
这是一个经典的面试题。如何检查一个字符串中的括号(如()
, []
, {}
)是否是成对且正确嵌套的?
# balanced_parentheses.py
def is_balanced(s: str) -> bool:
"""使用栈来检查括号是否平衡。"""
stack = [] # 使用列表作为栈
# 创建一个映射,将闭括号映射到其对应的开括号
mapping = {
")": "(", "]": "[", "}": "{"}
for char in s: # 遍历字符串中的每个字符
if char in mapping: # 如果当前字符是一个闭括号
# 1. 尝试从栈顶弹出一个元素。如果栈为空,说明没有对应的开括号,用一个虚拟值'#'代替。
top_element = stack.pop() if stack else '#'
# 2. 检查弹出的开括号是否与当前闭括号匹配
if mapping[char] != top_element: # 如果不匹配
return False # 括号不平衡,立即返回False
else: # 如果当前字符是一个开括号
stack.append(char) # 将其压入栈中
# 循环结束后,如果栈是空的,说明所有开括号都被正确匹配了
# 如果栈不为空,说明有未被匹配的开括号
return not stack # 返回栈是否为空的布尔值
# 测试用例
print(f"'()[]{
{}}' 是否平衡? {is_balanced('()[]{}')}") # True
print(f"'([)]' 是否平衡? {
is_balanced('([)]')}") # False
print(f"'{
{[]}}' 是否平衡? {is_balanced('{
{[]}}')}") # True
print(f"'(' 是否平衡? {
is_balanced('(')}") # False
应用三:逆波兰表达式求值 (后缀表达式)
逆波兰表达式(Reverse Polish Notation, RPN)是一种将运算符写在操作数后面的数学表达式。例如,3 4 +
等价于 3 + 4
。求值RPN表达式是栈的完美应用场景。
# evaluate_rpn.py
def eval_rpn(tokens: list[str]) -> int:
"""使用栈来计算逆波兰表达式的值。"""
stack = [] # 使用列表作为栈
for token in tokens: # 遍历表达式中的每个标记
if token in "+-*/": # 如果标记是运算符
# 从栈顶弹出两个操作数
# 注意顺序:先弹出的是右操作数
right_operand = stack.pop() # 弹出右操作数
left_operand = stack.pop() # 弹出左操作数
# 根据运算符进行计算
if token == "+":
result = left_operand + right_operand
elif token == "-":
result = left_operand - right_operand
elif token == "*":
result = left_operand * right_operand
else: # token == "/"
# 注意Python中除法的结果是浮点数,题目通常要求向零取整
result = int(left_operand / right_operand)
stack.append(result) # 将计算结果压回栈中
else: # 如果标记是数字
stack.append(int(token)) # 将其转换为整数并压入栈中
# 遍历结束后,栈中应该只剩下一个元素,即最终结果
return stack.pop() # 弹出并返回最终结果
# 测试用例
expression1 = ["2", "1", "+", "3", "*"] # (2 + 1) * 3 = 9
expression2 = ["4", "13", "5", "/", "+"] # 4 + (13 / 5) = 4 + 2 = 6
print(f"表达式 {
expression1} 的值是: {
eval_rpn(expression1)}")
print(f"表达式 {
expression2} 的值是: {
eval_rpn(expression2)}")
2.4 队列:先进先出 (FIFO) 的公平之道
与栈的LIFO原则相对,队列(Queue)遵循的是**先进先出(First-In, First-Out, FIFO)**的原则。这就像在超市排队结账,最先来排队的人,总是第一个结账离开。
队列的核心操作:
enqueue(item)
: 将一个元素加入队尾(入队)。dequeue()
: 从队头移除一个元素,并返回它(出队)。peek()
(或 front()
): 查看队头元素,但不移除。is_empty()
: 检查队列是否为空。同样,所有这些核心操作的时间复杂度都应该是O(1)
。
2.4.1 Python中的队列实现:为何不能用list
?
如果我们尝试用Python的list
来模拟队列:
list.append(item)
,这是一个O(1)
的操作,很好。list.pop(0)
。2.1.1
节已经知道,list.pop(0)
是一个**O(n)
**的操作,因为它需要将所有后续元素向左移动一位。当队列很长时,这会变得极其低效。结论:永远不要用list
来实现一个真正的队列!
正确的实现方式:collections.deque
collections.deque
(双端队列)是专门为此而生的。
deque.append(item)
(对应 enqueue
)deque.popleft()
(对应 dequeue
)这两个操作都是严格的O(1)
时间复杂度,因为deque
底层是用双向链表实现的,对两端的操作都只需要修改头尾指针。
# queue_using_deque.py
from collections import deque
queue = deque() # 使用deque创建一个队列
# Enqueue 操作
queue.append('任务1') # "任务1" 入队
queue.append('任务2') # "任务2" 入队
queue.append('任务3') # "任务3" 入队
print(f"当前队列 (deque表示): {
queue}") # 打印队列内容
# Peek 操作 (查看队头)
# deque的第一个元素就是队头
front_element = queue[0] # 获取deque的第一个元素
print(f"队头元素是: {
front_element}") # 打印队头元素
# Dequeue 操作
dequeued_item = queue.popleft() # 从队列左端(头部)弹出一个元素
print(f"出队的元素是: {
dequeu