动态规划与一维数组的结合主要用于解决那些状态可以由单个变量表示的问题。 这通常意味着问题具有某种线性或单调递增的性质。 一维数组 dp[i]
存储的是到达状态 i
的最优解。 状态 i
的最优解通常依赖于它之前状态 (0
到 i-1
) 的最优解。
让我们通过几个例子来详细讲解:
1. 斐波那契数列:
这是动态规划中最经典的例子之一。 斐波那契数列的第 n 项定义为前两项之和:F(n) = F(n-1) + F(n-2)
,其中 F(0) = 0
, F(1) = 1
。
使用一维数组,我们可以轻松地自底向上计算斐波那契数列:
def fibonacci(n):
if n <= 1:
return n
dp = [0] * (n + 1) # 一维数组存储斐波那契数
dp[0] = 0
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
print(fibonacci(6)) # 输出 8
在这里,dp[i]
存储斐波那契数列的第 i 项。 状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]
直接体现在循环中。
2. 爬楼梯:
一个经典的动态规划问题。 你需要爬上一个有 n 个台阶的楼梯,每次你可以爬 1 或 2 个台阶。 问有多少种不同的方法可以爬到楼梯顶端?
状态:dp[i]
表示爬到第 i 个台阶的方法数。
状态转移方程:dp[i] = dp[i-1] + dp[i-2]
(到达第 i 个台阶,可以从 i-1 或 i-2 台阶到达)
边界条件:dp[0] = 1
(一种方法到达第0个台阶,即不动), dp[1] = 1
(一种方法到达第一个台阶)
def climbStairs(n):
dp = [1, 1] # 初始化边界条件
for i in range(2, n + 1):
dp.append(dp[i - 1] + dp[i - 2])
return dp[n]
print(climbStairs(5)) # 输出 8
这里,我们用一个列表模拟一维数组,并且空间复杂度可以优化为常数级别,因为我们只需要存储最后两个状态的值。
3. 最长递增子序列 (LIS):
给定一个整数序列,找到最长递增子序列的长度。
状态:dp[i]
表示以 nums[i]
结尾的最长递增子序列的长度。
状态转移方程:dp[i] = max(dp[j] + 1 for j in range(i) if nums[j] < nums[i])
(找到所有小于 nums[i]
的元素,取其 dp[j]
的最大值加 1)
边界条件:dp[i] = 1
(每个元素本身就是一个长度为 1 的递增子序列)
def longestIncreasingSubsequence(nums):
dp = [1] * len(nums)
for i in range(1, len(nums)):
for j in range(i):
if nums[i] > nums[j]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
print(longestIncreasingSubsequence([10,9,2,5,3,7,101,18])) # 输出 4
让我们再看几个使用一维数组解决动态规划问题的例子,并更侧重于分析问题和设计状态转移方程的思路:
4. 打家劫舍 (House Robber):
问题描述:一个贼要偷窃沿街的房屋。 每间房屋的现金数量不同,相邻的房屋装有相互连接的防盗系统,不能同时偷窃。 求贼能够偷窃到的最大金额。
状态:dp[i]
表示偷窃到第 i 间房屋时能够获得的最大金额。
状态转移方程:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
(偷窃第 i 间房屋,则不能偷窃第 i-1 间;不偷窃第 i 间房屋,则金额为 dp[i-1]
)
边界条件:dp[0] = nums[0]
, dp[1] = max(nums[0], nums[1])
def rob(nums):
if not nums:
return 0
n = len(nums)
if n == 1:
return nums[0]
dp = [0] * n
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n):
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
return dp[n - 1]
print(rob([2,7,9,3,1])) # Output: 12
5. 解码方法:
问题描述:一条编码消息由数字组成。 每个数字代表一个字母(‘A’ = 1, ‘B’ = 2, … ‘Z’ = 26)。 解码这条消息有多少种不同的方法?
状态:dp[i]
表示解码消息前 i 个数字的方法数。
状态转移方程:
c
是有效字母 (1-9),dp[i] += dp[i-1]
c
和前一个数字 p
组合起来是有效字母 (10-26),dp[i] += dp[i-2]
边界条件:dp[0] = 1
, dp[1] = 1
(假设至少有一个数字)
def numDecodings(s):
n = len(s)
dp = [0] * (n + 1)
dp[0] = 1
dp[1] = 1 if s[0] != '0' else 0
for i in range(2, n + 1):
if 1 <= int(s[i-1]) <= 9:
dp[i] += dp[i-1]
if 10 <= int(s[i-2:i]) <= 26:
dp[i] += dp[i-2]
return dp[n]
print(numDecodings("12")) # Output: 2
print(numDecodings("226")) # Output: 3
6. 分割数组的最大和:
问题描述:给定一个非负整数数组 nums
,你需要将它分成 k
个相邻的子数组。 目标是最大化这些子数组和的最小值。
状态: 这个例子稍微复杂一些,可能需要二分查找来优化,但核心思想仍然是一维 DP 的应用。 我们不在这里展开复杂的细节,因为一维数组主要用于状态转移相对简单的场景。 更复杂的场景可能需要二维或更高维度的数组。
让我们用中文再讲解几个动态规划问题,这些问题可以用一维数组解决,但状态转移方程可能稍微复杂一些:
7. 硬币找零 (最小硬币数):
问题描述:给定不同面值的硬币和一个总金额 amount,编写一个函数计算凑成该金额所需的最少硬币数量。如果无法用任何硬币组合凑成该金额,则返回 -1。
状态:dp[i]
表示凑成金额 i
所需的最少硬币数。
状态转移方程:对于每个硬币 coins[j]
,如果 i - coins[j] >= 0
,则 dp[i] = min(dp[i], dp[i - coins[j]] + 1)
。这意味着我们尝试使用每个硬币来凑成金额 i
,并选择使用最少硬币的组合。
边界条件:dp[0] = 0
(凑成金额 0 不需要硬币)。其他 dp[i]
初始化为一个很大的值(例如,无穷大),表示该金额尚未达到。
import sys
def coinChange(coins, amount):
dp = [sys.maxsize] * (amount + 1)
dp[0] = 0
for coin in coins:
for i in range(coin, amount + 1):
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != sys.maxsize else -1
print(coinChange([1, 2, 5], 11)) # 输出: 3
print(coinChange([2], 3)) # 输出: -1
8. 最长回文子序列:
问题描述:给定一个字符串 s
,找到其最长回文子序列的长度。子序列是可以从另一个序列中删除一些或没有元素而不会改变其余元素顺序的序列。
状态:dp[i]
表示以索引 i
结尾的最长回文子序列的长度。(这是一个简化,更准确的表示可能需要二维数组,但我们可以通过巧妙的索引使用一维数组来实现解决方案。)
状态转移方程:这个方程比较复杂,需要迭代方法并仔细考虑回文特性。我们这里不详细说明完整的方程,因为它超出了对一维数组简要解释的范围。(通常情况下,二维 DP 解决方案更清晰易懂。)
9. 最小路径和 (简化版):
问题描述:给定一个数字三角形数组,找到从顶部到底部的最小路径和。你只能移动到下一行中相邻的数字。(这是一个简化版本;完整的问题通常使用矩形网格。)
状态:dp[i]
表示到达当前行中第 i
个元素的最小路径和。
状态转移方程:这需要跟踪上一行的最小路径和,从概念上来说,使用二维数组更容易,但是可以通过巧妙地管理一维数组并从右到左或从左到右迭代来完成。
好的,我们再用中文讲解几个可以用一维动态规划解决的问题,并着重分析其状态和状态转移方程:
10. 编辑距离:
问题描述:给定两个字符串 word1 和 word2,找到将 word1 转换为 word2 所需的最小编辑次数。允许的操作包括插入、删除和替换一个字符。
状态:dp[i]
表示将 word1 的前 i 个字符转换为 word2 的前 j 个字符的最小编辑距离 (j 固定)。 为了使用一维数组,我们需要在每次迭代中更新 dp
数组。
状态转移方程:这个方程比较复杂,需要考虑三种操作:
word1[i-1] == word2[j-1]
,则 dp[i] = dp[i-1]
(无需操作)dp[i] = min(dp[i-1] + 1, dp[i] + 1, dp[i-1] + 1)
(分别对应删除、插入、替换) 需要注意的是,这裡的 dp[i]
在计算过程中会不断更新。边界条件:dp[0] = j
(删除 word1 的所有字符)
11. 最大子数组和:
问题描述:给定一个整数数组 nums,找到其中一个具有最大和的连续子数组(子数组至少包含一个元素),并返回其最大和。
状态:dp[i]
表示以 nums[i] 结尾的子数组的最大和。
状态转移方程:dp[i] = max(nums[i], dp[i-1] + nums[i])
(要么只包含 nums[i],要么包含之前的最大子数组和 nums[i])
边界条件:dp[0] = nums[0]
12. 股票买卖最佳时机 II:
问题描述:给定一个数组 prices ,其中 prices[i] 表示第 i 天的股票价格。 你可以无限次地买卖股票,每次买卖都需要支付交易费用 fee。设计一个算法来计算你所能获取的最大利润。
状态:dp[i][0]
表示第 i 天持有股票的最大利润,dp[i][1]
表示第 i 天不持有股票的最大利润。为了简化成一维,我们可以利用滚动数组的思想。
状态转移方程:
dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i])
(持有股票,要么从前一天持有,要么今天买入)dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i] - fee)
(不持有股票,要么从前一天不持有,要么今天卖出)边界条件:dp[0][0] = -prices[0]
, dp[0][1] = 0
使用滚动数组,空间复杂度可以优化到 O(1)。
这些例子说明,一维动态规划可以解决多种问题,但选择一维数组的关键在于能够巧妙地设计状态和状态转移方程,有时需要一些技巧,例如滚动数组,来处理依赖关系。 如果状态之间的依赖关系过于复杂,二维或更高维度的数组通常会使解决方案更清晰易懂。 记住,代码的可读性和可维护性也同样重要。