作为NOIp2012D2T3,这道题可以说是比较复杂了。思路虽然不会太难,但实现起来比较复杂,细节很多。虽然比不上很多巨佬题目,但身为紫题也可以说是当之无愧了。不过虽说复杂,理解了思路之后实现起来也不会难。

本蒟蒻完全看不懂题解中各位大佬的代码,于是决定写一篇题解。本文的思路和代码会尽量清晰,希望能对大家有帮助。

本文的思路和大部分大佬的思路基本相同,主要是为大家带来更具体的讲解和更清晰的实现。


实现流程:树上倍增预处理+二分答案+贪心

思路:

因为若一个时间限制满足题意,则所有比它大的时间限制一定都满足题意,因此本题答案具有单调性,可以想到二分答案求解。

那么接下来的重点就是check函数了,如何判断一个时间限制符不符合条件呢?显然,对于所有军队,我们希望它最后停留的节点深度越小越好,这样可以控制最多的路径。因此我们让所有的军队尽量地向上走,若一个军队可以走到根节点,则令其暂时停在根节点的子节点;否则走到时间限制内能够走到的深度最小的节点。这个步骤可以用树上倍增进行优化。

经过这步处理后,我们先找出所有以根节点的子节点为根的子树中,是否有到叶子节点的路径还未被驻扎,并记录下还有路径未被驻扎的这些子树的根节点。对于这些节点,可以证明,若该节点上停留有军队,则剩余时间最小的军队驻扎在该节点一定是最优的。这样处理过这些节点后,把剩下的节点按照到根节点的距离从小到大排序。

现在可能还会有一些军队未确定最后的驻扎节点,把这些军队按照剩余时间从小到大排序,然后和上一步处理出来的这些节点一一进行匹配。这是一个可以证明正确性的贪心策略。若所有未被驻扎的节点都有军队驻扎,则说明当前的时间限制可行;反之则不可行。

接下来会对实现流程进行具体地讲解。


具体实现流程:

1.输入

输入就不多说了,建议用邻接表存图,遍历时更方便。因为路径是双向的,因此要双向存边。在输入的同时统计所有路径的长度之和,以作为二分的右边界。

int ver[2*N],edge[2*N],Next[2*N],head[N],query[N];

void add(int x,int y,int z)
{
    ver[++tot]=y,edge[tot]=z,Next[tot]=head[x],head[x]=tot;
}//邻接表插入操作

cin>>n;

for(int i=1;i<n;i++)
{
    int x,y,z;
    scanf("%d%d%d",&x,&y,&z);
    add(x,y,z),add(y,x,z);
    r+=z;//统计所有边的长度之和
}

cin>>m;
for(int i=1;i<=m;i++)
    scanf("%d",&query[i]);//输入军队

2. 树上倍增预处理

这个步骤的实现和求LCA基本相同,用到了二进制拆分的思想。关于树上倍增法求LCA,不懂的可以点击食用(虽然和这题没有太大关系),接下来也会详细讲解。

在这里用 $f_{i,j}$ 存储 $i$ 节点的第 $2^j$ 个祖先的节点编号, $dist_{i,j}$ 存储 $i$ 节点到它的第 $2^j$ 个祖先的路径长度。在预处理过程中同时也处理出每个节点的深度,用 $d_i$ 表示。

以上三个数组的值可以通过递推得出,对于 $f$ 数组和 $dist$ 数组,有以下递推式:

f[i][j]=f[f[i][j-1]][j-1];
dist[i][j]=dist[i][j-1]+dist[f[i][j-1]][j-1];

这个递推式根据数组的定义很容易推出,请读者自行思考。

d数组的递推式就不多说了,待下面给出实现过程时读者自然清楚。

本文这个步骤用bfs实现,当然用dfs实现也可以。

接下来给出具体实现过程:

  1. 建立一个空队列,并将根节点入队,同时存储根节点的深度
  2. 取出队头,遍历其所有出边。由于存储的时候是按照无向图存储,因此要进行深度判定,对于连接到它父亲节点的边,直接continue即可。记当前路径的另一端节点为 $y$ ,处理出 $y$ 的 $d$ 、 $f$ 、 $dist$ 三个数组的值,然后将 $y$ 入队。
  3. 重复第2步,直到队列为空

    $f$ 数组和 $dist$ 数组的递推初值是多少呢?显然, $f_{i,0}$ 表示的就是 $i$ 节点的父亲节点,因此可以直接得出 $f_{i,0}$ 和 $dist_{i,0}$ 的值,其余为0,接下来进行递推就可以了。

