CC++ & Algorithm

Python算法优化策略

较难2
语言版本:C++Python
概述:通过几个简单的方法,比如用“备忘录”记住结果、选对数据结构,可以让程序跑得更快。

让程序跑得快: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 可迭代对象])和 summapfilter 等内置函数,通常比手写的 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_setif qid in id_set 几乎不花时间;备忘录 memo_score 记录了已经计算过的成绩,对重复查询的学号(比如1234出现两次)只用算一次。整个查找和计算一气呵成。


常见错误总结

  1. 忘记定义备忘录:递归函数没有传递 memo 参数,或者 memo 每次递归都被重置。解决方案:使用默认参数 memo={} 或者 @lru_cache
  2. 混用列表和集合:明明只需要去重或判断存在,却用列表,导致程序变慢。记住:集合查找是 O(1),列表查找是 O(n)。
  3. 循环内重复计算固定值:比如 for i in range(len(expensive_func()))expensive_func 每次循环都调一次,应该改成 result = expensive_func(); for i in range(len(result))
  4. 忽略内存占用:用集合和备忘录虽然快,但会多用内存。如果数据量极大(比如上亿个),需要考虑是否值得。

相关指引:还想学更多?

  • 时间复杂度:了解大O符号(O(1)、O(n)、O(n²)),能帮你一眼看出程序快慢。
  • 空间换时间:缓存(缓存)、动态规划(动态规划)思想,都是备忘录的延伸。
  • Python内置优化mapfilterreduce、列表推导式、生成器(yield)等,能让代码更高效。
  • 算法可视化:可以去“VisuAlgo”网站看看斐波那契递归树的图,直观理解重复计算有多可怕。

现在,你可以试着用这些技巧优化自己的程序了——下一次写代码时,想想是不是有重复计算?能不能换数据结构?循环里有没有多余的动作?你也能写出飞快的程序!

例题精讲

1单选题

以下哪种方法可以显著减少斐波那契数列递归计算中的重复子问题?

A使用列表存储计算结果
B使用字典(备忘录)存储计算结果
C使用集合存储中间值
D使用元组存储参数
2判断题

在Python中,使用列表作为字典的键是允许的,因为列表是可变的。

3填空题
下面函数使用记忆化递归计算斐波那契数列,填空处应填入什么?

import functools
___
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

print(fib(10))
4单选题

在一个需要频繁根据用户ID查找用户信息的程序中,以下哪种数据结构最适合实现快速查找?

A列表
B字典
C元组
D集合
5判断题

使用生成器表达式代替列表推导式可以降低内存占用,这是算法优化中常见的策略。