莫队算法学习笔记

【前言】

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

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

如果一个区间问题能在 \(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)\) 转移。(也就是上面的 AddDel

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

【独特的优化】

其实就是卡常。

  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  阅读(84)  评论(0编辑  收藏  举报