int d[N],f[N][20];
ll dist[N][20];

queue<int> q;

t=log2(n)+1;//f数组和dist数组第二维下标的最大值

void bfs()
{
    q.push(1);
    d[1]=1;
    while(q.size())
    {
        int x=q.front();q.pop();
        for(int i=head[x];i;i=Next[i])//遍历x的出边
        {
            int y=ver[i];
            if(d[y])
                continue;//若深度小于当前节点,说明是当前节点的父节点
            d[y]=d[x]+1;//这步不用解释了吧
            f[y][0]=x,dist[y][0]=edge[i];//递推初值
            for(int j=1;j<=t;j++)
            {
                f[y][j]=f[f[y][j-1]][j-1];
                dist[y][j]=dist[y][j-1]+dist[f[y][j-1]][j-1];
            }//递推
            q.push(y);
        }
    }
}

3. 二分答案

经过预处理后,就开始二分答案了。二分时,若当前的时间限制满足题意,则更新答案。这个步骤比较简单,就不多说了。

while(l<=r)
{
    mid=(l+r)>>1;
    if(check(mid))
    {
        r=mid-1;
        ans=mid;
        ok=1;//有解标记
    }
    else
        l=mid+1;
}

下面给出check函数的实现

4. 上移军队并存储闲置军队

与求LCA类似,利用树上倍增实现上移。用二进制拆分的思想,具体来说,就是依次尝试向上走 $2^{logn},...,2^1,2^0$ 步,并保证终点在根节点之前,且时间不超过当前的时间限制。

将军队上移到根节点的子节点或者能够达到的最浅节点后,若该军队还能向上到达根节点,说明它还有转移到其它子树去驻扎的可能,因此将它记为暂时闲置,用一个二元组存储起来;否则就将该军队驻扎在当前节点,并将当前节点标记为已驻扎。

二元组排序时会以第一维的大小排序,因此在这里第一维存储该军队到达根节点后剩余的时间,第二维存储该军队当前所在的节点编号。

int ctot=0;
pair<ll,int> h[N];
bool sta[N];

//lim表示当前的时限,即check函数传入的参数
for(int i=1;i<=m;i++)
{
    ll x=query[i],cnt=0;//cnt统计时间花费
    for(int j=t;j>=0;j--)//从logn到0枚举
        if(f[x][j]>1 && cnt+dist[x][j]<=lim)//若终点在根节点之前且不会超过时限
        {
            cnt+=dist[x][j];
            x=f[x][j];
        }
    if(f[x][0]==1 && cnt+dist[x][0]<=lim)//若当前节点为根节点的子节点且该军队可以在时限内到达根节点
        h[++ctot]=make_pair(lim-cnt-dist[x][0],x);//存储闲置军队
    else
        sta[x]=1;//已驻扎标记
}

5. dfs寻找路径未被驻扎的叶子节点

这里的思想比较简单,从每个根节点的子节点开始搜索,若搜索时遇到已驻扎节点,则返回1;若一直搜索到某个叶子节点都没有遇到已驻扎节点,则说明根节点的这个子节点需要军队驻扎,返回0。

和上面的bfs预处理一样,当遇到连接父节点的边时,continue即可。

dfs查找结束后,存储需要根节点的需要被驻扎的子节点。

bool need[N];

bool dfs(int x)
{
    bool pson=0;//判断是否为叶子节点
    if(sta[x])
        return 1;//若当前节点已被驻扎,则返回1
    for(int i=head[x];i;i=Next[i])//遍历x的出边
    {
        int y=ver[i];
        if(d[y]<d[x])
            continue;//遇到父节点
        pson=1;//若有一条不是连接着父节点的边,说明不是叶子节点
        if(!dfs(y))//若某个子节点搜索时遇到路径未被驻扎的叶子节点,直接返回0
            return 0;
    }
    if(!pson)//当前节点是叶子节点且未被驻扎
        return 0;
    return 1;//没有遇到路径未被驻扎的叶子节点,返回1
}

for(int i=head[1];i;i=Next[i])//遍历根节点的子节点
    if(!dfs(ver[i]))//若需要被驻扎
        need[ver[i]]=1;

6. 对根节点的需要被驻扎的子节点进行初步处理

在讲解这个步骤之前先说明一个引理:对于第5步中需要被驻扎的节点,若该节点上有军队停留,则对于这些军队中剩余时间最小的军队,若该军队的剩余时间不足够令它从根节点再返回现在停留的节点,则直接驻扎在该节点是最优的。

