莫队算法学习笔记

【前言】

莫队算法是基于对询问进行分块的离线算法,由提出者的名字命名。

都说分块是优雅的暴力,莫队显然也不是例外。

如果一个区间问题能在 \(O(1)\sim O(\log n)\) 的时间内扩展 \(1\) 个长度,且不强制在线,那么可以尝试莫队。

【前置芝士】

  1. 基础分块思想。(或许你可以看看这篇文章)
  2. 模拟。
  3. 各种卡常。

可以看出莫队算法还是相对比较独立的算法,不需要过多的知识基础。

【普通莫队】

【梦开始的地方】

莫队算法的起源就是 这道题。

给定一个长度为 \(n\) 的序列,每次查询 \([l,r]\) 中的数 两两组队时 数字相等的对 的数量。

考虑暴力做法:

  1. 每次直接遍历一遍区间,\(O(n^2)\),没什么可优化的了。
  2. 首先暴力处理第一个区间询问,之后每次询问相应的移动左右端点,在移动时顺便 \(O(1)\) 修改答案。

第二个优化看上去很厉害的样子,可惜如果区间跨度很大,时间还是 \(O(n^2)\) 甚至更大的。

那么能否把询问离线下来,然后以某种特定的顺序进行查询,保证左右端点的跳度没那么大呢。

于是莫队算法诞生了。

【主要思想】

我们可以将左端点升序排序,分成 \(\sqrt n\) 块,块内再按照右端点排序。

这有什么用呢,看看时间复杂度变化:(假设询问次数与序列长度同级)

首先左端点在同一块中,每次最大移动长度为 \(\sqrt n\),\(n\) 次询问最多移动 \(n\sqrt n\) 次。

然后对于每个块,右端点递增排序,最多移动 \(n\) 次,共有 \(\sqrt n\) 个块,总共移动次数也最多是 \(n\sqrt n\)。

本题中每次移动都是 \(O(1)\) 的,所以总时间复杂度为 \(O(n\sqrt n)\)。

这样就保证了莫队算法的时间复杂度为 \(O(n\sqrt n)\)。

关键主要思想它就这么多。

【代码实现】

首先我们要对询问进行分块和排序,其实非常简单,基本代码结构长这样:

bool cmp(Query a, Query b){
	return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : a.r < b.r;
}

n = read(), m = read(), block = sqrt(n);
for(int i=1; i<=n; i++){
	a[i] = read();
	bl[i] = (i-1) / block + 1;
}
for(int i=1; i<=m; i++){
	q[i].l = read(), q[i].r = read();
	q[i].id = i;	
}
sort(q+1, q+m+1, cmp);

然后就是每次暴力移动 l,r 两端点。

int l = 1, r = 0;
for(int i=1; i<=m; i++){
	int ql = q[i].l, qr = q[i].r, id = q[i].id;
	while(l < ql) Del(a[l++]);
	while(l > ql) Add(a[--l]);
	while(r < qr) Add(a[++r]);
	while(r > qr) Del(a[r--]);
	// bla,bla,bla...
}

值得注意的是,我们初始化时令 l = 1, r = 0,这是为什么呢。

在描述暴力做法 \(2\) 时我们提到过 首先暴力处理第一个区间询问。

但是如果真的单独写一个预处理貌似很麻烦,那么我们就利用初始化来避免这一点。

我们的目标是:特别构造初始化,使首次移动后就得到第一个区间的答案。

首先考虑令 \(r=0\),这样 \(r\) 就会一直扫过去,遍历区间 \(1\sim qr\) 并增添答案。

然后显然我们需要删去区间 \(1\sim ql-1\) 的答案,于是令 \(l=1\) 即可,结合上方代码不难看出其正确性。

然后给出本题的全部代码(之后的题目就只给出部分主要代码):

#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
using namespace std;
typedef long long LL;
const int N = 50010;

int n, m, block;
int a[N], cnt[N], bl[N];
LL sum, ans1[N], ans2[N];
struct Query{int l, r, id;} q[N]; 

int read(){
	int x=0,f=1;char c=getchar();
	while(c<'0' || c>'9') f=(c=='-')?-1:1,c=getchar();
	while(c>='0' && c<='9') x=x*10+c-48,c=getchar();
	return x*f;
}

