倍增法
倍增法与二分法是“相反”的算法,二分法是每次缩小一半,从而以 \(O(\log n)\) 的速度快速缩小定位到解;倍增法是每次扩大一倍,从而以 \(O(2^n)\) 的速度快速地扩展到解空间。
倍增就是“成倍增长”,很多时候倍增的实现利用的是二进制本身的倍增特性。把一个数 \(n\) 用二进制展开,则 $n = a_0 \times 2^0 + a_1 \times 2^1 + a_2 \times 2^2 + a_3 \times 2^3 + a_4 \times 2^4 + \cdots $。例如 \(35\),它的二进制是 \(100011\),第 \(5\) 位、第 \(1\) 位和第 \(0\) 位为 \(1\),即 \(a_5=a_1=a_0=1\),把这几位的权值相加,得到 \(35=2^5+2^1+2^0=32+2+1\)。
二进制划分反映了一种快速增长的特性,第 \(i\) 位的权值 \(2^i\) 等于前面所有权值的和加 \(1\),即 \(2^i=2^{i-1}+2^{i-2}+\cdots+2^1+2^0+1\),一个整数 \(n\),它的二进制只有 \(\log n\) 位。如果要从 \(0\) 增长到 \(n\),可以以 \(1,2,4,\cdots,2^k\) 为“跳板”,快速跳到 \(n\),这些跳板只有 \(k = \log n\) 个。
倍增法的局限性是需要提前计算出第 \(1,2,4,\cdots,2^k\) 个跳板,这要求数据是静态不变的,不是动态变化的。如果数据发生了变化,那么所有跳板需要重新计算,跳板就失去了意义。
例题:P4155 [SCOI2015] 国旗计划
边境上有 \(m\) 个边防站围成一圈,顺时针编号为 \(1 \sim m\)。有 \(n\) 名战士,每名战士常驻两个站 \(c_i\) 和 \(d_i\),能在两个站之间移动。局长有一个“国旗计划”,让边防战士举着国旗环绕一圈。局长想知道至少需要多少战士才能完成“国旗计划”,并且他想知道在某个战士必须参加的情况下,至少需要多少名边防战士。
数据范围:\(n \le 2 \times 10^5, m < 10^9, 1 \le c_i,d_i \le m\)。
题目的要求很清晰:计算能覆盖整个圆圈的最少区间(战士)。
题目给定的所有区间互相不包含,那么按区间的左端点排序后,区间的右端点也是也是单调递增的。这种情况下能用贪心法选择区间。
定义 \(go_{s,i}\) 表示从第 \(s\) 个区间出发,走 \(2^i\) 个最优区间后到达的区间。例如,\(go_{s,4}\) 是从 \(s\) 出发到达的第 \(2^4=16\) 个最优的区间,\(s\) 和 \(go_{s,4}\) 之间的区间也都是最优的。
预计算出从所有的区间出发的 \(go\),以它们为“跳板”,就能快速跳到目的地。
跳的时候先用大数再用小数。以从 \(s\) 跳到后面第 \(27\) 个区间为例:
- 从 \(s\) 跳 \(16\) 步,到达 \(s\) 后的第 \(16\) 个区间 \(f_1\);
- 从 \(f_1\) 跳 \(8\) 步,到达 \(f_1\) 后的第 \(8\) 个区间 \(f_2\);
- 从 \(f_2\) 跳 \(2\) 步到达 \(f_3\);
- 从 \(f_3\) 跳 \(1\) 步到达终点 \(f_4\)。
时间复杂度是多少?查询一次,用倍增法从 \(s\) 跳到终点的时间复杂度为 \(O(\log n)\)。共有 \(n\) 次查询,总时间复杂度为 \(O(n \log n)\)。
剩下的问题是如何快速预计算出 \(go\)。有非常巧妙的递推关系:\(go_{s,i}=go_{go_{s,i-1},i-1}\)。可以这样理解:
- \(go_{s,i-1}\)。从 \(s\) 起跳,先跳 \(2^{i-1}\) 步到区间 \(z=go_{s,i-1}\);
- \(go_{z,i-1}\)。再从 \(z\) 跳 \(2^{i-1}\) 步。
一共跳了 \(2^{i-1}=2^{i-1}=2^i\) 步,这样就实现了从 \(s\) 起跳,跳到 \(s\) 的第 \(2^i\) 个区间。
特别地,\(go_{s,0}\) 是 \(s\) 后第 \(2^0=1\) 个区间(用贪心法算出的下一个最优区间),\(go_{s,0}\) 是递推式的初始条件,从它递推出了其他的所有值。递推的计算量有多大?从任意 \(s\) 到末尾,最多有个 \(\log n\) 个 \(go_{s,\dots}\),所以只需要递推 \(O(\log n)\) 次。计算 \(n\) 个节点的 \(go\),共计算 \(O(n \log n)\) 次。
以上所有计算,包括预计算 \(go\) 和 \(n\) 次查询,总时间复杂度为 \(O(n \log n)\)。
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long LL;
const int MAXN = 4e5 + 5;
struct Interval {
LL c, d; // c和d为战士的左右区间
int id; // id为战士的编号
bool operator<(const Interval& other) const {
return c < other.c;
}
};
Interval a[MAXN];
int go[MAXN][20], n, m, ans[MAXN];
void init() { // 贪心+预计算倍增
int nxt = 1;
for (int i = 1; i <= n; i++) { // 用贪心求每个区间的下一个最优区间
// 最优区间是有交集且右端点最大的区间
while (nxt <= n && a[nxt].c <= a[i].d) nxt++;
go[i][0] = nxt - 1; // 区间i的下一个区间
}
for (int i = 1; (1 << i) < n / 2; i++) // 倍增:i=1,2,4,8,...,共logn次
for (int s = 1; s <= n; s++) // 每个区间后第2的i次方个区间
go[s][i] = go[go[s][i - 1]][i - 1];
}
int calc(int x) { // 从第x个战士出发
int ret = 1, dest = a[x].c + m;
for (int i = 19; i >= 0; i--) { // 从最大的i开始找
int nxt = go[x][i];
if (nxt && a[nxt].d < dest) {
x = nxt; // 跳到新位置
ret += 1 << i; // 累加跳过的区间个数
}
}
return ret + 1;
}
int main()
{
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
scanf("%lld%lld", &a[i].c, &a[i].d);
a[i].id = i; // 记录战士的顺序
if (a[i].c > a[i].d) a[i].d += m; // 把环变成链
}
sort(a + 1, a + n + 1); // 按左端点排序
for (int i = 1; i <= n; i++) { // 拆环加倍成一条链
a[n + i].c = a[i].c + m;
a[n + i].d = a[i].d + m;
}
n *= 2;
init();
for (int i = 1; i <= n / 2; i++) ans[a[i].id] = calc(i); // 逐个计算每个战士
for (int i = 1; i <= n / 2; i++) printf("%d%c", ans[i], i * 2 == n ? '\n' : ' ');
return 0;
}
习题:P8251 [NOI Online 2022 提高组] 丹钓战
解题思路
先按题意模拟整个序列的入栈出栈过程。当由于一个新元素想要入栈而导致某元素被弹出时,则说明如果被弹出元素是入栈序列中的第一个元素,则它的下一个“成功的”元素是这个想要入栈的元素。因此可以预处理每一个元素的下一个“成功的”元素是谁以及相应的倍增跳跃表。对于每一个查询序列,利用倍增表就可以快速计算整个序列中“成功的”元素的数量。
参考代码
#include <cstdio>
#include <stack>
using std::stack;
const int N = 5e5 + 5;
const int LOG = 19;
int a[N], b[N], go[N][LOG];
int main()
{
int n, q; scanf("%d%d", &n, &q);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= n; i++) scanf("%d", &b[i]);
stack<int> s;
for (int i = 1; i <= n; i++) {
while (!s.empty()) {
int pre = s.top();
int ta = a[pre], tb = b[pre];
if (a[i] != ta && b[i] < tb) break;
go[pre][0] = i; // 记录每个元素因为谁导致要弹出
s.pop();
}
s.push(i);
}
for (int i = 1; i < LOG; i++)
for (int j = 1; j <= n; j++)
go[j][i] = go[go[j][i - 1]][i - 1]; // 预处理倍增表
while (q--) {
int l, r; scanf("%d%d", &l, &r);
int ans = 0;
int cur = l;
for (int i = LOG - 1; i >= 0; i--)
if (go[cur][i] != 0 && go[cur][i] <= r) { // 向右跳但没有越过边界
// 跳了2的i次方步
ans += 1 << i; cur = go[cur][i];
}
printf("%d\n", ans + 1); // 要算上起点本身
}
return 0;
}