动态规划之习题分析

本文介绍了动态规划算法,其通过拆分问题、定义状态及关系,以递推方式解决问题。还结合爬楼梯、机器人路径、最长公共子序列等多个实例,详细阐述了动态规划的解法和代码分析,帮助理解该算法在实际问题中的应用。

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

一、动态规划的概念

​ 动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。
​ 动态规划算法的基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
基本思想与策略编辑:
​ 由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。

​ 说实话,没有动态规划的基础很难看懂,但是也能从中看出一些信息,下面我翻译成人话:
首先是拆分问题,我的理解就是根据问题的可能性把问题划分成一步一步这样就可以通过递推或者递归来实现.
关键就是这个步骤,动态规划有一类问题就是从后往前推到,有时候我们很容易知道:如果只有一种情况时,最佳的选择应该怎么做.然后根据这个最佳选择往前一步推导,得到前一步的最佳选择
然后就是定义问题状态和状态之间的关系,我的理解是前面拆分的步骤之间的关系,用一种量化的形式表现出来,类似于高中学的推导公式,因为这种式子很容易用程序写出来,也可以说对程序比较亲和(也就是最后所说的状态转移方程式)
我们再来看定义的下面的两段,我的理解是比如我们找到最优解,我们应该讲最优解保存下来,为了往前推导时能够使用前一步的最优解,在这个过程中难免有一些相比于最优解差的解,此时我们应该放弃,只保存最优解,这样我们每一次都把最优解保存了下来,大大降低了时间复杂度

​ 说很难理解清楚,容易懵懵懂懂的,所以下面结合实例看一下(建议结合实例,纸上谈兵不太好):

