8-2 找零

找零问题(Making Change / Coin Change)

找零问题是经典的动态规划(Dynamic Programming)问题。给定一组硬币面额(coin denominations)和一个目标金额(target amount),求用最少的硬币凑出该金额。例如:硬币面额为 {1, 5, 10, 25},金额为 30,则最少需要 2 枚硬币(25 + 5)。

找零问题有两个常见变体:

  1. 最少硬币数(优化问题):求凑出目标金额所需的最少硬币数量
  2. 组合数(计数问题):求凑出目标金额共有多少种不同的方式

贪心法(Greedy) — 仅适用于特定币种

贪心法(Greedy Algorithm)的核心思想是:每次选择面额最大的硬币,直到凑满目标金额。对于标准的美分系统 {1, 5, 10, 25},贪心法可以得到最优解。但对于任意面额的硬币组合,贪心法可能失败。

反例:硬币 = {1, 3, 4},金额 = 6。

  • 贪心法:先选 4,剩余 2,再选两个 1,共 3 枚(4 + 1 + 1)
  • 最优解:选两个 3,共 2 枚(3 + 3)

因此,贪心法仅在特定币种系统(canonical coin systems)下有效,不具有通用性。

C++ 实现

#include <iostream>
#include <vector>
#include <algorithm>

int greedyChange(const std::vector<int>& coins, int amount)
{
    // sort coins in descending order
    std::vector<int> sorted = coins;
    std::sort(sorted.begin(), sorted.end(), std::greater<int>());

    int count = 0;
    int remaining = amount;

    for (int coin : sorted)
    {
        // use as many of this coin as possible
        while (remaining >= coin)
        {
            remaining -= coin;
            count++;
        }
    }

    // if remaining > 0, change cannot be made
    if (remaining > 0)
    {
        return -1;
    }

    return count;
}

int main()
{
    std::vector<int> coins = {1, 5, 10, 25};
    int amount = 30;

    int result = greedyChange(coins, amount);
    std::cout << "Greedy: minimum coins for " << amount
              << " = " << result << "\n";

    // counterexample: greedy fails
    std::vector<int> coins2 = {1, 3, 4};
    int amount2 = 6;
    int greedyResult = greedyChange(coins2, amount2);
    std::cout << "Greedy for {1,3,4} amount=" << amount2
              << " = " << greedyResult << " (optimal is 2)\n";

    return 0;
}

运行该程序将输出

Greedy: minimum coins for 30 = 2
Greedy for {1,3,4} amount=6 = 3 (optimal is 2)

C 实现

#include <stdio.h>
#include <stdlib.h>

// comparison function for descending sort
int cmpDesc(const void* a, const void* b)
{
    return *(int*)b - *(int*)a;
}

int greedyChange(int coins[], int n, int amount)
{
    // sort coins in descending order
    qsort(coins, n, sizeof(int), cmpDesc);

    int count = 0;
    int remaining = amount;

    for (int i = 0; i < n; i++)
    {
        // use as many of this coin as possible
        while (remaining >= coins[i])
        {
            remaining -= coins[i];
            count++;
        }
    }

    // if remaining > 0, change cannot be made
    if (remaining > 0)
    {
        return -1;
    }

    return count;
}

int main()
{
    int coins1[] = {1, 5, 10, 25};
    int amount1 = 30;
    int result1 = greedyChange(coins1, 4, amount1);
    printf("Greedy: minimum coins for %d = %d\n", amount1, result1);

    // counterexample: greedy fails
    int coins2[] = {1, 3, 4};
    int amount2 = 6;
    int greedyResult = greedyChange(coins2, 3, amount2);
    printf("Greedy for {1,3,4} amount=%d = %d (optimal is 2)\n",
           amount2, greedyResult);

    return 0;
}

运行该程序将输出

Greedy: minimum coins for 30 = 2
Greedy for {1,3,4} amount=6 = 3 (optimal is 2)

Python 实现

def greedy_change(coins, amount):
    # sort coins in descending order
    sorted_coins = sorted(coins, reverse=True)

    count = 0
    remaining = amount

    for coin in sorted_coins:
        # use as many of this coin as possible
        while remaining >= coin:
            remaining -= coin
            count += 1

    # if remaining > 0, change cannot be made
    if remaining > 0:
        return -1

    return count


coins1 = [1, 5, 10, 25]
amount1 = 30
print(f"Greedy: minimum coins for {amount1} = {greedy_change(coins1, amount1)}")

# counterexample: greedy fails
coins2 = [1, 3, 4]
amount2 = 6
greedy_result = greedy_change(coins2, amount2)
print(f"Greedy for {{1,3,4}} amount={amount2} = {greedy_result} (optimal is 2)")

运行该程序将输出

Greedy: minimum coins for 30 = 2
Greedy for {1,3,4} amount=6 = 3 (optimal is 2)

Go 实现

package main

import (
	"fmt"
	"sort"
)

func greedyChange(coins []int, amount int) int {
	// sort coins in descending order
	sort.Sort(sort.Reverse(sort.IntSlice(coins)))

	count := 0
	remaining := amount

	for _, coin := range coins {
		// use as many of this coin as possible
		for remaining >= coin {
			remaining -= coin
			count++
		}
	}

	// if remaining > 0, change cannot be made
	if remaining > 0 {
		return -1
	}

	return count
}

