NOI C++ 课程体系 / 算法篇 / 第7讲
CSP-J 入门级 · 基础算法 · m4-07

倍增法

成倍跳跃,高效查询 —— 掌握倍增思想的核心,学会 ST 表、LCA 等经典应用,将 O(n) 的查询优化到 O(log n) 甚至 O(1)。

6 大模块 4 个经典案例 2 大核心应用 5 大易错点 32 道分层练习
本讲 · 第 31/56 讲倍增法
模块 01

情景导航

从生活中的"翻倍跳跃"开始,直觉理解倍增思想。

故事猜数字与翻倍跳跃

小明在玩一个猜数字游戏:一个 1~1000 之间的整数,每次猜一个数,系统告诉他"大了"或"小了"。

笨办法:从 1 开始逐个试:1, 2, 3, ... 最坏要试 1000 次。

聪明办法:每次猜中间的数!先猜 500 → "大了" → 猜 250 → "小了" → 猜 375 → ...

每次排除一半,只需 log₂1000 ≈ 10 次!

这就是二分查找,而倍增法正是它的"堂兄弟":不是每次折半,而是每次翻倍!

想象你在一条路上跳格子:第1步跳1格,第2步跳2格,第3步跳4格,第4步跳8格……

1 → 2 → 4 → 8 → 16 → 32 → 64 → ...

只需 log₂n 步就能跳到 n 的位置!这就是倍增的核心:每次步长翻倍。

💡 倍增的威力

• 区间最值查询(RMQ):预处理 O(n log n),查询 O(1)!

• 树上最近公共祖先(LCA):预处理 O(n log n),查询 O(log n)!

• 快速幂:aⁿ 的计算从 O(n) 优化到 O(log n)!

倍增思想 = 利用 2 的幂次来加速"逐步推进"的过程。

①生活类比:倍增无处不在
🦘

袋鼠跳远

第一跳1米,第二跳2米,第三跳4米…快速到达远处。

→ 步长翻倍
🔍

翻字典找词

先翻到一半,再翻一半的一半…快速定位目标页。

→ 二分/倍增定位
💰

复利翻倍

本金翻倍→再翻倍→再翻倍…指数增长。

→ 2ⁿ 增长
📶

WiFi信号搜索

先大步走找信号方向,再小步精确定位最强点。

→ 大步试探+小步精确
②编程定义

倍增法(Binary Lifting / Doubling):利用 2 的幂次(1, 2, 4, 8, 16, ...)来加速计算或查询的算法思想。核心是预处理出 2 的幂次长度的信息,然后通过组合这些预处理结果来快速回答查询。

关键性质:任意正整数都可以表示为若干个 2 的幂次之和(即二进制分解)。例如 13 = 8 + 4 + 1 = 2³ + 2² + 2⁰。

📌 本讲核心知识地图

板块核心内容对应大纲
倍增思想2的幂次预处理 + 组合查询倍增法
ST表(Sparse Table)O(n log n) 预处理,O(1) RMQ 查询倍增法经典应用
LCA(最近公共祖先)倍增法求树上两点LCA倍增法经典应用
倍增跳跃利用倍增快速定位/跳跃倍增法应用
③核心口诀
🔑 倍增核心思想

预处理:把 2⁰, 2¹, 2², ... 长度的信息都算好;
查询时:把任意长度拆成 2 的幂次,拼起来就是答案;
任意数 = 二进制 → 最多 log n 个 2 的幂次!

🔑 ST表核心

st[i][j] = 从位置 i 开始、长度为 2^j 的区间最值;
递推:st[i][j] = min(st[i][j-1], st[i+2^(j-1)][j-1])
查询 [L,R]:取 k = ⌊log₂(R-L+1)⌋,两段覆盖!

模块 02

知识探究

深入理解倍增思想,掌握 ST 表和 LCA 两大经典应用。

1倍增核心思想 核心

倍增法的精髓在于:任意正整数都可以唯一地表示为若干个 2 的幂次之和(二进制表示)。

📖 二进制分解示例

13 的二进制 = 1101₂ = 2³ + 2² + 2⁰ = 8 + 4 + 1

25 的二进制 = 11001₂ = 2⁴ + 2³ + 2⁰ = 16 + 8 + 1

100 的二进制 = 1100100₂ = 2⁶ + 2⁵ + 2² = 64 + 32 + 4

结论:任意 n 最多拆成 log₂n 个 2 的幂次。预处理这些幂次长度的信息,就能在 O(log n) 内组合出任意长度的答案!

