题解 P6190 【[NOI Online 入门组]魔法(民间数据)】

· · 题解

题解可能有一点长,请耐心看完谢谢

2020年CCF入门组测试出两个紫题,提高组都只是3个蓝题。。。

题目大意:有一个n个点,m条边的图,你可以使用k此魔法,使通过下一条道路时,距离变为原来的相反数,问从1到n的最小距离。

前置知识:dfs,Floyd,spfa(默认为大家都会qwq

30分

显然当k=0时,就是一个最短路的板子题,因为n的范围太小了,所以用Floyd就可以了。Floyd应该都会吧。。。 30分代码:

#include<cmath>
#include<cstdio>
#include<iostream>
const short M(2501),N(101);//比规定值大1即可
const long long I(2500000000001);//无穷大为m*t(边权)+1
long long d[N][N];//d[i][j]表示从i到j的最小距离
int main()
{
    short i,j,l,m,n,u,v;
    int k,t;
    scanf("%hd%hd%d",&n,&m,&k);//输入,虽然不需要k,但是还是要输入
    for(i=1; i<=n; ++i)
        for(j=1; j<=n; ++j)
            if(i!=j)//如果i==j,d[i][j]=0
                d[i][j]=I;//否则,赋值成无穷大
    for(i=1; i<=m; ++i)
    {
        scanf("%hd%hd%d",&u,&v,&t);//输入边
        d[u][v]=t;//存边
    }
    for(l=1; l<=n; ++l)//Floyd板子(加上了一点优化)
        for(i=1; i<=n; ++i)
            if(i!=l)//如果i=l,下面的式子就变成了d[i][j]=min(d[i][j],d[i][i]+d[i][j]),而d[i][i]=0,不用做了
                for(j=1; j<=n; ++j)
                    if(i!=j&&j!=l&&d[i][j]>d[i][l]+d[l][j])//同上
                        d[i][j]=d[i][l]+d[l][j];
    printf("%lld",d[1][n]);//输出从1到n的最小值
    return 0;
}

50分Floyd

可以发现,当这一张图是一张有向无环图时,每一条边最多走一遍,所以一开始处理的时候可以全部记录成负数,就可以把这一部分AC了。

50分Floyd代码:

#include<cmath>
#include<cstdio>
#include<iostream>
const short M(2501),N(101);//比规定值大1即可
const long long I(2500000000001);//无穷大为m*t(边权)+1
long long d[N][N];//d[i][j]表示从i到j的最小距离
int main()
{
    short i,j,l,m,n,u,v;
    int k,t;
    scanf("%hd%hd%d",&n,&m,&k);//输入
    for(i=1; i<=n; ++i)
        for(j=1; j<=n; ++j)
            if(i!=j)//如果i==j,d[i][j]=0
                d[i][j]=I;//否则,赋值成无穷大
    for(i=1; i<=m; ++i)
    {
        scanf("%hd%hd%d",&u,&v,&t);//输入
        if(k)//能使用魔法
            d[u][v]=-t;//使用魔法
        else//不能使用魔法
            d[u][v]=t;//不使用魔法
    }
    for(l=1; l<=n; ++l)//Floyd板子(加上了一点优化)
        for(i=1; i<=n; ++i)
            if(i!=l)//如果i=l,下面的式子就变成了d[i][j]=min(d[i][j],d[i][i]+d[i][j]),而d[i][i]=0,不用做了
                for(j=1; j<=n; ++j)
                    if(i!=j&&j!=l&&d[i][j]>d[i][l]+d[l][j])//同上
                        d[i][j]=d[i][l]+d[l][j];
    printf("%lld",d[1][n]);//输出从1到n的最小值
    return 0;
}

50分搜索

这一道题用搜索也可以得50分。

50分dfs代码:

#include<cmath>
#include<cstdio>
#include<iostream>
const short M(2501),N(101);//比规定值大1即可
const long long I(2500000000001);//无穷大为m*t(边权)+1
short c,e[M],h[N],n,o[M];
int w[M];
long long a(I);//先赋值成无穷大
inline void add(short u,short v,int x)
{
    e[++c]=h[u];
    h[u]=c;
    o[c]=v;
    w[c]=x;
}//spfa建边
inline void dfs(short b,int d,long long f)//b表示当前点,d表示剩余魔法次数,f表示价值
{
    if(a<=f&&d==0)//如果价值大于最小值
        return;//直接返回
    if(b==n)//如果到终点
        if(a>f)//记录最小值
            a=f;//因为n号点可以经过好几次,所以不要return
    short i;
    for(i=h[b]; i; i=e[i]) //spfa遍历边
    {
        dfs(o[i],d,f+w[i]);//不使用魔法
        if(d>0)//可以使用魔法
            dfs(o[i],d-1,f-w[i]);
    }
}
int main()
{
    short i,m,u,v;
    int k,t;
    scanf("%hd%hd%d",&n,&m,&k);
    for(i=1; i<=m; ++i)
    {
        scanf("%hd%hd%d",&u,&v,&t);//输入
        add(u,v,t);//建边
    }
    dfs(1,k,0);
    printf("%lld",a);//输出结果
    return 0;
}

70分

这一道题也是一个dp题,可以先处理使用0次和1次魔法的情况,之后用动态规划求出使用2到k次魔法的情况。

首先是几个状态转移方程:

1 不使用魔法

d[i][j][0]=min(d[i][j][0],d[i][l][0]+d[l][j][0])

Floyd的状态转移方程,相信大家都会

2 使用一次魔法

d[i][j][1]=min(d[i][j][0],d[i][j][1])

使用魔法之后距离肯定不超过不使用魔法的距离

d[i][j][1]=min(d[i][j][1],d[i][u[l]][0]+d[v[k]][j][0]-t[l])

d[i][u[l]][0]表示从i到第l条边的起点的距离

d[v[k]]][j][0]表示从第l条边的终点到j的距离

d[i][u[l]][0]+d[v[k]][j][0]-t[l]表示从i到j走第l条边并在第l条边使用魔法的距离

所以原式表示不使用魔法和在第l条边使用魔法的最小距离

3 使用k次魔法

d[i][j][p]=min(d[i][j][p],d[i][l][p-1]+d[l][j][1])

从i到j使用p次魔法的最小距离是本身或从i到l使用p-1次魔法和从l到j使用1次魔法的最小值

为什么一个是p-1一个是1呢?

因为1已经提前求出来了,所以其中一个一定是1,而p-1在上一步也已经求出来了,所以一个是1一个是p-1,才能保证总数为p。把1和p-1的位置换过来也是可以的,因为经过循环两种情况都会做一遍

70分代码:

#include<cmath>
#include<cstdio>
#include<iostream>
const short K(1001),M(2501),N(101);//比规定范围大1即可
const long long I(2500000000001);//规定无穷大为n*t(边权)+1即可
long long d[N][N][K];//注意开long long,K太大会MLE
//d[i][j][l]表示从i到j最多使用l吃次魔法时的最小距离
int main()
{
    short i,j,l,m,n,p,u[M],v[M];
    int k,o,t[M];
    scanf("%hd%hd%d",&n,&m,&k);//输入
    for(o=0; o<=k; ++o)
        for(i=1; i<=n; ++i)
            for(j=1; j<=n; ++j)
                d[i][j][o]=I;//初始化成最大值
    for(i=1; i<=n; ++i)
        d[i][i][0]=0;//自己到自己为0
    for(i=1; i<=m; ++i)
    {
        scanf("%hd%hd%d",&u[i],&v[i],&t[i]);//输入
        d[u[i]][v[i]][0]=t[i];
    }
    //先处理不使用魔法的情况
    for(l=1; l<=n; ++l)//Floyd板子(加上了一些小小的优化)
        for(i=1; i<=n; ++i)
            if(i!=l)//如果i=l,下面的式子就变成了d[i][j][0]=min(d[i][j][0],d[i][i][0]+a[i][j][0]),而d[i][i][0]=0,可以不用做
                for(j=1; j<=n; ++j)
                    if(i!=j&&j!=l&&d[i][j][0]>d[i][l][0]+d[l][j][0])//道理同上
                        d[i][j][0]=d[i][l][0]+d[l][j][0];
    //再处理使用1次魔法的情况
    for(l=1; l<=m; ++l) //枚举使用魔法的边
        for(i=1; i<=n; ++i)
            for(j=1; j<=n; ++j) //枚举两个点
            {
                if(d[i][j][0]<d[i][j][1])
                    d[i][j][1]=d[i][j][0];//先记录上一次的结果
                if(d[i][j][1]>d[i][u[l]][0]+d[v[l]][j][0]-t[l])
                    d[i][j][1]=d[i][u[l]][0]+d[v[l]][j][0]-t[l];//判断经过第l条使用魔法的边后距离是否变短
            }
    for(p=2; p<=k; ++p) //枚举使用几次魔法,从2开始就可以了
        for(l=1; l<=n; ++l) //枚举转折点
            for(i=1; i<=n; ++i) //枚举起点
                for(j=1; j<=n; ++j) //枚举终点
                    if(d[i][j][p]>d[i][l][p-1]+d[l][j][1])
                        d[i][j][p]=d[i][l][p-1]+d[l][j][1];
    //从i到j使用p次魔法的最小距离是本身或从i到l使用p-1次魔法和从l到j使用1次魔法的最小值
    printf("%lld",d[1][n][k]);//输出从1到n最多使用k次魔法的最小距离
    return 0;
}

90分

分层图做法,每一次使用魔法就往下走一层。

核心建边:

第一个循环枚举每一条边,第二个循环枚举每一层,循环中第一行表示建立每一层横着的边,第二行表示从上一层到这一层的边。

之后,再跑一边spfa就可以了。

注意!!!spfa要修改!!!

90分代码:

#include<cmath>
#include<cstdio>
#include<iostream>
#include<queue>
using std::queue;
const short K(1001),M(2501),N(101);//比最大值大1即可
const long long I(2500000000001);//无穷大时m*t(边权)+1
//有n*k+n个点,2*m*k+m条边
int c,e[M+2*K*M],h[N+N*K],k,m,n,o[M+2*K*M],w[M+2*K*M];
long long d[N+N*K];
inline void add(int u,int v,int x)
{
    e[++c]=h[u];
    h[u]=c;
    o[c]=v;
    w[c]=x;
}//spfa建边
//spfa模板\注意,这一个spfa不需要bool数组,加上只能得到75分\这一个卡spfa的方式好奇怪
inline void spfa(short s)
{
    int i,u;
    queue<int>q;
    for(i=1; i<=n+n*k; ++i)
        d[i]=I;
    d[s]=0;
    q=queue<int>();
    q.push(s);
    while(!q.empty())
    {
        u=q.front();
        q.pop();
        for(i=h[u]; i; i=e[i])
            if(d[o[i]]>d[u]+w[i])
            {
                d[o[i]]=d[u]+w[i];
                q.push(o[i]);
            }
    }
}
int main()
{
    int i,j,u[M],v[M],t[M];
    long long a(I);//要求最小值,先赋值成最大值
    scanf("%d%d%d",&n,&m,&k);//输入
    for(i=1; i<=m; ++i)
    {
        scanf("%d%d%d",&u[i],&v[i],&t[i]);//输入
        add(u[i],v[i],t[i]);//spfa建边
    }
    for(i=1; i<=m; ++i)//建立分层图
        for(j=1; j<=k; ++j)
        {
            add(u[i]+n*j,v[i]+n*j,t[i]);//建立横着的边
            add(u[i]+n*(j-1),v[i]+n*j,-t[i]);//建立斜着的边
        }
    spfa(1);//spfa
    for(i=n; i<=n+n*k; i+=n)
        if(a>d[i])
            a=d[i];//寻找最小值,注意最小值不一定是n*k+n,必须遍历每一层的n
    printf("%lld",a);
    return 0;
}

100分

会发现,70分的dp做法无论是时间还是空间,都过不了这一道题的限制(1s,250MB)

所以要进行优化(100分做法)。

优化之前,先大概讲一下本题用到的知识:矩阵快速幂。

矩阵快速幂实质上就是快速幂和矩阵乘法的合体

快速幂:

众所周知,求a的b次方普通做法的时间复杂度为O(b),而快速幂的时间复杂度为O(logb)

求a的b次方普通做法可以用循环解决。考虑优化一下,如果提前算出a的平方,那么a的b次方,时间复杂度为O(b/2),如果继续算出了a的4次方、8次方、16次方等,那么就相当于对b进行了二进制拆分,时间复杂度就变成了O(logb)

P1226的题解中有详细的讲解

代码:

inline int quickpower(int x,int y)
{
    int a(1),c(x);
    while(y)//y!=0
    {
        if(y&1)//y%2==1
            a*=c;//a=a*c
        c*=c;//c=c*c
        y>>=1;//y=y/2
    }
    return a;
}

矩阵乘法:

一个nm的矩阵可以和一个mo的矩阵相乘得到一个n*o的矩阵,n,m,o可以相同,m和m必须一样,两个矩阵相乘必须第一个矩阵的列和第二个矩阵的行相同。

结果矩阵的第i行第j列结果是a矩阵的第i行的每一个数乘上b矩阵的第j列的每一个数。

代码:

struct jz
{
    int e[N][N],m,n;//n记录行,m记录列
} a,b;
jz operator*(const jz &x,const jz &y)//重载运算符
{
    short i,j,k;
    jz z;
    z.n=x.n;//记录边长
    z.m=y.m
    for(i=1; i<=z.n; ++i)
        for(j=1; j<=z.m; ++j)
            z.e[i][j]=0;//清零
    for(k=1; k<=x.m; ++k)
        for(i=1; i<=x.n; ++i)
            for(j=1; j<=y.m; ++j)
                z.e[i][j]+=x.e[i][k]*y.e[k][j];//矩阵乘法核心代码
    return z;
}

矩阵快速幂:

由矩阵乘法的定义可知,如果a*a合法,那么a的行等于a的列,所以可以快速幂的矩阵必须是方阵(行和列相等)

之后,快速幂中有一个a,初始化为1,那么矩阵快速幂中的1是什么呢? 之所以定义为1,因为任何数乘1都等于它本身,所以,一定要找到一个方阵a,使与a边长相同的矩阵乘a结果等于它本身。

这里的a是单位矩阵,只有对角线是1,其他的值都为0,这时能保证与a边长相同的矩阵乘a结果等于它本身。

之后,矩阵快速幂和快速幂基本上就差不多了。

代码:

#include<cmath>
#include<cstdio>
#include<iostream>
const short N(101);
struct jz
{
    int e[N][N],n;//n记录边长
} a,b;
jz operator*(const jz &x,const jz &y)//重载运算符
{
    short i,j,k;
    jz z;
    z.n=x.n;//记录边长
    for(i=1; i<=z.n; ++i)
        for(j=1; j<=z.n; ++j)
            z.e[i][j]=0;//清零
    for(k=1; k<=z.n; ++k)
        for(i=1; i<=z.n; ++i)
            for(j=1; j<=z.n; ++j)
                z.e[i][j]+=x.e[i][k]*y.e[k][j];//矩阵乘法核心代码
    return z;
}
inline jz qp(jz x,int y)//求x的y次方
{
    short i,j;
    jz c,d(x);
    c.n=x.n;
    for(i=1; i<=c.n; ++i)
        for(j=1; j<=c.n; ++j)
            if(i!=j)
                c.e[i][j]=0;
            else
                c.e[i][j]=1;//定义单位矩阵
    while(y)
    {
        if(y&1)
            c=c*d;//注意,不能写成c*=d,因为重载的是*,不是*=
        d=d*d;
        y>>=1;
    }//快速幂模板
    return c;
}
int main()
{
    short c,i,j,n;
    scanf("%hd%hd",&n,&c);
    a.n=n;//记录边长
    for(i=1; i<=n; ++i)
        for(j=1; j<=n; ++j)
            scanf("%d",&a.e[i][j]);//输入
    b=qp(a,c);//求a的c次方
    for(i=1; i<=n; ++i)
    {
        for(j=1; j<=n; ++j)
            printf("%d ",b.e[i][j]);
        putchar('\n');//输出
    }
    return 0;
}

好了,再次回归本题的100分做法。

前面与70分做法差不多。

代码(部分):

#include<cmath>
#include<cstdio>
#include<iostream>
const short M(2501),N(101);//比规定范围大1即可
const long long I(2500000000001);//规定无穷大为n*t(边权)+1即可
long long d[N][N];//存不使用魔法时的距离,注意开long long
int main()
{
    short i,j,l,m,n,u[M],v[M];
    int k,t[M];
    scanf("%hd%hd%d",&n,&m,&k);
    for(i=1; i<=n; ++i)
        for(j=1; j<=n; ++j)
            if(i!=j)
                d[i][j]=I;//初始化成最大值
    for(i=1; i<=m; ++i)
    {
        scanf("%hd%hd%d",&u[i],&v[i],&t[i]);//输入
        d[u[i]][v[i]]=t[i];
    }
    //先处理不使用魔法的情况
    for(l=1; l<=n; ++l)//Floyd板子(加上了一些小小的优化)
        for(i=1; i<=n; ++i)
            if(i!=l)//如果i=l,下面的式子就变成了d[i][j]=min(d[i][j],d[i][i]+a[i][j]),而d[i][i]=0,不用做
                for(j=1; j<=n; ++j)
                    if(i!=j&&j!=l&&d[i][j]>d[i][l]+d[l][j])//道理同上
                        d[i][j]=d[i][l]+d[l][j];
    if(k)//可以使用魔法
    {
        //再处理使用1次魔法的情况
        a.n=n;
        for(i=1; i<=n; ++i)
            for(j=1; j<=n; ++j)
                a.e[i][j]=d[i][j];//先存储不使用魔法的情况
        for(l=1; l<=m; ++l) //枚举使用魔法的边
            for(i=1; i<=n; ++i)
                for(j=1; j<=n; ++j) //枚举两个点
                    if(a.e[i][j]>d[i][u[l]]+d[v[l]][j]-t[l])
                        a.e[i][j]=d[i][u[l]]+d[v[l]][j]-t[l];//判断经过第l条使用魔法的边后距离是否变短
        //输出结果
        return 0;//结束
    }
    printf("%lld",d[1][n]);//不能使用魔法
    return 0;
}

通过70分做法中的状态转移方程:

d[i][j][q]=min(d[i][j][q],d[i][k][q-1]+d[k][j][1])

其实还可以写成:

d[i][j][x+y]=min(d[i][j][x+y],d[i][k][x]+d[k][j][y])

会发现,如果把+换成*,把min换成“求和”,那么就和矩阵乘法长得差不多了,所以可以把矩阵乘法的代码稍微改一下,求最小值:

之后,就只剩下最后一个求值部分了。

注意,接下来的矩阵乘法是修改过的矩阵乘法,不是原本的矩阵乘法

用70分做法,把样例1的dp数组打表后结果如下(I表示无穷大):

不使用魔法:

0 5 9 10

I 0 4 5

I I 0 1

I I I 0

使用1次魔法:

I -5 -1 0

I 0 -4 -3

I I 0 -1

I I I 0

使用两次魔法:

0 -5 -9 -8

I 0 -4 -5

I I 0 -1

I I I 0

会发现,用修改过的矩阵乘法,第一个矩阵乘上第二个矩阵就是第二个矩阵,第一个矩阵乘上第二个矩阵的平方就是第三个矩阵,如果继续打表出使用三次魔法的矩阵,会发现,第一个矩阵乘上第二个矩阵的三次方就是第四个矩阵。

总结:用第一个矩阵(不使用魔法的矩阵)乘上第二个矩阵(使用1次魔法的矩阵)的k次方相当于使用k次魔法。

所以结果就是不使用魔法的矩阵乘上使用一次魔法的矩阵的k次方。

又可以发现,原来的快速幂是单位矩阵乘上矩阵的k次方,所以可以吧单位矩阵换成不使用魔法的矩阵即可

“矩阵快速幂”代码:

如果不使用矩阵快速幂,时间复杂度和70分做法相同(又一次证明了算法的正确性),但是使用矩阵快速幂,时间复杂度就可以降到O(n2m+n3logk),而logk最大为20,所以可以忽略,时间复杂度为O(n2m),空间复杂度为O(n2)。

完整满分代码:

#include<cmath>
#include<cstdio>
#include<iostream>
const short M(2501),N(101);//比规定范围大1即可
const long long I(2500000000001);//规定无穷大为n*t(边权)+1即可
long long d[N][N];//存不使用魔法时的距离,注意开long long
struct jz
{
    short n;
    long long e[N][N];
} a;
jz operator*(const jz &x,const jz &y)//重载运算符
{
    short i,j,k;
    jz z;
    z.n=x.n;//记录结果矩阵的边长
    for(i=1; i<=z.n; ++i)
        for(j=1; j<=z.n; ++j)
            z.e[i][j]=I;//因为要求最小值,先赋值成最大值
    for(k=1; k<=z.n; ++k)
        for(i=1; i<=z.n; ++i)
            for(j=1; j<=z.n; ++j)
                if(x.e[i][k]+y.e[k][j]<z.e[i][j])
                    z.e[i][j]=x.e[i][k]+y.e[k][j];//改成min
    return z;//返回
}
inline jz qp(jz x,int y)//求x的y次方
{
    short i,j;
    jz e,f(x);//f=x
    e.n=x.n;//记录边长
    for(i=1; i<=e.n; ++i)
        for(j=1; j<=e.n; ++j)
            e.e[i][j]=d[i][j];//先赋值成d矩阵
    while(y)
    {
        if(y&1)
            e=e*f;//注意,不可以写成d*=e,因为重载的运算符是*,不是*=
        f=f*f;//同上
        y>>=1;
    }//快速幂模板
    return e;
}
int main()
{
    short i,j,l,m,n,u[M],v[M];
    int k,t[M];
    scanf("%hd%hd%d",&n,&m,&k);
    for(i=1; i<=n; ++i)
        for(j=1; j<=n; ++j)
            if(i!=j)
                d[i][j]=I;//初始化成最大值
    for(i=1; i<=m; ++i)
    {
        scanf("%hd%hd%d",&u[i],&v[i],&t[i]);//输入
        d[u[i]][v[i]]=t[i];
    }
    //先处理不使用魔法的情况
    for(l=1; l<=n; ++l)//Floyd板子(加上了一些小小的优化)
        for(i=1; i<=n; ++i)
            if(i!=l)//如果i=l,下面的式子就变成了d[i][j]=min(d[i][j],d[i][i]+a[i][j]),而d[i][i]=0,不用做
                for(j=1; j<=n; ++j)
                    if(i!=j&&j!=l&&d[i][j]>d[i][l]+d[l][j])//道理同上
                        d[i][j]=d[i][l]+d[l][j];
    if(k)//可以使用魔法
    {
        //再处理使用1次魔法的情况
        a.n=n;
        for(i=1; i<=n; ++i)
            for(j=1; j<=n; ++j)
                a.e[i][j]=d[i][j];//先存储不使用魔法的情况
        for(l=1; l<=m; ++l) //枚举使用魔法的边
            for(i=1; i<=n; ++i)
                for(j=1; j<=n; ++j) //枚举两个点
                    if(a.e[i][j]>d[i][u[l]]+d[v[l]][j]-t[l])
                        a.e[i][j]=d[i][u[l]]+d[v[l]][j]-t[l];//判断经过第l条使用魔法的边后距离是否变短
        printf("%lld",qp(a,k).e[1][n]);//输出结果
        return 0;//结束
    }
    printf("%lld",d[1][n]);//不能使用魔法
    return 0;
}

orz