7.8 DAG上的多源最短(长)路径问题(动态规划)


前言

首先要知道什么是DAG,有向无环图,可以求拓扑排序,关键路径,在工程规划上有很大的用处。如果发现某个问题给的前提是DAG,那么,根据DAG的无圈性,可以证明其具有最优子结构,就可以在 O ( n + e ) O(n+e) O(n+e)的复杂度内求得DAG的多元最短(最长)路。而对于所以顶点之间计算最短路我们可以用一般图的Floyd算法,其原理是利用传递闭包的原理每次 E k = E k + 1 E^k=E^{k+1} Ek=Ek+1,相当于路径长度+1,基于动态规划: d p [ i ] [ j ] = m a x ( d p [ i ] [ k ] + d p [ k ] [ j ] , d p [ i ] [ j ] ) dp[i][j]=max(dp[i][k]+dp[k][j],dp[i][j]) dp[i][j]=max(dp[i][k]+dp[k][j],dp[i][j]),复杂度 O ( n 3 ) O(n^3) O(n3)。而利用DAG的性质我们却可以在 O ( n e ) O(ne) O(ne)完成,原理也是基于动态规划。


图模型算法

DAG最短路

描述

  • 首先我我们可以先给图做一次拓扑排序,这样做的原因是要让每次在更新一个点的最短路时可能递推到它的点全部已经计算完毕,拓扑排序就是在干这个事情,不难得出它没有后效性,因为每个点的的最优决策和之前哪边传递过来的没有关系。
  • 保证在 O ( n + e ) O(n+e) O(n+e)的复杂度内的前提是用邻接表存图。
  • 用pre表示到达当前点的最短路径的前驱节点
  • 用dist表示其他顶点到v点的最短路径长度
  • 用ts表示拓扑排序
  • 首先path初始化为0,c初始化为INF,c[源点]=0

说点题外话,这里dp状态dist有两个理解方式:

  1. 前面的点到达当前的最短路(以i点出发的最短路)(固定终点)
  2. 每个节点传递下去的最短路(以i点结束的最短路)(固定起点)

很显然,状态2那个表示法是一个递归的过程,有个优化手段就是记忆化,而对于类似于某些问题(比如邻接表),容易得到<i, j>的边权而却不容易得到<j, i>的边权(相当于反着枚举)时,我们除了改变建图的数据结构(比如邻接矩阵)、反向建边等还可以采用“刷表法”:就是每走到一个状态时,把它邻接的边(下一个状态)全部传递下去,区别去这种做法的常规做法是填表法
但是对于状态2在求某些字典序的问题,这个方法却不好求得,这时要采用状态1的设计方式

实现

void shortTest() {
	Topsort(edge, ts); //计算拓扑排序存在ts中
	for (int i = 0; i < ts.size(); i++) { //刷表法 每次遍历到一个节点把后面的节点刷新
		int cur = ts[i]; //当前节点
		for (int j = 0; j < edge[cur].size(); j++) {
			edge& v = edge[cur][j]; //当前点的邻接节点
			if (c[v.to] > c[cur] + v.w) { //更新后继节点
				c[v.to] = c[cur] + v.w; //update
				pre[v.to] = cur; //记录前驱节点
			}
		}
	}
}

DAG最长路

描述

同DAG最短路类似只需每次更新的时候变动一下条件即可

实现

void shortTest() {
	Topsort(edge, ts); //计算拓扑排序存在ts中
	for (int i = 0; i < ts.size(); i++) { //刷表法 每次遍历到一个节点把后面的节点刷新
		int cur = ts[i]; //当前节点
		for (int j = 0; j < edge[cur].size(); j++) {
			edge& v = edge[cur][j]; //当前点的邻接节点
			if (c[v.to] < c[cur] + v.w) { //更新后继节点 注:这里遇到更大的更新
				c[v.to] = c[cur] + v.w; //update
				pre[v.to] = cur; //记录前驱节点
			}
		}
	}
}

DAG所有顶点对之间的最短路

描述

同样是根据DAG的图上最优子结构来进行动态规划,核心思路是:利用 d f s dfs dfs求出每

个邻接点的最短路,把结果保存在一个矩阵 d p [ i ] [ j ] dp[i][j] dp[i][j]中,回溯的时候由于子结构已经

计算完毕并且最优,可以根据许多最短路算法一样进行松弛,松弛是利用三角不

等式也就是bellman方程进行更新到其余邻接点的最短路,当然如果不连通显然不能

够更新。
具体过程如下

  1. 预处理所有点即dp置为INF,即初始情况下所有点都不连通,当然自己到自己也置为不连通,至于这样做的作用是为了方便判定一个点是否计算过了(我们搜索是基于点的搜索)。当然也可以换成用一个vis标记每个点是否搜索过,只不过这样空间会多出来而已,但是增加了可读性,许多记忆化搜索都用到了这个技巧
  2. 每次搜索把 d p [ c u r ] [ c u r ] dp[cur][cur] dp[cur][cur]标为0,相当于自己和自己之间没有距离,作用也只是同 v i s [ c u r ] = t r u e vis[cur]=true vis[cur]=true一致:该点被搜索过了!
  3. 枚举邻接点,如果邻接点搜索过了并且能够更新当前到它的直接距离w,则更新它(这个判断的作用是防止之前有当前点的之前搜索过的邻接点把这段距离提前更新了,现在要更新回来),当然,此时也可以记录路径,即用一个矩阵来表示当前节点走最短路的后继点;如果发现没搜索过就递归去处理那个点的最优子结构。
  4. 最后利用这个邻接点进行松弛其他点,显然能松弛的前提是这个邻接点对其他点已经松弛过了。
  5. 算法结束

实现

void init() {
    memset(dist, inf, sizeof(dist)); //所有点初始时不连通
    memset(suf, -1, sizeof(suf)); //所有点初始时没有后继点
}

void dfs(int cur) {
    dist[cur][cur] = 0; //自己到自己没有cost,还有个含义表示该点被搜索过
    for (int i = 0; i < edge[cur].size(); i++) {
        edge& v = edge[cur][i];
        if (dist[cur][v] < v.w) { //更新当前点到邻接点的距离
            dist[cur][v] = v.w;
            suf[cur][v] = v.to; //记录最短路路径
        }
        if (dist[s][v] == inf) dfs(v.to); //递归搜索子节点  隐含着如果搜索过就不在搜索的记忆化的思想
        for (int j = 1; j <= n; j++) //对其他点进行更新 
            if (dist[v.to][j] < inf) //如果该邻接点对其他点已经更新成连通时就有更新当前点到其他点的距离的可能
                if (dist[cur][j] > dist[v.to][j] + v.w) {
                    dist[cur][j] > dist[v.to][j] + v.w;
                    suf[cur][j] = v.to; //走这个邻接点的路径更短
                }
    }
}

void AllshortPaths() {
    init();
    for (int i = 1; i <= n; i++)
        if (dist[i][i] == inf)
            dfs(i);
}

抽象图模型

二维矩形嵌套

有n个矩形,每个矩形可以用两个整数a、b描述,表示它的长和宽,

矩形(a,b)可以嵌套在矩形(c,d)当且仅当a<c且b<d,

要求选出尽量多的矩形排成一排,使得除了最后一个外,

每一个矩形都可以嵌套在下一个矩形内,如果有多解,矩形编号的字典序应尽量小。

分析

思路1(转为DAG上最长路)

用一个邻接矩阵存边或者邻接表存边,之后利用刷表法递推。详见刘汝佳的《算法竞赛入门经典第二部》P262~P267

本问题代码参考博客

/* DAG上的动态规划之嵌套矩形 */
#include <cstdio>
#include <cstring>

const int maxn = 1005;
int n, G[maxn][maxn];
int a[maxn], b[maxn];
int dp[maxn];

void swap(int &x, int &y){
    x ^= y;
    y ^= x;
    x ^= y;
}

//将x和y的最大值存在x中
inline void CMAX(int& x, int y){
    if (y > x){
        x = y;
    }
}

/* 采用记忆化搜索 求从s能到达的最长路径 */
int DP(int s){
    int& ans = dp[s];
    if (ans > 0)
        //记忆化搜索,避免重复计算
        return ans;
    ans = 1;
    for (int j = 1; j <= n; ++j){
        if (G[s][j]){
            //sj有边 利用子问题dp[j]+1更新最大值
            CMAX(ans, DP(j) + 1);
        }
    }
    return ans;
}

void print_ans(int i){
    printf("%d ", i);
    for (int j = 1; j <= n; ++j){
        if (G[i][j] && dp[j] + 1 == dp[i]){
            print_ans(j);
            break;
        }
    }//for(j)
}

