第29章 算法
第二十九章:算法——程序员的"武松打虎"指南
🐯 警告:本章内容可能让你对算法产生无法治愈的兴趣,从此沉迷于刷题无法自拔,老婆孩子热炕头?不存在的,只有LeetCode。
欢迎来到算法的世界!如果你觉得编程是"写代码",那算法就是"写好代码"背后的灵魂。打个比方,写代码像是做饭,算法就是菜谱——同样的食材,有人做出米其林,有人做出黑暗料理,全看菜谱(算法)够不够硬核。
算法,说白了就是"解决问题的套路"。计算机科学家们花了几十年研究这些套路,有些套路像太极拳(慢但稳),有些像闪电鞭(快但猛),今天我们就来一一拆解,让你从"萌新"进化成"套路王"。
29.1 排序算法
排序,就是把一堆乱七八糟的数据整理成"按顺序排排坐"的过程。想象你有一堆乱糟糟的扑克牌,排序就是把它们从A到K排好。排序算法是算法世界里的"Hello World",几乎每个程序员面试都会被问:“来,手写一个快排吧!"(面试官微笑.jpg)
29.1.1 冒泡排序
冒泡排序是排序界的"憨厚老大哥”——思想简单,代码简洁,但效率嘛…你懂的,就像它的名字一样,气泡慢慢往上冒,每轮把最大的"冒"到最右边去。
核心思想:从左到右两两比较,如果左边比右边大,就交换位置,像气泡一样把大的逐步"冒"到最右边。
为什么叫冒泡?因为最大的元素像气泡一样,会经过一轮比较后"浮"到数组末端。想象一下水底的泡泡往上冒,最大的先到水面。经典!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| def bubble_sort(arr):
"""
冒泡排序:两两比较,大的往后走
时间复杂度:O(n²) — 稳如老狗,但慢如蜗牛
空间复杂度:O(1) — 不占额外空间,环保!
"""
n = len(arr)
for i in range(n):
# 标志位:如果某一轮没有发生交换,说明已经有序,可以提前结束
swapped = False
for j in range(0, n - i - 1):
# 两两比较
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j] # 交换位置
swapped = True
print(f"第 {i + 1} 轮排序后: {arr}") # 打印每轮结果
# 没有交换?说明已经排好了,收工!
if not swapped:
break
return arr
# 测试一下!
data = [64, 34, 25, 12, 22, 11, 90]
print("原始数组:", data)
# 原始数组: [64, 34, 25, 12, 22, 11, 90]
bubble_sort(data)
print("排序后:", data)
# 排序后: [11, 12, 22, 25, 34, 64, 90]
|
冒泡排序的幽默解读:想象食堂打饭,大家乱成一锅粥,冒泡排序的策略是——每次找一个最高的同学,让他跟右边的人比,如果右边的人更高就换位,然后继续比。这样一轮下来,最高的同学会慢慢"飘"到队伍最右边(食堂打饭的队尾)。最坏情况要比较 n-1 轮,每轮比较 n-i-1 次,总复杂度 O(n²),食堂大妈看了会沉默,面试官看了会流泪——倒不是感动,是替你觉得慢。
冒泡排序的适用场景:教学演示、小规模数据(n<1000)、面试手写环节。生产环境?除非你的电脑比秦始皇的还好,否则还是用别的吧。
29.1.2 选择排序
选择排序是个"挑食的小孩"——每一轮从待排序的部分中选择出最小(或最大)的元素,放在已排序序列的末尾。就像你整理衣柜,每轮都从乱的那堆里挑一件最想丢掉的放到整理好的那边。
核心思想:在未排序序列中找最小元素,放到已排序序列的末尾。重复直到所有元素都有了自己的位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| def selection_sort(arr):
"""
选择排序:每一轮选个"最能打的",放到正确位置
时间复杂度:O(n²) — 无论数据长什么样,都要慢慢挑
空间复杂度:O(1) — 原地操作,空间占用极少
"""
n = len(arr)
for i in range(n):
# 假设当前位置是最小值
min_idx = i
for j in range(i + 1, n):
if arr[j] < arr[min_idx]:
min_idx = j
# 把找到的最小值换到第 i 个位置
arr[i], arr[min_idx] = arr[min_idx], arr[i]
print(f"第 {i + 1} 轮选择后: {arr}")
return arr
data = [64, 25, 12, 22, 11]
print("原始数组:", data)
# 原始数组: [64, 25, 12, 22, 11]
selection_sort(data)
print("排序后:", data)
# 排序后: [11, 12, 22, 25, 64]
|
选择排序的幽默解读:想象你有一抽屉乱七八糟的袜子,选择排序的策略是:第一轮遍历所有袜子,找出最喜欢的那只(最小的),放到抽屉最左边;第二轮从剩下的袜子里再找最喜欢的,放到左边第二个…以此类推。你确实每次都选到了"最优解",但问题是——每选一次都要把抽屉翻个底朝天,时间复杂度 O(n²),你妈看了会问:“找袜子需要这么久吗?”
💡 选择排序小贴士:选择排序的交换次数最多只有 n 次(每轮最多换一次),所以当写入操作很昂贵(比如要写硬盘)时,它反而比冒泡排序更优秀!这叫什么?这叫"我虽然慢,但我省墨"。
29.1.3 插入排序
插入排序是个"扑克牌玩家"——摸到一张牌,就把它插到手里已排序区域的正确位置。想象你打斗地主,摸牌的时候左手已经按顺序理好了,每次摸到新牌就往合适的位置一插,完美!
核心思想:将数组分为"已排序区"和"未排序区",从未排序区逐个取出元素,在已排序区找到正确位置插入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| def insertion_sort(arr):
"""
插入排序:摸牌插牌,边摸边理
时间复杂度:O(n²)(最坏),O(n)(最好,即已排序)
空间复杂度:O(1)
"""
for i in range(1, len(arr)):
# 当前要插入的"新牌"
key = arr[i]
# 已在左手(已排序区)的元素从右往左比较
j = i - 1
print(f"摸到新牌: {key},开始寻找插入位置,当前左手: {arr[:i]}")
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j] # 元素往后挪,空出位置
j -= 1
# j+1 就是新牌的归宿
arr[j + 1] = key
print(f"插入完成: {arr[:i+1]}")
return arr
data = [12, 11, 13, 5, 6]
print("原始数组:", data)
# 原始数组: [12, 11, 13, 5, 6]
insertion_sort(data)
print("排序后:", data)
# 排序后: [5, 6, 11, 12, 13]
|
插入排序的幽默解读:插入排序就像整理书架——你手里有一排已经按高矮排好的书(已排序区),现在要插入一本新书。你会从右往左比较,找到第一个比你矮的书,把新书插到它后面。运气好的时候(数据基本有序),插入排序几乎不需要动;运气不好的时候(数据完全逆序),每本书都要往后挪,累死你。
💡 插入排序的杀手锏:它对近乎有序的数据表现极好!时间复杂度可以接近 O(n)。所以如果你知道数据"差不多有序",插入排序就是你的首选。这就像打牌时如果你的手牌本来就比较整齐,摸牌插牌就轻松多了。
29.1.4 归并排序
归并排序是个"团队协作大师"——贯彻分而治之的思想:先把大问题拆成小问题,小问题解决了再合并答案。就像一个连长说:“一班去守左边的山头,二班去守右边的,完成后我们汇合!”
核心思想:分——把数组从中间劈成两半,递归地对左右两半排序;治——把两个有序的半边合并成一个有序的整体。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| def merge_sort(arr):
"""
归并排序:分而治之,各个击破
时间复杂度:O(n log n) — 稳定高效
空间复杂度:O(n) — 需要额外空间合并
"""
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
print(f" 合并: left={left}, right={right}")
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:])
print(f" 合并结果: {result}")
return result
# 测试
data = [38, 27, 43, 3, 9, 82, 10]
print("原始数组:", data)
# 原始数组: [38, 27, 43, 3, 9, 82, 10]
sorted_data = merge_sort(data)
print("排序后:", sorted_data)
# 排序后: [3, 9, 10, 27, 38, 43, 82]
|
归并排序的幽默解读:想象一个班级要按身高排队,班主任的策略是——先让每两个人自己排好(基本单位合并),然后每四个人的小组排序,再八个,再十六个…最后全班一起排。班主任自己不需要一个个比,他只需要把两个已排好的小组"对齐"比较就行了。这就像合并两个已经按字母排序的名单——你不需要重新扫一遍,直接双指针往下一扫就行了!
29.1.5 快速排序
快速排序是排序界的"当红炸子鸡"——快排快排,名副其实的快!面试问到排序,十次有九次会考快排。手写快排几乎是程序员的"入门礼",就像学吉他要会弹《小星星》一样。
核心思想:从数组中挑一个元素作为"基准"(pivot),然后把数组分成两部分——小于基准的在左边,大于基准的在右边,再递归对两边快排,最终拼起来就是有序数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| def quick_sort(arr):
"""
快速排序:选基准,分左右,递归排序
时间复杂度:O(n log n)(平均),O(n²)(最坏)
空间复杂度:O(log n)(递归栈)
"""
if len(arr) <= 1:
return arr
# 选择基准(这里选中间的元素)
pivot = arr[len(arr) // 2]
print(f"基准值: {pivot}, 待排序: {arr}")
# 分成三部分:小于基准、等于基准、大于基准
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]
print(f" 左边: {left}, 中间: {middle}, 右边: {right}")
# 递归排序左右,然后拼接
return quick_sort(left) + middle + quick_sort(right)
# 测试
data = [10, 7, 8, 9, 1, 5]
print("原始数组:", data)
# 原始数组: [10, 7, 8, 9, 1, 5]
sorted_data = quick_sort(data)
print("排序后:", sorted_data)
# 排序后: [1, 5, 7, 8, 9, 10]
|
快速排序的幽默解读:快速排序就像"荷兰国旗问题"的日常版——给你一堆红白蓝三种颜色的国旗,要求按颜色排好。你随机挑一个人当"基准"(比如白旗),然后让所有人排队:比白旗浅的站左边,比白旗深的站右边,一样深的站中间。排好后,你再对左边和右边的人群分别用同样的方法处理。整个过程就像瘟疫一样——哦不,像病毒一样扩散(递归),直到每个区域只有一个人为止。
💡 快排的生存智慧:基准选得好,快排快到爆;基准选得烂,快排变成"慢排"。想象每次你选基准都选到了最大值或最小值,那分出来的数组就是一边重一边轻,递归直接退化成 O(n²)。所以工业界一般用"三数取中"法选基准——取首、中、尾三个数的中位数作为基准,稳如老狗。
29.1.6 堆排序
堆排序是个"数据结构综合派"——它用到了堆这种数据结构。什么是堆?堆就是一种完全二叉树(想象一个每层都塞满、最后一层从左往右排列的三角形),并且满足堆属性:父节点要么比孩子都大(最大堆),要么比孩子都小(最小堆)。
核心思想:先把数组建成一个最大堆,然后反复"弹出"堆顶(最大值),每次弹出来后把最后一个元素放到堆顶,再调整堆(heapify),直到堆空。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| import heapq # Python自带堆模块,但手写更能理解原理
def heap_sort(arr):
"""
堆排序:利用堆的性质进行排序
时间复杂度:O(n log n) — 稳定高效
空间复杂度:O(1) — 原地排序
"""
n = len(arr)
# 第一步:构建最大堆(从最后一个非叶子节点往前调整)
# 叶子节点不需要调整,所以从 (n//2 - 1) 开始
for i in range(n // 2 - 1, -1, -1):
heapify(arr, n, i)
print(f"构建好的最大堆: {arr}")
# 第二步:逐个提取元素
for i in range(n - 1, 0, -1):
arr[0], arr[i] = arr[i], arr[0] # 把堆顶(最大值)移到末尾
print(f"交换后 [堆顶 {arr[i]} 到位]: {arr}")
heapify(arr, i, 0) # 重新调整剩余的堆
return arr
def heapify(arr, heap_size, root_index):
"""
堆化:从上往下调整,保持最大堆性质
父节点 index -> i
左孩子 -> 2*i + 1
右孩子 -> 2*i + 2
"""
largest = root_index
left = 2 * root_index + 1
right = 2 * root_index + 2
# 如果左孩子比根大
if left < heap_size and arr[left] > arr[largest]:
largest = left
# 如果右孩子更大
if right < heap_size and arr[right] > arr[largest]:
largest = right
# 如果最大的不是根,需要调整
if largest != root_index:
arr[root_index], arr[largest] = arr[largest], arr[root_index]
# 递归往下调整
heapify(arr, heap_size, largest)
# 测试
data = [12, 11, 13, 5, 6, 7]
print("原始数组:", data)
# 原始数组: [12, 11, 13, 5, 6, 7]
heap_sort(data)
print("排序后:", data)
# 排序后: [5, 6, 7, 11, 12, 13]
|
堆排序的幽默解读:堆排序就像"抢凳子"游戏的终极版。想象一群人围着一圈坐,凳子数量比人数少一把。每个凳子上坐的人都要比两边相邻凳子上的人高(或低,看你建的是最大堆还是最小堆)。游戏开始时音乐响起,人们抢凳子,音乐停时没抢到的人出局,然后去掉一个凳子,继续抢…每轮淘汰一个,最终留下来的就是"冠军"(最大值)。这游戏的精髓是什么?不能一个人闷头抢,要时刻维护"堆"的性质,让最高的人始终在"堆顶"(能第一个看到凳子空了没)。
堆与完全二叉树的关系:堆用数组就能表示,根本不需要真的建一棵树。索引 i 的节点,父节点是 (i-1)//2,左孩子是 2i+1,右孩子是 2i+2。Python 的 heapq 模块默认是最小堆,想当最大堆用?把元素取负就行了!
29.1.7 时间复杂度对比
光说不练假把式,光练不说傻把式。让我们来看看这六种排序算法的"成绩单":
| 算法 | 平均时间复杂度 | 最坏时间复杂度 | 最好时间复杂度 | 空间复杂度 | 稳定性 |
|---|
| 冒泡排序 | O(n²) | O(n²) | O(n) | O(1) | ✅ 稳定 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | ❌ 不稳定 |
| 插入排序 | O(n²) | O(n²) | O(n) | O(1) | ✅ 稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | ✅ 稳定 |
| 快速排序 | O(n log n) | O(n²) | O(n log n) | O(log n) | ❌ 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | ❌ 不稳定 |
📝 名词解释:
- 时间复杂度:随着数据规模 n 增长,算法执行时间的增长趋势。O(n²) 就是"数据量翻倍,时间翻四倍";O(n log n) 就是"数据量翻倍,时间大约翻一倍多"。
- 空间复杂度:算法除了输入数据外,额外占用的内存空间。
- 稳定性:排序后相等元素的相对位置是否保持不变。稳定意味着"相等元素的江湖地位不变",不稳定则可能发生"颠倒"。
选择指南(口诀):
- 数据少+基本有序 → 插入排序,你是我的眼
- 数据多+要求稳定 → 归并排序,稳定如一
- 数据多+追求速度 → 快速排序,快如闪电(但记得基准选好)
- 不想多占空间 → 堆排序,空间效率之王
- 面试手写 → 快排或归并,随便挑,反正面试官都认识
29.2 查找算法
排序是"整理",查找就是"搜索"。想象你在图书馆找一本书,或者在字典里查一个单词——查找算法就是解决这类问题的套路。
29.2.1 顺序查找
顺序查找(也叫线性查找)是最简单的查找方式——一个一个地找,从头查到尾。就像你在宿舍楼下喊人:“张三!李四!王五…“一个个点名,总会找到的(或者发现人不在)。
核心思想:遍历整个数组,逐个比较,找到目标就返回下标,没找到就返回 -1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| def sequential_search(arr, target):
"""
顺序查找:老实人策略,一个一个找
时间复杂度:O(n) — 最坏情况要查遍全数组
空间复杂度:O(1)
"""
for i, num in enumerate(arr):
print(f"正在检查第 {i + 1} 个元素: {num}...")
if num == target:
print(f"找到了!目标 {target} 在索引 {i} 处!🎉")
return i
print(f"遗憾!目标 {target} 不在这个数组里...")
return -1
# 测试
data = ['苹果', '香蕉', '橙子', '葡萄', '西瓜']
print("=" * 40)
result = sequential_search(data, '橙子')
# 正在检查第 3 个元素: 橙子...
# 找到了!目标 橙子 在索引 2 处!🎉
print("=" * 40)
result = sequential_search(data, '桃子')
# 遍历完所有元素
# 遗憾!目标 桃子 不在这个数组里...
|
顺序查找的幽默解读:想象你在一个没有索引的Excel文件里找一个单元格的内容,你能怎么办?只能一个一个往下拉,一个个看。不聪明,但管用(而且当你根本不知道数据排没排好序的时候,这是唯一的选择)。这就像你去相亲角找对象——你没有对方的联系方式,只能一个个摊位走过去看,看到合适的就停下来。效率感人,但机会均等。
💡 顺序查找的生存场景:当数据量很小(<100),或者数据无序(根本没法用二分),或者你只需要查找一次——顺序查找就够了。省去了排序的时间,直接上手查。
29.2.2 二分查找
二分查找(Binary Search)是查找界的"学霸”——它利用了数据已排序的特性,每次把搜索范围缩小一半,效率极高。想象你在猜价格游戏里猜商品价格:“500?““低了!““750?““高了!““625?““低了!”…每次都排除掉一半,这就是二分查找的精髓。
核心思想:在有序数组中,先看中间元素,如果中间元素正好是目标,命中!如果比目标大,就去左半边继续找;如果比目标小,就去右半边继续找。每一步都把搜索范围缩小一半。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| def binary_search(arr, target):
"""
二分查找:每次砍一半,快到飞起
时间复杂度:O(log n) — 10亿数据最多只要查30次!
空间复杂度:O(1)
前提:数组必须有序!
"""
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2 # 中间位置
print(f"搜索范围: [{left}, {right}], 中间位置: {mid}, 中间值: {arr[mid]}")
if arr[mid] == target:
print(f"🎯 找到了!目标 {target} 在索引 {mid} 处!")
return mid
elif arr[mid] < target:
print(f" {arr[mid]} < {target},目标在右半边,继续!")
left = mid + 1
else:
print(f" {arr[mid]} > {target},目标在左半边,继续!")
right = mid - 1
print(f"遗憾!目标 {target} 不在数组中...")
return -1
# 测试
data = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21]
print("有序数组:", data)
print("=" * 50)
result = binary_search(data, 13)
# 搜索范围: [0, 10], 中间位置: 5, 中间值: 11
# 11 < 13,目标在右半边,继续!
# 搜索范围: [6, 10], 中间位置: 8, 中间值: 17
# 17 > 13,目标在左半边,继续!
# 搜索范围: [6, 7], 中间位置: 6, 中间值: 13
# 🎯 找到了!目标 13 在索引 6 处!
print("=" * 50)
result = binary_search(data, 6)
# 搜索范围: [0, 10], 中间位置: 5, 中间值: 11
# ...
# 遗憾!目标 6 不在数组中...
|
二分查找的幽默解读:想象你在翻字典查一个单词,比如"algorithm”。你不会傻到从第一页一页一页翻——你会先随手翻开字典中间,发现是M开头的单词,太靠后了;然后翻到前半本的中点,是G开头的,还是靠后;再往前…如此反复,每次都把搜索范围减半。数学上可以证明,100万条数据最多只需要20次"翻字典"操作就能找到目标!这就是二分的威力——对数级别的增长,慢个鬼。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # 二分查找的递归版本
def binary_search_recursive(arr, target, left, right):
"""递归版本的二分查找"""
if left > right:
return -1
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
return binary_search_recursive(arr, target, mid + 1, right)
else:
return binary_search_recursive(arr, target, left, mid - 1)
|
📝 二分查找的注意事项:
- 数组必须有序!无序数组用二分等于大海捞针——你在一个乱序的图书馆里用二分的思路翻字典,翻到神经错乱也找不到。
left <= right 还是 left < right?记住循环条件是 <=,因为当 left == right 时还可能有答案(只有一个元素的区间)。mid = (left + right) // 2 还是 (left + right) / 2?对于大数求和可能溢出,但在 Python 里整数可以无限大,所以没这个问题。
29.3 递归与分治
递归是算法世界里最重要的思想之一,可以说是"程序员的瑞士军刀”。不会递归,就像厨师不会用刀——能做饭,但效率低得感人。
递归:函数调用自己。听起来很荒谬?但这就是递归的魅力——大问题可以拆成同类型的子问题,子问题再拆成更小的子问题,直到子问题简单到可以直接解决(这叫基准情形)。
29.3.1 递归三要素
不是所有的递归都叫好递归,一个优雅的递归必须满足三个要素:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| # 递归三要素示例:计算阶乘
def factorial(n):
"""
递归三要素:
1. 【基准情形】最小问题,直接返回答案
2. 【递归调用】大问题变小问题,调用自身
3. 【递归返回】子问题的答案组合成大问题的答案
"""
# 要素1:基准情形 — 0! = 1,这是递归的终点
if n == 0 or n == 1:
return 1
# 要素2:递归调用 — n! = n * (n-1)!
result = n * factorial(n - 1)
# 要素3:返回结果 — 子问题答案组合成当前问题的答案
return result
# 测试
print(f"5! = {factorial(5)}") # 5! = 120
print(f"0! = {factorial(0)}") # 0! = 1
|
递归三要素详解:
基准情形(Base Case):递归的"出口”,如果没有基准情形,递归会无穷无尽,直到栈溢出(Stack Overflow)——对,就是那个程序员问答网站的名字。基准情形就是简单到不需要递归就能直接得到答案的情况,比如阶乘的 n=0 或 n=1,比如二叉树的空节点。
递归调用(Recursive Case):把问题"缩小”,调用自身去解决更小的同一问题。比如计算 n!,就调用 factorial(n-1) 去计算 (n-1)!。记住,每次递归调用都必须让问题规模缩小,否则就是在原地转圈圈。
答案返回(Return):子问题的答案必须能组合成当前问题的答案。如果子问题的解不能帮你解决当前问题,那递归就是在做无用功。
💡 递归的幽默解读:递归就像你在图书馆找书,书架上有个牌子写着"索引在B区”,B区又有个牌子写着"索引在C区”…如此往复,直到你找到一个写着"索引在这里"的小抽屉。这就是递归调用——层层深入,直到找到基准情形。然后你再逐层返回,把每一层的信息汇总,最终拿到完整的索引。这和《盗梦空间》有点像——每层梦境都有自己的任务,最后要一层层回到现实世界(基准情形)。什么?你说你看不懂《盗梦空间》?那你应该更能理解递归——因为你正在经历的就是递归!
29.3.2 汉诺塔问题
汉诺塔是递归思想的"经典营销案例”——一个古老的传说,三根柱子,若干圆盘,要把所有圆盘从A柱移到C柱,规则是每次只能移动一个圆盘,大盘子不能压在小盘子上。这个问题难倒了几代人,直到递归出现,人们才发现——哦,原来这么简单!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| def hanoi(n, source, target, auxiliary):
"""
汉诺塔问题:经典的递归问题
目标:把 n 个盘子从 source 柱子,借助 auxiliary 柱子,移到 target 柱子
核心思想:
1. 把上面的 n-1 个盘子从 source 移到 auxiliary(借助 target)
2. 把最大的盘子从 source 移到 target
3. 把 n-1 个盘子从 auxiliary 移到 target(借助 source)
"""
if n == 1:
# 基准情形:只有一个盘子,直接搬
print(f"把第 1 个盘子从 {source} 移到 {target}")
return
# 步骤1:把上面 n-1 个盘子从 source 移到 auxiliary(target 当跳板)
hanoi(n - 1, source, auxiliary, target)
# 步骤2:把最大的盘子(第 n 个)从 source 移到 target
print(f"把第 {n} 个盘子从 {source} 移到 {target}")
# 步骤3:把 n-1 个盘子从 auxiliary 移到 target(source 当跳板)
hanoi(n - 1, auxiliary, target, source)
# 测试:3个盘子的汉诺塔
print("=" * 50)
print("【汉诺塔 - 3个盘子】")
print("=" * 50)
hanoi(3, 'A', 'C', 'B')
# 把第 1 个盘子从 A 移到 C
# 把第 2 个盘子从 A 移到 B
# 把第 1 个盘子从 C 移到 B
# 把第 3 个盘子从 A 移到 C
# 把第 1 个盘子从 B 移到 A
# 把第 2 个盘子从 B 移到 C
# 把第 1 个盘子从 A 移到 C
|
汉诺塔的幽默解读:汉诺塔的递归解法妙在"借力打力”。你想把一摞盘子从A搬到C?没问题,先把上面n-1个搬到B,然后把最大的那个搬到C,最后把B上的n-1个搬到C就行了。什么?你说你还是不懂?那就对了,连爱因斯坦都说:“上帝不掷骰子,但汉诺塔确实把人类逼疯过。”
💡 汉诺塔的步数:n 个盘子需要移动 2^n - 1 步。3个盘子需要7步,4个需要15步,5个需要31步,10个需要1023步…所以如果你想在汉诺塔游戏里破解世界纪录,起码得准备2^64 - 1步——这是18446744073709551615步,约等于"宇宙热寂之前搬不完"步。
1
2
3
4
5
6
7
8
9
10
11
12
| # 计算n个盘子的移动步数
def hanoi_steps(n):
return 2 ** n - 1
for i in range(1, 11):
print(f"{i} 个盘子需要 {hanoi_steps(i)} 步")
# 1 个盘子需要 1 步
# 2 个盘子需要 3 步
# 3 个盘子需要 7 步
# ...
# 10 个盘子需要 1023 步
|
29.3.3 归并排序详解
还记得前面提到的归并排序吗?现在我们已经掌握了递归三要素,来深入看看归并排序的递归之美。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
| def merge_sort_verbose(arr, depth=0):
"""
带日志的归并排序,看清楚递归的每一步
"""
indent = " " * depth
print(f"{indent}[递归深度 {depth}] 开始排序: {arr}")
# 基准情形:只有一个或零个元素,本身就是有序的
if len(arr) <= 1:
print(f"{indent}[基准情形] 元素 {arr} 已就绪,无需排序")
return arr
# 递归情形:分而治之
mid = len(arr) // 2
print(f"{indent}[分割] 左半边: {arr[:mid]}, 右半边: {arr[mid:]}")
left = merge_sort_verbose(arr[:mid], depth + 1)
right = merge_sort_verbose(arr[mid:], depth + 1)
# 合并两个有序数组
merged = merge_two_arrays(left, right)
print(f"{indent}[合并] {left} + {right} = {merged}")
return merged
def merge_two_arrays(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
# 测试
data = [38, 27, 43, 3, 9, 82, 10]
print("原始数组:", data)
print("=" * 60)
sorted_data = merge_sort_verbose(data)
print("=" * 60)
print("排序后:", sorted_data)
|
运行结果会清晰地展示递归的"深度优先"特性——先一路分到底,然后逐层合并回来。
graph TD
A["[38,27,43,3,9,82,10]<br/>第一层"] --> B["[38,27,43]<br/>第二层"]
A --> C["[3,9,82,10]<br/>第二层"]
B --> D["[38,27]<br/>第三层"]
B --> E["[43]<br/>第三层"]
C --> F["[3,9]<br/>第三层"]
C --> G["[82,10]<br/>第三层"]
D --> H["[38]<br/>第四层"]
D --> I["[27]<br/>第四层"]
F --> J["[3]<br/>第四层"]
F --> K["[9]<br/>第四层"]
G --> L["[82]<br/>第四层"]
G --> M["[10]<br/>第四层"]
H --> N["[27,38]<br/>合并回来"]
I --> N
J --> O["[3,9]<br/>合并回来"]
K --> O
L --> P["[10,82]<br/>合并回来"]
M --> P
N --> Q["[27,38,43]<br/>合并回来"]
E --> Q
O --> R["[3,9,10,82]<br/>合并回来"]
G --> R
Q --> S["[3,9,10,27,38,43,82]<br/>最终结果"]
R --> S📝 归并排序的递归理解:归并排序的递归树就像一棵倒挂的树——从根节点开始分叉(分),一直分到叶子节点(基准情形,每个数组只有一个元素),然后从叶子节点开始往上合并(治)。分的过程是"先递归后返回”(深度优先),合并的过程是"从叶子到根"逐层汇总。
29.4 动态规划
动态规划(Dynamic Programming,简称DP)是算法界的"灭霸”——掌握它的人可以说"我不是针对谁,我是说在座的各位…"。很多人听到DP就头疼,因为它的核心思想虽然简单,但应用起来千变万化,让人防不胜防。
动态规划的定义:把一个复杂问题分解成重叠的子问题,先求解子问题,再利用子问题的答案构建最终答案。“动态"指的是问题像楼梯一样有阶段性地推进,“规划"指的是"找最优解”。
什么时候用DP:如果一个问题的解可以由子问题的解构建,并且子问题会重复出现(重叠子问题),那就可以用DP。这就像你算斐波那契数列时,第5项会用到第4项和第3项,而第4项又会被第3项"蹭蹭”——子问题重复了,用DP记忆化一下就能省大量计算。
29.4.1 斐波那契数列
斐波那契数列是动态规划的"Hello World”。斐波那契数列长这样:0, 1, 1, 2, 3, 5, 8, 13, 21, 34…规律是:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| # 方法1:纯递归(不看不知道,一看吓一跳)
def fib_recursive(n):
"""纯递归:简单直接,但时间复杂度爆炸"""
if n <= 1:
return n
return fib_recursive(n - 1) + fib_recursive(n - 2)
# 方法2:记忆化递归(DP的递归版本)
def fib_memoized(n, memo=None):
"""记忆化递归:用字典存储已计算的结果,避免重复计算"""
if memo is None:
memo = {}
if n in memo:
return memo[n]
if n <= 1:
return n
memo[n] = fib_memoized(n - 1, memo) + fib_memoized(n - 2, memo)
return memo[n]
# 方法3:DP表格法(DP的迭代版本)
def fib_dp_table(n):
"""自底向上:用表格存储每个子问题的答案"""
if n <= 1:
return n
# dp[i] 表示第 i 个斐波那契数
dp = [0] * (n + 1)
dp[0] = 0
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
# 方法4:空间优化(只保留前两个数)
def fib_optimized(n):
"""空间复杂度 O(1) 的版本"""
if n <= 1:
return n
prev, curr = 0, 1
for _ in range(2, n + 1):
prev, curr = curr, prev + curr
return curr
# 测试
n = 10
print(f"斐波那契数列前 {n + 1} 项:")
for i in range(n + 1):
print(f"F({i}) = {fib_dp_table(i)}")
# F(0) = 0, F(1) = 1, F(2) = 1, F(3) = 2, F(4) = 3,
# F(5) = 5, F(6) = 8, F(7) = 13, F(8) = 21, F(9) = 34, F(10) = 55
print(f"\n第 10 项: {fib_dp_table(10)}") # 55
print(f"第 100 项: {fib_optimized(100)}") # 354224848179261915075
|
斐波那契数列的幽默解读:斐波那契数列在自然界中广泛存在——向日葵的花盘、菠萝的鳞片、松果的排列…都遵循斐波那契规律。有人说这是宇宙的终极密码,也有人说这是大自然的"偷懒策略"——因为按照斐波那契排列,能让种子/鳞片在最紧凑的空间里分布得最均匀。就像程序员写代码——不是为了炫技,而是因为这样"最省"。
💡 斐波那契数列的时间复杂度对比:
- 纯递归:O(2^n) ——指数级灾难,算第40项就要等好几年
- 记忆化递归:O(n) ——“我记住了”,不再重复算
- DP表格法:O(n) ——自底向上,一步步填表
- 空间优化版:O(n) 时间,O(1) 空间 ——最优解
29.4.2 背包问题
背包问题是动态规划的"经典出道题"。想象你是个小偷,背了一个容量为W的背包,去偷东西。商店里有n件商品,每件商品有价值v和重量w。你要在不超过背包容量的前提下,偷走价值最高的商品组合。这就是0-1背包问题(每件物品只能偷0个或1个,不能拆开偷)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| def knapsack(weights, values, capacity):
"""
0-1 背包问题:经典的动态规划问题
weights: 各物品的重量列表
values: 各物品的价值列表
capacity: 背包容量
时间复杂度:O(n * capacity)
空间复杂度:O(n * capacity)
"""
n = len(weights)
# dp[i][w] 表示:只考虑前 i 个物品,背包容量为 w 时的最大价值
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
print("开始动态规划...")
for i in range(1, n + 1):
print(f"\n考虑第 {i} 件物品 (重量={weights[i-1]}, 价值={values[i-1]}):")
for w in range(capacity + 1):
# 不选第 i 件物品
dp[i][w] = dp[i - 1][w]
# 选第 i 件物品(前提:背包能装下)
if weights[i - 1] <= w:
candidate = dp[i - 1][w - weights[i - 1]] + values[i - 1]
if candidate > dp[i][w]:
dp[i][w] = candidate
if w == capacity:
print(f" 容量 {w}: {dp[i][w]}")
else:
print(f" 容量 {w}: {dp[i][w]}", end=" | ")
print()
return dp[n][capacity]
# 测试
weights = [2, 3, 4, 5] # 物品重量
values = [3, 4, 5, 6] # 物品价值
capacity = 5 # 背包容量
print("=" * 60)
max_value = knapsack(weights, values, capacity)
print("=" * 60)
print(f"\n背包能装的最大价值: {max_value}")
# 最大价值为 7(选重量2价值3 + 重量3价值4,或者选重量5价值6)
|
背包问题的幽默解读:背包问题在现实中太有用了——你出差只能带一个行李箱,电脑和游戏机只能选一个带,你妈和你女朋友同时约你今晚见面,你只能去一个地方…人生就是一场背包问题。你想带的东西太多,但容量有限,怎么办?DP告诉你:“别急,一个个考虑,每个决策都要权衡——选这个还是选那个?最优解就藏在这些权衡里。”
📝 0-1背包 vs 完全背包:
- 0-1背包:每件物品只能选一次(偷或不偷)
- 完全背包:每件物品可以选无限次(随便拿,只要背包装得下)
区别仅在状态转移方程的一行代码,但答案可能完全不同!
29.4.3 最长公共子序列
最长公共子序列(Longest Common Subsequence,简称LCS)是字符串处理中的经典问题。给定两个字符串,找出它们的最长公共子序列的长度。子序列是指从原字符串中不改变顺序地删除一些(或不删除)字符后得到的新序列。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
| def lcs(str1, str2):
"""
最长公共子序列问题
时间复杂度:O(m * n)
空间复杂度:O(m * n)
dp[i][j] 表示:str1[0..i-1] 和 str2[0..j-1] 的最长公共子序列长度
"""
m, n = len(str1), len(str2)
# dp表初始化
dp = [[0] * (n + 1) for _ in range(m + 1)]
print(f"字符串1: '{str1}' (长度 {m})")
print(f"字符串2: '{str2}' (长度 {n})")
print()
# 填表
for i in range(1, m + 1):
for j in range(1, n + 1):
if str1[i - 1] == str2[j - 1]:
# 字符匹配,LCS长度 +1
dp[i][j] = dp[i - 1][j - 1] + 1
else:
# 字符不匹配,取前面计算的最大值
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# 打印dp表
print("DP表格:")
print(" ", end=" ")
for j in range(n + 1):
if j == 0:
print(" ε", end=" ")
else:
print(f" {str2[j-1]}", end=" ")
print()
for i in range(m + 1):
if i == 0:
print(" ε", end=" ")
else:
print(f"{str1[i-1]} ", end=" ")
for j in range(n + 1):
print(f" {dp[i][j]}", end=" ")
print()
return dp[m][n]
# 测试
str1 = "ABCDGH"
str2 = "AEDFHR"
print("=" * 50)
result = lcs(str1, str2)
print("=" * 50)
print(f"\n最长公共子序列长度: {result}") # 3 ("ADH")
print("\n" + "=" * 50 + "\n")
str1 = "AGGTAB"
str2 = "GXTXAYB"
result = lcs(str1, str2)
print(f"最长公共子序列长度: {result}") # 4 ("GTAB")
|
最长公共子序列的幽默解读:LCS在生物信息学中大名鼎鼎——DNA序列比对就是用它!想象两个物种的基因序列,科学家想知道它们有多相似,就用LCS来找出共同的"祖先片段"。这也解释了为什么你和你家猫有相似的基因——你们都是"地球人",共享一个远古祖先!所以LCS不仅是算法题,更是揭示生命奥秘的钥匙。(虽然面试官考你LCS的时候可不会这么解释,他只会说:“手写LCS。”
💡 LCS的应用场景:
- Git的diff命令:比较两个版本代码的差异,就是找最长公共子序列
- 论文查重:把两份论文扔进LCS,能找出相似段落
- DNA比对:生物学家用它来找物种间的亲缘关系
- 输入法联想词:你打"你好",输入法猜你下一个字要打"啊"(虽然经常猜错)
29.5 图论算法
图(Graph)是计算机科学中最重要的数据结构之一。顾名思义,图就是由节点(也叫顶点)和边组成的数据结构——就像你的微信好友关系,你是一个节点,你的好友也是节点,你们之间的好友关系就是边。
图的基本概念:
- 有向图 vs 无向图:有向图的边有方向(微信关注,A关注B不代表B关注A),无向图的边没有方向(Facebook好友,A是B好友B就是A好友)
- 带权图 vs 无权图:边的"长度"是否固定
- 邻接表 vs 邻接矩阵:图的两种存储方式
1
2
3
4
5
6
7
8
9
| # 用邻接表表示一个无向图
graph = {
'A': ['B', 'C'], # A 连接 B 和 C
'B': ['A', 'D', 'E'], # B 连接 A、D、E
'C': ['A', 'F'], # C 连接 A 和 F
'D': ['B'], # D 连接 B
'E': ['B', 'F'], # E 连接 B 和 F
'F': ['C', 'E'] # F 连接 C 和 E
}
|
29.5.1 BFS(广度优先搜索)
BFS(Breadth-First Search)就像水波纹扩散——从起点出发,先访问所有邻居,再访问邻居的邻居,一圈一圈往外扩散。它利用队列(Queue)这种数据结构,先到先服务。
核心思想:用一个队列来管理待访问的节点。每次从队列头部取出一个节点,访问它,然后把它的所有未访问邻居加入队列尾部。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
| from collections import deque
def bfs(graph, start):
"""
广度优先搜索(BFS)
适用场景:找最短路径、层次遍历、社交网络中"n度好友"查询
时间复杂度:O(V + E),V是顶点数,E是边数
空间复杂度:O(V)
"""
visited = set() # 记录已访问的节点
queue = deque([start]) # 队列初始化,放入起点
visited.add(start)
traversal_order = [] # 记录访问顺序
print(f"BFS 从 '{start}' 开始:")
step = 0
while queue:
step += 1
node = queue.popleft() # 从队列头部取出
traversal_order.append(node)
print(f" 第 {step} 步: 访问节点 '{node}'")
# 遍历所有邻居
for neighbor in graph.get(node, []):
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
print(f" → 发现未访问邻居 '{neighbor}',加入队列")
print(f"\n访问顺序: {' → '.join(traversal_order)}")
return traversal_order
# 测试
graph = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'F'],
'D': ['B'],
'E': ['B', 'F'],
'F': ['C', 'E']
}
print("=" * 50)
bfs(graph, 'A')
# BFS 从 'A' 开始:
# 第 1 步: 访问节点 'A'
# → 发现未访问邻居 'B',加入队列
# → 发现未访问邻居 'C',加入队列
# 第 2 步: 访问节点 'B'
# → 发现未访问邻居 'D',加入队列
# → 发现未访问邻居 'E',加入队列
# 第 3 步: 访问节点 'C'
# → 发现未访问邻居 'F',加入队列
# 第 4 步: 访问节点 'D'
# 第 5 步: 访问节点 'E'
# 第 6 步: 访问节点 'F'
#
# 访问顺序: A → B → C → D → E → F
|
BFS的幽默解读:BFS就像你往水里扔了一块石头,涟漪一圈一圈扩散出去。假设你在某人的朋友圈里找一个人(比如你前女友的现男友的老婆的前男友),BFS告诉你:“先查你的一度好友,没有的话再查二度好友,三度好友…按层级来。“这样找能保证——如果你们真的认识,一定是通过最少的人认识的。BFS是社恐患者的救星,哦不对,是"最短路径搜索器”。
💡 BFS找最短路径:BFS的一个重要性质是,从起点到任意节点,第一次到达该节点的路径就是最短路径。这在迷宫问题、社交网络” degrees of separation “等问题中非常有用。
graph LR
A["🟢 A (起点)"] --> B["🔵 B"]
A --> C["🔵 C"]
B --> D["🟡 D"]
B --> E["🟡 E"]
C --> F["🟡 F"]
E --> F
style A fill:#90EE90
style B fill:#87CEEB
style C fill:#87CEEB
style D fill:#FFFF99
style E fill:#FFFF99
style F fill:#FFFF9929.5.2 DFS(深度优先搜索)
DFS(Depth-First Search)就像一个人走进迷宫——先一条路走到黑,遇到死胡同就回头(回溯),然后尝试另一条路。DFS利用栈(Stack)或者递归实现。
核心思想:从起点开始,尽可能"深"地探索一条路径,直到无路可走,然后回溯到最近的岔路口,换条路继续。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
| def dfs_recursive(graph, node, visited=None):
"""
深度优先搜索(DFS)- 递归版本
时间复杂度:O(V + E)
空间复杂度:O(V)(递归栈)
"""
if visited is None:
visited = set()
visited.add(node)
print(f" 访问节点: '{node}'")
# 深度优先:递归访问所有未访问的邻居
for neighbor in graph.get(node, []):
if neighbor not in visited:
dfs_recursive(graph, neighbor, visited)
return visited
def dfs_iterative(graph, start):
"""
深度优先搜索(DFS)- 迭代版本(用栈)
"""
visited = set()
stack = [start]
traversal_order = []
print(f"DFS 从 '{start}' 开始(迭代版本):")
step = 0
while stack:
node = stack.pop() # 从栈顶取出
if node not in visited:
visited.add(node)
traversal_order.append(node)
step += 1
print(f" 第 {step} 步: 访问节点 '{node}'")
# 注意:这里是逆序加入,因为栈是LIFO
for neighbor in reversed(graph.get(node, [])):
if neighbor not in visited:
stack.append(neighbor)
print(f"\n访问顺序: {' → '.join(traversal_order)}")
return traversal_order
# 测试
graph = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'F'],
'D': ['B'],
'E': ['B', 'F'],
'F': ['C', 'E']
}
print("=" * 50)
print("【DFS 递归版本】")
print("=" * 50)
dfs_recursive(graph, 'A')
print("\n" + "=" * 50)
print("【DFS 迭代版本】")
print("=" * 50)
dfs_iterative(graph, 'A')
|
DFS的幽默解读:DFS就像你玩RPG游戏时探索地图——先往东走,走到死胡同(没路了),然后按暂停,回退到上一个岔路口,往南走…又死胡同了?再回退,往北走…终于找到宝藏了!这就是DFS的核心思想:不撞南墙不回头,撞了南墙就换路。想象你是《盗墓笔记》里的吴邪,每次下斗都是DFS——一直往下挖,遇到粽子(死胡同)就撤,换条路继续挖。
💡 BFS vs DFS对比:
- BFS像"水面波纹”,层层扩散,适合找最短路径
- DFS像"一条道走到黑",适合穷举搜索、拓扑排序、连通分量
- 两者时间复杂度相同,区别只是访问顺序不同
graph TD
A["🟢 A (起点)"] --> B["1️⃣ B"]
A --> C["3️⃣ C"]
B --> D["2️⃣ D"]
B --> E["2️⃣ E"]
C --> F["3️⃣ F"]
E --> F
style A fill:#90EE90
style B fill:#ff9999
style C fill:#ff9999
style D fill:#ffff99
style E fill:#ffff99
style F fill:#ffff99
subgraph "DFS访问顺序示例(数字表示访问顺序)"
end29.5.3 最短路径(Dijkstra)
Dijkstra算法(迪杰斯特拉算法,念成"迪杰斯特拉"就好,别念成"迪卡塔尔")是图论中的"当红炸子鸡"——用于在带权图中找从一个起点到所有其他节点的最短路径。它是GPS导航、网络路由等应用的理论基础。
核心思想:贪心策略。从起点开始,每次选择当前未处理的、距离起点最近的节点,然后"松弛"它的邻居距离。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
| import heapq
def dijkstra(graph, start):
"""
Dijkstra最短路径算法
适用于:带权图,边权非负
时间复杂度:O((V + E) log V) — 使用优先队列优化
graph: 邻接表形式的带权图
返回: {节点: 到起点的最短距离}
"""
# 到各节点的最短距离,初始为无穷大
distances = {node: float('inf') for node in graph}
distances[start] = 0
# 优先队列:(距离, 节点)
pq = [(0, start)]
# 记录已确认最短距离的节点
visited = set()
print(f"Dijkstra 从 '{start}' 开始:")
step = 0
while pq:
step += 1
current_dist, current_node = heapq.heappop(pq)
# 如果已经处理过,跳过
if current_node in visited:
continue
visited.add(current_node)
print(f" 第 {step} 步: 处理节点 '{current_node}', 当前距离: {current_dist}")
# 松弛当前节点的所有邻居
for neighbor, weight in graph.get(current_node, []):
if neighbor not in visited:
new_dist = current_dist + weight
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
heapq.heappush(pq, (new_dist, neighbor))
print(f" → 更新 '{neighbor}' 距离: {new_dist}")
return distances
# 测试 - 带权图
# A ---2--- B
# | / \
# 1 3 1
# | / \
# C---4------- D
# \ /
# 2-----1
graph_weighted = {
'A': [('B', 2), ('C', 1)],
'B': [('A', 2), ('C', 3), ('D', 1)],
'C': [('A', 1), ('B', 3), ('D', 4), ('E', 2)],
'D': [('B', 1), ('C', 4), ('E', 1)],
'E': [('C', 2), ('D', 1)]
}
print("=" * 60)
distances = dijkstra(graph_weighted, 'A')
print("=" * 60)
print("\n从 A 出发的最短距离:")
for node, dist in distances.items():
print(f" A → {node}: {dist}")
# A → A: 0
# A → B: 2
# A → C: 1
# A → D: 3 (A→B→D: 2+1=3 或 A→C→D: 1+4=5,取小的3)
# A → E: 3 (A→C→E: 1+2=3 或 A→D→E: 3+1=4,取小的3)
|
Dijkstra算法的幽默解读:Dijkstra算法就像你在城市里打车,GPS会帮你算最短路线。每次你到一个新地方(当前节点),GPS会问你:“从这里到附近各个地方各要多少钱?"(松弛邻居),然后它会重新评估到各个目的地的最短路线。算法保证每次处理的节点,都是目前已知的全局最近的未处理节点——这就保证了找到的一定是最短路径。当然,如果边权是负数,这算法就废了——因为"贪心"的前提是"走一步算一步”,边权一旦可能是负的,你今天走近路可能明天就亏了,算法就"贪"不动了。
💡 Dijkstra vs BFS:BFS实际上是Dijkstra的特殊情况——当所有边权都是1时,Dijkstra就退化成了BFS。所以BFS是Dijkstra的"简化版"或Dijkstra是BFS的"升级版"。
29.6 贪心算法
贪心算法(Greedy Algorithm)是一种"活在当下"的算法思想——每一步都做当前看起来最优的选择,不考虑全局,不后悔,不回头。虽然它不一定能得到全局最优解,但很多问题用贪心确实能得到最优解,而且实现简单,效率极高。
贪心算法的核心:
- 局部最优选择:每一步都选当前最好的选项
- 无后效性:一旦做出选择,以后的步骤不再受之前选择的影响
- 最终得到全局最优(对于某些问题)
什么样的问题可以用贪心:当问题的最优解包含子问题的最优解,且每一步的局部最优都能传递到全局最优时,就可以用贪心。这类问题有个专门的名字:贪心选择性质。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
| # 贪心算法示例1:找零钱问题(假设货币系统合理)
def make_change(amount):
"""
用最少硬币找零 — 假设有面值为 1, 5, 10, 20, 100 的硬币
"""
coins = [100, 20, 10, 5, 1] # 按从大到小排序
result = {}
for coin in coins:
if amount >= coin:
count = amount // coin
result[coin] = count
amount -= coin * count
print(f"找零方案: {result}")
return result
print("【找零问题】")
make_change(68) # 应该得到:3个20, 1个5, 3个1
# 找零方案: {100: 0, 20: 3, 10: 0, 5: 1, 1: 3}
# 贪心算法示例2:活动选择问题
def activity_selection(activities):
"""
活动选择问题:给定一组活动,每个活动有开始和结束时间,
选择最多不重叠的活动
贪心策略:每次选择结束时间最早的活动(这样能为后面的活动留更多时间)
"""
# 按结束时间排序
sorted_activities = sorted(activities, key=lambda x: x[1])
selected = []
current_end = 0 # 当前已选择活动的结束时间
print("活动列表(按结束时间排序):")
for i, (name, start, end) in enumerate(sorted_activities):
print(f" {name}: {start}~{end}")
print("\n选择过程:")
for i, (name, start, end) in enumerate(sorted_activities):
if start >= current_end:
selected.append(name)
current_end = end
print(f" ✓ 选择 '{name}' (开始={start}, 结束={end})")
print(f"\n选择的最多活动: {selected}")
return selected
print("\n【活动选择问题】")
activities = [
('A', 0, 6),
('B', 3, 4),
('C', 1, 2),
('D', 5, 9),
('E', 5, 7),
('F', 8, 10),
]
activity_selection(activities)
# 选择: C, B, E, F(或其他最多组合)
|
贪心算法的幽默解读:贪心就像找对象——你总是选择当前遇到的"最优解",不纠结过去,不考虑未来。“这个人比我之前见过的都好,先交往着,以后遇到更好的再说!"——这就是贪心的人生哲学。问题是,如果你一直遇到更好的就换,最终可能错过真爱(全局最优解)。所以贪心算法只适用于"你的眼光刚好能看出最优解"的问题。对于货币系统合理的找零钱问题,贪心确实能得到最优解;但如果货币系统是 [1, 3, 4],贪心可能翻车——比如找零6元,贪心会选4+1+1(3个硬币),但最优解是3+3(2个硬币)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| # 贪心失效的例子
def coin_change_greedy_fail(amount):
"""贪心在某些货币系统下会失败"""
# 如果只有 [1, 3, 4] 面值的硬币
coins = [4, 3, 1] # 按贪心策略从大到小排
count = 0
result = []
for coin in coins:
while amount >= coin:
amount -= coin
count += 1
result.append(coin)
print(f"贪心结果: {result},用了 {count} 个硬币")
return count
print("\n【贪心可能翻车的例子】")
print("amount = 6, coins = [1, 3, 4]")
print("贪心策略: ", end="")
coin_change_greedy_fail(6)
# 贪心: 4 + 1 + 1 = 6,用了 3 个硬币
# 但最优解: 3 + 3 = 6,只用 2 个硬币!
def coin_change_dp(amount):
"""动态规划得到最优解"""
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in [1, 3, 4]:
if i >= coin:
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount]
print(f"DP最优解: {coin_change_dp(6)} 个硬币")
# DP最优解: 2 个硬币
|
📝 贪心 vs 动态规划:
- 贪心:每步只考虑当前最优,可能错失全局最优(但有些问题确实能得到全局最优)
- 动态规划:考虑所有子问题,综合得出最优解(必定得到全局最优,但复杂度更高)
- 判断标准:如果一个问题能用贪心得到最优解,那它一定满足"贪心选择性质"和"最优子结构”。不确定的时候,用DP准没错——虽然慢一点,但稳。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| # 贪心算法示例3:霍夫曼编码(数据压缩的基础)
def huffman_coding(frequencies):
"""
霍夫曼编码:用贪心思想构造最优前缀码
频率高的字符用短码,频率低的字符用长码,实现数据压缩
这也是为什么 ZIP 压缩能工作的核心技术!
"""
import heapq
# 把所有字符放入优先队列(频率小的优先)
heap = [[freq, [char, ""]] for char, freq in frequencies.items()]
heapq.heapify(heap)
print("霍夫曼编码过程:")
while len(heap) > 1:
# 取出频率最小的两个节点
left = heapq.heappop(heap)
right = heapq.heappop(heap)
# 给它们的码字加前缀
left[1][1] = "0" + left[1][1]
right[1][1] = "1" + right[1][1]
# 合并(频率相加),重新放回队列
merged = [left[0] + right[0], [left[1], right[1]]]
heapq.heappush(heap, merged)
print(f" 合并节点: 频率={merged[0]}")
# 返回构建好的霍夫曼树(优先队列中唯一的元素)
return heap[0]
def flatten_codes(node, prefix=""):
"""把霍夫曼树展平为字典"""
if isinstance(node[1], str):
return {node[1]: prefix or "0"}
codes = {}
codes.update(flatten_codes(node[1][0], prefix + "0"))
codes.update(flatten_codes(node[1][1], prefix + "1"))
return codes
print("\n【霍夫曼编码】")
frequencies = {'A': 45, 'B': 13, 'C': 12, 'D': 16, 'E': 9, 'F': 5}
huffman_tree = huffman_coding(frequencies)
codes_dict = flatten_codes(huffman_tree)
print("\n编码结果:")
for char, code in sorted(codes_dict.items()):
print(f" {char}: {code} (频率: {frequencies[char]}%)")
# A: 0 (45%)
# F: 1000 (5%)
# ...
# 霍夫曼编码保证了:高频字符短码,低频字符长码,总编码长度最短!
|
本章小结
恭喜你!终于把算法这座大山翻过来了。让我们回顾一下今天学到了什么:
📌 排序算法
| 算法 | 时间复杂度 | 特点 |
|---|
| 冒泡排序 | O(n²) | 简单粗暴,面试常客 |
| 选择排序 | O(n²) | 交换次数少,适合写操作昂贵的场景 |
| 插入排序 | O(n²)/O(n) | 对近乎有序数据表现极好 |
| 归并排序 | O(n log n) | 稳定高效,分治思想典范 |
| 快速排序 | O(n log n) | 实际应用最快,面试必考 |
| 堆排序 | O(n log n) | 原地排序,空间效率之王 |
实战建议:数据量小选插入,数据量中选快排,需要稳定选归并,不清楚就堆排。
📌 查找算法
- 顺序查找:O(n),简单粗暴,无序数据的不二之选
- 二分查找:O(log n),数据必须有序,一刀切一半,快到飞起
📌 递归与分治
递归三要素:基准情形(出口)、递归调用(缩小规模)、答案返回(组合子问题)
- 汉诺塔:递归的经典教学案例,2^n - 1 步的视觉冲击力
- 归并排序:分治思想的完美体现,“分"到极致再"治”
📌 动态规划
什么时候用DP:重叠子问题 + 最优子结构
- 斐波那契数列:DP入门,从O(2^n)优化到O(n)的经典案例
- 背包问题:选或不选,每个决策都影响未来
- 最长公共子序列:字符串处理的利器,Git diff的技术基础
📌 图论算法
- BFS(广度优先搜索):一圈一圈扩散,找最短路径的不二之选
- DFS(深度优先搜索):一条道走到黑,穷举搜索的标配
- Dijkstra算法:带权最短路径,GPS导航的理论基础
📌 贪心算法
核心理念:每步最优,不保证全局最优(但某些问题确实能得到全局最优)
- 找零钱(货币系统合理时)
- 活动选择(结束时间早优先)
- 霍夫曼编码(数据压缩的核心)
🏆 学习算法的终极奥义:算法不是背的,是理解的。背题库能过一面,但过不了二面(手写红黑树除外,那是真的勇士)。理解每种算法的"为什么"——为什么快排快?为什么DP能解?什么时候用贪心?——这才是从"会做题"到"会解决问题"的关键。
记住:算法虐我千百遍,我待算法如初恋。总有一天你会发现,这些看似无聊的排序、查找、DP,其实是你解决真实问题的屠龙刀。练好算法,走遍天下都不怕!
下章见,朋友们! 🚀