动态规划 - 找零钱问题

14

我正在复习一些算法课程的老笔记,动态规划问题对我来说似乎有点棘手。 我有一个问题,我们有无限多个硬币,其面值为x1,x2,... xn,我们想要为某个价值X找零。 我们试图设计一个动态程序来确定是否可以找到X的零钱(不是最小化硬币数量或返回哪些硬币,只需true或false)。

我已经思考了这个问题,并且可以看到一种递归方法可以解决这个问题,大致像是......

MakeChange(X, x[1..n this is the coins])
    for (int i = 1; i < n; i++)
    {
        if ( (X - x[i] ==0) || MakeChange(X - x[i]) )
            return true;
    }
    return false;

我很难将此转换为动态程序。 我应该如何处理?


2
忽略 Heath,这是 dp。 - Timmy
7个回答

12

你的代码已经是一个很好的开始。将递归解法转化为动态规划解法通常采用“自底向上”而不是“自顶向下”的方式进行。也就是说,如果你的递归解法使用较小 x 的值来计算某个 X 的值,那么可以修改成从更小的 x 开始计算相同的值,并将结果存入表格中。

在你的情况下,将你的 MakeChange 递归函数改成 canMakeChange 表格即可。

canMakeChange[0] = True
for X = 1 to (your max value):
   canMakeChange[X] = False
   for i=1 to n:
     if X>=x[i] and canMakeChange[X-x[i]]==True: 
       canMakeChange[X]=True

3
@Heath 不是真的。上述算法最初仅对0返回true。然后当硬币中的一个(X - xi) = 9时,它将返回true。这些结果会从下往上逐步累加。这就是动态规划的核心思想。 - Tony
2
好的,我明白你的意思。但这也不是动态规划。相关问题“你要找回哪些硬币作为找零”不能用这种方式解决。我原以为你也想解决那个问题。对于任何包含x[n]=1的货币,它是一个更简单的问题;返回true。 - Heath Hunnicutt
2
这是什么让它不是动态规划?当你解决矩阵乘法问题时,很多时候你只需要返回最小的乘法次数。这仍然是最真实的动态规划,不是吗? - Tony
1
在我看来,使得这种方法不是动态规划的特定质量是所有中间结果都被计算了,而不仅仅是通过追求最优子结果的“路径”形成的子集;因为在这种实现中实际上没有“最优”子结果的概念。例如,如果可以用四个25分硬币、十个10分硬币、二十个5分硬币或一百个1分硬币进行找零,则在该算法返回之前将计算所有这些可能性。 - Heath Hunnicutt
我知道这个答案很古老,但如果有人对它的复杂度感到好奇,给定目标值v和一组d硬币面额,该算法的时间复杂度为O(v*d),并且它使用了O(v)的额外空间。@Heath Hunnicutt说得有道理,你可以通过提前返回来进一步优化这种方法,例如,在canMakeChange[X]=True之后,你可以添加一个额外的条件if (X==v) return true。优化版本将在遇到所有可能的解中的第一个时停止,即使这不是最聪明的找零方式(例如,使用一百个便士找零1英镑)。 - Anthony Accioly
显示剩余2条评论

4

我提供的解决方案是一种贪婪方法,计算所有解决方案并缓存最新的最优解。如果当前执行的方案已经大于缓存的方案,则中止该路径。请注意,为了获得最佳性能,货币面额应该按降序排列。

import java.util.ArrayList;
import java.util.List;

public class CoinDenomination {

    int denomination[] = new int[]{50,33,21,2,1};
    int minCoins=Integer.MAX_VALUE;
    String path;

    class Node{
        public int coinValue;
        public int amtRemaining;
        public int solutionLength;
        public String path="";
        public List<Node> next;

        public String toString() { return "C: "+coinValue+" A: "+amtRemaining+" S:"+solutionLength;}
    }

    public List<Node> build(Node node)
    {
        if(node.amtRemaining==0)
        {
            if (minCoins>node.solutionLength) {
                minCoins=node.solutionLength;
                path=node.path;
            }
            return null;
        }

        if (node.solutionLength==minCoins) return null;
        List<Node> nodes = new ArrayList<Node>();
        for(int deno:denomination)
        {
           if(node.amtRemaining>=deno)
           {
               Node nextNode = new Node();
               nextNode.amtRemaining=node.amtRemaining-deno;
               nextNode.coinValue=deno;
               nextNode.solutionLength=node.solutionLength+1;
               nextNode.path=node.path+"->"+deno;
               System.out.println(node);
               nextNode.next = build(nextNode);
               nodes.add(node);

           }
        }

        return nodes;
    }

