算法----动态规划

198 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

一、什么是动态规划

动态规划(英语:Dynamic programming,简称DP),是一种将复杂问题分解成更小的子问题来解决的优化技术。

动态规划在查找有很多重叠子问题的情况的最优解时有效。它将问题重新组合成子问题。为了避免多次解决这些子问题,它们的结果都逐渐被计算并被保存,从简单的问题直到整个问题都被解决。因此,动态规划保存递归时的结果,因而不会在解决同样的问题时花费时间。

说到这里,我们说下动态规划和分治法的区别:

分而治之方法是把问题分解成相互独 立的子问题,然后组合它们的答案 动态规划则是将问题分解成相互依赖的子问题

动态规划考虑的要点

  1. 定义子问题
  2. 实现要反复执行来解决子问题的部分(这一步要参考之前的文章:算法----递归的步骤)
  3. 识别并求解出基线条件
  4. 总结为三点:最优子结构边界状态转移公式

接下来我们通过不同的题目来理解动态规划

二、动态规划相关应用

2.1 斐波那契数列

在之前讲述递归时,我们也实现了此方法,接下来我们从动态规划的角度来进行分析

我们对这个题目进行分析:

  1. 边界条件:F(0)=0和F(1)=1
  2. 状态转移公式:F(n)=F(n−1)+F(n−2)

相关代码,比较简单:

function fib(n) {
    // 边界条件
    if (n == 1 || n == 0) return 1
    let a = 0, b = 1, c
    for (let i = 2; i <= n; i++) {
        c = a + b
        a = b
        b = c
    }
    return c
}
console.log(fib(5)) //5

2.2 最长公共子序列

找出两个字符 串序列的最长子序列的长度。最长子序列是指,在两个字符串序列中以相同顺序出现,但不要求 连续(非字符串子串)的字符串序列。 例如:

输入:text1 = "abcde", text2 = "ace"

输出:3

解释:最长公共子序列是 "ace" ,它的长度为 3 。

这个动态规划和其余问题还是有点区别的,这是一个典型的二维动态规划问题。

  1. 假设字符串text1和text2的长度分别为m,n。则我们需要创建一个 m+1 行n+1 列的二维数组dp,其中dp[i][j]表示位置i行j列的最长公共子序列的长度
  2. 考虑边界问题:
    • 当i == 0时,表示为空字符串,即空字符串与任何字符串的最长公共子序列均为0,dp[0][j] = 0
    • 当j == 0 时,与i==0情况一直,则dp[i][0] =0
    • 总结,当i=0或 j=0时,dp[i][j]=0。
  3. 状态转移方程:当i>0 和j>0时,dp[i][j]应该如何取值
    • step 1:遍历两个字符串的所有位置,开始状态转移:若是i位与j位的字符相等,则该问题可以变成1+dp[i−1][j−1],即到此处为止最长公共子序列长度由前面的结果加1
    • step 2:若是不相等,说明到此处为止的子串,最后一位不可能同时属于最长公共子序列,毕竟它们都不相同,因此我们考虑换成两个子问题,dp[i][j−1]或者dp[i−1][j],我们取较大的一个就可以了,由此感觉可以用递归解决
    • step 3:但是递归的复杂度过高,重复计算了很多低层次的部分,因此可以用动态规划,从前往后加,由此形成一个表,表从位置1开始往后相加,正好符合动态规划的转移特征
    • step 4:因为最后要返回该序列,而不是长度,所以在构造表的同时要以另一个二维矩阵记录上面状态转移时选择的方向,我们用1表示来自左上方,2表示来自左边,3表示来自上边
    • step 5:获取这个序列的时候,根据从最后一位开始,根据记录的方向,不断递归往前组装字符,只有来自左上的时候才添加本级字符,因为这种情况是动态规划中两个字符相等的情况,字符相等才可用
    • step 6:若还不太懂,可观察下面的数据

image.png

总结的状态转移方程为:

  • dp[i][j] = 1+dp[i−1][j−1] (str1[i] == str2[j])

  • dp[i][j] = Math.max(dp[i-1][j],dp[i][j-1]),则最大

