Python算法优化策略
较难2让程序跑得快:Python算法优化小技巧
你有没有遇到过这种情况?写了一个程序,跑起来却像蜗牛一样慢,等得你直打哈欠。其实,就像做家务时先集中收拾杂物再扫地比边扫边收拾快得多一样,写程序也有“优化”的小窍门。通过一些简单的方法,比如用“备忘录”记住计算结果、挑选合适的数据结构,可以让你的程序瞬间“飞”起来。今天我们就来学习三个最实用的优化策略:减少重复计算、选择合适的数据结构和简化循环。每一个都配上生活里的例子,保证一看就懂。
策略1:减少重复计算——做个“小本本”记下结果
为什么需要记“备忘录”?
想象一下,你每天都要做同一道数学题(比如算斐波那契数列),每次算到一半又要重新开始,是不是很累?如果第一次算完后把答案写在“备忘录”上,下次遇到同样的题目直接翻本子,不就省下好多时间了?程序也是一样,有些函数会反复计算同一个值,比如求斐波那契数的递归函数:fib(40) 会先算 fib(39) 和 fib(38),而 fib(39) 又会算一次 fib(38)……这样一来,同一个 fib(38) 被算了不知多少遍,就像你每天重复做同一道题,效率低得可怜。
普通递归 vs 带备忘录的递归
下面两段代码,一个没记笔记,一个记了笔记,速度差别非常大。试试看:
import time
# 普通递归(没记笔记,超级慢)
def fib_slow(n):
"""计算第 n 个斐波那契数,普通递归"""
if n <= 1:
return n
return fib_slow(n-1) + fib_slow(n-2)
# 带备忘录的递归(用字典记下结果,很快)
def fib_fast(n, memo={}):
"""计算第 n 个斐波那契数,带备忘录"""
if n in memo: # 如果本子上已经记过,直接拿出来
return memo[n]
if n <= 1:
return n
memo[n] = fib_fast(n-1, memo) + fib_fast(n-2, memo) # 算完后写进本子
return memo[n]
# 测试第40个斐波那契数
start = time.time()
result_slow = fib_slow(40) # 注意:这个会跑好几秒,耐心等
print("普通递归耗时:", time.time() - start)
start = time.time()
result_fast = fib_fast(40)
print("带备忘录用时:", time.time() - start)
你可能会发现,普通递归可能要花好几秒,而带备忘录的几乎一瞬间就出来了!这是因为备忘录把每个只算一遍的结果存起来,避免了成指数倍的增长。
生活中的例子:记零花钱账本
假设你每周要统计这学期的零花钱总和,如果你每周都从头到尾把以前每周的零花钱加起来,那么第10周就要加10次,第20周加20次……太麻烦。正确做法是每次拿到零花钱后,在账本里记下累计总额,下次直接看账本。这就是“备忘录”的思路。
常见错误:忘记检查备忘录,或者字典写错键
- 错误1:在递归里每次都新建一个空字典,导致备忘录没用。应该像上面那样,把
memo作为参数传递,或者用@lru_cache装饰器(高阶技巧,这里先不展开)。 - 错误2:判断
if n in memo时,注意n的类型(这里是整数),别写成if memo[n]那样会报错。
策略2:选对数据结构——用“集合”而不是“列表”来查东西
为什么集合更快?
你有没有在超市里找过一件特定商品?如果你把整个超市的货架从头走到尾(像列表查找),肯定很慢;但如果你知道这件商品大概在哪个货区甚至能直接告诉收银员“有”还是“没有”(像集合查找),那就快多了。Python 的列表(list)查找元素时,是从头挨个看过去,直到找到为止,速度跟列表长度成正比;而集合(set)内部用了“哈希表”技术,能瞬间知道某个元素在不在里面,不管集合多大,查找时间几乎不变。
实战对比:从10000个数里找8888
import time
# 用列表查找
my_list = list(range(10000)) # 创建一个包含0~9999的列表
target = 8888 # 要查找的目标数字
start = time.time()
found_in_list = target in my_list # 列表全部看一遍
print("列表查找用时:", time.time() - start)
# 用集合查找
my_set = set(range(10000)) # 创建一个相同数字的集合
start = time.time()
found_in_set = target in my_set # 集合瞬间判断
print("集合查找用时:", time.time() - start)
通常集合比列表快成百上千倍!但集合也有代价:它占用的内存比列表大一些(因为要维护哈希表),这就是“空间换时间”。
什么时候该用列表,什么时候该用集合?
- 列表:需要按顺序保存元素、经常按位置访问(比如第二个、第三个数)、允许重复值时,用列表。
- 集合:只关心某个元素“有没有”,不关心顺序和重复,并且经常要查找时,用集合。
生活中的例子:点名表 vs 抽奖箱
老师点名时手里拿的名单(列表)是按学号顺序排好的,要查“小明在第几个”很方便,但想知道“小明在不在班上”还得从头看到尾。而抽奖箱里是一堆写着名字的纸条(集合),你只要往箱子里瞅一眼有没有某个名字就行,不用管顺序。
常见错误:混淆 in 在列表和集合中的用法
新手常犯的错误是:把一个元素很多的大列表做成 if x in big_list,结果程序卡死。应该先想想是否需要顺序,如果只需要判断存在性,换成集合就快多了。另外,集合里的元素必须是不可变的(比如整数、字符串),不能放列表或字典。
策略3:循环优化——别在循环里“重复造轮子”
循环里要减少哪些“重复劳动”?
循环体中的代码会执行很多遍,如果循环里有一些不依赖循环变量的计算,比如打开文件、计算固定值、调用同一个函数,那么每次循环都重复做这些事,浪费时间。正确的做法是把这些计算移到循环外面,只做一次。
例子:算1到100的平方和
最简单的平方和:1^2 + 2^2 + ... + 100^2。通常写法和优化写法:
# 方法1:直接循环累加(这里 i*i 很简单,但展示思路)
total = 0
for i in range(1, 101):
total += i * i # 每次循环都算一次乘法,但 i 在变,所以没法提前
print("平方和(循环内计算):", total)
# 方法2:先把平方都算好放进列表,再求和(更清晰,且后续可复用)
squares = [i * i for i in range(1, 101)] # 列表推导式,一次性算好所有平方
total = sum(squares) # 用内置 sum 求和,更快
print("平方和(列表推导式):", total)
虽然在这个简单例子里两种方法速度差不多,但方法2的代码更简洁,而且如果你以后还需要其他平方值,就不用在循环里重复算了。
真正需要优化的场景:循环里反复打开同一个文件、反复调用一个耗时的函数、反复计算一个不随循环变化的表达式。比如:
# 不优化:每次循环都打开文件(很慢!)
total = 0
for i in range(1000):
f = open("data.txt") # 打开文件1000次
lines = f.readlines()
total += len(lines)
f.close()
# 优化:只在循环外打开一次
f = open("data.txt")
lines = f.readlines()
total = len(lines) * 1000 # 如果每次都是读同样内容
f.close()
生活中的例子:排队买奶茶
你如果每次点单前都要先问一遍“价钱多少?”,然后才点,等于重复了同一个无意义的问题。更聪明的做法是先问好价钱,记在心里,然后按顺序点单。循环优化就是把那些“问价钱”的动作放到循环外面。
进一步优化:利用列表推导式和内置函数
列表推导式([表达式 for 变量 in 可迭代对象])和 sum、map、filter 等内置函数,通常比手写的 for 循环快,因为它们底层用 C 语言实现。例如,用 map 求平方和:
nums = range(1, 101)
squares = map(lambda x: x**2, nums) # map 返回一个迭代器
total = sum(squares)
常见错误:在循环体内做不必要的大计算
- 错误1:在循环里每次重新计算一个固定的列表长度。
for i in range(len(my_list))其实每次循环都要调用len,但len很快,这点可以忽略。但更严重的是,如果循环里调用了time.time()几百次,那就不值得。 - 错误2:循环里创建大量临时对象,比如每次循环都拼接字符串
s = s + "a",应该用列表存再join。
完整可运行示例:综合三个优化策略
下面这个程序模拟了一个“学生成绩查询系统”:有一个包含10000个学生学号的列表,要查询某学生是否存在,并计算所有学生的总成绩。我们用集合优化查找,用备忘录优化重复计算的成绩(比如有些学生成绩相同),并移出循环中不必要的计算。
import time
# 准备数据:假设有10000个学生学号(0~9999)
student_ids = list(range(10000)) # 学生学号列表
score_dict = {} # 学号 -> 成绩的字典,成绩随机生成(这里简化)
for i in student_ids:
score_dict[i] = (i * 7) % 100 # 模拟成绩:0~99之间
# 需要多次查询的学号列表
query_ids = [1234, 5678, 9999, 1234, 5678, 1111] # 有的重复
# ----- 优化1:用集合快速判断学号是否存在 -----
id_set = set(student_ids) # 把列表转成集合,用于快速查找
# ----- 优化2:用备忘录记录已经查过的成绩 -----
memo_score = {} # 存储学号的成绩,避免重复计算
start = time.time()
total_score = 0
for qid in query_ids: # 遍历每个要查询的学号
if qid in id_set: # 集合查找,瞬间判断
if qid not in memo_score: # 如果还没记过这个学号的成绩
# 模拟一个耗时的计算(比如从数据库拿成绩,这里用简单乘法代替)
score = (qid * 7) % 100
memo_score[qid] = score # 记入备忘录
total_score += memo_score[qid]
else:
print(f"学号 {qid} 不存在")
print("总成绩(含优化):", total_score)
print("耗时:", time.time() - start)
这个例子中,集合 id_set 让 if qid in id_set 几乎不花时间;备忘录 memo_score 记录了已经计算过的成绩,对重复查询的学号(比如1234出现两次)只用算一次。整个查找和计算一气呵成。
常见错误总结
- 忘记定义备忘录:递归函数没有传递 memo 参数,或者 memo 每次递归都被重置。解决方案:使用默认参数
memo={}或者@lru_cache。 - 混用列表和集合:明明只需要去重或判断存在,却用列表,导致程序变慢。记住:集合查找是 O(1),列表查找是 O(n)。
- 循环内重复计算固定值:比如
for i in range(len(expensive_func())),expensive_func每次循环都调一次,应该改成result = expensive_func(); for i in range(len(result))。 - 忽略内存占用:用集合和备忘录虽然快,但会多用内存。如果数据量极大(比如上亿个),需要考虑是否值得。
相关指引:还想学更多?
- 时间复杂度:了解大O符号(O(1)、O(n)、O(n²)),能帮你一眼看出程序快慢。
- 空间换时间:缓存(缓存)、动态规划(动态规划)思想,都是备忘录的延伸。
- Python内置优化:
map、filter、reduce、列表推导式、生成器(yield)等,能让代码更高效。 - 算法可视化:可以去“VisuAlgo”网站看看斐波那契递归树的图,直观理解重复计算有多可怕。
现在,你可以试着用这些技巧优化自己的程序了——下一次写代码时,想想是不是有重复计算?能不能换数据结构?循环里有没有多余的动作?你也能写出飞快的程序!
例题精讲
以下哪种方法可以显著减少斐波那契数列递归计算中的重复子问题?
在Python中,使用列表作为字典的键是允许的,因为列表是可变的。
下面函数使用记忆化递归计算斐波那契数列,填空处应填入什么?
import functools
___
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)
print(fib(10))在一个需要频繁根据用户ID查找用户信息的程序中,以下哪种数据结构最适合实现快速查找?
使用生成器表达式代替列表推导式可以降低内存占用,这是算法优化中常见的策略。