数据结构与算法(五) 动态规划 下

简介: 数据结构与算法(五) 动态规划

正文


4、例题


对于动态规划题目来说,使用自上而下的思想解题相对来说是比较简单的

但是为了帮助大家更好地掌握动态规划的题目,以下例题都会使用更难的自下而上的思路进行分析

(1)最长递增子序列 | leetcode300

给定一个整数数组,找出其中最长严格递增子序列的长度(子序列是不要求连续的)

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        // 特判
        int n = nums.size();
        if (n == 0) {
            return 0;
        }
        // 定义数组,dp[i] 表示以 nums[i] 结尾的子问题的结果,即最长严格递增子序列的长度,0 <= i <= n - 1
        int dp[n];
        // 填充数组
        // 边界情况,dp[0] 表示以 nums[0] 结尾的子问题的结果,为一,因为有且只有一个元素
        dp[0] = 1;
        // 填充数组
        // 其余情况,从上往下、从左往右填充
        for (int i = 1; i < n; i++) {
            dp[i] = 1;
            // 遍历选择
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i] ) {
                    dp[i] = max(dp[i] , dp[j] + 1);
                }
            }
        }
        // 返回结果
        return *std::max_element(dp, dp + n);
    }
};


(2)最长公共子序列 | leetcode1143

给定两个字符串,找出它们最长公共子序列的长度(子序列是不要求连续的)

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        // 特判
        int m = text1.size();
        int n = text2.size();
        if (m == 0) {
            return 0;
        }
        if (n == 0) {
            return 0;
        }
        // 定义数组,dp[i][j] 表示当 text1 长度为 i 且 text2 长度为 j 时子问题的结果,即最长公共子序列的长度,0 <= i <= m,0 <= j <= n
        int dp[m + 1][n + 1];
        // 填充数组
        // 边界情况,dp[i][0] 表示当 text1 长度为 i 且 text2 长度为 0 时子问题的结果,为零,因为 text2 字符串长度为零
        //          dp[0][j] 表示当 text1 长度为 0 且 text2 长度为 j 时子问题的结果,为零,因为 text1 字符串长度为零
        for (int i = 0; i <= m; i++) {
            dp[i][0] = 0;
        }
        for (int j = 0; j <= n; j++) {
            dp[0][j] = 0;
        }
        // 填充数组
        // 其余情况,从上往下、从左往右填充
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                // 分类讨论
                if (text1[i - 1] == text2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
                }
            }
        }
        // 返回结果
        return dp[m][n];
    }
};


(3)最长回文子序列 | leetcode516

给定一个字符串,找出其中最长回文子序列的长度(子序列是不要求连续的)

class Solution {
public:
    int longestPalindromeSubseq(string s) {
        // 特判
        int n = s.size();
        if (n == 0) {
            return 0;
        }
        // 定义数组,dp[i][j] 表示以 s[i] 开头且 s[j] 结尾的子问题的结果,即最长回文子序列的长度,0 <= i <= j <= n
        int dp[n][n];
        memset(dp, 0, sizeof(dp));
        // 填充数组
        // 边界情况,dp[i][i] 表示以 s[i] 开头且 s[i] 结尾的子问题的结果,为一,因为有且只有一个元素
        for (int i = 0; i < n; i++) {
            dp[i][i] = 1;
        }
        // 填充数组
        // 其余情况,从下往上、从左往右填充
        for (int i = n - 2; i >= 0; i--) {
            for (int j = i + 1; j < n; j++) {
                // 分类讨论
                if (s[i] == s[j]) {
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                } else {
                    dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
                }
            }
        }
        // 返回结果
        return dp[0][n - 1];
    }
};


(4)高楼扔鸡蛋 | leetcode887

