动态规划,简称DP(Dynamic Programming),是求解最优化问题的一种常用策略,如找零钱,硬币个数最少,最大连续子序列和等。
通常的使用套路(一步一步优化)
1.暴力递归(自顶向下,出现了重叠 子问题)
2.记忆化搜索(自顶向下,缓存)
3.递推(自底向上)
举例:
假设有25分、20分、5分、1分的硬币,现要找给客户41分的零钱,如何办到硬币个数最少?
如果用贪心策略,每次都取最大的硬币,将会得到1枚25分、3枚5分、1枚1分,共5枚,而实际上最优解只需要2枚20分和1枚1分,共3枚,因此贪心策略得到的往往不是最优解。用动态规划则能得到最优解,动态规划很适合解决这种最优化的问题。
假设dp(41) = 凑到41分需要的最少硬币个数
dp(n) = 凑到n分需要的最少硬币个数。
如果第1次选择了25分的硬币,那么dp(n)=dp(n-25)+1
如果第1次选择了20分的硬币,那么dp(n)=dp(n-20)+1
如果第1次选择了5分的硬币,那么dp(n)=dp(n-5)+1
如果第1次选择了1分的硬币,那么dp(n)=dp(n-1)+1
4种情况都有可能,取一个最小的,所以dp(n) = min{dp(n-25),dp(n-20),dp(n-5),dp(n-1)}+1
动态规划考虑了所有的情况,所以能求最优解,贪心只看眼前的局部利益,这就是贪心的问题所在。
1.暴力递归
function coins(n){
if(n < 1) return Number.MAX_VALUE;
if(n == 25 || n == 20 || n == 5 || n == 1) return 1;
return Math.min(coins(n - 25), coins(n - 20), coins(n - 5), coins(n - 1)) + 1;
}
类似于斐波那契数列的递归版,会有大量的重复计算,时间复杂度较高。
2.记忆化搜索
function coins(n) {
//dp[n]代表凑够n分需要的最少硬币个数
if (n < 1) return -1;//排除不合理的情况
let dp = new Array(n + 1).fill(0);//长度是n+1,才能放到dp[n],初始化均为0
// dp[25] = dp[20] = dp[5] = dp[1] = 1; 这样写不好,n可能是小于20的,dp[20]会越界在其他语言中。
let faces = [1, 5, 20, 25];
for (let i = 0; i < faces.length; i++) {//这样写更安全,不越界
if (n < faces[i]) break;
dp[faces[i]] = 1;
}
return change(n, dp)
function change(n, dp) {
if (n < 1) return Number.MAX_VALUE;//为递归基服务
if (dp[n] == 0) { // 没有缓存过,需要计算
let min1 = Math.min(change(n - 25, dp), change(n - 20, dp));
let min2 = Math.min(change(n - 5, dp), change(n - 1, dp));
dp[n] = Math.min(min1, min2) + 1;
}
return dp[n]; //缓存过,直接取缓存的数据
}
}
记忆化搜索,虽节省了重复计算,但还是有一定的栈空间开销。
3.递推
function coins(n) {
if (n < 1) return -1;
let dp = new Array(n + 1).fill(0);
for (let i = 1; i <= n; i++) {
let min = dp[i - 1];
if (i >= 5) min = Math.min(dp[i - 5], min);
if (i >= 20) min = Math.min(dp[i - 20], min);
if (i >= 25) min = Math.min(dp[i - 25], min);
dp[i] = min + 1;//巧妙地利用数组初始化值0,刚好实现i = 1,5,20,25时是1
}
return dp[n];
}
时间复杂度、空间复杂度O(n)。
思考题:求出找零钱的具体方案,即都有哪些硬币。
function coins(n) {
if (n < 1) return -1;
//dp[n]代表凑够n分需要的最少硬币个数
let dp = new Array(n + 1).fill(0);
// faces[n] 代表凑够n分钱的最后选择的那枚硬币的面值
let faces = new Array(n + 1).fill(0)
for (let i = 1; i <= n; i++) {
let min = Number.MAX_VALUE;
if (i >= 1 && dp[i - 1] < min) {
min = dp[i - 1];
faces[i] = 1;
}
if (i >= 5 && dp[i - 5] < min) {
min = dp[i - 5];
faces[i] = 5;
}
if (i >= 20 && dp[i - 20] < min) {
min = dp[i - 20];
faces[i] = 20;
}
if (i >= 25 && dp[i - 25] < min) {
min = dp[i - 25];
// 能来这里,说明最少硬币数,选择的最后一枚是25
faces[i] = 25;
}
printfc(faces, i);
dp[i] = min + 1;
}
// printfc(faces, n);
return dp[n]
}
function printfc(faces, n) {
let temp = n, str = '';
while (n > 0) {
str += ' ' + faces[n];
n -= faces[n]
}
str = "[" + temp + "] = " + str;
console.log(str)
}
通用方案,可以自定义硬币面值传参。
function coins(n, faces = [1, 5, 20, 25]) {
if (n < 1) return -1;
let dp = new Array(n + 1).fill(0);
let fc = new Array(n + 1).fill(0);
for (let i = 1; i <= n; i++) {
let min = Number.MAX_VALUE;
for (let j = 0; j < faces.length; j++) {
if (i >= faces[j] && dp[i - faces[j]] < min) {
min = dp[i - faces[j]]
fc[i] = faces[j]
}
}
dp[i] = min + 1;
// printfc(fc, i)
}
printfc(fc, n)
return dp[n];
}
function printfc(faces, n) {
let temp = n, str = '';
while (n > 0) {
str += ' ' + faces[n];
n -= faces[n]
}
str = "[" + temp + "] = " + str;
console.log(str)
}
通过解这道题,刷新了我对数组的认知,原来数组的索引下标不只是代表顺序,还可以有业务含义,数组的遍历不只是按+1或-1的顺序遍历,还可以根据业务需要跳着遍历。
趁热打铁,做下力扣上的找硬币题。
var coinChange = function(coins, amount) {
if (amount == 0) return 0;
let dp = new Array(amount + 1).fill(0);
for (let i = 1; i <= amount; i++) {
let min = Number.MAX_VALUE;
for (let j = 0; j < coins.length; j++) {
if (i >= coins[j] && dp[i - coins[j]] >= 0) {
min = Math.min(dp[i - coins[j]], min)
}
}
if (min == Number.MAX_VALUE) { // 如果没有任何一种硬币组合能组成总金额,返回 -1。
dp[i] = -1
}else {// 能凑到,则正常返回
dp[i] = min + 1
}
}
return dp[amount]
};