☰
倍增算法详解:从二进制拆分到ST表与LCA实战
2026/10/6 9:24:19 网站建设 项目流程

"倍增"这词第一次听到的时候,不少人心里会咯噔一下,觉得这是不是某个高深的数学公式。实际上它就是我们平时说的"步子迈大一点",只不过它是按2的幂来迈。你不需要一步步往上爬,而是手里提前备好1格、2格、4格、8格的跳板,遇到要走多少步的问题,拆成几个二进制块,一蹦就到位。

这套思想在算法竞赛、面试题、甚至工业级组件里出现频率极高。快速幂、ST表、最近公共祖先、跳表,背后都是同一个底子。可以说,把倍增吃透,等于一次性解锁了至少五六个高频算法题型的核心思路。这篇文章不搞玄乎推导,用大白话把来龙去脉讲清楚,代码、边界、坑点都给你拆开,看完能自己手写那种。

1. 倍增算法的核心逻辑与设计思路

1.1 一切源于二进制拆分

先说一个最基础的数学事实:任何正整数都能写成若干个2的幂之和,而且写法唯一。比如13这个数,二进制是1101,展开就是8+4+1,也就是2³+2²+2⁰。再比如27,二进制11011,展开是16+8+2+1,也就是2⁴+2³+2¹+2⁰。

这句话看起来平平无奇,但它是倍增算法的命根子。因为"能用二进制拆"就意味着,当我们需要执行一段长度为x的连续操作时,不需要把x从头到尾一步步来一遍。只要预先准备好长度为1、2、4、8、16……的操作结果,就能把这些块拼接起来,快速得到最终答案。

这里有一个容易忽略的细节:二进制的每个位上要么是0要么是1,所以每个"2的幂块"要么用一次,要么不用。不存在"用半块"的情况。这一点保证了算法在拼接过程中不会出现拆分不干净的问题。

1.2 预处理 + 跳跃:把线性变对数

倍增算法的标准形态,通常长这样:先预处理一张表,记录从每个位置出发,走2^j步之后的状态。然后面对任意一个步数x,把x按二进制拆开,用若干个"2的幂步长"组合完成目标。

举个例子,如果你想从某个节点往上跳13次,13拆成8+4+1,你只需要三次跳跃:先跳8步,再跳4步,再跳1步。三次操作完事,而不是循环13次。

这里的核心转变是:把"线性推进"变成了"跳跃式推进"。单次查询的复杂度从O(x)降到O(log x)。x哪怕达到10⁹量级,二进制位也就30位左右,30次操作就能搞定,性能提升是指数级的。

用大白话打比方:你在玩跳棋,普通棋子一格一格走,现在给你装备了1格、2格、4格、8格的跳板。走13格你直接跳8+4+1,而不是走13次。数据结构语境里,这些跳板就是预处理出来的表。

1.3 复杂度真相

  • 时间:预处理一般O(n log n),单次查询O(log n)
  • 空间:额外O(n log n)

对比暴力方法,比如树上单次查询LCA的朴素做法最坏情况下要O(n),一次查询还好,但如果有10⁵次查询,暴力就跑不动了。倍增的意义不在于单次有多快,而在于把"预处理成本"均摊到大量查询上,换取每个查询对数级的速度,这在竞赛和工程里都非常划算。

很多人第一次学倍增会陷入一个误区,以为它是某种记忆化搜索。其实记忆化是"算过的不重复算",倍增是"提前把所有2的幂步长结果都算好,查询时拼凑"。两者是不同层面的优化思想,不要混在一起。

2. 从快速幂入手:理解倍增的第一现场

2.1 朴素乘方为什么慢

如果让你算2¹⁰,你自然会写一个循环乘10次。如果让你算2^{10⁹},还要对某个大质数取模,循环10⁹次就非常痛苦了。

快速幂的思路非常直白:把指数按二进制拆开。比如求2¹⁰,指数10=8+2,所以2¹⁰ = 2⁸ × 2²。问题就变成,怎么不循环10次拿到2⁸和2²这两个数?

