硬币找零(动态编程)

Posted

技术标签:

【中文标题】硬币找零(动态编程)【英文标题】:Coin change(Dynamic programming) 【发布时间】:2017-08-14 06:54:56 【问题描述】:

我有一个关于硬币找零问题的问题,我们不仅要打印用给定硬币面额例如 1,5,10,25 改变 $n 的方式的数量,还要打印方式

例如,如果目标 = $50,而硬币是1,5,10,25,那么实际使用硬币获得目标的方法是

2 × 25 美元 1 × $25 + 2 × $10 + 1 × $5 等

解决这个问题的最佳时间复杂度是多少? 我尝试修改硬币找零问题的动态规划解决方案,我们只需要方式的数量而不需要实际的方式

我无法计算出时间复杂度。 我确实使用了记忆,这样我就不必为给定的硬币和总和值再次解决相同的问题,但我们仍然需要遍历所有解决方案并打印它们。所以时间复杂度肯定大于 O(ns),其中 n 是硬币的数量,s 是目标 它是指数的吗?任何帮助将不胜感激

【问题讨论】:

当我在我最喜欢的搜索引擎中输入“硬币问题复杂性”时,我得到的结果包含可能的动态规划解决方案以及有关问题时间复杂度的信息。打印结果通常应在与查找结果相同的复杂度上限内。因此,如果您在 O(ns) 内找到结果,那么您应该能够在 O(ns) 内打印它,除非您选择了比实际结果更复杂的打印表示。所以在我们讨论输出复杂度之前,你必须指定输出格式。 您正在寻找为什么它是指数的证据? @grek40:回复:“打印结果通常应该在与查找结果相同的复杂度上限内”:当然,但是查找和打印 k 的复杂性不同值通常会比简单计算 k 的复杂度高很多 ways to change $n, n is the number of coins, s is the target 我希望 n 根本没有改变含义,但是:不同面额的数量硬币数量(可用),完全按面额,还是别的什么? @user12331 我更改了答案中的算法。我希望它使推理更容易理解。 【参考方案1】:

打印组合

def coin_change_solutions(coins, S):
  # create an S x N table for memoization
  N = len(coins)
  sols = [[[] for n in xrange(N + 1)] for s in xrange(S + 1)]
  for n in range(0, N + 1):
    sols[0][n].append([])

  # fill table using bottom-up dynamic programming
  for s in range(1, S+1):
    for n in range(1, N+1):
      without_last = sols[s][n - 1]
      if (coins[n - 1] <= s):
          with_last = [list(sol) + [coins[n-1]] for sol in sols[s - coins[n - 1]][n]]
      else:
          with_last = []
      sols[s][n] = without_last + with_last

  return sols[S][N]


print coin_change_solutions([1,2], 4)
# => [[1, 1, 1, 1], [1, 1, 2], [2, 2]]

没有:我们不需要使用最后一枚硬币来计算总和。通过递归到solution[s][n-1],可以直接找到所有硬币解决方案。我们将所有这些硬币组合复制到with_last_sols

with:我们确实需要使用最后一个硬币。所以那个硬币必须在我们的解决方案中。剩余的硬币通过sol[s - coins[n - 1]][n]递归找到。阅读此条目将为我们提供许多可能的选择,以了解剩余的硬币应该是什么。对于每个可能的选择sol,我们附加最后一个硬币coin[n - 1]

# For example, suppose target is s = 4
# We're finding solutions that use the last coin.
# Suppose the last coin has a value of 2:
#
# find possible combinations that add up to 4 - 2 = 2: 
# ===> [[1,1], [2]] 
# then for each combination, add the last coin 
# so that the combination adds up to 4)
# ===> [[1,1,2], [2,2]]

通过获取第一种情况和第二种情况的组合并将两个列表连接起来,可以找到最终的组合列表。

without_last_sols = [[1,1,1,1]]
with_last_sols = [[1,1,2], [2,2]]
without_last_sols + with_last_sols = [[1,1,1,1], [1,1,2], [2,2]]

时间复杂度

在最坏的情况下,我们有一个包含从 1 到 n 的所有硬币的硬币组:硬币 = [1,2,3,4,...,n] – 可能的硬币和组合的数量,num solutions,等于 s 的 integer partitions 的数量,p(s )。 可以看出整数分区的数量p(s)增长了exponentially。 因此 num 个解决方案 = p(s) = O(2^s)。任何解决方案都必须至少拥有这个,以便它可以打印出所有这些可能的解决方案。因此,这个问题本质上是指数级的。

我们有两个循环:一个循环用于 s,另一个循环用于 n。 对于每个 s 和 n,我们计算 sols[s][n]

没有:我们查看sol[s - coins[n - 1]][n] 中的 O(2^s) 组合。对于每个组合,我们在 O(n) 时间内复制它。所以总的来说这需要: O(n×2^s) 时间。 with:我们查看sol[s][n] 中的所有 O(2^s) 组合。对于每个组合列表sol,我们在 O(n) 时间内创建该新列表的副本,然后附加最后一个硬币。总的来说,这种情况需要 O(n×2^s)。

因此时间复杂度为 O(s×n)×O(n2^s + n2^s) = O(s×n^2×2^s)。


