《区间最值操作与历史最值问题》 学习笔记

一、势能分析

势能分析法可以利用势函数来分析一些数据结构的时间复杂度。

简单而言,对于一个数据结构,定义其关于某个信息的势能函数 \(\Phi\),将信息映射成一个实数。例如信息可以是所有树节点的深度,元素个数等。

对于有穷的操作,通过分析不同操作对 \(\Phi(D)\) 的改变量,求出其增加 / 减少的上界/ 下界,即可求出一个时间复杂度上限。

例 1:【LOJ6507】A

一个简单的剪枝思路是当当前修改不会对所有值造成影响或者不会改变所有数的相对大小时,直接打上标记。维护区间所有数按位或 / 与 \(os, as\)。 若区间或上 \(x\),当 \(x\) 有的位所有数都有时显然不用修改,当所有数在 \(x\) 为 1 的位都是 1 或都是 0 显然不会改变相对大小,打上区间或标记。对于与同理,打上区间与标记,注意或与与的相互影响。

这样的时间复杂度可以证明是 \(O(n+m)\log n\log V)\) 的。原因如下:

每个点的势能函数 \(\Phi\) 等于其子树中每一位上不全为 0 / 1 的个数。一次暴力 DFS,至少会使得一位全部相同。标记下传不会改变势能,打上标记最多增加 \(O(k\log n)\) 的势能,初始势能为 \(O(nk\log n)\),因此时间复杂度为 \(O((n+m)\log n\log V)\)

核心代码:

	inline void upd(int p, int l, int r, int o, int x) {
		if(l <= t[p].l && t[p].r <= r) {
			if(!o) {
				if((t[p].s2 | x) == t[p].s2) return ; 
				if((t[p].s1 | x) == (t[p].s2 | x)) return push_down_tg1(p, x), void(); 
			}
			if(o) {
				if((t[p].s1 & x) == t[p].s1) return ; 
				if((t[p].s1 & x) == (t[p].s2 & x)) return push_down_tg2(p, x), void(); 
			}
		} push_down(p); int mid = t[p].l + t[p].r >> 1; 
		if(l <= mid) upd(p << 1, l, r, o, x); if(r > mid) upd(p << 1 | 1, l, r, o, x); 
		push_up(p); 
	}

例 2:【BZOJ3211】花神游历各国

由于一个 \(x\) 最多在开 \(\log\log x\) 方后变成 1,设势能函数为所有叶子节点的开方次数,若区间最大值不为 1 则暴力递归到叶子修改,时间复杂度 \(O(n\log n \times \log \log n)\)

例 3:【LOJ6029】 「雅礼集训 2017 Day1」市场

设一个节点的势能函数为区间极差,则每次除以 \(d\) 后极差也会除以 \(d\),当极差不为 0 时暴力递归子树。但存在的问题是可能向下取整不完整,例如 \(3, 4\) 对 2 取整变成 $1,2 $,极差不变。

容易发现,这种情况只会出现在极差为 1 的情况。注意到取整前后所有数减去的差相等,因此设 \(v = \left\lfloor\dfrac{\max - \min}d\right\rfloor\),当 \(\max - v = \min - v\) 时直接上标记,即可解决这种情况。

容易分析时间复杂度为 \(O(n\log^2n)\)

核心代码:

inline void upd_div(int p, int l, int r, int v) {
    if(l <= t[p].l && t[p].r <= r) {
    	if(t[p].mx - (floor((db)t[p].mx / v)) == t[p].mn - (floor((db)t[p].mn / v))) return push_down_tag(p, -t[p].mx + (floor((db)t[p].mx / v))), void(); 
	}	
	int mid = t[p].l + t[p].r >> 1; push_down(p); 
	if(l <= mid) upd_div(p << 1, l, r, v); if(r > mid) upd_div(p << 1 | 1, l, r, v); 
	push_up(p); 
}

二、区间最值操作

以一道经典例题引入:

例 4:【HDU5306】Gorgeous Sequence

维护一个长度为 \(n\) 的数组,支持以下三种操作:

  1. 区间对 \(x\)\(\min\)
  2. 询问区间最大值
  3. 询问区间和

对于线段树的每一个节点, 维护最大值 \(mx\), 最大值个数 \(cnt\), 严格次大值 \(sec\)

