问题简述
给定 nnn 个点 mmm 条边的有向简单图,每条边有个边权,代表经过这条边需要花费的时间,我们只能从编号小的点走到编号大的点,问从 111 号点走到 nnn 号点最少需要花费多少时间?
输入格式
第一行两个整数 nnn,mmm。
接下来 mmm 行,每行三个整数 uuu,vvv,www,表示存在一条从 uuu 到 vvv 的边权为 www 的有向边。保证存在一条 111 到 nnn 的路径。
输出格式
输出一个数,表示答案。
此时我们先看样例:
我们通过枚举所有可行的边,发现红线标出的路时本图的最短路,长度为 14。
思路
尝试
贪心
首先我们的第一个思路是贪心——从 1 出发,枚举连出来的边,每次只走能走到的边中最短的那一条,这样时间复杂度就是我们走过的那一条路径,非常快。
但这种方法是对的吗?不然。看到样例,在 4 这个点做决策的时候,按照贪心的思路我们肯定会从 4 走到 10,但接下来的最优解只能是 10 -> 11 -> 13 -> 9,显然不如从 4 号点走到 5 号点,然后再 5 -> 7 -> 8 -> 9。
搜索
首先我们第一个思路就是存下来图,枚举每一种可能的路径,最后将答案一一比较,肯定有一条是最短路。虽然复杂度高,但这样是正确的。
边上代码边讲。
#include <iostream>
#include <algorithm>
#include <vector>
#include <climits>
using namespace std;
#define N (int)1e3 + 1
struct Node{
int to,len;
Node(int x,int y) : to(x),len(y) {}//构造函数,意思为将 x 赋值给 to,y 赋值给 len
};
vector<Node> vec[N];//使用邻接表存储
int n,m;
int ans = INT_MAX;//一开始不知道答案,需要将答案设置成很大
void dfs(int num,int sum) {
if (num == n) {//到达终点
ans = min(ans, sum);//更新答案
return;
}
for (auto i : vec[num]) {//遍历这个点能达的所有点
dfs(i.to, sum + i.len);//到那个点继续做 dfs
}
}
int main(int argc, const char * argv[]) {
scanf("%d%d",&n,&m);
for (int i = 1; i<=m; ++i) {
int u,v,w;
scanf("%d%d%d",&u,&v,&w);
vec[u].push_back(Node(v, w));//v 就是结构体里的 to,w 就是结构体里的 len
}
dfs(1, 0);//从 1 号点开始 dfs,所用的时间初始为 0
printf("%d\n",ans);
return 0;
}
时间复杂度为所有可行的边上的点的数量之和。
正解
记忆化搜索
上述搜索的时间浪费在哪里:黑色笔与红色笔标出了我们在搜索时走过的两条路。我们发现有一大段重合的部分,也就是说我们虽然出发的地方不同,但如果从 4 这个点出发的最短路都是 4 -> 10 -> 11 -> 13 -> 9。不管走哪条路,只要经过了 4,那么从 4 出发的最短路的长度是不会被改变的,所以我们没有必要再访问一遍并不是最优的路径或者重复访问最优的路径。
那我们能不能在回溯到上一个点前,记录一下从这个点出发的最短路长度,下次换地方出发的时候直接使用被记录的信息呢?
当然是可以的,并且跑的飞快!由于我们会记录从每个点出发的最短路,所以每个点最多并且最少都会访问到一次,所以时间复杂度就和我们这张图的点线性。
由于我们需要记忆化,因此我们不应该使用外界变量,而是用函数返回值的形式记录从当前这个点出发的最短路。
边上代码边讲。
#include <iostream>
#include <algorithm>
#include <vector>
#include <climits>
#include <cstring>
using namespace std;
#define N (int)1e3 + 1
struct Node{
int to,len;
Node(int x,int y) : to(x),len(y) {}
};
vector<Node> vec[N];
int dp[N];//dp[i] 的意思是从 i 这个点出发的最短路
int n,m;
int dfs(int num) {
if (dp[num] != -1) {//访问过,已经有值
return dp[num];
}
if (num == n) {//从 n 号点走到 n 号点不需要花时间
return 0;
}
dp[num] = 1 << 30;//INT_MAX可能会溢出
for (auto i : vec[num]) {
dp[num] = min(dp[num], dfs(i.to) + i.len);//都尝试一下,在回溯时记忆化
}
return dp[num];//直接返回
}
int main(int argc, const char * argv[]) {
scanf("%d%d",&n,&m);
for (int i = 1; i<=m; ++i) {
int u,v,w;
scanf("%d%d%d",&u,&v,&w);
vec[u].push_back(Node(v, w));
}
memset(dp, 255, sizeof(dp));//初始化为 -1
printf("%d\n",dfs(1));
return 0;
}
动态规划
动态规划概述
我们发现,搜索要做的就是拆问题,直到问题小到能被解决为止,也就是我们的递归边界。上述搜索拆问题体现在我们要算从 1 号点走到 n 号点的最短值,就是在算 2 号点走到 n 号点的最短路和 3 号点走到 n 号点的最短路……直到我们拆到 n 号点走到 n 号点的最短路,此时自己走到自己不需要时间,直接返回 0,此时回溯解决我们拆出来的一个个问题。
那如果我们的问题已经很明显,能否跳过拆问题这个阶段,直接算问题?
当然是可以的,因为这道题具有最优子结构和无后效性两个性质。
最优子结构
我们已知 1 到 x 的最短路,又已知 x 和 y 有一条边可达,此时 1 到 y 的最短路就是 x 的最短路加上 x 到 y 所有的边的边权的最小值。因为一个局部最优解加上另一个局部最优解就肯定还是下一个局部最优解。我们把这个叫做最优子结构性质,如果一道题不满足最优子结构性质,那么就不能用动态规划解决。
无后效性
当我们解决完前面的问题,那么前面的答案不会被后面的决策影响。换句话说,前面的最短路的长度是永远不会变的,因此我们可以使用前面推出的结果推出后面的结果。如果一道题不满足无后效性性质,那么也不能用动态规划解决。
状态
状态是指动态规划中,一个局部最优解。
状态含义
一个状态需要有一个含义,比如说我们在此题中 dp[i]dp[i]dp[i] 表示1 到 i 号点的最短路。
初始状态
动态规划题目必须拥有一个初始状态。初始状态可以理解为搜索的边界条件,也可以理解为已知的条件。上述问题的初始状态就是 n 号点走到 n 号点不需要任何时间。
状态转移
我们的目标就是将状态转移,用局部最优解推出全局最优解。将局部状态转移到一个更大的局部状态的过程叫做状态的转移。
状态转移方程
状态转移方程就是描述局部状态如何转移到更大的局部状态的语句。知道了状态转移方程,那么整个问题就迎刃而解。此题的状态转移方程可以设计为 dp[y]=min(dp[y],dp[x]+z)dp[y] = min(dp[y], dp[x]+ z )dp[y]=min(dp[y],dp[x]+z) 。(假设 x 号点到 y 号点有边相连(y 号点的编号大于 x 号点),长度为 z)。因为 dp[x]dp[x]dp[x] 已经为 1 到 x 的最短路,那么 y 的最短路是 x 到 y 的最短路再加上 1 到 x 的最短路。
状态转移顺序
状态转移顺序是在循环中以哪种顺序转移状态,根据题目要求而定。比如说此题的题目要求我们从编号小的点走到编号大的点,我们知道编号小的点状态才能求出编号大的点的状态,所以我们的状态转移顺序应该是从 1 到 n 。
动态规划解决此题
边上代码边讲
#include <iostream>
#include <cstring>
#include <algorithm>
#include <vector>
using namespace std;
#define N (int)1e3 + 1
struct Node{
int to,len;
Node (int x,int y) {
to = x;
len = y;
}
};
vector<Node> vec[N];
int f[N];
int main(int argc, const char * argv[]) {
int n,m;
scanf("%d%d",&n,&m);
for (int i = 1; i<=m; ++i) {
int u,v,w;
scanf("%d%d%d",&u,&v,&w);
vec[u].push_back(Node(v, w));
}
memset(f, 127, sizeof(f));
f[1] = 0;//初始状态
for (int i = 1; i<n; ++i) {//顺序枚举
for (auto j : vec[i]) {//枚举连出去的边
f[j.to] = min(f[j.to], f[i] + j.len);//dp[y] = min(dp[y], dp[x] + z),此时的 y 为 j.to,x 为 i,z 为 j.len
}
}
printf("%d\n",f[n]);//我们要求的是 1 到 n 的最短路
return 0;
}
动态规划与记忆化搜索的异同
记忆化搜索是动态规划的一种实现方式,动态规划也是记忆化搜索的实现方式。由于动态规划不用拆问题,记忆化搜索需要拆问题,所以动态规划在常数上略优,但是时间复杂度都是相同的,只是实现方式不同。我们可以理解为动态规划在模拟搜索回溯的过程并添加记忆化。
同时,记忆化搜索能解决的动态规划也一定能解决,记忆化搜索能解决的问题动态规划也一定能解决。但他们的思维方式不太一样,在做题时我们应该灵活使用。相同的,记忆化搜索也需要遵守最优子结构与无后效性,没有前者问题就不能进行拆分,没有后者记忆将失去效果。
完结撒花!
有任何问题欢迎私信或者在评论区指出。