func main() {
	coins1 := []int{1, 5, 10, 25}
	amount1 := 30
	fmt.Printf("Greedy: minimum coins for %d = %d\n", amount1,
		greedyChange(coins1, amount1))

	// counterexample: greedy fails
	coins2 := []int{1, 3, 4}
	amount2 := 6
	greedyResult := greedyChange(coins2, amount2)
	fmt.Printf("Greedy for {1,3,4} amount=%d = %d (optimal is 2)\n",
		amount2, greedyResult)
}

运行该程序将输出

Greedy: minimum coins for 30 = 2
Greedy for {1,3,4} amount=6 = 3 (optimal is 2)

Go 使用 sort.Reverse(sort.IntSlice(...)) 实现降序排序,简洁直观。sort.IntSlice 将切片包装为可排序接口,sort.Reverse 反转排序方向。注意 greedyChange 函数会修改传入的切片顺序。


动态规划法 — 最少硬币数

动态规划(Dynamic Programming,简称 DP)是解决找零问题的标准方法。核心思路是将问题分解为子问题,并通过记忆化避免重复计算。

定义状态转移方程(state transition equation):

  • dp[i] 表示凑出金额 i 所需的最少硬币数
  • dp[0] = 0(金额为 0 不需要硬币)
  • dp[i] = min(dp[i - coin] + 1),对每个 coin 遍历取最小值

以硬币 {1, 3, 4}、金额 6 为例,DP 表如下:

金额:     0   1   2   3   4   5   6
dp值:     0   1   2   1   1   2   2

解释:

  • dp[0] = 0(基准)
  • dp[1] = dp[0] + 1 = 1(用 1 枚 1)
  • dp[2] = dp[1] + 1 = 2(用 2 枚 1)
  • dp[3] = min(dp[2]+1, dp[0]+1) = 1(用 1 枚 3)
  • dp[4] = min(dp[3]+1, dp[1]+1, dp[0]+1) = 1(用 1 枚 4)
  • dp[5] = min(dp[4]+1, dp[2]+1, dp[1]+1) = 2(用 4+1 或 3+1+1)
  • dp[6] = min(dp[5]+1, dp[3]+1, dp[2]+1) = 2(用 3+3)

C++ 实现

#include <iostream>
#include <vector>
#include <algorithm>
#include <climits>

int minCoins(const std::vector<int>& coins, int amount)
{
    // dp[i] = minimum coins to make amount i
    std::vector<int> dp(amount + 1, INT_MAX);
    dp[0] = 0;

    for (int i = 1; i <= amount; i++)
    {
        for (int coin : coins)
        {
            if (coin <= i && dp[i - coin] != INT_MAX)
            {
                dp[i] = std::min(dp[i], dp[i - coin] + 1);
            }
        }
    }

    return dp[amount] == INT_MAX ? -1 : dp[amount];
}

int main()
{
    std::vector<int> coins1 = {1, 3, 4};
    int amount1 = 6;
    std::cout << "Minimum coins for " << amount1
              << " = " << minCoins(coins1, amount1) << "\n";

    std::vector<int> coins2 = {1, 5, 10, 25};
    int amount2 = 30;
    std::cout << "Minimum coins for " << amount2
              << " = " << minCoins(coins2, amount2) << "\n";

    return 0;
}

运行该程序将输出

Minimum coins for 6 = 2
Minimum coins for 30 = 2

C 实现

#include <stdio.h>
#include <limits.h>

int minCoins(int coins[], int n, int amount)
{
    // dp[i] = minimum coins to make amount i
    int dp[amount + 1];

    for (int i = 0; i <= amount; i++)
    {
        dp[i] = INT_MAX;
    }
    dp[0] = 0;

    for (int i = 1; i <= amount; i++)
    {
        for (int j = 0; j < n; j++)
        {
            if (coins[j] <= i && dp[i - coins[j]] != INT_MAX)
            {
                int val = dp[i - coins[j]] + 1;
                if (val < dp[i])
                {
                    dp[i] = val;
                }
            }
        }
    }

    return dp[amount] == INT_MAX ? -1 : dp[amount];
}

int main()
{
    int coins1[] = {1, 3, 4};
    printf("Minimum coins for 6 = %d\n", minCoins(coins1, 3, 6));

    int coins2[] = {1, 5, 10, 25};
    printf("Minimum coins for 30 = %d\n", minCoins(coins2, 4, 30));

    return 0;
}

运行该程序将输出

Minimum coins for 6 = 2
Minimum coins for 30 = 2

Python 实现

def min_coins(coins, amount):
    # dp[i] = minimum coins to make amount i
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0

    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i:
                dp[i] = min(dp[i], dp[i - coin] + 1)

    return -1 if dp[amount] == float('inf') else dp[amount]


coins1 = [1, 3, 4]
print(f"Minimum coins for 6 = {min_coins(coins1, 6)}")

coins2 = [1, 5, 10, 25]
print(f"Minimum coins for 30 = {min_coins(coins2, 30)}")

运行该程序将输出

Minimum coins for 6 = 2
Minimum coins for 30 = 2

Go 实现

package main

import (
	"fmt"
	"math"
)

func minCoins(coins []int, amount int) int {
	// dp[i] = minimum coins to make amount i
	dp := make([]int, amount+1)
	for i := range dp {
		dp[i] = math.MaxInt32
	}
	dp[0] = 0

	for i := 1; i <= amount; i++ {
		for _, coin := range coins {
			if coin <= i && dp[i-coin] != math.MaxInt32 {
				if dp[i-coin]+1 < dp[i] {
					dp[i] = dp[i-coin] + 1
				}
			}
		}
	}

	if dp[amount] == math.MaxInt32 {
		return -1
	}
	return dp[amount]
}