为什么呢?因为该军队无法从当前的节点到达根节点再返回,说明若它驻扎在根节点的其它子树,只能驻扎在一个到根节点的距离小于当前节点到根节点的距离的节点。而这个节点所有剩余时间比它多的军队一定也可以去驻扎,甚至还可能驻扎到其它更多的节点。因此不如让剩余时间多的节点去驻扎,而自己留在当前节点即可。

身为一个蒟蒻,也没办法给出多么严密的解释,不过总之,上面给出的引理可以证明是成立的。因此对于任意一个需要被驻扎的节点,若它上面停留着一支军队不能到达根节点并返回该节点,则令其驻扎在该节点;另外的,因为一个节点只需要一支军队驻扎,因此我们在这里选择剩余时间最小的节点。所以在处理前要先将 $h$ 数组按照剩余时间从小到大排序。

对于处理过后仍闲置的军队,将它们的剩余时间存储起来备用。因为此时我们已经不关心它当前停留在哪个节点,因此只要存储剩余时间就好。

int atot=0;
ll tim[N];

sort(h+1,h+ctot+1);//排序,二元组按照第一维大小排序
for(int i=1;i<=ctot;i++)//遍历所有闲置的军队
    if(need[h[i].second] && h[i].first<dist[h[i].second][0])//若该军队所处的节点需要被驻扎且该军队无法到达根节点并返回
        need[h[i].second]=0;//去除标记
    else
        tim[++atot]=h[i].first;//存储军队的剩余时间

7. 找到仍需要被驻扎的节点并存储

经过第6步的初步处理,可能还有一些节点需要被驻扎,此时遍历一遍根节点的子节点,找到这些节点并存储起来备用。与第6步相同,这里只要存储节点到根节点的距离就可以了,不必存储节点编号。

int btot=0;
ll ned[N];

for(int i=head[1];i;i=Next[i])
    if(need[ver[i]])//如果仍需要被驻扎
        ned[++btot]=dist[ver[i]][0];

8. 利用贪心策略完成最后的匹配

这里有一个贪心策略:对于现在闲置的军队和需要被驻扎的节点,让剩余时间小的军队优先驻扎在距离根节点近的节点,这样可以保证决策最优。匹配过后,若所有需要被驻扎的节点都已有军队驻扎,则说明当前的时限可行;反之则不可行。

if(atot<btot)
    return 0;//如果剩余的军队比需要被驻扎的节点还少,显然不可行,直接返回0
sort(tim+1,tim+atot+1),sort(ned+1,ned+btot+1);//分别对军队的剩余时间和节点到根节点的距离进行升序排序
int i=1,j=1;//指针
while(i<=btot && j<=atot)   //扫描整个tim和ned数组 
    if(tim[j]>=ned[i])//若可行
    {
        i++,j++;//都扫描到下一位
    }
    else
        j++;//否则扫描下一支军队
if(i>btot)//说明所有需要被驻扎的节点都已被驻扎
    return 1;
return 0;

至此,check函数就愉快地结束了。

9. 特判并输出

题目中要求对无解的情况进行特判,若二分时没有任何一个时限满足条件,自然就说明无解。否则输出答案即可。虽然并没有无解的数据

if(!ok)
    cout<<-1;
else
    cout<<ans;

完结撒花!

整道题目主要还是细节多,在写这类较为复杂的题目时一定要注意细节,防止会写的题爆零。


如果以上讲解没有听懂,可以借助下面这组数据更详细地了解:

10 5
2 1 3
2 3 4
1 4 7
5 1 9
6 1 2
4 7 9
7 8 8
9 8 8
1 10 2
2 8 5 4 2 

这组数据构造出来就长这个样子:

假设我们当前二分到的时间限制为9,模拟上面的步骤进行一遍。

  1. 上移军队:只有处于节点8的军队上移到了节点7,其余节点由于都在根节点的子节点上,不动。

  1. 找出闲置军队与需要被驻扎的节点。显然,闲置军队为目前处于4、2、5节点上的4支军队,需要被驻扎的节点为10、2、5、6节点。
  2. 初步处理这些数据。由于5号节点上的军队无法到达根节点再返回,且该节点需要被驻扎,因此该军队驻扎在五号节点。

  1. 找出目前的闲置军队和需要被驻扎的节点。剩余的闲置军队为目前处于4、2节点上的3支军队,需要被驻扎的节点为2、5、6号节点。

  2. 利用贪心策略进行最后的匹配。最后,4号节点上的军队驻扎到2号节点,2号节点上的两支军队分别驻扎到6、10号节点。该方案是可行的。


