势能分析(摊还分析)
基本概念
1.1 摊还分析的引入
在算法分析中,我们通常关注单次操作的最坏时间复杂度。然而,对于某些数据结构,虽然单次操作的最坏情况代价很高,但在一系列操作中,高代价操作的总次数是受限的,因此平均到每次操作的代价并不高。
定义 1.1(摊还分析) :摊还分析(Amortized Analysis)是一种分析算法时间复杂度的方法,它研究的是一系列操作的平均代价,而非单个操作的最坏代价。
摊还分析保证的是:在最坏情况下,所有操作的总代价的上界。它不涉及概率,而是确定性的保证。
注意:摊还分析与"平均情况分析"不同。平均情况分析假设输入服从某种概率分布,而摊还分析对任意输入序列都成立。
1.2 势能法
势能法(Potential Method)是摊还分析的一种常用技术,其思想来源于物理学中的势能概念。
定义 1.2(势能函数) :设 \(D_0\) 为数据结构的初始状态,\(D_i\) 为执行第 \(i\) 次操作后的状态。势能函数 \(\Phi\) 将每个状态 \(D_i\) 映射到一个非负实数 \(\Phi(D_i)\),称为该状态的势能。
定义 1.3(摊还代价) :第 \(i\) 次操作的摊还代价 \(\hat{c}_i\) 定义为:
\(\hat{c}i = c_i + \Phi(D_i) - \Phi(D{i-1})\)
其中 \(c_i\) 为第 \(i\) 次操作的实际代价。
换言之,摊还代价等于实际代价加上本次操作引起的势能变化量。
1.3 核心定理
定理 1.1 :设 \(\Phi\) 为一个势能函数,满足 \(\Phi(D_0) = 0\) 且对所有 \(i \ge 0\) 有 \(\Phi(D_i) \ge 0\)。则对任意 \(n \ge 1\),有:
\(\sum_{i=1}^{n} c_i \le \sum_{i=1}^{n} \hat{c}_i\)
即总实际代价不超过总摊还代价。
证明:
将摊还代价的定义式累加:
\(\sum_{i=1}^{n} \hat{c}i = \sum{i=1}^{n} c_i + \sum_{i=1}^{n} \left( \Phi(D_i) - \Phi(D_{i-1}) \right)\)
右端第二项为\(望远镜求和\),
化简得:\(\sum_{i=1}^{n} \left( \Phi(D_i) - \Phi(D_{i-1}) \right) = \Phi(D_n) - \Phi(D_0)\)
因此:\(\sum_{i=1}^{n} \hat{c}i = \sum{i=1}^{n} c_i + \Phi(D_n) - \Phi(D_0)\)
由题设 \(\Phi(D_0) = 0\) 且 \(\Phi(D_n) \ge 0\),
故:\(\sum_{i=1}^{n} c_i = \sum_{i=1}^{n} \hat{c}i - \Phi(D_n) \le \sum{i=1}^{n} \hat{c}_i\)
证毕。
1.4 使用势能法的一般步骤
- 设计势能函数 :根据数据结构的特点,选取合适的势能函数 \(\Phi\)。
- 验证非负性 :确认 \(\Phi(D_0) = 0\) 且对所有状态均有 \(\Phi(D) \ge 0\)。
- 计算摊还代价 :对每种操作,计算其摊还代价 \(\hat{c}_i\) 的上界。
- 求和得总代价:将所有操作的摊还代价求和,得到总实际代价的上界。
入门实例:二进制计数器
2.1 问题描述
考虑一个 \(k\) 位二进制计数器,初始值为 0。支持一种操作:加一。每次加一时,从最低位开始翻转位:若该位为 1,则翻为 0 并继续向高位进位;若该位为 0,则翻为 1 并停止。
定义操作的实际代价为本次操作中翻转的二进制位的数量。
问题 :从 0 开始,连续执行 \(n\) 次加一操作,求总翻转次数的上界。
2.2 朴素分析
单次操作最多翻转 \(k\) 位(当所有位均为 1 时)。因此,\(n\) 次操作的总代价上界为 \(O(nk)\)。但这是一个较为宽松的上界。
2.3 精确计数法
我们可以直接统计每一位被翻转的次数:
- 第 0 位(最低位):每次加一均翻转,共 \(n\) 次
- 第 1 位:每 2 次加一翻转一次,共 \(\lfloor n/2 \rfloor\) 次
- 第 2 位:每 4 次加一翻转一次,共 \(\lfloor n/4 \rfloor\) 次
- 第 \(i\) 位:每 \(2^i\) 次加一翻转一次,共 \(\lfloor n/2^i \rfloor\) 次
总翻转次数:
\(\sum_{i=0}^{k-1} \left\lfloor \frac{n}{2^i} \right\rfloor \le \sum_{i=0}^{\infty} \frac{n}{2^i} = 2n\)
因此总代价为 \(O(n)\)。
2.4 势能法分析
现在我们用势能法来得到同样的结论,以演示势能法的使用方法。
定义势能函数 :设 \(\Phi(D_i)\) 为第 \(i\) 次加一后,计数器中值为 1 的二进制位的个数。
- 初始状态:\(\Phi(D_0) = 0\)(计数器为 0,没有 1)
- 非负性:显然成立,1 的个数不可能为负
分析单次操作的摊还代价:
设第 \(i\) 次加一操作翻转了 \(t\) 位。其中 \(t-1\) 位是从 1 翻为 0(连续的末尾 1),1 位是从 0 翻为 1(第一个遇到的 0)。
因此:
- 实际代价:\(c_i = t\)
- 势能变化:\(\Phi(D_i) - \Phi(D_{i-1}) = 1 - (t-1) = 2 - t\)(少了 \(t-1\) 个 1,多了 1 个 1)
摊还代价:
\(\hat{c}i = c_i + \Phi(D_i) - \Phi(D{i-1}) = t + (2 - t) = 2\)
每次操作的摊还代价均为 \(O(1)\)。
由定理 1.1,总实际代价不超过总摊还代价:
\(\sum_{i=1}^{n} c_i \le \sum_{i=1}^{n} \hat{c}_i = 2n = O(n)\)
结论:\(n\) 次加一操作的总代价为 \(O(n)\)。
经典应用:区间开根号
3.1 问题描述
给定长度为 \(n\) 的正整数序列 \(a_1, a_2, \dots, a_n\),支持两种操作:
- 区间开根号 :对所有 \(i \in l, r\),令 \(a_i \leftarrow \lfloor \sqrt{a_i} \rfloor\)。
- 区间求和 :求 \(\sum_{i=l}^{r} a_i\)。
其中 \(n, q \le 10^5\),\(1 \le a_i \le 10^9\)。
3.2 核心观察
引理 3.1 :设 \(x\) 为正整数,定义 \(f(x) = \lfloor \sqrt{x} \rfloor\)。令 \(f^{(k)}(x)\) 表示 \(f\) 迭代 \(k\) 次的结果。则对任意 \(x \le 10^9\),存在 \(k \le 5\) 使得 \(f^{(k)}(x) = 1\)。
证明:
- \(10^9 \rightarrow 31622\)(第 1 次)
- \(31622 \rightarrow 177\)(第 2 次)
- \(177 \rightarrow 13\)(第 3 次)
- \(13 \rightarrow 3\)(第 4 次)
- \(3 \rightarrow 1\)(第 5 次)
且当 \(x = 1\) 时,\(f(x) = 1\),不再变化。
因此,每个元素至多被有效修改(值发生变化)5 次。
3.3 基于线段树的实现
使用线段树维护区间和与区间最大值。对于区间开根号操作:
- 若区间最大值 \(\le 1\),则区间内所有值均为 1,无需修改,直接返回。
- 否则,若当前节点为叶子节点,执行开根号。
- 否则,递归处理左右子节点,然后更新当前节点信息。
3.4 势能分析
定义势能函数 :设 \(\Phi\) 为所有线段树节点中,"区间内仍大于 1 的元素个数"的某种度量。更精确地,我们直接以"全体元素中大于 1 的个数"作为势能的等价物进行分析。
更严谨地,我们采用如下论证:
每个元素至多被有效修改(开根号后值变化)\(O(\log \log A)\) 次,其中 \(A\) 为值域上限。每次有效修改需要从根节点走到叶子节点,代价为 \(O(\log n)\)。因此所有有效修改的总代价为 \(O(n \log \log A \cdot \log n)\)。
查询操作的代价为 \(O(\log n)\),共 \(q\) 次,总查询代价为 \(O(q \log n)\)。
因此总时间复杂度为:
\(O((n \log \log A + q) \log n)\)
注:本题也可以形式化地定义势能函数。例如令 \(\Phi = \sum_{i=1}^{n} k_i\),其中 \(k_i\) 为 \(a_i\) 还能被有效开根号的次数。每次有效开根号使 \(k_i\) 减 1,势能减 1。初始势能为 \(O(n \log \log A)\)。每次递归到叶子并有效修改时,势能至少减 1。递归路径上的内部节点代价可以分摊到叶子的有效修改上。
3.5 代码实现
cpp
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 100005;
int n, q;
ll a[N];
struct SegTree {
struct Node {
int l, r;
ll sum;
ll mx;
} tr[N * 4];
void push_up(int p) {
tr[p].sum = tr[p * 2].sum + tr[p * 2 + 1].sum;
tr[p].mx = max(tr[p * 2].mx, tr[p * 2 + 1].mx);
}
void build(int p, int l, int r) {
tr[p].l = l, tr[p].r = r;
if (l == r) {
tr[p].sum = tr[p].mx = a[l];
return;
}
int mid = (l + r) >> 1;
build(p * 2, l, mid);
build(p * 2 + 1, mid + 1, r);
push_up(p);
}
void update_sqrt(int p, int l, int r) {
if (tr[p].mx <= 1) return;
if (tr[p].l == tr[p].r) {
tr[p].sum = (ll)sqrt(tr[p].sum);
tr[p].mx = tr[p].sum;
return;
}
int mid = (tr[p].l + tr[p].r) >> 1;
if (l <= mid) update_sqrt(p * 2, l, r);
if (r > mid) update_sqrt(p * 2 + 1, l, r);
push_up(p);
}
ll query(int p, int l, int r) {
if (tr[p].l >= l && tr[p].r <= r) return tr[p].sum;
int mid = (tr[p].l + tr[p].r) >> 1;
ll res = 0;
if (l <= mid) res += query(p * 2, l, r);
if (r > mid) res += query(p * 2 + 1, l, r);
return res;
}
} seg;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
seg.build(1, 1, n);
cin >> q;
while (q--) {
int op, l, r;
cin >> op >> l >> r;
if (l > r) swap(l, r);
if (op == 1) {
seg.update_sqrt(1, l, r);
} else {
cout << seg.query(1, l, r) << '\n';
}
}
return 0;
}
进阶:区间加 lowbit 与公共低位后缀
4.1 问题描述(简化版)
给定长度为 \(n\) 的正整数序列 \(a_1, a_2, \dots, a_n\),支持两种操作:
- 区间加 lowbit :对所有 \(i \in l, r\),令 \(a_i \leftarrow a_i + \operatorname{lowbit}(a_i)\),其中 \(\operatorname{lowbit}(x) = x \& (-x)\)。
- 区间求和 :求 \(\sum_{i=l}^{r} a_i\)。
保证任意时刻均有 \(1 \le a_i < 2^B\),其中 \(B = 50\)。
4.2 末尾零数的单调性
定义 4.1 :对正整数 \(x\),定义 \(\nu(x)\) 为 \(x\) 的二进制表示中末尾连续 0 的个数。即 \(\operatorname{lowbit}(x) = 2^{\nu(x)}\)。
引理 4.1 :对任意正整数 \(x\),有 \(\nu(x + \operatorname{lowbit}(x)) \ge \nu(x) + 1\)。
证明 :设 \(x\) 的二进制末尾有 \(k\) 个 0,则最低位的 1 在第 \(k\) 位。加 \(\operatorname{lowbit}(x) = 2^k\) 后,第 \(k\) 位的 1 向前进位。若第 \(k+1\) 位原为 0,则 \(\nu(x+\operatorname{lowbit}(x)) = k+1\);若第 \(k+1\) 位原为 1,则继续进位,末尾零数进一步增加。因此总有 \(\nu(x + \operatorname{lowbit}(x)) \ge k + 1 = \nu(x) + 1\)。
推论 :每个元素至多经历 \(B\) 次 lowbit 修改(因为 \(\nu(x) < B\),每次至少 +1)。
4.3 简化版的势能分析
定义势能函数:
\(\Phi = \sum_{i=1}^{n} (B - \nu(a_i))\)
- 初始势能:\(\Phi_0 \le Bn\)
- 非负性:由 \(\nu(a_i) < B\) 保证
每次有效修改(即元素值发生变化)时,\(\nu(a_i)\) 至少增加 1,故势能至少减少 1。每次查询不改变势能。
总有效修改次数不超过初始势能,即 \(O(Bn)\)。
使用树状数组或线段树维护,每次修改和查询的代价为 \(O(\log n)\)。总时间复杂度为 \(O((Bn + q) \log n)\)。
4.4 加一操作与公共低位后缀
现在引入加一操作(区间加 1),问题变得复杂,因为加一可以使偶数变为奇数,导致 \(\nu(x)\) 减小。
定义 4.2 :对于区间 \(l, r\),定义其公共低位后缀长度 \(k\) 为最大的非负整数 \(k\),使得对任意 \(i, j \in l, r\),有 \(a_i \equiv a_j \pmod{2^k}\)。
换言之,区间内所有数的二进制最低 \(k\) 位全部相同。
引理 4.2 :对区间内所有数同时加上一个整数 \(d\),公共低位后缀长度不变。
证明 :对任意 \(i, j\),有 \((a_i + d) - (a_j + d) = a_i - a_j\)。若 \(a_i \equiv a_j \pmod{2^k}\),则 \(2^k \mid (a_i - a_j)\),故 \(2^k \mid ((a_i + d) - (a_j + d))\),即 \(a_i + d \equiv a_j + d \pmod{2^k}\)。因此最大的 \(k\) 不变。
4.5 lowbit 修改的分类
设区间 \(l, r\) 的公共低位后缀长度为 \(k\),取区间首元素 \(v = a_l\) 作为代表值。
情形 1 :\(\nu(v) < k\)。
此时 \(v\) 的最低位 1 位于公共后缀内。由于所有数的最低 \(k\) 位相同,每个数的最低位 1 也在第 \(\nu(v)\) 位,故所有数的 \(\operatorname{lowbit}\) 值相同,均为 \(2^{\nu(v)}\)。
因此,本次 lowbit 修改等价于区间统一加上 \(2^{\nu(v)}\)。由引理 4.2,\(k\) 不变。
情形 2 :\(\nu(v) \ge k\)。
此时 \(v\) 的最低 \(k\) 位全为 0,所有数的最低 \(k\) 位也全为 0。每个数的 \(\operatorname{lowbit}\) 可能不同。分别执行加 lowbit 后,每个数的最低 \(k+1\) 位均为 0(第 \(k\) 位由 0 变 1 后再进位?需更细致分析)。
更准确地说:修改后,新的公共低位后缀长度 \(k' \ge k + 1\)。
4.6 线段树实现
每个线段树节点维护以下信息:
sum:区间和val:区间首元素的当前值(代表值)k:公共低位后缀长度add:加法懒标记
合并操作 :
设左子节点为 \(L\),右子节点为 \(R\),则父节点 \(P\):
P.sum = L.sum + R.sumP.val = L.valP.k = min(L.k, R.k, ν(L.val xor R.val))
其中约定 \(\nu(0) = B\)。
正确性说明:
- 左子区间内部的公共后缀长度为 \(L.k\)
- 右子区间内部的公共后缀长度为 \(R.k\)
- \(L.val\) 与 \(R.val\) 的异或值的末尾零数,即两个代表值的公共后缀长度
- 三者取最小值,即为整个区间的公共后缀长度
4.7 势能分析
定义势能函数:
\(\Phi = \sum_{u \in T} (B - k_u)\)
其中 \(T\) 为线段树所有节点的集合,\(k_u\) 为节点 \(u\) 的公共低位后缀长度,\(B = 50\)。
初始势能 :线段树共有 \(O(n)\) 个节点,每个节点的 \(B - k_u \le B\),故 \(\Phi_0 = O(Bn)\)。
非负性 :由 \(k_u \le B\) 保证。
下面分析各操作对势能的影响。
查询操作
不修改任何值,势能不变。实际代价为 \(O(\log n)\)。
区间加一操作(类型 2)
对于完全覆盖的节点,由引理 4.2,\(k\) 不变,势能不变。
对于部分覆盖的节点,需要下传懒标记并递归。每层至多有 2 个部分覆盖节点,共 \(O(\log n)\) 个。每个部分覆盖节点的 \(k\) 可能下降,但下降幅度不超过 \(B\)(从 \(B\) 到 0)。
因此,一次区间加一操作最多使势能增加 \(O(B \log n)\)。
区间加 lowbit 操作(类型 1)
我们按线段树的递归过程分析:
- 若当前节点完全被覆盖且 \(\nu(val) < k\):可以统一加 lowbit,\(k\) 不变,势能不变。实际代价 \(O(1)\)。
- 若当前节点完全被覆盖但 \(\nu(val) \ge k\):不能统一处理,需要递归到子节点。递归返回后,由于子节点的 \(k\) 均至少增加(情形 2),当前节点的 \(k\) 也至少增加 1,势能至少减少 1。
- 若当前节点部分覆盖:下传懒标记并递归。部分覆盖节点每层至多 2 个,共 \(O(\log n)\) 个,其 \(k\) 可能下降。
关键观察 :每次"不能直接处理、需要展开"的完全覆盖节点,在修改完成后,其 \(k\) 至少增加 1,势能至少减少 1。
设全部操作中,这种"展开"的总次数为 \(X\)。每次展开至少使势能减少 1。
初始势能为 \(O(Bn)\)。每次类型 2 操作最多补回 \(O(B \log n)\) 的势能,\(q\) 次操作最多补回 \(O(Bq \log n)\)。
由势能不能为负,有:
\(X \le O(Bn) + O(Bq \log n)\)
加上查询操作和常规边界访问的代价 \(O(q \log n)\),总时间复杂度为:
\(T = O(Bn + Bq \log n)\)
其中 \(B = 50\)。
4.8 代码实现
cpp
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 500005;
const int B = 50;
int n, q;
ll a[MAXN];
struct SegTree {
struct Node {
int l, r;
__int128 sum;
ll val;
int k;
ll add;
} tr[MAXN * 4];
static int ctzll(ll x) {
if (x == 0) return B;
return __builtin_ctzll(x);
}
static ll lowbit(ll x) {
return x & (-x);
}
void push_up(int p) {
tr[p].sum = tr[p * 2].sum + tr[p * 2 + 1].sum;
tr[p].val = tr[p * 2].val;
ll diff = tr[p * 2].val ^ tr[p * 2 + 1].val;
tr[p].k = min({tr[p * 2].k, tr[p * 2 + 1].k, ctzll(diff)});
}
void apply_add(int p, ll d) {
tr[p].sum += (__int128)d * (tr[p].r - tr[p].l + 1);
tr[p].val += d;
tr[p].add += d;
// k 不变:统一加法不改变公共低位后缀长度
}
void push_down(int p) {
if (tr[p].add) {
apply_add(p * 2, tr[p].add);
apply_add(p * 2 + 1, tr[p].add);
tr[p].add = 0;
}
}
void build(int p, int l, int r) {
tr[p].l = l, tr[p].r = r;
tr[p].add = 0;
if (l == r) {
tr[p].sum = a[l];
tr[p].val = a[l];
tr[p].k = B;
return;
}
int mid = (l + r) >> 1;
build(p * 2, l, mid);
build(p * 2 + 1, mid + 1, r);
push_up(p);
}
void modify(int p, int l, int r, int type) {
if (tr[p].l >= l && tr[p].r <= r) {
if (type == 2) {
apply_add(p, 1);
return;
}
// type == 1: 加 lowbit
int t = ctzll(tr[p].val);
if (t < tr[p].k) {
apply_add(p, lowbit(tr[p].val));
return;
}
// 不能统一处理,继续递归
}
push_down(p);
int mid = (tr[p].l + tr[p].r) >> 1;
if (l <= mid) modify(p * 2, l, r, type);
if (r > mid) modify(p * 2 + 1, l, r, type);
push_up(p);
}
__int128 query(int p, int l, int r) {
if (tr[p].l >= l && tr[p].r <= r) return tr[p].sum;
push_down(p);
int mid = (tr[p].l + tr[p].r) >> 1;
__int128 res = 0;
if (l <= mid) res += query(p * 2, l, r);
if (r > mid) res += query(p * 2 + 1, l, r);
return res;
}
} seg;
void print(__int128 x) {
if (x > 9) print(x / 10);
putchar(x % 10 + '0');
}
int main() {
freopen("rplrprq.in", "r", stdin);
freopen("rplrprq.out", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
}
seg.build(1, 1, n);
while (q--) {
int t, l, r;
cin >> t >> l >> r;
if (t == 1 || t == 2) {
seg.modify(1, l, r, t);
} else {
__int128 ans = seg.query(1, l, r);
print(ans);
cout << '\n';
}
}
return 0;
}
总结与方法
5.1 势能法的适用场景
势能法适用于满足以下特征的问题:
- 存在某种"退化"操作,它使数据结构的某种度量朝着"更简单"的方向单调变化。
- 存在另一种操作,它可能使数据结构"变复杂",但其影响可以被限制。
- 退化操作的总次数受限于初始状态与恢复操作的总影响。
5.2 设计势能函数的经验法则
| 问题类型 | 势能函数候选 |
|---|---|
| 开根号 / 取模 / 除法 | 大于阈值的元素个数,或各元素的"剩余可操作次数"之和 |
| 加 lowbit / 位操作 | 末尾零数的补和 |
| 排序 / 逆序对 | 逆序对数量 |
| 树结构 | 子树大小之和、节点深度和 |
| 自动机 / 状态机 | 当前状态的某种度量 |
核心原则:令昂贵操作使势能严格减少,便宜操作使势能增加或不变。势能有上界,则昂贵操作的总次数受限。
5.3 分析步骤
- 识别单调性:找出操作中哪些是"退化"的(使数据更简单),哪些是"恢复"的。
- 定义势能函数:确保初始有界、始终非负、退化操作使其减少、恢复操作使其增加但增量可控。
- 计算摊还代价:对每种操作,建立实际代价与势能变化的关系。
- 求和得界:累加所有操作的摊还代价,得到总代价上界。