class Solution {
public:
    int superEggDrop(int k, int n) {
        // 特判
        if (
            k <= 1 ||
            n <= 1
        ) {
            return n;
        }
        // 定义数组,dp[i][j] 表示当鸡蛋数为 i 且楼层数为 j 时子问题的结果,即确定在最坏情况下鸡蛋掉落不会碎的最高楼层的操作次数
        int dp[k + 1][n + 1];
        // 填充数组
        // 边界情况,dp[i][0] 表示当鸡蛋数为 i 且楼层数为 0 时子问题的结果,只需随便返回 0,因为此情况不合法
        //          dp[0][j] 表示当鸡蛋数为 0 且楼层数为 j 时子问题的结果,只需随便返回 0,因为此情况不合法
        //          dp[i][1] 表示当鸡蛋数为 i 且楼层数为 1 时子问题的结果,因为楼层数为 1,所以只需在第一层楼层尝试即可,因此最小操作次数为 1
        //          dp[1][j] 表示当鸡蛋数为 1 且楼层数为 j 时子问题的结果,因为鸡蛋数为 1,所以只能从低到高楼层逐层尝试,因此最小操作次数为 j
        for (int i = 0; i <= k; i++) {
            dp[i][0] = 0;
            dp[i][1] = 1;
        }
        for (int j = 0; j <= n; j++) {
            dp[0][j] = 0;
            dp[1][j] = j;
        }
        // 填充数组
        // 其余情况,从上往下、从左往右填充
        for (int i = 2; i<= k; i++) {
            for (int j = 2; j <= n; j++) {
                // 遍历选择
                // 当鸡蛋数为 i 且楼层数为 j 时,因为不知道在哪一层扔鸡蛋符合条件,所以需要逐一尝试在每一层扔鸡蛋的情况
                int result = INT_MAX;
                for (int k = 1; k <= j; k++) {
                    // 如果鸡蛋碎了,说明在 k 层及以上的楼层扔鸡蛋也会碎
                    // 此时鸡蛋数量减一,之后继续检查 k 层以下的楼层即可,即状态转移为:dp[i - 1][k - 1]
                    int case1 = dp[i - 1][k - 1] + 1;
                    // 如果鸡蛋没碎,说明在 k 层及以下的楼层扔鸡蛋也不碎
                    // 此时鸡蛋数量不变,之后继续检查 k 层以上的楼层即可,即状态转移为:dp[i][j - k]
                    int case2 = dp[i][j - k] + 1;
                    // max 对应最坏情况
                    // min 对应最少次数
                    result = min(result, max(case1, case2));
                }
                dp[i][j] = result;
            }
        }
        // 返回结果
        return dp[k][n];
    }
};


class Solution {
public:
    int superEggDrop(int k, int n) {
        // 特判
        if (
            k <= 1 ||
            n <= 1
        ) {
            return n;
        }
        // 定义数组,dp[i][j] 表示当鸡蛋数为 i 且楼层数为 j 时子问题的结果,即确定在最坏情况下鸡蛋掉落不会碎的最高楼层的操作次数
        int dp[k + 1][n + 1];
        // 填充数组
        // 边界情况,dp[i][0] 表示当鸡蛋数为 i 且楼层数为 0 时子问题的结果,只需随便返回 0,因为此情况不合法
        //          dp[0][j] 表示当鸡蛋数为 0 且楼层数为 j 时子问题的结果,只需随便返回 0,因为此情况不合法
        //          dp[i][1] 表示当鸡蛋数为 i 且楼层数为 1 时子问题的结果,因为楼层数为 1,所以只需在第一层楼层尝试即可,因此最小操作次数为 1
        //          dp[1][j] 表示当鸡蛋数为 1 且楼层数为 j 时子问题的结果,因为鸡蛋数为 1,所以只能从低到高楼层逐层尝试,因此最小操作次数为 j
        for (int i = 0; i <= k; i++) {
            dp[i][0] = 0;
            dp[i][1] = 1;
        }
        for (int j = 0; j <= n; j++) {
            dp[0][j] = 0;
            dp[1][j] = j;
        }
        // 填充数组
        // 其余情况,从上往下、从左往右填充
        for (int i = 2; i<= k; i++) {
            for (int j = 2; j <= n; j++) {
                // 修改之处
                // 当鸡蛋数为 i 且楼层数为 j 时,可以用二分搜索来查找楼层,代替线性搜索
                int result = INT_MAX;
                int lp = 1;
                int rp = j;
                while (lp <= rp) {
                    // 找出中间楼层 m
                    int m = lp + (rp - lp) / 2;
                    // 如果鸡蛋碎了,说明在 m 层及以上的楼层扔鸡蛋也会碎
                    // 此时鸡蛋数量减一,之后继续检查 m 层以下的楼层即可
                    int case1 = dp[i - 1][m - 1] + 1;
                    // 如果鸡蛋没碎,说明在 m 层及以下的楼层扔鸡蛋也不碎
                    // 此时鸡蛋数量不变,之后继续检查 m 层以上的楼层即可
                    int case2 = dp[i][j - m] + 1;
                    // 缩小搜索区间
                    if (case1 > case2) {
                        rp = m - 1;
                    } else {
                        lp = m + 1;
                    }
                    // max 对应最坏情况
                    // min 对应最少次数
                    result = min(result, max(case1, case2));
                }
                dp[i][j] = result;
            }
        }
        // 返回结果
        return dp[k][n];
    }
};