在区间对 \(x\)\(\min\) 时,

  • \(mx \leq x\), 则不会修改任何值, 直接退出。
  • \(x < mx\), 且 \(sec \geq x\), 则所有的 \(mx\) 变成 \(x\), 区间和加上 \(cnt \times (x - mx)\), 退出。
  • 否则递归左右子树。

该做法的时间复杂度为 \(O(n\log n)\)

吉老师的证法是:

线段树中每个点维护当前子树最大值,若一个点与其父亲权值相同则擦除该点标记。剩下的存在标记的叶子节点形成了从叶子到根的若干条不交链,显然标记总数是 \(O(n)\) 的。一个叶子节点的权值等于从当前叶子出发,向父亲方向走到第一个存在标记的节点。

维护的次小值相当于子树中权值最大的点(不包含根),而暴力 DFS 相当于回收掉子树中权值大于 \(x\) 的所有标记。

对不同标记分类,同一次区间取 \(\min\) 产生的标记和同一个标记下传得到的两个标记属于同一个标记类。每个标记类的权值为其所有存在标记的店在线段树上形成的虚树大小和(即所有包含至少一个标记的线段树节点)。

每次取 \(\min\) 最多使得所有经过的点加入虚树大小中,\(O(\log n)\),每次标记下传增加 \(O(1)\) 个标记。\(m\) 次操作的总数为 \(O(m\log n)\),每次暴力 DFS 时,该子树内一定存在需要回收的标记,回收后该节点不存在标记,因此每暴力经过一个点都会使得标记减少 1。即势函数的总变化量为 \(O(m\log n)\)

更简单的证法是:

设每个点的势能函数 \(\Phi\) 为子树内不同值的数量,初始为 \(O(n\log n)\),每次暴力 DFS 时,至少会把最大值和严格次大值合并,势能函数减一,下传标记不改变势能,因此时间复杂度 $O((n+m)\log n) $。

核心代码:

inline void push_down(int p) {
    int mx = max(t[p << 1].mx, t[p << 1 | 1].mx); 
    if(t[p << 1].mx == mx) push_down_tg(p << 1, t[p].tg); 
    if(t[p << 1 | 1].mx == mx) push_down_tg(p << 1| 1, t[p].tg);
}
	inline void upd(int p, int l, int r, int x) {
		if(t[p].mx <= x) return ; 
		if(l <= t[p].l && t[p].r <= r && t[p].cmx < x) return push_down_tg(p, x - t[p].mx), void(); 
		int mid = t[p].l + t[p].r >> 1; push_down(p); 
		if(l <= mid) upd(p << 1, l, r, x); if(r > mid) upd(p << 1 | 1, l, r, x); 
	    push_up(p); 
	}

例题 5:Picks loves segment tree

在例 1 的基础上,加上区间加操作

可以沿用上面的做法,由于对 \(x\)\(\min\) 时只能修改最大值,相当于把维护的元素分成了 最大值非最大值, 用两个懒标记记录。在标记下传时,需要根据两个儿子的最大值进行讨论,最大值标记只下传到最大的儿子(若相等则同时下传),对于非最大值儿子,都下传非最大值标记。

时间复杂度同样可以证明为 \(O(m\log^2 n)\),在实际表现中类似于 \(O(m\log n)\)

当同时出现取 \(\max\),取 \(\min\) 时,上面的分析仍然适用。维护最大 / 最大个数 / 严格次大,最小 / 最小个数 / 严格最小。由于两种操作只有在标记下传时有影响,需要在 \(\max = \min\) 以及互为严格小时特判。

例题 6:【BZOJ4355】Play with sequence

维护长度为 \(n\) 的序列,支持:

  1. 区间 \([l, r]\) 赋值为 \(C \ \ (C\geq 0)\)
  2. 区间 \([l, r]\) 赋值为 \(\max(a_i+c, 0)\)
  3. 询问区间 $= 0 $ 的个数

显然区间最小值 \(\geq 0\),只用维护区间最小值。将所有操作写成 \(\max(x+a,b)\) 的形式,则操作 1 等价于 \((-\infty, C)\),操作 2 为 \((a, 0)\)。由于 \(\max(\max(x+a,b)+c,d) = \max(x+a+c,\max(b+c,d))\),于是标记之间具有结合律,\((a, b)+(c,d) \rightarrow (a+c,\max(b+c,d))\)。维护这个标记即可,此时不用维护对于最大值或非最大值的 tag。对于下传时也不需要判断最小值的位置。

还可以直接套用用区间 +,区间取 \(\max\) 的模板。

三、历史最值问题

