(第一次蹭热点,有点紧张。)
动态规划(Dynamic Programming,后文简称DP)可以说是面试中大家最头疼的问题之一了,动态规划本身又是很多大厂经常喜欢用的面试题。
实际上,在竞赛算法问题中,动态规划是属于相当友好的问题,因为动态规划问题本身相对来说更依赖套路,需要变通和灵感的成分比较低。我们本篇文章给大家讲解一组硬币问题,借此帮助大家熟悉动态规划的“套路”,让你以后面试中能够更好地应对这类问题。
第一题
有数目不限的1分,2分,5分的硬币,现要凑齐1元钱,有多少种凑法。
这个题是面试的经典题目,也是我非常喜欢用的一个题,它适合做面试题的原因是:它本身并不是完全依赖动态规划的题目,它有非常容易想到的平凡解法,这样,面试题就可以很好地提供区分度,不至于出现要么会要么不会的尴尬局面。
首先我们看平凡解,最容易想到的就是暴力计数,通过三重循环,数一数有多少种凑法。代码如下:
let r = 0;
for(let x = 0; x <= 100; x++)
for(let y = 0; y <= 50; y++)
for(let z = 0; z <= 20; z++)
if(x + 2 * y + 5 * z === 100)
++r;
console.log(r); //541
但是实际上这个平凡解,也有优化的空间,我们看,面值中有个非常特殊的值:1
,这个1
可以凑出任何数目的价值,而且有且仅有一种凑法,所以我们是否有必要针对这个1循环呢?答案当然是否定的。我们可以把代码这样写:
let r = 0;
for(let y = 0; y <= 50; y++)
for(let z = 0; z <= (100 - y * 2) / 5; z++)
++r;
console.log(r);
接下来,我们继续尝试优化这个平凡解,既然第二层循环,每次都必然加1,那么是否不需要循环,直接数出来呢?其实仔细看看这个循环,我们完全可以用除法搞定。最终代码如下:
let r = 0;
for(let y = 0; y <= 50; y++)
r += Math.floor((100 - y * 2) / 5) + 1;
console.log(r); //541
到这个程度,其实平凡解优化已经接近极限了,但是,如果我们不考虑数据的特殊性,把它推广到任意面值的若干硬币,是否有好的解法呢?我们看看第二题。
第二题 推广
有数目不限的硬币,其面值存储于数组values当中,现要凑齐价值n,问有多少种凑法。
作为第一题的推广,把常量变成变量,我们就没办法利用数据的特殊性了。
所有动态规划问题的一个通用思路,就是我们先把问题的规模缩小去思考,再考虑如何解决更大规模,一方面,我们可以通过解决小规模的问题,熟悉题目,积累经验,另一方面,小规模的问题的解,也可以作为更大规模问题的基础。
那么,我们首先考虑,数组values
长度为1
的情况,我们只有一种硬币就是values[0]
,我们再把n限定范围,如果n
的值小于values[0]
,那么显然就没有解,如果n
的值等于values[0]
,那么就有一种解。
我们继续尝试扩大n
的规模,我们不难发现values[0]
的倍数,解为1,否则解为0.
好了,这个结论看上去像是废话,很显然,又好像没什么用。但其实它正是开启问题更大规模的钥匙。
接下来我们看,假设数组values
长度为2
,会发生什么呢?
values[0]
的情况我们已经讨论过了,接下来,我们看看values[1]
,同样的套路,我们是否可以得出values[1]
的倍数,都有1种解呢?等等,我们这里似乎遗漏了什么,前面我们已经得出结论values[0]
的整数倍,已经有1种解了。
这里我们需要跳出固有思维框架,考虑下“整数倍”这个结论是怎么来的。
values[0]
的2倍,之所以有1种解,是在values[0]
的1倍有1种解的基础上,加了一个values[0]
得出来的。values[0]
的3倍,之所以有1种解,是在values[0]
的2倍有1种解的基础上,加了一个values[0]
得出来的……以此类推。
我们可以想象,values[0]
的倍数,是从前一个"1种解"出发,不断以values[0]
为步长,前往新节点。
而对values[1]
来说,场上已经有了若干"1种解"的节点,所以我们既可以从0出发,又可以从values[0]
的倍数出发,以values[1]
为步长前进。
那么问题来了,如果出现了重合,怎么办呢?这时候,"1种解"的节点,就变成了'2种解'的节点。
接下来,values[2]
到values[3]
就顺理成章了。
我们用这个思路,给大家看一段动画演示(可以自行修改参数查看):
最后我们写出代码:
function countCoins(values, n) {
let table = new Array(n + 1).fill(0);
table[0] = 1;
for(let value of values) {
for(let i = value; i <= n; i++)
table[i] += table[i - value];
}
return table[n];
}
console.log(countCoins([1, 2, 5], 100)); //541
第三题 约束
有若干的硬币,其面值存储于数组coins当中,以对象{value:number, count:number}表示其面值和数量,现要凑齐价值n,问有多少种凑法
好了,有了前面的基础,我们来看看,如果我们同时约束了硬币的数量,又该怎么解呢?
最容易想到的当然是跟之前的思路一样,从0出发。
但是代码写起来,我们就会发现一个问题,因为硬币数量有限制,当我们计算一个面值时,必须区分节点是否已经用过当前的面值,所以我们需要把之前的数组做一个快照。
那么,有没有更好的办法呢?
当然是,有。其实我们只要把循环变成倒序,那么更新后的节点就一定在身后,也就不用区分节点是否已经更新了。
最后代码如下:
function countCoins(coins, n) {
let table = new Array(n + 1).fill(0);
table[0] = 1;
for(let coin of coins) {
let {value, count} = coin;
for(let i = n; i >= 0; i--)
if(table[i] > 0)
for(let j = 1; j <= count && i + value * j <= n; j++)
table[i + value * j] += table[i];
}
return table[n];
}
console.log(countCoins([
{value:1, count:100},
{value:2, count:50},
{value:5, count:20}], 100));
第四题 变体
现有一堆硬币,欲将其分为两堆,使得两堆的差值尽量小,求其最小差值。
最后我们看看,这个硬币分堆问题。其实我们解决算法问题,在达到一定熟练度之后,往往难点并不在所谓的“动态规划”、“贪心”、“分治”等等的套路,而是在于对问题的抽象和理解。
分为两堆,使得两堆的差值尽量小这个问题,其实跟凑钱问题并没有什么区别。一堆硬币总价值确定,那么分两堆其实就是凑一个值,剩下的就是另一堆。要两堆的差值尽量小,那么就是希望凑出来的值,尽量接近总价值的1/2。
既然问题可以转换成凑钱问题,那么我们可以用第四题的套路,算出总价值的1/2以下的 所有能凑出来的值,然后找出最接近总价值的1/2的节点即可。
在上一题的基础上,我们几乎不用修改代码,就可以作为本题的答案。
function countCoins(coins) {
let total = ((coins) => {
let r = 0;
for(let coin of coins) {
let {value, count} = coin;
r += value * count;
}
return r;
})(coins);
let n = Math.floor(total / 2);
let table = new Array(n + 1).fill(false);
table[0] = true;
for(let coin of coins) {
let {value, count} = coin;
for(let i = n; i >= 0; i--)
if(table[i] > 0)
for(let j = 1; j <= count && i + value * j <= n; j++)
table[i + value * j] = true;
}
for(let i = n; i >= 0; i--){
if(table[i])
return total - 2 * i;
}
}
console.log(countCoins([
{value:2, count:1},
{value:5, count:5}])); //3
思考题
通过本文,大家应该已经学会了硬币问题的大概套路,但是本文所讲的问题,都只是计算了凑法,没有具体求出凑钱的方案,有兴趣的同学可以把这部分逻辑补充完,发到评论区大家一起交流。