赞
踩
动态规划(Dynamic Programming, DP)是一种用于解决复杂问题的优化算法设计技术。它将原问题分解为若干相互重叠的子问题,通过记录子问题的解,避免重复计算,从而大大减少了计算量。
动态规划典型的应用场景包括:
最优化问题:如求最短路径、最小编辑距离等。
计数问题:如有多少种方式走到终点、排列组合数量等。
取值问题:如背包问题、切钢条问题等。
问题的最优解包含其子问题的最优解。这意味着问题可以通过组合子问题的解来解决。
动态规划问题的最优子结构性质是指原问题的最优解可以由其子问题的最优解推导出来。也就是说,原问题的解是以子问题的最优解为基础,经过一定的组合或运算得到的。
这个性质反映了原问题与子问题之间的关系:子问题的最优解构成了原问题最优解的一部分。因此,我们可以先求解子问题,然后根据子问题的最优解推导出原问题的最优解。
通常我们使用状态转移方程来刻画这种子问题与原问题之间的关系。状态转移方程定义了如何由小规模子问题的最优解,组合得到大规模问题的最优解。
所以最优子结构性质是动态规划的基础,它保证了我们能够通过解决子问题,逐步推导出原问题的最优解,从而达到将大问题分解为小问题解决的目的。满足这个性质是采用动态规划算法的前提条件。
动态规划的基本步骤是一套系统的方法论,用于将复杂问题分解为更小、更易于管理的子问题。这些步骤有助于高效解决具有重叠子问题和最优子结构特征的问题。以下是动态规划的五个基本步骤:
1. 识别问题类型
确认问题是否适合用动态规划解决。关键是检查问题是否具有以下两个特性:
2. 定义状态
dp[i]
或dp[i][j]
。其中dp
是一个常用的命名约定,代表“动态规划”。3. 确定状态转移方程
4. 确定边界条件
5. 计算最终结果
6. (可选)路径重构
斐波那契数 (通常用 F(n)
表示)形成的序列称为 斐波那契数列 。该数列由 0
和 1
开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给定 n
,请计算 F(n)
。
重复计算:在计算如斐波那契数列这样的问题时,递归方法可能会多次计算相同的子问题。例如,在计算 F(n)
的过程中,F(n-2)
会在计算 F(n-1)
和 F(n)
时被重复计算。
效率问题:由于重复计算,递归方法的时间复杂度可能会非常高。对于斐波那契数列的递归实现,时间复杂度是指数级的(大约是 O(2^n)),这对于较大的 n
是非常低效的。
class Solution {
public int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n-2) + fib(n-1);
}
}
定义状态
dp
,其中 dp[i]
表示斐波那契数列的第 ( i ) 项。初始状态
dp[0] = 0
和 dp[1] = 1
。状态转移方程
dp[i] = dp[i - 1] + dp[i - 2]
。计算顺序
答案
dp[n]
就是问题的答案。class Solution { public int fib(int n) { // Step 1 & 2: 初始化dp数组和初始状态 if (n <= 1) { return n; } int[] dp = new int[n + 1]; dp[0] = 0; dp[1] = 1; // Step 3 & 4: 使用状态转移方程计算dp数组中的每一项 for (int i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } // Step 5: 返回答案 return dp[n]; } }
在这个实现中,我们使用了一个大小为 n + 1
的数组 dp
来存储斐波那契数列的每个值,避免了重复计算相同项的问题,这是动态规划的核心优势。对于大值的 n
,这种方法比递归方法要高效得多。
给你一个整数数组 nums
,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7]
是数组 [0,3,1,6,2,2,7]
的子序列。
示例:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
定义状态
dp
,其中 dp[i]
表示以 nums[i]
结尾的最长递增子序列(LIS)的长度。初始状态
dp
的每个元素为 1,因为每个元素自身可以看作是长度为 1 的递增子序列。状态转移方程
i
(从 1 到 nums.length - 1
),对于每个 j
(从 0 到 i - 1
),如果 nums[i] > nums[j]
,则 dp[i] = Math.max(dp[i], dp[j] + 1)
。计算顺序
dp[1], dp[2], ..., dp[nums.length - 1]
。答案
dp
数组中的最大值。class Solution { public int lengthOfLIS(int[] nums) { if (nums == null || nums.length == 0) { return 0; } int n = nums.length; int[] dp = new int[n]; int maxLength = 1; // Step 2: 初始化dp数组 for (int i = 0; i < n; i++) { dp[i] = 1; } // Step 3 & 4: 动态规划计算 for (int i = 1; i < n; i++) { for (int j = 0; j < i; j++) { if (nums[i] > nums[j]) { dp[i] = Math.max(dp[i], dp[j] + 1); } } // 更新最长长度 maxLength = Math.max(maxLength, dp[i]); } // Step 5: 返回答案 return maxLength; } }
我们首先初始化一个 dp
数组,用来存储以每个元素结尾的最长递增子序列的长度。通过双层循环,我们不断更新 dp
数组中的值,从而找到最长递增子序列。最后,从 dp
数组中找到最大值,这就是我们要求的最长递增子序列的长度。
给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1
。
你可以认为每种硬币的数量是无限的。
示例:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
这个问题可以通过动态规划来解决。我们将问题分解为较小的子问题,然后找出每个子问题的解,从而找到最终问题的解。以下是使用动态规划求解最少硬币个数问题的详细步骤:
定义状态
dp
,其中 dp[i]
表示组成金额 i
所需的最少硬币个数。初始状态
dp[0] = 0
,因为组成金额 0 不需要任何硬币。i
(1 到 amount
),初始化为一个大数(例如 amount + 1
),表示初始时无法达到该金额。状态转移方程
i
,遍历所有硬币面额。如果 i
大于等于当前硬币面额 coin
,则 dp[i] = Math.min(dp[i], dp[i - coin] + 1)
。状态转移方程的核心详解:
遍历每个金额
i
:
- 我们考虑从
1
到amount
的每个金额。对于每个这样的金额i
,我们要找出组成这个金额所需的最少硬币个数。遍历所有硬币面额:
- 对于金额
i
,我们检查每种硬币面额coin
。这包括数组coins
中的每个元素。检查条件
i >= coin
:
- 我们只有在金额
i
大于等于硬币面额coin
时才考虑这枚硬币。如果i < coin
,这枚硬币太大,不能用来组成金额i
。更新
dp[i]
:
- 现在,如果金额
i
大于等于硬币面额coin
,我们尝试用这枚硬币来组成金额i
。如果用这枚硬币,那么我们还需要组成金额i - coin
。为此,我们查看dp[i - coin]
,它表示组成金额i - coin
所需的最少硬币个数。- 然后,我们使用
dp[i - coin] + 1
来更新dp[i]
。这里加 1 是因为我们使用了一枚面额为coin
的硬币。这实际上是说:“如果我使用这枚硬币,那么组成金额i
所需的总硬币数是组成i - coin
所需的硬币数加上这一枚硬币”。取最小值:
- 我们用
Math.min(dp[i], dp[i - coin] + 1)
来更新dp[i]
。这意味着我们对于每种硬币,都检查是否使用它会得到更少的硬币总数。我们选择能组成金额i
的最少硬币数量。通过这种方式,
dp[i]
最终存储的是组成金额i
所需的最少硬币个数。这个过程会对所有金额和所有硬币组合进行尝试,以找出最优解。
计算顺序
dp[1], dp[2], ..., dp[amount]
。答案
dp[amount]
大于 amount
,意味着无法组成该金额,返回 -1
;否则返回 dp[amount]
。class Solution { public int coinChange(int[] coins, int amount) { int max = amount + 1; int[] dp = new int[amount + 1]; Arrays.fill(dp, max); dp[0] = 0; for (int i = 1; i <= amount; i++) { for (int coin : coins) { if (i >= coin) { dp[i] = Math.min(dp[i], dp[i - coin] + 1); } } } return dp[amount] > amount ? -1 : dp[amount]; } }
dp
数组存储了组成每个金额所需的最少硬币个数。通过遍历所有的硬币面额和所有可能的金额,我们能够填充 dp
数组,找到组成特定金额所需的最少硬币个数。如果最终的 dp[amount]
值仍然大于 amount
,这意味着没有合适的硬币组合可以组成这个金额,因此返回 -1
。
给你两个单词 word1
和 word2
, 请返回将 word1
转换成 word2
所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
定义状态
dp
,其中 dp[i][j]
表示 word1
的前 i
个字符转换成 word2
的前 j
个字符所需的最少操作数。初始状态
dp[0][j] = j
,表示将空字符串转换为 word2
的前 j
个字符需要 j
步(全部插入操作)。dp[i][0] = i
,表示将 word1
的前 i
个字符转换为空字符串需要 i
步(全部删除操作)。状态转移方程
i > 0
和 j > 0
,我们考虑以下情况:
word1[i - 1] == word2[j - 1]
,则无需操作,dp[i][j] = dp[i - 1][j - 1]
。dp[i][j - 1] + 1
dp[i - 1][j] + 1
dp[i - 1][j - 1] + 1
状态转移方程解析
状态转移方程考虑的是将
word1
的前i
个字符转换成word2
的前j
个字符所需的最少操作数。设dp[i][j]
为这个最少操作数。转换方法有三种:插入、删除、替换。我们需要找到最优的操作序列,使得操作次数最少。状态转移方程如下:
当
word1[i - 1] == word2[j - 1]
时:
- 当前字符相等,不需要任何操作。因此,我们只需考虑
word1
的前i-1
个字符和word2
的前j-1
个字符。所以,dp[i][j] = dp[i - 1][j - 1]
。当
word1[i - 1] != word2[j - 1]
时:
- 当前字符不相等,我们有三种操作方式,选择其中操作数最少的一种:
- 插入:在
word1
的i
位置插入一个字符,使其与word2[j]
相等。然后,我们需要将word1
的前i
个字符变成word2
的前j-1
个字符。因此,操作数为dp[i][j-1] + 1
。- 删除:删除
word1[i]
,然后将word1
的前i-1
个字符变成word2
的前j
个字符。操作数是dp[i-1][j] + 1
。- 替换:将
word1[i]
替换成word2[j]
,接下来只需将word1
的前i-1
个字符变成word2
的前j-1
个字符。操作数为dp[i-1][j-1] + 1
。在每一步,我们选择这三种操作中最小的操作数作为
dp[i][j]
的值。简单举例
例如,考虑
word1 = "abc"
和word2 = "yabd"
。如果我们要计算dp[3][4]
(即将"abc"
变成"yabd"
的最少操作数):
word1[3-1] != word2[4-1]
(c != d
),所以我们考虑三种操作:
- 插入:将
d
插入到word1
的末尾,然后我们只需要考虑将"abc"
变为"yab"
,即dp[3][3] + 1
。- 删除:删除
word1
的最后一个字符c
,然后我们只需将"ab"
变为"yabd"
,即dp[2][4] + 1
。- 替换:将
word1
的最后一个字符c
替换为d
,然后我们只需将"ab"
变为"yab"
,即dp[2][3] + 1
。我们从这三个操作中选择最小的一个来更新
dp[3][4]
。通过这种方式,我们可以构建整个
dp
数组,最终dp[word1.length()][word2.length()]
就是将word1
转换成word2
所需的最少操作数。
计算顺序
dp[i][j]
。答案
dp[word1.length()][word2.length()]
是最终答案。class Solution { public int minDistance(String word1, String word2) { int m = word1.length(); int n = word2.length(); int[][] dp = new int[m + 1][n + 1]; // Step 2: 初始化dp数组 for (int i = 0; i <= m; i++) { dp[i][0] = i; } for (int j = 0; j <= n; j++) { dp[0][j] = j; } // Step 3 & 4: 计算dp数组 for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (word1.charAt(i - 1) == word2.charAt(j - 1)) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i][j - 1], dp[i - 1][j])) + 1; } } } // Step 5: 返回答案 return dp[m][n]; } }
假设你正在爬楼梯。需要 n
阶你才能到达楼顶。
每次你可以爬 1
或 2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
定义状态
dp
,其中 dp[i]
表示到达第 i
阶楼梯有多少种不同的方法。初始状态
dp[0] = 1
和 dp[1] = 1
。到达第0阶(起点)只有1种方法(即不爬),到达第1阶也只有1种方法(爬1阶)。状态转移方程
i >= 2
,每一阶楼梯都可以从前一阶爬上来(一步),或者从前两阶跨两步爬上来。因此,dp[i] = dp[i - 1] + dp[i - 2]
。计算顺序
dp[2]
开始计算,一直到 dp[n]
。答案
dp[n]
就是到达第 n
阶楼梯的不同方法数量。class Solution { public int climbStairs(int n) { if (n <= 1) { return 1; } int[] dp = new int[n + 1]; dp[0] = 1; dp[1] = 1; for (int i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; } }
我们首先处理了两个初始状态 dp[0]
和 dp[1]
,然后利用状态转移方程计算出每一阶楼梯的爬法数。最后,dp[n]
就给出了爬到第 n
阶楼梯的不同方法数量。这个问题本质上和斐波那契数列是相同的,因为每一阶的方法数都是前两阶方法数的和。
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
定义状态
dp
,其中 dp[i]
表示以 nums[i]
结尾的最大子数组和。初始状态
dp[0] = nums[0]
,因为最开始的最大子数组和只能是数组的第一个元素。状态转移方程
i
(从 1
到 nums.length - 1
),dp[i]
是 dp[i-1] + nums[i]
和 nums[i]
之间的较大值。这表示我们可以选择继续累加前面的子数组或者从当前位置重新开始一个新的子数组。dp[i] = Math.max(dp[i - 1] + nums[i], nums[i])
。计算顺序
dp[1]
开始计算,直到 dp[nums.length - 1]
。答案
dp
数组中的最大值。class Solution { public int maxSubArray(int[] nums) { if (nums == null || nums.length == 0) { return 0; } int n = nums.length; int[] dp = new int[n]; dp[0] = nums[0]; int maxSum = dp[0]; for (int i = 1; i < n; i++) { dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]); maxSum = Math.max(maxSum, dp[i]); } return maxSum; } }
我们首先处理了初始状态 dp[0]
,然后使用状态转移方程计算出每个位置的最大子数组和。同时,我们追踪 dp
数组中的最大值,这就是最大子数组和。这种方法比直接遍历所有可能的子数组要高效得多。
一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
示例:
输入:m = 3, n = 7
输出:28
定义状态
dp
,其中 dp[i][j]
表示到达网格中的位置 (i, j)
的不同路径数。初始状态
dp[0][j] = 1
对于所有 j
(从左上角到第一行的任何位置都只有一条路径,即全部向右移动)。dp[i][0] = 1
对于所有 i
(从左上角到第一列的任何位置都只有一条路径,即全部向下移动)。状态转移方程
i > 0
和 j > 0
,机器人只能从上方或左方到达 (i, j)
,所以 dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
。计算顺序
dp[i][j]
,从 dp[1][1]
开始,直到 dp[m - 1][n - 1]
。答案
dp[m - 1][n - 1]
就是到达右下角的不同路径总数。class Solution { public int uniquePaths(int m, int n) { int[][] dp = new int[m][n]; // 初始化第一列和第一行 for (int i = 0; i < m; i++) { dp[i][0] = 1; } for (int j = 0; j < n; j++) { dp[0][j] = 1; } // 动态规划填充其余的网格 for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; } } return dp[m - 1][n - 1]; } }
我们首先初始化了网格的第一行和第一列,因为这些位置的路径数是已知的。然后我们使用状态转移方程逐格计算其他位置的路径数。最后,dp[m - 1][n - 1]
给出了到达右下角的不同路径总数。
也称为记忆化递归(Memoization)。
思路:
实现细节:
优缺点:
也称为表格化(Tabulation)。
思路:
实现细节:
优缺点:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。