1
📐
预处理

计算所有 2^j 长度的信息(j = 0, 1, 2, ...)

2
🔢
二进制分解

将查询区间长度拆成 2 的幂次之和

3
🧩
组合答案

用预处理好的信息拼出最终答案

4
⚡
高效查询

查询时间 O(log n) 或 O(1)

2^jj=0j=1j=2j=3j=4j=5j=10j=20
值1248163210241048576
案例经典案例1:ST 表(Sparse Table)必会

RMQ(Range Minimum/Maximum Query):给定一个长度为 n 的数组,多次查询区间 [L, R] 中的最小值(或最大值)。

🔍 为什么需要 ST 表?

暴力法:每次查询遍历 [L, R],时间 O(n),m 次查询 = O(nm)

ST 表:预处理 O(n log n),每次查询 O(1)!m 次查询 = O(n log n + m)

当 n=10⁵, m=10⁶ 时:暴力 ~10¹¹ 次,ST表 ~10⁶ 次,快了10万倍!

ST 表核心定义:

st[i][j] = 从位置 i 开始、长度为 2^j 的区间中的最小值

即:st[i][j] = min(a[i], a[i+1], ..., a[i + 2^j - 1])

📊 ST 表可视化(数组 a = [3, 1, 4, 1, 5, 9, 2, 6])
i=0
i=1
i=2
i=3
i=4
i=5
i=6
i=7
j=0(长1)
3
1
4
1
5
9
2
6
j=1(长2)
1
1
1
1
5
2
2
—
j=2(长4)
1
1
1
1
2
—
—
—
j=3(长8)
1
—
—
—
—
—
—
—

黄色高亮:st[0][2] = min(a[0..3]) = min(3,1,4,1) = 1

递推公式(预处理):

🔑 ST 表递推

初始值:st[i][0] = a[i](长度为 1 的区间最小值就是自身)
递推:st[i][j] = min(st[i][j-1], st[i + 2^(j-1)][j-1])
含义:长度为 2^j 的区间 = 前一半(2^(j-1)) + 后一半(2^(j-1))

查询 [L, R]:

令 k = ⌊log₂(R - L + 1)⌋,则:

min(a[L..R]) = min(st[L][k], st[R - 2^k + 1][k])

两段长度各为 2^k 的区间,一定能完全覆盖 [L, R](可能有重叠,但求 min/max 时重叠不影响)。

C++st_table.cpp — ST表完整实现
#include <iostream>
#include <cmath>
#include <algorithm>
using namespace std;

const int MAXN = 100005;
const int LOG = 17;  // log2(100000) ≈ 17
int a[MAXN];
int st[MAXN][LOG];  // st[i][j] = min of a[i..i+2^j-1]
int lg[MAXN];       // lg[i] = floor(log2(i))

void build(int n) {
    // 预处理 log2 值
    lg[1] = 0;
    for (int i = 2; i <= n; i++)
        lg[i] = lg[i / 2] + 1;

// 初始值:j = 0
    for (int i = 1; i <= n; i++)
        st[i][0] = a[i];

// 递推:j = 1, 2, ..., LOG-1
    for (int j = 1; j < LOG; j++)
        for (int i = 1; i + (1 << j) - 1 <= n; i++)
            st[i][j] = min(st[i][j - 1],
                          st[i + (1 << (j - 1))][j - 1]);
}

int query(int L, int R) {
    int k = lg[R - L + 1];
    return min(st[L][k], st[R - (1 << k) + 1][k]);
}

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) cin >> a[i];
    build(n);
    while (m--) {
        int L, R;
        cin >> L >> R;
        cout << query(L, R) << "\n";
    }
    return 0;
}
运行结果
输入:8 3
3 1 4 1 5 9 2 6
1 4    → 查询 a[1..4] = min(3,1,4,1) = 1
3 7    → 查询 a[3..7] = min(4,1,5,9,2) = 1
5 8    → 查询 a[5..8] = min(5,9,2,6) = 2
输出:
1
1
2

💡 查询过程详解(以 [3, 7] 为例)

区间长度 = 7 - 3 + 1 = 5

k = ⌊log₂5⌋ = 2(因为 2² = 4 ≤ 5)

查询 = min(st[3][2], st[7 - 4 + 1][2]) = min(st[3][2], st[4][2])

st[3][2] = min(a[3..6]) = min(4,1,5,9) = 1

st[4][2] = min(a[4..7]) = min(1,5,9,2) = 1