例题 7:【模板】吉司机线段树/势能线段树

维护长度为 \(n\) 的序列,支持:

1:区间 \([l, r]\)\(c\)

2:区间 \([l, r]\)\(v\)\(\min\)

3:区间求和

4:区间最大值

5:区间历史最大值

唯一的问题是维护历史最大值。历史类问题可以分为三类:历史最大 / 最小值,历史版本和。

以历史最大值为例,本质上是所有 tag 的最大前缀和加上当前最值。方便起见,我们不合并标记,而是把标记看做按照时间顺序依次加入当前点的队列。由于父亲节点的 tag 时间一定大于儿子节点,因此 push_down 可以看做将父亲的队列加到儿子的后面。最大前缀和要么是不加入父亲节点的 mxtag,要么是加入父亲节点,即 nowtag + mxftag。而历史和要么是未加入父亲的历史和 mxb,要么是 max(a_i) + mxftag = mxa + mxftag。

对于最小值同理。

根据上面的经验,我们还是将 tag 分为对最大值生效的,对非最大值生效的,每个 tag 都有其对应的 mxtag,因此总共有 4 个 tag。

主要代码如下:

	inline void push_down_tg(int p, ll tg1, ll tg2, ll tg3, ll tg4) { 
	    t[p].tg3 = max(t[p].tg3, t[p].tg1 + tg3), t[p].tg4 = max(t[p].tg4, t[p].tg2 + tg4); 
		t[p].tg1 += tg1, t[p].s += (ll)tg1 * t[p].cnt + (ll)tg2 * (t[p].len - t[p].cnt); 
		t[p].mxb = max(t[p].mxb, max(t[p].mxa + tg3, t[p].sec + tg4)), t[p].mxa += tg1; 
		if(t[p].sec != -INF) t[p].sec += tg2, t[p].tg2 += tg2; 
	}
	inline void push_down(int p) {
		const int lc = p << 1, rc = p << 1 | 1; 
		ll mx = max(t[lc].mxa, t[rc].mxa); 
		if(mx == t[lc].mxa) push_down_tg(lc, t[p].tg1, t[p].tg2, t[p].tg3, t[p].tg4); 
		else push_down_tg(lc, t[p].tg2, t[p].tg2, t[p].tg4, t[p].tg4); 
		if(mx == t[rc].mxa) push_down_tg(rc, t[p].tg1, t[p].tg2, t[p].tg3, t[p].tg4); 
		else push_down_tg(rc, t[p].tg2, t[p].tg2, t[p].tg4, t[p].tg4); 
		t[p].tg1 = t[p].tg2 = t[p].tg3 = t[p].tg4 = 0; 
	}

对于历史最大值还有一种维护方法,设辅助数组 \(C = B_i- A_i\)。可以看做当前 \(A_i\) 与历史最大值的差值,则每次 \(A_i\leftarrow A_i+c\) 后,\(C\) 会减去 \(c\),但不会小于 \(0\),因此 \(C_i \leftarrow \max(0,C_i - c)\)。 就变成了例 6。对于最小值同理。但时间复杂度变为 \(O(n\log^2n)\)

例8 【BZOJ3064】CPU 监控

由于存在区间赋值和区间加操作,不能直接合并起来(需要保证赋值操作在加操作前或后,但都无法维护)。

注意到一次赋值操作后,接下来的所有加操作都可以看成赋值操作,因此区赋值操作后就可以全是赋值操作,整个操作队列可以看做先是一些加操作然后是赋值操作,分开维护即可。由于两种标记的有序性,在下传时需要先下传加标记再下传赋值标记,只用额外维护当前前是否是赋值标记即可。

例 9【BZOJ4262】Sum

首先二维差分成 4 个前缀,可以利用单调栈转化为历史和问题。

下面就来探讨历史和的维护。

设辅助数组 \(C = B - t \times A\) ,其中 \(t\) 为当前时间,若在 \(t+1\) 时刻将 \(a_i \leftarrow a_i+v\),则 \(c_i = b_i+a_i+v-(t+1)(a_i+v) = b_i - (t+1)v\),因此直接开两个线段树维护 \(a_i\)\(c_i\) 即可。

还有一种直接用线段树标记的维护方法,每个线段树节点维护当前求和次数 \(tim\),历史和 \(his\),当前 tag,两个 tag 合并:\((a.his+a.sum \times b.tim + b.his, a.tim + b.tim, a.tag+b.tag)\),tag 和信息 info 合并:\((a.sum \times b.tim + a.len \times b.his)\)

