「笔记」Link Cut Tree
写在前面
世界上怎么会有这么优美的数据结构!
这是一篇 Rewrite,「笔记」LCT 的 1.0 版本已经被削除了。
本文是具有主观性质的一些理解,用于自我总结复习,Link Cut Tree 初学者请移步其它博客。作者水平有限,若有不当之处请不吝赐教。
以及不要太在意 Tarjan 是怎么想到它的= =
动态树问题
给定一片森林,要求在线支持下列操作:
- 修改路径权值。
- 查询路径权值和。
- 断开并连接一些边,保证仍是一片森林。
要求单次操作复杂度 级别。
若给定的森林是静态的,即不存在操作 3,该问题可以通过对原树的树链剖分,将树上路径/子树问题转化为基于 dfs 序的序列问题,从而使用序列静态数据结构维护。
加入动态加/删边操作后,我们仍然考虑对原树进行树链剖分来维护路径信息。在静态问题中我们使用了静态数据结构维护树链。在这里可以考虑使用易于修改的 Splay 动态维护树链信息。
换句话说,LCT 是通过 Splay 动态维护树链剖分结构,从而将树上问题转化为 Splay 上的序列问题的一种数据结构。
定义
实链剖分
实链剖分是一种对原树的树链剖分的方式,它将树划分成了多条实链:
- 每个节点能且仅能存在于一条实链中。实链是节点深度递增的一条树链,实链与实链间通过虚边连接。
- 实链中的边称为实边,称一条实边的儿子是父亲的实儿子。显然一个节点至多有一个实儿子,但可能有多个虚儿子。
- 在实链剖分中,对于每个节点,仅记录其父亲 与 实儿子的信息,即“认父不认子”。
确定边的虚实的标准是人的主观能动,即可以灵活地“随意划分”,以达到灵活修改剖分结构的功能。以下是一个实链剖分的例子:

