【离线算法】- 莫队
莫队
简介
莫队是可以支持多次询问区间 \([l,r]\) 的信息的 离线 算法。通过将询问范围以块长为 \(\sqrt n\) 分块后按端点所属分块排序的方式优化复杂度。
普通莫队
定义
普通莫队针对的是序列上的区间询问。常见形式为:对于一个长度为 \(n\) 的序列,提出 \(m\) 次询问,每次询问区间 \([l,r]\) 中数值的个数。总之,我们可以简单求得从区间 \([l,r]\) 转移到相邻区间 \([l,r-1]、[l+1,r]、[l,r+1]、[l-1,r]\) 后的答案即可使用莫队。
过程
自然,我们定义两个指针 \(l、r\) ,通过移动双指针 \(l、r\),使之与询问区间端点 \(L、R\) 重合,并在移动过程中更改答案即可。
而为了处理众多询问,就有了对这样的暴力的优化:普通莫队——通过将询问值域分块后以区间左端点所在的块的序号为第一关键字,以右端点为第二关键字排序后再依次处理询问即可得到 \(O(n\sqrt n)\) 的复杂度。
int len = sqrt(n); //部分题的卡常小寄巧pow(n*2, 2.0/3.0);
for (int i = 1; i <= n; i ++) bel[i] = (i-1) / len + 1;
这里对于右端点的比较有一处优化——奇偶化排序:当左端点属与奇数块时从小到大排序,否则从大到小排序。感性理解,右指针先从左往右移动后,左指针跳到下一分块时就又得移回区间左端点在这一分块下时最小的右端点。与其先移回最小处再往前推至其他区间,不如在回退时就将这些区间先行处理掉。而左端点最多只会在块内移动,保证了复杂度。
struct Que {
int l, r, id;
} Q[M];
bool cmp(Que a, Que b) {
return (bel[a.l] != bel[b.l]) ? a.l < b.l :(a.r == b.r ?
0 : ((bel[a.l] & 1) ? a.r < b.r : a.r > b.r));
}
但是要注意指针移动顺序:先扩大区间再缩小区以避免指针非法。并且扩大区间时应先移动再处理,缩小则反之。针对某一位的处理:对于数字种类问题即判断加入前/删除后此种类是否「灭绝」来决定总数增减。
sort(Q+1, Q+m+1, cmp);
int l = 1, r = 0;
for (int i = 1; i <= m; i ++) {
int L = Q[o].l, R = Q[i].r;
while (l > L) sum += !cnt[a[-- l]] ++; //ins(-- l);
while (r < R) sum += !cnt[a[++ r]] ++; //ins(++ r);
while (l < L) sum -= !-- cnt[a[l ++]]; //del(l ++);
while (r > R) sum -= !-- cnt[a[r --]]; //del(r --);
ans[Q[i].id] = sum;
}
带修莫队
定义
带修莫队针对的是序列上有修改的区间询问。常见形式为:对于一个长度为 \(n\) 的序列,\(m\) 次询问区间 \([l,r]\) 中数值的个数并间或有 \(t\) 次对某一位的修改操作。总之,我们可以通过加入时间轴求得从区间 \([l,r,t]\) 转移到相邻区间 \([l,r-1,t]、[l+1,r,t]、[l,r+1,t]、[l-1,r,t]、[l,r,t-1]、[l,r,t+1]\) 来维护信息即可使用带修莫队。
过程
直接加入时间轴的一维,并维护每次修改对序列的影响。比普通莫队排序多了一个关键字,区间转移需要保证三个指针都完美重合。据说此时的块长为 \(n^{2/3}\) 时更优。
struct Que {
int l, r, t, id;
} Q[N];
struct Chg {
int pos, col;
} C[N];
bool cmp(Que a, Que b) {
return (bel[a.l] == bel[b.l]) ? ((bel[a.r] == bel[b.r]) ?
a.t < b.t : a.r < b.r) : a.l < b.l;
}
时间推移时需要暴力更改时间点至询问时间,实时改变区间覆盖到的修改处,并记录序列位置的原值。可以用 \(swap\) 兼顾赋值与记忆历史值的功能。
sort(Q+1, Q+cntq+1, cmp);
int l = 1, r = 0, t = 0;
for (int i = 1; i <= cntq; i ++) {
int L = Q[i].l, R = Q[i].r, T = Q[i].t;
while (l > L) sum += !cnt[col[-- l]] ++;
while (r < R) sum += !cnt[col[++ r]] ++;
while (l < L) sum -= !-- cnt[col[l ++]];
while (r > R) sum -= !-- cnt[col[r --]];
while (t < T) {
++ t;
if (L <= C[t].pos && C[t].pos <= R)
sum -= !-- cnt[col[C[t].pos]] - !cnt[C[t].col] ++;
swap(col[C[t].pos], C[t].col);
}
while (t > T) {
if (L <= C[t].pos && C[t].pos <= R)
sum -= !-- cnt[col[C[t].pos]] - !cnt[C[t].col] ++;
swap(col[C[t].pos], C[t].col);
t --;
}
ans[Q[i].id] = sum;
}
树上莫队
定义
树上莫队针对的是序列上的区间询问……诶?序列?。莫队是针对序列的,对象变成树之后我们就得再把树「拍扁」成序列。
树上莫队针对的是树上的路径询问。常见形式为:给定一棵 \(n\) 个节点的树,每个节点有权值,\(m\) 次询问路径 \(u\to v\) 上权值的种数。总之,可以把树压为序列进行操作来维护信息的即可使用树上莫队。
过程
首先,一般树都能遍历得到DFS序、欧拉序和 括号序 。而对于路径操作,DFS序是不可做的,欧拉序同一节点会出现多次,不好操作,但括号序就有良好性质支持在序列上找出路径。因为括号序是 \(dfs\) 过程中在 进点和出点 时才将当前节点加入序列(for
循环前后将E[++tot]=u
,长度为\(2n\))得到的,而非欧拉序的 访问和回溯 (for
循环内外E[++tot]=u
,长度为\(2n-1\))。
注意:括号序与欧拉序有别,注意不要混淆概念,我一开始以为括号序就是可以将LCA转RMQ问题的欧拉序来着……
所以括号序性质包括但不限于:
- 一个点会且只会出现两次,而该点的两次出现位置之间出现的数也都出现两次,且都属于该点子树。下文记节点 \(x\) 在括号序中第一次出现的位置为 \(fr(x)\),第二次出现的位置为 \(la(x)\)。出现节点 \(u\) 和 \(v\) 时默认使 \(u\) 在序列的位置靠前,
- 对于两个点,它们首次出现的位置之间的序列中出现奇数次的数都在它们的路径上。(路径上的点只会出现一次嘛,出现两次的就都是兄弟、祖先兄弟及其子树等节点,不会在路径上)下文“询问区间”意即统计该区间出现了一次的节点贡献。
- 对于“|”类型的路径,考虑两个点 \(u\) 和 \(v\)。此时路径 \(u\to v\) 构成一条链,\(lca(u,v)=u\)。那么询问区间 \([fr(u),fr(v)]\),因为此时 \(v\) 在 \(u\) 子树内,从 \(u\) 往下搜索子树时自然统计到第一次找到 \(v\) 时的序列即可。
- 对于“︿”类型的路径,考虑两个点 \(u\) 和 \(v\)。此时路径 \(u\to v\) 可拆分为 \(u\to lca、lca\to v\)。那么询问区间 \([la(u),fr(v)]\),再加入\(lca\) 的贡献。因为 \(v\) 在 \(u\) 子树外,自然从 \(u\) 往上返回的过程才有贡献,而 \([fr(u),la(u)]\) 的区间是从 \(u\) 往下搜索时子树产生的序列,所以可以忽略,节省时间。而 \(lca\) 会将 \([fr(u),la(v)]\) 整块区间括起来,所以需要通过倍增/树剖单独求出再加入贡献。
提醒:结合图例可以验证在括号序上查找路径的规律,记得存序列开两倍空间
void dfs(int u, int f) {
fa[u][0] = f; dep[u] = dep[f] + 1;
ord[++ tim] = u; fr[u] = tim;
for (int i = h[u]; i; i = ne[i]) {
int v = e[i];
if (v == f) continue;
dfs(v, u);
}
ord[++ tim] = u; la[u] = tim;
}
void pre() {
for (int i = 1; i <= 25; i ++)
for (int j = 1; j <= n; j ++)
fa[j][i] = fa[fa[j][i-1]][i-1];
}
int lca(int x, int y) { //倍增求lca
if (dep[x] < dep[y]) swap(x, y);
for (int i = 25; i >= 0; i --)
if (dep[fa[x][i]] >= dep[y])
x = fa[x][i];
if (x == y) return x;
for (int i = 25; i >= 0; i --)
if (fa[x][i] != fa[y][i]) {
x = fa[x][i]; y = fa[y][i];
}
return fa[x][0];
}
像普通莫队一样,先依照括号序下标分块后按左右端点排序。询问区间时有个小trick,使用一个used[]
数组通过简单异或运算达到去重目的。
struct Que {
int l, r, lca, id;
} Q[N];
bool cmp(Que x, Que y) {
return (bel[x.l] == bel[y.l]) ? ((bel[x.l] & 1) ?
x.r < y.r : x.r > y.r) : bel[x.l] < bel[y.l];
}
void work(int x) {
!used[x] ? sum += !cnt[col[x]] ++ : sum -= !-- cnt[col[x]];
used[x] ^= 1;
}
最后提醒一点,查询时记得将输入的节点转为括号序中的下标,在统计贡献时再使用节点编号,不然树上莫队做了个寂寞……
for (int i = 1, x, y, lca; i <= m; i ++) {
scanf("%d%d", &x, &y);
if (dep[x] > dep[y]) swap(x, y);
int LCA = lca(x, y);
if (LCA == x) {
Q[i].l = fr[x];
Q[i].r = fr[y];
Q[i].lca = 0;
}
else {
Q[i].l = la[x];
Q[i].r = fr[y];
Q[i].lca = LCA;
}
Q[i].id = i;
}
sort(Q+1, Q+m+1, cmp);
int l = 1, r = 0;
for (int i = 1; i <= m; i ++) {
int L = Q[i].l, R = Q[i].r, LCA = Q[i].lca;
while (l > L) work(ord[-- l]);
while (r < R) work(ord[++ r]);
while (l < L) work(ord[l ++]);
while (r > R) work(ord[r --]);
if (LCA) work(LCA);
ans[Q[i].id] = sum;
if (LCA) work(LCA);
}
现在你已经会带修莫队和树上莫队了,来尝试一道带修树上莫队吧!
糖果公园:每个点有一个值,多次询问 \(u\to v\) 的路径的一个定义值: \(\sum _{i\in col(u\to v)} \sum_{j=1}^{cnt_i}V_i\times W_j\) 。并带有修改某一点颜色的操作。
带修树上莫队
#include
typedef long long ll;
using namespace std;
const int N = 1e5+10;
struct Que {
int l, r, lca, t, id;
} Q[N];
struct Chg {
int x, y;
} C[N];
int n, m, q, col[N], cntq, cntc;
ll V[N], W[N], ans[N], cnt[N], tot, all;
int h[N], ne[N*2], e[N*2], idx, fa[N][20], dep[N];
int ori[N*2], fr[N], la[N], bel[N*2];
bool vis[N];
void add(int a, int b) {
ne[++ idx] = h[a]; h[a] = idx; e[idx] = b;
}
void dfs(int u, int f) {
fa[u][0] = f; dep[u] = dep[f] + 1;
ori[++ tot] = u; fr[u] = tot;
for (int i = h[u]; i; i = ne[i]) {
int v = e[i];
if (v == f) continue;
dfs(v, u);
}
ori[++ tot] = u; la[u] = tot;
}
void pre() {
for (int i = 1; i <= 17; i ++)
for (int j = 1; j <= n; j ++)
fa[j][i] = fa[fa[j][i-1]][i-1];
}
int lca(int x, int y) {
if (dep[x] < dep[y]) swap(x, y);
for (int i = 17; i >= 0; i --)
if (dep[fa[x][i]] >= dep[y]) x = fa[x][i];
if (x == y) return y;
for (int i = 17; i >= 0; i --)
if (fa[x][i] != fa[y][i]) {
x = fa[x][i]; y = fa[y][i];
}
return fa[x][0];
}
bool cmp(Que x, Que y) {
return bel[x.l] == bel[y.l] ? (bel[x.r] == bel[y.r] ?
x.t < y.t : x.r < y.r) : x.l < y.l;
}
void work(int u) {
if (!vis[u])
all += V[col[u]] * W[++ cnt[col[u]]];
else all -= V[col[u]] * W[cnt[col[u]] --];
vis[u] ^= 1;
}
signed main() {
scanf("%d%d%d", &n, &m, &q); int len = pow(n*2, 2.0/3.0);
for (int i = 1; i <= m; i ++) scanf("%lld", &V[i]);
for (int i = 1; i <= n; i ++) scanf("%lld", &W[i]);
for (int i = 1; i < n; i ++) {
int u, v;
scanf("%d%d", &u, &v);
add(u, v); add(v, u);
}
for (int i = 1; i <= n; i ++) scanf("%d", &col[i]);
dfs(1, 1);
pre();
for (int i = 1; i <= n*2; i ++) bel[i] = (i-1) / len + 1;
while (q --) {
int op, x, y;
scanf("%d%d%d", &op, &x, &y);
if (op) {
if (dep[x] > dep[y]) swap(x, y);
int LCA = lca(x, y);
if (LCA == x) {
Q[++ cntq].l = fr[x];
Q[cntq].r = fr[y];
Q[cntq].lca = 0;
}
else {
Q[++ cntq].l = la[x];
Q[cntq].r = fr[y];
Q[cntq].lca = LCA;
}
Q[cntq].t = cntc; Q[cntq].id = cntq;
}
else {
C[++ cntc].x = x; C[cntc].y = y;
}
}
sort(Q+1, Q+cntq+1, cmp);
int l = 1, r = 0, t = 0;
for (int i = 1; i <= cntq; i ++) {
ll L = Q[i].l, R = Q[i].r, LCA = Q[i].lca, T = Q[i].t;
while (l > L)
work(ori[-- l]);
while (r < R)
work(ori[++ r]);
while (l < L)
work(ori[l ++]);
while (r > R)
work(ori[r --]);
if (LCA) work(LCA);
while (t < T) {
++ t;
if (vis[C[t].x]) {
all += V[C[t].y] * W[++ cnt[C[t].y]];
all -= V[col[C[t].x]] * W[cnt[col[C[t].x]] --];
}
swap(col[C[t].x], C[t].y);
}
while (t > T) {
if (vis[C[t].x]) {
all += V[C[t].y] * W[++ cnt[C[t].y]];
all -= V[col[C[t].x]] * W[cnt[col[C[t].x]] --];
}
swap(col[C[t].x], C[t].y);
t --;
}
ans[Q[i].id] = all;
if (LCA) work(LCA);
}
for (ll i = 1; i <= cntq; i ++) printf("%lld\n", ans[i]);
}
回滚莫队
定义
普通莫队针对的是序列上的区间询问。常见形式为:对于一个长度为 \(n\) 的序列,提出 \(m\) 次询问,每次询问区间 \([l,r]\) 中相同数值的最大距离。总之,我们无法简单求得从区间 \([l,r]\) 转移到相邻区间 \([l,r-1]、[l+1,r]\) 后的答案即可考虑回滚莫队。ヽ(・_・;)ノ
像最大值这种东西的更新,加入易删除难啊!但如果状态(譬如这里只需维护数值首次/最后出现位置)容易改变的话,回滚莫队(在这里是不删除莫队)便应运而生。
过程
不再使用奇偶化排序,一贯按右端点从小到大排序。这样一来我们发现左端点在同块中的区间,它们右端点单调递增。
bool cmp(Que x, Que y) {
return bel[x.l] == bel[y.l] ?
x.r < y.r : x.l < y.l;
}
于是得益于块长的限制,我们可以这样按块处理:对于每一分块,枚举所有左端点隶属于它的区间。先特判掉左右端点在同块的情况暴力做,对于之后的区间再顺次将 \(r\) 指针右移。而对于左端点,左移后转移到下一区间肯定有需要右移的情况。那么既然无法在删除时快速更新答案,我们就直接记录左端点在该块右界尚未移动时的答案,每次进入下一询问时将状态回退到只移动了右端点的状态即可。这里有个小trick:开一个stack
记录计算此分块贡献时更改的数值,在枚举完区间后通过记录的stack
删除状态,避免了滥用memset
。
sort(Q+1, Q+m+1, cmp);
for (int i = 1, j = 1; i <= (n-1)/len+1 && j <= m; i ++) {
int r = min(i*len, n), l = r+1; res = 0;
for (; bel[Q[j].l] <= i && j <= m; j ++) {
int L = Q[j].l, R = Q[j].r;
if (bel[L] == bel[R]) {
for (int k = L; k <= R; k ++) temp[a[k]] = 0; tmp = 0;
for (int k = L; k <= R; k ++) {
if (!temp[a[k]]) temp[a[k]] = k;
else tmp = max(tmp, k - temp[a[k]]);
}
res[q[j].id] = tmp; continue;
}
while (r < R) {
++ r;
mx[a[r]] = r;
if (!mn[a[r]]) mn[a[r]] = r, la.push(a[r]);
res = max(res, r - mn[a[r]]);
}
int last = res;
while (l > L) {
-- l;
if (mx[a[l]]) res = max(res, mx[a[l]] - l);
else mx[a[l]] = l;
}
ans[Q[j].id] = res;
while (l < min(i*len, n) + 1) {
if (mx[a[l]] == l) mx[a[l]] = 0;
l ++;
}
ans = last;
}
while (!la.empty()) {
int pos = la.top(); la.pop();
mx[pos] = mn[pos] = 0;
}
}
二维莫队
挖坑
莫队二离
挖坑