关于硬币的几个动态规划问题

16,195 阅读2分钟

(第一次蹭热点,有点紧张。)

动态规划(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]就顺理成章了。

我们用这个思路,给大家看一段动画演示(可以自行修改参数查看):

output.jsbin.com/kasodafifu

最后我们写出代码:

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

思考题

通过本文,大家应该已经学会了硬币问题的大概套路,但是本文所讲的问题,都只是计算了凑法,没有具体求出凑钱的方案,有兴趣的同学可以把这部分逻辑补充完,发到评论区大家一起交流。