bool cmp(Query a, Query b){
	return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : (bl[a.l] & 1) ? a.r < b.r : a.r > b.r; 
}

void Add(int x){
	cnt[x] ++;
	if(cnt[x] > 1){
		sum += 1LL * cnt[x] * (cnt[x] - 1);
		sum -= 1LL * (cnt[x] - 1) * (cnt[x] - 2);
	}
}

void Del(int x){
	cnt[x] --;
	if(cnt[x] > 0){
		sum += 1LL * cnt[x] * (cnt[x] - 1);
		sum -= 1LL * cnt[x] * (cnt[x] + 1);
	}
}

LL Gcd(LL x, LL y){
	while(y) {LL z = x; x = y; y = z % y;}
	return x;
}

int main(){
	n = read(), m = read(), block = sqrt(n);
	for(int i=1; i<=n; i++){
		a[i] = read();
		bl[i] = (i-1) / block + 1;
	}
	for(int i=1; i<=m; i++){
		q[i].l = read(), q[i].r = read();
		q[i].id = i;	
	}
	sort(q+1, q+m+1, cmp);
	int l = 1, r = 0;
	for(int i=1; i<=m; i++){
		int ql = q[i].l, qr = q[i].r, id = q[i].id;
		while(l < ql) Del(a[l++]);
		while(l > ql) Add(a[--l]);
		while(r < qr) Add(a[++r]);
		while(r > qr) Del(a[r--]);
		LL now = 1LL * (qr - ql + 1) * (qr - ql);
		if(!now) {ans1[id] = 0; ans2[id] = 1; continue;}
		LL G = Gcd(now, sum);
		ans1[id] = sum / G;
		ans2[id] = now / G; 
	}
	for(int i=1; i<=m; i++) 
		printf("%lld/%lld\n", ans1[i], ans2[i]);
	return 0;
}

【小结】

前面解释过什么情况下可以使用莫队算法,那么莫队算法在实现时的主要难点是什么呢。

其实看出是莫队的题之后往往变得十分套路,唯一需要考虑的是如何 \(O(1)\) 转移。(也就是上面的 Add 和 Del)

所以在尝试实现代码之前,一定要将转移的所有细节想清楚,借此判断这道题是否适合莫队。

【独特的优化】

其实就是卡常。

  1. 奇偶性排序。

    主要可以参考上面的代码的 cmp 函数写法。

    就是 r 在奇数块递增排序,偶数块递减排序,从而优化复杂度。

    原理是奇数块递增之后 r 会很大,如果偶数快递减的话正好从大变小,到了下一个奇数块又从小到大。

    这样保证了每一步都是单调的,理论上可以时间减半,实际有所偏差,但是优化很大。

    值得注意的是这个优化会破坏全局 r 的单调性,所以有时可能不太能用。

    主要用在普通莫队和树上莫队中吧。

  2. 分块大小。

    有的时候 \(\sqrt n\) 的分块大小可能并不是最优的,可以证明 \(n^{\frac{2}{3}}\) 有时更快。(部分基于评测机心情)

    具体方法就是将 block = sqrt(n) 变为 block = pow(n, 2.0 / 3.0)。

    主要用于带修莫队的优化。

【带修莫队】

板子题

每次询问一个区间中有多少个不同的数,支持单点修改。

【主要思想】

莫队似乎不太能支持修改呢。

其实可以引入另一个概念——时间轴。

我们以这次询问之前最后一次修改的编号作为这次询问的时间。

那么每次除了左右端点的移动外,还要考虑时间的移动,实际并不麻烦多少。

本质上就是:增添一维的莫队。

【代码实现】

注意块的大小最好取 \(n^{\frac{2}{3}}\),那么总时间复杂度 \(O(n^{\frac{5}{3}})\)。

或许你发现块取 \(\sqrt n\) 时的 \(O(n^{\frac{3}{2}})\) 更优,可惜它有可能被卡成 \(O(n^2)\),证明我也不会。

而且注意排序时三个关键字的顺序,然后最好不要用奇偶性排序。

还有一个重要的小技巧:

每次修改时将修改的颜色与当前颜色 swap 一下,这样避免了不必要的讨论,减低代码实现复杂度。

const int N = 2000000;