(5)正则表达式 | leetcode10

class Solution {
public:
    bool isMatch(string s, string p) {
        // 预处理
        // 字符串 s 和字符串 p 前填充一个无关字符,此时 dp[i][j] 可以直接表示 s 的前 i 个字符和 p 的前 j 个字符是否匹配
        int m = s.size();
        int n = p.size();
        s.insert(0, "_");
        p.insert(0, "_");
        // 定义数组,dp[i][j] 表示 s 的前 i 个字符和 p 的前 j 个字符是否匹配
        bool dp[m + 1][n + 1];
        // 填充数组
        // 边界情况,dp[0][0] 表示 s 的前 0 个字符和 p 的前 0 个字符是否匹配,为 true ,因为两者都是空字符串
        //          dp[i][0] 表示 s 的前 i 个字符和 p 的前 0 个字符是否匹配,此时 p 为空字符串,只有 s 是空字符串才能匹配
        //          dp[0][j] 表示 s 的前 0 个字符和 p 的前 j 个字符是否匹配,此时 s 为空字符串,只有 p 是“字符+星号”组合才能匹配
        dp[0][0] = true;
        for (int i = 1; i <= m; i++) {
            dp[i][0] = false;
        }
        for (int j = 1; j <= n; j++) {
            dp[0][j] = j % 2 == 0 && p[j] == '*' && dp[0][j - 2];
        }
        // 填充数组
        // 其余情况,从上往下、从左往右填充
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                // 分类讨论
                // 如果 p[j] 为字符且 s[i] != p[j],那么 dp[i][j] 结果就等于 false
                // 如果 p[j] 为字符且 s[i] == p[j],那么 dp[i][j] 结果取决于 dp[i - 1][j - 1]
                if (
                    p[j] != '.' &&
                    p[j] != '*'
                ) {
                    dp[i][j] = s[i] == p[j] && dp[i - 1][j - 1];
                }
                // 如果 p[j] 为点号,那么 p 中点号必然匹配 s 中一个字符,那么 dp[i][j] 结果取决于 dp[i - 1][j - 1]
                else if (p[j] == '.') {
                    dp[i][j] = dp[i - 1][j - 1];
                }
                // 如果 p[j] 为星号且 p 中“字符+星号”匹配 s 中零个字符,那么 dp[i][j] 结果取决于 dp[i][j - 2]
                // 如果 p[j] 为星号且 p 中“字符+星号”匹配 s 中一个字符,那么 dp[i][j] 结果取决于 dp[i - 1][j]
                else if (p[j] == '*') {
                    dp[i][j] = dp[i][j - 2] || ((p[j - 1] == s[i] || p[j - 1] == '.') && dp[i - 1][j]);
                }
            }
        }
        // 返回结果
        return dp[m][n];
    }
};