最后给出完整代码:

#include<iostream>
#include<cstdio>
#include<cstring>
#include<queue>
#include<cmath>
#include<algorithm>
#define ll long long
using namespace std;
const int N=6e4;
int n,m,t,tot=0,atot=0,btot=0,ctot=0;
int d[N],query[N],f[N][20];
int ver[2*N],edge[2*N],Next[2*N],head[N];
bool ok,sta[N],need[N];
ll ans,tim[N],ned[N],dist[N][20];
pair<ll,int> h[N];
queue<int> q;
void add(int x,int y,int z)
{
    ver[++tot]=y,edge[tot]=z,Next[tot]=head[x],head[x]=tot;
}//1、输入
void bfs()
{
    q.push(1);
    d[1]=1;
    while(q.size())
    {
        int x=q.front();q.pop();
        for(int i=head[x];i;i=Next[i])
        {
            int y=ver[i];
            if(d[y])
                continue;
            d[y]=d[x]+1;
            f[y][0]=x,dist[y][0]=edge[i];
            for(int j=1;j<=t;j++)
            {
                f[y][j]=f[f[y][j-1]][j-1];
                dist[y][j]=dist[y][j-1]+dist[f[y][j-1]][j-1];
            }
            q.push(y);
        }
    }
}//2、树上倍增预处理
bool dfs(int x)
{
    bool pson=0;
    if(sta[x])
        return 1;
    for(int i=head[x];i;i=Next[i])
    {
        int y=ver[i];
        if(d[y]<d[x])
            continue;
        pson=1;
        if(!dfs(y))
            return 0;
    }
    if(!pson)
        return 0;
    return 1;
}//5、dfs寻找路径未被驻扎的叶子节点
bool check(ll lim)
{
    memset(sta,0,sizeof(sta));
    memset(tim,0,sizeof(tim));
    memset(ned,0,sizeof(ned));
    memset(h,0,sizeof(h));
    memset(need,0,sizeof(need));
    atot=0,btot=0,ctot=0;//初始化
    for(int i=1;i<=m;i++)
    {
        ll x=query[i],cnt=0;
        for(int j=t;j>=0;j--)
            if(f[x][j]>1 && cnt+dist[x][j]<=lim)
            {
                cnt+=dist[x][j];
                x=f[x][j];
            }
        if(f[x][0]==1 && cnt+dist[x][0]<=lim)
            h[++ctot]=make_pair(lim-cnt-dist[x][0],x);
        else
            sta[x]=1;
    }//4、上移军队并处理闲置军队
    for(int i=head[1];i;i=Next[i])
        if(!dfs(ver[i]))
            need[ver[i]]=1;//5、dfs寻找路径未被驻扎的叶子节点
    sort(h+1,h+ctot+1);
    for(int i=1;i<=ctot;i++)
        if(need[h[i].second] && h[i].first<dist[h[i].second][0])
            need[h[i].second]=0;
        else
            tim[++atot]=h[i].first;//6、对根节点的需要被驻扎的子节点进行初步处理
    for(int i=head[1];i;i=Next[i])
        if(need[ver[i]])
            ned[++btot]=dist[ver[i]][0];//7、找到仍需要被驻扎的节点并存储
    if(atot<btot)
        return 0;
    sort(tim+1,tim+atot+1),sort(ned+1,ned+btot+1);
    int i=1,j=1;
    while(i<=btot && j<=atot)
        if(tim[j]>=ned[i])
        {
            i++,j++;
        }
        else
            j++;
    if(i>btot)
        return 1;
    return 0;//8、利用贪心策略完成最后的匹配
}
int main()
{
    ll l=0,r=0,mid;
    cin>>n;
    t=log2(n)+1;
    for(int i=1;i<n;i++)
    {
        int x,y,z;
        scanf("%d%d%d",&x,&y,&z);
        add(x,y,z),add(y,x,z);
        r+=z;
    }//1、输入
    bfs();//2、树上倍增预处理
    cin>>m;
    for(int i=1;i<=m;i++)
        scanf("%d",&query[i]);
    while(l<=r)
    {
        mid=(l+r)>>1;
        if(check(mid))
        {
            r=mid-1;
            ans=mid;
            ok=1;
        }
        else
            l=mid+1;//1、输入
    }//3、二分答案
    if(!ok)
        cout<<-1;
    else
        cout<<ans;//9、特判并输出
    return 0;
}

该解释的都解释得很清楚了,完整代码里就不再具体解释了。


希望能对大家有所帮助,有不足之处请指出