动态规划算法题

博主在上一篇博文 简单聊一聊动态规划算法 中讲了一些关于动态规划的基本知识。本篇博文主要介绍动态规划相关的习题,目的是帮助大家强化动态规划的思想与应用。

由于博主从事 web 前端相关的工作,所以编程语言选择的是 JavaScript。不过算法重要的不是语言而是解题思路,相信学习其他编程语言的同学也能看懂。

最长上升子序列(LIS)

最长上升子序列(Longest Increasing Subsequence)这题算是动态规划中的经典题,我们先来看看该题。

题目:

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

输入: [10,9,2,5,3,7,101,18]
输出: 4 
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。

说明:

  • 可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
  • 你算法的时间复杂度应该为 O(n2)O(n^2)

由于我们主要介绍动态规划,所以这里就不讨论其他算法了。

题解:

这道题说的是子序列,没有说连续的,所以只要保证先后顺序,即使断开也算。假设我们知道了第 i 项前所有以各元素为结尾的序列的最长上升子序列,那么我们如何求以第 i 项为结尾的最长上升子序列呢?

只要得出这个答案,我们就找到了本题的状态转移方程。其实也不难,举个例子,看我们上面给出的示例。现在我们知道了以数值 3 为结尾的最长上升子序列为 [2,3] ,那以数值 7 为结尾的如何计算了。7 比 3 大,我们只要在 3 结尾的最大子序列上加 1 即可。

但是如果此时说这个值最大,是不一定了。7 前面不止还有很多子序列,我们需要比较其前面每一个元素结尾的最长上升子序列。

状态转移方程为:dp[i]=max(dp[j])+1,0j<inum[j]<num[i]dp[i]=max(dp[j])+1,其中0≤j<i且num[j]<num[i]

我们可以结合代码来进一步看如何使用动态规划的思想。

var lengthOfLIS = function(nums) {
	// 基本的空和空数组校验
    if (nums === null || nums.length === 0) {
        return 0
    }
    const len = nums.length, dp = new Array(len)
    let ansMax = 1
    dp[0] = 1
    for (let i = 1; i < len; i++) {
        let itemMax = 0
        for (let j = 0; j < i; j++) {
            if (nums[i] > nums[j]) {
                itemMax = Math.max(itemMax, dp[j])
            }
        }
        // 保存每一项为结尾的最长上升子序列个数
        dp[i] = itemMax + 1
        // 这一步不要,最后整个从 dp 数组中比较得出最大值也行
        ansMax = Math.max(dp[i], ansMax)
    }
    return ansMax
};

最大子序和

看懂上一题,估计做动态规划相关的题目就有感觉了。再来看一道简单的题。

题目:

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例:

输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

题解:

假设我们知道了包含第 k 项的最大和的连续子数组,那么我们可以很简单求出包含第 k + 1 项的最大和的连续子数组的值。

状态转移方程:dp[k]=Math.max(dp[k1]+nums[k],nums[k])dp[k] = Math.max(dp[k - 1] + nums[k], nums[k])

最终我们要求的是 dp 数组中的最大值。

var maxSubArray = function(nums) {
    if (!nums || nums.length === 0) {
    	return 0
    }
    const len = nums.length, dp = new Array(len)
    let max = dp[0]
    for (let i = 1; i < len; i++) {
       	dp[i] = Math.max(dp[i - 1] + nums[i], nums[i])
		max = Math.max(dp[i], max)
    }
    return max
};

这里我们还可以进一步优化空间复杂度,可以根据需要自行优化。

乘积最大子数组

上一题,我们求的是最大和,这一题我们来看看最大积。

题目:

给你一个整数数组 nums ,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。

示例:

输入: [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
输入: [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。

题解:

可能很多人和博主最开始一样,直接拿标准的动态规划模板套。但是最后发现不对头,因为负负得正。可能前面连续最小的,最后乘当前值会变成最大的。所以我们需要同时维护上一个最大值和上一个最小值。

var maxProduct = function(nums) {
    if (!nums || nums.length === 0) {
    	return 0
    }
    const len = nums.length
    let max = nums[0], min = nums[0], ans = nums[0]
    for (let i = 1; i < len; i++) {
        const tmax = max, tmin = min
        min = Math.min(tmax * nums[i], tmin * nums[i], nums[i])
        max = Math.max(tmax * nums[i], tmin * nums[i], nums[i])
        ans = Math.max(ans, max)
    }
    return ans
};

看到这里,很多人可能觉得动态规划好无聊!
在这里插入图片描述
确实,我们上面看到的都是一些数学问题。接下来的我们联系生活中的场景来看看动态规划的应用。什么场景呢?大家感兴趣的 5 个字:money(钱)。

硬币

在这里插入图片描述
题目:

给定数量不限的硬币,币值为25分、10分、5分和1分,编写代码计算n分有几种表示法。(结果可能会很大,你需要将结果模上1000000007)

示例:

 输入: n = 5
 输出:2
 解释: 有两种方式可以凑成总金额:
5=5
5=1+1+1+1+1
输入: n = 10
 输出:4
 解释: 有四种方式可以凑成总金额:
10=10
10=5+5
10=5+1+1+1+1+1
10=1+1+1+1+1+1+1+1+1+1

说明:

你可以假设:0<=n()<=10000000 <= n (总金额) <= 1000000

题解:

这里和前面几种不太一样,因为它是二维的,需要考虑两个维度,即硬币的类型和给出的总钱数(分)。这里博主画一个表格帮助大家理解。

首先,我们如果只用1分的硬币,那答案很简单。接着,如果我们只用1分和5分两种硬币,答案要复杂些,但是我们可以基于前面只用1分得出的结果来计算。依次类推,我们一直往下计算,最终可以得出包所有硬币的分法。
在这里插入图片描述

根据上面的表格,我们可以写出如下代码。

var waysToChange = function(n) {
    const dp5 = new Array(n + 1)
    const dp10 = new Array(n + 1)
    const dp25 = new Array(n + 1)
    dp5[0] = 1
    dp10[0] = 1
    dp25[0] = 1
    for (let i = 1; i <= n; i++) {
        dp5[i] = i - 5 >= 0 ? dp5[i - 5] + 1 : 1
    }
    for (let i = 1; i <= n; i++) {
        dp10[i] = i - 10 >= 0 ? dp10[i - 10] + dp5[i] : dp5[i]
    }
    for (let i = 1; i <= n; i++) {
        dp25[i] = i - 25 >= 0 ? dp25[i - 25] + dp10[i] : dp10[i]
    }
    return dp25[n] % 1000000007
};

其实上面的代码的空间复杂度可以继续优化,通过分析,我们可以发现一旦进行下一种类型的硬币的计算,其实只需要依赖前一种硬币的结果,其他的可以覆盖掉。经过优化我们可以得出如下代码:

var waysToChange = function(n) {
    const dp = new Array(n + 1)
    dp.fill(0)
    dp[0] = 1
    const coins = [1, 5, 10, 25]
    for (let i = 0; i < 4; i++) {
        for (let j = 1; j <= n; j++) {
            const sub = j - coins[i]
            if (sub >= 0) {
                dp[j] += dp[sub]
            }
        }
    }
    return dp[n] % 1000000007
};

零钱兑换

题目:

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例:

输入: coins = [1, 2, 5], amount = 11
输出: 3 
解释: 11 = 5 + 5 + 1
输入: coins = [2], amount = 3
输出: -1

题解:

如果对回溯法比较熟悉的人,可能第一感觉是使用回溯的思想,利用递归的技巧解题。但是这篇博文的主题是动态规划,所以大家可以思考如何利用动态规划的思想来求解。

其实这题和上面那道题很类似,但不同的是这题是一个求最优解的问题。我们先找到最优子结构,F(S):组成金额 S 所需的最少硬币数量。若组成金额 S 最少的硬币数,最后一枚硬币的面值是 C,分析可得出状态转移方程,F(S)=F(S−C)+1

我们这里只要比对每一个硬币,得出其中的最小值即可。

var coinChange = function(coins, amount) {
    if (amount === 0) return 0
    let ans = Number.POSITIVE_INFINITY
    const dp = new Array(amount + 1)
    dp.fill(-1)
    dp[0] = 0
    for (let i = 1; i <= amount; i++) {
        for (let j = 0; j < coins.length; j++) {
            if (i - coins[j] < 0 || dp[i - coins[j]] === -1) {
                continue
            }
            dp[i] = dp[i] === -1 ? dp[i - coins[j]] + 1 : Math.min(dp[i - coins[j]] + 1, dp[i])
        }
    }
    return dp[amount]
};

后记

动态规划比较难的是找到状态的定义,然后分析得出状态转移方程。另一个必要重要的是重叠子问题,事实上在根据状态转移方程得出每个状态时,需要缓存这个状态,这样可以避免重复计算,也是动态规划的核心。

之前说过,动态规划适合求解最优解问题。这类问题加了一个最优子结构的概念,其实就是前面状态中符合提题意的一种状态,博主认为一种特殊情况。最优子结构(特殊的状态),找到后和普通动态规划问题一样,继续找到状态转移方程。

经过博文中的几道动态规划的题,可以让大家稍微明白动态规划的应用与解题思路。如果对动态规划特别感兴趣,可以点击下面的链接继续做题。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章