算法基础练习总结入口:算法学习地图-CSDN博客

目录

一、基本概念

二、适用场景

三、典型题目


一、基本概念

动态规划:是求解决策过程最优化数学方法,利用各个阶段之间的关系,逐个求解,最终得到全局最优解。这类题往往思路重要,代码量少。重点:确定原问题dp[n]与子问题、动态规划状态、边界状态值,状态转移方程式等关键要素;

二、适用场景

当具备下面三种特点时,可以优先考虑动态规划:

1)求最优解。目标是求一个最大值、最小值、最长、最短或者计数(某种方式的数量)

2)问题具有最优子结构。DP成立的基础,它意味着整个问题的最优解可以由其子问题的最优解组合推导出来。

3)问题具有重叠子问题。这是DP优于普通递归和分治的关键。它意味着在递归分解问题的过程中,相同的子问题会被反复计算多次。

总结一下,如果遇到一个‘求极值’或‘计数’的问题,并且它可以通过其子问题的解以某种方式组合得到,同时子问题存在大量重复,我就会优先考虑动态规划。

典型动态规划模板:

以经典的『322. 零钱兑换』为例。给定不同面额的硬币和一个总金额,计算可以凑成总金额所需的最少的硬币个数。322. 零钱兑换 - 力扣(LeetCode)

  • 第一步:定义dp数组的含义

    • 我们定义dp[i]凑出总金额i所需的最少硬币个数

    • 我们的目标就是求dp[amount]

  • 第二步:写出状态转移方程

    • 对于当前金额i,我可以选择一枚硬币coin,那么剩下的金额就是i - coin

    • 凑出i - coin这个子问题所需的最少硬币数是dp[i - coin]

    • 所以,dp[i]应该是所有可能选择(for coin in coins)中,最小的那个dp[i - coin],然后再加1(因为我用掉了这枚coin)。

    • 方程dp[i] = min(dp[i], dp[i - coin] + 1) for coin in coins if i >= coin

  • 第三步:初始化dp数组

    • dp[0] = 0:凑出金额0需要0枚硬币。

    • 其他位置初始化为一个很大的数(如amount + 1INT_MAX),因为我们要找最小值,初始化为大数便于后续被更小的值替换。

  • 第四步:确定遍历顺序

    • 这是一个完全背包问题。金额i需要从1amount正序遍历。

    • 对于硬币的顺序,内外循环都可以。这里选择外层遍历金额,内层遍历硬币,逻辑更清晰。

  • 第五步:举例推导dp数组(Debug)

    • 假设coins = [1, 2, 5]amount = 5

    • 我会手动计算一下:

      • dp[0] = 0

      • dp[1] = min(dp[1-1]+1, ...) = 1

      • dp[2] = min(dp[2-1]+1=2, dp[2-2]+1=1) = 1

      • dp[3] = min(dp[3-1]+1=2, dp[3-2]+1=2) = 2

      • dp[4] = min(...) = 2

      • dp[5] = min(dp[5-1]+1=3, dp[5-2]+1=3, dp[5-5]+1=1) = 1

    • 手动验证结果正确,最后返回dp[5] = 1。”

三、典型题目

经验总结:以下面8道题为举例。

1、第一类题目,求的最终解和n相关依赖于问题的规模,dp[n]和前项如dp[n-1]有较为明显的推导关系。可以直接从0~n遍历推导。一些非线性的全局计算也一样,按规则分类 求出每个坐标位置规则下的推导公式。如

爬楼梯,爬到第n阶题目的方法数量 和 爬到n-1,n-2数量存在推导关系, dp[n] = dp[i-1] + dp[i-2];

打家劫舍,到n个房间最大财报数量 和 n-1,n-2房间财报数量存在推导关系,dp[n] = max(dp[n - 1], dp[n-2] + nums[i-1]); 

