Bootstrap

数据结构与算法——动态规划

目录

引言

最优子结构

重叠子问题

打家劫舍(LeetCode 198题)

经典例题

1. 爬楼梯(LeetCode 70题)

2. 斐波那契数列(LeetCode 126题)

3. 最长公共子序列(LeetCode 95题)


引言

动态规划(Dynamic Programming, 简称DP)是一种在数学、计算机科学、经济学和生物信息学等领域广泛使用的算法设计技术。它通过把原问题分解为相对简单的子问题的方式,来求解复杂问题。动态规划的核心思想包括两个方面:

最优子结构

        一个问题的最优解包含其子问题的最优解,即可以通过组合子问题的最优解来构造原问题的最优解。

例如:假设你有一个装满不同面额硬币的钱包,包括1元、5元、10元等,你需要找给顾客正好30元的零钱,且希望使用的硬币数量尽可能少。

这个问题可以分解为多个子问题:

  1. 找给顾客1元:显然只需要一个1元硬币。
  2. 找给顾客5元:显然只需要一个5元硬币。
  3. 找给顾客10元:显然只需要一个10元硬币。
  4. 找给顾客20元:这里可能需要多个硬币,比如两个10元,或者一个10元加上两个5元,或者四个5元,等等,但我们要找的是使用硬币数量最少的那种方式。
  5. 找给顾客30元:这就是我们的原问题。它可以分解为找给顾客20元的问题(子问题)加上找给顾客10元的问题(另一个子问题),或者找给顾客25元(比如5个5元)加上5元,等等。我们需要找到这些组合中硬币数量最少的那一种。

如果我们知道了找给顾客任意小于或等于30元金额的最少硬币数量(即所有子问题的最优解),那么我们就可以通过组合这些最优解来找到找给顾客30元的最少硬币数量(即原问题的最优解)。

实际上在解决这个具体的零钱找零问题时,我们可能会使用动态规划的一个变种——贪心算法


重叠子问题

        在递归算法中,相同的子问题会被多次计算。动态规划通过存储这些子问题的解来避免重复计算,从而提高效率

例如:斐波那契数列:0, 1, 1, 2, 3, 5, 8, 13, 21, ...,其中每个数字是前两个数字的和。

使用递归方法来计算斐波那契数列的第n项,在计算fib(4)时,会分别计算fib(3)fib(2),而在计算fib(3)时又会计算fib(2)fib(1)。随着n的增大,重复计算的量会急剧增加。

def fib(n):  
    if n <= 1:  
        return n  
    return fib(n-1) + fib(n-2)

   使用一个数组来保存已计算过的斐波那契数来避免重复计算,从而大大提高效率。

def fib_dp(n):  
    if n <= 1:  
        return n  
    dp = [0] * (n + 1)  
    dp[1] = 1  
    for i in range(2, n + 1):  
        dp[i] = dp[i-1] + dp[i-2]  
    return dp[n]

打家劫舍(LeetCode 198题)

动态规划通常用于解决具有重叠子问题和最优子结构性质的问题。它使用一个数组或字典来存储不同状态的解,并通过状态转移方程来定义如何从一个或多个已知状态推导出下一个状态。

通过一个简单的例子——打家劫舍”(LeetCode 198题)来讲解动态规划的基本思想。

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,能够偷窃到的最高金额。

解题思路

首先,我们思考如何将这个问题分解为更小的子问题。对于数组中的每一个位置,我们都有两种选择:偷或不偷。但是,由于相邻的房屋不能同时被偷,所以我们的选择会受到前一个位置选择的影响。

我们可以定义一个数组dp,其中dp[i]表示偷到第i家房屋时(注意是“到”第i家,而不一定是偷第i家)能够获得的最高金额。但是,这里需要注意,由于我们不能偷相邻的房屋,所以dp[i]的值并不是直接由dp[i-1]决定的,而是可能由dp[i-1](即不偷第i家)或dp[i-2] + nums[i](即偷第i家,但不偷第i-1家)中的较大者决定。

因此,我们可以得到状态转移方程:

  • 如果i = 0(即第一家房屋),则dp[0] = nums[0](因为没有前一家可以比较,所以只能偷第一家)。
  • 如果i = 1(即第二家房屋),则dp[1] = max(nums[0], nums[1])(比较偷第一家和第二家的收益)。
  • 对于i > 1,则dp[i] = max(dp[i-1], dp[i-2] + nums[i])(选择不偷第i家或偷第i家但不偷第i-1家中的较大收益)。

最后,dp[n-1](其中n是数组的长度)就是我们要找的答案,即偷到最后一家房屋时能够获得的最高金额。

#include <vector>    
#include <algorithm>    
  