结果 = min(1, 1) = 1 ✅

关键:两段 [3,6] 和 [4,7] 覆盖了 [3,7],虽然有重叠([4,6]),但求 min 时重叠不影响结果!

案例经典案例2:LCA 最近公共祖先 高频

LCA(Lowest Common Ancestor):在一棵有根树中,求两个节点的最近公共祖先。

🌳 树结构示例(求节点 4 和 5 的 LCA)
1 2 3 4 5 6 7 8 LCA(4,5) = 2

🔍 倍增法求 LCA 的思路

预处理:对每个节点 u,预处理 up[u][j] = u 的第 2^j 个祖先。

• up[u][0] = u 的父亲(第 2⁰ = 1 个祖先)

• up[u][1] = u 的爷爷(第 2¹ = 2 个祖先)

• up[u][2] = u 的曾祖父的父亲(第 2² = 4 个祖先)

• 递推:up[u][j] = up[up[u][j-1]][j-1]

查询 LCA(u, v):

① 让较深的节点先跳到与较浅节点同一深度

② 如果此时两者相同,LCA 就是当前节点

③ 否则,从大到小尝试 2^j 步跳跃:如果 up[u][j] ≠ up[v][j],则同时跳

④ 最终 u 和 v 的父亲就是 LCA

C++lca.cpp — 倍增法求 LCA
#include <iostream>
#include <vector>
using namespace std;

const int MAXN = 100005, LOG = 17;
vector<int> adj[MAXN];
int depth[MAXN];
int up[MAXN][LOG]; // up[u][j] = u 的第 2^j 个祖先

// DFS 预处理 depth 和 up
void dfs(int u, int p) {
    up[u][0] = p;  // 父亲
    for (int j = 1; j < LOG; j++)
        up[u][j] = up[up[u][j-1]][j-1]; // 倍增递推
    for (int v : adj[u]) {
        if (v != p) {
            depth[v] = depth[u] + 1;
            dfs(v, u);
        }
    }
}

int lca(int u, int v) {
    // ① 让 u 是较深的节点
    if (depth[u] < depth[v]) swap(u, v);

// ② u 向上跳,使深度相同
    int diff = depth[u] - depth[v];
    for (int j = 0; j < LOG; j++)
        if ((diff >> j) & 1)  // 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];
        }

// ④ 父亲就是 LCA
    return up[u][0];
}

int main() {
    int n, m, root;
    cin >> n >> m >> root;
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    depth[root] = 0;
    dfs(root, root); // root 的父亲设为自己
    while (m--) {
        int u, v; cin >> u >> v;
        cout << lca(u, v) << "\n";
    }
    return 0;
}
运行结果(上图的树)
输入:8 3 1
1 2  1 3  2 4  2 5  3 6  3 7  4 8
查询 LCA(4, 5) → 2
查询 LCA(4, 7) → 1
查询 LCA(6, 8) → 1
输出:2  1  1
案例经典案例3:倍增跳跃 思想

问题:在一条数轴上,你从位置 0 出发,每次可以跳 1、2、4、8、...步。问跳到位置 n 最少需要几步?

🔍 分析

这等价于:n 的二进制表示中有多少个 1?

例:n = 13 = 1101₂ → 需要跳 8 + 4 + 1 = 三步

例:n = 25 = 11001₂ → 需要跳 16 + 8 + 1 = 三步

答案 = n 的二进制中 1 的个数(popcount)

更一般的问题:给定一个函数 f(x),从 x 出发,每次可以"跳"到 f(x)、f(f(x))、f(f(f(x)))...。求从 x 跳 k 次后到达哪里?

用倍增:jump[x][j] = 从 x 跳 2^j 次后到达的位置。

递推:jump[x][j] = jump[jump[x][j-1]][j-1]

C++binary_jump.cpp — 倍增跳跃
// 从 x 出发跳 k 次
int jump_to(int x, int k) {
    for (int j = 0; j < LOG; j++)
        if ((k >> j) & 1)  // k 的第 j 位为 1
            x = jump[x][j]; // 跳 2^j 步
    return x;
}

💡 倍增跳跃的本质

将 k 次跳跃分解为若干个 2 的幂次跳跃之和。

例:k = 13 = 8 + 4 + 1 → 先跳 2⁰=1 步,再跳 2²=4 步,再跳 2³=8 步

总共只需 O(log k) 次操作,而不是 O(k)!

LCA 算法中的"向上跳"就是这个思想的应用。

案例经典案例4:快速幂 必会

