【SJTUOJ笔记】P1080 小F的公寓(NOIP2007 树网的核)

https://acm.sjtu.edu.cn/OnlineJudge/problem/1080
这道题对树网的核的数据范围做了改动,增加了 n=500000 n = 500000 的数据,因此必须采用时间复杂度 O(n) O ( n ) 级别的算法。
OJ上给了大段的提示,这里先原文搬过来:

本题是NOIP题目,由于数据范围很小,O(N3)、O(N2)、O(N×S)等复杂度的算法都可以获得满分,但实际上存在更优秀的算法。
题目中要求找的路径必须在树的直径上,如果直接按照描述来,需要找出树的所有直径,而树的直径最多可以是O(N2)级别的,这样做的话最终复杂度必然会很高,所以必须先对此作出简化。
树的直径虽然可能很多,但它们的形态是很有规律的,题目描述中就已经给了我们一个很有用的规律:
所有直径的中点是重合于树的中心。
我们从最简单的情况入手:树有多条直径,长度均为D,所有直径唯一的交集就是树的中心(那么树的中心一定处于一个结点上)。
那么显而易见,树的形态应该是这样的:从树的中心出发有4条以上没有交集的长度为D/2的路径,不妨称它们为半径。
显然无论怎样选择核的位置,它最多只能覆盖到两条半径,那么偏心距不可能小于D/2,而直接选择树的中心作为核,偏心距就已经是D/2了,所以树的中心就是核,而这个核位于所有的直径上。
如果所有直径的交集不是一个点呢?由于直径是树上的连续路径,而两条连续路径的交集如果不是空集,一定也是连续路径。
而题目已经告诉了我们,所有直径必定是有交集的,那么交集不是一个点的话,就必然是一段连续的路径,设这个交集为W。
每一条直径都是W两头再延伸出去一段路径形成的,稍加分析就能证明,对于W的某一头,直径延伸出去的所有路径长度都是相等的,
//===裂掉的图和说明文字===
联系前面讨论的“交集为一个点”的情况,会发现这里情况是类似的,如果核覆盖到了蓝色的边,那么将核在蓝色边上的部分删掉,偏心距是不会变化的,所以核一定是在红色的部分上的,换句话说:核在任意一条直径上,这和前一种情况得出的结论是相同的。
有了这个结论,我们就可以随便找出一条树的直径,再在上面找核了。找树的直径可以使用动态规划算法或者两次dfs的方法,复杂度均为O(N),具体方法不在此文叙述。下面来研究怎样找到核的位置。
一条直径上最多有O(N)个点,那是否有O(N2)种核的选择方案呢?注意到这样一个性质,向一条路径多加入一条边,偏心距是不会变得更大的,所以如果核在直径上的起点确定了,就可以一直沿着直径向下走,直到走到头或者总长度将要超过S为止,这样一来,核就只有O(N)种选择方案了。
当选定了一种核的位置后,就需要确定它的偏心距。因为核完全位于直径上,所以任意点要走到核上,一定要先走到直径上,不妨将其走到直径上的第一个点称为“进入点”。由于我们只关心离核最远的点的距离,所以树的结构可以简化为链:对于直径上的每个点,找出以它作为进入点的点中距离最远的一个,称为最远旁枝,这一步是O(N)的。现在要确定核的偏心距,它由三部分产生,第一部分是核上所有点的最远旁枝,第二部分是核头部距离直径头部的距离,第三部分是核尾部距离直径尾部的距离。后两个部分很容易得到,对于第一个部分,实际上是一个RMQ问题,当然可以使用各种RMQ算法解决。但事实上,RMQ也是不必要的,因为我们是从前向后在直径上枚举核的起点,终点的位置也是不会回头的,所以实现一个单调队列就可以在O(N)的时间复杂度解决这个问题。
综合以上所述的各个步骤,整个算法的复杂度O(N),和输入规模同阶,已经是理论下界。

