PY-09 算法思维
09 · 算法思维(Algorithm Thinking)
📅 预计 90 分钟 | ⭐ 常用算法与数据结构
✍️ 本讲配套练习:本地python grader.py 09,或网页练习
9.1 什么是算法:把「怎么做」写成代码
算法不是数学课里的天书,它就是你每天在生活里都在用的做事步骤。比如「泡一杯奶茶」:拿杯子 → 倒茶 → 加糖 → 搅拌。把这一串步骤写成 Python,让别人(电脑)照着做,就是算法。
好的算法看两点:正确(结果对)和高效(跑得快、省内存)。怎么衡量快不快?用时间复杂度,用大 O 表示「当数据变大 n 倍时,时间大约变几倍」。
| 复杂度 | 生活比喻 | 例子 |
|---|---|---|
| O(1) | 一秒钟完成,和数量无关 | 取列表第一个元素 |
| O(log n) | 猜数字,每猜一次范围砍半 | 二分查找 |
| O(n) | 挨个看一遍 | 线性查找、遍历 |
| O(n log n) | 先分组再合并,比 n² 快很多 | 归并排序、快排 |
| O(n²) | 每两个人都要比一次 | 嵌套两层循环 |
# O(n):一趟遍历求最大值
nums = [4, 7, 2, 9, 5]
mx = nums[0]
for x in nums:
if x > mx:
mx = x
print(mx) # 输出 9
# O(n²):两层循环全比较(冒泡排序的雏形)
a = [3, 1, 2]
for i in range(len(a)):
for j in range(i + 1, len(a)):
if a[i] > a[j]:
a[i], a[j] = a[j], a[i]
print(a) # 输出 [1, 2, 3]
⚠️ 常见错误
- 把 O(2n)、O(n/2) 当回事:大 O 只关心数量级,2n 和 n 都写成 O(n)。两层循环嵌套(即使其中一层只走一半)也是 O(n²)。
- 只求对,不求快:笔试里「数据量 10 万」直接提醒你 O(n²) 大概率超时,要想着优化。
9.2 二分查找:猜数字游戏的高级版 ⭐
你玩过「1~100 猜数字」吗?最聪明的玩法不是从 1 猜到 100,而是每次猜中间:对方说大了,范围立刻砍半。二分查找就是这个思路——只不过猜的对象是有序列表里的下标。
前提:序列必须有序。每次取中间下标 mid,和目标比大小,直接丢掉一半,所以 O(n) 变成 O(log n)。数据量翻 100 倍,二分只多查几次。
def binary_search(nums, target):
left, right = 0, len(nums) - 1 # 左右夹逼
while left <= right:
mid = (left + right) // 2 # 取中间(防下标越界)
if nums[mid] == target:
return mid # 找到了,返回下标
elif nums[mid] < target:
left = mid + 1 # 目标在右半,砍掉左半
else:
right = mid - 1 # 目标在左半,砍掉右半
return -1 # 没找到
print(binary_search([1, 3, 5, 7, 9], 7)) # 输出 3
print(binary_search([1, 3, 5, 7, 9], 2)) # 输出 -1
⭐ 二分模板三要素:while 条件(left <= right)、mid 取中间、左右边界更新(mid ± 1)。三者错一步就会死循环或漏解。
Python 内置 bisect.bisect_left(nums, target) 就是二分的现成实现,竞赛里可以直接用。
⚠️ 常见错误
- 边界写成
left = mid或right = mid:当区间只剩 1 个元素时,mid永远不变 → 死循环。记住要mid + 1/mid - 1。 - while 条件写成
left < right:会漏掉只剩一个元素时恰好是目标的情况。配套练习 ex02 专门测这个。 - 忘了列表要先排序:二分只对有序序列成立,乱序直接二分结果全错。
9.3 穷举:暴力出奇迹
穷举(brute force)= 把所有可能性都试一遍。思路简单、代码好写,数据量小时它就是最优解。经典题「两数之和」:找哪两个数加起来等于 target,那就两层循环把每对都试一次。
def two_sum(nums, target):
for i in range(len(nums)):
for j in range(i + 1, len(nums)): # j 从 i+1 开始,避免自己和自己的下标对
if nums[i] + nums[j] == target:
return [i, j] # 题目要求小下标在前
return [-1, -1]
print(two_sum([2, 7, 11, 15], 9)) # 输出 [0, 1]
print(two_sum([2, 7, 11, 15], 30)) # 输出 [-1, -1]
刷题口诀:先想暴力能不能过,再想能不能优化。能过就直接交,不能过再上双指针/哈希。
⚠️ 常见错误
- j 从 0 开始:会算上
i == j的「自己+自己」,还可能重复输出[i,j]和[j,i]。j 从i+1开始,天然去重。 - 直接
return [-1,-1]放错位置:要放到两层循环外面,否则第一次比较不相等就提前返回了。配套练习 ex04 考察这个。 - 把下标当值比较:比的是
nums[i] + nums[j],不是i + j。
9.4 排序:从冒泡到快排
排序是算法里最经典的入门战场。考试常考手写冒泡和选择,面试爱问快排思想,工程里直接用内置 sorted()。先记住这张速览表:
| 算法 | 复杂度 | 特点 |
|---|---|---|
| 冒泡/选择/插入 | O(n²) | 简单,入门必会 |
| 归并/快排 | O(n log n) | 高效,常考思想 |
内置 sorted() |
O(n log n) | 最常用,直接用 |
9.4.1 冒泡排序:气泡上浮
想象气泡从水底往上浮,越往上气泡越大。冒泡排序就是相邻两个比大小,大的往右「冒」,每一轮把当前最大数推到末尾。
def bubble_sort(lst):
arr = lst[:] # 复制一份,不改原列表
n = len(arr)
for i in range(n): # 进行 n 轮
for j in range(n - 1 - i): # 已排好的末尾不用再比
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j] # 交换
return arr
print(bubble_sort([3, 1, 2])) # 输出 [1, 2, 3]
print(bubble_sort([1, 1, 2, 1])) # 输出 [1, 1, 1, 2]
9.4.2 选择排序:每轮挑最小的放前面
像挑西瓜:先挑出全场最小的放第一位,再从剩下里挑最小的放第二位……每轮只做一次交换。
def selection_sort(lst):
arr = lst[:]
n = len(arr)
for i in range(n):
m = i # 假设 i 是最小值位置
for j in range(i + 1, n):
if arr[j] < arr[m]:
m = j # 记下真正最小值的下标
arr[i], arr[m] = arr[m], arr[i] # 放到正确位置
return arr
print(selection_sort([4, 2, 3, 1])) # 输出 [1, 2, 3, 4]
9.4.3 插入排序:像打扑克理牌
抓一张新牌,插入到手里已经排好序的牌里。插入排序就是从第 2 个元素开始,把它往左边已经有序的部分里插。
def insertion_sort(lst):
arr = lst[:]
for i in range(1, len(arr)):
key = arr[i] # 手里的新牌
j = i - 1
while j >= 0 and arr[j] > key: # 把比 key 大的牌往后挪
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key # 新牌落位
return arr
print(insertion_sort([5, 2, 4, 1, 3])) # 输出 [1, 2, 3, 4, 5]
9.4.4 归并思想:先拆两半,排好再合并
归并排序分三步:拆 → 各自排 → 合并。最核心的「合并两个有序列表」就是经典的双指针套路——两个指针各自指向有序列表开头,谁小谁进结果,谁的后移。
def merge_lists(a, b):
i = j = 0
res = []
while i < len(a) and j < len(b):
if a[i] <= b[j]:
res.append(a[i]); i += 1
else:
res.append(b[j]); j += 1
res.extend(a[i:]) # 剩下的直接接上
res.extend(b[j:])
return res
print(merge_lists([1, 3, 5], [2, 4, 6])) # 输出 [1, 2, 3, 4, 5, 6]
9.4.5 快速排序:选个「基准」,小的站左大的站右
快排是分治思想的主角:选一个数当基准(pivot),比它小的放左边,比它大的放右边,然后对左右各自再排。写法有很多种,最易懂的版本直接用列表推导式划分:
def quick_sort(lst):
if len(lst) <= 1:
return lst[:] # 递归出口
pivot = lst[0]
left = [x for x in lst if x < pivot] # 严格小于
right = [x for x in lst if x > pivot] # 严格大于
mid = [x for x in lst if x == pivot] # 相等的单独放一起,避免死循环
return quick_sort(left) + mid + quick_sort(right)
print(quick_sort([6, 3, 8, 5, 2])) # 输出 [2, 3, 5, 6, 8]
print(quick_sort([3, 1, 3, 2])) # 输出 [1, 2, 3, 3]
9.4.6 哈希计数:用字典当计数器
很多排序/去重问题其实不用排序,用字典统计次数更直接——这叫哈希计数。遍历一遍,每个元素在字典里加一,就是 O(n)。
def char_frequency(s):
counts = {}
for ch in s:
counts[ch] = counts.get(ch, 0) + 1 # get 取不到就默认 0
return counts
print(char_frequency("hello"))
# 输出 {'h': 1, 'e': 1, 'l': 2, 'o': 1}
⚠️ 常见错误(排序)
- 直接改原列表:面试官一问「原列表变了吗」就露馅。写
arr = lst[:]复制一份再排。 - 冒泡内层
range(n-1-i)写成range(n):会把已经排好的末尾再比一次,浪费但不算错;写成range(n-i)会导致arr[j+1]越界报 IndexError。 - 插入排序漏掉
j >= 0条件:key 比左边所有牌都小时,j 会一直减到 -1,arr[j]越界。 - 快排对重复元素死循环:如果只分
<=和>,全相等的列表会无限递归。用mid单独装相等元素是标准解法。 - 归并忘记接剩余部分:某个列表先走完,另一个剩下的要
extend进去。
9.5 递归与分治:套娃的艺术
递归就是函数调用自己,像俄罗斯套娃:打开最大的娃,里面套着一样的、但更小的娃,直到最小的那个是实心的(这就是终止条件)。任何递归都要回答两个问题:① 终止条件是什么(出口)?② 这一步和更小的一步有什么关系(递推式)?
经典例子:斐波那契数列 f(n) = f(n-1) + f(n-2),出口是 n < 2 时 f(n) = n。
def fib(n):
if n < 2: # 出口:0 和 1 直接返回
return n
return fib(n - 1) + fib(n - 2) # 递推:拆成两个更小的子问题
print(fib(6)) # 输出 8
print(fib(10)) # 输出 55
汉诺塔:把 n 个盘子从 A 挪到 C
三根柱子 A、B、C,一次只能挪一个,大的不能压小的。n 个盘子怎么挪?把上面的 n-1 个先挪到 B(借助 C),把最底下那个挪到 C,再把 n-1 个从 B 挪到 C(借助 A)——这就是把大问题拆成两个规模 n-1 的小问题。
def hanoi(n, src, dst, mid):
if n == 1:
print(f"{src} -> {dst}")
return
hanoi(n - 1, src, mid, dst) # 先把 n-1 个挪到中转柱
print(f"{src} -> {dst}") # 把最大的挪到目标柱
hanoi(n - 1, mid, dst, src) # 再把 n-1 个挪回目标柱
hanoi(2, 'A', 'C', 'B')
# 输出:
# A -> B
# A -> C
# B -> C
最大公约数:辗转相除
gcd(a, b) = gcd(b, a % b),直到 b == 0 时返回 a。这是递归思想的教科书例子。
def gcd(a, b):
if b == 0: # 出口
return a
return gcd(b, a % b) # 递推
print(gcd(12, 18)) # 输出 6
print(gcd(7, 13)) # 输出 1
⚠️ 常见错误(递归)
- 忘了写出口 → 无限递归,
RecursionError: maximum recursion depth exceeded(爆栈)。先写出口再写递推。 - 出口写错:比如
fib出口写成if n == 1,那fib(0)会一直递归直到爆栈。 - 无脑递归不优化:
fib(40)朴素的f(n-1)+f(n-2)会指数级膨胀,慢到怀疑人生。大 n 用循环或动态规划(见 9.8)。 - 汉诺塔打印顺序错:打印要放在「两次递归之间」,不是开头也不是结尾。
9.6 双指针与滑动窗口:让遍历不回头
双指针:一头一尾往中间夹
很多时候暴力要 O(n²),用两个指针一左一右同时动,一趟就搞定 O(n)。最经典的就是反转列表:
def reverse_list(lst):
arr = lst[:]
i, j = 0, len(arr) - 1
while i < j:
arr[i], arr[j] = arr[j], arr[i] # 左右交换
i += 1
j -= 1 # 同时向中间走
return arr
print(reverse_list([1, 2, 3, 4])) # 输出 [4, 3, 2, 1]
双指针还有另一种形态:一快一慢(快指针负责前进,慢指针记录位置),用于原地去重等。思路一样:两个指针互相配合,省掉一层循环。
滑动窗口:一根窗口在数组上滑
滑动窗口适合「找满足条件的连续子数组」。想象一扇可以伸缩的窗,right 端往前扩把数加进来,一旦和满足条件,就试着从 left 端收缩找更短的窗口——两个指针都只往前走,不回头,所以是 O(n)。
经典题「最短子数组」(LeetCode 209):
def min_window(target, nums):
left = 0
total = 0
best = len(nums) + 1 # 用一个很大的初始值
for right in range(len(nums)):
total += nums[right] # 窗口右端扩张
while total >= target: # 满足条件就尝试收缩
best = min(best, right - left + 1)
total -= nums[left]
left += 1
return best if best <= len(nums) else 0 # 没有答案返回 0
print(min_window(7, [2, 3, 1, 2, 4, 3])) # 输出 2 (子数组 [4,3])
print(min_window(11, [1, 1, 1, 1])) # 输出 0 (全加起来都不够)
⚠️ 常见错误(双指针)
- 交换后忘记让两个指针同时动:只
i += 1不j -= 1,指针会交叉越界或死循环。 - while 条件写成
i <= j:两个指针相遇后还会再交换一次,把已反转的部分又翻回来。 - 滑动窗口的
while写成了if:窗口可能需要收缩多次,if只收缩一次会漏掉更短答案。 - 窗口收缩时忘了扣掉
nums[left]:total 不更新,left 再前进也没意义。
9.7 贪心:每步都吃最大那一口
贪心(greedy)的策略是:每一步都做当前看起来最优的选择,从不回头。很多问题这样就能得到全局最优——但不是所有问题都行,所以「判断能不能贪」本身就是考点。
最典型的例子是找零钱:面额 1、5、10、25 美分,凑 38 美分要最少硬币。贪心做法是优先用大面额:一个 25、一个 10、三个 1,共 5 枚。
def min_coins(coins, amount):
coins = sorted(coins, reverse=True) # 从大到小
count = 0
for c in coins:
count += amount // c # 这个面额最多用几个
amount %= c # 剩下多少
return count if amount == 0 else -1 # 凑不出返回 -1
print(min_coins([1, 5, 10, 25], 38)) # 输出 5
print(min_coins([5, 10], 3)) # 输出 -1 (凑不出)
另一个经典是区间调度:选最多的不重叠区间。做法:按结束时间排序,每次选「结束最早且和上一个不冲突」的区间——因为结束越早,给后面留的余地越大。
⚠️ 常见错误(贪心)
- 以为贪心永远正确:比如面额
[1, 3, 4]凑 6,贪心会选4+1+1(3 枚),但正确答案是3+3(2 枚)。贪心错了就是错了,这类题要用动态规划。配套练习 ex12 用的都是贪心成立的标准面额。 - 忘了面额要先排序:不排序从大到小,第一个面额可能不是最大的。
- 凑不出时返回 0 而不是 -1:约定清楚,练习里凑不出要返回 -1。
9.8 动态规划初探:记住算过的,别重复算
动态规划(DP)的核心就一句话:把子问题的答案存起来,后面的答案由前面的拼出来。它和递归的区别是:递归是「从上往下拆」,DP 是「从下往上垒」,用数组把每一步记下来。
经典入门「爬楼梯」:一次能爬 1 级或 2 级,到第 n 级有几种方法?到第 n 级,要么从第 n-1 级爬 1 级上来,要么从第 n-2 级爬 2 级上来,所以:
f(n) = f(n-1) + f(n-2),f(1)=1, f(2)=2。
它和斐波那契长得很像,但用循环写就不会爆栈、也不会重复计算:
def climb_stairs(n):
if n <= 2:
return n
a, b = 1, 2 # a=f(1), b=f(2)
for _ in range(3, n + 1):
a, b = b, a + b # 滚动递推,只记住前两个
return b
print(climb_stairs(4)) # 输出 5
print(climb_stairs(10)) # 输出 89
看到题先找状态(存什么)和转移方程(怎么从前面推出来),DP 就有了骨架。
⚠️ 常见错误(DP)
- 初始值定义错:爬楼梯
f(1)=1, f(2)=2,写成f(1)=1, f(2)=1整个数列全错。 - 范围没含端点:循环
range(3, n)少了第 n 项,漏一。 - 用递归写 DP 但不记忆化:
climb_stairs(35)指数级超时。DP 的意义就在于用循环/数组避免重复计算。 - 滚动变量顺序搞反:
a, b = b, a + b是同时赋值;写成a = b; b = a + b会让 b 变成2b,全错。
9.9 二维列表与矩阵:一张表格的遍历
二维列表就是「列表的列表」,像一张表格。matrix[i][j] 表示第 i 行第 j 列。遍历口诀:外层管行,内层管列。
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
for i in range(len(matrix)): # 行
for j in range(len(matrix[i])): # 列
print(matrix[i][j], end=' ')
# 输出 1 2 3 4 5 6 7 8 9
矩阵转置:行变列
转置就是 result[j][i] = matrix[i][j]。做法:先造一个 列数×行数 的空矩阵,再逐个填。
def transpose(matrix):
rows, cols = len(matrix), len(matrix[0])
res = [[0] * rows for _ in range(cols)] # 注意维度交换
for i in range(rows):
for j in range(cols):
res[j][i] = matrix[i][j]
return res
print(transpose([[1, 2, 3], [4, 5, 6]]))
# 输出 [[1, 4], [2, 5], [3, 6]]
螺旋遍历:绕着圈走
高级玩法:顺时针螺旋遍历矩阵,像剥洋葱,一层层从外往里。用 top/bottom/left/right 四个边界控制,每走完一条边就收缩边界——这是综合题 ex15 的考点。
def spiral_matrix(matrix):
if not matrix:
return []
top, bottom = 0, len(matrix) - 1
left, right = 0, len(matrix[0]) - 1
res = []
while top <= bottom and left <= right:
for j in range(left, right + 1): # 上边,向右
res.append(matrix[top][j])
top += 1
for i in range(top, bottom + 1): # 右边,向下
res.append(matrix[i][right])
right -= 1
if top <= bottom: # 下边,向左
for j in range(right, left - 1, -1):
res.append(matrix[bottom][j])
bottom -= 1
if left <= right: # 左边,向上
for i in range(bottom, top - 1, -1):
res.append(matrix[i][left])
left += 1
return res
print(spiral_matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]]))
# 输出 [1, 2, 3, 6, 9, 8, 7, 4, 5]
⚠️ 常见错误(矩阵)
- 用
[[0] * rows] * cols造矩阵:这是同一个列表被复制了 cols 次,改一行其他行跟着变!必须用[[0] * rows for _ in range(cols)]。 - 转置忘记交换行列数:
rows×cols转置后是cols×rows,维度反了就 IndexError。 - 螺旋遍历少了
if top <= bottom判断:奇数行/列时边界会交叉,重复添加元素。螺旋题先写if not matrix处理空矩阵。
9.10 质数判断与素数筛选
质数(素数)= 大于 1 且只有 1 和它本身两个约数的数。判断 n 是不是质数,最朴素的做法是从 2 试到 sqrt(n)——因为如果 n 有约数,必然有一个不超过它的平方根。
def is_prime(n):
if n < 2:
return False
i = 2
while i * i <= n: # 只试到根号 n
if n % i == 0:
return False
i += 1
return True
print(is_prime(7)) # 输出 True
print(is_prime(1)) # 输出 False
print(is_prime(4)) # 输出 False
埃氏筛:一次筛出很多质数
要快速得到「2 到 n 里所有质数」,用埃拉托斯特尼筛法:先假设全是质数,从 2 开始,把每个质数的倍数全部划掉,剩下的就是质数。
def sieve(n):
ok = [True] * (n + 1)
ok[0] = ok[1] = False # 0 和 1 不是质数
for i in range(2, int(n ** 0.5) + 1):
if ok[i]: # i 是质数
for j in range(i * i, n + 1, i): # 划掉它的倍数
ok[j] = False
return [x for x in range(2, n + 1) if ok[x]]
print(sieve(20)) # 输出 [2, 3, 5, 7, 11, 13, 17, 19]
⚠️ 常见错误(质数)
- 忘了处理 n < 2:1 不是质数,负数也不是,先判掉。
- 循环到 n 而不是
sqrt(n):能算对,但数据大时慢到超时。i * i <= n是正确的边界。 - 埃氏筛下标越界:
ok长度是n+1,循环要range(2, n+1),别漏最后一个。 - 把筛法里
if ok[i]写反:只在 i 是质数时筛它的倍数,写成「是合数才筛」就全错了。
9.11 刷题建议
- 用 Python 刷题比 C++ 快得多,直接刷 LeetCode 入门 100 题,优先数组/字符串/哈希/双指针。
- 与数据结构的关系:排序、查找、链表、树等数据结构,用 Python 各写一遍,能加深对算法与数据结构之间关系的理解。
- 每道题先想「暴力能不能过」,再想「能不能优化」,最后对照标准解看思路差距。
- 做不出来很正常:先看 5 分钟想不出,就看思路再自己写一遍。写不出来 ≠ 看不懂,看懂并重写一遍才是真的会。
📌 双语术语表(本讲)
| 中文 | English |
|---|---|
| 算法 | algorithm |
| 复杂度 | complexity |
| 二分查找 | binary search |
| 穷举 | brute force |
| 排序 | sorting |
| 冒泡排序 | bubble sort |
| 选择排序 | selection sort |
| 插入排序 | insertion sort |
| 归并排序 | merge sort |
| 快速排序 | quick sort |
| 哈希计数 | hash counting |
| 递归 | recursion |
| 分治 | divide and conquer |
| 双指针 | two pointers |
| 滑动窗口 | sliding window |
| 贪心 | greedy |
| 动态规划 | dynamic programming |
| 矩阵 | matrix |
| 埃氏筛 | Sieve of Eratosthenes |
💡 记忆口诀
- 二分:有序先二分,while 左右夹,边界 ±1 别忘啦。
- 冒泡:两两比大小,大的往上冒,末尾先排好。
- 选择:每轮挑最小,跟头一位交换掉。
- 插入:像打扑克,摸一张,往有序的牌里插。
- 归并:先拆两半排,合并用双指针。
- 快排:选个基准,小的站左大的站右,左右再递归。
- 双指针:一头一尾往中间夹,一趟搞定省一层循环。
- 滑动窗口:右端扩张,左端收缩,窗口滑过 O(n)。
- 贪心:每步吃最大一口,不回头——先确认对不对。
- 动态规划:记住算过的,后面由前面推。
- 递归:先想出口,再想怎么拆成更小的自己。
- 矩阵:外层管行内层管列,造空矩阵要用推导式。
⭐ 本讲考点清单
- 复杂度量级判断(嵌套循环 → O(n²),二分 → O(log n))
- 二分查找模板(有序序列,边界
mid ± 1) - 冒泡/选择/插入排序手写(不改原列表)
- 归并思想:合并两个有序列表
- 快速排序思想(基准划分 + 递归)
- 哈希计数:用字典统计频次
- 双指针:反转列表、滑动窗口最短子数组
- 贪心:找零钱(标准面额)、区间调度
- 递归:斐波那契、汉诺塔、最大公约数
- 动态规划:爬楼梯递推
- 二维列表遍历、矩阵转置、螺旋矩阵
- 质数判断(试到 sqrt(n))与埃氏筛
- 两数之和(穷举 + 进阶哈希)