int n, m, sum, block;
int cntq, cntc;
int a[N], bl[N], ans[N], cnt[N];
struct Query{int l, r, id, tim;} q[N];
struct Modify{int pos, col;} c[N];

void Add(int x){sum += !cnt[x]++;}
void Del(int x){sum -= !--cnt[x];}

bool cmp(Query a, Query b){
	return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : ((bl[a.r] ^ bl[b.r]) ? bl[a.r] < bl[b.r] : a.tim < b.tim);
}

int main(){
	n = read(), m = read();
	block = pow(n, 2.0 / 3.0);
	for(int i=1; i<=n; i++){
		a[i] = read();
		bl[i] = (i-1) / block + 1;
	}
	cntq = cntc = 0;
	char str[5];
	for(int i=1; i<=m; i++){
		scanf("%s", str);
		if(str[0] == 'Q'){
			q[++cntq].l = read();
			q[cntq].r = read();
			q[cntq].tim = cntc;
			q[cntq].id = cntq; 
		}else{
			c[++cntc].pos = read();
			c[cntc].col = read();
		}
	}
	sort(q+1, q+cntq+1, cmp);
	int l = 1, r = 0, tim = 0;
	sum = 0;
	for(int i=1; i<=cntq; i++){
		int ql = q[i].l, qr = q[i].r;
		int qt = q[i].tim, id = q[i].id;
		while(l < ql) Del(a[l++]);
		while(l > ql) Add(a[--l]);
		while(r < qr) Add(a[++r]);
		while(r > qr) Del(a[r--]);
		while(tim < qt){
			tim ++;
			if(ql <= c[tim].pos && c[tim].pos <= qr) Del(a[c[tim].pos]), Add(c[tim].col);
			swap(c[tim].col, a[c[tim].pos]);
		}
		while(tim > qt){
			if(ql <= c[tim].pos && c[tim].pos <= qr) Del(a[c[tim].pos]), Add(c[tim].col);
			swap(c[tim].col, a[c[tim].pos]);
			tim --;
		}
		ans[id] = sum;
	}
	for(int i=1; i<=cntq; i++) printf("%d\n", ans[i]);
	return 0;
}

【树上莫队】

板子题

给定 \(n\) 个结点的树,每个结点有一种颜色。

\(m\) 次询问,每次询问给出 \(u,v\),回答 \(u,v\) 之间的路径上的结点的不同颜色数。

【主要思想】

其实和树剖的思想差不多,我们都需要将树上问题转化为序列问题。

(其实还有一种真的在树上跑莫队的 真·树上莫队 算法,太过神仙,可以看这里)

可惜单纯的 DFS 序不太能满足我们的要求(或许你可以尝试一下在 DFS 序上标记处任意两点之间的路径)

所以引入欧拉序(括号序)这个概念:

DFS 遍历一棵树,遍历到一个节点时将其加入序列,回溯时再次加入序列,就得到了这棵树的欧拉序。

不难发现欧拉序列具有以下性质:

树的欧拉序上两个相同编号(设为 \(x\))之间的所有编号都出现两次,且都位于 \(x\) 的子树上。

这样,如果得到了欧拉序,我们可以将树上路径转化为序列上的一段区间,方法如下:

假设当前树上两节点为 \((u,v)\),得到他们在欧拉序中第一次和第二次出现的下标。

本别计为 \(\rm st(u),ed(u),st(v),ed(v)\)(如果 \(\rm st(u) > st(v)\) 就交换一下 \(\rm u,v\))

同时假设 \(\rm lca = lca(u,v)\),我们分类讨论一下:

  1. 若 \(\rm lca = u\),那么 \(\rm v\) 是 \(\rm u\) 的直接子节点,\(\rm st(u)\sim st(v)\) 即构成路径。

  2. 若 \(\rm lca\neq u\),那么两点的路径可以分为 \(\rm u\sim lca\) 和 \(\rm lca \sim v\) 两段,在序列上的表现为 \(\rm ed(u)\sim st(v)\)。

    同时这一段是不包含 \(\rm lca\) 的,还要特别处理 \(\rm lca\) 的贡献。

在上述路径中,出现两次的节点需要忽略。(它们相当于其它子树的节点,并不构成路径)