func main() {
	coins1 := []int{1, 3, 4}
	fmt.Printf("Minimum coins for 6 = %d\n", minCoins(coins1, 6))

	coins2 := []int{1, 5, 10, 25}
	fmt.Printf("Minimum coins for 30 = %d\n", minCoins(coins2, 30))
}

运行该程序将输出

Minimum coins for 6 = 2
Minimum coins for 30 = 2

Go 中使用 math.MaxInt32 表示无穷大,通过 make([]int, amount+1) 创建 DP 数组。Go 的 range 遍历切片语法简洁,无需手动管理索引。


动态规划法 — 找零方案还原

仅仅知道最少硬币数还不够,通常还需要知道具体使用了哪些硬币。我们可以通过一个额外的数组 parent 来追踪每一步使用了哪个硬币,然后反向还原出具体的硬币组合。

思路:

  • parent[i] 记录凑出金额 i 时最后使用的那枚硬币面额
  • amount 出发,每次减去 parent[amount],直到金额为 0
  • 收集经过的所有硬币即为所求方案

C++ 实现

#include <iostream>
#include <vector>
#include <algorithm>
#include <climits>
#include <map>

std::vector<int> minCoinsWithChange(const std::vector<int>& coins, int amount)
{
    // dp[i] = minimum coins to make amount i
    std::vector<int> dp(amount + 1, INT_MAX);
    // parent[i] = coin used to reach amount i
    std::vector<int> parent(amount + 1, -1);
    dp[0] = 0;

    for (int i = 1; i <= amount; i++)
    {
        for (int coin : coins)
        {
            if (coin <= i && dp[i - coin] != INT_MAX)
            {
                if (dp[i - coin] + 1 < dp[i])
                {
                    dp[i] = dp[i - coin] + 1;
                    parent[i] = coin;
                }
            }
        }
    }

    // reconstruct the coin combination
    std::vector<int> result;
    if (dp[amount] == INT_MAX)
    {
        return result; // empty = no solution
    }

    int curr = amount;
    while (curr > 0)
    {
        result.push_back(parent[curr]);
        curr -= parent[curr];
    }

    return result;
}

int main()
{
    std::vector<int> coins = {1, 5, 10, 25};
    int amount = 63;

    std::vector<int> used = minCoinsWithChange(coins, amount);

    // count each coin type
    std::map<int, int, std::greater<int>> coinCount;
    for (int c : used)
    {
        coinCount[c]++;
    }

    std::cout << "Amount = " << amount << "\n";
    std::cout << "Minimum coins = " << used.size() << "\n";
    std::cout << "Coins used: ";
    for (auto& [coin, cnt] : coinCount)
    {
        std::cout << coin << " x " << cnt << "  ";
    }
    std::cout << "\n";

    return 0;
}

运行该程序将输出

Amount = 63
Minimum coins = 6
Coins used: 25 x 2  10 x 1  1 x 3

C 实现

#include <stdio.h>
#include <limits.h>

void minCoinsWithChange(int coins[], int n, int amount)
{
    int dp[amount + 1];
    int parent[amount + 1];

    for (int i = 0; i <= amount; i++)
    {
        dp[i] = INT_MAX;
        parent[i] = -1;
    }
    dp[0] = 0;

    for (int i = 1; i <= amount; i++)
    {
        for (int j = 0; j < n; j++)
        {
            if (coins[j] <= i && dp[i - coins[j]] != INT_MAX)
            {
                if (dp[i - coins[j]] + 1 < dp[i])
                {
                    dp[i] = dp[i - coins[j]] + 1;
                    parent[i] = coins[j];
                }
            }
        }
    }

    if (dp[amount] == INT_MAX)
    {
        printf("No solution\n");
        return;
    }

    // count each coin type
    int count[amount + 1];
    for (int i = 0; i <= amount; i++) count[i] = 0;

    int totalCoins = 0;
    int curr = amount;
    while (curr > 0)
    {
        count[parent[curr]]++;
        curr -= parent[curr];
        totalCoins++;
    }

    printf("Amount = %d\n", amount);
    printf("Minimum coins = %d\n", totalCoins);
    printf("Coins used: ");
    for (int i = amount; i >= 1; i--)
    {
        if (count[i] > 0)
        {
            printf("%d x %d  ", i, count[i]);
        }
    }
    printf("\n");
}

int main()
{
    int coins[] = {1, 5, 10, 25};
    minCoinsWithChange(coins, 4, 63);
    return 0;
}

运行该程序将输出

Amount = 63
Minimum coins = 6
Coins used: 25 x 2  10 x 1  1 x 3

Python 实现

def min_coins_with_change(coins, amount):
    # dp[i] = minimum coins to make amount i
    dp = [float('inf')] * (amount + 1)
    # parent[i] = coin used to reach amount i
    parent = [-1] * (amount + 1)
    dp[0] = 0

    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i and dp[i - coin] + 1 < dp[i]:
                dp[i] = dp[i - coin] + 1
                parent[i] = coin

    if dp[amount] == float('inf'):
        return [], -1

    # reconstruct the coin combination
    used = []
    curr = amount
    while curr > 0:
        used.append(parent[curr])
        curr -= parent[curr]

    return used, dp[amount]


coins = [1, 5, 10, 25]
amount = 63
used, total = min_coins_with_change(coins, amount)