var longestCommonSubsequence = function (text1, text2) {
    let m = text1.length, n = text2.length
    let dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0))
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (text1[i - 1] == text2[j - 1]) {
                dp[i][j] = 1 + dp[i - 1][j - 1]
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
            }
        }
    }
    return dp[m][n]
}

针对上一题,我们还想还想知道最长递增子序列时什么,要如何修改呢?

上述方案,我们得到一个状态转移表,从表中我们可以看出,右下角的数据是最大的,此时我们需要在倒叙遍历,找到最终的数据:

接替的重点:先求出状态转移数组的值,然后从后向前遍历,对比字符串对应的字符是否相等。若相等,则直接push到新数组中,若不想等,则比较遍历时dp[i - 1][j]和dp[i][j - 1]的值的大小来决定循环的方向

var longestCommonSubsequence = function (text1, text2) {
    let m = text1.length, n = text2.length
    let dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0))
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (text1[i - 1] == text2[j - 1]) {
                dp[i][j] = 1 + dp[i - 1][j - 1]
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
            }
        }
    }
    // return dp[m][n]

    // 从后向前遍历,找到最终的子序列
    let res = []
    for (let i = m, j = n; i >= 1 && j >= 1 && dp[i][j] >= 1;) {
        if (text1[i - 1] == text2[j - 1]) {
            res.push(text1[i - 1])
            i--;
            j--;
        } else if (i == 1 || dp[i][j - 1] > dp[i - 1][j]) {
            j--
        } else {
            i--
        }
    }
    if (res.length == 0) return "-1";
    return res.reverse().join('');
}

3.3 最少硬币找零问题

最少硬币找零问题是给出要找零的钱 数,以及可用的硬币面额 d1, …, dn及其数量,找到所需的最少的硬币个数 例如:

  • 目标: 给定硬币的面值, 例如, 1, 5, 10, 25, 100, 设计一种使用最少数量的硬币向客户付款的方法
  • 收银员算法(Cashier's algorithm): 在每次迭代中, 添加不会使我们超过要支付的金额的最大值的硬币
  • 例子: 33 美分(33 cents) --> 一个 25 美分 + 一个 5 美分 + 三个 1 美分

分析:

状态转移方程:定义 F(i)组成金额 i 所需最少的硬币数量,假设在计算 F(i)之前,我们已经计算出 F(0)-F(i-1)的答案。 则F(i) 对应的转移方程应为:F(i) =

image.png 代表的含义:c(j)代表的是第 j 枚硬币的面值

F(i)最小硬币数量
F(0)0 //金额为0不能由硬币组成
F(1)1 //F(1)=min(F(1-1),F(1-2),F(1-5))+1
F(2)1 //F(2)=min(F(2-1),F(2-2),F(2-5))+1
F(3)2 //min(F(3-1),F(3-2),F(3-5))+1
F(4)2 //F(4)=min(F(4-1),F(4-2),F(4-5))+1
......
F(11)3 //F(11)=min(F(11-1),F(11-2),F(11-5))+1
function coinChange(coins, amount) {
    const cache = [];
    const makeChange = (value) => {
        if (!value) {
            return [];
        }
        if (cache[value]) {
            return cache[value]
        }
        let min = []
        let newMin, newAmount;
        for (let i = 0; i < coins.length; i++) {
            const coin = coins[i]
            newAmount = value - coin
            if (newAmount >= 0) {
                newMin = makeChange(newAmount);
            }
            if (newAmount >= 0 && (newMin.length < min.length - 1 || !min.length) && (newMin.length || !newAmount)) {
                min = [coin].concat(newMin);

            }
        }
        return (cache[value] = min);
    }
    return makeChange(amount);
}

若只求次数,可简化为:

function coinChange(coins, amount) {
    if (!amount) return 0
    // 初始化数组
    const dp = new Array(amount + 1).fill(Infinity)
    dp[0] = 0
    for (let i = 0; i < coins.length; i++) {
        for (let j = coins[i]; j <= amount; j++) {
            dp[j] = Math.min(dp[j-coins[i]]+1,dp[j])
        }
    }
    return dp[amount] === Infinity ? -1 : dp[amount];
}

—————END—————

参考与感谢

  • leetcode官网

相关算法题,也可在leetcode官网-动态规划分类中查找,理解思路后多看多做才能加深印象。