    public void start(int value)
    {
        Node root = new Node();
        root.amtRemaining=value;
        root.solutionLength=0;
        root.path="start";
        root.next=build(root);
        System.out.println("Smallest solution of coins count: "+minCoins+" \nCoins: "+path);
    }

    public static void main(String args[])
    {
        CoinDenomination coin = new CoinDenomination();
        coin.start(35);
    }
}

2

只需要在递归解决方案中添加一个记忆化步骤,就可以得到动态规划算法。以下示例使用Python编写:

cache = {}
def makeChange(amount, coins):
    if (amount,coins) in cache:
        return cache[amount, coins]
    if amount == 0:
        ret = True
    elif not coins:
        ret = False
    elif amount < 0:
        ret = False 
    else:
        ret = makeChange(amount-coins[0], coins) or makeChange(amount, coins[1:])
    cache[amount, coins] = ret
    return ret

当然,你可以使用装饰器来自动进行记忆化,从而使代码更加自然:
def memoize(f):
    cache = {}
    def ret(*args):
        if args not in cache:
            cache[args] = f(*args)
        return cache[args]
    return ret
@memoize
def makeChange(amount, coins):
    if amount == 0:
        return True
    elif not coins:
        return False
    elif amount < 0:
        return False
    return makeChange(amount-coins[0], coins) or makeChange(amount, coins[1:])

注意:即使您发布的非动态编程版本也存在各种边缘情况的错误,这就是为什么上面的makeChange比您的代码略长的原因。

它仍然存在一个错误:如果你传递硬币作为列表而不是元组,你会得到一个“TypeError: unhashable type”的错误。 - dan04
5
这不是程序漏洞,而是一种特性(如果你把“amount”作为字符串传入,同样会得到异常)。当你传入错误的数据类型时出现类型错误是一种特性,而非漏洞。 - moshez