(6)编辑距离 | leetcode72

class Solution {
public:
    int myMin(int a, int b, int c) {
        return min(min(a, b), c);
    }
    int minDistance(string word1, string word2) {
        // 特判
        int m = word1.size();
        int n = word2.size();
        if (m == 0) {
            return n;
        }
        if (n == 0) {
            return m;
        }
        // 定义数组,dp[i][j] 表示当 A 长度为 i 且 B 长度为 j 时子问题的结果,即编辑距离
        int dp[m + 1][n + 1];
        // 填充数组
        // 边界情况,dp[i][0] 表示当 A 长度为 i 且 B 长度为 0 时子问题的结果,为 i,只需对 A 做 i 次删除或对 B 做 i 次插入即可
        //          dp[0][j] 表示当 A 长度为 0 且 B 长度为 j 时子问题的结果,为 j,只需对 A 做 j 次插入或对 B 做 j 次删除即可
        for (int i = 0; i < m + 1; i++) {
            dp[i][0] = i;
        }
        for (int j = 0; j < n + 1; j++) {
            dp[0][j] = j;
        }
        // 填充数组
        // 其余情况,从上往下、从左往右填充
        for (int i = 1; i < m + 1; i++) {
            for (int j = 1; j < n + 1; j++) {
                // 分类讨论
                if (word1[i - 1] == word2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1];
                } else {
                    dp[i][j] = myMin(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1);
                }
            }
        }
        // 返回结果
        return dp[m][n];
    }
};


5、技巧


经过上面题目的练习,相信大家对解决动态规划问题都有一定的感悟

下面是从题目中总结的一些关键技巧,有些是规律性的总结,有些是对过程的优化

建议大家看完下面的总结后重新回顾下例题


(1)怎么定义 dp 函数或 dp 数组


常用的定义方法如下:


  • 以 nums[i] 开头的子问题的结果
  • 以 nums[i] 结尾的子问题的结果
  • nums[0...i] 中的子问题的结果(从前往后数长度为 i 的数组中 子问题的结果)
  • nums[i...n] 中的子问题的结果(从后往前数长度为 i 的数组中 子问题的结果)


(2)怎么推导 dp 函数或 dp 数组


推导过程的核心是数学归纳法:在已知 dp[1...k] 的情况下,求 dp[k + 1]

常用的推导思路如下:

  • 在当前状态下,面临哪些选择,尝试所有选择并取最值
  • 在当前状态下,满足哪些条件才能进行选择,对不同的条件分类讨论


对于自下而上的思路来说,推导的过程就是填充数组的过程


而填充数组就有两个方向:从上往下、从左往右;从下往上,从右往左


我们可以借助已知信息和待求信息,分析它们的相对位置,得出填充方向,具体来说:


  • 思考:如果要求当前值,信息可以从什么方向获得
  • 思考:如果要求目标值,怎么减小问题规模,怎么利用以前信息

(3)空间优化


动态规划是对时间复杂度的一种优化,一般来说,可以将时间复杂度降到多项式时间


但在动态规划的框架下,对于某些情况,还能使用滚动数组来对空间复杂度进行优化


使用滚动数组一般可以将二维数组压缩成一维数组,或者将一维数组压缩成常数



什么情况下才能使用滚动数组,以及滚动数组究竟要怎么用呢?


如果在填充数组的过程中,要求当前值只需要用到之前的某些值,那么我们就没必要保存完整的数组


而只需要用更小的数组保存以后需要的值就好,这就是滚动数组的思想


(4)记录过程


通过动态规划,我们可以求出问题的最值,但是我们能不能知道经过怎样的过程才能得到这个最值呢


答案是肯定的,不过我们需要对求解的过程做一些修改


一种思路是我们在填充数组时,不仅仅要记录结果值,还要记录到达此结果的路径