int main()
{
    while (scanf("%d", &n) == 1){
        //n个矩形
        for (int i = 1; i <= n; ++i){
            //默认a存长,b存宽(a > b)
            scanf("%d%d", a + i, b + i);
            if (a[i] < b[i]){
                swap(a[i], b[i]);
            }
        }
        /*
            建图 G[i][j]为1表示矩形i可以嵌套在矩形j中
            那么原问题便转化为求DAG上的最长路径
            
            定义状态dp[i]表示从结点i出发可以到达的最长路径
            则 dp[i] = max(dp[j] + 1), 其中 G[i][j]=1,
        */
        memset(G, 0, sizeof G);
        for (int i = 1; i <= n; ++i){
            for (int j = 1; j <= n; ++j){
                //矩形i的长和宽都小于矩形j的长和宽
                if (a[i] < a[j] && b[i] < b[j]){
                    G[i][j] = 1; //可以嵌套,则有边
                }
            }
        }//for(i)
        memset(dp, 0, sizeof dp);
        int ans = 0;
        int best;
        for (int i = 1; i <= n; ++i){
            if (DP(i) > ans){
                ans = dp[i];
                best = i;
            }
        }//for(i)
        printf("ans = %d\n", ans);
        print_ans(best);
        printf("\n");
    }
    return 0;
}
思路2(转为LIS问题)

预处理一下结构体排序,然后求LIS.

#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

const int maxn = 1005;
int dp[maxn];

struct Node{
    int a, b;
    bool operator<(Node& other){
        if (a != other.a){
            return a < other.a;
        }
        else{
            return b < other.b;
        }
    }
}A[maxn];

void SWAP(int& x, int& y){
    x ^= y;
    y ^= x;
    x ^= y;
}

void CMAX(int& x, int y){
    if (y > x){
        x = y;
    }
}

int main()
{
    int n;
    while (scanf("%d", &n) == 1){
        for (int i = 1; i <= n; ++i){
            //a为长,b为宽
            scanf("%d%d", &A[i].a, &A[i].b);
            if (A[i].a < A[i].b){
                SWAP(A[i].a, A[i].b);
            }
            dp[i] = 1;
        }//for(i)
        sort(A + 1, A + n + 1);
        //求b的最长上升子序列
        int ans = 1;
        int best = 0;
        dp[0] = 0;
        for (int i = 1; i <= n; ++i){
            for (int j = 0; j < i; ++j){
                if (A[j].a < A[i].a && A[j].b < A[i].b){
                    CMAX(dp[i], dp[j] + 1);
                }
            }
            if (dp[i] > ans){
                ans = dp[i];
                best = i;
            }
            //CMAX(ans, dp[i]);
        }
        printf("ans = %d\n", ans);
    }
    return 0;
}

硬币问题

假设有n种硬币,分别为V1,V2,…Vn,每种无限多。现在需要凑出S元,问如何组

合才能使硬币的数量最少和最多?(以下考虑最多)

思路

完全背包比较

先说点题外话,首先这个和完全背包问题的区别在于:这个问题它要凑到恰好S元花

费的硬币,而完全背包是凑一个价值尽量大并且在某个数量限制的最大价值。也就是

这个问题dp的含义是:目前手里的容量恰好凑满最多(少)能得到多少价值(硬

币数量)。而完全背包dp的含义是:目前手里的容量尽量凑满最多(少)能得到

多少价值(硬币数量)。

记忆化搜索

这种问题的状态设计我想不到,参考了《算法竞赛入门经典第二版》。根据书本的描

述,我们可以先将每个面值看成一个点,表示“还需要凑足的面值”,这样初试状态

就是S元,目标就是还需要0元。这样子就能够得到状态转移方程:
d p [ i ] = m a x ( d p [ i ] , d p [ s − V [ j ] ] + 1 ) dp[i]=max(dp[i], dp[s-V[j]]+1) dp[i]=max(dp[i],dp[sV[j]]+1)这里1是得到的"价值"也就是数量。其实整个问题就已经转化成DAG上的问题了。你

还可以这样理解dp含义:从结点i出发到达0的最长路径长度

//注意初始化问题
dp[i] = -1   表示还没搜索过 这样初始化就要memset(dp, -1, sizeof(dp))
dp[i] = -inf 表示达不到终点0或者说凑不到i元
否则,dp[i]   表示凑足的i元最多得到的硬币数
int dp(int s) {
	if (d[s] != -1) return d[s];
	d[s] = -(1<<30);
	for (int i = 1; i <= n; i++)
		d[s] = max(d[s], dp(s-V[i]));
	return d[s];
}

关于记忆化搜索为了防止每个数字含义设置不当,也就是初试值没考虑好,有一个比较简单的做法就是再开一个vis数组表示每个状态是否搜索过

//初始化memset(vis, false, sizeof(vis))
int dp(int s) {
	if (vis[s]) return d[s];
	vis[s] = true;
	dp[s] = -(1<<30);
	for (int i = 1; i <= n; i++)
		d[s] = max(d[s], dp(s-V[i]));
	return d[s];
}

关于打印路径,有的题目会问题一些字典序的问题

