从dfs到记忆化搜索再到动态规划

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站上看到一位大神总结得非常到位,在这里搬运一下。这是解决动态规划题目的通法:

第一部:分析问题

第二部:分解子问题

第三部:求解最小子问题

第四步:状态转移方程

参考: 

. - 力扣(LeetCode)

动态规划(dp)入门 | 这tm才是入门动态规划的正确方式! | dfs记忆化搜索 | 全体起立!!_哔哩哔哩_bilibili

ACM 金牌选手教你动态规划的本质。力扣 No.72 编辑距离,真·动画教编程,适合语言初学者或编程新人。_哔哩哔哩_bilibili

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值