倍增法的应用:快速幂和最近公共祖先
较难2倍增法:让你“跳着走”的聪明方法——快速幂和最近公共祖先
想象一下,你要从一楼爬到十楼,你会一层一层爬,还是每两阶跨一步?如果楼梯上有个“跳跃按钮”,按一下就能直接跳到第2^k层,那该多省力!倍增法就是这种“跳着走”的编程思想:它先把“跳2^0步、2^1步、2^2步……”的路线提前算好(就像画好一张跳跃地图),然后每次跳跃时,根据当前需要,从这张地图里挑选合适的跳法,一口气就能到达目的地。
倍增法最经典的两个应用是快速幂(快速计算一个数的很大次方)和最近公共祖先(LCA)(在一棵大树里找两个节点的共同祖先)。今天我们就用生活中的例子和完整的代码,把它们彻底搞懂。
一、快速幂:用“翻倍”代替“连乘”
1. 为什么需要快速幂?
比如你想知道2的10次方是多少。最笨的方法是:2×2×2×2×2×2×2×2×2×2,连乘10次。如果算2的100次方呢?连乘100次,不仅慢,还容易按错计算器按钮。更可怕的是,在编程里用循环连乘,时间复杂度是O(N),当N很大时(比如10^9),程序会卡死。
快速幂利用二进制和倍增思想,把次数N拆成若干个2的幂之和,把乘法次数从N降低到log₂(N)。比如计算2^10,10的二进制是1010,也就是2^10 = 2^8 × 2^2,我们只需要先算出2^1、2^2、2^4、2^8这4个值(通过不断平方得到),然后挑出2^8和2^2相乘,只做了4次平方和1次乘法,一共5次运算,大大减少了工作量。
2. 生活例子:零花钱“翻倍”计划
你妈妈给你零花钱,第一天给你1元,第二天给你2元,第三天给你4元……每天都是前一天的两倍。你想知道第10天妈妈一共给了多少钱,但不需要把前9天的金额都算出来,只需要知道第10天的金额是2的9次方(因为第1天是2^0=1元)。如果你用快速幂的思想,先算2^1=2,再平方得2^2=4,再平方得2^4=16,再平方得2^8=256,然后凑出2^9 = 2^8 × 2^1 = 256×2=512元。这样只算了4次平方和1次乘法,比连乘9次快多了。
3. 快速幂的核心步骤(取模版)
在实际编程中,数字会非常大(比如2^1000),计算机存不下,所以通常要对一个数取模(比如模1000)。取模不影响结果,而且可以防止溢出。
Python代码(每行变量定义都附中文注释):
def quick_pow(base, exponent, mod):
"""
快速幂取模:计算 (base^exponent) % mod
base: 底数
exponent: 指数
mod: 模数
"""
result = 1 # 最终结果,初始化为1
base = base % mod # 先对底数取模,防止一开始就太大
while exponent > 0: # 当指数还大于0时,继续处理
# 检查 exponent 的最低位(二进制最后一位)是不是1
if exponent & 1: # 如果最低位是1,说明需要乘上当前的base
result = (result * base) % mod # 乘上去,并取模
# 把 base 平方,准备下一次跳跃
base = (base * base) % mod # base -> base^2 -> base^4 -> base^8 …
# 把 exponent 右移一位(相当于除以2,丢掉最低位)
exponent >>= 1 # 相当于 exponent = exponent // 2
return result
# 测试一下:计算 2^10 对 1000 取模
print("2^10 mod 1000 =", quick_pow(2, 10, 1000)) # 输出 24,因为 2^10=1024,1024%1000=24
4. 常见错误和需要注意的地方
- 忘记取模:如果你不取模,结果很可能变成天文数字,Python虽然支持大整数,但会占用大量内存和计算时间。所以一定要在每次乘法后取模。
- 位运算混淆:
exponent & 1是判断最低位是否为1,exponent >>= 1是右移一位。不要写成exponent //= 2虽然效果一样,但位运算更快,也更容易理解。 - 底数先取模:如果底数本身很大(比如100000),先取模一次可以保证后面的平方不会更大。
- 指数为0的情况:任何数的0次方等于1(0^0特殊情况,通常认为是1)。我们函数里
while exponent > 0,当 exponent=0 时直接返回 result=1,正确的。
5. 完整示例(带多种测试)
# 快速幂完整测试
def quick_pow(base, exponent, mod):
result = 1
base = base % mod
while exponent > 0:
if exponent & 1:
result = (result * base) % mod
base = (base * base) % mod
exponent >>= 1
return result
# 计算几个例子
print("2^10 mod 1000 =", quick_pow(2, 10, 1000)) # 24
print("3^5 mod 100 =", quick_pow(3, 5, 100)) # 3^5=243, 243%100=43
print("5^0 mod 7 =", quick_pow(5, 0, 7)) # 1
print("100^50 mod 9999 =", quick_pow(100, 50, 9999)) # 试一试
二、最近公共祖先(LCA):在家族树里找共同的“老爷爷”
1. 什么是树和祖先?
树就像你的家族谱:每个人(节点)都有父亲(父节点),最上面的那个没有父亲的人叫做根节点。比如:
0 (老祖宗)
/ \
1 2
/ \ \
3 4 5
/
6
节点0的子女是1和2;节点1的子女是3和4;节点2的子女是5;节点5的子女是6。
祖先:从某个节点向上一直走到根节点,路上经过的所有人都是它的祖先。比如节点6的祖先:6 → 5 → 2 → 0。
最近公共祖先(LCA):两个节点共同的祖先中,离它们最近的那个。比如节点3和节点6:3的祖先是3,1,0;6的祖先是6,5,2,0;共同的祖先是0,所以LCA是0。节点3和节点4:共同的祖先是1(再往上0也是,但1更近),所以LCA是1。
2. 生活例子:同学聚会找共同亲戚
你和你的同桌都想参加家族聚会,但你们家是两棵不同的树?不对,你们其实来自同一棵家族树!你们各自的名字是节点,要找到你们辈分最近的那个共同长辈。如果一个个往上问(“你爸爸是谁?你爷爷是谁?”),太慢了,万一家族有1000代人,要问1000次。用倍增法,你们可以先跳到同一辈分(深度一样),然后一起往上一级级大跳(2^k步),直到发现再跳就会跳过共同祖先。这样只需要问 log₂(深度) 次就能找到。
3. 预处理:生成“跳跃表” up
我们需要提前知道每个节点的第2^k级祖先(即向上跳2^k步到达的节点)。比如节点6的第0级祖先(2^0=1步)是节点5;第1级祖先(2^1=2步)是节点2(6→5→2);第2级祖先(2^2=4步)是节点0(6→5→2→0,第4步正好到0);第3级祖先(8步)不存在,设为-1。
用一个二维数组 up[node][k] 来存储这些信息,k 从0到 MAX_LOG-1(MAX_LOG 要足够大,比如 log₂(节点数)+1)。
计算规则:
up[node][0]= 该节点的直接父节点(根节点的父节点设为-1)up[node][k]=up[ up[node][k-1] ][k-1]
意思:先向上跳2^(k-1)步,再从这个新位置跳2^(k-1)步,合起来就是2^k步。
同时我们还需要每个节点的深度(距离根节点的步数)。根节点深度为0,其子节点深度为1,以此类推。可以用广度优先搜索(BFS)或深度优先搜索(DFS)计算。
4. LCA查询步骤
假设要查询节点 x 和 y 的LCA:
- 对齐深度:如果
x比y深,就让x向上跳到和y一样深(使用倍增法,从大到小尝试跳跃)。反过来也一样。 - 一起向上跳:从最大的
k开始,尝试让x和y同时往上跳2^k步,如果跳完后它们的祖先不相同,就真的跳过去(因为这说明它们还没有到达LCA,而且LCA还在更上面)。如果跳完后祖先相同,说明可能跳过头了,或者已经到LCA了,就不跳。 - 最后一步:经过步骤2,
x和y会成为LCA的两个儿子(即它们的父节点是LCA),所以返回up[x][0](或up[y][0])即可。
5. 完整的Python实现(可运行)
我们以刚才那棵树为例(共7个节点,编号0~6,根节点0)演示。
# 定义树的父节点数组:parent[node] 表示 node 的直接父节点,根节点0的父节点设为-1
parent = [-1, 0, 0, 1, 1, 2, 2] # 节点1、2的父亲是0;3、4的父亲是1;5、6的父亲是2
# 节点总数
N = 7
# 计算log2(N)向上取整,作为跳跃的最大指数
import math
MAX_LOG = int(math.log2(N)) + 1 # 因为2^3=8≥7,所以MAX_LOG=3+1=4更保险,也可以直接设为3
# 1. 构建倍增表 up[node][k] 和深度数组 depth[node]
up = [[-1] * MAX_LOG for _ in range(N)] # up[node][k]:node的第2^k级祖先
depth = [0] * N # 每个节点的深度
# 初始化 up[node][0] = parent[node]
for node in range(N):
up[node][0] = parent[node]
# 用BFS计算每个节点的深度(从根节点0开始)
from collections import deque
queue = deque()
queue.append(0) # 根节点入队
depth[0] = 0 # 根节点深度为0
order = [0] # 记录BFS遍历顺序(也可以先知道父节点再填子节点)
# 构建树的邻接表(用于BFS)
children = [[] for _ in range(N)]
for node in range(1, N):
p = parent[node]
if p != -1:
children[p].append(node)
while queue:
cur = queue.popleft()
for child in children[cur]:
depth[child] = depth[cur] + 1 # 子节点深度 = 父节点深度 + 1
queue.append(child)
# 计算 up 表中 k>=1 的值(需要按深度从小到大的顺序填充)
# 由于子节点的深度大于父节点,我们可以直接按节点编号顺序,但最好按照BFS顺序或按深度排序
# 简单起见,我们按节点编号从小到大填充(假设父节点编号小于子节点时可用,但我们树中父节点确实小于子节点)
for k in range(1, MAX_LOG):
for node in range(N):
if up[node][k-1] != -1: # 如果跳2^(k-1)步后位置存在
up[node][k] = up[ up[node][k-1] ][k-1] # 再跳2^(k-1)步
else:
up[node][k] = -1 # 否则不存在
# 2. 实现LCA函数
def lca(x, y):
# 步骤1:让深度较深的节点先跳到和另一个节点同一深度
if depth[x] < depth[y]:
x, y = y, x # 确保x是较深的节点
# 把x往上跳 depth[x] - depth[y] 步
diff = depth[x] - depth[y]
k = 0
while diff:
if diff & 1: # 如果diff的二进制最低位是1,就跳2^k步
x = up[x][k]
diff >>= 1
k += 1
# 现在 x 和 y 在同一深度
if x == y:
return x # 如果已经是同一个节点,那它本身就是LCA
# 步骤2:从大到小尝试两个节点一起往上跳
for k in range(MAX_LOG-1, -1, -1):
if up[x][k] != up[y][k]: # 如果跳完后祖先不同,说明还没到LCA,可以跳
x = up[x][k]
y = up[y][k]
# 循环结束后,x和y的父亲就是LCA
return up[x][0]
# 3. 测试几个查询
print("节点3和节点5的LCA是:", lca(3, 5)) # 预期0
print("节点3和节点4的LCA是:", lca(3, 4)) # 预期1
print("节点5和节点6的LCA是:", lca(5, 6)) # 预期2
print("节点0和节点5的LCA是:", lca(0, 5)) # 预期0(一个节点是根节点时,LCA就是根)
运行结果:
节点3和节点5的LCA是: 0
节点3和节点4的LCA是: 1
节点5和节点6的LCA是: 2
节点0和节点5的LCA是: 0
6. 常见错误和注意事项
- 忘记初始化深度:LCA必须依赖深度对齐,如果没有深度数组,直接跳跃会出错。
- 倍增表大小不够:
MAX_LOG必须至少为ceil(log2(N)),否则向上跳可能找不到祖先。建议设置成int(math.log2(N)) + 1。 - 根节点的祖先:根节点的父节点和所有
up[root][k]都应该设为 -1,代码中已经处理。 - 位运算深度对齐:上面代码中使用 while diff 循环,每次检测最低位,这是一种方法。也可以使用 for 循环从高位到低位,就像跳跃一样。两种都可以,注意理解。
- 子节点按顺序填充 up:填充
up[node][k]时,需要up[up[node][k-1]][k-1],这要求up[node][k-1]已经计算好。通常按节点编号顺序(父节点编号小于子节点)或者按BFS顺序都可以。如果不放心,可以先用queue记录遍历顺序,按顺序填充。
三、总结:倍增法的“超级跳跃”思维
无论是快速幂还是LCA,核心都是把大任务拆成2的幂次小任务,提前准备好这些“跳跃点”,然后在需要的时候,利用二进制位决定是否跳跃。生活中还有很多地方可以用到倍增思想,比如:
- 查询区间最大值(RMQ):把数组分成2的幂次长度的区间,预处理最大值,然后快速回答任意区间。
- 在链表或数组里快速跳跃:比如你要从第i个位置跳到一个很远的距离,也可以先用倍增预处理每个位置跳2^k步后的位置。
- 找循环节:在无限循环的数列里,用倍增法可以快速找到循环起点。
如果你已经掌握了倍增法,那么接下来可以学习 ST表(Sparse Table)(一种基于倍增的区间查询结构)、树剖分(用倍增辅助快速查询路径信息)等。倍增法就像编程世界里的一双“飞毛腿”,让你轻松跨越数据集和树结构的广阔天地。
现在,不妨自己动手试试: 自己构造一棵家庭树(比如用字典表示父子关系),写出完整的LCA查询程序,然后测试几个组合。如果遇到疑问,欢迎回到这里复习哦!
例题精讲
快速幂算法计算 a^b mod m 时,主要利用了 b 的哪种表示来降低时间复杂度?
在使用倍增法求解树上最近公共祖先(LCA)问题时,预处理阶段需要计算每个节点的深度以及每个节点向上 2^k 步的祖先节点(k=0,1,...,log2(n))。
设一棵树有 n 个节点,根节点为 1,使用倍增法求 LCA 时,通常将倍增数组的第二个维度大小(即 k 的最大值)设为多少?
下面是快速幂的迭代实现,请补全空缺处的代码。
def fast_pow(a, b, m):
res = 1
while b > 0:
if ___(1)___:
res = (res * a) % m
a = (a * a) % m
___(2)___
return res下面是倍增法求 LCA 的查询函数,请补全空缺处的代码。假设已预处理 depth 数组和 up[v][k] 表示节点 v 向上 2^k 步的祖先(其中 up[v][0] 为 v 的父节点),LOG = ⌈log2(n)⌉。
def lca(u, v):
if depth[u] < depth[v]:
u, v = v, u
# 将 u 提升到与 v 同一深度
diff = depth[u] - depth[v]
for i in range(LOG):
if diff & (1 << i):
u = ___(1)___
if u == v:
return u
for i in range(LOG-1, -1, -1):
if up[u][i] != up[v][i]:
u = ___(2)___
v = ___(3)___
return up[u][0]