势能分析
定义
首先什么是势能分析,先不用严格数学语言,大概就是,一个状态,我们可以操作一定次数,使其无法继续操作,那么当前这个状态,操作到无法操作,需要的操作次数,就是这个状态的势能
以栈为例
这个理论的意义是,可以帮我们分析一些数据结构的复杂度。比如单调栈,一个栈内有 n n n个元素,那对这个栈能进行的操作,就是不断弹出元素,至多能进行 O ( n ) O(n) O(n)次,操作,那么可以认为这个状态的是势能是 O ( n ) O(n) O(n)的。
以上的分析很简单,因为势能在不断减小。比较麻烦的情况是,操作不仅能减少/释放势能,还能增加势能。比如我们也可以往栈内插入元素,一共 q q q次操作,每次可以是插入,或弹出元素,此时的复杂度如何分析?
依然可以用势能分析,因为每次插入一个元素,对势能的增长是 O ( 1 ) O(1) O(1)的,那么插入 q q q次最多也就增加 O ( q ) O(q) O(q)的势能,总势能最大 O ( n + q ) O(n+q) O(n+q),全部释放了也没多少。
以单调栈为例
一个进阶一点的分析是单调栈的复杂度。初学时,很容易被那个 w h i l e ( ) q . p o p ( ) while()q.pop() while()q.pop()所迷惑,认为每一轮弹出的复杂度,都是 O ( n ) O(n) O(n)的,而且一共有 O ( n ) O(n) O(n)轮,那总复杂度不就 O ( n 2 ) O(n^2) O(n2)了吗?
但实际上,单调栈的过程,每次增加势能都是压入一个元素,是 O ( 1 ) O(1) O(1)的,增加 n n n次,增加的总势能是 O ( n ) O(n) O(n)的,弹出操作会消耗这个势能,即使全部消耗了,也只有 O ( n ) O(n) O(n)的复杂度。
如果在某一轮全部消耗了,确实会让这一轮的弹出复杂度为 O ( n ) O(n) O(n),但是消耗的势能,不会超过增加的总势能,而增加的总势能是 O ( n ) O(n) O(n)的,所以消耗势能的操作,总复杂度也是 O ( n ) O(n) O(n)的
以珂朵莉树为例
珂朵莉树的复杂度分析,不使用势能分析的话,有点难以理解,但是在势能分析的框架下,也很好理解。
珂朵莉树每次对一个区间进行推平,也就是把一堆区间进行合并,或者说删除,然后插入一个新的大区间。我们可以把这个区间删除,视为释放势能。一个状态的势能,就是不同颜色的区间个数。
但是,推平的区间边界 l , r l,r l,r,可能落在区间内部,此时我们会把 l , r l,r l,r所在区间,分别在 l , r l,r l,r位置断开,产生新区间,然后把新区间进行合并。也就是说我们的操作会增加区间个数,这就是增加势能的操作。
这会导致复杂度退化吗?并不会,因为每次操作最多增加 2 2 2个新区间,也就是增加的势能是 O ( 1 ) O(1) O(1)的,那么初始势能是 O ( n ) O(n) O(n), q q q次操作增加的势能是 O ( q ) O(q) O(q),可消耗的总势能,只有 O ( n + q ) O(n+q) O(n+q),至此我们就证明了珂朵莉树的区间删除次数,至多 O ( n + q ) O(n+q) O(n+q)次。
势能线段树
理论分析
铺垫了这么多,进入正题。势能线段树就是用势能分析来分析线段树操作的复杂度上界,一般就是线段树上每个元素,都只能进行有限次操作,一般是 O ( log V ) O(\log V) O(logV)次,那么一个元素势能就是 O ( log V ) O(\log V) O(logV),整个线段树 n n n个元素,总势能就是 O ( n log V ) O(n\log V) O(nlogV)。
所以尽管有 q q q次区间操作,但我们可以让每一次区间操作,只要区间内有元素还能操作,就暴力递归到这个元素所在的叶子,进行操作,这看似会导致每次操作最坏都是 O ( n log n ) O(n\log n) O(nlogn)(暴力递归到所有叶子,相当于遍历整个线段树所有节点),总复杂度 O ( q n log n ) O(qn\log n) O(qnlogn),但根据我们前面的势能分析,实际暴力递归到叶子的次数只有 O ( n log V ) O(n\log V) O(nlogV),整体复杂度 O ( n log V log n ) O(n\log V\log n) O(nlogVlogn)。因为很多元素在区间操作几次之后,就无法操作了,并不会递归到这些元素对应的叶子。
势能线段树相比普通线段树,虽然只增加了一个 l o g log log的复杂度,但我们为什么要暴力递归到叶子进行处理?因为有一些操作,我们无法 O ( 1 ) O(1) O(1)时间计算出它对一个区间元素的影响,比如区间开方,每个元素开方后变化量都不一样,不像区间加那样每个元素贡献相同。那么我们就只能递归到叶子,每次只对一个元素进行操作。
所以势能线段树的要点就是:如果一个区间操作无法 O ( 1 ) O(1) O(1)确定对区间的影响,每个元素可进行的操作次数有限,那么我们可以寻找一个判断条件,在线段树递归时,根据这个条件判断区间内是否有需要暴力递归的元素,如果有则暴力递归,否则返回。
如果还有赋值操作,把元素从势能较小的值,变成势能较大的值了,一般就是把元素值变大了,会增加势能。如果是单点赋值,每次增加势能是 O ( 1 ) O(1) O(1)的,增加的总势能只有 O ( q log V ) O(q\log V) O(qlogV),整体复杂度 O ( ( n + q ) log V log n ) O((n+q)\log V\log n) O((n+q)logVlogn),仍然可控
如果是区间更新,则较为复杂,如果可以转化为类似珂朵莉树的颜色段均摊,那么仍然可以保证复杂度可控。后面将在例题里具体分析
例题
P4145 上帝造题的七分钟 2 / 花神游历各国
区间开方,区间和查询
这就是我们前面说的
- 开方操作,无法 O ( 1 ) O(1) O(1)确定对一个区间的影响。
- 同时开方只能操作 O ( log V ) O(\log V) O(logV)次(这只是上界,实际次数比这更小),变成 1 1 1之后就不变了
- 所以可以找一个条件(区间内最大值是否小于 2 2 2)来判断区间内是否有需要暴力递归的元素
至此势能线段树的三个要素都齐了,实现就很简单了。
c
struct Tree {
#define ls u<<1
#define rs u<<1|1
struct Node {
int l, r;
ll mx, todo, sum;
} tr[N << 2];
void pushup(int u) {
tr[u].sum = tr[ls].sum + tr[rs].sum;
tr[u].mx = max(tr[ls].mx, tr[rs].mx);
}
void pushdown(int u) {
if (tr[u].todo != -1) {
tr[ls].sum = tr[u].todo * (tr[ls].r - tr[ls].l + 1);
tr[rs].sum = tr[u].todo * (tr[rs].r - tr[rs].l + 1);
tr[ls].mx = tr[u].todo;
tr[rs].mx = tr[u].todo;
tr[ls].todo = tr[u].todo;
tr[rs].todo = tr[u].todo;
tr[u].todo = -1;
}
}
void build(int u, int l, int r, vi &a) {
tr[u] = {l, r, 0, -1, 0};
if (l == r) {
tr[u].mx = tr[u].sum = a[l];
return;
}
int mid = (l + r) >> 1;
build(ls, l, mid, a);
build(rs, mid + 1, r, a);
pushup(u);
}
void modify(int u, int l, int r, int val) {
if (tr[u].l >= l && tr[u].r <= r) {
tr[u].sum = val * (tr[u].r - tr[u].l + 1);
tr[u].mx = val;
tr[u].todo = val;
return ;
} else {
int mid = (tr[u].l + tr[u].r) >> 1;
pushdown(u);
if (mid >= l) modify(ls, l, r, val);
if (r > mid) modify(rs, l, r, val);
pushup(u);
}
}
void upd(int u, int l, int r) {
if (tr[u].l == tr[u].r) {
tr[u].mx = tr[u].sum = sqrt(tr[u].mx);
return;
}
if (tr[u].l >= l && tr[u].r <= r && tr[u].mx == 1) {
return;
} else {
int mid = (tr[u].l + tr[u].r) >> 1;
pushdown(u);
if (mid >= l) upd(ls, l, r);
if (r > mid) upd(rs, l, r);
pushup(u);
}
}
ll query(int u, int l, int r) {
if (l <= tr[u].l && tr[u].r <= r) return tr[u].sum;
pushdown(u);
int mid = (tr[u].l + tr[u].r) >> 1;
if (r <= mid)return query(ls, l, r);
if (l > mid)return query(rs, l, r);
return query(ls, l, r) + query(rs, l, r);
}
} t;
void solve() {
int n, q;
cin >> n ;
vi a(n + 1);
rep(i, 1, n) {
cin >> a[i];
}
t.build(1, 1, n, a);
cin >> q;
rep(i, 1, q) {
int op, l, r;
cin >> op >> l >> r;
if (l > r)swap(l, r);
if (op == 0) {
t.upd(1, l, r);
} else {
cout << t.query(1, l, r) << '\n';
}
}
}
D. The Child and Sequence
区间取模 单点更新 区间求和
区间取模,区间元素值不同,很难确定影响,考虑势能线段树。
取模操作的一个性质是, x m o d y x \mod y xmody,如果 y < = x y<=x y<=x,那么取模后的结果至少折半。如果 y < x / 2 y<x/2 y<x/2,显然取模后 < y < x / 2 <y<x/2 <y<x/2,如果 x / 2 < y < = x x/2<y<=x x/2<y<=x,那么取模就等于减法, x − y < x − x / 2 = x / 2 x-y<x-x/2=x/2 x−y<x−x/2=x/2。
所以对于模数不超过元素值的情况,我们可以暴力递归,势能为 O ( n log V ) O(n\log V) O(nlogV)的。
那么是否递归的条件也就确定了,检查区间最大值,是否小于当前模数,如果小于,则取模后所有元素值都不变,无影响,返回;否则说明区间内至少有一个元素,取模后折半,我们往下递归寻找这些元素。
至于单点修改,前面分析过,对势能影响不大。我们在势能线段树基础上,增加一个普通线段树单点修改即可。
F. SUM and REPLACE
区间求和,区间内元素赋值为 x = d ( x ) , d ( x ) 代表 x 的因子个数 x=d(x),d(x)代表x的因子个数 x=d(x),d(x)代表x的因子个数
分析一下什么时候无法操作?显然所有 x > 2 x>2 x>2, d ( x ) < x d(x)<x d(x)<x,会一直变小直到变成 2 2 2,到了 2 2 2之后, x = d ( x ) x=d(x) x=d(x),不变。对于 x = 1 x=1 x=1,也有 x = d ( x ) x=d(x) x=d(x),不变。
所以区间内都无法操作的条件是最大值 < = 2 <=2 <=2,维护区间最大值来剪枝即可
A. And RMQ
区间按位与,单点修改,求区间最大值
单点修改不会使得复杂度劣化。
主要思考剪枝条件,什么时候整个区间都不存在需要更新的元素了?对于一个元素,按位与操作无效的条件是,元素是即将与上的值的子集。也就是x&y=x,x|y=y。那么判断一个区间的元素是否都按位与之后无效,可以把区间内元素去并,也就是按位或,如果这个并集仍然是 y y y的子集,那么这个区间按位与之后都不变,可以剪枝。
F - Clearance
区间减 k k k,如果 a i < k a_i<k ai<k则减 a i a_i ai,也就是减到零,对于已经变成零的元素,后续操作忽略。每次操作问区间内减少值的和。
和前面的有一定区别,单个元素的势能分析,不再是每次进行一个操作,这个操作只会生效 log \log log次,区间减 k k k是可以执行很多次的,所以我们的势能设计,肯定不是把减法视为一次释放势能的操作
那么每个元素有什么只能进行有限次的操作呢?就只有从 a i > 0 a_i>0 ai>0变成 a i = 0 a_i=0 ai=0了,每个元素至多只会发生一次。
那么我们的剪枝条件就应该是,区间内是否存在,在本次操作后,会变成零的元素。如果不存在,直接执行一个整体区间减,和普通的懒标记线段树完全一样;如果存在,则递归到这些会变成零的元素。为判断区间内是否存在本次操作后变成零的元素,可以维护区间最小值,如果最小值 < = k <=k <=k,则存在需要暴力的元素。
剩下还有一些细节
- 递归到叶子后,一个元素变成零了,为了让他不影响我们后续的剪枝,也就是不影响区间最小值的计算,可以把这个元素赋值为无穷大
- 对于答案的计算,如果一个区间没有需要暴力的元素,那么答案就是,区间内未变成零的元素个数,乘上 k k k;对于暴力的元素,答案是当前的 a i a_i ai。我们在修改时,统计这两种答案的和即可,也就是修改和计算答案都在一个递归内完成
- 一个不需要暴力的区间,返回的是区间内未变零的元素个数,这个变量需要我们线段树维护。初始每个叶子的这个值都为1,元素变零后,把这个值改为0
Ex - I like Query Problem
区间除法,区间赋值,区间求和
区间除法看来和前面的区间开方,区间取模都类似,每次至少折半,如果我们把势能定义为, n n n个元素的除法操作次数之和,总势能有限,看起来简单。
但难点在于区间赋值,开头我们分析了,如果是单点赋值,每次操作增加常数的势能,是可接受的,但是区间赋值,每次会增加 O ( n log V ) O(n\log V) O(nlogV)的势能,增加势能的总量可能是 O ( q n log V ) O(qn\log V) O(qnlogV)的!
这说明使用这个势能定义,是不能保证复杂度的,需要换个势能定义。注意到区间赋值就是区间推平,这让我们想到了什么?前面有一种势能分析也是处理区间推平操作的,就是珂朵莉树的势能分析。
也就是,把势能定义为颜色段个数,或者说元素值相同的极大区间段数,那么每次区间推平操作,至多增加两个颜色段,增加的势能是常数的,增加势能的总量就只有 O ( q ) O(q) O(q)了,可以接受。
所以,基于这个势能定义,我们的剪枝条件应该是:如果一个区间元素全部相同,则一块进行区间除法, O ( 1 ) O(1) O(1)地计算出除法的影响,打懒标记,然后返回。具体来说,区间全部相同,可以用区间最大值等于区间最小值来判断,区间除法,可以看作区间赋值为除法后的结果,计算出除法后的结果,就可以套用区间赋值的懒标记线段树的实现。
c
struct Tree {
#define ls u<<1
#define rs u<<1|1
struct Node {
int l, r;
ll mx, mn, todo, sum;
} tr[N << 2];
void pushup(int u) {
tr[u].sum = tr[ls].sum + tr[rs].sum;
tr[u].mx = max(tr[ls].mx, tr[rs].mx);
tr[u].mn = min(tr[ls].mn, tr[rs].mn);
}
void pushdown(int u) {
if (tr[u].todo != -1) {
tr[ls].sum = tr[u].todo * (tr[ls].r - tr[ls].l + 1);
tr[rs].sum = tr[u].todo * (tr[rs].r - tr[rs].l + 1);
tr[ls].mx = tr[u].todo;
tr[rs].mx = tr[u].todo;
tr[ls].mn = tr[u].todo;
tr[rs].mn = tr[u].todo;
tr[ls].todo = tr[u].todo;
tr[rs].todo = tr[u].todo;
tr[u].todo = -1;
}
}
void build(int u, int l, int r, vi &a) {
tr[u] = {l, r, 0, inf, -1, 0};
if (l == r) {
tr[u].mx = tr[u].mn = tr[u].sum = a[l];
return;
}
int mid = (l + r) >> 1;
build(ls, l, mid, a);
build(rs, mid + 1, r, a);
pushup(u);
}
void modify(int u, int l, int r, int val) {
if (tr[u].l >= l && tr[u].r <= r) {
tr[u].sum = val * (tr[u].r - tr[u].l + 1);
tr[u].mx = tr[u].mn = val;
tr[u].todo = val;
return ;
} else {
int mid = (tr[u].l + tr[u].r) >> 1;
pushdown(u);
if (mid >= l) modify(ls, l, r, val);
if (r > mid) modify(rs, l, r, val);
pushup(u);
}
}
void div(int u, int l, int r, int val) {
if (tr[u].l >= l && tr[u].r <= r) {
if (tr[u].mx == tr[u].mn) {
int x = tr[u].mx;
x /= val;
tr[u].mn = tr[u].mx = x;
tr[u].sum = x * (tr[u].r - tr[u].l + 1);
tr[u].todo = x;
return;
} else {
pushdown(u);
div(ls, l, r, val);
div(rs, l, r, val);
pushup(u);
}
} else {
int mid = (tr[u].l + tr[u].r) >> 1;
pushdown(u);
if (mid >= l) div(ls, l, r, val);
if (r > mid) div(rs, l, r, val);
pushup(u);
}
}
ll query(int u, int l, int r) {
if (l <= tr[u].l && tr[u].r <= r) return tr[u].sum;
pushdown(u);
int mid = (tr[u].l + tr[u].r) >> 1;
if (r <= mid)return query(ls, l, r);
if (l > mid)return query(rs, l, r);
return query(ls, l, r) + query(rs, l, r);
}
} t;
void solve() {
int n, q;
cin >> n >> q;
vi a(n + 1);
rep(i, 1, n) {
cin >> a[i];
}
t.build(1, 1, n, a);
rep(i, 1, q) {
int op, l, r, x;
cin >> op >> l >> r;
if (op == 1) {
cin >> x;
t.div(1, l, r, x);
} else if (op == 2) {
cin >> x;
t.modify(1, l, r, x);
} else {
cout << t.query(1, l, r) << '\n';
}
}
}
D. Why Does Every Baozii Cup Have a GCD Problem
区间与 x x x计算 g c d gcd gcd,单点修,区间和
单点修不影响复杂度。 g c d gcd gcd操作,只有 g c d ( x , y ) , y = k x gcd(x,y),y=kx gcd(x,y),y=kx时没有影响,其余都会让 x x x至少折半,那么可以以有效 g c d gcd gcd操作次数作为势能。
那么区间不需要暴力递归的条件是,区间内所有元素 a i a_i ai,都是本次操作的 x x x的因子。如何判断这一点?考利维护区间 l c m lcm lcm,如果区间 l c m lcm lcm是 x x x因子,那么区间所有元素都是 x x x因子,直接返回。
注意区间 l c m lcm lcm这个东西可能爆 l o n g l o n g long long longlong,可以设为大于某个值时,统一设成一个上界,这个上界只要大于 g c d gcd gcd操作的 x x x,就不会对我们的剪枝产生影响。
c
struct Tree {
#define ls u<<1
#define rs u<<1|1
struct Node {
int l, r;
ll mx, lcm, todo, sum;
} tr[N << 2];
void pushup(int u) {
tr[u].sum = tr[ls].sum + tr[rs].sum;
tr[u].mx = max(tr[ls].mx, tr[rs].mx);
tr[u].lcm = lcm(tr[ls].lcm, tr[rs].lcm);
if (tr[u].lcm > M1) {
tr[u].lcm = M1;
}
}
void pushdown(int u) {
if (tr[u].todo != -1) {
tr[ls].sum = tr[u].todo * (tr[ls].r - tr[ls].l + 1);
tr[rs].sum = tr[u].todo * (tr[rs].r - tr[rs].l + 1);
tr[ls].mx = tr[u].todo;
tr[rs].mx = tr[u].todo;
tr[ls].lcm = tr[u].todo;
tr[rs].lcm = tr[u].todo;
tr[ls].todo = tr[u].todo;
tr[rs].todo = tr[u].todo;
tr[u].todo = -1;
}
}
void build(int u, int l, int r, vi &a) {
tr[u] = {l, r, 0, inf, -1, 0};
if (l == r) {
tr[u].mx = tr[u].lcm = tr[u].sum = a[l];
return;
}
int mid = (l + r) >> 1;
build(ls, l, mid, a);
build(rs, mid + 1, r, a);
pushup(u);
}
void modify(int u, int l, int r, int val) {
if (tr[u].l >= l && tr[u].r <= r) {
tr[u].sum = val * (tr[u].r - tr[u].l + 1);
tr[u].mx = tr[u].lcm = val;
tr[u].todo = val;
return ;
} else {
int mid = (tr[u].l + tr[u].r) >> 1;
pushdown(u);
if (mid >= l) modify(ls, l, r, val);
if (r > mid) modify(rs, l, r, val);
pushup(u);
}
}
void upd(int u, int l, int r, int v = 0) {
if (tr[u].l == tr[u].r) {
tr[u].lcm = tr[u].sum = gcd(tr[u].lcm, v);
return;
}
if (tr[u].l >= l && tr[u].r <= r && v % tr[u].lcm == 0) {
return;
} else {
int mid = (tr[u].l + tr[u].r) >> 1;
pushdown(u);
if (mid >= l) upd(ls, l, r, v);
if (r > mid) upd(rs, l, r, v);
pushup(u);
}
}
ll query(int u, int l, int r) {
if (l <= tr[u].l && tr[u].r <= r) return tr[u].sum;
pushdown(u);
int mid = (tr[u].l + tr[u].r) >> 1;
if (r <= mid)return query(ls, l, r);
if (l > mid)return query(rs, l, r);
return query(ls, l, r) + query(rs, l, r);
}
} t;
void solve() {
int n, q;
cin >> n >> q;
vi a(n + 1);
rep(i, 1, n) {
cin >> a[i];
}
t.build(1, 1, n, a);
rep(i, 1, q) {
int op, l, r, k, x;
cin >> op ;
if (op == 1) {
cin >> k >> x;
t.modify(1, k, k, x);
} else if (op == 2) {
cin >> l >> r >> x;
t.upd(1, l, r, x);
} else {
cin >> l >> r;
cout << t.query(1, l, r) << '\n';
}
}
}