一、前言
1、递归与动态规划
动态规划有时为什么被认为是一种与递归相反
的技术呢?
内容 | 方式 | 语法 | 效率 |
---|---|---|---|
递归 | 自上而下 | 简介 | 不高 |
动态规划 | 自下而上 | 稍微复杂 | 高 |
因为递归是从顶部开始将问题分解,通过解决掉所有分解出小问题的方式,来解决整个问题。
动态规划解决方案从底部开始解决问题,将所有小问题解决掉,然后合并成一个整体解决方案,从而解决掉整个大问题。
使用递归去解决问题虽然简洁,但效率不高。包括 JavaScript 在内的众多语言,不能高效地将递归代码解释为机器代码,尽管写出来的程序简洁,但是执行效率低下。
但这并不是说使用递归是件坏事,本质上说,只是那些指令式编程语言和面向对象的编程语言对递归 的实现不够完善,因为它们没有将递归作为高级编程的特性。
二、 斐波拉契数列
斐波拉契数列定义为以下序列:
0,1,1,2,3,5,8,13,21,34,55,......
可以看到,当 n >= 2,a(n) = a(n - 1) + a(n - 2)。这个数列的历史非常悠久,它是被公元700年一位意大利数学家斐波拉契用来描述在理想状态下兔子的增长情况。
不难看出,这个数列可以用一个简单的递归函数
表示。
function fibo (n) {
if (n <= 0) return 0;
if (n === 1) return 1;
return fibo(n - 1) + fibo(n - 2);
}
这种实现方式非常耗性能
,在n的数量级到达千级别,程序就变得特别慢,甚至失去响应。如果使用动态规划从它能解决的最简单子问题着手的话,效果就很不一样了。这里我们先用一个数组来存取每一次产生子问题的结果,方便后面求解使用。
function fibo (n) {
if (n <= 0) return 0;
if (n <= 1) return 1;
var arr = [0, 1];
for (var i = 2; i <= n; i++) {
arr[i] = arr[i - 1] + arr[i - 2];
}
return arr[n];
}
细心的同学发现,这里的数组可以去掉,换做局部变量来实现可以省下不少内存空间。
function fibo (n) {
if (n <= 0) return 0;
if (n <= 1) return 1;
var res, a = 0, b = 1;
for (var i = 2; i <= n; i++) {
res = a + b;
a = b;
b = res;
}
return res;
}
这里实现方式还有没有可能更简洁呢?答案是肯定的,我可以再节省一个变量。
function fibo (n) {
if (n <= 0) return 0;
if (n <= 1) return 1;
var a = 0, b = 1;
for (var i = 2; i <= n; i++) {
b = a + b;
a = b - a;
}
return b;
}
三、背包问题
1、问题描述
背包问题是算法研究中的一个经典问题。试想你是一个保险箱大盗,打开了一个装满奇珍异宝的保险箱,但是你必须将这些宝贝放入你的一个小背包中。保险箱中的物品规格和价值不同。你希望自己的背包装进的宝贝总价值最大。
当然,暴力计算可以解决这个问题,但是动态规划会更为有效。使用动态规划来解决背包问题的关键思路是计算装入背包的每一个物品的最大价值,直到背包装满。
如果在我们例子中的保险箱中有 5 件物品,它们的尺寸分别是 3、4、7、8、9,而它们的价值分别是 4、5、10、11、13,且背包的容积为 16,那么恰当的解决方案是选取第三件物品和第五件物品,他们的总尺寸是 16,总价值是 23。
2、代码实现
以下是使用 JavaScript 实现解决背包问题的动态规划方法:
function knapsackProblem(values, sizes, capacity) {
// 创建一个二维数组来存储中间结果
const dp = Array(values.length + 1).fill().map(() => Array(capacity + 1).fill(0));
// 遍历物品
for (let i = 1; i <= values.length; i++) {
for (let j = 0; j <= capacity; j++) {
// 如果当前物品的尺寸大于背包容量,不能放入,直接取上一个物品的结果
if (sizes[i - 1] > j) {
dp[i][j] = dp[i - 1][j];
} else {
// 否则可以选择放入或者不放入,取价值较大的情况
dp[i][j] = Math.max(dp[i - 1][j], values[i - 1] + dp[i - 1][j - sizes[i - 1]]);
}
}
}
// 回溯找出选择的物品
let result = [];
let i = values.length;
let j = capacity;
while (i > 0 && j > 0) {
if (dp[i][j]!== dp[i - 1][j]) {
result.push(i - 1);
j -= sizes[i - 1];
}
i--;
}
return { maxValue: dp[values.length][capacity], selectedItems: result.reverse() };
}
// 示例数据
const values = [4, 5, 10, 11, 13];
const sizes = [3, 4, 7, 8, 9];
const capacity = 16;
const result = knapsackProblem(values, sizes, capacity);
console.log('最大价值:', result.maxValue);
console.log('选择的物品索引:', result.selectedItems);
代码解析:
-
创建二维数组
dp
:dp[i][j]
表示考虑前i
个物品,背包容量为j
时的最大价值。- 初始化第一行和第一列都为 0,因为当没有物品可选择或者背包容量为 0 时,最大价值都是 0。
-
填充
dp
数组:- 对于每个物品
i
和每个背包容量j
:- 如果当前物品的尺寸
sizes[i - 1]
大于背包容量j
,那么不能选择这个物品,此时dp[i][j]
的值等于dp[i - 1][j]
,即不考虑当前物品时的最大价值。 - 如果当前物品的尺寸小于等于背包容量,那么可以选择放入或者不放入当前物品。选择放入时,价值为当前物品的价值
values[i - 1]
加上剩余容量下(即j - sizes[i - 1]
)不考虑当前物品时的最大价值dp[i - 1][j - sizes[i - 1]]
;不放入时,价值等于dp[i - 1][j]
。取两者中的较大值作为dp[i][j]
。
- 如果当前物品的尺寸
- 对于每个物品
-
回溯找出选择的物品:
- 从最后一个物品和背包满容量的状态开始回溯。
- 如果
dp[i][j]
的值不等于不考虑当前物品时的最大价值dp[i - 1][j]
,说明选择了当前物品,将当前物品的索引加入结果数组,并减少背包容量j
为j - sizes[i - 1]
。 - 然后继续回溯上一个物品,直到
i
为 0 或者j
为 0。
-
返回结果:
- 最终结果包括最大价值和选择的物品索引。
对于给定的问题,这个算法通过动态规划的方式计算出了在背包容量为 16 时,选择哪些物品可以获得最大价值。在这个过程中,通过填充二维数组和回溯的方式,清晰地展示了解决背包问题的思路。
3、优化
以上方法空间复杂度和时间复杂度是O(nm),其中 n 为物品个数,m 为背包容量。时间复杂度没有优化的余地了,但是空间复杂我们可以优化到O(m)。首先我们要改写状态转移方程:
f[w] = max{ f[w], f[w-w[i]]+v[i] }
进而实现我们的优化方案:
function knapsack (capacity, objectArr) {
var n = objectArr.length;
var f = [];
for (var w = 0; w <= capacity; w++) {
for (var i = 0; i < n; i++) {
if (w === 0) {
f[w] = 0;
} else if (objectArr[i].size <= w) {
var size = objectArr[i].size,
value = objectArr[i].value
f[w] = Math.max(f[w - size] + value, f[w] || 0);
} else {
f[w] = Math.max(f[w] || 0, f[w - 1]);
}
}
}
return f[capacity];
}