1
这里是 C# 版本,仅供参考,用于查找给定金额所需的最小硬币数量:
(可以参考我的博客@http://codingworkout.blogspot.com/2014/08/coin-change-subset-sum-problem-with.html 获取更多详细信息)
public int DP_CoinChange_GetMinimalDemoninations(int[] coins, int sum)
        {
            coins.ThrowIfNull("coins");
            coins.Throw("coins", c => c.Length == 0 || c.Any(ci => ci <= 0));
            sum.Throw("sum", s => s <= 0);
            int[][] DP_Cache = new int[coins.Length + 1][];
            for (int i = 0; i <= coins.Length; i++)
            {
                DP_Cache[i] = new int[sum + 1];
            }
            for(int i = 1;i<=coins.Length;i++)
            {
                for(int s=0;s<=sum;s++)
                {
                    if (coins[i - 1] == s)
                    {
                        //k, we can get to sum using just the current coin
                        //so, assign to 1, no need to process further
                        DP_Cache[i][s] = 1;
                    }
                    else 
                    {
                        //initialize the value withouth the current value
                        int minNoOfCounsWithoutUsingCurrentCoin_I = DP_Cache[i - 1][s];
                        DP_Cache[i][s] = minNoOfCounsWithoutUsingCurrentCoin_I;
                        if ((s > coins[i - 1]) //current coin can particiapte
                            && (DP_Cache[i][s - coins[i - 1]] != 0))
                        {
                            int noOfCoinsUsedIncludingCurrentCoin_I = 
                                DP_Cache[i][s - coins[i - 1]] + 1;
                            if (minNoOfCounsWithoutUsingCurrentCoin_I == 0)
                            {
                                //so far we couldnt identify coins that sums to 's'
                                DP_Cache[i][s] = noOfCoinsUsedIncludingCurrentCoin_I;
                            }
                            else
                            {   
                                int min = this.Min(noOfCoinsUsedIncludingCurrentCoin_I, 
                                    minNoOfCounsWithoutUsingCurrentCoin_I);
                                DP_Cache[i][s] = min;
                            }
                        }
                    }
                }
            }
            return DP_Cache[coins.Length][sum];
        }

1
在一般情况下,当硬币的价值可以是任意值时,您所提出的问题被称为背包问题,并已知属于NP完全问题(Pearson, D. 2004),因此不能像动态规划那样在多项式时间内解决。

以x [2] = 51,x [1] = 50,x [0] = 1,X = 100为病态例子。然后需要算法“考虑”使用硬币x [2]进行更改的可能性,或者从x [1]开始进行更改。第一步使用国家货币,也称为贪心算法 - “使用小于工作总数的最大硬币”,将无法处理病态硬币。相反,这些算法经历了组合爆炸,使它们成为NP完全问题。

对于某些特殊的硬币价值安排,例如实际使用中的几乎所有硬币价值安排,包括虚构系统X [i + 1] == 2 * X [i],有非常快速的算法,甚至在虚构情况下是O(1),以确定最佳输出。这些算法利用硬币价值的属性。

我不知道有一个动态规划的解决方案:利用编程模式所需的最优子方案。一般情况下,只有当问题可以分解为子问题时,才能通过动态规划来解决。当这些子问题被最优地解决后,它们可以重新组合成可证明最优的解决方案。也就是说,如果程序员无法在数学上证明重新组合问题的最优子解会导致最优解,则不能应用动态规划。

动态规划的一个常见例子是将多个矩阵相乘的应用。根据矩阵的大小,选择计算A·B·C作为两个等价形式之一:((A·BC)或(A·(B·C)),将导致不同数量的乘法和加法计算。也就是说,一种方法比另一种方法更优(更快)。 动态规划是一种模式,它记录不同方法的计算成本,并根据在运行时动态计算的时间表(或程序)执行实际计算。

一个关键特点是按照计算的时间表执行计算,而不是通过枚举所有可能的组合来执行计算——无论是递归还是迭代地执行枚举。在矩阵乘法的例子中,在每个步骤中,只选择最小成本的乘法。因此,从未计算过中间成本次优时间表的可能成本。换句话说,时间表不是通过搜索所有可能的时间表来计算最优时间表,而是通过从零开始逐步构建最优时间表来计算的。
“动态规划”这个术语可以与“线性规划”进行比较,在其中“程序”也被用于表示“调度”的意义。
要了解更多关于动态规划的信息,请参考我所知道的最好的算法书籍《算法导论》(Cormen、Leiserson、Rivest和Stein)。 "Rivest" 是 "RSA" 的 "R",动态规划只是其中的一章。

Cover of the recommended book.


3
你说的很多内容都很有用并且正确。然而,决定是否可以进行更改的行为肯定具有最优子结构。如果我能将我的分母X分解成因数,那么这些因数的解决方案放在一起就会得出我的解决方案。是的,背包问题是NP完全问题,但这个问题更加具体,不是吗? - Tony
因子是用于乘法的,对吧?这是减法,当我们找零时,我想。如果我欠你一美元,然后我付给你0.25美元,那么我现在欠你0.75美元(减法),还是现在我欠你一个无量纲的数字“4”(除法)?我认为我们想要的是减法,而不是因子。 - Heath Hunnicutt
你没有深入探究我的示例,那么你的程序如何知道如何找零100元,如果这些是有效的选择:51+1+1+...(49次);50+50?但是,如果输入是51,我们确实想选择面值为51的硬币。我们必须检查所有可能性,或者至少检查其中许多可能性。 - Heath Hunnicutt
如果“或”运算符进行短路计算,那么你最初的实现方式就是“贪心算法”,我认为。 - Heath Hunnicutt
1
有趣的想法。就我个人的知识而言,我会称他的解决方案为动态规划。但是,我还有很多东西要学习。感谢这次讨论。它真的让我思考了DP的问题。 - Tony
显示剩余13条评论

1

这篇论文非常相关:http://ecommons.library.cornell.edu/handle/1813/6219

基本上,就像其他人所说的那样,使用任意面额集合进行最优找零总数为X是NP难问题,这意味着动态规划无法产生及时的算法。该论文提出了一个多项式时间(也就是输入大小的多项式级别,这是对以前算法的改进)的算法,用于确定贪心算法是否总是针对给定的面额集合产生最优结果。


0

如果你以递归的方式编写代码,那么可以使用基于内存的搜索。你需要存储已经计算过的结果,这样就不会重复计算了。

int memory[#(coins)]; //initialize it to be -1, which means hasn't been calculated
MakeChange(X, x[1..n this is the coins], i){
    if(memory[i]!=-1) return memory[i];
    for (int i = 1; i < n; i++)
    {
        if ( (X - x[i] ==0) || MakeChange(X - x[i], i) ){
            memory[i]=true;
            return true;
        }
    }
    return false;
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接