找零钱 最小数量规则,反推法,dp[i] = min(dp[i-nums[0], dp[i-nums[1]]...dp[i-nums[j]]) + 1,如果dp[i-nums[0]~dp[i-nums[j]如果均不能到达即-1,不参与后续计算,否则为其中最小

三角形求最小路径,每个点的最小距离都可求,dp[i][j] = min(dp[i+1][j], dp[i+1][j+1]) + triangle[i][j],定点的dp[i][j]即为最终解;

求 m x n 网格的最小路径,每个点的最小距离都可以求,d[i][j] = min(d[i-1][j], d[i][j-1]) + grid[i][j]

地牢游戏 求最低初始血量,设置dp[i][j]为在i,j位置至少需要多少血量,从右下角开始遍历,起始dp[m-1][n-1] = max(1, 1 - dungeon[m-1][n-1]),过程推导dp[i][j] = max(1, min(dp[i+1][j], dp[i][j+1]) - dungeon[i][j]),dp[0][0]为返回值

2、第二类题目,最终解和结束n无关,它可能出现在中间任何一个区域,可以换一个思路,找过程中每个点i是否存在和前向i-1某种可推导公式(甚至是i可以通过0~i-1一次遍历循环来求出),选择求出dp[i]的每个值,然后记录要求的dp。 

求最大(连续)字段和,每个以i做结尾的最大字段,dp[i-1] > 0 ? dp[i-1]+nums[i] : nums[i],通过遍历求出每个dp[i]的值,然后过程记录最大值dp_max;

最长上升子序列,每个以i结尾的最大上升子序列长度,为已经遍历的0~i-1的x中,只要nums[i] > nums[x], dp[i]=dp[x]+1中最大值,记录过程中的dp_max


70. 爬楼梯 - 力扣(LeetCode)

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?注意:给定 n 是一个正整数。

class Solution {
public:
    int climbStairs(int n) {
        /* 动态规划:dp[n] = dp[n-1]+dp[n-2] 
            第i阶的方法数量 = 第i-1方法数量(走一步) + 第i-2阶方法数量(走两步)
        */
        if (n <= 0) {
            return 0;
        }
        if (n <= 2) {
            return n;
        }
        int dp_n = 0;
        int dp_n_2 = 1;
        int dp_n_1 = 2;
        for (int i = 3; i <= n; i++) {
            dp_n = dp_n_1 + dp_n_2;
            dp_n_2 = dp_n_1;
            dp_n_1 = dp_n;
        }
        return dp_n;
    }
};

198. 打家劫舍 - 力扣(LeetCode)

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

class Solution {
public:
    int rob(vector<int>& nums) {
        /* 动态规划, dp[n] = max(dp[n-1], dp[n-2]+nums[n]);
           当前的点的最大累计值为 上一个点最大累计值,或者上上个点最大累计值 + 当前点的值        
        */
        if (nums.size() == 0) {
            return 0;
        }

        if (nums.size() == 1) {
            return nums[0];
        }
        if (nums.size() == 2) {
            return max(nums[0], nums[1]);
        }
        int dp_n = 0;
        int dp_n_2 = nums[0];
        int dp_n_1 = max(nums[0], nums[1]);
        for (int i = 2; i < nums.size(); i++) {
            dp_n = max(dp_n_1, dp_n_2 + nums[i]);
            dp_n_2 = dp_n_1;
            dp_n_1 = dp_n;
        }
        return dp_n;
    }
};

53. 最大子数组和 - 力扣(LeetCode)

给定一个整数数组nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。示例: 输入: [-2,1,-3,4,-1,2,1,-5,4], 输出: 6 解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        /* 动态规划,
            选择求以每个索引结束点最大连续子数组为计算目标,然后取这里面最大的一个;
            dp[n] = dp[n-1] > 0 ? dp[n-1] + nums[n] : nums[n]; 
        */
        if (nums.size() == 0) {
            return 0;
        }
        if (nums.size() == 1) {
            return nums[0];
        }
        int max_dp = nums[0];
        int dp_n = 0;
        int dp_n_1 = nums[0];
        for (int i = 1; i < nums.size(); i++) {
            dp_n = dp_n_1 > 0 ? dp_n_1 + nums[i] : nums[i];
            dp_n_1 = dp_n;
            if (max_dp < dp_n) {
                max_dp = dp_n;
            }
        }
        return max_dp;
    }
};

322. 零钱兑换 - 力扣(LeetCode)

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
示例: 输入: coins = [1, 2, 5], amount = 11 输出: 3  解释: 11 = 5 + 5 + 1

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        /* 动态规划,dp[n] = min(dp[n-coins[0]], dp[n-coins[1]]...dp[n-coins[n]]) + 1;
            设不可到的位置dp[i] = -1, 任何一个可以到的dp[i] 必然等于最小 dp[i-coins[x]]非-1时(可到达)+1
        */
        if (amount <= 0 || coins.size() == 0) {
            return 0;
        }
        vector <int> dp(amount + 1, -1); // 初始化amount + 1个位置,每个位置默认都不可到
        dp[0] = 0; // 初始化第一个位置0,这样在第一轮初值判断的时候,比如 1个硬币判断1位置,可以使用相同策略
        for (int i = 1; i <= amount; i++) { // 求每一个位置dp
            for (int j = 0; j < coins.size(); j++) { // 尝试每一种硬币
                if (i - coins[j] >= 0 && dp[i - coins[j]] != -1) { // 判断这个位置,能否用此硬币可以达到
                    if (dp[i] == -1 || dp[i] > dp[i - coins[j]] + 1) { // 没有刷新过,或者找到更小的次数
                        dp[i] = dp[i - coins[j]] + 1;
                    }
                }
            }
        }
        
        return dp[amount];
    }
};