快速幂(Fast Exponentiation):计算 a^n mod p,将 O(n) 优化到 O(log n)。

🔍 倍增思想在快速幂中的体现

核心等式:a^(2k) = (a^k)²,a^(2k+1) = (a^k)² × a

例:计算 3^13 = 3^(8+4+1) = 3^8 × 3^4 × 3^1

预处理:3^1 = 3, 3^2 = 9, 3^4 = 81, 3^8 = 6561

3^13 = 6561 × 81 × 3 = 1594323

只需 log₂13 ≈ 4 次乘法,而不是 13 次!

C++fast_pow.cpp — 快速幂
long long fast_pow(long long a, long long n, long long p) {
    long long result = 1;
    a %= p;
    while (n > 0) {
        if (n & 1)           // n 的最低位为 1
            result = result * a % p;
        a = a * a % p;      // a = a^(2^j)
        n >>= 1;            // n 右移一位
    }
    return result;
}
运行结果
fast_pow(3, 13, 1000000007) = 1594323
fast_pow(2, 100, 1000000007) = 976371285
n 的二进制对应的幂次分解乘法次数
13 = 1101₂3⁸ × 3⁴ × 3¹6 次
100 = 1100100₂2⁶⁴ × 2³² × 2²13 次
10⁹约30个幂次相乘~60 次
模块 03

实践应用

通过详细分析题,巩固倍增法的实际应用能力。

题1ST 表查询分析

题目:数组 a = [7, 2, 5, 3, 8, 1, 6, 4],使用 ST 表查询以下区间的最小值。请详细写出查询过程。

① 查询 [1, 5](即 min(a[1..5]))
② 查询 [2, 7](即 min(a[2..7]))

查看参考答案

① 查询 [1, 5]:

区间长度 = 5 - 1 + 1 = 5

k = ⌊log₂5⌋ = 2(因为 2² = 4 ≤ 5 < 8 = 2³)

两段覆盖:

• st[1][2] = min(a[1..4]) = min(7, 2, 5, 3) = 2

• st[5-4+1][2] = st[2][2] = min(a[2..5]) = min(2, 5, 3, 8) = 2

结果 = min(2, 2) = 2

② 查询 [2, 7]:

区间长度 = 7 - 2 + 1 = 6

k = ⌊log₂6⌋ = 2(因为 2² = 4 ≤ 6 < 8)

两段覆盖:

• st[2][2] = min(a[2..5]) = min(2, 5, 3, 8) = 2

• st[7-4+1][2] = st[4][2] = min(a[4..7]) = min(3, 8, 1, 6) = 1

结果 = min(2, 1) = 1

注意:两段 [2,5] 和 [4,7] 有重叠部分 [4,5],但求最小值时重叠不影响正确性。这就是 ST 表只能用于"可重复贡献"操作(min/max/gcd)的原因。

题2LCA 求解过程追踪

题目:给定如下树(根为 1),用倍增法求 LCA(8, 6)。请详细写出每一步。

树结构:1-2, 1-3, 2-4, 2-5, 4-8, 3-6, 3-7

查看参考答案

Step 1:确定深度

depth[1]=0, depth[2]=1, depth[3]=1, depth[4]=2, depth[5]=2, depth[6]=2, depth[7]=2, depth[8]=3

Step 2:预处理 up 数组

up[8][0]=4, up[8][1]=up[4][0]=2, up[8][2]=up[2][1]=1

up[6][0]=3, up[6][1]=up[3][0]=1, up[6][2]=up[1][1]=1

Step 3:对齐深度

u=8(深3),v=6(深2),diff=1

diff=1 的第0位为1 → u = up[8][0] = 4,现在 depth[u]=depth[v]=2

Step 4:判断是否相遇

u=4 ≠ v=6,未相遇

Step 5:同时向上跳

j=1: up[4][1]=1, up[6][1]=1 → 相同,不跳

j=0: up[4][0]=2, up[6][0]=3 → 不同,跳!u=2, v=3

Step 6:返回父亲

up[2][0] = 1 → LCA(8, 6) = 1

验证:8→4→2→1,6→3→1,最近公共祖先确实是 1 ✅

题3综合实战:快速幂应用

题目:计算 2^1000000000 mod 1000000007。请分析快速幂的执行过程(前几步)。

查看参考答案

分析:n = 10⁹ = 111011100110101100101000000000₂(30位二进制)

快速幂执行过程(前几步):

