势能分析(摊还分析)

势能分析(摊还分析)


基本概念

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 使用势能法的一般步骤

  1. 设计势能函数 :根据数据结构的特点,选取合适的势能函数 \(\Phi\)。
  2. 验证非负性 :确认 \(\Phi(D_0) = 0\) 且对所有状态均有 \(\Phi(D) \ge 0\)。
  3. 计算摊还代价 :对每种操作,计算其摊还代价 \(\hat{c}_i\) 的上界。
  4. 求和得总代价:将所有操作的摊还代价求和,得到总实际代价的上界。

入门实例:二进制计数器

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\),支持两种操作:

  1. 区间开根号 :对所有 \(i \in l, r\),令 \(a_i \leftarrow \lfloor \sqrt{a_i} \rfloor\)。
  2. 区间求和 :求 \(\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\),支持两种操作:

  1. 区间加 lowbit :对所有 \(i \in l, r\),令 \(a_i \leftarrow a_i + \operatorname{lowbit}(a_i)\),其中 \(\operatorname{lowbit}(x) = x \& (-x)\)。
  2. 区间求和 :求 \(\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.sum
  • P.val = L.val
  • P.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 势能法的适用场景

势能法适用于满足以下特征的问题:

  1. 存在某种"退化"操作,它使数据结构的某种度量朝着"更简单"的方向单调变化。
  2. 存在另一种操作,它可能使数据结构"变复杂",但其影响可以被限制。
  3. 退化操作的总次数受限于初始状态与恢复操作的总影响。

5.2 设计势能函数的经验法则

问题类型 势能函数候选
开根号 / 取模 / 除法 大于阈值的元素个数,或各元素的"剩余可操作次数"之和
加 lowbit / 位操作 末尾零数的补和
排序 / 逆序对 逆序对数量
树结构 子树大小之和、节点深度和
自动机 / 状态机 当前状态的某种度量

核心原则:令昂贵操作使势能严格减少,便宜操作使势能增加或不变。势能有上界,则昂贵操作的总次数受限。

5.3 分析步骤

  1. 识别单调性:找出操作中哪些是"退化"的(使数据更简单),哪些是"恢复"的。
  2. 定义势能函数:确保初始有界、始终非负、退化操作使其减少、恢复操作使其增加但增量可控。
  3. 计算摊还代价:对每种操作,建立实际代价与势能变化的关系。
  4. 求和得界:累加所有操作的摊还代价,得到总代价上界。
相关推荐
盘古开天16662 小时前
PPO算法代码实战(三):PyTorch从零实现PPO求解CartPole
人工智能·pytorch·算法
yyy(十一月限定版)2 小时前
CF2138A Cake Assignment
算法
盘古开天16662 小时前
PPO算法原理详解(下):Clip机制深入剖析与实践指南
人工智能·算法·机器学习
All for pursuit.2 小时前
【贪心-4】581.最短无序连续子数组
数据结构·c++·算法·leetcode
weixin_307779132 小时前
从“人操作仪器”到“Agent 自主发现”:OPL 金属材料自驱动实验室 MVP 技术论述
开发语言·人工智能·算法·架构
释厄6233 小时前
01AB 基本元理——任何智能体的三元法理·0=1→0≠1法理跃迁为天理
人工智能·windows·算法·microsoft·机器学习
Omics Pro3 小时前
经典多组学整合算法→商用云平台
数据库·人工智能·算法·机器学习·自然语言处理
ting94520003 小时前
深度拆解|1752vc Pitch Deck Analyzer 底层技术架构与核心算法原理
人工智能·算法·架构
贾斯汀frank3 小时前
基于 Java + Playwright 实现网站自动访问与数据采集
算法