120. 三角形最小路径和 - 力扣(LeetCode)

给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。
例如,给定三角形:
[
     [2],
    [3,4],
   [6,5,7],
  [4,1,8,3]
]
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。

class Solution {
public:
    int minimumTotal(vector<vector<int>>& triangle) {
        /* 动态规划,dp[m][n] = min(dp[m+1][n], dp[m+1][n+1]) + triangle[m][n],
            构建一个相同形状的dp, 每个点的值就是每个位置的最小路径,
            从下往上,可以有如上规律
        */
        int n = triangle.size();
        vector<vector<int>> dp(n, vector<int>(n));
        // 最底层直接填写内容
        for (int j = 0; j < n; j++) {
            dp[n-1][j] = triangle[n-1][j];
        }
        // 从倒数第二层开始计算
        for (int i = n - 2; i >= 0; i--) {
            for (int j = 0; j < triangle[i].size(); j++) {
                dp[i][j] = min(dp[i+1][j], dp[i+1][j+1]) + triangle[i][j]; // 求最小路径
            }
        }
        return dp[0][0];
    }
};

300. 最长递增子序列 - 力扣(LeetCode)

给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例: 输入: [10,9,2,5,3,7,101,18] 输出: 4  解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        /* 动态规划,以按i位置结束最长递i增子序列dp[i], 遍历全部位置,求最大一个
            dp[n] = dp[n-1]...dp[0]中满足nums[x] < nums[n] + 1, 如果找不到为1
        */
        if (nums.size() == 0) {
            return 0;
        }
        int max_dp = 1;
        int len = nums.size();
        vector<int> dp(len, 1); // 每个位置至少为1
        for (int i = 1; i < len; i++) { // 从第一个位置开始,找0~i-1中满足条件的
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i] && dp[j] >= dp[i]) { //满足条件,更新size
                    dp[i] = dp[j] + 1;
                }
                if (max_dp < dp[i]) { // 更新max_dp
                    max_dp = dp[i];
                }
            } 
        }
        return max_dp;

    }
};

64. 最小路径和 - 力扣(LeetCode)

给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:每次只能向下或者向右移动一步。

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        /* 动态规划,求每个位置最小路径和,最后终点即为结果,
            dp[m][n] = min(dp[m][n-1], dp[m-1][n]) + grid[m][n]
        */
        if (grid.size() == 0) {
            return 0;
        }
        int m = grid.size();
        int n = grid[0].size();
        vector<vector<int>> dp(m, vector<int>(n));
        // 先写最右排和最上排的数值(只能右移动或者下移动,因此值计算确定)
        dp[0][0] = grid[0][0];
        for (int j = 1; j < n; j++) {
            dp[0][j] = dp[0][j - 1] + grid[0][j];
        }
        for (int i = 1; i < m; i++) {
            dp[i][0] = dp[i - 1][0] + grid[i][0];
        }
        // 从上到下,从左到右,计算中间的值
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                dp[i][j] = min(dp[i][j-1], dp[i-1][j]) + grid[i][j];
            }
        }
        return dp[m - 1][n - 1];
    }
};

174. 地下城游戏 - 力扣(LeetCode)

地下城是由 M x N 个房间组成的二维网格。公主关在了地下城的右下角。骑士在左上角,穿过地下城拯救公主。为了尽快到达公主,骑士决定每次只向右或向下移动一步。骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下会立即死亡。有些房间由恶魔守卫,进入会失去健康点数(值为负表示骑士将损失健康点数);其他房间要么是空的(值为 0),要么包含增加健康点魔法球(值为正表将增加健康点数)。计算确保骑士能够拯救到公主所需的最低初始健康点数。