答案是:不断让底数自平方。底数a每次平方,得到的序列是a¹、a²、a⁴、a⁸、a¹⁶……每一次平方,指数翻倍。这本身就是"倍增"的过程。遍历指数n的二进制位,遇到某一位是1,就把对应的那一个"平方结果"乘到最终答案里去。

2.2 核心代码实现

C++写法:

long long fast_pow(long long a, long long n, long long mod) { long long res = 1; while (n > 0) { if (n & 1) { res = res * a % mod; } a = a * a % mod; n >>= 1; } return res; }

Python写法:

def fast_pow(a, n, mod): res = 1 while n: if n & 1: res = res * a % mod a = a * a % mod n >>= 1 return res

初学者最容易卡住的地方是:为什么a每次都自平方?因为指数向左移动一位,意味着当前需要追踪的底数幂次翻倍。n的二进制位里如果当前最低位是1,说明这一位对应的2^k块需要用到,就把当前自平方得到的a累乘进res。

这里有一个实用小技巧:如果mod设为0或不传,底数和指数又可能很大,中间乘法很容易溢出。C++里建议直接用long long承接乘法结果,如果模数接近10⁹级别,乘法两边都可能接近10⁹,乘积就超出long long范围了。这种情况要改用快速乘或者用__int128临时过渡。

快速幂看起来简单,但它完整演示了倍增的三大步骤:二进制拆分、步长倍增预处理、查询时按需组合。后面所有倍增算法,本质都逃不出这个框架。

3. ST表:解决区间最值查询的高效方案

3.1 RMQ问题是什么

给定一个静态数组,反复询问某个区间[l, r]里的最大值或最小值。数组只读不修改,要求快速响应大量查询。

暴力做法很好理解,每次查询从l遍历到r,复杂度O(len)。如果数组长度10⁵,查询次数10⁵,最坏情况下就变成了10¹⁰次操作,直接卡死。

ST表就是为"静态区间最值查询"这种场景量身定做的。它只用O(n log n)预处理,就能做到每次查询O(1)。

ST表的核心定义是:st[i][j]表示从下标i开始,长度为2^j的区间里的最大值。

初始状态j=0,区间长度为1,st[i][0]就是数组本身arr[i]。

递推关系是:st[i][j] = max(st[i][j-1], st[i+2^(j-1)][j-1])。

为什么递推式长这样?因为长度为2^j的区间可以拆成两半,每一半长度恰好是2^(j-1)。前半段从i开始,后半段从i+2^(j-1)开始。两个子区间取最大值,就是整个区间的最大值。这个递推本身是标准的倍增思路,步长从1变2、2变4、4变8,不断翻倍。

3.2 查询时的重叠覆盖

查询[l, r],区间长度len = r - l + 1。我们取k = floor(log2(len)),然后求:

max(st[l][k], st[r - 2^k + 1][k])

这里让不少初学者困惑的是:这两个区间明明可能重叠,为什么还能保证答案正确?

因为最大值操作是幂等的,两个区间有重叠部分也不影响取max。左区间覆盖从l开始的长2^k段,右区间覆盖从r-2^k+1结束的长2^k段。2^k不超过len,因此这两段一定能覆盖整个[l, r]。重叠是允许的,且重叠区域只是被重复考虑了而已。所以用max、min这类操作时,ST表可以O(1)查询。如果是求和这类不支持重复计算的操作,就不能这样直接重叠覆盖。

另外需要注意,这里的k必须是整数。直接用log2()函数计算浮点数再取整,容易因为精度问题拿错值。比如log2(8)在某些环境下可能因为浮点误差返回2.9999999,向下取整成2,那就错了。正确做法是预计算一个整数log数组lg[i],lg[1] = 0,lg[i] = lg[i/2] + 1。这样可以严格保证lg[len]等于floor(log2(len))。

3.3 参考代码

