一、介绍
动态规划(Dynamic Programming,简称 DP)是一种解决多阶段决策问题
的数学方法。
在计算机科学中,动态规划常用于解决具有重叠子问题
和最优子结构性质
的问题,通过将问题分解成相互重叠的子问题,并以自底向上或自顶向下的方式进行求解,从而避免重复计算
,提高算法效率。
动态规划的核心思想:利用之前已经求解过的子问题的解来求解当前问题,从而将问题的规模不断缩小,直到求解最终问题。
动态规划通常包括以下五个步骤:
-
定义状态:将问题抽象成一个状态空间,定义状态表示问题的子问题解集合。
-
确定状态转移方程:建立状态之间的递推关系,确定不同状态之间的转移规则。
-
初始化边界条件:确定最小规模问题的解,即边界条件。
-
自底向上或自顶向下求解:根据状态转移方程和边界条件,采用自底向上或自顶向下的方式求解问题。
-
返回结果:根据问题的要求,返回最终问题的解。
动态规划常见的应用包括:
- 计数问题:如组合数、排列数等。
- 路径问题:如最短路径、最短编辑距离等。
- 最优化问题:如最长递增子序列、0-1 背包问题等。
- 概率问题:如期望值、方差等。
动态规划是一种十分重要的算法思想,在算法设计与解决实际问题中都有广泛的应用。
二、由斐波那契数列入门动态规划
斐波那契数列(Fibonacci Sequence)是一个经典的数学问题,也是动态规划中的一个常见例子。
斐波那契数列是一个递归序列,定义如下:
第一个和第二个数为 1;从第三个数开始,每个数都是前两个数之和。数学表示为:F(1) = 1, F(2) = 1, F(n) = F(n-1) + F(n-2) (n > 2)
斐波那契数列的前几个数是:1, 1, 2, 3, 5, 8, 13, 21, …
斐波那契数列的递推关系是其特点之一,这种递推关系可以用递归或动态规划来求解。
递归方法通常会存在重复计算的问题,而动态规划则可以通过保存中间结果来避免重复计算。
递归法
比较简单:
function fibonacci(n) {
if (n <= 2) {
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
// 示例用法
console.log(fibonacci(1)); // 输出:1
console.log(fibonacci(2)); // 输出:1
console.log(fibonacci(5)); // 输出:5
console.log(fibonacci(10)); // 输出:55
这是斐波那契数列的基本解法,但我们今天的主角是动态规划。请往下看。
动态规划法
- 第一步,确定
dp[i]
含义【定义状态】
在做动态规划类的题目,都要定义一个一维或者二维的用来做状态转移的数组。
在斐波那契数列里,dp[i]
表示第 i 个斐波那契数的值。
- 第二步,确定递推公式【确定状态转移方程】
dp[i] = dp[i-1] + dp[i-2]
- 第三步,初始化【初始化边界条件】
dp[0] = 1, dp[1] = 1
- 第四步,确定遍历顺序【自底向上或自顶向下求解】
对于斐波那契数列,遍历顺序为自前向后。
- 第五步,打印
dp[]
数组【返回结果】
主要用来 debug
function fibonacci(n) {
// 1. 确定 dp[i] 含义
// 3. 初始化
let dp = [0, 1];
// 4. 确定遍历顺序
for(let i = 2; i <= n; i++) {
// 2. 确定递推公式
dp[i] = dp[i - 1] + dp[i - 2];
}
// 5. 打印 dp[] debug
console.log(dp);
return dp[n];
}
// 示例用法
console.log(fibonacci(1)); // 输出:1
console.log(fibonacci(2)); // 输出:1
console.log(fibonacci(5)); // 输出:5
console.log(fibonacci(10)); // 输出:55
在这个实现基础之上,我们还可以将空间复杂的从 O(n) 降低到 O(1)。
var fibonacci = function(n) {
// 动规状态转移中,当前结果只依赖前两个元素的结果,所以只要两个变量代替dp数组记录状态过程,就将空间复杂度降到 O(1)
let pre1 = 1;
let pre2 = 0;
let temp;
if (n === 0) return 0;
if (n === 1) return 1;
for(let i = 2; i <= n; i++) {
temp = pre1;
pre1 = pre1 + pre2;
pre2 = temp;
}
return pre1;
};
三、由爬楼梯进一步巩固
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
- 1 阶 + 1 阶
- 2 阶
示例 2:
输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
- 1 阶 + 1 阶 + 1 阶
- 1 阶 + 2 阶
- 2 阶 + 1 阶
function climbStairs(n) {
// 1. 确定 dp[i] 含义:层数为 i,有多少种方法可以爬到层数 i
// 2. 确定递推公式:dp[i] = dp[i-1] + dp[i-2]
// 3. 初始化
// 4. 确定遍历顺序
// 5. 打印 dp[]
// let dp = [1, 1];
// for(let i = 2; i <= n; i++) {
// 其实 dp[0] 是没有意义的
let dp[1] = 1;
let dp[2] = 2
for(let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
console.log(dp);
return dp[n];
}
// 示例用法
console.log(climbStairs(1)); // 输出:1
console.log(climbStairs(2)); // 输出:2
console.log(climbStairs(3)); // 输出:3
console.log(climbStairs(4)); // 输出:5
console.log(climbStairs(5)); // 输出:8
这道题目还可以继续深化,就是一步一个台阶,两个台阶,三个台阶,直到 m 个台阶,有多少种方法爬到 n 阶楼顶。这又有难度了,这其实是一个完全背包问题,在后续系列关于背包问题再进行分析。
四、总结
这就是动态规划的介绍和解题基本步骤,在后面的系列我们将由浅及深学习更复杂的场景。
最后,讲一下动态规划和贪心的区别,其实大家不用太强调理论上的区别,做做题,就感受出来了。
动态规划(Dynamic Programming,DP)和贪心算法(Greedy Algorithm)都是常见的算法思想,但它们有一些重要的区别:
- 重叠子问题:
- 动态规划:子问题可能会重叠,即在求解问题的过程中会重复计算相同的子问题。
- 贪心算法:通常不涉及重叠子问题,每一步的决策都是基于当前局部最优解。
- 最优子结构性质:
- 动态规划:问题具有最优子结构性质,即问题的最优解可以通过子问题的最优解来求解。
- 贪心算法:问题具有局部最优解性质,即每一步的局部最优解能够导致全局最优解。
- 解决的问题类型:
- 动态规划:适用于求解具有最优子结构性质的问题,如最长递增子序列、0-1 背包问题等。
- 贪心算法:适用于求解具有贪心选择性质的问题,例如某些排序问题、最小生成树问题、部分背包问题等。
- 复杂度分析:
- 动态规划:通常需要使用额外的空间来存储子问题的解,时间复杂度通常为 O(n^2) 或 O(n*m),其中 n 和 m 是问题的规模。
- 贪心算法:通常不需要额外的空间,时间复杂度通常为 O(n*log(n)) 或 O(n),其中 n 是问题的规模。
- 是否能够得到全局最优解:
- 动态规划:由于具有最优子结构性质,能够得到问题的全局最优解。
- 贪心算法:通常只能得到局部最优解,并不能保证得到问题的全局最优解。
总的来说,动态规划通常用于求解具有最优子结构性质且子问题重叠的问题,能够得到问题的全局最优解;而贪心算法则通常用于求解具有贪心选择性质的问题,得到的是局部最优解,并不保证全局最优。