轮次n 的最低位a 的值result
102² = 41(不乘)
204² = 161(不乘)
3016² = 2561(不乘)
............
~30——最终结果

总共约 30 轮循环(因为 log₂10⁹ ≈ 30),每轮最多2次乘法。

总乘法次数 ≈ 60 次,而暴力法需要 10亿次!

答案:fast_pow(2, 1000000000, 1000000007) = 140625001

模块 04

总结提升

1核心要点总结
知识板块必须记住的核心
倍增思想任意数 = 2 的幂次之和(二进制分解),预处理 2^j 长度的信息
ST 表st[i][j] = min(a[i..i+2^j-1]),递推:两段合并。查询 O(1)
LCAup[u][j] = u 的第 2^j 个祖先。先对齐深度,再同时跳
快速幂a^n:n 按位分解,a 不断平方。O(log n)
适用范围ST表只能用于可重复贡献操作(min/max/gcd),不能用于求和
复杂度预处理 O(n log n),查询 O(1) 或 O(log n)
2拓展:倍增思想的更多应用

💡 倍增思想的广泛应用

1. 树上路径查询:LCA + 树上差分 = 路径上的和/最值

2. 后缀数组:倍增法构建后缀数组 O(n log² n)

3. 矩阵快速幂:将递推关系写成矩阵,用快速幂加速到 O(log n)

4. KMP 的 fail 指针:也可以用倍增思想优化

5. 在线 RMQ:ST 表是最经典的在线 RMQ 方案

核心模式:看到"重复做某操作 k 次"→ 想倍增!看到"区间查询"→ 想 ST 表!

3思考与启发

💡 学完本讲,你应该掌握的6项能力

1. 理解倍增的核心思想:用 2 的幂次来加速。

2. 能手写 ST 表的预处理和查询代码。

3. 能用倍增法求 LCA,理解 up 数组的含义。

4. 能写快速幂并理解其与倍增的关系。

5. 知道 ST 表只适用于可重复贡献操作。

6. 能识别哪些问题适合用倍增法解决。

模块 05

学习检测

5 个初学者最常犯的错误。

易错1

ST 表用于区间求和

❌ 错误
"ST 表查询区间和:st[L][k] + st[R-2^k+1][k]"
✅ 正确
ST 表两段有重叠!min/max/gcd 重叠不影响,但求和会重复计算。区间求和用前缀和或线段树。
口诀:ST 表只能 min/max/gcd,求和请用前缀和!
易错2

LOG 开太小

❌ 错误
n = 10⁵ 时 LOG = 10(2^10 = 1024 < 100000),数组越界。
✅ 正确
LOG 至少取 ⌈log₂n⌉ + 1。n=10⁵ 时 LOG≥17,n=10⁶ 时 LOG≥20。
口诀:LOG = 17 保万安,10⁵ 以内都够用!
易错3

LCA 忘记对齐深度

❌ 错误
直接让两个节点同时向上跳,但它们不在同一深度。
✅ 正确
第一步必须对齐深度!让较深的节点先跳到与较浅节点同一层。
口诀:LCA 先对齐,深度一样再一起跳!
易错4

LCA 跳跃方向搞反

❌ 错误
从 j=0 开始往上跳(小步先跳),可能跳过头。
✅ 正确
从 j=LOG-1 往下到 j=0(大步先跳),确保不会跳过 LCA。
口诀:LCA 从大到小跳,大步在前小步跟!
易错5

快速幂忘记取模

❌ 错误
result = result * a(不取模),long long 也溢出。
✅ 正确
每一步乘法后都要取模:result = result * a % p; a = a * a % p;
口诀:快速幂每步都 mod,不 mod 必溢出!
速查倍增法速查表
应用预处理查询核心递推
ST 表 (RMQ)O(n log n)O(1)st[i][j] = min(st[i][j-1], st[i+2^(j-1)][j-1])
LCAO(n log n)O(log n)up[u][j] = up[up[u][j-1]][j-1]
快速幂—O(log n)a^(2k) = (a^k)²
倍增跳跃O(n log n)O(log k)jump[x][j] = jump[jump[x][j-1]][j-1]
📌知识点笔记卡

💡 倍增思想

任意数 = 2的幂次之和

预处理2^j长度的信息

查询时组合拼出答案

📊 ST 表

st[i][j] = 区间最值

预处理 O(n log n)

查询 O(1) 两段覆盖

🌳 LCA

up[u][j] = 第2^j祖先

先对齐深度

从大到小同时跳

⚡ 快速幂

a^n 按位分解

