dfs是暴力解算,时间复杂度和空间复杂度都很废,我们可以从dfs到记忆化搜索再到动态规划优化时间复杂度,从动态规划到动态数组优化空间复杂度。
一、dp[n]
我们从一道经典的动态规划题--打家劫舍说起。
1.画出所有情况
假设这几家的现金是【50, 30, 12, 5】我们从第一家开始,可以把所有的情况画出来(打勾的表示抢劫,否则不抢)
每一家都可以选择抢劫或不抢劫。如果选了第一家,那就只能从第三家开始选;如果没有选,则可以从第二家开始选。这是每一轮递归要干的事情。
2.搞出递归关系
所以就有了这个由人话写成的函数:
//n 表示当前是第几家
//total 表示一共有几家人
int 从第n家开始拿到的钱的最大值(int n, int total)
{
//结束条件
如果n >= total,抢劫完毕,没法再抢劫了,返回0
//关系式
从第n家开始拿到的钱的最大值 = max(
抢这家的钱!+ 从第n+2家开始拿到的钱的最大值 ,
不抢这家的钱!+ 从第n+1家开始拿到的钱的最大值
)
返回 从第n家开始拿到的钱的最大值;
}
转换成gcc认识的,就是:
//每一家的现金数量
//left 表示当前是第几家
//n 表示一共有几家人
int cashes[N] = {0};
int dfs(int left, int n)
{
int temp;
int rst = 0;
if (left >= n)
{
return 0;
}
if (mono[left] != 0)
{
return mono[left];
}
rst = cashes[left] + dfs(left + 2, n);
temp = cashes[left+1] + dfs(left + 3, n);
if (temp > rst)
{
rst = temp;
}
mono[left] = rst;
return rst;
}
3.加上记忆化搜索
在树状图中,我们很容易发现,节点12被计算了两次,如果我们能够把计算结果存其来就能极大的减少计算量。注意到函数的返回值只与参数left有关,我们就可以根据参数left来存储返回值。由于left是连续的,我们直接用数组存就好了。函数的逻辑就成了这样:
//n 表示当前是第几家
//total 表示一共有几家人
int 缓存[total] = {0};
int 从第n家开始拿到的钱的最大值(int n, int total)
{
//结束条件
如果缓存[n] != 0,说明已经计算过,返回缓存[n]
如果n >= total,抢劫完毕,没法再抢劫了,返回0
//关系式
从第n家开始拿到的钱的最大值 = max(
抢这家的钱!+ 从第n+2家开始拿到的钱的最大值 ,
不抢这家的钱!+ 从第n+1家开始拿到的钱的最大值
)
缓存[n] = 从第n家开始拿到的钱的最大值
返回 从第n家开始拿到的钱的最大值;
}
int cashes[N] = {0};
int mono[N] = {0};
int dfs(int left, int n)
{
int temp;
int rst = 0;
if (left >= n)
{
return 0;
}
if (mono[left] != 0)
{
return mono[left];
}
rst = cashes[left] + dfs(left + 2, n);
temp = cashes[left+1] + dfs(left + 3, n);
if (temp > rst)
{
rst = temp;
}
mono[left] = rst;
return rst;
}
4.自顶而下变自下而上(状态转移方程)
仔细观察这个关系式
从第n家开始拿到的钱的最大值 = max(
抢这家的钱!+ 从第n+2家开始拿到的钱的最大值 ,
不抢这家的钱!+ 从第n+1家开始拿到的钱的最大值
)
我们发现“ 从第n家开始拿到的钱的最大值 ” 只和 “ 抢这家的钱!+ 从第n+2家开始拿到的钱的最大值 ” 和“ 不抢这家的钱!+ 从第n+1家开始拿到的钱的最大值 ” 有关。这好像我高一刚学过的数列的递推公式。我们递归的“递”的过程是把问题分解成子问题的过程,“归”才是求解的过程。那我们为什么不直接把最小的子问题直接解决,从下往上解决问题呢?这不就省略了“递”,而直接“归”了吗?
这次,我们倒着“抢劫”,对于【50, 30, 12, 5】:
从第5家开始拿到的钱的最大值 = 5
从第4家开始拿到的钱的最大值 = max(
抢这家的钱!(12)+ 从第4+2家开始拿到的钱的最大值 (超出范围,0),
不抢这家的钱!(0) + 从第4+1=5家开始拿到的钱的最大值(5)
) = 12
从第3家开始拿到的钱的最大值 = max(
抢这家的钱!(30)+ 从第3+2=5家开始拿到的钱的最大值 (5),
不抢这家的钱!(0) + 从第3+1=4家开始拿到的钱的最大值(12)
) = 35
...以此类推,我们可以得出答案。这就是得到局部的最优解后一步一步地得到了全局的最优解。
我们可以写得装一些,这就是状态转移方程:
dp[i] = max(cashes[i] + dp[i+2], dp[i+1])
dp里面的东西其实就是我们之前写的记忆化搜索的“缓存”里面的东西。只不过这次我们不用递归得到,而是用递推。
int dp[N] = {0};
int max(int a, int b)
{
if (a < b)
{
return b;
}
return a;
}
int s2_dp(int left, int n)
{
dp[n] = 0;
dp[n + 1] = 0;
for (int i=n-1; i>=0; i--)
{
dp[i] = max(dp[i + 1] , dp[i + 2] + cashes[i]);
}
}
5.动态数组
int s3_dp(int left, int n)
{
int i = 0, i1 = 0, i2 = 0;
for (int j=n-1; j>=0; j--)
{
i = max(i1, i2 + cashes[j]);
i2 = i1;
i1 = i;
}
}
6.再来一题
int min(int a, int b)
{
if (a > b)
{
return b;
}
return a;
}
int minCostClimbingStairs(int* cost, int costSize) {
int x = 0, x1 = 0, x2 = 0;
for (int i=costSize-1; i>0; i--)
{
x = min(cost[i] + x1, cost[i] + x2);
x2 = x1;
x1 = x;
}
return min(x, x1);
}
方法都对,可是为什么报错呢?
原来迭代到i = 0的时候,x1被赋值为x,min(x, x1)就不起作用了。我们可以把i=0的特殊情况单拎出来:
int minCostClimbingStairs(int* cost, int costSize) {
int x = 0, x1 = 0, x2 = 0;
for (int i=costSize-1; i>0; i--)
{
x = min(cost[i] + x1, cost[i] + x2);
x2 = x1;
x1 = x;
}
x = min(cost[0] + x1, cost[0] + x2);
x2 = x1;
return min(x, x1);
}
7.再来一题
【2, 2, 3, 3, 3, 4】
处理一下:
数字0的数量 数字1的数量 数字2的数量 数字3的数量 数字4的数量
数据 = 【0, 0, 2, 3, 1】
int dsize = 0;
for (int i=0; i<numsSize; i++)
{
data[nums[i]]++;
dsize = fmax(dsize, nums[i]);
}
现在,每一个点都可以删除或不删除
从第n个数据开始删能得到的最大点数 = max{
删除这个数据!(得到 “数据【n】*(n)“的点数)+ 从第n+2个数据开始删能得到的最大点数,
不删除这个数据!(得到 0的点数)+ 从第n+1个数据开始删能得到的最大点数,
}
状态转移方程这不就来了吗?
直接无脑写上去
int deleteAndEarn(int* nums, int numsSize) {
int dsize = 0;
int data[10003] = {0};
int dp[10003] = {0};
for (int i=0; i<numsSize; i++)
{
data[nums[i]]++;
dsize = fmax(dsize, nums[i]);
}
for (int i=dsize; i>=0; i--)
{
dp[i] = fmax(data[i]*i + dp[i+2], dp[i+1]);
}
return dp[0];
}
注意:我一开始把这数组的初始化放到了全局区,导致没初始化为0出错。当时非常疑惑,于是把代码拷到了vscode本地运行了一下,发现是对的。所以在online judge中搞全局数组,一定要记得memset初始化一下!
(
memset 在c语言的<string.h>当中,
memset(数组, 初始化的值, 长度【以byte为单位,如果是int,就要sizeof(int)*数字个数】)
)
二、dp[i][j]
刚才函数的返回值只由一个参数决定,如果由两个参数决定呢?
那不也好办,把这两个参数都记录下来不就行了?
尝试一下
对于一个最简单的输入,我们来分析一下状态转移方程。我们给每一个点都表上坐标值(x,y),如图:
————————————
|(0,0) |(1, 0) |
————————————
|(0,1) | (1,1) |
————————————
如果我们站在(1, 0)这个点,只有一种走法,向下走。站在(0,1)这个点,也只有一种走法,向右走。如果我们站在(0, 0)这个点,如果向右走,总共有1种走法;如果向下走,总共有1种走法。加起来,(0, 0)这个点就有1+1=2种走法。
我们再分析一个稍微复杂一些的例子,结合上面的两张图,我们应该很容易得出一些规律:
设m-1 , n-1是目标点的坐标
1.从点【m-1,y】开始走,所有的走法 = 1
2.从点【x,n-1】开始走,所有的走法 = 1
3.从点【x,y】开始走,所有的走法 = 从点【x+1,y】开始走,所有的走法 + 从点【x+1,y】开始走,所有的走法
这样,很容易就能够写出状态转移方程。
再此贴出我粗拙的题解:
int uniquePaths(int m, int n) {
long long dp[103][103] = {0};
if (m == 1 || n==1)
{
return 1;
}
dp[m-2][n-1] = 1;
dp[m-1][n-2] = 1;
dp[m-1][n-1] = 1;
for (int j=n-2; j>=0; j--)
{
dp[m-1][j] = dp[m-1][j+1];
}
for (int i=m-2; i>=0; i--)
{
dp[i][n-1] = dp[i+1][n-1];
for (int j=n-2; j>=0; j--)
{
dp[i][j] = dp[i+1][j] + dp[i][j+1];
}
}
return dp[0][0];
}
2.牛刀小试
在B站上看到一位大神总结得非常到位,在这里搬运一下。这是解决动态规划题目的通法:
第一部:分析问题
第二部:分解子问题
第三部:求解最小子问题
第四步:状态转移方程
参考:
动态规划(dp)入门 | 这tm才是入门动态规划的正确方式! | dfs记忆化搜索 | 全体起立!!_哔哩哔哩_bilibili
ACM 金牌选手教你动态规划的本质。力扣 No.72 编辑距离,真·动画教编程,适合语言初学者或编程新人。_哔哩哔哩_bilibili