Link-Cut Tree
LCT 使用 Splay 来维护实链。每一条实链中的节点构成一棵 Splay。Splay 具有以下性质:
- 每棵 Splay 维护原树中的一条实链,且中序遍历这棵 Splay 得到的点序列,从前到后对应原树自上到下的这条实链。
- 原树每个节点与 Splay 节点一一对应。
- 对于每一棵对应实链的 Splay,与一般 Splay 不同的是,其根节点也存在父亲节点,指向原树中其父亲节点。这条边对应着原树中两条实链间的虚边。所有 Splay 节点中仅有原树根对应节点的父节点为空。
- 所有实链的 Splay 的父子关系构了一棵树,称为辅助树。由以上性质,辅助树可以体现原树中的所有父子关系,两棵原树的辅助树不同,两棵原树就不同。维护操作时仅需维护辅助树即可。
下图展示了上述实链剖分对应的一种合法的辅助树:
小结
- 实链剖分是一种树链剖分的方式。实链是节点深度递增的一条树链,实链与实链间通过虚边连接。
- 对于原树的实链:每棵 Splay 维护一条实链,其中序遍历对应自上到下的这条实链。
- 对于原树的虚边:子节点是所在 Splay 的根,且其父亲指向虚边的父节点。但父节点的儿子不指向子节点。
- 辅助树可以在满足辅助树、Splay 的性质下任意换根。
分步实现
以下以 P1501 Tree II 为例。
给定一 个节点的树,初始点权为 。给定 次操作:
- 路径加。
- 路径乘。
- 删去一条边,再加入一条边,保证操作完之后仍然是一棵树。
- 查询路径和,答案对 取模。
。
2S,512MB。
定义及声明
复制复制#define f fa[now_] #define ls son[now_][0] #define rs son[now_][1] const int kMaxNode = kN; int fa[kMaxNode], son[kMaxNode][2], val[kMaxNode]; //Splay 的结构信息 LL siz[kMaxNode], sum[kMaxNode], tagplus[kMaxNode], tagprod[kMaxNode]; //子树大小,子树和,两种标记 bool tagrev[kMaxNode]; //Splay 的子树反转标记
Splay
void Pushup(int now_) { //维护子树和 和 子树大小 sum[now_] = (sum[ls] + sum[rs] + val[now_]) % mod; siz[now_] = siz[ls] + siz[rs] + 1; } void PushReverse(int now_) { //子树反转标记,使得 Splay 节点的中序遍历反向。若原 splay 表示一条自顶向下的链,反转相当于将 splay 表示的链的边反向,父子关系互换。 if (!now_) return; std::swap(ls, rs); tagrev[now_] ^= 1; } void PushPlus(int now_, LL val_) { //子树加 if (!now_) return; val[now_] = (val[now_] + val_) % mod; sum[now_] = (sum[now_] + siz[now_] * val_ % mod) % mod; tagplus[now_] = (tagplus[now_] + val_) % mod; } void PushProd(int now_, LL val_) { //子树乘 if (!now_) return; val[now_] = val[now_] * val_ % mod; sum[now_] = sum[now_] * val_ % mod; tagplus[now_] = tagplus[now_] * val_ % mod; tagprod[now_] = tagprod[now_] * val_ % mod; } void Pushdown(int now_) { //注意下放顺序 LL plus = tagplus[now_], prod = tagprod[now_], rev = tagrev[now_]; if (prod != 1) PushProd(ls, prod), PushProd(rs, prod); if (plus) PushPlus(ls, plus), PushPlus(rs, plus); if (rev) PushReverse(ls), PushReverse(rs); tagprod[now_] = 1, tagplus[now_] = 0, tagrev[now_] = 0; } bool IsRoot(int now_) { //判断 now_ 是否为当前 Splay 的根 return son[f][0] != now_ && son[f][1] != now_; } bool WhichSon(int now_) { return son[f][1] == now_; } void Rotate(int now_) { int fa_ = f, w = WhichSon(now_); if (!IsRoot(f)) son[fa[f]][WhichSon(f)] = now_; f = fa[f]; son[fa_][w] = son[now_][w ^ 1]; fa[son[fa_][w]] = fa_; son[now_][w ^ 1] = fa_; fa[fa_] = now_; Pushup(fa_), Pushup(now_); } void Update(int now_) { //将 Splay 路径上的所有标记下放 if (!IsRoot(now_)) Update(f); Pushdown(now_); } void Splay(int now_) { Update(now_); for (; !IsRoot(now_); Rotate(now_)) { if (!IsRoot(f)) Rotate(WhichSon(f) == WhichSon(now_) ? f : now_); } }
Access 构造实链
上面提到,LCT 使用 Splay 来维护实链,可以将实链信息转化为 Splay 的子树信息。由此我们可以考虑把需要维护的对象拿出来,构成一个 Splay,再对该 Splay 进行操作即可。
Access 函数的作用,是将当前辅助树的根到指定节点的路径上所有点,构造成一个 Splay。即使得原树中由根到指定节点的链成为实链。
下图展示 Access(F)
后辅助树的变化。

实现时自下向上构建,舍弃父亲的原有右实儿子,更新为深度更深的链的根即可。
void Access(int now_) { //使得树中由根->now 的链成为实链,构造出由它们组成的 Splay,满足 Splay 的中序遍历深度递减的性质。 //自下向上构建,舍弃父亲的原有右儿子,换成 -> last 的链。 for (int last_ = 0; now_; last_ = now_, now_ = f) { Splay(now_), rs = last_; Pushup(now_); } }
MakeRoot 换根
使指定节点成为原树的根。
画张图可以发现,换根相当于将根到指定节点的路径反向。

于是可以先使原树中由根到指定节点的链成为实链,再使它成为 Splay 的根。
此时的 Splay 表示一条自顶向下的链,路径反向等价于将 splay 表示的链的边反向,父子关系互换。这又等价于反转 Splay 的中序遍历,直接在 Splay 的根上打反转标记即可。
void MakeRoot(int now_) { //使 now 成为原树的根 Access(now_); //先使得树中由根->now 的链成为实链,构造出由它们组成的 Splay。 Splay(now_); //使 now 成为 splay 的根节点 PushReverse(now_); //将根->now 的链反转,使 now 成为原树的根。原理参考 PushReverse 函数的注释。 }
Find 找到指定节点所在原树的根
构造出根到指定节点的 Splay 后,根据实链节点深度递增的性质,Splay 中序遍历的第一个元素即为原树的根。
int Find(int now_) { //找到 now_ 所在原树的根 Access(now_); Splay(now_); while (ls) Pushdown(now_), now_ = ls; //使得树中由根->now 的链成为实链,构造出由它们组成的 Splay,再找到 Splay 中序遍历的第一个元素,即为原树的根。 Splay(now_); //为了下一步操作,把根再转回去 return now_; }
Split 构造由路径组成的 Splay
使 成为根,构造出根到 的 Splay 即得。此时 Splay 根 的子树信息即为路径信息。
通过该操作可以取出指定的原树的一条路径放入 Splay 中,从而将路径问题转化为序列数据结构问题。
void Split(int x_, int y_) { //构造由路径 x->y 组成的 Splay MakeRoot(x_); //使 x 成为根,构造出根 -> y 的 Splay 即得,Splay 根的子树信息即为路径信息。 Access(y_); Splay(y_); }
Link 连边
使 成为根,将根的父亲指向 即可。注意判断两点之间是否已经连通。
void Link(int x_, int y_) { //加边 (x,y) MakeRoot(x_); //使 x 成为根,再给根一个父亲 if (Find(y_) != x_) fa[x_] = y_; }
Cut 删边
先使 成为根。再使用 Find
判断两点是否连通。
之后需要判断两点间是否有直接连边。
在 Find
中 Access(y)
之后, 所在的 Splay 由 的链构成。又 是 Splay 的根, 的中序遍历恰好是 之后的一个,则若边 存在,则 的位置只有一种可能:
是 的右儿子,且 没有左儿子,按照下述代码判断即可。
void Cut(int x_, int y_) { //删边 (x,y) MakeRoot(x_); //使 x 成为根 //Find(y_) != x_ 保证 y 与 x 连通 //在 Find 函数中 Access(y) 之后,y 所在的 splay 由 x->y 的链构成。又 x 是 splay 的根,y 的中序遍历在 x 后,则若边 (x,y) 存在,则 y 的位置只有一种可能: //y 是 x 的右儿子,且 y 没有左儿子。从而保证 y 在中序遍历中是 x 的后一个。 if (Find(y_) != x_ || fa[y_] != x_ || son[y_][0]) return ; fa[y_] = son[x_][1] = 0; //断绝父子关系 Pushup(x_); }
路径修改 和 路径查询
Child's play。
void Modify(int x_, int y_, int val_, int type) { //路径修改 Split(x_, y_); //给路径构成的 Splay 打标记 if (!type) PushPlus(y_, val_); else PushProd(y_, val_); } LL Query(int x_, int y_) { //路径查询 Split(x_, y_); //返回路径构成的 Splay 的信息 return sum[y_]; }
封装模板
//知识点:LCT /* By:Luckyblock */ namespace LCT { #define f fa[now_] #define ls son[now_][0] #define rs son[now_][1] const int kMaxNode = kN; int fa[kMaxNode], son[kMaxNode][2], val[kMaxNode]; //Splay 的结构信息 LL siz[kMaxNode], sum[kMaxNode], tagplus[kMaxNode], tagprod[kMaxNode]; //子树大小,子树和,两种标记 bool tagrev[kMaxNode]; //Splay 的子树反转标记 void Pushup(int now_) { //维护子树和 和 子树大小 sum[now_] = (sum[ls] + sum[rs] + val[now_]) % mod; siz[now_] = siz[ls] + siz[rs] + 1; } void PushReverse(int now_) { //子树反转标记,使得 Splay 节点的中序遍历反向。若原 splay 表示一条自顶向下的链,反转相当于将 splay 表示的链的边反向,父子关系互换。 if (!now_) return; std::swap(ls, rs); tagrev[now_] ^= 1; } void PushPlus(int now_, LL val_) { if (!now_) return; val[now_] = (val[now_] + val_) % mod; sum[now_] = (sum[now_] + siz[now_] * val_ % mod) % mod; tagplus[now_] = (tagplus[now_] + val_) % mod; } void PushProd(int now_, LL val_) { if (!now_) return; val[now_] = val[now_] * val_ % mod; sum[now_] = sum[now_] * val_ % mod; tagplus[now_] = tagplus[now_] * val_ % mod; tagprod[now_] = tagprod[now_] * val_ % mod; } void Pushdown(int now_) { //注意下放顺序 LL plus = tagplus[now_], prod = tagprod[now_], rev = tagrev[now_]; if (prod != 1) PushProd(ls, prod), PushProd(rs, prod); if (plus) PushPlus(ls, plus), PushPlus(rs, plus); if (rev) PushReverse(ls), PushReverse(rs); tagprod[now_] = 1, tagplus[now_] = 0, tagrev[now_] = 0; } bool IsRoot(int now_) { //判断 now_ 是否为当前 Splay 的根 return son[f][0] != now_ && son[f][1] != now_; } bool WhichSon(int now_) { return son[f][1] == now_; } void Rotate(int now_) { int fa_ = f, w = WhichSon(now_); if (!IsRoot(f)) son[fa[f]][WhichSon(f)] = now_; f = fa[f]; son[fa_][w] = son[now_][w ^ 1]; fa[son[fa_][w]] = fa_; son[now_][w ^ 1] = fa_; fa[fa_] = now_; Pushup(fa_), Pushup(now_); } void Update(int now_) { //将 Splay 路径上的所有标记下放 if (!IsRoot(now_)) Update(f); Pushdown(now_); } void Splay(int now_) { Update(now_); for (; !IsRoot(now_); Rotate(now_)) { if (!IsRoot(f)) Rotate(WhichSon(f) == WhichSon(now_) ? f : now_); } } void Access(int now_) { //使得树中由根->now 的链成为实链,构造出由它们组成的 Splay,满足 Splay 的中序遍历深度递减的性质。 //自下向上构建,舍弃父亲的原有右儿子,换成 -> last 的链。 for (int last_ = 0; now_; last_ = now_, now_ = f) { Splay(now_), rs = last_; Pushup(now_); } } void MakeRoot(int now_) { //使 now 成为原树的根 Access(now_); //先使得树中由根->now 的链成为实链,构造出由它们组成的 Splay。 Splay(now_); //使 now 成为 splay 的根节点 PushReverse(now_); //将根->now 的链反转,使 now 成为原树的根。原理参考 PushReverse 函数的注释。 } int Find(int now_) { //找到 now_ 所在原树的根 Access(now_); Splay(now_); while (ls) Pushdown(now_), now_ = ls; //使得树中由根->now 的链成为实链,构造出由它们组成的 Splay,再找到 Splay 中序遍历的第一个元素,即为原树的根。 Splay(now_); //为了下一步操作,把根再转回去 return now_; } void Split(int x_, int y_) { //构造由路径 x->y 组成的 Splay MakeRoot(x_); //使 x 成为根,构造出根 -> y 的 Splay 即得,Splay 根的子树信息即为路径信息。 Access(y_); Splay(y_); } void Link(int x_, int y_) { //加边 (x,y) MakeRoot(x_); //使 x 成为根,再给根一个父亲 if (Find(y_) != x_) fa[x_] = y_; } void Cut(int x_, int y_) { //删边 (x,y) MakeRoot(x_); //使 x 成为根 //Find(y_) != x_ 保证 y 与 x 连通 //在 Find 函数中 Access(y) 之后,y 所在的 splay 由 x->y 的链构成。又 x 是 splay 的根,y 的中序遍历在 x 后,则若边 (x,y) 存在,则 y 的位置只有一种可能: //y 是 x 的右儿子,且 y 没有左儿子。从而保证 y 在中序遍历中是 x 的后一个。 if (Find(y_) != x_ || fa[y_] != x_ || son[y_][0]) return ; fa[y_] = son[x_][1] = 0; //断绝父子关系 Pushup(x_); } void Modify(int x_, int y_, int val_, int type) { //路径修改 Split(x_, y_); //给路径构成的 Splay 打标记 if (!type) PushPlus(y_, val_); else PushProd(y_, val_); } LL Query(int x_, int y_) { //路径查询 Split(x_, y_); //返回路径构成的 Splay 的信息 return sum[y_]; } }
复杂度
上述 Splay
和 Access
操作的复杂度都是均摊 的,可得其它所有操作的复杂度都是均摊 的。
小技巧
一次 Find
操作中进行了 1 次 Access
和 两次 Splay
,并进行了一次 Splay 上的向下查询操作。这使得此操作复杂度较高。
在某些不带删除的题目中可以直接使用并查集维护树根,避免调用 Find
函数。
在保证加删边合法时(例如本题),就不需要在 Link
和 Cut
操作中判断是否连通了。两个函数可改写为下述简单形式。同时避免了 Find
函数的调用,降低了复杂度。
void Link(int x_, int y_) { MakeRoot(x_); fa[x_] = y_; } void Cut(int x_, int y_) { Split(x_, y_); fa[x_] = son[y_][0] = 0; //在 Splay 中 x 和 y 有直接连边,x 在中序遍历中是 y 的前一个,Split 后 Splay 的根是 y,则 x 一定是 y 的左儿子。 Pushup(y_); }
例题
我相信有能力学习 LCT 的读者并不需要过多对代码的解释了= =
「SDOI2008」洞穴勘测
维护连通性。
给定 个点,给定 次操作:
- 断开一条边,保证断开的边存在。
- 连接一条边,保证操作后是一片森林。
- 查询两点的连通性。
,。
1S,128MB。
板题,又保证操作合法,套板子即可。
P3690 【模板】Link Cut Tree (动态树)
维护树链信息。
给定 个点以及每个点的权值,给定 次操作:
- 询问路径上的点的权值异或和。
- 单点权值修改。
- 断开一条边,若边不存在则跳过。
- 连接一条边,若两点已连通则跳过。
,。
2S,128MB。
保证操作后仍是森林,Splay 维护子树节点异或和即可。注意判断操作不合法。可以用如下方法简单实现单点修改:
void Modify(int now_, int val_) { Splay(now_); //先转到根,修改后不影响其它子树的 xor 和 val[now_] = val_; Pushup(now_); }
「HNOI2010」弹飞绵羊
模型的建立。
下述题意是抽象过的,建议先阅读原题面。
给定一长度为 的正整数数列 。对于点 ,从点 向点 连有向边,若 不连边。
给定 次操作:
- 给定点 ,查询以它为起点的有向路径上点的数量。
- 给定参数 ,表示将 修改为 。
,。
1S,128MB。
考虑建立虚拟节点 。对于节点 ,若满足 ,则令 连向 。发现每个节点只会向编号比它大的一个节点连一条边,构成了一个树状结构。
在这棵树上,询问的答案即为 到 链的长度 。修改相当于动态删边加边操作。Splay 维护子树大小即可。总复杂度 级别。
bzoj 4998 星球联盟
加边,维护边双连通分量。
时空限制是在 Mina! 看的 = =
给定一 个点 条边的无向图,给定 次操作。
每次操作加入一条边,查询加入这条边后这条边的两端点是否在一个边双连通分量中,若在则输出该分量内节点个数。
,。
1S,256MB。
无向图边双缩点后一定是一棵树,考虑使用 LCT 维护加边操作。
具体地,使用并查集维护每个点所在的边双(注意不是连通性)。加边时分类讨论。
- 若两点已经在一个边双中,跳过操作。
- 两点不连通,直接连边即可。
- 两点已连通,考虑把两点路径上所有点取出,把它们合并成一个点。新点代表它们所在的边双。
并查集中维护 size。每次加边后考察两点是否在同一个边双内,若在则输出对应祖先的 size。
具体实现上:对于需要合并成一个点的路径,将该路径取出后遍历其中所有点,并在并查集中把它们合并。之后每次需要调用一个 LCT 节点时,都找到该节点在并查集中的祖先,然后调用它的祖先。由于事先 Access(u,v)
了,这样做显然不会破坏 LCT 中原有的各 Splay 的父子关系,可以保证正确性。
考虑复杂度。每个原图中的节点都只会被合并一次,合并一个节点均摊 。则总复杂度为常数飞天的 级别。
//知识点:LCT /* By:Luckyblock */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #define LL long long const int kN = 2e5 + 10; //============================================================= //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmax(int &fir, int sec) { if (sec > fir) fir = sec; } void Chkmin(int &fir, int sec) { if (sec < fir) fir = sec; } namespace UF { //Union-Find data structure const int kMaxNode = kN; int fa[kMaxNode], siz[kMaxNode]; int Find(int x_) { int y = fa[x_]; return fa[x_] == x_ ? x_ : fa[x_] = Find(fa[x_]); } void Union(int x_, int y_) { x_ = Find(x_), y_ = Find(y_); if (x_ != y_) { fa[x_] = y_; siz[y_] += siz[x_]; } } } namespace LCT { #define f fa[now_] #define ls son[now_][0] #define rs son[now_][1] const int kMaxNode = kN; int fa[kMaxNode], son[kMaxNode][2], siz[kMaxNode]; bool tagrev[kMaxNode]; void Pushup(int now_) { now_ = UF::Find(now_); siz[now_] = siz[ls] + siz[rs] + 1; } void PushReverse(int now_) { now_ = UF::Find(now_); if (!now_) return; std::swap(ls, rs); tagrev[now_] ^= 1; } void Pushdown(int now_) { now_ = UF::Find(now_); if (!now_) return ; if (tagrev[now_]) PushReverse(ls), PushReverse(rs); tagrev[now_] = 0; } bool IsRoot(int now_) { int x = f, y = UF::Find(f); return son[UF::Find(f)][0] != now_ && son[UF::Find(f)][1] != now_; } bool WhichSon(int now_) { return son[UF::Find(f)][1] == now_; } void Rotate(int now_) { now_ = UF::Find(now_); int fa_ = UF::Find(f), w = WhichSon(now_); if (!IsRoot(fa_)) son[UF::Find(fa[fa_])][WhichSon(fa_)] = now_; f = UF::Find(fa[fa_]); son[fa_][w] = son[now_][w ^ 1]; fa[son[fa_][w]] = fa_; son[now_][w ^ 1] = fa_; fa[fa_] = now_; Pushup(fa_), Pushup(now_); } void Update(int now_) { now_ = UF::Find(now_); if (!IsRoot(now_)) Update(f); Pushdown(now_); } void Splay(int now_) { now_ = UF::Find(now_); Update(now_); for (; !IsRoot(now_); Rotate(now_)) { if (!IsRoot(UF::Find(f))) Rotate(WhichSon(UF::Find(f)) == WhichSon(now_) ? UF::Find(f) : now_); } } void Access(int now_) { now_ = UF::Find(now_); for (int last_ = 0; now_; last_ = now_, now_ = UF::Find(f)) { Splay(now_), rs = last_; Pushup(now_); } } void MakeRoot(int now_) { now_ = UF::Find(now_); Access(now_); Splay(now_); PushReverse(now_); } int Find(int now_) { now_ = UF::Find(now_); Access(now_); Splay(now_); while (ls) Pushdown(now_), now_ = ls; Splay(now_); return now_; } void Split(int x_, int y_) { x_ = UF::Find(x_), y_ = UF::Find(y_); MakeRoot(x_); Access(y_); Splay(y_); } void Delete(int now_, int fa_) { //递归地遍历 Splay,合并原节点 now_ = UF::Find(now_); if (!now_) return; UF::Union(now_, fa_); Delete(ls, fa_), Delete(rs, fa_); } bool Link(int x_, int y_) { x_ = UF::Find(x_), y_ = UF::Find(y_); if (x_ == y_) return true; MakeRoot(x_); if (Find(y_) != x_) { fa[x_] = y_; return false; } Delete(son[x_][1], x_); //合并原节点 son[x_][1] = 0; //x 即合并后的得到的代表边双的节点。将它的儿子置零。 Pushup(x_); return true; } int Query(int x_, int y_) { x_ = UF::Find(x_), y_ = UF::Find(y_); Split(x_, y_); return siz[y_] - 1; } } //============================================================= int main() { int n = read(), m = read(), p = read(); for (int i = 1; i <= n; ++ i) { UF::fa[i] = i; LCT::siz[i] = UF::siz[i] = 1; } for (int i = 1; i <= m; ++ i) { int u_ = read(), v_ = read(); LCT::Link(u_, v_); } for (int i = 1; i <= p; ++ i) { int u_ = read(), v_ = read(); if (LCT::Link(u_, v_)) { printf("%d\n", UF::siz[UF::Find(v_)]); } else{ printf("No\n"); } } return 0; }
「AHOI2005」航线规划
加边,维护边双连通分量。
给定一 个点 条边的无向图,给定 次操作:
- 断开一条边,保证断开的边存在。
- 查询给定两点间所有路径的必经边的数量。
,,。
1S,128MB。
先离线反向操作,将删边转化为加边。
容易发现查询的答案即原图边双连通分量缩点后指定两点间的边数,同上题使用并查集维护节点所在边双。查询时取出指定路径,答案即路径上的点数 ,维护 Splay 子树大小即可。
总复杂度 级别。
上述做法通用性较强。对于此题来说,还有另外一种解法。
同样离线反向操作,先进行边双缩点,在新图上考虑边转点。将代表边的点的权值赋为 1,其它点权值为 0。边权为 1 的实际含义是该边不在一个环中。
若一次加边后会出现环,则将这条路径上所有点的点权都置为 0 即可。查询的答案即指定两点间路径的权值和。
此时树是静态的,甚至可以直接树剖维护,常数要小很多。
LCT 代码
//知识点:LCT /* By:Luckyblock */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #include <map> #define LL long long #define pr std::pair #define mp std::make_pair const int kN = 5e4 + 10; //============================================================= int n, m, e_num, u[kN << 1], v[kN << 1]; int q_num, q[kN][3], ans[kN]; std::map <pr <int, int>, bool> del; //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmax(int &fir, int sec) { if (sec > fir) fir = sec; } void Chkmin(int &fir, int sec) { if (sec < fir) fir = sec; } namespace UF { //Union-Find data structure const int kMaxNode = kN; int fa[kMaxNode]; int Find(int x_) { int y = fa[x_]; return fa[x_] == x_ ? x_ : fa[x_] = Find(fa[x_]); } void Union(int x_, int y_) { x_ = Find(x_), y_ = Find(y_); if (x_ != y_) fa[x_] = y_; } } namespace LCT { #define f fa[now_] #define ls son[now_][0] #define rs son[now_][1] const int kMaxNode = kN; int fa[kMaxNode], son[kMaxNode][2], siz[kMaxNode]; bool tagrev[kMaxNode]; void Pushup(int now_) { //维护 size now_ = UF::Find(now_); siz[now_] = siz[ls] + siz[rs] + 1; } void PushReverse(int now_) { now_ = UF::Find(now_); if (!now_) return; std::swap(ls, rs); tagrev[now_] ^= 1; } void Pushdown(int now_) { now_ = UF::Find(now_); if (!now_) return ; if (tagrev[now_]) PushReverse(ls), PushReverse(rs); tagrev[now_] = 0; } bool IsRoot(int now_) { int x = f, y = UF::Find(f); return son[UF::Find(f)][0] != now_ && son[UF::Find(f)][1] != now_; } bool WhichSon(int now_) { return son[UF::Find(f)][1] == now_; } void Rotate(int now_) { now_ = UF::Find(now_); int fa_ = UF::Find(f), w = WhichSon(now_); if (!IsRoot(fa_)) son[UF::Find(fa[fa_])][WhichSon(fa_)] = now_; f = UF::Find(fa[fa_]); son[fa_][w] = son[now_][w ^ 1]; fa[son[fa_][w]] = fa_; son[now_][w ^ 1] = fa_; fa[fa_] = now_; Pushup(fa_), Pushup(now_); } void Update(int now_) { now_ = UF::Find(now_); if (!IsRoot(now_)) Update(f); Pushdown(now_); } void Splay(int now_) { now_ = UF::Find(now_); Update(now_); for (; !IsRoot(now_); Rotate(now_)) { if (!IsRoot(UF::Find(f))) Rotate(WhichSon(UF::Find(f)) == WhichSon(now_) ? UF::Find(f) : now_); } } void Access(int now_) { now_ = UF::Find(now_); for (int last_ = 0; now_; last_ = now_, now_ = UF::Find(f)) { Splay(now_), rs = last_; Pushup(now_); } } void MakeRoot(int now_) { now_ = UF::Find(now_); Access(now_); Splay(now_); PushReverse(now_); } int Find(int now_) { now_ = UF::Find(now_); Access(now_); Splay(now_); while (ls) Pushdown(now_), now_ = ls; Splay(now_); return now_; } void Split(int x_, int y_) { x_ = UF::Find(x_), y_ = UF::Find(y_); MakeRoot(x_); Access(y_); Splay(y_); } void Delete(int now_, int fa_) { //递归地遍历 Splay,合并原节点 now_ = UF::Find(now_); if (!now_) return; UF::Union(now_, fa_); Delete(ls, fa_), Delete(rs, fa_); } void Link(int x_, int y_) { x_ = UF::Find(x_), y_ = UF::Find(y_); if (x_ == y_) return; MakeRoot(x_); if (Find(y_) != x_) { fa[x_] = y_; return ; } Delete(son[x_][1], x_); //合并原节点 son[x_][1] = 0; //x 即合并后的得到的代表边双的节点。将它的儿子置零。 Pushup(x_); } int Query(int x_, int y_) { x_ = UF::Find(x_), y_ = UF::Find(y_); Split(x_, y_); return siz[y_] - 1; } } //============================================================= signed main() { n = read(), m = read(); for (int i = 1; i <= n; ++ i) { UF::fa[i] = i; LCT::siz[i] = 1; } for (int i = 1; i <= m; ++ i) { int u_ = read(), v_ = read(); u[++ e_num] = u_, v[e_num] = v_; if (u_ > v_) std::swap(u[e_num], v[e_num]); } while(true) { int opt = read(); if (opt == -1) break; int u_ = read(), v_ = read(); if (u_ > v_) std::swap(u_, v_); q[++ q_num][0] = opt, q[q_num][1] = u_, q[q_num][2] = v_; if (!opt) del[mp(u_, v_)] = true; } for (int i = 1; i <= m; ++ i) { int u_ = u[i], v_ = v[i]; if (del[mp(u_, v_)]) continue; LCT::Link(u_, v_); } for (int i = q_num; i; -- i) { //注意反向 int opt = q[i][0], u_ = q[i][1], v_ = q[i][2]; if (!opt) LCT::Link(u_, v_); if (opt) ans[i] = LCT::Query(u_, v_); } for (int i = 1; i <= q_num; ++ i) { if (q[i][0]) printf("%d\n", ans[i]); } return 0; }
bzoj 2959 长跑
加边,维护边双连通分量。
给定一 个点的无向图,点有点权,初始图中没有边。给定 次操作:
- 连接一条无向边。
- 单点权值修改。
- 给定节点 ,要求把所有边定向,最大化从 到 的所有路径中,经过的点权值和的最大值(经过多次权值也只计算一次),输出该最大值。
,,点权非负。
1S,256MB。
点权非负,显然应该尽量增加经过的点数。
考虑这个查询操作。对于一个边双连通分量,显然可通过构造使得可以在边双里绕圈圈,从而令每个节点都可以被经过并获得其权值,且可以从任意一个节点离开边双。
考虑对原图进行边双缩点,新点的权值为内部所有节点的权值和,查询的答案即两点路径上所有节点的权值和,使用 LCT 维护加边、点权修改即可。
总复杂度 。
//知识点:LCT /* By:Luckyblock */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #define LL long long const int kN = 2e5 + 10; //============================================================= int origin[kN]; //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmax(int &fir, int sec) { if (sec > fir) fir = sec; } void Chkmin(int &fir, int sec) { if (sec < fir) fir = sec; } namespace UF { //Union-Find data structure const int kMaxNode = kN; int fa[kMaxNode]; int Find(int x_) { int y = fa[x_]; return fa[x_] == x_ ? x_ : fa[x_] = Find(fa[x_]); } void Union(int x_, int y_) { x_ = Find(x_), y_ = Find(y_); if (x_ != y_) fa[x_] = y_; } } namespace LCT { #define f fa[now_] #define ls son[now_][0] #define rs son[now_][1] const int kMaxNode = kN; int fa[kMaxNode], son[kMaxNode][2], val[kMaxNode]; LL sum[kMaxNode]; bool tagrev[kMaxNode]; void Pushup(int now_) { now_ = UF::Find(now_); sum[now_] = sum[ls] + sum[rs] + val[now_]; } void PushReverse(int now_) { now_ = UF::Find(now_); if (!now_) return; std::swap(ls, rs); tagrev[now_] ^= 1; } void Pushdown(int now_) { now_ = UF::Find(now_); if (!now_) return ; if (tagrev[now_]) PushReverse(ls), PushReverse(rs); tagrev[now_] = 0; } bool IsRoot(int now_) { return son[UF::Find(f)][0] != now_ && son[UF::Find(f)][1] != now_; } bool WhichSon(int now_) { return son[UF::Find(f)][1] == now_; } void Rotate(int now_) { now_ = UF::Find(now_); int fa_ = UF::Find(f), w = WhichSon(now_); if (!IsRoot(fa_)) son[UF::Find(fa[fa_])][WhichSon(fa_)] = now_; f = UF::Find(fa[fa_]); son[fa_][w] = son[now_][w ^ 1]; fa[son[fa_][w]] = fa_; son[now_][w ^ 1] = fa_; fa[fa_] = now_; Pushup(fa_), Pushup(now_); } void Update(int now_) { now_ = UF::Find(now_); if (!IsRoot(now_)) Update(f); Pushdown(now_); } void Splay(int now_) { now_ = UF::Find(now_); Update(now_); for (; !IsRoot(now_); Rotate(now_)) { if (!IsRoot(UF::Find(f))) Rotate(WhichSon(UF::Find(f)) == WhichSon(now_) ? UF::Find(f) : now_); } } void Access(int now_) { now_ = UF::Find(now_); for (int last_ = 0; now_; last_ = now_, now_ = UF::Find(f)) { Splay(now_), rs = last_; Pushup(now_); } } void MakeRoot(int now_) { now_ = UF::Find(now_); Access(now_); Splay(now_); PushReverse(now_); } int Find(int now_) { now_ = UF::Find(now_); Access(now_); Splay(now_); while (ls) Pushdown(now_), now_ = ls; Splay(now_); return now_; } void Split(int x_, int y_) { x_ = UF::Find(x_), y_ = UF::Find(y_); MakeRoot(x_); Access(y_); Splay(y_); } void Delete(int now_, int fa_) { //递归地遍历 Splay,合并原节点 now_ = UF::Find(now_); if (!now_) return; UF::fa[now_] = fa_; val[fa_] += val[now_]; Delete(ls, fa_), Delete(rs, fa_); } void Link(int x_, int y_) { x_ = UF::Find(x_), y_ = UF::Find(y_); if (x_ == y_) return; MakeRoot(x_); if (Find(y_) != x_) { fa[x_] = y_; return; } Delete(son[x_][1], x_); //合并原节点 son[x_][1] = 0; //x 即合并后的得到的代表边双的节点。将它的儿子置零。 Pushup(x_); } void Modify(int x_, int val_) { //单点修改 Splay(UF::Find(x_)); val[UF::Find(x_)] -= origin[x_]; origin[x_] = val_; val[UF::Find(x_)] += origin[x_]; Pushup(UF::Find(x_)); } LL Query(int x_, int y_) { //查询路径权值和 x_ = UF::Find(x_), y_ = UF::Find(y_); MakeRoot(x_); if (Find(y_) != x_) return -1; //不连通 Split(x_, y_); return sum[y_]; } } //============================================================= int main() { int n = read(), m = read(); for (int i = 1; i <= n; ++ i) { UF::fa[i] = i; origin[i] = LCT::val[i] = LCT::sum[i] = read(); } for (int i = 1; i <= m; ++ i) { int opt = read(), x = read(), y = read(); if (opt == 1) LCT::Link(x, y); if (opt == 2) LCT::Modify(x, y); if (opt == 3) printf("%lld\n", LCT::Query(x, y)); } return 0; }
P4234 最小差值生成树
LCT 维护生成树。
给定一 个点, 条边的无向图,求边权最大值与最小值的差值最小的生成树,输出最小的差值。图可能存在自环。
,,。
3S,256MB。
考虑按照权值升序枚举边,并加入生成树中,以固定边权值最大的边。生成树中权值最小的边的权值应尽可能小,考虑使用 LCT 动态维护生成树中最小的边。
若加入一条边 后图上会产生环,为保证树的形态,则需删掉原树中路径 中任意一条边。显然应贪心地删去权值最小的边,LCT 维护最小边权即可。只要加入新边后构成生成树就统计贡献。
由于树的形态不定,边权并不好直接维护,考虑边转点,对每条边都新建一个虚拟节点并储存边权。
总复杂度 级别。
//知识点:LCT /* By:Luckyblock */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #include <set> #define LL long long const int kN = 5e5 + 10; const int kInf = 1e9 + 2077; //============================================================= struct Edge { int u, v, w; } e[kN]; int ans = kInf; std::multiset <int> edge; //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmax(int &fir, int sec) { if (sec > fir) fir = sec; } void Chkmin(int &fir, int sec) { if (sec < fir) fir = sec; } bool CMP(Edge fir_, Edge sec_) { return fir_.w < sec_.w; } namespace LCT { #define f fa[now_] #define ls son[now_][0] #define rs son[now_][1] const int kMaxNode = kN; int fa[kMaxNode], son[kMaxNode][2], val[kMaxNode]; int minval[kMaxNode], node[kMaxNode]; bool tagrev[kMaxNode]; void Pushup(int now_) { minval[now_] = val[now_], node[now_] = now_; if (ls && minval[ls] < minval[now_]) { minval[now_] = minval[ls], node[now_] = node[ls]; } if (rs && minval[rs] < minval[now_]) { minval[now_] = minval[rs], node[now_] = node[rs]; } } void PushReverse(int now_) { if (!now_) return; std::swap(ls, rs); tagrev[now_] ^= 1; } void Pushdown(int now_) { if (tagrev[now_]) PushReverse(ls), PushReverse(rs); tagrev[now_] = 0; } bool IsRoot(int now_) { return son[f][0] != now_ && son[f][1] != now_; } bool WhichSon(int now_) { return son[f][1] == now_; } void Rotate(int now_) { int fa_ = f, w = WhichSon(now_); if (!IsRoot(f)) son[fa[f]][WhichSon(f)] = now_; f = fa[f]; son[fa_][w] = son[now_][w ^ 1]; fa[son[fa_][w]] = fa_; son[now_][w ^ 1] = fa_; fa[fa_] = now_; Pushup(fa_), Pushup(now_); } void Update(int now_) { if (!IsRoot(now_)) Update(f); Pushdown(now_); } void Splay(int now_) { Update(now_); for (; !IsRoot(now_); Rotate(now_)) { if (!IsRoot(f)) Rotate(WhichSon(f) == WhichSon(now_) ? f : now_); } } void Access(int now_) { for (int last_ = 0; now_; last_ = now_, now_ = f) { Splay(now_), rs = last_; Pushup(now_); } } void MakeRoot(int now_) { Access(now_); Splay(now_); PushReverse(now_); } int Find(int now_) { Access(now_); Splay(now_); while (ls) Pushdown(now_), now_ = ls; Splay(now_); return now_; } void Split(int x_, int y_) { MakeRoot(x_); Access(y_); Splay(y_); } void Link(int x_, int y_) { MakeRoot(x_); if (Find(y_) != x_) fa[x_] = y_; } void Cut(int x_, int y_) { MakeRoot(x_); if (Find(y_) != x_ || fa[y_] != x_ || son[y_][0]) return ; fa[y_] = son[x_][1] = 0; Pushup(x_); } } //============================================================= int main() { int n = read(), m = read(); for (int i = 1; i <= n; ++ i) { LCT::val[i] = LCT::minval[i] = kInf; LCT::node[i] = i; } for (int i = 1; i <= m; ++ i) e[i] = (Edge) {read(), read(), read()}; std::sort(e + 1, e + m + 1, CMP); for (int i = 1, tot = 0; i <= m; ++ i) { int u_ = e[i].u, v_ = e[i].v, w_ = e[i].w; if (u_ == v_) continue; LCT::val[n + i] = LCT::minval[n + i] = w_; LCT::node[n + i] = n + i; if (LCT::Find(u_) != LCT::Find(v_)) { //加边后没有环 ++ tot; } else { //加边后产生环 LCT::Split(u_, v_); int minval = LCT::minval[v_], node = LCT::node[v_]; //找到最小边 edge.erase(edge.find(minval)); //削除 LCT::Cut(e[node - n].u, node), LCT::Cut(e[node - n].v, node); } LCT::Link(u_, n + i), LCT::Link(v_, n + i); //连边 if (tot == n - 1) { //同级生成树的贡献 if (!edge.size()) Chkmin(ans, e[i].w); else Chkmin(ans, e[i].w - (*edge.begin())); } edge.insert(w_); } printf("%d\n", ans); return 0; }
「NOI2014」魔法森林
LCT 维护生成树
给定一 个节点 条边的无向图,每条边有两个属性 。
对于一棵生成树 ,最小化并输出:,,。
1S,256MB。
二元最小生成树问题。
考虑把所有边按 升序排序,按顺序枚举加入生成树中,固定树中各边 的最大值。同上题的套路,先边转点,若加入新边后产生环,删去原路径上 最大的边以最小化 。只要加入新边后构成生成树就 Split(1,n)
来统计贡献。
LCT 分别维护两个属性的最大值即可。总复杂度 。
加入的边不一定影响 ,为什么这样可以保证正确性?可以发现上面实际上就是在模拟 Kruskal 构建最小瓶颈树的过程,结合 Kruskal 的性质可知这样是正确的。
//知识点:LCT /* By:Luckyblock */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #define LL long long const int kN = 5e5 + 10; const int kInf = 2e9 + 2077; //============================================================= struct Edge { int u, v, w1, w2; } e[kN]; int n, m, ans = kInf, fa[kN]; //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmax(int &fir, int sec) { if (sec > fir) fir = sec; } void Chkmin(int &fir, int sec) { if (sec < fir) fir = sec; } bool CMP(Edge fir_, Edge sec_) { return fir_.w1 < sec_.w1; } int Find(int x_) { return fa[x_] == x_ ? x_ : fa[x_] = Find(fa[x_]); } void Union(int x_, int y_) { x_ = Find(x_), y_ = Find(y_); if (x_ != y_) fa[x_] = y_; } namespace LCT { #define f fa[now_] #define ls son[now_][0] #define rs son[now_][1] const int kMaxNode = kN; int fa[kMaxNode], son[kMaxNode][2], vala[kMaxNode], valb[kMaxNode]; int mxa[kMaxNode], posa[kMaxNode], mxb[kMaxNode], posb[kMaxNode]; bool tagrev[kMaxNode]; void Init(int now_, int vala_, int valb_) { vala[now_] = mxa[now_] = vala_; valb[now_] = mxb[now_] = valb_; posa[now_] = posb[now_] = now_; } void Pushup(int now_) { mxa[now_] = vala[now_], mxb[now_] = valb[now_]; posa[now_] = posb[now_] = now_; if (ls && mxa[ls] > mxa[now_]) { mxa[now_] = mxa[ls], posa[now_] = posa[ls]; } if (rs && mxa[rs] > mxa[now_]) { mxa[now_] = mxa[rs], posa[now_] = posa[rs]; } if (ls && mxb[ls] > mxb[now_]) { mxb[now_] = mxb[ls], posb[now_] = posb[ls]; } if (rs && mxb[rs] > mxb[now_]) { mxb[now_] = mxb[rs], posb[now_] = posb[rs]; } } void PushReverse(int now_) { if (!now_) return; std::swap(ls, rs); tagrev[now_] ^= 1; } void Pushdown(int now_) { if (tagrev[now_]) PushReverse(ls), PushReverse(rs); tagrev[now_] = 0; } bool IsRoot(int now_) { return son[f][0] != now_ && son[f][1] != now_; } bool WhichSon(int now_) { return son[f][1] == now_; } void Rotate(int now_) { int fa_ = f, w = WhichSon(now_); if (!IsRoot(f)) son[fa[f]][WhichSon(f)] = now_; f = fa[f]; son[fa_][w] = son[now_][w ^ 1]; fa[son[fa_][w]] = fa_; son[now_][w ^ 1] = fa_; fa[fa_] = now_; Pushup(fa_), Pushup(now_); } void Update(int now_) { if (!IsRoot(now_)) Update(f); Pushdown(now_); } void Splay(int now_) { Update(now_); for (; !IsRoot(now_); Rotate(now_)) { if (!IsRoot(f)) Rotate(WhichSon(f) == WhichSon(now_) ? f : now_); } } void Access(int now_) { for (int last_ = 0; now_; last_ = now_, now_ = f) { Splay(now_), rs = last_; Pushup(now_); } } void MakeRoot(int now_) { Access(now_); Splay(now_); PushReverse(now_); } int Find(int now_) { Access(now_); Splay(now_); while (ls) Pushdown(now_), now_ = ls; Splay(now_); return now_; } void Split(int x_, int y_) { MakeRoot(x_); Access(y_); Splay(y_); } void Link(int x_, int y_) { MakeRoot(x_); if (Find(y_) != x_) fa[x_] = y_; } void Cut(int x_, int y_) { MakeRoot(x_); if (Find(y_) != x_ || fa[y_] != x_ || son[y_][0]) return ; fa[y_] = son[x_][1] = 0; Pushup(x_); } int Query(int x_, int y_) { Split(x_, y_); return mxa[y_] + mxb[y_]; } } //============================================================= int main() { n = read(), m = read(); for (int i = 1; i <= n; ++ i) fa[i] = i; for (int i = 1; i <= m; ++ i) { e[i].u = read(), e[i].v = read(); e[i].w1 = read(), e[i].w2 = read(); } std :: sort(e + 1, e + m + 1, CMP); for (int i = 1; i <= m; ++ i) { if (e[i].u == e[i].v) continue; int u_ = e[i].u, v_ = e[i].v, w1_ = e[i].w1, w2_ = e[i].w2; LCT::Init(i + n, w1_, w2_); if (Find(u_) != Find(v_)) { Union(u_, v_); LCT::Link(u_, i + n), LCT::Link(v_, i + n); } else { LCT::Split(u_, v_); int mxb = LCT::mxb[v_], posb = LCT::posb[v_]; if (mxb <= w2_) continue; LCT::Cut(e[posb - n].u, posb), LCT::Cut(e[posb - n].v, posb); LCT::Link(i + n, u_), LCT::Link(i + n, v_); } if (Find(1) == Find(n)) Chkmin(ans, LCT::Query(1, n)); } printf("%d\n", ans == kInf ? -1 : ans); return 0; } //*我写 kruscal 骗分,好吗? //评测机:*不! //5pts
写在最后
鸣谢
Link Cut Tree - OI Wiki
杨哲《QTREE 解法的一些研究》
Link-Cut-Tree - PoPoQQQ
Link-Cut Tree(LCT)&TopTree讲解 - The_Virtuoso
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix