什么是动态规划
动态规划(Dynamic Programming,简称 DP)是一种通过将复杂问题分解为多个子问题,并通过求解子问题的最优解来得到整个问题的最优解的常用的算法设计。
- DP 背后的核心思想是存储子问题的解决方案,以便每个子问题只解决一次
- 为了解决 DP 问题,我们首先以递归树中有重叠子问题的方式编写递归解决方案(使用相同参数多次调用递归函数)
- 为了确保递归值只计算一次(以缩短算法所需时间),我们存储递归调用的结果
- 存储结果有两种方式,一种是自上而下(或记忆法),另一种是自下而上(或制表法)
什么时候使用动态规划
动态规划用于解决具有以下特征的问题:
最佳子结构
最优子结构是指利用子问题的最优结果来达到更大问题的最优结果。
比如:考虑在加权图中寻找从源节点到目标节点的最低成本路径问题。我们可以将这个问题分解为更小的子问题:
- 找到从源节点到每个中间节点的最低成本路径。
- 找到从每个中间节点到目标节点的最低成本路径。
可以根据这些较小子问题的解来构建较大问题(找到从源节点到目标节点的最低成本路径)的解。
具有最优子结构的一些经典问题包括:
重叠子问题
相同的子问题在问题的不同部分被重复解决。
与分而治之法一样,动态规划将解决方案合并到子问题中。动态规划主要用于需要反复解决相同子问题的情况。在动态规划中,计算出的子问题解决方案存储在表中,这样就不必重新计算。因此,当没有共同(重叠)的子问题时,动态规划没有用处,因为如果不再需要解决方案,存储解决方案就没有意义。 例如,二分查找没有共同的子问题。但如果我们以斐波那契数列的递归程序为例,有许多子问题需要反复解决。要计算索引 n 处的斐波那契数,我们需要计算索引 n-1 和 n-2 处的斐波那契数。这意味着,在解决计算索引 n 处的斐波那契数的更大问题时,计算索引 n-2 处的斐波那契数的子问题被使用了两次。
js
<script>
/* a simple recursive program for Fibonacci numbers */
function Fib(n) {
if (n <= 1)
return n;
return Fib(n - 1) + Fib(n - 2);
}
</script>
Fib(3)
// output: 2
我们可以看到函数 Fib(1) 被调用了2次。如果我们存储了 Fib(1) 的值,那么我们可以重用旧的存储值而不是再次计算它。有以下两种不同的方法来存储这些值,以便这些值可以被重用:
- 记忆法(自上而下)
- 制表(自下而上)
动态规划是如何工作的
动态规划 (DP) 是一种在多项式时间内解决某些特定类型问题的技术。动态规划解决方案比指数蛮力方法更快,并且可以轻松证明其正确性。动态规划的工作原理是将问题分解为较小的子问题,独立解决每个子问题,并使用这些子问题的解来构建整体解决方案。子问题的解存储在表或数组中(记忆化)或以自下而上的方式(制表)以避免冗余计算。
- 识别子问题: 将主要问题划分为较小的、独立的子问题。
- 存储解决方案: 解决每个子问题并将解决方案存储在表或数组中。
- 建立解决方案: 使用存储的解决方案来构建主要问题的解决方案。
- 避免重新计算: 通过存储解决方案,DP 确保每个子问题只解决一次,从而减少计算时间。
- 最优原则: 利用最优化原则,保证解的最优。
记忆法(自上而下)
问题的记忆程序类似于递归版本,但有一点修改,即在计算解决方案之前先查看查找表。我们初始化一个查找数组,所有初始值均为 NIL。每当我们需要子问题的解决方案时,我们首先查看查找表。如果预先计算的值存在,则我们返回该值,否则,我们计算该值并将结果放入查找表中,以便以后可以重复使用。
以下是第 n 个斐波那契数的记忆版本
js
<script>
const MAX = 100;
let NIL = -1;
const lookup = new Array(MAX);
function _initialize() {
for (let i = 0; i < MAX; i++) {
lookup[i] = NIL;
}
}
function Fib(n) {
if (lookup[n] === NIL) {
if (n <= 1) {
lookup[n] = n;
} else {
lookup[n] = Fib(n - 1) + Fib(n - 2);
}
}
return lookup[n];
}
const n = 40;
_initialize();
document.write("Fibonacci number is" + " " + Fib(n)+"<br>");
// This code is contributed by avanitrachhadiya2155
</script>
时间复杂度: O(N)。这是因为该算法只计算每个斐波那契数一次,并将结果存储在数组中以供将来使用。后续使用相同输入值 n 调用该函数将从查找表中检索存储的值,从而避免重新计算。因此,时间复杂度是线性的,并且该算法对于较大的 n 值非常有效。
空间复杂度: O(N),因为已经创建了查找表。
制表法(自下而上)
针对给定问题的制表程序以自下而上的方式构建表格并返回表格中的最后一个条目。例如,对于同一个斐波那契数,我们首先计算 Fib(0),然后计算 Fib(1),然后计算 Fib(2),然后计算 Fib(3),依此类推。因此,从字面上看,我们正在自下而上构建子问题的解决方案。
以下是第 n 个斐波那契数的表格版本。
js
<script>
function Fib(n) {
const f = new Array(n + 1);
f[0] = 0;
f[1] = 1;
for (let i = 2; i <= n; i++) {
f[i] = f[i - 1] + f[i - 2];
}
return f[n];
}
// Driver code
var n = 9;
document.write("Fibonacci number is " + Fib(n));
// This code is contributed by akshitsaxenaa09
</script>
时间复杂度: O(N)。
空间复杂度: O(N)。
对上述方法的优化
- 在上面的代码中,我们可以看到任何斐波那契数的当前状态仅取决于前两个值
- 因此,我们不需要存储大小为 n+1 的整个表,而是只存储前两个值
js
function Fibo(n) {
let prevPrev = 0, prev = 1, curr = 1;
// Using the bottom-up approach
for (let i = 2; i <= n; i++) {
curr = prev + prevPrev;
prevPrev = prev;
prev = curr;
}
return curr;
}
const n = 5;
console.log(Fibo(n));
记忆法和制表法的区别
制表和记忆是实现动态规划的两种技术。当存在重叠子问题(同一子问题执行多次)时,这两种技术都适用。以下是两种方法的概述。
记忆化:
- 自上而下的方法
- 将函数调用的结果存储在表中
- 递归实现
- 在需要时填充条目
制表:
- 自下而上的方法
- 将子问题的结果存储在表中
- 迭代实现
- 条目以自下而上的方式从最小大小填充到最终大小。
制表(自下而上) | 记忆 (自上而下) | |
---|---|---|
状态 | 状态转换关系很难思考 | 状态转换关系很容易想到 |
代码 | 当需要很多条件时,代码会变得复杂 | 通过修改底层递归解决方案,代码很容易编写。 |
速度 | 速度很快,因为我们没有递归调用开销。 | 由于大量递归调用而导致速度很慢。 |
子问题求解 | 如果所有子问题都必须至少解决一次,那么自下而上的动态规划算法肯定比自上而下的记忆算法好一个常数倍 | 如果子问题空间中的一些子问题根本不需要解决,那么记忆化解决方案的优点是只解决那些肯定需要的子问题 |
表条目 | 从第一个条目开始,所有条目都逐一填写 | 所有条目按需填写。 |
动态规划的优点
动态规划具有很多优点,包括:
-
避免多次重新计算相同的子问题,从而节省大量时间。
-
确保通过考虑所有可能的组合来找到最佳解决方案。
动态规划的应用
动态规划有广泛的应用,包括:
-
优化: 背包问题、最短路径问题、最大子阵问题
-
计算机科学: 最长公共子序列、编辑距离、字符串匹配
-
运筹学: 库存管理、调度、资源分配
动态规划和其他算法
动态规划只是递归吗?
动态规划和递归是完全不同的东西。虽然动态规划可以使用递归技术,但递归本身并不具有与动态规划相似的东西。动态规划涉及将问题分解为较小的子问题,存储这些子问题的解决方案以避免冗余计算,并使用这些解决方案构建整体解决方案。另一方面,递归是一种通过将问题分解为较小的子问题并以递归方式解决这些问题的技术。
贪婪算法与动态规划有何相似之处?
贪婪算法与动态规划相似,因为它们都是优化工具。动态规划和贪婪算法都用于优化问题。然而,动态规划将问题分解为较小的子问题并独立解决它们,而贪婪算法在每一步都做出局部最优选择,希望找到全局最优解。
总结:
动态规划算法的特点:对于任何问题,如果有一个简单的递归解,并且递归树有多次相同的递归调用(或重叠的子问题),我们就使用 DP。
理论不如实操,如果想更好地掌握动态规划的知识,可点击下面链接,参与动态规划的刷题活动,和动态规划握手言和吧。