a 不断平方

每步取模 O(log n)

⚠️ ST表限制

只能 min/max/gcd

不能用于求和

求和用前缀和

🔑 LOG 取值

n=10⁵ → LOG≥17

n=10⁶ → LOG≥20

宁大勿小

课后练习

分层练习作业

A基础 / B提高 / C挑战 + 洛谷20题,难度递增,点击展开答案与解析。

🟢 A组 · 基础(4题)

A1选择题

ST 表(Sparse Table)的查询时间复杂度是()

A. O(n)   B. O(log n)   C. O(1)   D. O(n log n)

查看参考答案

答案:C. O(1)

ST 表预处理 O(n log n) 后,每次查询只需取两段的最小值,时间 O(1)。这是 ST 表最大的优势。

考点:ST 表用 O(n log n) 的预处理换取每次查询 O(1),这是它最大的优势。

易错:把查询复杂度写成 O(log n)——那是线段树或树状数组的查询复杂度,两者不能混为一谈。

A2选择题

以下哪种操作不能用 ST 表来实现?

A. 区间最小值   B. 区间最大值   C. 区间求和   D. 区间 GCD

查看参考答案

答案:C. 区间求和

ST 表的两段覆盖可能有重叠。min/max/gcd 操作满足"可重复贡献"性质(重叠不影响结果),但求和不满足——重叠部分会被重复计算。区间求和应使用前缀和或线段树。

考点:ST 表两段区间允许重叠,因此要求合并运算满足可重复贡献(min/max/gcd 满足,求和、异或不满足)。

易错:以为 ST 表是万能的,把区间求和也交给它——重叠部分会被重复相加,结果偏大。

A3选择题

快速幂计算 a^n mod p 的时间复杂度是()

A. O(n)   B. O(log n)   C. O(n²)   D. O(1)

查看参考答案

答案:B. O(log n)

快速幂利用倍增思想,将 n 按二进制分解,每轮将 n 右移一位(除以2),共 log₂n 轮。每轮最多2次乘法。

考点:快速幂把指数按二进制分解,循环轮数与指数的二进制位数同阶。

易错:把快速幂的复杂度写成 O(n)(那是朴素连乘 n 次的做法),或误答 O(log²n)。

A4编程题

用快速幂计算 3^100 mod 1000000007,写出完整代码。

查看参考答案
#include <iostream>
using namespace std;
long long fast_pow(long long a, long long n, long long p) {
    long long res = 1;
    a %= p;
    while(n > 0) {
        if(n & 1) res = res * a % p;
        a = a * a % p;
        n >>= 1;
    }
    return res;
}
int main() {
    cout << fast_pow(3, 100, 1000000007) << endl;
    return 0;
}
运行结果
886041711

分析:100 = 1100100₂,共 7 轮循环,3 的 100 次方 = 3^64 × 3^32 × 3^4。

易错:底数没有先做 a%=p,a 较大时 a*a 会溢出 long long;或循环里忘了写 n>>=1,造成死循环。

🟡 B组 · 提高(4题)

B1编程题

用 ST 表解决 RMQ 问题:输入 n 个整数和 m 个查询,每次查询区间 [L, R] 的最大值。

查看参考答案
#include <iostream>
#include <algorithm>
using namespace std;
const int MAXN = 100005, LOG = 17;
int a[MAXN], st[MAXN][LOG], lg[MAXN];
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;j<LOG;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 k = lg[R-L+1];
    return max(st[L][k], st[R-(1<<k)+1][k]);
}
int main() {
    int n, m; cin >> n >> m;
    for(int i=1;i<=n;i++) cin >> a[i];
    build(n);
    while(m--) {
        int L, R; cin >> L >> R;
        cout << query(L, R) << "\n";
    }
    return 0;
}

关键改动:将 min 改为 max 即可。ST 表同时适用于 min 和 max,因为两者都满足可重复贡献性质。

思路:预处理 lg 数组与 st[i][j](长度为 2^j 的区间最值),查询时取两段长度 2^k 的重叠区间即可。