void print_ans(int* d, int s) {
	for (int i = 1; i <= n; i++)
		if (s >= V[i] && d[s] == d[s-V[i]]+1) { //找到字典序最小并且恰好比当前数量多一个的状态
			printf("%d ", i);
			print_ans(d, s-V[i]);
			break; 
			//如果要打印所有情况要用一个栈存住,最后找到0状态再输出,
			//光删除break是没有用的,不够当然也要删,不然找了一个就跳出了
		}
}
递推

注意和完全背包两个循环的区别,用记忆化搜索的顺序去理解区别这两个

minv[0] = maxv=[0] = 0; //0到达自己没有硬币要凑
for (int i = 1; i <= s; i++) { //每种状态初试都没有能凑的方案
	minv[i] = inf;
	maxv[i] = -inf;
}
for (int i = 1; i <= s; i++) {
	for (int j = 1; j <= n; j++) { //找到所有能凑的面值(路径)转移
		if (i >= V[j])
		minv[i] = min(minv[i], minv[i-V[j]]+1);
		maxv[i] = max(maxv[i], maxv[i-V[j]]+1);
	}
}
printf("maxSize = %d minSize = %d\n", minv[s], maxv[s]);
打印路径补充

在上面写的那个方法也可以加上路径记录

minv[0] = maxv=[0] = 0; //0到达自己没有硬币要凑
for (int i = 1; i <= s; i++) { //每种状态初试都没有能凑的方案
	minv[i] = inf;
	maxv[i] = -inf;
}
for (int i = 1; i <= s; i++) {
	for (int j = 1; j <= n; j++) { //找到所有能凑的面值(路径)转移
		if (i >= V[j])
		if (minv[i] > minv[i-V[j]]+1) {
			minv[i] = minv[i-V[j]]+1;
			minv_nxt[i] = j; //记录住在i元下一次要选第几种才能最少
		}
		if (maxv[i] > maxv[i-V[j]]+1) {
			maxv[i] = maxv[i-V[j]]+1;
			maxv_nxt[i] = j; //记录住在i元下一次要选多少第几种才能最多
		}
	}
}
printf("maxSize = %d minSize = %d\n", minv[s], maxv[s]);

有了上面每次状态的记录就可以通过不断范围后继结点打印出路径,这是一种空间换时间的做法,使得不用每次还有去找那个才是比当前数量少1的点,需要注意的是记录路径的时候要每次大于或小于才能更新,否则不满足字典序了,除非你枚举字典序的是从大的往小的枚举。

while(s>0) {
	printf("%d ", min_nxt[s]);
	s -= V[min_nxt[s]]; //扣除该种硬币的价格
}

n维矩形嵌套问题

UVA103

  • 这题难点是建边,转化成DAG后相当于没有后效性就可以用找最长路了
  • 还有一种思路是按维排序,之后转为LIS问题,排序的过程也是为了消除后效性

有几份讲这题还不错的博客以及论文
DAG做法
DAG做法
贪心做法(论文)
特别是这份,两种方法都有实现

代码:

#include <bits/stdc++.h>
using namespace std;
int k,n,dp[35],x[35][12];bool ma[35][35],fir;
bool check(int i,int j)
{
	for(int k=0;k<n;k++)
		if(x[i][k]>=x[j][k])return false;
	return true;
}
void build()
{
	memset(ma,0,sizeof(ma));
	for(int i=1;i<=k;i++)
	{
		for(int j=i+1;j<=k;j++)
			if(check(i,j))
			ma[i][j]=1;
		    else if(check(j,i))
			ma[j][i]=1;
	}
}
int dfs(int u)
{
	int& ans=dp[u];
	if(ans>0) return ans;
	ans=1;
	for(int i=1;i<=k;i++)
		if(ma[u][i])ans=max(ans,dfs(i)+1);
	return ans;
}
void output(int x)
{
	if(fir)printf("%d",x);
	else printf(" %d",x);
	fir=0;
	for(int i=1;i<=k;i++)
		if(ma[x][i]&&dp[x]==dp[i]+1)
	{
		output(i);
		break;
	}
}
int main()
{
	while(scanf("%d%d",&k,&n)!=EOF)
	{
		for(int i=1;i<=k;i++)
		{
			for(int j=0;j<n;j++)
			scanf("%d",&x[i][j]);
			sort(x[i],x[i]+n);
		}
		build();int ans=-INF,indx;
		memset(dp,-1,sizeof(dp));
		for(int i=1;i<=k;i++)
			if(ans<dfs(i))
			{
				ans=dp[i];
				indx=i;
			}
		printf("%d\n",ans);
		fir=1;output(indx);puts("");
	}
	return 0;
}


总结

动态规划主要还是看你如何理解状态的含义。对于矩形嵌套的属于没有明确起点和终点的,此时用设计状态1会好处理点,而像硬币这种有终点的要设计状态2。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小胡同的诗

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值