class Solution {
public:
    int calculateMinimumHP(vector<vector<int>>& dungeon) {
        /* 动态规划,dp[i][j] 为走到该位置至少需要多少血量,从出口反向推导dp[0][0],
            最终目标d[m-1][n-1] = max(1, 1 - dungeon[i][j])
            dp[i][j] = max(1 , min(dp[i + 1][j], dp[i][j + 1])- dungeon[i][j]);
        */
        int m = dungeon.size(); // 行数row
        int n = dungeon[0].size(); // 列数clo
        vector<vector<int>> dp(m, vector<int>(n));
        dp[m-1][n-1] = max(1, 1 - dungeon[m-1][n-1]); // 先确定最右下角的点
        // 由于只能右移动和下移动,最右边和最下边单方向可以先推导
        for (int j = n - 2; j >= 0; j--) {
            dp[m-1][j] = max(1, dp[m-1][j+1] - dungeon[m-1][j]);

        }
        for (int i = m - 2; i >= 0; i--) {
            dp[i][n-1] = max(1, dp[i+1][n-1] -  dungeon[i][n-1]);
            cout << i << n - 1 << dp[i][n-1] << " " <<endl;
        }
        // 再计算中间的区域;
        for (int i = m - 2; i >= 0; i--) {
            for (int j = n - 2; j >= 0; j--) {
                dp[i][j] = max(1 , min(dp[i+1][j], dp[i][j+1]) - dungeon[i][j]);
            }
        }
        return dp[0][0];
    }
};

最长括号字符串 (牛课网)

给出一个仅包含字符'('和')'的字符串,计算最长的格式正确的括号子串的长度。对于字符串"(()"来说,最长的格式正确的子串是"()",长度为2.再举一个例子:对于字符串")()())",来说,最长的格式正确的子串是"()()",长度为4.

    int longestValidParentheses(string s) {
        /* 动态规划,求以每个点结束的最长子串长度dp[i], 然后遍历过程中找出最大的,
            找到s[i - 1 - dp[i-1]],判断是否和s[i] 匹配;如果匹配就为 dp[i-1] + 2 + dp[i-2-dp[i-1];
            dp[i-1]紧挨着的合法子串,2为这次新增的括号对,dp[i-2-dp[i-1] 为匹配的尾部括号之后的合法子串
            */
        if (s.size() < 2) {
            return 0;
        }
        int dp_max = 0;
        vector<int>dp(s.length());
        dp[0] = 0;
        for (int i = 1; i < s.size(); i++) {
            if (s[i] == ')') { // 找到第一个右括号
                if (s[i-1] == '(') { // 如果之前为左括号
                    dp[i] =  (i >= 2 ? dp[i - 2] : 0) + 2; // 连接前一个dp
                } else { // 如果之前也为右括号
                    if (s[i-1-dp[i-1]] == '(') { // 找dp[i-1]之前是否为匹配括号,如果匹配就再往前连接
                        dp[i] = dp[i-1] + 2 + (i >= 2 ? dp[i-2-dp[i-1]] : 0);
                    }
                }
            } 
            dp_max = max(dp_max, dp[i]);
        }
        return dp_max;
    }

最长公共子串(牛客网)

给定两个字符串str1和str2,输出两个字符串的最长公共子串。题目保证str1和str2的最长公共子串存在且唯一。

    string LCS(string str1, string str2) {
        // write code here
        /* 动态规划,dp[i][j] 标识str1[i]和str2[j]结尾的最大公共子串长度 */
        int dp_max = 0;
        int dp_max_index = 0;
        int m = str1.length();
        int n = str2.length();
        vector<vector<int>> dp(m, vector<int>(n));
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
               if (str1[i] == str2[j]) {
                   dp[i][j] = 1 + ((i > 0 && j > 0) == true ? dp[i-1][j-1] : 0);
                   // cout << str1[i] << " " << dp[i][j] << endl;
                   if (dp_max < dp[i][j]) {
                       dp_max = dp[i][j];
                       dp_max_index = i;
                       // cout << dp_max << "" << dp_max_index << endl;
                   }
               }
            }
        } 
        return str1.substr(dp_max_index + 1 - dp_max, dp_max);
    }

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