动态规划解题入门

本文介绍了动态规划的解题思路,包括递归求解、记忆化搜索、去递归以及贪心优化。通过具体题目分析,阐述了如何寻找递推关系,以及如何进行空间和时间的优化,例如在题目1中实现从回溯法到记忆化搜索再到自底向上的动态规划的转变,题目2的空间优化将空间复杂度降至O(1),以及题目3的二维空间优化。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

动态规划是一种算法思想,刚入门的时候可能感觉十分难以掌握,总是会有看了题不知道怎么做,但是一看答案就恍然大悟的感觉。结合这一段时间的学习,在这里做一下总结。


解题思路

在解题的过程中,首先可以主动寻找递推关系,比如对当前数组进行逐步拉伸,看新的元素和已有结果是否存在某种关系。
对于没有思路的题目,求解可以分为暴力递归(回溯),记忆性搜索,递归优化,时间或空间最终优化四个阶段。
在碰到一道可以使用动态规划的题目的时候,如果还不知道怎么下手,那么第一步,一定要去想如何递归求解。
所谓递归求解,说的简单点,就是一种穷举,文艺一点,也可以叫回溯。是的,在学习动态规划之前,一定要对回溯法有所了解。

    backtracking(member){
        //如果已经不可能再得到结果,直接返回。也叫剪枝,分支限界。
        if(is_invalid) return;
        //如果得到最终结果,处理显示。
        if(is_solution) print_result();
        //递归即将进入下一层级,如果有数据在下一层级中需要使用,更新它们。
        move_ahead();

        //准备要进入递归的元素。
        candidate[] candidates = get_candidates;
        for(candidate in candidates){
            backtracking(candidate)
        }

        //递归回到当前层级,将数据更新回当前层级所需数据。
        move_back();

    }

上面就是回溯法的基本模板,看清来可能有点模糊,下面的第一道题目的第一个步骤,就将对此作出详细解释。

题目1

给一个非负数组,你一开始处在数组收尾(index=0),数组中元素代表你能从当前位置向后跳的**最大**步数,问能否达到数组末尾。比如:
A = [2,3,1,1,4], return true.
A = [3,2,1,0,4], return false.

递归求解

最为直观的回溯法求解如下:
思路十分直观,当我们到了每个位置,在此位置上,可以向后跳1到最大步数,在每一跳之后进行递归,依次类推穷举出所有情况,一旦有一种可以到达最终位置,那么我们就可以得到最终结果。

public class Solution {
    public boolean canJumpFromPosition(int position, int[] nums) {
        if (position == nums.length - 1) {
            return true;
        }

        int furthestJump = Math.min(position + nums[position], nums.length - 1);
        for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++) {
            if (canJumpFromPosition(nextPosition, nums)) {
                return true;
            }
        }

        return false;
    }

    public boolean canJump(int[] nums) {
        return canJumpFromPosition(0, nums);
    }
}

首先先进行一下简单的优化,在每一步判断下一跳位置的时候,为了尽快的到达最后的位置,我们很明显应该尽可能多走步数,一旦发现最后无法到达再减少步数。

// 原始代码
for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++)
// 新的代码
for (int nextPosition = furthestJump; nextPosition > position; nextPosition--)

记忆化搜索(自顶向下动态规划)

可以看到,上面的递归基本就是暴力解法,那么进一步的优化,就是在递归上面应用存储,已经计算过的分支不再继续进行计算。

public class Solution {
    Index[] memo;

    public boolean canJumpFromPosition(int position, int[] nums) {
    //存储已经计算过的分支
        if (memo[position] != Index.UNKNOWN) {
            return memo[position] == Index.GOOD ? true : false;
        }

        int furthestJump = Math.min(position + nums[position], nums.length - 1);
        for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++) {
            if (canJumpFromPosition(nextPosition, nums)) {
                memo[position] = Index.GOOD;
                return true;
            }
        }

        memo[position] = Index.BAD;
        return false;
    }

    public boolean canJump(int[] nums) {
        memo = new Index[nums.length];
        for (int i = 0; i < memo.length; i++) {
            memo[i] = Index.UNKNOWN;
        }
        memo[memo.length - 1] = Index.GOOD;
        return canJumpFromPosition(0, nums);
    }
}