空间复杂性

空间复杂度为 O(s×n^2×2^s) 因为我们有一个 s×n 表 每个条目存储 O(2^s) 个可能的组合,(例如[[1, 1, 1, 1], [1, 1, 2], [2, 2]]),每个组合(例如[1,1,1,1])占用 O(n) 个空间。

【讨论】:

这不是最佳解决方案。此外,您还使用 Python 指令一遍又一遍地构建数组。虽然这是一条 Python 指令,但它在内部进行迭代,这增加了时间复杂度。【参考方案2】:

我倾向于做的是递归解决问题,然后从那里构建一个记忆解决方案。

从递归开始,方法很简单,从目标中减去硬币,不要选硬币。

当你选择一枚硬币时,你将它添加到一个向量或你的列表中,当你不选择一个时,你会弹出你之前添加的那个。代码看起来像:

void print(vector<int>& coinsUsed)

    for(auto c : coinsUsed)
    
        cout << c << ",";
    
    cout << endl;


int helper(vector<int>& coins, int target, int index, vector<int>& coinsUsed)

    if (index >= coins.size() || target < 0) return 0;

    if (target == 0)
    
        print(coinsUsed);
        return 1;
    

    coinsUsed.push_back(coins[index]);
    int with = helper(coins, target - coins[index], index, coinsUsed);

    coinsUsed.pop_back();
    int without = helper(coins, target, index + 1, coinsUsed);

    return with + without;


int coinChange(vector<int>& coins, int target)

    vector<int> coinsUsed;
    return helper(coins, target, 0, coinsUsed);

你可以这样称呼它:

vector<int> coins = 1,5,10,25;
cout << "Total Ways:" << coinChange(coins, 10);

因此,这为您提供了到达存储在coinsUsed 中的目标的全部方式以及过程中使用的硬币,您现在可以通过将传入的值存储在缓存中来随意记住这一点。

递归解的时间复杂度是指数级的。

链接到正在运行的程序:http://coliru.stacked-crooked.com/a/5ef0ed76b7a496fe

【讨论】:

这是backtracking 不是动态编程。正如你所指出的,没有记忆(没有缓存)——所以你将一遍又一遍地解决相同的子问题(这个问题确实有重叠的子问题)。除了要在回溯树的叶子中打印出指数数量的解决方案之外,您还必须处理指数数量的递归调用才能到达这些叶子节点。 @James Lawson 我很理解这一点,我确实提到它可以被记忆以达到最佳解决方案。通常直接从 DP 解决方案开始通常并不直观,除非您可以熟练地定义子问题。人们倾向于记住解决方案,然后在遇到问题的变体时失败。【参考方案3】:

令 d_i 为面额,即硬币的美分价值。在您的示例中 d_i = 1, 5, 10, 25。 设 k 为面额(硬币)的数量,这里 k = 4。

我们将使用二维数组 numberOfCoins[1..k][0..n] 来确定进行更改所需的最小 数量 硬币。最优解由下式给出:

numberOfCoins[k][n] = min(numberOfCoins[i − 1][j], numberOfCoins[i][j − d_i] + 1)

上面的等式代表了这样一个事实,即我们要么不使用 d_i,所以我们需要使用较小的硬币(这就是下面 i 递减的原因):

numberOfCoins[i][j] = numberOfCoins[i − 1][j]   // eq1

或者我们使用 d_i,所以我们将所需的硬币数量 +1 并减少 d_i(我们刚刚使用的硬币的价值):

numberOfCoins[i][j] = numberOfCoins[i][j − d_i] + 1   // eq2

时间复杂度为 O(kn),但在 k 很小的情况下,如您的示例中的情况,我们有 O(4n) = O(n)。

我们将使用另一个与 numberOfCoins 具有相同维度的二维数组 coinUsed 来标记使用了哪些硬币。每个条目都会通过在该位置设置一个“^”来告诉我们没有使用 coinUsed[i][j] 中的硬币(这对应于 eq1)。或者我们通过在该位置设置一个“

两个数组都可以在算法运行时构建。我们只会在内循环中有更多的指令,因此构建两个数组的时间复杂度仍然是 O(kn)。

要打印我们需要迭代的解决方案,在最坏的情况下超过 k + n+1 个元素。例如,当最优解使用所有 1 美分面额时。但请注意,打印是在构建后完成的,因此总体时间复杂度为 O(kn) + O(k + n+1)。如前所述,如果 k 很小,则复杂度为 O(kn) + O(k + n+1) = O(kn) + O(n+1) = O(kn) + O(n) = O((k +1)n) = O(n)。

【讨论】:

我修正了最后一段中的一个错误。 我不明白这是如何找到所有组合的。据我所知,这只会找到 one 组合。最后,一旦你构建了coinUsed,你将从右下角开始,按照每个 一个 组合,对吗?你能举一个 coinUsed 表的例子或给出一些伪代码吗?

以上是关于硬币找零(动态编程)的主要内容,如果未能解决你的问题,请参考以下文章

硬币变化(动态编程)

动态规划-硬币找零

硬币找零问题的动态规划实现

最少硬币找零问题-动态规划

动态规划——硬币找零

11.动态规划——找零问题