以上内容参考《经典中的经典算法:动态规划(详细解释,从入门到实践,逐步讲解)》(https://blog.youkuaiyun.com/ailaojie/article/details/83014821)

二、爬楼梯

(一)、题目需求

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。

1.  1 阶 + 1 阶
2.  2 阶

示例 2:

输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。

1.  1 阶 + 1 阶 + 1 阶
2.  1 阶 + 2 阶
3.  2 阶 + 1 阶

(二)、解法

public int climbStairs(int n) {
    if (n < 3) {
        return n;
    }
    int[] steps = new int[3];
    steps[0] = 1;
    steps[1] = 2;
    for (int i = 2; i < n; i++) {
        steps[i % 3] = steps[(i - 1) % 3] + steps[(i - 2) % 3];
    }
    return steps[(n - 1) % 3];
}

(三)、代码分析

​ 由于从第三步开始,每一步的走法种类皆等于前两步的和。所以可以通过定义一个大小为3的数组。并且遍历得到每一步的走法种类数量。最后返回最后一步的种类数量

三、独一的道路一

(一)、题目需求

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

问总共有多少条不同的路径?

img

例如,上图是一个7 x 3 的网格。有多少可能的路径?

示例 1:

输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。

1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右

示例 2:

输入: m = 7, n = 3
输出: 28

(二)、解法

public int uniquePaths(int m, int n) {
    int[][] arr = new int[m][n];
    for (int i = 0; i < m; i++) {
        arr[i][0] = 1;
    }
    for (int i = 0; i < n; i++) {
        arr[0][i] = 1;
    }
    for (int i = 1; i < m; i++) {
        for (int j = 1; j < n; j++) {
            arr[i][j] = arr[i - 1][j] + arr[i][j - 1];
        }
    }
    return arr[m - 1][n - 1];
}

(三)、代码分析

1、首先可以得出状态方程:arr[i][j] = arr[i-1][j] + arr[i][j-1]。除了第一行和第一列属于特殊情况外,其他位置的到达走法皆由其上方位置以及左方位置决定。

2、首先定义并初始化特殊情况——第一行与第一列

int[][] arr = new int[m][n];
for (int i = 0; i < m; i++) {
    arr[i][0] = 1;
}
for (int i = 0; i < n; i++) {
    arr[0][i] = 1;
}

3、逐个位置的进行遍历,并得出该位置的到达走法数量

for (int i = 1; i < m; i++) {
    for (int j = 1; j < n; j++) {
        arr[i][j] = arr[i - 1][j] + arr[i][j - 1];
    }
}

四、独一的道路二

(一)、题目需求

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

img

网格中的障碍物和空位置分别用 10 来表示。

示例 1:

img

输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:

1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右

示例 2:

img

输入:obstacleGrid = [[0,1],[0,0]]
输出:1

(二)、解法

public int uniquePathsWithObstacles(int[][] obstacleGrid) {
    if (obstacleGrid == null || obstacleGrid.length == 0) {
        return 1;
    }
    if (obstacleGrid[0] == null || obstacleGrid[0].length == 0) {
        return 1;
    }
    int m = obstacleGrid.length;
    int n = obstacleGrid[0].length;
    int[][] result = new int[m][n];
    for (int i = 0; i < m; i++) {
        if (obstacleGrid[i][0] != 1) {
            result[i][0] = 1;
        } else {
            break;
        }
    }
    for (int i = 0; i < n; i++) {
        if (obstacleGrid[0][i] != 1) {
            result[0][i] = 1;
        } else {
            break;
        }
    }
    for (int i = 1; i < m; i++) {
        for (int j = 1; j < n; j++) {
            if (obstacleGrid[i][j] == 1) {
                result[i][j] = 0;
            } else {
                result[i][j] = result[i - 1][j] + result[i][j - 1];
            }
        }
    }
    return result[m - 1][n - 1];
}

(三)、代码分析

1、首先得到状态方程

if(obstacleGrid[i][j] != 0) {
	arr[i][j] = arr[i-1][j] + arr[i][j-1]
} else {
    arr[i][j] = 0;
}

若该位置无障碍则除了第一行和第一列属于特殊情况外,其他位置的到达走法皆由其上方位置以及左方位置决定。

若该位置存在障碍,则该位置无法到达,其值为0。

2、定义并初始化相关变量。初始化特殊情况第一行与第一列。若第一行或第一列中存在一个位置为障碍位置,则其后续所有位置皆不可到达,直接退出循环。

int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
int[][] arr = new int[m][n];
arr[0][0] = 1;
for (int i = 1; i < m; i++) {
    if (obstacleGrid[i][0] == 0 && obstacleGrid[i - 1][0] != 1 && arr[i - 1][0] != 0) {
        arr[i][0] = 1;
    } else {
        arr[i][0] = 0;
    }
}
for (int i = 1; i < n; i++) {
    if (obstacleGrid[0][i] == 0 && obstacleGrid[0][i - 1] != 1 && arr[0][i - 1] != 0) {
        arr[0][i] = 1;
    } else {
        arr[0][i] = 0;
    }
}

3、循环遍历每个位置,得出每个位置的到达走法数量。若该位置为障碍位置则无法到达,若不是障碍位置则由其左方位置以及上方位置决定其数量。

for (int i = 1; i < m; i++) {
    for (int j = 1; j < n; j++) {
        if (obstacleGrid[i][j] == 1) {
            arr[i][j] = 0;
        } else {
            arr[i][j] = arr[i - 1][j] + arr[i][j - 1];
        }
    }
}
return arr[m - 1][n - 1];

五、最长公共子序列

(一)、题目需求

给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。

若这两个字符串没有公共子序列,则返回 0。

示例 1:

输入:text1 = "abcde", text2 = "ace" 
输出:3  
解释:最长公共子序列是 "ace",它的长度为 3。

示例 2:

输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc",它的长度为 3。

示例 3:

输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0。

(二)、解法

public int longestCommonSubsequence(String text1, String text2) {
    if (text1 == null || text2 == null || text1.length() == 0 || text2.length() == 0) {
        return 0;
    }
    int aLength = text1.length();
    int bLength = text2.length();
    int[][] result = new int[aLength + 1][bLength + 1];
    for (int i = 1; i <= aLength; i++) {
        for (int j = 1; j <= bLength; j++) {
            if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
                result[i][j] = result[i - 1][j - 1] + 1;
            } else {
                result[i][j] = Math.max(result[i - 1][j], result[i][j - 1]);
            }
        }
    }
    return result[aLength][bLength];
}