# count each coin type
coin_count = {}
for c in used:
    coin_count[c] = coin_count.get(c, 0) + 1

print(f"Amount = {amount}")
print(f"Minimum coins = {total}")
print("Coins used:", "  ".join(f"{c} x {cnt}" for c, cnt in sorted(coin_count.items(), reverse=True)))

运行该程序将输出

Amount = 63
Minimum coins = 6
Coins used: 25 x 2  10 x 1  1 x 3

Go 实现

package main

import (
	"fmt"
	"math"
	"sort"
)

func minCoinsWithChange(coins []int, amount int) ([]int, int) {
	// dp[i] = minimum coins to make amount i
	dp := make([]int, amount+1)
	// parent[i] = coin used to reach amount i
	parent := make([]int, amount+1)
	for i := range dp {
		dp[i] = math.MaxInt32
		parent[i] = -1
	}
	dp[0] = 0

	for i := 1; i <= amount; i++ {
		for _, coin := range coins {
			if coin <= i && dp[i-coin] != math.MaxInt32 {
				if dp[i-coin]+1 < dp[i] {
					dp[i] = dp[i-coin] + 1
					parent[i] = coin
				}
			}
		}
	}

	if dp[amount] == math.MaxInt32 {
		return nil, -1
	}

	// reconstruct the coin combination
	var used []int
	curr := amount
	for curr > 0 {
		used = append(used, parent[curr])
		curr -= parent[curr]
	}

	return used, dp[amount]
}

func main() {
	coins := []int{1, 5, 10, 25}
	amount := 63

	used, total := minCoinsWithChange(coins, amount)

	// count each coin type
	coinCount := make(map[int]int)
	for _, c := range used {
		coinCount[c]++
	}

	// sort keys in descending order for display
	var keys []int
	for k := range coinCount {
		keys = append(keys, k)
	}
	sort.Sort(sort.Reverse(sort.IntSlice(keys)))

	fmt.Printf("Amount = %d\n", amount)
	fmt.Printf("Minimum coins = %d\n", total)
	fmt.Print("Coins used: ")
	for _, k := range keys {
		fmt.Printf("%d x %d  ", k, coinCount[k])
	}
	fmt.Println()
}

运行该程序将输出

Amount = 63
Minimum coins = 6
Coins used: 25 x 2  10 x 1  1 x 3

parent 数组记录了每一步所使用的硬币面额。还原时从目标金额出发,每次减去对应硬币面额,直到金额归零。63 = 25 + 25 + 10 + 1 + 1 + 1,共 6 枚硬币。


动态规划法 — 组合数

组合数(Number of Combinations)问题:给定硬币面额,求凑出目标金额共有多少种不同的组合方式。注意,这里的组合区分顺序,1+2+2 和 2+1+2 视为不同组合;但使用外层循环遍历硬币、内层循环遍历金额的写法,可以保证每种组合只计数一次(即按硬币种类有序处理)。

状态转移方程:

  • count[i] 表示凑出金额 i 的组合数
  • count[0] = 1(金额为 0 有 1 种方式:不选任何硬币)
  • count[i] += count[i - coin],对每个 coin 累加

以硬币 {1, 2, 5}、金额 5 为例,共有 4 种方式:

  1. 1 + 1 + 1 + 1 + 1
  2. 1 + 1 + 1 + 2
  3. 1 + 2 + 2
  4. 5

C++ 实现

#include <iostream>
#include <vector>

long long countCombinations(const std::vector<int>& coins, int amount)
{
    // count[i] = number of ways to make amount i
    std::vector<long long> count(amount + 1, 0);
    count[0] = 1;

    // outer loop: coins (ensures each combination counted once)
    for (int coin : coins)
    {
        for (int i = coin; i <= amount; i++)
        {
            count[i] += count[i - coin];
        }
    }

    return count[amount];
}

int main()
{
    std::vector<int> coins = {1, 2, 5};
    int amount = 5;

    long long ways = countCombinations(coins, amount);
    std::cout << "Number of ways to make " << amount
              << " = " << ways << "\n";

    return 0;
}

运行该程序将输出

Number of ways to make 5 = 4

C 实现

#include <stdio.h>

long long countCombinations(int coins[], int n, int amount)
{
    // count[i] = number of ways to make amount i
    long long count[amount + 1];
    for (int i = 0; i <= amount; i++)
    {
        count[i] = 0;
    }
    count[0] = 1;

    // outer loop: coins (ensures each combination counted once)
    for (int j = 0; j < n; j++)
    {
        for (int i = coins[j]; i <= amount; i++)
        {
            count[i] += count[i - coins[j]];
        }
    }

    return count[amount];
}

int main()
{
    int coins[] = {1, 2, 5};
    long long ways = countCombinations(coins, 3, 5);
    printf("Number of ways to make 5 = %lld\n", ways);

    return 0;
}

运行该程序将输出

Number of ways to make 5 = 4

Python 实现

def count_combinations(coins, amount):
    # count[i] = number of ways to make amount i
    count = [0] * (amount + 1)
    count[0] = 1

    # outer loop: coins (ensures each combination counted once)
    for coin in coins:
        for i in range(coin, amount + 1):
            count[i] += count[i - coin]

    return count[amount]


coins = [1, 2, 5]
amount = 5
print(f"Number of ways to make {amount} = {count_combinations(coins, amount)}")

运行该程序将输出

Number of ways to make 5 = 4

Go 实现

package main

import "fmt"

