Link_Cut_Tree 学习笔记

【前言】

动态树(Link_Cut_Tree)可以维护很多树上问题。

它既可以 Link 又可以 Cut 呢,先推 Dalao 的文章 再说。

【前置芝士】

  1. 简单树上问题基础。
  2. Splay。
  3. 或许初步了解链剖分。(重链、实链、长链)

估计会比较长,但是限于篇幅,仅会简单介绍原理 + 核心代码实现。

Splay 是众多平衡树的一种,具有结构灵活多变,便于维护不同信息的优点。

它能作为 Link_Cut_Tree 的实现树的重要原因之一是可以进行区间翻转操作,但这是后话了。

文艺平衡树 为例,来康康 Splay 怎么玩。

Splay 是一种通过旋转操作使 二叉搜索树 达到平衡的数据结构。

旋转可以简单理解为将一个节点上提,方式为交换其父亲节点的某些节点,在满足二叉搜索树性质的前提下上提。

比如酱紫:

struct Tree{
    int sum, fa, ch[2];
    bool flag;//这个就是区间翻转的标记。
} tr[N];

void update(int x){
    tr[x].sum = tr[tr[x].ch[0]].sum + tr[tr[x].ch[1]].sum + 1;
}

void connect(int x, int fa, int son){
    tr[x].fa = fa;
    tr[fa].ch[son] = x;
}

void rotate(int x){
    int Y = tr[x].fa;
    if(Y == root) root = x;
    int R = tr[Y].fa;
    int Yson = findfa(x);
    int Rson = findfa(Y);
    int B = tr[x].ch[Yson ^ 1];
    connect(B, Y, Yson);
    connect(Y, x, Yson ^ 1);
    connect(x, R, Rson);
    update(Y), update(x);
}

这长得就很好理解的样子。

我们称仅一个点不断 rotate 的 Splay 为单旋,假设我们从链的底部不断往上 rotate,最终它还是一条链。

但如果我们讨论一下,将 Splay 写成双旋的,当该节点 Splay 到根之后,链会变为一棵二叉树,期望深度 \(\log n\)

这保证了树的平衡,这样 Splay 的期望均摊时间复杂度为 \(O(\log n)\)

比如酱紫写:

int findfa(int x){
    return tr[tr[x].fa].ch[0] == x ? 0 : 1;
}

void splay(int x, int to){
    while(tr[x].fa != to){
        int y = tr[x].fa;
        if(tr[y].fa == to)
            rotate(x);
        else if(findfa(x) == findfa(y))
            rotate(y), rotate(x);
        else
            rotate(x), rotate(x);
    }
}

但这和区间翻转有什么关系呢。

假设我们需要翻转区间 \([l,r]\),根据二叉搜索树的性质,我们只需要以下三步:

  1. 将代表 \(l-1\) 的节点 Splay 至根。
  2. 将代表 \(r+1\) 的节点 Splay 至根。
  3. 此时 \(l-1\) 必为根(\(r-1\)) 的左子节点,\(l-1\)右子树所代表的就是区间 \([l,r]\)

这很清楚的吧。

然后就可以像线段树一样打 lazy tag 就是了。

想要更详细的了解可以打开所给例题的题解,但其实上面这么多就够了。

【主要思想】

模板题为例。

LCT 的实质是动态维护虚实链剖分,可以不断虚实转换,所以需要性质强大的 Splay 维护。

其实 LCT 维护的是一个森林,和树剖很像,我们将 重/轻 换成 实/虚 就是了。

从根节点和每个虚子节点出发,都有一条实链,我们用多个 Splay 维护每一条实链。

维护方式为根据节点深度(一条链上肯定没有深度相同的节点)建立平衡树,这是一个重要的性质

关键主要思想它就这么多。(可惜太懒没有图,或许可以看看前言里推的 Blog)

下面是一些主要操作。

【access】

据说是最重要的操作...。

调用 \(\rm access(x)\) 的作用是“打通” \(x\) 到所在树的 \(\rm root\) 的实链。(将原来的实链改成虚链)

怎么打,不断将 \(x\) Splay 到当前实链的最顶端,然后改变 \(\rm fa(x)\) 的实链方向为指向自己,直到 \(x\) 没有 \(\rm fa\)

像酱紫:

void access(int x){
    for(int y = 0; x; y = x, x = fa[x])
        splay(x), c[x][1] = y, push_up(x);//子树变了记得 push_up。
}

值得注意的是,我们在打通的同时还将 \(x\) 以下的实链部分断开了。(初始化 \(y=0\)

【make_root】

人如其名,\(\rm make\_root(x)\) 可以将 \(x\) 变为所在树的根,就是换根。

具体方法,先打通 \(x\) 到根的路,然后将 \(x\) Splay 到根,这时 \(x\) 是没有右子节点的,因为它原本是最深的节点。

然后直接翻转左右区间,将 \(x\) 变为根。(实质上是将整棵树倒过来了)

代码像酱紫:

void make_root(int x){
    access(x), splay(x);
    rev[x] ^= 1;
}

【split】

东西有了,怎么统计答案呢。

利用 \(\rm split(x, y)\),我们可以将 \((x,y)\) 这条路径的信息处理出来。

方法是将 \(x\) 变为根,然后打通 \(y\) 到根(\(x\))的路,然后 Splay \(y\) 到根,此时 \(y\) 上的信息就是路径 \((x,y)\) 的信息。

其实它就是三个函数...。

代码:

void split(int x, int y){
    make_root(x), access(y), splay(y);
}

可以发现使用 \(\rm split(x,y)\) 之后,\(x\) 为树根,\(y\) 为 Splay 的根,且 \(x\)\(y\) 的左子节点。

【find】

利用 \(\rm find(x)\),我们可以快速找到 \(x\) 所在树的根节点。

方法为打通 \(x\) 到根的路,然后 Splay \(x\) 到根,最后不断跳 \(x\) 的左子节点。(因为根一定是最浅的节点)

像这样:

int find(int x){
    access(x), splay(x), push_down(x);
    while(c[x][0]) x = c[x][0] push_down(x);
    splay(x);
    return x;
}

【link/cut】

这是 LCT 名字的由来,两者操作稍有不同。

\(\rm link(x,y)\) 表示将两个点之间建一条边。

方法为将 \(x\) 当做根,然后直接令 \(fa(x)=y\),此时两者之间连的是一条虚边。

\(\rm cut(x,y)\) 表示将两点之间的边断掉。

方法为处理出 \((x,y)\) 之间的路径,然后之间双向断开 \(x,y\) 之间的边即可,不要忘记 push_up。

void link(int x, int y){
    make_root(x);
    fa[x] = y;
}

void cut(int x, int y){
    split(x, y);
    c[y][0] = fa[x] = 0, push_up(y);
}

值得注意的是,这是在保证 Link/Cut 合法的前提下使用的。

如果可能由不合法情况,我们要这样写:

void link(int x, int y){
    make_root(x);
    fa[x] = y;
}

void cut(int x, int y){
    split(x, y);
    if(c[y][0] == x && c[x][1] == 0)//保证 x, y 之间有边。
        c[y][0] = fa[x] = 0, push_up(y);
}

int x, y;
if(find(x) != find(y)) link(x, y);
if(find(x) == find(y)) cut(x, y);

【代码实现】

基本操作就那么多,然后是每道题都可能不太一样的 push_up 和 push_down。

还有一个与普通 Splay 的重要区别是,如果 rotate 时 \(y=fa(x)\) 是根,那么不能让 \(fa(y)\) 指向 \(x\)

不然会导致一个节点上出现多条实链,这是不符合性质的。

同时因此,我们还需要判断一个节点是否为实链的根,方法很简单,如果它的父亲节点的子节点并不指向它。

那么这个节点就是一条实链的根。

然后是完整代码,比较长,也大概率是本文唯一一次给出题目完整的参考代码。

#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 300010;

int n, m, val[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;
}

struct Link_Cut_Tree{
    int dat[N], c[N][2], fa[N], st[N], rev[N];
    
    void push_up(int x){
        dat[x] = dat[c[x][0]] ^ dat[c[x][1]] ^ val[x];
    }

    void push_down(int x){
        if(!rev[x]) return;
        rev[c[x][0]] ^= 1; rev[c[x][1]] ^= 1;
        swap(c[x][0], c[x][1]);
        rev[x] = 0;
    }

    bool isroot(int x){
        return (c[fa[x]][0] != x && c[fa[x]][1] != x);
    }

    void rotate(int x){
        int Y = fa[x], R = fa[Y];
        int Yson = (c[Y][0] == x) ? 0 : 1, Rson = (c[R][0] == Y) ? 0 : 1;
        int B = c[x][Yson ^ 1];
        if(!isroot(Y)) c[R][Rson] = x;
        fa[x] = R, fa[Y] = x; fa[B] = Y;
        c[x][Yson ^ 1] = Y, c[Y][Yson] = B;
        push_up(Y), push_up(x);
    }

    void splay(int x){
        int tot = 0; st[++ tot] = x;
        for(int i = x; !isroot(i); i = fa[i]) st[++ tot] = fa[i];
        for(int i = tot; i; i --) push_down(st[i]);
        while(!isroot(x)){
            int y = fa[x], z = fa[y];
            if(isroot(y))
                rotate(x);
            else if((c[y][0] == x) == (c[z][0] == y))
                rotate(y), rotate(x);
            else
                rotate(x), rotate(x);
        }
    }

    void access(int x){
        for(int y = 0; x; y = x, x = fa[x])
            splay(x), c[x][1] = y, push_up(x);
    }

    void make_root(int x){
        access(x), splay(x);
        rev[x] ^= 1;
    }

    int find(int x){
        access(x), splay(x), push_down(x);
        while(c[x][0]) x = c[x][0], push_down(x);
        splay(x);
        return x;
    }

    void split(int x, int y){
        make_root(x), access(y), splay(y);
    }

    void cut(int x, int y){
        split(x, y);
        if(c[y][0] == x && c[x][1] == 0)
            c[y][0] = fa[x] = 0, push_up(y);
    }

    void link(int x, int y){
        make_root(x);
        fa[x] = y;
    }
} T;

int main(){
    n = read(), m = read();
    for(int i = 1; i <= n; i ++) 
        val[i] = read(), T.dat[i] = val[i];
    while(m --){
        int opt = read(), x = read(), y = read();
        if(opt == 0){
            T.split(x, y);
            printf("%d\n", T.dat[y]);
        } else if(opt == 1){
            int fx = T.find(x), fy = T.find(y);
            if(fx != fy) T.link(x, y);
        } else if(opt == 2){
            int fx = T.find(x), fy = T.find(y);
            if(fx == fy) T.cut(x, y);
        } else{
            T.access(x), T.splay(x);
            val[x] = y, T.push_up(x);
        }
    }
    return 0;
}

【时间复杂度】

不太好分析,但可以证明 access 以及其他操作的时间复杂度期望是 \(O(n\log n)\) 的。

这和评测机速度以及你 Splay 的常数大小严格相关

【做题经验】

  1. 一道题目是否应该用 LCT,主要看是否有 Link/Cut 操作,如果没有,建议更容易实现且常数更小的树剖。
  2. 记得在访问一个节点子树时 push_down,在改变一个子节点时 push_up,这是时刻需要牢记的。
  3. 要思考清楚 LCT 的初始化过程,虽然可能它并不用初始化。
  4. 若只有 Link 没有 Cut,建议用并查集而非 find 函数判断两个节点的连通性,因为 find 的常数略大。
  5. 如果有子树修改操作,LCT 并不能很好的维护(但树剖可以),但如果只有子树查询,LCT 还是能做的。(见后文)

【经典应用】

LCT 能干的事情有很多,希望你不要惊讶。

【维护链上信息】

这是 LCT 最经典以及最常见的作用之一,以一道例题为例。

Tree II

树上区间加/乘 和 Link/Cut 边。

树上的线段树 2,传 lazy tag 的方法应该都会(先乘后加),然后就是经典的 LCT 入门了。

struct LCT{
	int c[N][2], fa[N], dat[N], mul[N], add[N], val[N], sz[N], rev[N], st[N];

    bool isroot(int x){
        return (c[fa[x]][0] != x && c[fa[x]][1] != x);
    }

    void push_up(int x){
        dat[x] = (dat[c[x][0]] + dat[c[x][1]] + val[x]) % MOD;
        sz[x] = (sz[c[x][0]] + sz[c[x][1]] + 1) % MOD;
    }

    void push_add(int x, int v){
        dat[x] = (dat[x] + 1LL * v * sz[x] % MOD) % MOD;
        add[x] = (add[x] + v) % MOD;
        val[x] = (val[x] + v) % MOD;
    }

    void push_mul(int x, int v){
        dat[x] = 1LL * dat[x] * v % MOD;
        add[x] = 1LL * add[x] * v % MOD;
        mul[x] = 1LL * mul[x] * v % MOD;
        val[x] = 1LL * val[x] * v % MOD;
    }

    void push_down(int x){
        if(mul[x] != 1)
            push_mul(c[x][0], mul[x]), push_mul(c[x][1], mul[x]), mul[x] = 1;
        if(add[x])
            push_add(c[x][0], add[x]), push_add(c[x][1], add[x]), add[x] = 0;
        if(rev[x]){
            rev[c[x][0]] ^= 1, rev[c[x][1]] ^= 1;
            swap(c[x][0], c[x][1]);
            rev[x] = 0;
        }
    }
    ......
} T;

int main(){
    n = read(), m = read();
    //不要忘记初始化。
    for(int i = 1; i <= n; i ++) T.mul[i] = T.val[i] = T.sz[i] = 1;
    for(int i = 1; i < n; i ++){
        int u = read(), v = read();
        T.link(u, v);
    }
    char s[3];
    while(m --){
        scanf("%s", s);
        if(s[0] == '+'){
            int u = read(), v = read(), c = read();
            T.split(u, v);
            T.push_add(v, c);
        } else if(s[0] == '-'){
            int u1 = read(), v1 = read(), u2 = read(), v2 = read();
            T.cut(u1, v1), T.link(u2, v2);
        } else if(s[0] == '*'){
            int u = read(), v = read(), c = read();
            T.split(u, v);
            T.push_mul(v, c);
        } else{
            int u = read(), v = read();
            T.split(u, v);
            printf("%d\n", T.dat[v]);
        }
    }
    return 0;
}

【维护连通性】

这是一个不太常见的用法,其实 LCT 可以看做是加强版的并查集,不仅支持 Link 还支持 Cut。

一道例题:洞穴勘测

就是板子题了,代码也不给了。

【维护边双连通分量】

其实 e-DCC 缩点后,无向图就是一棵树,所以 LCT 理所当然的可以维护这么个东西。

具体方法,多一个 merge,表示连边。

如果 \(\rm merge(x,y)\) 时,两者之间不连通,就和原来一样 \(\rm link(x,y)\),否则暴力缩点。

缩点后用一个标志点代表整个双连通分量,并查集维护每个点所在的分量的标志点。

因为最多缩 \(n\) 个点(因为总共就 \(n\) 个点),加上并查集的复杂度也才 \(O(n\log n)\),所以复杂度是对的。

例如这道题:航线规划

多次询问无向图两点之间桥的数量,支持 Cut 边操作。

理论上来说,离线下来倒序询问,将 Cut 变为 Link 要好做一些。

然后就是暴力缩点了,显然需要维护的是链的长度。(这里用 \(sz(x)\) 表示)

struct LCT{
    ......
	void access(int x){
        for(int y = 0; x; y = x, x = fa[y] = Get(fa[x]))//Attention
            splay(x), c[x][1] = y, push_up(x);
    }
    void del(int x, int y){
        if(y) h[y] = x, del(x, c[y][0]), del(x, c[y][1]);
    }

    void merge(int x, int y){
        if(x == y) return;
        make_root(x);
        if(find(y) != x) {fa[x] = y; return;}
        //注意 find(y) 之后 x 有被 Splay 到根了,所以此时环 (x,y) 就是 x 的右子树。
        //这是一个有趣而巧妙的操作。
        del(x, c[x][1]);
        c[x][1] = 0, push_up(x);
    }
} T;

int main(){
    n = read(), m = read();
    for(int i = 1; i <= n; i ++) 
        h[i] = i, T.sz[i] = 1;
    for(int i = 1; i <= m; i ++){
        e[i].x = read(), e[i].y = read();
        if(e[i].x > e[i].y) swap(e[i].x, e[i].y);
    }
    int tot = 0;
    while((q[++ tot].opt = read()) != -1){
        q[tot].x = read(), q[tot].y = read();
        if(q[tot].opt) continue;
        if(q[tot].x > q[tot].y) swap(q[tot].x, q[tot].y);
        mp[(Edge){q[tot].x, q[tot].y}] = true;
    }
    tot --;
    for(int i = 1; i <= m; i ++)
        if(!mp[e[i]])
            T.merge(Get(e[i].x), Get(e[i].y));
    int cnt = 0;
    for(int i = tot; i; i --){
        int x = Get(q[i].x), y = Get(q[i].y);
        if(q[i].opt){
            T.split(x, y);
            ans[++ cnt] = T.sz[y] - 1;
        }
        else T.merge(x, y);
    }
    for(int i = cnt; i; i --) printf("%d\n", ans[i]);
    return 0;
}

【维护生成树】

众所周知生成树也是树,所以也可以 LCT 维护。

主要方法:拆边

将边当做点,假设有边 \((x,y)\),我们给它一个 \(\rm id\) 表示这条边的编号。

那么直接 \(\rm link(x, id),link(y,id)\) 即可表示连边。

其中 \(\rm id\) 的点权即为边权,\(\rm x,y\) 的点权则需要赋为恰当的值(使其不会对答案产生影响)。

生成树修改时如果产生环,就删掉环上边权 最大/小 的边,保证生成树的性质。

例题:水管局长

维护最小生成树,询问路径 \((x,y)\) 中的最大边权,支持 Cut。

和上一题一样的套路,离线下来将 Cut 变为 Link。

然后利用上述方法维护。

记得维护时,一定要记录的是最大边权的边的编号,这要便于 Cut。

struct LCT{
    void push_up(int x){
        int l = dat[c[x][0]], r = dat[c[x][1]];
        dat[x] = val[x];
        if(e[l].z > e[dat[x]].z) dat[x] = l;
        if(e[r].z > e[dat[x]].z) dat[x] = r;
    }
    ......
} T;

int main(){
    n = read(), m = read(), Q = read();
    for(int i = 1; i <= m; i ++){
        e[i].x = read(), e[i].y = read(), e[i].z = read();
        if(e[i].x > e[i].y) swap(e[i].x, e[i].y);
    }
    sort(e + 1, e + m + 1, cmp);
    e[0].z = 0;
    for(int i = 1; i <= m; i ++){
        id[make_pair(e[i].x, e[i].y)] = i;
        T.val[i + n] = T.dat[i + n] = i;
    }
    for(int i = 1; i <= Q; i ++){
        q[i].opt = read(), q[i].x = read(), q[i].y = read();
        if(q[i].x > q[i].y) swap(q[i].x, q[i].y);
        if(q[i].opt == 2){
            int d = id[make_pair(q[i].x, q[i].y)];
            q[i].d = d;
            vis[d] = true;
        }
    }
    int sum = 0;
    for(int i = 1; i <= m; i ++) if(!vis[i]){
        if(sum == n - 1) break;
        int x = e[i].x, y = e[i].y;
        if(T.find(x) == T.find(y)) continue;
        T.link(x, i + n), T.link(y, i + n);
        sum ++;
    }
    int cnt = 0;
    for(int i = Q; i; i --){
        int x = q[i].x, y = q[i].y;
        T.split(x, y);
        if(q[i].opt == 1){
            ans[++ cnt] = e[T.dat[y]].z;
        }
        else{
            int d = q[i].d, t = T.dat[y];
            if(e[d].z < e[t].z){
                T.cut(e[t].x, t + n), T.cut(e[t].y, t + n);
                T.link(x, d + n), T.link(y, d + n);
            }
        }
    }
    for(int i = cnt; i; i --) printf("%d\n", ans[i]);
    return 0;
}

【查询子树信息】

上面提到过,若有子树修改就不好做了,但是子树查询还是没有问题的。

考虑一个节点的子树是什么,不就是 实子树 + 虚子树 吗,那就在维护答案的同时,统计虚子树的答案即可。

考虑影响 虚实 的操作有哪些,其实只有两个:

  1. Link 操作导致 \(y\) 多一个虚子树 \(x\)
  2. access 操作导致虚实发生根本性变化。

那就改一下就是了,假设 \(\rm si(x)\) 表示 \(x\) 的虚子树的答案,\(\rm s(x)\) 表示 \(x\) 所有子树(虚/实)的答案。

void push_up(int x){
    int l = c[x][0], r = c[x][1];
    s[x] = s[l] + s[r] + si[x] + 1;//记得 + 1。
}
void access(int x){
    for(int y = 0; x; y = x, x = fa[x]){
        splay(x);
        si[x] += s[c[x][1]];
        si[x] -= s[c[x][1] = y];
        push_up(x);
    }
}
void link(int x, int y){
    make_root(x);
    si[fa[x] = y] += s[x];
    push_up(y);
}

板子题:大融合

求树上经过边 \((x,y)\) 的路径数,支持 Link。

值得讨论的是答案的统计。

假设需要统计边 \((x,y)\) 的答案,显然是 \(x\) 这边的节点数 \(\times\) \(y\) 这边的节点数。

在 LCT 中,我们先令 \(x\) 为根,然后打通 \(y\) 并将 \(y\) Splay 到根。

(你可以偷懒写成 \(\rm split(x,y)\),但这里不是提取路径的意思)

然后此时 \(x\) 是树的根,\(y\)\(x\) 的实儿子,且 \(y\) 没有实儿子

那么显然答案就是 \(\rm (si(x)+1)\times (si(y)+1)\)

因为 \(x\) 这边的节点数就是,其所有虚儿子的节点数 + 自己(实儿子是 \(y\),显然不能算)。

\(y\) 没有实儿子,所以就是虚儿子数 + 自己。

显然你已经不需要代码了。

【维护树上颜色连通块】

其实是维护链的一个综合。

具体方法,有多少种颜色就开多少个 LCT,每种颜色互不干扰。(显然这类题目的颜色数是很少的)

举个栗子:网络

注意到每种颜色的边构成的图中,节点度数最大为 \(2\),且无环,所以显然是一个森林。

所以每种颜色开 LCT,然后就是裸题了。

技巧还是有的,毕竟要记录每个节点的度数。

struct LCT{
	......
    void link(int x, int y) {d[x] ++, d[y] ++; make_root(x), fa[x] = y;}
	void cut(int x, int y) {d[x] --, d[y] --; split(x, y); fa[x] = c[y][0] = 0; push_up(y);}
} T[10];

void Modify_Point(){
    int x = read(), y = read();
    val[x] = y;
    for(int i = 0; i < C; i ++) T[i].splay(x);
}

void Modify_Edge(){
    int u = read(), v = read(), w = read();
    if(u > v) swap(u, v);
    if(col.find(make_pair(u, v)) == col.end()) {puts("No such edge."); return;}
    int t = col[make_pair(u, v)];
    if(t == w) {puts("Success."); return;}
    if(T[w].d[u] == 2 || T[w].d[v] == 2) {puts("Error 1."); return;}
    if(T[w].find(u) == T[w].find(v)) {puts("Error 2."); return;}
    T[t].cut(u, v); T[w].link(u, v);
    col[make_pair(u, v)] = w;
    puts("Success.");
}

int main(){
    n = read(), m = read(), C = read(), q = read();
    for(int i = 1; i <= n; i ++) val[i] = read();
    for(int i = 1; i <= m; i ++){
        int u = read(), v = read(), w = read();
        if(u > v) swap(u, v);
        T[w].link(u, v); col[make_pair(u, v)] = w;
    }
    while(q --){
        int opt = read();
        if(!opt) Modify_Point();
        else if(opt == 1) Modify_Edge();
        else{
            int c = read(), u = read(), v = read();
            T[c].make_root(u);
            if(T[c].find(v) != u) puts("-1");
            else{
                T[c].split(u, v);
                printf("%d\n", T[c].dat[v]);
            }
        }
    }
    return 0;
}

【习题】

自己做的题也不多,所以没啥好推荐的。

Dalao 们倒是有很多不错的题单:

  1. LCT总结——应用篇(附题单)(LCT)
  2. Link-Cut-Tree

坑留在这里,之后这些题单里的题目都会慢慢完成的。

【总结】

LCT 是很灵活多变的处理树上 Link/Cut 问题的优秀方法。

希望 2021 联合省选 rp ++。

完结撒花。

posted @ 2021-03-30 15:02  LPF'sBlog  阅读(86)  评论(0编辑  收藏  举报