这样我们就将树上问题转化为了区间问题。

【代码实现】

如果出现两次就要忽略那个节点,具体实现方式可以利用一个数组不断异或,为 \(1\) 就加,为 \(0\) 就减。

这样就完美避免了奇偶性的讨论,简化代码实现。

这里用的是树剖求 \(\rm lca\),其它实现方式也是 OK 的。

const int N = 100010;

int n, m, tot, sum;
int a[N], b[N], bl[N], ans[N], num[N];
int St[N], Ed[N], pos[N];
int head[N], cnt;
bool vis[N];
struct Edge{int nxt, to;} ed[N];
struct Query{int l, r, lca, id;} q[N];

struct LCA{
	int fa[N], dep[N], sz[N], son[N];
	void dfs1(int u, int Fa){
		fa[u] = Fa; dep[u] = dep[Fa] + 1;
		pos[++tot] = u, St[u] = tot;
		sz[u] = 1;
		for(int i=head[u]; i; i=ed[i].nxt){
			int v = ed[i].to;
			if(v == Fa) continue;
			dfs1(v, u);
			sz[u] += sz[v];
			if(sz[v] > sz[son[u]]) son[u] = v;
		}
		pos[++tot] = u, Ed[u] = tot;
	}
	int top[N];
	void dfs2(int u, int Top){
		top[u] = Top;
		if(!son[u]) return;
		dfs2(son[u], Top);
		for(int i=head[u]; i; i=ed[i].nxt){
			int v = ed[i].to;
			if(v == fa[u] || v == son[u]) continue;
			dfs2(v, v);
		}
	}
	int Lca(int x, int y){
		while(top[x] != top[y]){
			if(dep[top[x]] < dep[top[y]]) swap(x, y);
			x = fa[top[x]];
		}
		if(dep[x] > dep[y]) swap(x, y);
		return x; 
	}
	void build(){dfs1(1, 0); dfs2(1, 1);}
} T;

void add(int u, int v){
	ed[++cnt] = (Edge){head[u], v};
	head[u] = cnt;
}

void Init(){
	n = read(), m = read();
	for(int i=1; i<=n; i++) a[i] = b[i] = read();
	sort(b+1, b+n+1);
	int k = unique(b+1, b+n+1) - (b+1);
	for(int i=1; i<=n; i++)
		a[i] = lower_bound(b+1, b+k+1, a[i]) - b;
		
	for(int i=1; i<n; i++){
		int u = read(), v = read();
		add(u, v), add(v, u);
	}
	T.build();
}

bool cmp(Query a, Query b){
	return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : (bl[a.l] & 1) ? a.r < b.r : a.r > b.r;
}

void Modify(int x){
	if(vis[x]) sum -= !--num[a[x]];
	else sum += !num[a[x]]++;
	vis[x] ^= 1;
}

int main(){
	Init();
	int block = sqrt(n);
	for(int i=1; i<=tot; i++)
		bl[i] = (i-1) / block + 1;
	for(int i=1; i<=m; i++){
		int u = read(), v = read();
		int Lca = T.Lca(u, v);
		if(St[u] > St[v]) swap(u, v);
		if(u == Lca) q[i].l = St[u], q[i].r = St[v];
		else q[i].l = Ed[u], q[i].r = St[v], q[i].lca = Lca;
		q[i].id = i;
	}
	sort(q+1, q+m+1, cmp);
	int l = 1, r = 0; sum = 0;
	for(int i=1; i<=m; i++){
		int ql = q[i].l, qr = q[i].r;
		int lca = q[i].lca, id = q[i].id;
		while(l < ql) Modify(pos[l++]);
		while(l > ql) Modify(pos[--l]);
		while(r < qr) Modify(pos[++r]);
		while(r > qr) Modify(pos[r--]);
		if(lca) Modify(lca);
		ans[id] = sum;
		if(lca) Modify(lca);
 	}
 	for(int i=1; i<=m; i++) printf("%d\n", ans[i]);
 	return 0;
}

【回滚莫队】

【主要思想】

莫队算法的关键就在于如何进行区间的转移,这就可能涉及到很多的细节。

有一类普通莫队不可解的问题就是在转移区间过程中,可能出现删点或加点操作其中之一无法实现的问题。

那么我们就来探讨如何利用特殊的莫队算法来解决这类问题,而这种莫队算法就称之为回滚莫队算法。

【只加不减的回滚莫队】

某些区间问题适合增加点,但不容易删除,那么我们就只增加不删除,例如这道题。

区间询问 \({\rm Max}_{i=l}^r cnt_i\times a_i\),其中 \(cnt_i\) 表示数字 \(a_i\) 的出现次数。

不难想到加点方式,但是删点似乎只能暴力扫一遍值域找到次大值更新答案,莫队就显得很鸡肋。

或许可以有回滚莫队:

  1. 对原序列进行分块,并对询问按照如下的方式排序:以左端点所在的块升序为第一关键字,以右端点升序为第二关键字。
  2. 对于处理所有左端点在块 \(T\) 内的询问,我们先将莫队区间左端点初始化为 \(R[T]+1\),右端点初始化为\(R[T]\),原因上面提到过。
  3. 对于左右端点在同一个块中的询问,我们直接暴力扫描回答即可。
  4. 对于左右端点不在同一个块中的所有询问,由于其右端点升序,我们对右端点只做加点操作,总共最多加点 \(n\) 次。
  5. 对于左右端点不在同一个块中的所有询问,其左端点是可能乱序的,我们每一次从 \(R[T]+1\) 的位置出发,只做加点操作,到达询问位置即可,每一个询问最多加 \(\sqrt n\) 次。
  6. 回答完询问后,我们撤销本次移动左端点的所有改动,使左端点回到 \(R[T]+1\) 的位置。
  7. 按照相同的方式处理下一块。

【只减不加的回滚莫队】

某些问题适合删点但不适合加点,如 这道题。

\(m\) 次询问,每次询问一个区间内最小没有出现过的自然数。

删除很简单,顺便更新答案,但是增加似乎很麻烦。

或许可以有回滚莫队:

  1. 对原序列进行分块,并对询问按照如下的方式排序:以左端点所在的块升序为第一关键字,以右端点降序为第二关键字。
  2. 对于处理所有左端点在块 \(T\) 内的询问,我们先将莫队区间左端点初始化为 \(L[T]\),右端点初始化为 \(n\),然后将其中的点全部增加到统计数组中去,并暴力求一次答案。
  3. 对于左右端点在同一个块中的询问,我们直接暴力扫描回答即可。(这时要新开一个数组,不破坏原数组)
  4. 对于左右端点不在同一个块中的所有询问,由于其右端点升序,我们对右端点只做加点操作,总共最多加点 \(n\) 次。
  5. 对于左右端点不在同一个块中的所有询问,其左端点是可能乱序的,我们每一次从 \(R[T]+1\) 的位置出发,只做加点操作,到达询问位置即可,每一个询问最多加 \(\sqrt n\) 次。
  6. 回答完询问后,我们撤销本次移动左端点的所有改动,使左端点回到 \(L[T]\) 的位置。
  7. 按照相同的方式处理下一块。

【小结】

不论怎样,思考出莫队实现的细节之后,回滚莫队的难点是上述算法中的第六步,撤销改动。

因为使用回滚莫队本身的算法就是很难撤销改动的(不然用回滚莫队干什么),所以要格外注意。

同时要注意不能滥用 memset,撤销改动还是要有些技巧。

【代码实现】

还算好写。

例题一:

const int N = 100010;
typedef long long LL;

int n, m, block;
int a[N], val[N], cnt[N], bl[N];
LL ans[N];
struct Query{int l, r, id;} q[N];

bool cmp(Query a, Query b){
	return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : a.r < b.r;
}

void Add(int x, LL &Mx){
	cnt[a[x]] ++;
	Mx = max(Mx, 1LL * cnt[a[x]] * val[a[x]]);
}