// 定义一个函数,用于计算打家劫舍的最大金额  
int rob(std::vector<int>& nums) {    
    int n = nums.size();  // 获取数组长度  
    if (n == 0) return 0;  // 如果数组为空,则无法抢劫,返回0  
    if (n == 1) return nums[0];  // 如果数组中只有一个元素,则直接返回该元素  
    
    std::vector<int> dp(n, 0);  // 创建一个动态规划数组,用于存储到达每个位置时的最大金额  
    dp[0] = nums[0];  // 初始化第一个位置的最大金额为第一个元素的值  
    dp[1] = std::max(nums[0], nums[1]);  // 初始化第二个位置的最大金额为前两个元素中的较大值  
    
    // 从第三个位置开始遍历数组  
    for (int i = 2; i < n; ++i) {    
        // 对于当前位置i,有两种选择:  
        // 1. 不抢劫当前位置i的房子,那么最大金额就是dp[i-1](即抢劫到前一个位置时的最大金额)  
        // 2. 抢劫当前位置i的房子,但不能抢劫前一个位置的房子,因此最大金额为dp[i-2] + nums[i](即抢劫到前两个位置之前时的最大金额加上当前位置的金额)  
        dp[i] = std::max(dp[i-1], dp[i-2] + nums[i]);    
    }    
    
    // 返回到达最后一个位置时的最大金额  
    return dp[n-1];    
}

动态规划的核心思想:将大问题分解为小问题,通过解决小问题来逐步解决大问题,并且存储已解决的小问题的答案,以避免重复计算。在动态规划中,我们通常需要定义一个数组(或其他数据结构)来保存中间结果,并根据这些中间结果推导出最终答案。


经典例题

1. 爬楼梯(LeetCode 70题)

题目描述

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

解题思路

定义一个数组dp,其中dp[i]表示爬到第i阶楼梯的方法数。状态转移方程为dp[i] = dp[i-1] + dp[i-2](对于i > 1),初始条件为dp[0] = 1(不爬也是一种方法),dp[1] = 1(爬一阶楼梯)

#include <vector>    
  
// 计算爬到楼梯顶部有n阶的方法数  
int climbStairs(int n) {    
    if (n <= 1) return n;  // 如果楼梯只有0阶或1阶,则方法数分别为1  
    std::vector<int> dp(n + 1, 0);  // 创建一个长度为n+1的dp数组,并初始化为0  
    dp[0] = 1;  // 爬到第0阶楼梯的方法有1种(即不爬)  
    dp[1] = 1;  // 爬到第1阶楼梯的方法有1种(即爬1阶)  
    for (int i = 2; i <= n; ++i) {  // 从第2阶开始计算  
        dp[i] = dp[i - 1] + dp[i - 2];  // 爬到第i阶的方法数等于爬到第i-1阶和i-2阶的方法数之和  
    }    
    return dp[n];  // 返回爬到n阶楼梯的方法数  
}

2. 斐波那契数列(LeetCode 126题)

题目描述

斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是

        F(0) = 0,F(1) = 1
        F(n) = F(n - 1) + F(n - 2),其中 n > 1

                给定 n ,请计算 F(n) 。

                答案需要取模 1e9+7(1000000007) ,如计算初始结果为:1000000008,请返回 1。

解题思路

与爬楼梯类似,但通常斐波那契数列要求返回第n项的值,而不是方法数。状态转移方程为fib(n) = fib(n-1) + fib(n-2)(对于n > 1),初始条件为fib(0) = 0fib(1) = 1

#include <vector>    
  
// 计算斐波那契数列的第n项  
int fib(int n) {    
    if (n <= 1) return n;  // 如果n为0或1,则直接返回n  
    std::vector<int> dp(n + 1, 0);  // 创建一个长度为n+1的dp数组,并初始化为0  
    dp[0] = 0;  // 斐波那契数列的第0项是0  
    dp[1] = 1;  // 斐波那契数列的第1项是1  
    for (int i = 2; i <= n; ++i) {  // 从第2项开始计算  
        dp[i] = dp[i - 1] + dp[i - 2];  // 第i项等于前两项之和  
    }    
    return dp[n];  // 返回斐波那契数列的第n项  
}

3. 最长公共子序列(LeetCode 95题)

题目描述

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

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。

        例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。

两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。

解题思路

定义一个二维数组dp,其中dp[i][j]表示text1的前i个字符和text2的前j个字符的最长公共子序列的长度。状态转移方程为:

  • 如果text1[i-1] == text2[j-1],则dp[i][j] = dp[i-1][j-1] + 1
  • 否则,dp[i][j] = max(dp[i-1][j], dp[i][j-1])

初始条件为dp[i][0] = 0(对于所有i)和dp[0][j] = 0(对于所有j)。

#include <vector>    
#include <string>    
#include <algorithm>    
  
// 计算两个字符串text1和text2的最长公共子序列的长度  
int longestCommonSubsequence(std::string text1, std::string text2) {    
    int m = text1.size(), n = text2.size();  // 获取两个字符串的长度  
    std::vector<std::vector<int>> dp(m + 1, std::vector<int>(n + 1, 0));  // 创建一个二维dp数组,并初始化为0  
    for (int i = 1; i <= m; ++i) {  // 遍历text1的每个字符  
        for (int j = 1; j <= n; ++j) {  // 遍历text2的每个字符  
            if (text1[i - 1] == text2[j - 1]) {  // 如果当前字符相等  
                dp[i][j] = dp[i - 1][j - 1] + 1;  // 则当前位置的值等于左上角位置的值加1  
            } else {    
                dp[i][j] = std::max(dp[i - 1][j], dp[i][j - 1]);  // 否则取左边和上边位置的最大值  
            }    
        }    
    }    
    return dp[m][n];  // 返回dp数组的最后一个元素,即最长公共子序列的长度  
}
;