原文中有个图,不过图裂了,那么这里用文字对那张不存在的图说明一下。根据我的猜测,作者本意应该是:如果核超出了所有直径的公共部分 W W (红色部分),还包括了某一条旁枝的一部分(蓝色部分),由于 W W 的端点的偏心距已经和半径等长,那么把蓝色部分删掉,并不会影响总的偏心距。
这个思路已经很详细了,本文主要是对具体的细节实现做一下说明。
第一,如何找到一棵树的直径。原文提到了dp或两次dfs,但我用的是两次bfs(其实和dfs没啥区别)。整棵树采用边表存储。设 now n o w 是当前出队结点,按bfs的做法,应该把和 now n o w 相邻的结点入队,同时更新两个值:源点到邻点的距离和邻点到源点的路径。实现如下:

//x是源点编号,函数返回最远点的编号
int dis(int x){
    memset(q, 0, sizeof(q));
    memset(v, 0, sizeof(v));
    //q是队列,v是访问标记。由于数据规模太大,这两个数组必须定义为全局变量。
    int front = 0, rear = 0, ret = 0;
    v[x] = true;
    q[rear++] = x;
    dist[x] = 0;
    father[x] = 0;
    //father[i]表示从x到i的路径上,i的上一个点。其实如果把x当做树根,father[i]就是i的父亲。
    while (front != rear){
        int now = q[front++];
        for (int i = head[now]; i != 0; i = e[i].next){
            //边表遍历操作,e是存储边的数组。
            int next = e[i].v;
            if (!v[next]){
                dist[next] = dist[now] + e[i].w; //e[i].w是编号为i的边的长度。
                father[next] = now;
                if (dist[ret] < dist[next]) //更新最远点
                    ret = next;
                q[rear++] = next;
                v[next] = true;
            }
        }
    }
    return ret;
}
...
p1 = dis(1);
p2 = dis(p1);
//p1和p2即是一条直径的两个端点,由p2到p1的回溯路径保存在father中。可以画个图体会一下为什么这样做是成立的。

其二,如何确定核的偏心距。我们同样可以利用dis函数,只不过要稍作改动:在进行bfs之前,先把直径 p1p2 p 1 ↔ p 2 上的所有点打上访问标记。由于树结构中不存在环,这种操作相当于把原来的树以直径上的点为根,割裂成了许多子树。原文已经讲明,核必定是直径的一部分,因此核上某一点 c c 的偏心距就等于以 c c 为根的子树中到 c c 的最远距离。这样,就求出了直径上每一点的偏心距。
实际操作上,此方法和dfs结合比和bfs结合简单很多,但我一开始写成bfs,后来不想改了,所以多加了一个参数,导致代码非常丑陋。

int dis(int x, int indicator){ //多加个参数indicator,用来指示是不是特殊的dis
    memset(q, 0, sizeof(q));
    memset(v, 0, sizeof(v));
    int front = 0, rear = 0, ret = 0;
    if (indicator){ //提前割裂
        for (int i = p2; i != 0; i = father[i])
            v[i] = true;
    }
    v[x] = true;
    q[rear++] = x;
    dist[x] = 0;
    if (!indicator) //所有与father相关的操作都应被忽略
        father[x] = 0;
    while (front != rear){
        int now = q[front++];
        for (int i = head[now]; i != 0; i = e[i].next){
            int next = e[i].v;
            if (!v[next]){
                dist[next] = dist[now] + e[i].w;
                if (!indicator) //同上,不能修改father
                    father[next] = now;
                if (dist[ret] < dist[next])
                    ret = next;
                q[rear++] = next;
                v[next] = true;
            }
        }
    }
    return ret;
}
...
for (int i = p2; i != 0; i = father[i])
    dis(i, 1);

再细节的东西这里就不写了,关键还是要把引文部分的思路完全理解。

你可能感兴趣的:(算法与数据结构)