去递归(自底向上动态规划)

去递归的过程,其实就是人为的分析并指定计算过程的过程。
首先分析递归过程中的可变参数,这个可变参数就是循环中遍历的变量。这里很明显是当前位置 position。
然后需要分析递归的运行顺序,这里可以人为画递归树。我们可以发现,运算实质是从右向左进行的。一个点能否达到某一个点,取决于它右边点的运算结果。

enum Index {
    GOOD, BAD, UNKNOWN
}

public class Solution {
    public boolean canJump(int[] nums) {
        Index[] memo = new Index[nums.length];
        for (int i = 0; i < memo.length; i++) {
            memo[i] = Index.UNKNOWN;
        }
        memo[memo.length - 1] = Index.GOOD;

        for (int i = nums.length - 2; i >= 0; i--) {
            int furthestJump = Math.min(i + nums[i], nums.length - 1);
            //去当前点的右边看是否有可达点。
            for (int j = i + 1; j <= furthestJump; j++) {
                if (memo[j] == Index.GOOD) {
                    memo[i] = Index.GOOD;
                    break;
                }
            }
        }

        return memo[0] == Index.GOOD;
    }
}

贪心优化(贪心策略)

上面的时间复杂度为O(mn),m是数组中最大值,n是数组个数。在分析上面循环的过程中,我们发现找到的第一个点可以到达一个可达点,那么当前位置就不需要再判断后面的步数。也就是说,一个点只要找到离他最近的可达点,那么它就变成了下一轮的可达点。下一轮一旦有一个点可以达到它,那么该点又成为下一轮新的可达点。
这也就告诉我们,对于每个点,只要找到它右边第一个可达点即可。
这也就是典型的贪心策略。
我们可以从右向左,在某个可达点左边找一个最近的点可以达到它,更新该最近点为新的可达点,以此类推,知道最后的一个可达点是起始点。

public class Solution {
    public boolean canJump(int[] nums) {
        int lastPos = nums.length - 1;
        for (int i = nums.length - 1; i >= 0; i--) {
            if (i + nums[i] >= lastPos) {
                lastPos = i;
            }
        }
        return lastPos == 0;
    }
}

题目2

给一个非负数组,从数组中选取任意个数字求和,要求所选元素均不相邻,求最大和。

直接寻找递归关系

对于比较简单的dp,也可以寻找递推关系求解:
这到题的递推关系在于,对于每一个新的元素,都可以选择取或者不取,用一个数组dp记录前面不同长度数组的最大和,那么对当前元素dp[i],如果不取则最大和为dp[i-1],如果取则最大值为dp[i-2]+num[i];可以很轻易的根据递推关系写出动态规划:

public class Solution {
    public int rob(int[] nums) {
        if(nums == null || nums.length == 0) return 0;
        int[] dp = new int[nums.length+1];
        dp[0] = 0;
        dp[1] = nums[0];
        for(int i = 2;i<nums.length;i++){
            dp[i] = Math.max(dp[i-1],nums[i]+dp[i-2]);
        }
        return dp[nums.length];
}

同经典的钢条切割背包问题一样, 对于一个新出现的元素,选与不选是构成递归的重要策略。比如leetcode两道题目 :https://leetcode.com/problems/house-robber/
https://leetcode.com/problems/house-robber-ii/
都是对于一个新出现的元素,进行选与不选两种决策去寻找递推关系,动态规划可能的O(N)解法基本也只会出现在这种决策中。

空间优化

到这里还不算完,我们看见,对于每个dp[i]的计算,仅和dp[i-1],dp[i-2]有关,这也告诉我们根本不需要一个数组,因为以前用过的值在后面不会再使用。这样,仅仅使用两个变量就可以达到效果,空间复杂度也从O(N)降到了O(1)。

public class Solution {
    public int rob(int[] nums) {
        if(nums == null || nums.length == 0) return 0;
        int a =0,b = nums[0];
        for(int i=1;i<nums.length;i++){
            int temp = b;
            b = Math.max(b,a+nums[i]);
            a = temp;
        }
        return b;
    }
}

题目3

一个二维非负数组,找出从最左上到最右下的最小距离,只可以向右或者向下移动。

直接寻找递推关系

这道题基本是二维中最简单的了,直接看到某一点(i,j)的最短距离怎么求就可以。用二维数组记录到每个点的最短距离dp[i][j],可以直接根据递推关系 dp[i][j] = min{dp[i-1][j],dp[i][j-1]}就可以求解。

二维空间优化

一维动态规划可以通过空间优化达到常数级别的空间复杂度,同样二维动态规划也可以进一步优化。
首先,根据递归关系,我们发现每个位置只和上面i-1和左边j-1的值有关,于是可以采用数组滚动的方法。
在计算第i行的时候,只存储第i-1行的最短距离,比如计算(i,j)点,数组中dp[j]到右边的元素是二维表中(i-1,j)右边的元素。而数组中 dp[j-1]以及其左边的元素,是 二维表中 (i,j-1)及其左边的元素。
其实,就是计算将第i行计算过的结果存在数组前半部分,而后半部分是之前计算上一行存储的最短距离,用于以后计算使用。相当于通过滚动,覆盖了不再被需要的值。
如下面的简图,其实就是把一个数组分成两半,左边存储dp[i][j-1]所要用的数据,右边是dp[i-1][j]使用的数据。
这里写图片描述

优化过的代码如下,空间复杂度降到了O(n).

public class Solution {
    public int minPathSum(int[][] grid) {
        //空间压缩,数组滚动方法。
        int m = grid.length,n = grid[0].length;
        int[] dp = new int[n];
        dp[0] = grid[0][0];
        for(int i=1;i<n;i++)
            dp[i] =dp[i-1] + grid[0][i];
        for(int i=1;i<m;i++)
            for(int j=0;j<n;j++)
                dp[j] = (j>0?Math.min(dp[j - 1],dp[j]):dp[j]) + grid[i][j];
        return dp[n-1];
    }
}

题目4、5:
这两道题目是一维的动态规划,对于一维的动态规划很难从基本的暴力解法逐步推导过去,更多的是寻找递推关系,类似于钢条切割问题。个人还是比较头疼的。
第一个题目:
地址:https://leetcode.com/problems/maximum-subarray/
题目是在一个数组中,寻找连续的数,获得最大和。
比如:[-2,1,-3,4,-1,2,1,-5,4]数组,最大和是子数组[4,-1,2,1]为6。

一维动态规划,寻找递推关系。为了表明是dp问题,设置一个数组,dp[i]表示包含nums[i]的子数组的最大和。从左到右遍历数组,每新添一个数时,计算dp[i],可以知道新添的数要么和前面最大和子数组累加,得到dp[i]+nums[i],要么自己作为一个新的子数组的唯一元素,和是nums[i],则有递推关系 dp[i] = max(nums[i],dp[i-1] * nums[i])。
注意,dp[i]是包含第i个元素的局部最优解,全局最优解每次获得局部最优解比较一下就行。
代码如下:

public class Solution {
    //空间可以被优化
    public int maxSubArray(int[] nums) {
        if(nums == null || nums.length == 0) return 0;
        int[] dp = new int[nums.length];
        int r = nums[0];
        dp[0] = nums[0];
        for(int i=1;i<nums.length;i++){
            int n = nums[i];
            dp[i] = Math.max(n,dp[i-1]+n);
            r = Math.max(dp[i],r);
        }
        return r;
    }
}

第二个题目类似,只不过是乘法最大值。乘法就是要跟踪一下局部的最大值和最小值即可,因为乘法最小值乘以负数也可能出现最大值。代码如下:

public class Solution {
    public int maxProduct(int[] nums) {
        if(nums == null || nums.length == 0) return 0;
        int[] max = new int[nums.length];
        int[] min = new int[nums.length];
        int r = nums[0];
        max[0] = r;
        min[0] = r;
        for (int i = 1;i<nums.length;i++) {
            int n = nums[i];
            int a = max[i - 1] * n;
            int b = min[i - 1] * n;
            max[i] = Math.max(n, Math.max(a,b));
            min[i] = Math.min(n, Math.min(a, b));
            r = Math.max(max[i], r);
        }
        return r;

    }
}

很明显,两个题目都可以优化成O(1)空间,这里为了表示明显不进行优化,读者可以自己尝试一下。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值