func countCombinations(coins []int, amount int) int64 {
	// count[i] = number of ways to make amount i
	count := make([]int64, amount+1)
	count[0] = 1

	// outer loop: coins (ensures each combination counted once)
	for _, coin := range coins {
		for i := coin; i <= amount; i++ {
			count[i] += count[i-coin]
		}
	}

	return count[amount]
}

func main() {
	coins := []int{1, 2, 5}
	amount := 5
	fmt.Printf("Number of ways to make %d = %d\n", amount,
		countCombinations(coins, amount))
}

运行该程序将输出

Number of ways to make 5 = 4

注意外层循环遍历硬币、内层循环遍历金额的顺序至关重要。这种顺序保证了每种硬币按类型依次加入,避免了相同硬币组合因顺序不同被重复计数。使用 long long / int64 是因为组合数可能增长很快。


递归法(带记忆化)

递归法(Recursive with Memoization)采用自顶向下的方式求解。基本思路是将目标金额递归分解为子问题,同时用哈希表或数组缓存已计算的结果,避免重复计算。

递归关系:

  • solve(amount) = min(solve(amount - coin) + 1),对所有有效的 coin
  • 基准情形:amount == 0 返回 0,amount < 0 返回无穷大

C++ 实现

#include <iostream>
#include <vector>
#include <climits>
#include <unordered_map>

class CoinChangeMemo
{
private:
    std::unordered_map<int, int> memo;

public:
    int solve(const std::vector<int>& coins, int amount)
    {
        // base case
        if (amount == 0) return 0;
        if (amount < 0) return INT_MAX;

        // check memo
        if (memo.find(amount) != memo.end())
        {
            return memo[amount];
        }

        int minResult = INT_MAX;
        for (int coin : coins)
        {
            int sub = solve(coins, amount - coin);
            if (sub != INT_MAX)
            {
                minResult = std::min(minResult, sub + 1);
            }
        }

        memo[amount] = minResult;
        return minResult;
    }
};

int main()
{
    CoinChangeMemo solver;
    std::vector<int> coins1 = {1, 3, 4};
    int result1 = solver.solve(coins1, 6);
    std::cout << "Minimum coins for 6 = "
              << (result1 == INT_MAX ? -1 : result1) << "\n";

    CoinChangeMemo solver2;
    std::vector<int> coins2 = {1, 5, 10, 25};
    int result2 = solver2.solve(coins2, 30);
    std::cout << "Minimum coins for 30 = "
              << (result2 == INT_MAX ? -1 : result2) << "\n";

    return 0;
}

运行该程序将输出

Minimum coins for 6 = 2
Minimum coins for 30 = 2

Python 实现

def min_coins_memo(coins, amount, memo=None):
    if memo is None:
        memo = {}

    # base case
    if amount == 0:
        return 0
    if amount < 0:
        return float('inf')

    # check memo
    if amount in memo:
        return memo[amount]

    min_result = float('inf')
    for coin in coins:
        sub = min_coins_memo(coins, amount - coin, memo)
        if sub != float('inf'):
            min_result = min(min_result, sub + 1)

    memo[amount] = min_result
    return min_result


coins1 = [1, 3, 4]
r1 = min_coins_memo(coins1, 6)
print(f"Minimum coins for 6 = {-1 if r1 == float('inf') else r1}")

coins2 = [1, 5, 10, 25]
r2 = min_coins_memo(coins2, 30)
print(f"Minimum coins for 30 = {-1 if r2 == float('inf') else r2}")

运行该程序将输出

Minimum coins for 6 = 2
Minimum coins for 30 = 2

Go 实现

package main

import (
	"fmt"
	"math"
)

func minCoinsMemo(coins []int, amount int, memo map[int]int) int {
	// base case
	if amount == 0 {
		return 0
	}
	if amount < 0 {
		return math.MaxInt32
	}

	// check memo
	if val, ok := memo[amount]; ok {
		return val
	}

	minResult := math.MaxInt32
	for _, coin := range coins {
		sub := minCoinsMemo(coins, amount-coin, memo)
		if sub != math.MaxInt32 {
			if sub+1 < minResult {
				minResult = sub + 1
			}
		}
	}

	memo[amount] = minResult
	return minResult
}

func main() {
	memo1 := make(map[int]int)
	coins1 := []int{1, 3, 4}
	r1 := minCoinsMemo(coins1, 6, memo1)
	if r1 == math.MaxInt32 {
		r1 = -1
	}
	fmt.Printf("Minimum coins for 6 = %d\n", r1)

	memo2 := make(map[int]int)
	coins2 := []int{1, 5, 10, 25}
	r2 := minCoinsMemo(coins2, 30, memo2)
	if r2 == math.MaxInt32 {
		r2 = -1
	}
	fmt.Printf("Minimum coins for 30 = %d\n", r2)
}

运行该程序将输出

Minimum coins for 6 = 2
Minimum coins for 30 = 2

递归法带记忆化与自底向上的动态规划在本质上是相同的——都是通过避免重复计算来优化性能。区别在于递归法从目标金额出发,按需计算子问题;而动态规划从小到大依次填表。在实际应用中,动态规划通常更高效,因为不存在函数调用开销。


完整实现

下面将所有方法整合到一个完整程序中,方便对比不同算法的结果。

C++ 实现

#include <iostream>
#include <vector>
#include <algorithm>
#include <climits>
#include <unordered_map>
#include <map>