易错:st 数组第二维开得太小(n=10⁵ 时 LOG 至少要 17);查询右段写成 st[R-(1<

B2分析题

ST 表的空间复杂度是多少?当 n=10⁵, LOG=17 时,st 数组占用多少内存?

查看参考答案

空间复杂度:O(n log n)

st 数组大小 = MAXN × LOG = 100005 × 17 = 1,700,085 个 int

每个 int 占 4 字节,总共 = 1700085 × 4 ≈ 6.8 MB

加上 lg 数组(100005 × 4 ≈ 0.4 MB)和 a 数组(0.4 MB),总计约 7.6 MB

注意:如果 n=10⁶, LOG=20,则 st 数组 ≈ 80 MB,可能超出内存限制!此时考虑用线段树(O(n) 空间)。

思路:按 st 数组的元素总个数乘上 int 的字节数估算内存占用,再与题目给定的内存限制对照。

B3编程题

倍增法求 LCA:输入一棵 n 个节点的有根树(根为1)和 m 个查询,每次查询两个节点的 LCA。

查看参考答案
#include <iostream>
#include <vector>
using namespace std;
const int MAXN=100005, LOG=17;
vector<int> adj[MAXN];
int dep[MAXN], up[MAXN][LOG];
void dfs(int u, int p) {
    up[u][0]=p;
    for(int j=1;j<LOG;j++) up[u][j]=up[up[u][j-1]][j-1];
    for(int v:adj[u]) if(v!=p) { dep[v]=dep[u]+1; dfs(v,u); }
}
int lca(int u, int v) {
    if(dep[u]<dep[v]) swap(u,v);
    int d=dep[u]-dep[v];
    for(int j=0;j<LOG;j++) if((d>>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];
}
int main() {
    int n, m; cin >> n >> m;
    for(int i=0;i<n-1;i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }
    dep[1]=0; dfs(1,1);
    while(m--) {
        int u, v; cin >> u >> v;
        cout << lca(u,v) << "\n";
    }
    return 0;
}

思路:先 DFS 预处理每个节点的深度和 2^j 级祖先 up[u][j],查询时先把两点提到同一深度,再同步向上跳。

易错:up 数组预处理写成 up[u][j]=up[u][j-1](漏掉外层),或忘记先把较深的节点跳到与另一个同层。

B4分析题

在 LCA 算法中,为什么"同时向上跳"时要从 j=LOG-1 递减到 j=0,而不能从 j=0 递增?

查看参考答案

原因:从大到小跳可以保证不跳过 LCA。

假设 LCA 在距离 u/v 上方 5 步的位置(5 = 4 + 1 = 2² + 2⁰)。

从大到小:j=2(跳4步)→跳后距LCA 1步→j=1(跳2步)→会跳过,不跳→j=0(跳1步)→到达LCA下方。✅

从小到大:j=0(跳1步)→距LCA 4步→j=1(跳2步)→距LCA 2步→j=2(跳4步)→跳过LCA!❌

核心原理:从大到小跳 = 贪心策略,每次尽量跳远但不跳过目标。从小到大跳无法保证这一点。

考点:把两点间的距离按二进制拆分,从高位到低位“能跳就跳但不跳过目标”,这是标准的贪心跳法。

易错:把 j 的循环写成从小到大,跳远时容易越过 LCA,最终跳到根节点上去。

🔴 C组 · 挑战(4题)

C1初赛真题

ST 表中 st[i][j] 表示从位置 i 开始、长度为 2^j 的区间最小值。当 j=3 时,该区间包含多少个元素?

A. 3   B. 6   C. 8   D. 16

查看参考答案

答案:C. 8

j=3 时,区间长度 = 2^3 = 8。即 st[i][3] 覆盖 a[i], a[i+1], ..., a[i+7] 共 8 个元素。

考点:st[i][j] 覆盖区间的长度固定为 2^j,与起点 i 无关。

易错:把区间长度记成 j 或 2j(正确的是 2^j),于是 j=3 时误答 3 或 6。

C2综合编程

树上两点间距离:给定一棵带权树,m 次查询两点间路径长度。提示:dist(u,v) = depth[u] + depth[v] - 2×depth[LCA(u,v)]。

查看参考答案

思路:

① 用 DFS 预处理每个节点到根的距离 dis[u](而非深度)

② 用倍增法求 LCA

③ dist(u,v) = dis[u] + dis[v] - 2 × dis[LCA(u,v)]

long long dis[MAXN]; // 到根的距离
void dfs(int u, int p, long long d) {
    dis[u] = d; dep[u] = dep[p] + 1;
    up[u][0] = p;
    for(int j=1;j<LOG;j++) up[u][j]=up[up[u][j-1]][j-1];
    for(auto& e : adj[u]) if(e.to != p)
        dfs(e.to, u, d + e.w);
}
// 查询
long long dist(int u, int v) {
    return dis[u] + dis[v] - 2 * dis[lca(u, v)];
}

原理:u 到 v 的路径 = u→LCA + LCA→v。dis[u]-dis[LCA] 是 u 到 LCA 的距离,dis[v]-dis[LCA] 是 v 到 LCA 的距离。

易错:带权树仍然套用 depth(边数)代入公式——必须改成“到根的距离 dis[]”,深度公式只适用于边权全为 1 的情况。

C3综合编程

区间 GCD 查询:用 ST 表实现多次查询区间 [L, R] 的 GCD。分析为什么 GCD 可以用 ST 表。

查看参考答案

GCD 满足可重复贡献性质:

gcd(a, b, c) = gcd(gcd(a, b), gcd(b, c))

即 gcd(x, x) = x,重复计算不影响结果。所以 ST 表的重叠覆盖不影响 GCD 的正确性。

int gcd(int a, int b) { return b ? gcd(b, a%b) : a; }
// 预处理
st[i][0] = a[i];
st[i][j] = gcd(st[i][j-1], st[i+(1<<(j-1))][j-1]);
// 查询
int k = lg[R-L+1];
return gcd(st[L][k], st[R-(1<<k)+1][k]);

思路:gcd 满足可重复贡献(gcd(x,x)=x),所以 ST 表两段重叠覆盖不会影响结果的正确性。

易错:gcd 的递归写法把取模顺序写反(应递归 gcd(b, a%b)),或漏掉 gcd(0,x)=x 的边界终止条件。

C4综合编程

矩阵快速幂:用矩阵快速幂计算斐波那契数列的第 n 项(n ≤ 10¹⁸),结果对 10⁹+7 取模。

查看参考答案

思路:将 F(n) = F(n-1) + F(n-2) 写成矩阵形式:

[F(n)]    [1 1] ^ (n-2)   [F(2)]
[F(n-1)] = [1 0]           × [F(1)]

typedef long long ll;
const ll MOD = 1000000007;
struct Mat { ll m[2][2]; };
Mat mul(Mat A, Mat B) {
    Mat C={{{0,0},{0,0}}};
    for(int i=0;i<2;i++)
      for(int k=0;k<2;k++)
        for(int j=0;j<2;j++)
          C.m[i][j]=(C.m[i][j]+A.m[i][k]*B.m[k][j])%MOD;
    return C;
}
Mat mpow(Mat A, ll p) {
    Mat R={{{1,0},{0,1}}};
    while(p>0) { if(p&1) R=mul(R,A); A=mul(A,A); p>>=1; }
    return R;
}
int main() {
    ll n; cin >> n;
    if(n<=2) { cout << 1; return 0; }
    Mat T={{{1,1},{1,0}}};
    Mat R = mpow(T, n-2);
    ll ans = (R.m[0][0]*1 + R.m[0][1]*1) % MOD;
    cout << ans << endl;
}

复杂度:O(log n),n=10¹⁸ 时约 60 次矩阵乘法,每次 8 次标量乘法,总计约 480 次运算。

易错:矩阵乘法忘记取模导致中间结果溢出;n≤10¹⁸ 时必须用快速幂,直接循环 n 次一定超时。

🔥 洛谷原题精选(20题)

倍增法专项

以下题目均来自洛谷(luogu.com.cn),覆盖 ST 表、LCA、快速幂、倍增跳跃等核心考点。建议按序号顺序练习。

01P3865【模板】ST 表ST表
02P2880Balanced Lineup GST表
03P2251质量检测ST表
04P1816忠诚ST表
05P3379【模板】最近公共祖先(LCA)LCA
06P3384【模板】重链剖分/LCALCA
07P1967货车运输LCA+最大生成树
08P2912牧场散步LCA+树上距离
09P1265公路修建LCA应用
10P1060乘法表问题倍增
11P1226【模板】快速幂快速幂
12P1060N次方取模快速幂
13P1965转圈游戏快速幂
14P5099同位放置倍增+DP
15P1084疫情控制倍增+贪心
16P2052道路修建LCA+树DP
17P3258松鼠的新家LCA+树上差分
18P2680运输计划LCA+二分+差分
19P3938斐波那契LCA+数学
20P4281紧急救援LCA+树上操作

📋 练习建议

入门阶段(第1~4题):掌握 ST 表模板,理解预处理和查询。
基础阶段(第5~8题):掌握 LCA 模板,理解倍增跳跃。
进阶阶段(第9~14题):快速幂 + LCA 应用。
挑战阶段(第15~20题):倍增 + DP/贪心/差分的综合应用。