因此原问题可以直接维护。

例 10:【CF997G】Good Subsegments

等价于 \(\max - \min = r - l\),将询问离线后维护右端点的答案,相当于区间加,区间历史最小值个数和。

由于初始 \([l ,l]\) 必然满足条件,维护当前区间最小值以及个数,求和次数标记,下传历史和标记时,只有当儿子是当前最小值才下传,\(hs \leftarrow hs+cnt\times tim,a.tim \leftarrow a.tim + tim\), 答案就是区间历史最小值个数。

例 11:【SP1557】GSS2 - Can you answer these queries II

将最大子段和看做所有右端点的历史最大后者和,只用维护当前点的后缀和,再套用区间历史最大值做法即可。因为相同的数只算一次,维护 \(pre_{a_i}\) 表示上次出现的位置,则只对 \([pre_{a_i}+1, i]\) 区间加。

四、与区间历史最值结合

例 12:UOJ#164. 【清华集训2015】V

维护长度为 \(n\) 的序列,支持:

  1. 区间 \([l, r]\) 每个数变成 \(a_i+x\)
  2. 区间 \([l, r]\) 每个数变成 \(\max(a_i - x, 0)\)
  3. 区间 \([l, r]\) 每个数变成 \(x\)
  4. 询问单点值。
  5. 询问单点历史最值。

将所有操作写成 \(\max(x+a, b)\) 的形式,则操作一等价于 \((a,-\infty)\),操作二等价于 \((-a, 0)\),操作三等价于 \((-\infty, a)\)

显然二元组 \((a, b)\)\((c,d)\) 具有可合并性,\((a, b)+(c,d) \to (a+c,\max(d, b+c))\)。 于是单点值就可以区间打上 tag,每次下传 tag,最后作用于原始 \(a_i\) 即可。

对于历史最值,仍然沿用将所有标记看做队列中的元素,则只用维护当前值和当前最值,考虑两个函数取 \(\max\) 后的结果,容易发现,其还是一个一次函数:

容易发现 \(\max\{(a, b) +(c,d)\} \rightarrow (\max(a,c), \max(b,d))\)

于是维护最值标记 his,每次标记下传时 \(his \leftarrow \max\{his, tg + rhs.his\}\),最后维护单点值即可。

例13:UOJ#515. 【UR #19】前进四

维护一个长度为 \(n\) 的序列,支持:

  1. 单点修改 \(a_i \leftarrow v\)
  2. 查询后缀不同最小值个数。

有一个显然的 \(O(n\log^2 n)\) 的做法(称为“兔队线段树”),但是在 \(n = 10^6\) 下,无法通过。

考虑翻转序列维度和时间维度,从后往前遍历所有点,线段树下标为 \(i\) 的点表示 \(i\) 时刻的后缀最小值及其个数。

将所有操作离线后,对应的修改放在 \(x\) 上,类似线段树分治的思想,求出此次操作的影响时间 $[l, r] $,问题相当于变成区间 \([l, r]\)\(v\)\(\min\),若修改成功则 \(\mathrm{counter} +1\)。每个节点维护最大值和严格次大值即可,每次修改生效后打上 tag。

由于只有区间取 \(\min\) 操作,时间复杂度 \(O(n\log n)\)

核心代码:

	inline void mian() {
		n = read(), q = read(); 
		for(int i = 1; i <= n; ++i) a[i] = read(), P[i].push_back(pii(a[i], 0)); 
		for(int i = 1; i <= q; ++i) {
			int op = read(), x = read(), y; 
			if(op == 1) y = read(), P[x].push_back(pii(y, i)); 
			else Qu[x].push_back(pii(i, ++m)); 
		}
		build(1, 0, q); 
		for(int i = n; i; --i) {
			const int s = P[i].size(); 
			for(int j = 0; j < s; ++j) {
				if(j + 1 < s) upd(1, P[i][j].se, P[i][j + 1].se - 1, P[i][j].fi); 
				else upd(1, P[i][j].se, q, P[i][j].fi); // 区间 min
			}
			for(pii &z : Qu[i]) ans[z.se] = Q(1, z.fi); 
		} 
		for(int i = 1; i <= m; ++i) printf("%d\n", ans[i]); 
	}
posted @ 2021-11-21 10:24  henrici3106  阅读(214)  评论(0编辑  收藏  举报