int main(){
	n = read(), m = read();
	block = sqrt(n);
	for(int i=1; i<=n; i++){
		a[i] = val[i] = read();
		bl[i] = (i-1) / block + 1;
	}
	sort(val+1, val+n+1);
	int k = unique(val+1, val+n+1) - (val+1);
	for(int i=1; i<=n; i++)
		a[i] = lower_bound(val+1, val+k+1, a[i]) - val;
	for(int i=1; i<=m; i++){
		q[i].l = read(), q[i].r = read();
		q[i].id = i;	
	}
	sort(q+1, q+m+1, cmp);
	int i = 1;
	for(int j=1; j<=bl[n]; j++){
		memset(cnt, 0, sizeof(cnt));
		int l = j * block + 1, r = l - 1;
		LL sum = 0;
		for(; bl[q[i].l] == j; i ++){
			int ql = q[i].l, qr = q[i].r, id = q[i].id;
			LL now = 0;
			if(bl[ql] == bl[qr]){
				for(int p=ql; p<=qr; p++) cnt[a[p]] = 0;
				for(int p=ql; p<=qr; p++){
					cnt[a[p]] ++;
					now = max(now, 1LL * cnt[a[p]] * val[a[p]]);
				}
				for(int p=ql; p<=qr; p++) cnt[a[p]] = 0;
				ans[id] = now;
				continue;
			}
			while(r < qr) Add(++ r, sum);
			now = sum;
			while(l > ql) Add(-- l, now);
			ans[id] = now;
			while(l < j * block + 1) cnt[a[l ++]] --;
 		}
	}
	for(int i=1; i<=m; i++) printf("%lld\n", ans[i]);
	return 0;
}

例题二:

const int N = 200010;

int n, m, block;
int a[N], bl[N], ans[N];
int cnt[N], cnt_[N];
struct Query{int l, r, id;} q[N];

bool cmp(Query a, Query b){
	return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : a.r > b.r;
}

int main(){
	n = read(), m = read();
	block = sqrt(n);
	for(int i=1; i<=n; i++){
		a[i] = read();
		bl[i] = (i-1) / block + 1;
	}
	for(int i=1; i<=m; i++){
		q[i].l = read(), q[i].r = read();
		q[i].id = i;
	}
	sort(q+1, q+m+1, cmp);
	int i = 1;
	for(int k=1; k<=bl[n]; k++){
		int l = (k-1) * block + 1, r = n;
		memset(cnt, 0, sizeof(cnt));
		for(int j=l; j<=r; j++) cnt[a[j]] ++;
		int sum = 0;
		while(cnt[sum]) sum ++;
		for(; bl[q[i].l] == k; i ++){
			int ql = q[i].l, qr = q[i].r, id = q[i].id;
			int tmp = 0;
			if(bl[ql] == bl[qr]){
				for(int j=ql; j<=qr; j++) cnt_[a[j]] ++;
				while(cnt[tmp]) tmp ++;
				ans[id] = tmp;
				for(int j=ql; j<=qr; j++) cnt_[a[j]] = 0;
			}
			while(r > qr){
				cnt[a[r]] --;
				if(!cnt[a[r]]) sum = min(sum, a[r]);
				r --;
			}
			tmp = sum;
			while(l < ql){
				cnt[a[l]] --;
				if(!cnt[a[l]]) tmp = min(tmp, a[l]);
				l ++;
			}
			ans[id] = tmp;
			while(l > (k-1) * block + 1) cnt[a[--l]] ++;
		}
	}
	for(int i=1; i<=m; i++) printf("%d\n", ans[i]);
	return 0;
}

【简单习题】

  1. 小B的询问。

    套路题,直接上普通莫队即可。

  2. 大爷的字符串题。

    一致认为出题人语文不太行(dllxl)。

    求区间众数出现的次数,简单搞几个数组维护一下就好了。

  3. Mato的文件管理。

    求将区间变为升序至少要交换多少次,只能邻项交换。

    树状数组 + 普通莫队维护,需要一点思维和细节。(思考:相同数字怎么处理)

  4. 【模板】回滚莫队

    既然都是模板了...

  5. 糖果公园

    树上带修莫队,细节上注意一下就好了,也蛮简单的。

然后推荐一个题单,Dalao 总结的比蒟蒻强多了。

【总结】

莫队算法就是这么点东西,思想简单,代码简短,是不可多得的骗分神器。

(偷偷告诉你,其实还有二次离线莫队哦)

引用资料&特别鸣谢:

  1. 莫队算法——从入门到黑题。
  2. 『回滚莫队及其简单运用』。

完结撒花

posted @ 2021-03-15 15:20  LPF'sBlog  阅读(140)  评论(0)    收藏  举报