(三)、代码分析

1、首先得出状态方程

// 字符相等时:
arr[i][j] = arr[i-1][j-1] + 1;
// 字符不相等时:
arr[i][j] = Math.max(arr[i-1][j], arr[i][j - 1]);

2、定义并初始化变量,其中result数组之所以需要取text1,text2的长度+1,是为了判断空的情况。

result数组中result[i][j]表示text1的i位置的字符与text2的j位置的字符的相同字符的数量。

举例:text1 = “abcde”;text2 = “ace”。则:

result数组中第一行的0号位置表示null,1号位置表示a,2号位置表示b,3号位置表示c,4号位置表示d,5号位置表示e。

result数组中第一列的0号位置表示null,1号位置表示a,2号位置表示c,3号位置表示e。

则第一行与第一列皆为0,因为null与任何字符皆不相同,所以相同的字符数量为0。而数组初始化默认数值则为0,所以无需重复赋值。

int aLength = text1.length();
int bLength = text2.length();
int[][] result = new int[aLength + 1][bLength + 1];

3、循环遍历判断每个位置的相同字符数量。若text1的i位置与text2的j位置字符相同,则其相同字符数量由text1与text2的两者前一字符共同决定。即text1的i-1,以及text2的j-1共同决定。若text1的i位置与text2的j位置字符不相同,则其相同字符数量由text1与text2的两者前一字符数值较大的决定。此处运用了动态规划的思想,只保存最优解。最终返回最后一个字符的判断数量即可。

for (int i = 1; i <= aLength; i++) {
    for (int j = 1; j <= bLength; j++) {
        if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
            result[i][j] = result[i - 1][j - 1] + 1;
        } else {
            result[i][j] = Math.max(result[i - 1][j], result[i][j - 1]);
        }
    }
}
return result[aLength][bLength];

六、单词拆分

(一)、题目需求

给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。

说明:

拆分时可以重复使用字典中的单词。
你可以假设字典中没有重复的单词。
示例 1:

输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以被拆分成 "leet code"。

示例 2:

输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以被拆分成 "apple pen apple"。
 注意你可以重复使用字典中的单词。

示例 3:

输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

(二)、解法

public boolean wordBreak(String s, List<String> wordDict) {
    Set<String> dict = new LinkedHashSet<>();
    for (String word : wordDict) {
        dict.add(word);
    }
    boolean[] canSegment = new boolean[s.length() + 1];
    canSegment[0] = true;
    int maxWordLength = getMaxWordLength(dict);
    for (int i = 1; i <= s.length(); i++) {
        for (int j = 0; j <= maxWordLength && j <= i; j++) {
            if (!canSegment[i - j]) {
                continue;
            }
            if (dict.contains(s.substring(i - j, i))) {
                canSegment[i] = true;
            }
        }
    }
    return canSegment[s.length()];
}

private int getMaxWordLength(Set<String> dict) {
    int max = 0;
    for (String word : dict) {
        max = Math.max(max, word.length());
    }
    return max;
}

(三)、代码分析

1、得出状态方程:dp[i] = dp[j] && check(s(j,i-1))。字符串s的前面i个组成组成的字符串s(0,i-1)是否能够被拆分成若干个字典中出现的单词。check(s(j,i-1))表示检查字符串s(j,i-1)是否为字典中出现过的单词。

2、定义并初始化相关变量。

  • maxWordLength:字典中最长的单词长度。在比较中,若需检查i的前j个字符组成的字符串,当j的数量等于最长单词的长度时,则可无需继续再往下遍历,因为再继续遍历字典中已无可能有单词可与之匹配。
boolean[] canSegment = new boolean[s.length() + 1];
canSegment[0] = true;
int maxWordLength = getMaxWordLength(dict);

3、循环遍历判断i位置字符,是否存在存在单词的可能性。

  • 通过遍历字符串“s”的每个位置,然后j逐渐增大,判断i位置的前j个字符是否存在单词位于字典中,若存在则该位置为true。若不存在则为false。