const int N = 100005; int a[N]; int st[N][18]; int lg[N]; void build(int n) { lg[1] = 0; for (int i = 2; i <= n; ++i) { lg[i] = lg[i / 2] + 1; } for (int i = 1; i <= n; ++i) { st[i][0] = a[i]; } // 枚举步长倍增 for (int j = 1; (1 << j) <= n; ++j) { for (int i = 1; i + (1 << j) - 1 <= n; ++i) { st[i][j] = max(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]); } } } int query(int l, int r) { int len = r - l + 1; int k = lg[len]; return max(st[l][k], st[r - (1 << k) + 1][k]); }

代码里的第二层循环,i的右边界是n - 2^j + 1。这个下标处理是ST表最容易写错的地方,一不留神就越界。写代码时推荐先算一下边界公式再动手,别凭感觉。

3.4 ST表 vs 线段树

很多人在学会线段树之后会想,那还要ST表干什么?这里做一个对比:

维度ST表线段树
预处理复杂度O(n log n)O(n)建树
单次查询复杂度O(1)O(log n)
单点更新不支持,需重建O(log n)
实现难度简单中等偏上
适用场景静态数组大量区间查询动态修改加查询

所以ST表的定位很明确:数组完全静态、查询特别高频的场景。它用固定的预处理成本换取每次查询的极致速度。线段树则适合要频繁修改数组的场景。按照实际需求选择,不要为了炫技硬选。

4. 树上倍增求LCA:从0到1的完整实战

4.1 LCA是什么,朴素做法为什么慢

LCA全称Lowest Common Ancestor,最近公共祖先。给定一棵根确定的树,对任意两个节点u和v,找到离它们最近的、同时是两者祖先的节点。比如树里节点u在左子树很深的地方,节点v在右子树,它们的LCA往往是某个中间层的根节点。

朴素做法:从u一路向上标记到根,再从v向上走,遇到的第一个被标记过的节点就是LCA。最坏情况下,比如一条链,每次查询要向上走O(n)步,查询一多直接爆炸。

倍增法LCA的思路就是:每次让你少走几步,用预处理好的"2的幂步长"来完成深度对齐和共同攀爬。

4.2 预处理阶段:搞清depth和up表

先通过一次DFS或BFS遍历整棵树,记录两个东西:

第一个是depth[u],节点u的深度。根节点深度设为0或1都可以,但全代码要保持一致。

第二个是up表,up[u][j]表示从u向上跳2^j步到达的节点。

初始j=0时,up[u][0]就是u的父节点。根节点的父节点可以设成自己,这样往上跳再多也不会跳到不存在的空指针,能大大简化边界处理。

关键在于递推:up[u][j] = up[up[u][j-1]][j-1]

这个递推的含义是:从u跳2^(j-1)步到up[u][j-1],然后再从这个节点跳2^(j-1)步,总共跳了2^(j-1) + 2^(j-1) = 2^j步,到达up[u][j]。这里最底层的逻辑依然是倍增:步长翻倍,信息逐层构建。

预处理阶段的DFS代码框架:

void dfs(int u, int parent) { up[u][0] = parent; for (int j = 1; j < LOG; ++j) { up[u][j] = up[up[u][j - 1]][j - 1]; } for (int v : adj[u]) { if (v == parent) continue; depth[v] = depth[u] + 1; dfs(v, u); } }

如果树的规模很大,比如10⁶个节点,递归DFS容易爆栈,建议改成显式栈的迭代写法。C++竞赛环境可以加大栈空间,但工程代码里尽量用迭代,这个后面会说。

4.3 查询LCA的完整流程

查询LCA(u, v),分三步走:

第一步,深度对齐。如果depth[u] < depth[v],就交换u和v,保证u更深。然后算出差值diff = depth[u] - depth[v],把u向上跳diff步,让u和v处于同一深度。这里就用到二进制拆分:diff的二进制位里哪些位是1,就跳到对应的2^k步。

参考写法:

int diff = depth[u] - depth[v]; for (int j = 0; j < LOG; ++j) { if (diff >> j & 1) { u = up[u][j]; } }

如果对齐后u等于v,说明v本来就是u的祖先,直接返回u,LCA查询结束。

第二步,从高位往低位枚举跳跃。初始从大到小,看up[u][j]和up[v][j]是否不同。如果不同,说明这两个节点在第2^j步内的祖先有分叉,那就把u和v同时向上跳2^j步,缩小它们与LCA的距离。

这里有个极易踩的坑:为什么从高位往低位?因为高位跳跃的影响大,先跳大块再跳小块,能保证最终停在LCA的下方。如果从低位往高位跳,你可能会跳过LCA或者跳到公共祖先之上,最后得不到正确结果。

第三步,最终u和v是LCA的两个子节点,返回up[u][0]即可。

查询函数:

int lca(int u, int v) { if (depth[u] < depth[v]) swap(u, v); int diff = depth[u] - depth[v]; for (int j = 0; j < LOG; ++j) { if (diff >> j & 1) { u = up[u][j]; } } if (u == v) return u; for (int j = LOG - 1; j >= 0; --j) { if (up[u][j] != up[v][j]) { u = up[u][j]; v = up[v][j]; } } return up[u][0]; }

这里有一个细节可以留意:在第二步高位枚举时,条件判断只用"up[u][j] != up[v][j]",没有判断深度。因为此时u和v已经深度相同,同时跳2^j步后,它们的深度仍然相同,所以不会出现一个跳到上面、一个还在下面的情况。这个性质保证了对齐过程的有效性。

如果树有可能是一个森林(多棵独立的树),查询前需要先判断两个节点是否在同一棵子树里,通过并查集或者预处理时给每棵树打不同编号都能实现。

4.4 关于LOG大小的确定

LOG要取多大,直接决定up数组能不能装下。一般取ceil(log2(n)) + 1。n是节点数。2^18 = 262144,2^19 = 524288,如果n是10⁵级别,LOG取18或19就够。稳妥起见,我会把LOG取到20甚至21,多出来的空间用来防止边界溢出。

贪省空间的危害是:当树退化成一条链,最深的深度接近n,如果LOG太小,up[u][j]在遍历时访问到越界下标,导致结果错乱。建议养成习惯,额外+2。

4.5 换根树的LCA变形

有些场景不是固定根,而是每次动态换根。这时候不能直接套用上述DFS的父节点关系。常见的处理技巧是:仍然以某个固定根做预处理,然后通过深度和祖先关系判断。比如当前根是r,要查u和v的LCA,可以拆成三对固定根LCA再取深度最大的那个。这个技巧叫"三点LCA",在很多树上问题里非常实用。

5. 倍增思想的更多应用场景

5.1 跳表:链表上的倍增

跳表就是一个典型的链表倍增结构。普通链表找某个元素只能从头遍历,复杂度O(n)。跳表在原始链表之上增加多层索引,每一层索引的节点数量是下一层的一半,相当于层数越高,步长越大。带了一层一层向下逼近的过程,本质就是在用不同大小的2的幂步长搜索。

Redis有序集合的底层实现就用了跳表。它的插入、删除、查找复杂度都能做到O(log n),而且比平衡树更容易实现和理解。行业里称之为"链表+二分思想"的产物,实际上就是倍增思想在数据结构上的经典应用。

5.2 后缀数组的倍增构造法

后缀数组求所有后缀的字典序排序。朴素做法是直接对所有后缀排序,每次比较需要O(n),整体O(n² log n),完全无法接受。倍增构造法的思路是:第一轮先按照每个位置长度为1的子串排序,下一轮把长度翻倍,用上一轮的排序结果作为两个关键字排序,每轮排序长度翻倍。这样需要的轮数只有O(log n),配合基数排序,总复杂度可以做到O(n log n)。

这里把"倍增"二字体现得淋漓尽致:每次需要处理的信息长度翻倍,利用已有信息避免重复计算。

5.3 倍增DP与跳跃游戏

很多"从某点出发走k步到达哪里"的问题,都可以用倍增预处理。比如图上每个点有唯一出边,问走k步到哪个点,这就是经典的"倍增跳表"。预处理f[i][j]表示从i出发走2^j步到达的点,然后任意k都能在O(log k)时间内分解。这类题在关于环和函数图的问题中经常出现,思路和树上的LCA高度相似。

5.4 应用场景汇总

应用解决的问题步长来源
快速幂大指数幂次计算指数二进制位
ST表静态区间最值查询区间长度翻倍
树上倍增LCA最近公共祖先查询树的深度差、祖先链
跳表有序链表的查找多层索引间隔翻倍
后缀数组倍增法后缀排序子串长度翻倍
递增跳表DP图走向问题行走步数翻倍

这么多场景共用同一个底子,说明倍增不是一个孤立算法而是一类算法思想。遇到"单次操作需要连续执行多次,且结果可以阶段性复用"的问题,都应该往倍增方向想一想。

6. 实战中的常见问题与排查技巧

6.1 数组边界和空节点处理

这是做题时最常见的错误来源。up数组第二维开多大,刚才说了,LOG永远多取一点。u往上跳到根之后怎么办?最优雅的方式是把根节点的父节点设成它自己,这样无论怎么继续跳,最后都落在根节点上,不会出现越界。根从1编号,up[1][j]=1即可。

如果你习惯用0表示空,逻辑上也能通,但每次模拟跳跃时都必须多加一道判断"往上跳是不是跳到空了",代码冗长且容易漏。实战经验是:用自环根处理,代码干净很多。

6.2 整型log计算的精度坑

计算log2时用log2()函数再强制转int,极大概率出现精度问题。比如log2(8)可能返回2.999999999,向下取整变成2。这不是特定编译器的问题,而是浮点数的固有误差。SAFE的做法是预计算lg数组,前面ST表已经给过代码。如果是Java,可以用31 - Integer.numberOfLeadingZeros(x)。C++里用__lg(x)或者自写循环都行。

6.3 递归爆栈:树太长的隐患

树退化成一条链时,DFS递归深度可能达到10⁵量级。C++默认栈空间往往撑不住。解决方式有几个:一是用系统命令调大栈空间,这在算法竞赛中常用,但工程化不推荐;二是把DFS改成显式栈的迭代写法;三是用BFS先求出深度和父节点,再倒序计算up表,因为up依赖父节点,BFS的顺序天然保证父节点先于子节点处理。

BFS做法里取up[u][j]的时候,up父节点相关数据已经算好了,不需要递归,很稳妥。

6.4 常见错误速查

现象可能原因解决办法
LCA查询结果错误深度对齐时diff二进制拆分写错检查diff >> j & 1的判断
数组越界或段错误LOG开太小,跳到了临界值LOG = ceil(log2(n)) + 1,永远多开
浮点数log精度问题用了log2()再取整改为预计算整数lg数组
结果始终是根节点根节点的父节点没有正确设置将up[根][j]设为自己
递归爆栈树退化成链用BFS或迭代栈替代递归DFS
查询时目标节点之间的深度不同没有先做深度对齐先交换保证u更深,再按diff跳

6.5 如何正确调试倍增代码

我调试这类代码的经验是:先写一个小数据暴力验证,比如一棵7个节点的树,所有节点对都算一遍LCA,拿朴素方法对拍。只要小数据全对,大数据基本不会有逻辑错误,剩下的问题多半是数组越界或者内存。

如果直接在大数据上跑,错了也定位不了。学会对拍是算法题的基本功,尤其在倍增这种预处理和查询分离的代码里,暴力对拍能最快揪出哪里逻辑不对。

7. 我的个人实操体会

学习倍增的过程中,我自己最深的一点体会是:不要死背代码,而是抓住三个关键词——2的幂、预处理、按位拼接。你只要理解任意步数都能拆成2的幂之和,倍增算法就已经掌握了一半。

写代码的时候,建议从快速幂开始练习。它代码最短,但把倍增的框架完整体现了一遍。写熟了之后再看ST表,你会发现ST表只是把倍增用在了区间上。最后再挑战树上LCA,这个时候你已经知道怎么处理步长跳跃,剩下的就是对树结构的理解了。

还有一个经验:调试树相关问题,最好自己画一棵小树,7个节点就够了,把每个节点的depth和up表手工算出来,再拿代码去跑。纸上能算明白,代码就一定能写明白。倍增最怕的就是脑子里一团浆糊就上手,写出来的代码往往边界错漏百出。先把小例子吃透,再上规模,效率反而最高。

如果你后续还要接触树上差分、重链剖分这些进阶内容,倍增LCA更是绕不开的基础。它不只是一个算法模板,更是一把打开树上路径问题的钥匙。看懂它,后面很多东西学起来会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询