这样当到达目标值时,就能知道怎么到达这个目标的,这种方法返回结果快,但是需要额外的空间


而另一种思路是在填充数组时,还是只会记录结果值,当需要求路径时再通过某些方法回溯


这种方法返回结果会比较慢,但无需额外的存储空间保存路径


目录
相关文章
|
2月前
|
存储 算法
深入了解动态规划算法
深入了解动态规划算法
68 1
|
2月前
|
算法 测试技术 C++
【动态规划算法】蓝桥杯填充问题(C/C++)
【动态规划算法】蓝桥杯填充问题(C/C++)
|
5月前
|
算法 开发者 Python
惊呆了!Python算法设计与分析,分治法、贪心、动态规划...这些你都会了吗?不会?那还不快来学!
【7月更文挑战第10天】探索编程巅峰,算法至关重要。Python以其易读性成为学习算法的首选。分治法,如归并排序,将大问题拆解;贪心算法,如找零问题,每步求局部最优;动态规划,如斐波那契数列,利用子问题解。通过示例代码,理解并掌握这些算法,提升编程技能,面对挑战更加从容。动手实践,体验算法的神奇力量吧!
76 8
|
5月前
|
算法 Python
算法不再难!Python分治法、贪心、动态规划实战解析,轻松应对各种算法挑战!
【7月更文挑战第8天】掌握Python算法三剑客:分治、贪心、动态规划。分治如归并排序,将大问题拆解递归解决;贪心策略在每步选最优解,如高效找零;动态规划利用子问题解,避免重复计算,解决最长公共子序列问题。实例展示,助你轻松驾驭算法!**
75 3
|
1月前
|
算法 Python
在Python编程中,分治法、贪心算法和动态规划是三种重要的算法。分治法通过将大问题分解为小问题,递归解决后合并结果
在Python编程中,分治法、贪心算法和动态规划是三种重要的算法。分治法通过将大问题分解为小问题,递归解决后合并结果;贪心算法在每一步选择局部最优解,追求全局最优;动态规划通过保存子问题的解,避免重复计算,确保全局最优。这三种算法各具特色,适用于不同类型的问题,合理选择能显著提升编程效率。
50 2
|
2月前
|
算法
动态规划算法学习三:0-1背包问题
这篇文章是关于0-1背包问题的动态规划算法详解,包括问题描述、解决步骤、最优子结构性质、状态表示和递推方程、算法设计与分析、计算最优值、算法实现以及对算法缺点的思考。
99 2
动态规划算法学习三:0-1背包问题
|
2月前
|
算法
动态规划算法学习四:最大上升子序列问题(LIS:Longest Increasing Subsequence)
这篇文章介绍了动态规划算法中解决最大上升子序列问题(LIS)的方法,包括问题的描述、动态规划的步骤、状态表示、递推方程、计算最优值以及优化方法,如非动态规划的二分法。
79 0
动态规划算法学习四:最大上升子序列问题(LIS:Longest Increasing Subsequence)
|
2月前
|
算法
动态规划算法学习二:最长公共子序列
这篇文章介绍了如何使用动态规划算法解决最长公共子序列(LCS)问题,包括问题描述、最优子结构性质、状态表示、状态递归方程、计算最优值的方法,以及具体的代码实现。
168 0
动态规划算法学习二:最长公共子序列
|
2月前
|
存储 人工智能 算法
【算法——动态规划】蓝桥ALGO-1007 印章(C/C++)
【算法——动态规划】蓝桥ALGO-1007 印章(C/C++)
【算法——动态规划】蓝桥ALGO-1007 印章(C/C++)
|
2月前
|
存储 算法
动态规划算法学习一:DP的重要知识点、矩阵连乘算法
这篇文章是关于动态规划算法中矩阵连乘问题的详解,包括问题描述、最优子结构、重叠子问题、递归方法、备忘录方法和动态规划算法设计的步骤。
157 0