for (int i = 1; i <= s.length(); i++) {
    for (int j = 0; j <= maxWordLength && j <= i; j++) {
        if (!canSegment[i - j]) {
            continue;
        }
        if (dict.contains(s.substring(i - j, i))) {
            canSegment[i] = true;
        }
    }
}
return canSegment[s.length()];

七、回文分割

(一)、题目需求

给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。

返回符合要求的最少分割次数。

示例:

输入: "aab"
输出: 1
解释: 进行一次分割就可将 s 分割成 ["aa","b"] 这样两个回文子串。

(二)、解法

public int minCut(String s) {
    if (s == null || s.length() == 0) {
        return 0;
    }
    int[] minCut = new int[s.length() + 1];
    for (int i = 0; i <= s.length(); i++) {
        minCut[i] = i - 1;
    }
    boolean[][] pali = new boolean[s.length()][s.length()];
    assignPali(pali, s);
    for (int i = 1; i <= s.length(); i++) {
        for (int j = 0; j < i; j++) {
            if (pali[j][i - 1]) {
                minCut[i] = Math.min(minCut[j] + 1, minCut[i]);
            }
        }
    }
    return minCut[s.length()];
}

private void assignPali(boolean[][] pali, String s) {
    int length = s.length();
    for (int i = 0; i < length; i++) {
        pali[i][i] = true;
    }
    for (int i = 0; i < length - 1; i++) {
        pali[i][i + 1] = (s.charAt(i) == s.charAt(i + 1));
    }
    for (int i = 2; i < length; i++) {
        for (int j = 0; j + i < length; j++) {
            pali[j][i + j] = (pali[j + 1][i + j - 1]) && (s.charAt(j) == s.charAt(i + j));
        }
    }
}

(三)、代码分析

1、定义以及初始化相关变量。minCut表示每个位置最少需要切几下才可以形成回文。

由于每个字符单独来看都是回文。因此在最大的情况下,每个位置的切数为前一个+1。

int[] minCut = new int[s.length() + 1];
for (int i = 0; i <= s.length(); i++) {
    minCut[i] = i - 1;
}

2、pali数组,其中pali[0][1]表示第一个字符到第二个字符是否是回文。pali[1][7]表示第二个字符到第八个字符是否是回文

boolean[][] pali = new boolean[s.length()][s.length()];
assignPali(pali, s);

3、判断不同字符串的回文情况

​ (1)、第一个for循环,每个字符单独来看都是回文字符,所以对角线上的元素皆为true。

​ (2)、第二个for循环,由于字符串中存在相同两个字符连着形成回文字符的情况,所以需要进行判断。

​ (3)、由于前面两个for循环已经确定了字符串的前面两个字符的回文情况,所以循环从第三个字符开始。逐个判断s(j,i)是否为回文字符,而s(j,i)是否是回文字符则通过s(j+1,i-1)是否是回文字符以及s的第j位和第i位是否相等来判断。若相等则说明s(j,i)为回文字符。

private void assignPali(boolean[][] pali, String s) {
    int length = s.length();
    for (int i = 0; i < length; i++) {
        pali[i][i] = true;
    }
    for (int i = 0; i < length - 1; i++) {
        pali[i][i + 1] = (s.charAt(i) == s.charAt(i + 1));
    }
    for (int i = 2; i < length; i++) {
        for (int j = 0; j + i < length; j++) {
            pali[j][i + j] = (pali[j + 1][i + j - 1]) && (s.charAt(j) == s.charAt(i + j));
        }
    }
}

4、状态方程为:minCut[i] = Math.min(minCut[j] + 1, minCut[i])。当s(j,i)是回文字符时,则i位置的最小切数则等于原先的切数和j位置的切数+1中的较小值。

for (int i = 1; i <= s.length(); i++) {
    for (int j = 0; j < i; j++) {
        if (pali[j][i - 1]) {
            minCut[i] = Math.min(minCut[j] + 1, minCut[i]);
        }
    }
}
return minCut[s.length()];
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值