// --- Greedy ---
int greedyChange(std::vector<int> coins, int amount)
{
    std::sort(coins.begin(), coins.end(), std::greater<int>());
    int count = 0;
    int remaining = amount;
    for (int coin : coins)
    {
        while (remaining >= coin)
        {
            remaining -= coin;
            count++;
        }
    }
    return remaining == 0 ? count : -1;
}

// --- DP: minimum coins ---
int dpMinCoins(const std::vector<int>& coins, int amount)
{
    std::vector<int> dp(amount + 1, INT_MAX);
    dp[0] = 0;
    for (int i = 1; i <= amount; i++)
    {
        for (int coin : coins)
        {
            if (coin <= i && dp[i - coin] != INT_MAX)
            {
                dp[i] = std::min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    return dp[amount] == INT_MAX ? -1 : dp[amount];
}

// --- DP: coin combination reconstruction ---
std::vector<int> dpWithChange(const std::vector<int>& coins, int amount)
{
    std::vector<int> dp(amount + 1, INT_MAX);
    std::vector<int> parent(amount + 1, -1);
    dp[0] = 0;
    for (int i = 1; i <= amount; i++)
    {
        for (int coin : coins)
        {
            if (coin <= i && dp[i - coin] != INT_MAX)
            {
                if (dp[i - coin] + 1 < dp[i])
                {
                    dp[i] = dp[i - coin] + 1;
                    parent[i] = coin;
                }
            }
        }
    }
    std::vector<int> result;
    if (dp[amount] == INT_MAX) return result;
    int curr = amount;
    while (curr > 0)
    {
        result.push_back(parent[curr]);
        curr -= parent[curr];
    }
    return result;
}

// --- DP: combination count ---
long long dpCountCombinations(const std::vector<int>& coins, int amount)
{
    std::vector<long long> count(amount + 1, 0);
    count[0] = 1;
    for (int coin : coins)
    {
        for (int i = coin; i <= amount; i++)
        {
            count[i] += count[i - coin];
        }
    }
    return count[amount];
}

// --- Recursive with memoization ---
int memoSolve(const std::vector<int>& coins, int amount, std::unordered_map<int, int>& memo)
{
    if (amount == 0) return 0;
    if (amount < 0) return INT_MAX;
    if (memo.count(amount)) return memo[amount];
    int best = INT_MAX;
    for (int coin : coins)
    {
        int sub = memoSolve(coins, amount - coin, memo);
        if (sub != INT_MAX) best = std::min(best, sub + 1);
    }
    memo[amount] = best;
    return best;
}

// --- Main ---
int main()
{
    std::vector<int> coins = {1, 5, 10, 25};
    int amount = 63;

    std::cout << "=== Making Change: amount = " << amount << " ===\n";
    std::cout << "Coins: {1, 5, 10, 25}\n\n";

    // greedy
    int greedy = greedyChange(coins, amount);
    std::cout << "Greedy:               " << greedy << " coins\n";

    // DP min coins
    int dpMin = dpMinCoins(coins, amount);
    std::cout << "DP (minimum coins):   " << dpMin << " coins\n";

    // DP with change
    std::vector<int> used = dpWithChange(coins, amount);
    std::map<int, int, std::greater<int>> coinCount;
    for (int c : used) coinCount[c]++;
    std::cout << "DP (coin breakdown):  ";
    for (auto& [coin, cnt] : coinCount) std::cout << coin << "x" << cnt << " ";
    std::cout << "\n";

    // DP combination count
    long long ways = dpCountCombinations(coins, amount);
    std::cout << "DP (combinations):    " << ways << " ways\n";

    // Recursive memoization
    std::unordered_map<int, int> memo;
    int memoResult = memoSolve(coins, amount, memo);
    std::cout << "Recursive+Memo:       " << (memoResult == INT_MAX ? -1 : memoResult) << " coins\n";

    return 0;
}

运行该程序将输出

=== Making Change: amount = 63 ===
Coins: {1, 5, 10, 25}

Greedy:               6 coins
DP (minimum coins):   6 coins
DP (coin breakdown):  25x2 10x1 1x3
DP (combinations):    37 ways
Recursive+Memo:       6 coins

C 实现

#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#include <string.h>

// --- comparison for descending sort ---
int cmpDesc(const void* a, const void* b) { return *(int*)b - *(int*)a; }

// --- Greedy ---
int greedyChange(int* coins, int n, int amount)
{
    qsort(coins, n, sizeof(int), cmpDesc);
    int count = 0, remaining = amount;
    for (int i = 0; i < n; i++)
    {
        while (remaining >= coins[i]) { remaining -= coins[i]; count++; }
    }
    return remaining == 0 ? count : -1;
}

// --- DP: minimum coins ---
int dpMinCoins(int coins[], int n, int amount)
{
    int dp[amount + 1];
    for (int i = 0; i <= amount; i++) dp[i] = INT_MAX;
    dp[0] = 0;
    for (int i = 1; i <= amount; i++)
    {
        for (int j = 0; j < n; j++)
        {
            if (coins[j] <= i && dp[i - coins[j]] != INT_MAX)
            {
                int val = dp[i - coins[j]] + 1;
                if (val < dp[i]) dp[i] = val;
            }
        }
    }
    return dp[amount] == INT_MAX ? -1 : dp[amount];
}

// --- DP: combination count ---
long long dpCountCombinations(int coins[], int n, int amount)
{
    long long count[amount + 1];
    memset(count, 0, sizeof(count));
    count[0] = 1;
    for (int j = 0; j < n; j++)
    {
        for (int i = coins[j]; i <= amount; i++)
        {
            count[i] += count[i - coins[j]];
        }
    }
    return count[amount];
}

// --- Recursive with memoization ---
int memoSolve(int coins[], int n, int amount, int* memo)
{
    if (amount == 0) return 0;
    if (amount < 0) return INT_MAX;
    if (memo[amount] != -1) return memo[amount];
    int best = INT_MAX;
    for (int j = 0; j < n; j++)
    {
        int sub = memoSolve(coins, n, amount - coins[j], memo);
        if (sub != INT_MAX && sub + 1 < best) best = sub + 1;
    }
    memo[amount] = best;
    return best;
}

int main()
{
    int coins[] = {1, 5, 10, 25};
    int n = 4, amount = 63;

    printf("=== Making Change: amount = %d ===\n", amount);
    printf("Coins: {1, 5, 10, 25}\n\n");

    // greedy (make a copy because qsort modifies the array)
    int coinsCopy[4];
    memcpy(coinsCopy, coins, sizeof(coins));
    printf("Greedy:               %d coins\n", greedyChange(coinsCopy, n, amount));

    // DP min coins
    printf("DP (minimum coins):   %d coins\n", dpMinCoins(coins, n, amount));

    // DP combination count
    printf("DP (combinations):    %lld ways\n", dpCountCombinations(coins, n, amount));

    // Recursive memoization
    int memo[amount + 1];
    memset(memo, -1, sizeof(memo));
    int memoResult = memoSolve(coins, n, amount, memo);
    printf("Recursive+Memo:       %d coins\n", memoResult == INT_MAX ? -1 : memoResult);

    return 0;
}

运行该程序将输出

=== Making Change: amount = 63 ===
Coins: {1, 5, 10, 25}

Greedy:               6 coins
DP (minimum coins):   6 coins
DP (combinations):    37 ways
Recursive+Memo:       6 coins

Python 实现

# --- Greedy ---
def greedy_change(coins, amount):
    sorted_coins = sorted(coins, reverse=True)
    count, remaining = 0, amount
    for coin in sorted_coins:
        while remaining >= coin:
            remaining -= coin
            count += 1
    return count if remaining == 0 else -1


# --- DP: minimum coins ---
def dp_min_coins(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i:
                dp[i] = min(dp[i], dp[i - coin] + 1)
    return -1 if dp[amount] == float('inf') else dp[amount]


# --- DP: coin combination reconstruction ---
def dp_with_change(coins, amount):
    dp = [float('inf')] * (amount + 1)
    parent = [-1] * (amount + 1)
    dp[0] = 0
    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i and dp[i - coin] + 1 < dp[i]:
                dp[i] = dp[i - coin] + 1
                parent[i] = coin
    if dp[amount] == float('inf'):
        return [], -1
    used, curr = [], amount
    while curr > 0:
        used.append(parent[curr])
        curr -= parent[curr]
    return used, dp[amount]


# --- DP: combination count ---
def dp_count_combinations(coins, amount):
    count = [0] * (amount + 1)
    count[0] = 1
    for coin in coins:
        for i in range(coin, amount + 1):
            count[i] += count[i - coin]
    return count[amount]


# --- Recursive with memoization ---
def memo_solve(coins, amount, memo=None):
    if memo is None:
        memo = {}
    if amount == 0:
        return 0
    if amount < 0:
        return float('inf')
    if amount in memo:
        return memo[amount]
    best = float('inf')
    for coin in coins:
        sub = memo_solve(coins, amount - coin, memo)
        if sub != float('inf'):
            best = min(best, sub + 1)
    memo[amount] = best
    return best


coins = [1, 5, 10, 25]
amount = 63

print(f"=== Making Change: amount = {amount} ===")
print("Coins: {1, 5, 10, 25}\n")

print(f"Greedy:               {greedy_change(coins, amount)} coins")
print(f"DP (minimum coins):   {dp_min_coins(coins, amount)} coins")

used, total = dp_with_change(coins, amount)
coin_count = {}
for c in used:
    coin_count[c] = coin_count.get(c, 0) + 1
breakdown = " ".join(f"{c}x{cnt}" for c, cnt in sorted(coin_count.items(), reverse=True))
print(f"DP (coin breakdown):  {breakdown}")

print(f"DP (combinations):    {dp_count_combinations(coins, amount)} ways")
print(f"Recursive+Memo:       {memo_solve(coins, amount)} coins")

运行该程序将输出

=== Making Change: amount = 63 ===
Coins: {1, 5, 10, 25}

Greedy:               6 coins
DP (minimum coins):   6 coins
DP (coin breakdown):  25x2 10x1 1x3
DP (combinations):    37 ways
Recursive+Memo:       6 coins

Go 实现

package main

import (
	"fmt"
	"math"
	"sort"
)

// --- Greedy ---
func greedyChange(coins []int, amount int) int {
	sorted := make([]int, len(coins))
	copy(sorted, coins)
	sort.Sort(sort.Reverse(sort.IntSlice(sorted)))
	count, remaining := 0, amount
	for _, coin := range sorted {
		for remaining >= coin {
			remaining -= coin
			count++
		}
	}
	if remaining != 0 {
		return -1
	}
	return count
}

// --- DP: minimum coins ---
func dpMinCoins(coins []int, amount int) int {
	dp := make([]int, amount+1)
	for i := range dp {
		dp[i] = math.MaxInt32
	}
	dp[0] = 0
	for i := 1; i <= amount; i++ {
		for _, coin := range coins {
			if coin <= i && dp[i-coin] != math.MaxInt32 {
				if dp[i-coin]+1 < dp[i] {
					dp[i] = dp[i-coin] + 1
				}
			}
		}
	}
	if dp[amount] == math.MaxInt32 {
		return -1
	}
	return dp[amount]
}

// --- DP: coin combination reconstruction ---
func dpWithChange(coins []int, amount int) ([]int, int) {
	dp := make([]int, amount+1)
	parent := make([]int, amount+1)
	for i := range dp {
		dp[i] = math.MaxInt32
		parent[i] = -1
	}
	dp[0] = 0
	for i := 1; i <= amount; i++ {
		for _, coin := range coins {
			if coin <= i && dp[i-coin] != math.MaxInt32 {
				if dp[i-coin]+1 < dp[i] {
					dp[i] = dp[i-coin] + 1
					parent[i] = coin
				}
			}
		}
	}
	if dp[amount] == math.MaxInt32 {
		return nil, -1
	}
	var used []int
	curr := amount
	for curr > 0 {
		used = append(used, parent[curr])
		curr -= parent[curr]
	}
	return used, dp[amount]
}

// --- DP: combination count ---
func dpCountCombinations(coins []int, amount int) int64 {
	count := make([]int64, amount+1)
	count[0] = 1
	for _, coin := range coins {
		for i := coin; i <= amount; i++ {
			count[i] += count[i-coin]
		}
	}
	return count[amount]
}

// --- Recursive with memoization ---
func memoSolve(coins []int, amount int, memo map[int]int) int {
	if amount == 0 {
		return 0
	}
	if amount < 0 {
		return math.MaxInt32
	}
	if val, ok := memo[amount]; ok {
		return val
	}
	best := math.MaxInt32
	for _, coin := range coins {
		sub := memoSolve(coins, amount-coin, memo)
		if sub != math.MaxInt32 && sub+1 < best {
			best = sub + 1
		}
	}
	memo[amount] = best
	return best
}

func main() {
	coins := []int{1, 5, 10, 25}
	amount := 63

	fmt.Printf("=== Making Change: amount = %d ===\n", amount)
	fmt.Println("Coins: {1, 5, 10, 25}\n")

	fmt.Printf("Greedy:               %d coins\n", greedyChange(coins, amount))
	fmt.Printf("DP (minimum coins):   %d coins\n", dpMinCoins(coins, amount))

	used, _ := dpWithChange(coins, amount)
	coinCount := make(map[int]int)
	for _, c := range used {
		coinCount[c]++
	}
	var keys []int
	for k := range coinCount {
		keys = append(keys, k)
	}
	sort.Sort(sort.Reverse(sort.IntSlice(keys)))
	breakdown := ""
	for _, k := range keys {
		breakdown += fmt.Sprintf("%dx%d ", k, coinCount[k])
	}
	fmt.Printf("DP (coin breakdown):  %s\n", breakdown)

	fmt.Printf("DP (combinations):    %d ways\n", dpCountCombinations(coins, amount))

	memo := make(map[int]int)
	memoResult := memoSolve(coins, amount, memo)
	if memoResult == math.MaxInt32 {
		memoResult = -1
	}
	fmt.Printf("Recursive+Memo:       %d coins\n", memoResult)
}

运行该程序将输出

=== Making Change: amount = 63 ===
Coins: {1, 5, 10, 25}

Greedy:               6 coins
DP (minimum coins):   6 coins
DP (coin breakdown):  25x2 10x1 1x3
DP (combinations):    37 ways
Recursive+Memo:       6 coins

所有方法在标准美分系统 {1, 5, 10, 25} 上结果一致:金额 63 需要 6 枚硬币(25 + 25 + 10 + 1 + 1 + 1)。贪心法在此币种下恰好能给出最优解,但如前所述,这并不总是成立。


找零问题的性质

贪心法 vs 动态规划法

特性 贪心法(Greedy) 动态规划法(DP)
正确性 仅对规范币种有效 对任意币种均有效
时间复杂度 O(n log n + amount) O(n x amount)
空间复杂度 O(1) 或 O(n)(排序) O(amount)
实现难度 简单 中等
适用场景 美分系统等规范币种 通用场景
组合计数 不支持 支持

其中 n 为硬币种类数,amount 为目标金额。

贪心法何时有效

贪心法在以下币种系统中保证最优解:

  • 美分系统
  • 欧元硬币
  • 人民币硬币

这类币种系统称为规范硬币系统(Canonical Coin Systems)。其特征是:每种面额都是比它小的面额的倍数关系或满足特定数学性质,使得贪心选择始终安全。判断一个硬币系统是否为规范系统是一个已研究的问题,没有简单的通用判定方法。

应用场景

场景 说明
自动售货机 根据投入金额和商品价格计算找零方案
货币系统设计 设计新的硬币面额使找零效率最高
资源分配 将资源按最小单元分配,类似装箱问题
时间分配 将时间段按固定粒度划分(如课程排期)
密码学 背包密码系统(Knapsack Cryptosystem)的理论基础

复杂度总结

方法 时间复杂度 空间复杂度
贪心法 O(n log n + amount) O(1)
DP 最少硬币 O(n x amount) O(amount)
DP 方案还原 O(n x amount) O(amount)
DP 组合计数 O(n x amount) O(amount)
递归 + 记忆化 O(n x amount) O(amount)
posted @ 2026-04-17 03:07  游翔  阅读(26)  评论(0)    收藏  举报