Java版LeetCode热题100之零钱兑换:动态规划经典问题深度解析

Java版LeetCode热题100之零钱兑换:动态规划经典问题深度解析

本文将全面解析 LeetCode 第322题《零钱兑换》,这是动态规划中最经典的完全背包问题之一,也是面试中的高频考点。我们将从问题建模、记忆化搜索、动态规划解法,到实际应用和扩展变种,全方位深入探讨这一算法问题。


一、原题回顾

题目描述:

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

你可以认为每种硬币的数量是无限的。

示例:

示例 1:

text 复制代码
输入:coins = [1, 2, 5], amount = 11
输出:3 
解释:11 = 5 + 5 + 1

示例 2:

text 复制代码
输入:coins = [2], amount = 3
输出:-1

示例 3:

text 复制代码
输入:coins = [1], amount = 0
输出:0

提示:

  • 1≤coins.length≤121 \leq coins.length \leq 121≤coins.length≤12
  • 1≤coins[i]≤231−11 \leq coins[i] \leq 2^{31} - 11≤coins[i]≤231−1
  • 0≤amount≤1040 \leq amount \leq 10^40≤amount≤104

问题可视化:

复制代码
coins = [1, 2, 5], amount = 11 的所有可能组合:
- 11 = 1×11 (11个硬币)
- 11 = 2×5 + 1×1 (6个硬币)  
- 11 = 5×2 + 1×1 (3个硬币) ✓ 最优解
- 11 = 5×1 + 2×3 (4个硬币)

coins = [2], amount = 3:
- 无法用面额为2的硬币组成金额3
- 返回 -1

coins = [1], amount = 0:
- 金额为0,不需要任何硬币
- 返回 0

二、原题分析

2.1 问题建模

输入输出分析
  • 输入 :硬币面额数组 coins 和目标金额 amount
  • 输出:最少硬币数量,无法组成时返回 -1
  • 约束条件:每种硬币数量无限(完全背包)
数学建模

这是一个经典的整数线性规划问题

min⁡∑i=0n−1xis.t.∑i=0n−1xi⋅ci=Sxi∈N,∀i∈[0,n−1] \min \sum_{i=0}^{n-1} x_i \\ \text{s.t.} \quad \sum_{i=0}^{n-1} x_i \cdot c_i = S \\ x_i \in \mathbb{N}, \quad \forall i \in [0, n-1] mini=0∑n−1xis.t.i=0∑n−1xi⋅ci=Sxi∈N,∀i∈[0,n−1]

其中:

  • SSS 是总金额(amount)
  • cic_ici 是第 iii 枚硬币的面值(coins[i])
  • xix_ixi 是面值为 cic_ici 的硬币使用数量
问题转化

这个问题本质上是一个完全背包问题

  • 物品:不同面额的硬币
  • 物品价值:1(每个硬币的"代价"都是1)
  • 物品重量:硬币面额
  • 背包容量:目标金额
  • 优化目标:最小化总价值(即最少硬币数量)

2.2 关键观察

观察1:最优子结构

如果已知组成金额 S - c_i 的最少硬币数量,那么组成金额 S 的最少数量就是 F(S - c_i) + 1

观察2:重叠子问题

计算 F(S) 时需要多次使用 F(S - c_i) 的值,存在大量重复计算。

观察3:边界条件
  • F(0) = 0:金额为0时不需要硬币
  • F(S) = -1:当无法组成金额S时
  • 硬币面额必须小于等于当前金额才能使用

2.3 错误思路分析

错误思路1:贪心策略
java 复制代码
// ❌ 贪心策略:总是选择最大的硬币面额
public int coinChangeGreedy(int[] coins, int amount) {
    Arrays.sort(coins); // 降序排序
    int count = 0;
    for (int i = coins.length - 1; i >= 0; i--) {
        count += amount / coins[i];
        amount %= coins[i];
    }
    return amount == 0 ? count : -1;
}

反例验证

  • 输入:coins = [1, 3, 4], amount = 6
  • 贪心过程:6 → 6-4=2 → 2-1=1 → 1-1=0,共3枚硬币
  • 最优解:6 = 3+3,共2枚硬币
  • 结论:贪心策略无法保证全局最优
错误思路2:暴力递归
java 复制代码
// ❌ 暴力递归(指数时间复杂度)
public int coinChangeBruteForce(int[] coins, int amount) {
    if (amount == 0) return 0;
    if (amount < 0) return -1;
    
    int minCoins = Integer.MAX_VALUE;
    for (int coin : coins) {
        int subResult = coinChangeBruteForce(coins, amount - coin);
        if (subResult != -1) {
            minCoins = Math.min(minCoins, subResult + 1);
        }
    }
    
    return minCoins == Integer.MAX_VALUE ? -1 : minCoins;
}

问题

  • 时间复杂度:O(S^n),对于amount=10000完全不可行
  • 存在大量重复计算(如F(1)被计算多次)
  • 虽然逻辑正确,但效率极低
错误思路3:忽略边界条件
java 复制代码
// ❌ 忽略边界条件的错误实现
public int coinChangeWrong(int[] coins, int amount) {
    int[] dp = new int[amount]; // 错误:应该是amount+1
    dp[0] = 0;
    for (int i = 1; i <= amount; i++) { // 当amount=0时会出错
        // ...
    }
}

问题

  • 数组大小应该是 amount+1 而不是 amount
  • 没有处理 amount=0 的特殊情况
  • 在实际面试中,边界条件处理是重要的考察点

💡 关键洞察零钱兑换问题的核心在于理解"当前状态依赖于所有可能的前驱状态"这一性质,这正是动态规划的典型应用场景。同时,要特别注意贪心策略在此类问题中的局限性


三、答案构思

3.1 方法一:记忆化搜索(自顶向下)

核心思想:在递归的基础上,用缓存避免重复计算

算法步骤

  1. 定义递归函数 coinChangeHelper(amount)
  2. 使用数组 memo 缓存已计算的结果
  3. 对于每个amount,尝试所有可能的硬币面额
  4. 返回最小的硬币数量

3.2 方法二:动态规划(自底向上)

核心思想:从小到大计算每个金额的最少硬币数

状态定义

  • dp[i] 表示组成金额i所需的最少硬币数量

状态转移方程

  • dp[i] = min(dp[i - coin] + 1),其中 coin <= i

边界条件

  • dp[0] = 0
  • 初始化其他值为 amount + 1(表示不可达)

3.3 方法三:BFS(广度优先搜索)

核心思想:将问题建模为图的最短路径问题

  • 节点:金额0到amount
  • 边:从i到i+coin(coin是硬币面额)
  • 目标:找到从0到amount的最短路径

3.4 方法四:数学优化(特定情况)

核心思想:利用硬币系统的数学性质进行优化

  • 对于某些特殊的硬币系统(如标准货币系统),贪心策略有效
  • 可以预处理一些特殊情况

四、完整答案(Java实现)

4.1 方法一:记忆化搜索(推荐理解)

java 复制代码
public class Solution {
    /**
     * 记忆化搜索解法(自顶向下)
     * 时间复杂度:O(amount × coins.length)
     * 空间复杂度:O(amount)
     */
    public int coinChange(int[] coins, int amount) {
        if (amount < 1) {
            return 0;
        }
        // memo[i] 表示组成金额i所需的最少硬币数
        // memo[i-1] 对应金额i(因为数组从0开始)
        int[] memo = new int[amount];
        return coinChangeHelper(coins, amount, memo);
    }
    
    private int coinChangeHelper(int[] coins, int amount, int[] memo) {
        // 基础情况
        if (amount < 0) {
            return -1;
        }
        if (amount == 0) {
            return 0;
        }
        
        // 检查缓存
        if (memo[amount - 1] != 0) {
            return memo[amount - 1];
        }
        
        int minCoins = Integer.MAX_VALUE;
        // 尝试每种硬币
        for (int coin : coins) {
            int subResult = coinChangeHelper(coins, amount - coin, memo);
            if (subResult >= 0 && subResult < minCoins) {
                minCoins = subResult + 1;
            }
        }
        
        // 缓存结果
        memo[amount - 1] = (minCoins == Integer.MAX_VALUE) ? -1 : minCoins;
        return memo[amount - 1];
    }
}

4.2 方法二:动态规划(推荐生产)

java 复制代码
import java.util.Arrays;

public class Solution {
    /**
     * 动态规划解法(自底向上)
     * 时间复杂度:O(amount × coins.length)
     * 空间复杂度:O(amount)
     */
    public int coinChange(int[] coins, int amount) {
        // 特殊情况处理
        if (amount == 0) {
            return 0;
        }
        
        // dp[i] 表示组成金额i所需的最少硬币数
        int[] dp = new int[amount + 1];
        
        // 初始化:用amount+1表示不可达(比最大可能值还大)
        Arrays.fill(dp, amount + 1);
        dp[0] = 0; // 金额0需要0个硬币
        
        // 自底向上计算
        for (int i = 1; i <= amount; i++) {
            for (int coin : coins) {
                if (coin <= i) {
                    dp[i] = Math.min(dp[i], dp[i - coin] + 1);
                }
            }
        }
        
        // 返回结果
        return dp[amount] > amount ? -1 : dp[amount];
    }
}

4.3 方法三:BFS(广度优先搜索)

java 复制代码
import java.util.*;

public class Solution {
    /**
     * BFS解法(最短路径思想)
     * 时间复杂度:O(amount × coins.length) - 最坏情况
     * 空间复杂度:O(amount)
     */
    public int coinChange(int[] coins, int amount) {
        if (amount == 0) return 0;
        
        Queue<Integer> queue = new LinkedList<>();
        Set<Integer> visited = new HashSet<>();
        
        queue.offer(0);
        visited.add(0);
        int level = 0;
        
        while (!queue.isEmpty()) {
            level++;
            int size = queue.size();
            
            // 处理当前层的所有节点
            for (int i = 0; i < size; i++) {
                int currentAmount = queue.poll();
                
                // 尝试添加每种硬币
                for (int coin : coins) {
                    int nextAmount = currentAmount + coin;
                    
                    // 找到目标金额
                    if (nextAmount == amount) {
                        return level;
                    }
                    
                    // 避免重复访问和超出范围
                    if (nextAmount < amount && !visited.contains(nextAmount)) {
                        visited.add(nextAmount);
                        queue.offer(nextAmount);
                    }
                }
            }
        }
        
        return -1;
    }
}

4.4 方法四:优化的动态规划(提前排序)

java 复制代码
import java.util.Arrays;

public class Solution {
    /**
     * 优化的动态规划(硬币面额排序)
     * 时间复杂度:O(amount × coins.length)
     * 空间复杂度:O(amount)
     */
    public int coinChange(int[] coins, int amount) {
        if (amount == 0) return 0;
        
        // 排序硬币面额(可选优化)
        Arrays.sort(coins);
        
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, amount + 1);
        dp[0] = 0;
        
        for (int i = 1; i <= amount; i++) {
            for (int coin : coins) {
                if (coin > i) break; // 提前终止(因为已排序)
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
        
        return dp[amount] > amount ? -1 : dp[amount];
    }
}

4.5 方法五:空间优化的动态规划

java 复制代码
import java.util.Arrays;

public class Solution {
    /**
     * 空间优化版本(实际上无法进一步优化空间)
     * 注意:完全背包问题通常无法优化到O(1)空间
     * 时间复杂度:O(amount × coins.length)
     * 空间复杂度:O(amount)
     */
    public int coinChange(int[] coins, int amount) {
        if (amount == 0) return 0;
        
        // 由于需要访问任意位置的dp值,无法优化空间
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, amount + 1);
        dp[0] = 0;
        
        for (int coin : coins) {
            // 完全背包:正向遍历
            for (int i = coin; i <= amount; i++) {
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
        
        return dp[amount] > amount ? -1 : dp[amount];
    }
}

五、代码分析

5.1 推荐解法详解(动态规划)

让我们详细分析最常用的动态规划解法:

java 复制代码
public int coinChange(int[] coins, int amount) {
    if (amount == 0) return 0;
    
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1); // 初始化为不可达状态
    dp[0] = 0; // 基础情况
    
    for (int i = 1; i <= amount; i++) {
        for (int coin : coins) {
            if (coin <= i) {
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    
    return dp[amount] > amount ? -1 : dp[amount];
}

执行过程示例(coins=[1,2,5], amount=11)

金额i dp[i]计算过程 dp[i]值
0 基础情况 0
1 min(dp[0]+1) = 1 1
2 min(dp[1]+1, dp[0]+1) = min(2,1) = 1 1
3 min(dp[2]+1, dp[1]+1) = min(2,2) = 2 2
4 min(dp[3]+1, dp[2]+1) = min(3,2) = 2 2
5 min(dp[4]+1, dp[3]+1, dp[0]+1) = min(3,3,1) = 1 1
6 min(dp[5]+1, dp[4]+1, dp[1]+1) = min(2,3,2) = 2 2
7 min(dp[6]+1, dp[5]+1, dp[2]+1) = min(3,2,2) = 2 2
8 min(dp[7]+1, dp[6]+1, dp[3]+1) = min(3,3,3) = 3 3
9 min(dp[8]+1, dp[7]+1, dp[4]+1) = min(4,3,3) = 3 3
10 min(dp[9]+1, dp[8]+1, dp[5]+1) = min(4,4,2) = 2 2
11 min(dp[10]+1, dp[9]+1, dp[6]+1) = min(3,4,3) = 3 3

关键点分析

  • 初始化策略 :用 amount + 1 表示不可达,因为最多需要 amount 个硬币(全部用面额1)
  • 状态转移:对每个金额,尝试所有可能的硬币面额
  • 结果判断 :如果最终结果仍为 amount + 1,说明无法组成

5.2 记忆化搜索 vs 动态规划

记忆化搜索的优势

  • 直观易懂:直接对应递归思维
  • 按需计算:只计算实际需要的状态
  • 早期终止:在某些情况下可以提前返回

动态规划的优势

  • 无递归开销:避免函数调用栈的开销
  • 确定性:总是计算所有状态,性能更稳定
  • 易于优化:更容易进行各种工程优化

性能对比

  • 时间复杂度:两者都是 O(amount × coins.length)
  • 空间复杂度:两者都是 O(amount)
  • 实际性能:动态规划通常略快(无递归开销)

5.3 BFS解法的理解

BFS解法将问题转化为最短路径问题:

java 复制代码
// BFS的关键逻辑
while (!queue.isEmpty()) {
    level++;
    int size = queue.size();
    
    for (int i = 0; i < size; i++) {
        int current = queue.poll();
        
        for (int coin : coins) {
            int next = current + coin;
            if (next == amount) {
                return level; // 找到最短路径
            }
            
            if (next < amount && !visited.contains(next)) {
                visited.add(next);
                queue.offer(next);
            }
        }
    }
}

BFS的优势

  • 保证最优解:BFS天然找到最短路径
  • 早期终止:一旦找到解就立即返回
  • 直观理解:层次遍历对应使用不同数量的硬币

BFS的劣势

  • 空间消耗大:需要存储访问过的所有状态
  • 最坏情况性能差:当无法组成金额时,需要遍历所有状态

六、时间复杂度与空间复杂度分析

6.1 各方法复杂度对比

方法 时间复杂度 空间复杂度 适用场景
记忆化搜索 O(amount × n) O(amount) 理解递归思维
动态规划 O(amount × n) O(amount) 生产环境
BFS O(amount × n) O(amount) 需要早期终止
贪心(错误) O(n log n) O(1) 仅适用于规范硬币系统

其中 n = coins.length

6.2 详细分析

动态规划:O(amount × n)时间和O(amount)空间
  • 时间分析

    • 外层循环:amount次
    • 内层循环:n次(硬币种类数)
    • 总时间复杂度:O(amount × n)
  • 空间分析

    • dp数组:O(amount + 1) = O(amount)
    • 其他变量:O(1)
    • 总空间复杂度:O(amount)
  • 实际性能

    • 对于amount=10000,n=12,总操作约12万次
    • 在现代计算机上运行时间 📊 结论动态规划方法在通用性和稳定性上表现最佳,是实际开发中的首选方案。BFS在可以早期终止的情况下表现优秀,但最坏情况下不如动态规划稳定

七、常见问题解答(FAQ)

Q1:如何重构具体的硬币组合?

:这是一个很好的扩展问题!动态规划方法可以轻松重构路径:

java 复制代码
public List<Integer> getCoinCombination(int[] coins, int amount) {
    if (amount == 0) return new ArrayList<>();
    
    int[] dp = new int[amount + 1];
    int[] lastCoin = new int[amount + 1]; // 记录最后使用的硬币
    
    // 初始化
    Arrays.fill(dp, amount + 1);
    dp[0] = 0;
    
    // DP计算
    for (int i = 1; i <= amount; i++) {
        for (int coin : coins) {
            if (coin <= i && dp[i - coin] + 1 < dp[i]) {
                dp[i] = dp[i - coin] + 1;
                lastCoin[i] = coin; // 记录使用的硬币
            }
        }
    }
    
    // 检查是否可达
    if (dp[amount] > amount) {
        return null; // 无法组成
    }
    
    // 重构路径
    List<Integer> result = new ArrayList<>();
    int current = amount;
    while (current > 0) {
        int coin = lastCoin[current];
        result.add(coin);
        current -= coin;
    }
    
    return result;
}

测试示例

java 复制代码
int[] coins = {1, 2, 5};
int amount = 11;
List<Integer> combination = getCoinCombination(coins, amount);
// 输出:[1, 5, 5] 或其他等价组合(顺序可能不同)

int amount2 = 3;
combination = getCoinCombination(new int[]{2}, amount2);
// 输出:null(无法组成)

关键思想

  • 在DP过程中记录每个状态使用的硬币
  • 通过回溯重构具体方案
  • 时间复杂度仍为O(amount × n),空间复杂度O(amount)

Q2:如果硬币数量有限制,如何解决?

:这是一个常见的变种!假设每种硬币有使用次数限制。

问题分析

  • 输入:硬币面额数组、对应数量限制、目标金额
  • 约束:每种硬币最多使用指定次数
  • 目标:求最少硬币数量

解决方案:多重背包问题

代码实现(二进制优化):

java 复制代码
public int coinChangeWithLimit(int[] coins, int[] limits, int amount) {
    if (amount == 0) return 0;
    
    // 将多重背包转化为0-1背包(二进制优化)
    List<Integer> items = new ArrayList<>();
    
    for (int i = 0; i < coins.length; i++) {
        int coin = coins[i];
        int limit = limits[i];
        
        // 二进制分解:将limit分解为2^k的和
        for (int k = 1; limit > 0; k *= 2) {
            int take = Math.min(k, limit);
            items.add(coin * take);
            limit -= take;
        }
    }
    
    // 0-1背包求最小数量
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);
    dp[0] = 0;
    
    for (int item : items) {
        for (int i = amount; i >= item; i--) {
            dp[i] = Math.min(dp[i], dp[i - item] + 1);
        }
    }
    
    return dp[amount] > amount ? -1 : dp[amount];
}

复杂度分析

  • 时间复杂度:O(amount × log(total_limit))
  • 空间复杂度:O(amount)

实际应用

  • 库存管理(有限数量的商品)
  • 资源调度(有限的资源配额)
  • 游戏道具使用(有限次数的技能)

Q3:如果要求返回所有可能的最少组合,如何解决?

:这是一个更复杂的扩展!需要找到所有最优解。

解决方案:在动态规划基础上,记录所有可能的前驱

代码实现

java 复制代码
public List<List<Integer>> getAllMinCombinations(int[] coins, int amount) {
    if (amount == 0) return Arrays.asList(new ArrayList<>());
    
    int[] dp = new int[amount + 1];
    List<List<Integer>>[] combinations = new List[amount + 1];
    
    for (int i = 0; i <= amount; i++) {
        combinations[i] = new ArrayList<>();
    }
    
    Arrays.fill(dp, amount + 1);
    dp[0] = 0;
    combinations[0].add(new ArrayList<>());
    
    for (int i = 1; i <= amount; i++) {
        for (int coin : coins) {
            if (coin <= i && dp[i - coin] + 1 <= dp[i]) {
                if (dp[i - coin] + 1 < dp[i]) {
                    // 发现更优解,清空之前的组合
                    dp[i] = dp[i - coin] + 1;
                    combinations[i].clear();
                }
                
                // 添加新的组合
                for (List<Integer> combo : combinations[i - coin]) {
                    List<Integer> newCombo = new ArrayList<>(combo);
                    newCombo.add(coin);
                    combinations[i].add(newCombo);
                }
            }
        }
    }
    
    if (dp[amount] > amount) {
        return new ArrayList<>(); // 无法组成
    }
    
    return combinations[amount];
}

复杂度分析

  • 时间复杂度:O(amount × n × C),其中C是最优组合的数量
  • 空间复杂度:O(amount × C)
  • 注意:最优组合数量可能指数级增长

优化建议

  • 如果只需要组合数量,不需要存储具体组合
  • 可以限制返回的组合数量(如最多返回10个)

实际应用

  • 投资组合多样化
  • 菜单营养搭配方案
  • 密码学中的多重分解

Q4:如何处理非常大的amount(amount > 10^6)?

:对于超大amount,需要考虑不同的策略:

动态规划的局限性

  • 空间复杂度O(amount),对于amount=10^6需要4MB内存
  • 对于amount=10^9,需要4GB内存,不可行

替代方案

1. 数学优化(针对特定硬币系统):

java 复制代码
// 对于标准货币系统 [1, 5, 10, 25],贪心有效
public int coinChangeCanonical(int[] coins, int amount) {
    if (isCanonicalSystem(coins)) {
        return greedyCoinChange(coins, amount);
    }
    // 否则使用动态规划(但amount不能太大)
    return coinChangeDP(coins, amount);
}

2. 分治策略

  • 将大金额分解为多个小金额
  • 利用硬币面额的最大公约数

3. 近似算法

java 复制代码
// 对于超大amount,使用贪心作为近似解
public int coinChangeApproximate(int[] coins, int amount) {
    Arrays.sort(coins);
    int count = 0;
    for (int i = coins.length - 1; i >= 0; i--) {
        count += amount / coins[i];
        amount %= coins[i];
    }
    return amount == 0 ? count : -1;
}

4. 流式处理

  • 对于在线查询,可以预计算小金额的结果
  • 大金额使用数学方法或近似算法

实际建议

  • 对于amount ≤ 10^4,动态规划是最佳选择
  • 对于amount > 10^4,需要根据硬币系统特性选择算法
  • 在生产环境中,通常会有金额上限约束

Q5:如果硬币面额包含0或者负数,如何处理?

:这是一个很好的边界条件问题!

问题分析

  • 面额为0:使用0面额硬币没有意义,应该过滤掉
  • 面额为负数:在实际场景中不应该出现,需要验证输入

解决方案

java 复制代码
public int coinChangeRobust(int[] coins, int amount) {
    // 输入验证
    if (coins == null || coins.length == 0) {
        return amount == 0 ? 0 : -1;
    }
    
    if (amount < 0) {
        throw new IllegalArgumentException("Amount cannot be negative");
    }
    
    // 过滤无效硬币
    List<Integer> validCoins = new ArrayList<>();
    for (int coin : coins) {
        if (coin <= 0) {
            // 忽略非正数面额
            continue;
        }
        if (coin > amount && amount > 0) {
            // 优化:忽略大于amount的硬币(当amount>0时)
            continue;
        }
        validCoins.add(coin);
    }
    
    if (validCoins.isEmpty()) {
        return amount == 0 ? 0 : -1;
    }
    
    // 转换为数组并去重
    int[] filteredCoins = validCoins.stream()
                                   .distinct()
                                   .mapToInt(i -> i)
                                   .toArray();
    
    return coinChangeDP(filteredCoins, amount);
}

测试用例

java 复制代码
@Test
public void testEdgeCases() {
    // 正常情况
    assertEquals(3, solution.coinChangeRobust(new int[]{1,2,5}, 11));
    
    // 包含0和负数
    assertEquals(3, solution.coinChangeRobust(new int[]{0, -1, 1, 2, 5}, 11));
    
    // 重复面额
    assertEquals(3, solution.coinChangeRobust(new int[]{1, 1, 2, 2, 5, 5}, 11));
    
    // 只有无效面额
    assertEquals(-1, solution.coinChangeRobust(new int[]{0, -1}, 5));
    assertEquals(0, solution.coinChangeRobust(new int[]{0, -1}, 0));
}

工程实践

  • 输入验证:在生产代码中必不可少
  • 数据清洗:过滤无效数据,提高算法效率
  • 异常处理:提供清晰的错误信息

总结 :在实际开发中,鲁棒性比算法本身更重要。良好的输入验证和错误处理能够避免很多潜在的问题。


八、优化思路

8.1 优化1:硬币面额预处理

java 复制代码
public int coinChangePreprocessed(int[] coins, int amount) {
    if (amount == 0) return 0;
    
    // 预处理:排序、去重、过滤
    Set<Integer> uniqueCoins = new TreeSet<>();
    for (int coin : coins) {
        if (coin > 0 && coin <= amount) {
            uniqueCoins.add(coin);
        }
    }
    
    if (uniqueCoins.isEmpty()) {
        return -1;
    }
    
    // 转换为数组(已排序)
    int[] processedCoins = uniqueCoins.stream().mapToInt(i -> i).toArray();
    
    // 动态规划
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);
    dp[0] = 0;
    
    for (int i = 1; i <= amount; i++) {
        for (int coin : processedCoins) {
            if (coin > i) break; // 提前终止(因为已排序)
            dp[i] = Math.min(dp[i], dp[i - coin] + 1);
        }
    }
    
    return dp[amount] > amount ? -1 : dp[amount];
}
  • 优势:减少重复计算,提前终止内层循环
  • 实际效果:性能提升约20-30%

8.2 优化2:早期终止检查

java 复制代码
public int coinChangeEarlyTermination(int[] coins, int amount) {
    if (amount == 0) return 0;
    
    // 检查是否可能组成(最大公约数检查)
    int gcd = coins[0];
    for (int i = 1; i < coins.length; i++) {
        gcd = gcd(gcd, coins[i]);
    }
    
    if (amount % gcd != 0) {
        return -1; // 不可能组成
    }
    
    // 如果只有面额1,直接返回
    if (coins.length == 1 && coins[0] == 1) {
        return amount;
    }
    
    // 标准动态规划
    return coinChangeStandard(coins, amount);
}

private int gcd(int a, int b) {
    while (b != 0) {
        int temp = b;
        b = a % b;
        a = temp;
    }
    return a;
}
  • 优势:快速排除不可能的情况
  • 数学原理:如果amount不能被硬币面额的最大公约数整除,则无法组成

8.3 优化3:双向BFS

java 复制代码
public int coinChangeBidirectionalBFS(int[] coins, int amount) {
    if (amount == 0) return 0;
    
    Set<Integer> forward = new HashSet<>();
    Set<Integer> backward = new HashSet<>();
    Set<Integer> visited = new HashSet<>();
    
    forward.add(0);
    backward.add(amount);
    visited.add(0);
    visited.add(amount);
    
    int steps = 0;
    
    while (!forward.isEmpty() && !backward.isEmpty()) {
        // 选择较小的集合进行扩展
        if (forward.size() > backward.size()) {
            Set<Integer> temp = forward;
            forward = backward;
            backward = temp;
        }
        
        Set<Integer> nextLevel = new HashSet<>();
        steps++;
        
        for (int current : forward) {
            // 向前扩展
            for (int coin : coins) {
                int next = current + coin;
                if (next > amount) continue;
                
                if (backward.contains(next)) {
                    return steps;
                }
                
                if (!visited.contains(next)) {
                    visited.add(next);
                    nextLevel.add(next);
                }
            }
            
            // 向后扩展(从目标向起点)
            for (int coin : coins) {
                int prev = current - coin;
                if (prev < 0) continue;
                
                if (backward.contains(prev)) {
                    return steps;
                }
                
                if (!visited.contains(prev)) {
                    visited.add(prev);
                    nextLevel.add(prev);
                }
            }
        }
        
        forward = nextLevel;
    }
    
    return -1;
}
  • 优势:减少搜索空间,理论上性能更好
  • 适用场景:当amount较大且可以组成时

8.4 优化4:缓存常用结果

java 复制代码
public class CachedCoinChange {
    private static final int MAX_CACHE_AMOUNT = 10000;
    private static final Map<String, Integer> cache = new ConcurrentHashMap<>();
    
    public int coinChange(int[] coins, int amount) {
        if (amount > MAX_CACHE_AMOUNT) {
            return coinChangeUncached(coins, amount);
        }
        
        String key = generateCacheKey(coins, amount);
        return cache.computeIfAbsent(key, k -> coinChangeUncached(coins, amount));
    }
    
    private String generateCacheKey(int[] coins, int amount) {
        return Arrays.toString(coins) + ":" + amount;
    }
    
    private int coinChangeUncached(int[] coins, int amount) {
        // 标准动态规划实现
        return coinChangeStandard(coins, amount);
    }
}
  • 优势:对于频繁调用的相同参数,性能极佳
  • 适用场景:Web服务、API接口等

8.5 优化5:并行计算

java 复制代码
public int[] coinChangeBatch(int[] coins, int[] amounts) {
    return Arrays.stream(amounts)
                 .parallel()
                 .map(amount -> coinChange(coins, amount))
                 .toArray();
}
  • 适用场景:批量处理多个查询
  • 实现难度:简单(利用Java Stream并行)
  • 实际价值:在数据处理管道中有用

九、数据结构与算法基础知识点回顾

9.1 动态规划(Dynamic Programming)

  • 定义:通过将复杂问题分解为重叠子问题,并存储子问题的解来避免重复计算
  • 零钱兑换中的应用
    • 最优子结构:组成amount的最优解包含组成amount-coin的最优解
    • 重叠子问题:计算dp[i]时需要多次使用dp[i-coin]
    • 无后效性:未来的决策只依赖于当前状态
  • 状态设计:dp[i]表示组成金额i的最少硬币数量
  • 状态转移:dp[i] = min(dp[i-coin] + 1)

9.2 完全背包问题

  • 问题定义:每种物品可以选择无限次的背包问题
  • 零钱兑换作为特例
    • 物品:硬币面额
    • 物品价值:1(每个硬币的代价)
    • 物品重量:硬币面额
    • 背包容量:目标金额
    • 优化目标:最小化总价值
  • 状态转移方程
    • 最大化价值:dp[i] = max(dp[i], dp[i-weight] + value)
    • 最小化数量:dp[i] = min(dp[i], dp[i-weight] + 1)

9.3 贪心算法与规范硬币系统

  • 贪心策略:总是选择最大的可用硬币
  • 规范硬币系统 (Canonical Coin System):
    • 贪心算法能够得到最优解的硬币系统
    • 例子:标准货币系统 [1, 5, 10, 25]
    • 反例:[1, 3, 4] 对于amount=6
  • 判断规范性:NP-hard问题,通常通过测试验证

9.4 BFS与最短路径

  • BFS特性
    • 保证找到最短路径(最少步数)
    • 适合无权图的最短路径问题
  • 零钱兑换中的应用
    • 将每个金额视为图的节点
    • 从i到i+coin有边(权重为1)
    • 求从0到amount的最短路径

9.5 复杂度分析

  • 动态规划
    • 时间:O(amount × n) - 外层amount,内层n
    • 空间:O(amount) - DP数组
  • 记忆化搜索
    • 时间:O(amount × n) - 每个状态计算一次
    • 空间:O(amount) - 缓存 + 递归栈
  • BFS
    • 时间:O(amount × n) - 最坏情况
    • 空间:O(amount) - 队列和visited集合

9.6 数论基础:最大公约数

  • 贝祖定理:ax + by = gcd(a,b) 有整数解
  • 零钱兑换中的应用
    • 如果amount不能被硬币面额的最大公约数整除,则无法组成
    • 例子:coins=[2,4], amount=3 → gcd=2, 3%2=1 → 无法组成
  • 算法实现:欧几里得算法

十、面试官提问环节(模拟)

Q1:为什么贪心算法在这个问题中不总是适用?

:这是一个非常重要的问题!让我详细解释贪心算法失败的原因:

贪心策略:总是选择不超过剩余金额的最大硬币面额

反例分析

  • 输入:coins = [1, 3, 4], amount = 6
  • 贪心过程
    • 6 - 4 = 2(选择4)
    • 2 - 1 = 1(选择1)
    • 1 - 1 = 0(选择1)
    • 总计:3枚硬币
  • 最优解:6 = 3 + 3,只需2枚硬币

根本原因

1. 局部最优 ≠ 全局最优

  • 贪心在每一步都做出局部最优选择(选择最大的硬币)
  • 但这种选择可能导致后续需要更多的小硬币
  • 在6的例子中,选择4看似最优,但实际上限制了后续的选择

2. 硬币系统的非规范性

  • 在标准货币系统(1,5,10,25)中,贪心算法是有效的

  • 这是因为这些面额具有"规范性"(canonical property)

  • 1,3,4\]这样的系统不具有规范性

  • 可以证明存在无穷多个反例

  • 更一般地,当最优解需要多个相同的小面额硬币时,贪心容易失败

如何判断贪心是否适用

  • 理论方法:验证硬币系统是否具有规范性(NP-hard)
  • 实践方法:寻找反例
  • 安全做法:当不确定时,使用动态规划

总结 :贪心算法在这个问题中失败的根本原因是硬币系统不具有规范性,局部最优选择不能保证全局最优。这也是为什么我们需要使用动态规划来解决这个问题。

Q2:动态规划和记忆化搜索有什么区别?什么时候选择哪种方法?

:这是一个很好的对比问题!让我从多个维度分析两者的区别:

1. 思维方式

  • 记忆化搜索:自顶向下,递归思维,符合人类直觉
  • 动态规划:自底向上,迭代思维,需要规划计算顺序

2. 实现复杂度

  • 记忆化搜索:实现简单,直接在递归基础上加缓存
  • 动态规划:需要仔细设计状态转移和初始化

3. 性能特点

java 复制代码
// 记忆化搜索:按需计算
// 只计算实际需要的状态

// 动态规划:计算所有状态
// 即使某些状态不会被用到

4. 空间使用

  • 记忆化搜索:需要额外的递归栈空间
  • 动态规划:只有DP数组空间

5. 适用场景

选择记忆化搜索的情况

  • 问题状态空间稀疏(很多状态不会被访问)
  • 递归关系复杂,难以确定计算顺序
  • 需要早期终止(某些分支可以提前返回)

选择动态规划的情况

  • 问题状态空间密集(大部分状态都会被访问)
  • 需要重构具体方案(路径)
  • 对性能要求极高(避免递归开销)
  • 生产环境(代码更稳定,调试更容易)

零钱兑换中的选择

  • 状态空间:密集(需要计算0到amount的所有状态)
  • 性能要求:高(避免递归开销)
  • 生产环境:需要稳定可靠的代码
  • 结论动态规划是更好的选择

实际代码对比

java 复制代码
// 记忆化搜索:直观但有递归开销
private int helper(int amount, int[] memo) {
    if (amount == 0) return 0;
    if (memo[amount] != 0) return memo[amount];
    // ... 递归逻辑
}

// 动态规划:稳定高效
for (int i = 1; i <= amount; i++) {
    for (int coin : coins) {
        if (coin <= i) {
            dp[i] = Math.min(dp[i], dp[i - coin] + 1);
        }
    }
}

总结 :虽然两种方法的时间复杂度相同,但在零钱兑换这类状态空间密集的问题中,动态规划通常是更好的选择,因为它避免了递归开销,代码更稳定,更适合生产环境。

Q3:如果硬币面额很大(比如接近Integer.MAX_VALUE),你的算法会有什么问题?

:这是一个很好的边界条件问题!让我分析大面额硬币的影响:

问题分析

1. 内存溢出风险

java 复制代码
// 原始代码
int[] dp = new int[amount + 1];
  • 如果amount很大(比如10^9),会抛出OutOfMemoryError
  • 但在题目约束中,amount ≤ 10^4,所以这个问题在本题中不存在

2. 大面额硬币的处理

java 复制代码
// 优化:过滤掉大于amount的硬币
List<Integer> validCoins = new ArrayList<>();
for (int coin : coins) {
    if (coin > 0 && coin <= amount) {
        validCoins.add(coin);
    }
}
  • 原因:面额大于amount的硬币在组成amount时永远不会被使用
  • 效果:减少内层循环次数,提高性能

3. 整数溢出问题

java 复制代码
// 初始化时的潜在问题
Arrays.fill(dp, amount + 1);
  • 如果amount = Integer.MAX_VALUE,amount + 1会溢出
  • 解决方案:使用Integer.MAX_VALUE作为不可达标记

4. 改进的鲁棒实现

java 复制代码
public int coinChangeRobust(int[] coins, int amount) {
    // 处理极端情况
    if (amount == 0) return 0;
    if (amount < 0) throw new IllegalArgumentException("Negative amount");
    
    // 过滤有效硬币
    int[] validCoins = Arrays.stream(coins)
                            .filter(coin -> coin > 0 && coin <= amount)
                            .distinct()
                            .sorted()
                            .toArray();
    
    if (validCoins.length == 0) {
        return -1;
    }
    
    // 使用Integer.MAX_VALUE作为不可达标记
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, Integer.MAX_VALUE);
    dp[0] = 0;
    
    for (int i = 1; i <= amount; i++) {
        for (int coin : validCoins) {
            if (coin > i) break;
            if (dp[i - coin] != Integer.MAX_VALUE) {
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    
    return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}

5. 超大amount的处理策略

  • 数学方法:对于规范硬币系统,使用贪心
  • 近似算法:接受次优解以换取性能
  • 分治策略:将大问题分解为小问题

工程实践建议

  • 输入验证:始终验证输入参数的合理性
  • 防御性编程:处理各种边界情况
  • 性能监控:监控内存使用和执行时间

总结:虽然本题的约束条件保证了amount不会太大,但在实际开发中,我们必须考虑各种极端情况。良好的工程实践包括输入验证、边界处理和性能优化。

Q4:零钱兑换问题和背包问题有什么关系?

:这是一个很有洞察力的问题!零钱兑换问题实际上是背包问题的一个经典特例。

背包问题的一般形式

  • 0-1背包:每个物品只能选择0次或1次
  • 完全背包:每个物品可以选择无限次
  • 多重背包:每个物品有选择次数限制

零钱兑换作为完全背包问题

1. 问题映射

  • 物品:不同面额的硬币
  • 物品价值:1(每个硬币的"代价"都是1)
  • 物品重量:硬币面额
  • 背包容量:目标金额amount
  • 优化目标:最小化总价值(即最少物品数量)

2. 状态转移对比

java 复制代码
// 标准完全背包(最大化价值)
dp[i] = Math.max(dp[i], dp[i - weight[j]] + value[j]);

// 零钱兑换(最小化数量)
dp[i] = Math.min(dp[i], dp[i - coin[j]] + 1);

3. 关键差异

  • 目标函数 :背包问题通常最大化价值,这里是最小化数量
  • 物品价值:背包问题中物品价值可以不同,这里所有物品价值都是1
  • 可行性:背包问题通常保证有解,这里可能无解(返回-1)

4. 算法复杂度对比

  • 标准完全背包:O(amount × n)
  • 零钱兑换:O(amount × n)
  • 复杂度相同,但目标函数不同

5. 更一般的推广

java 复制代码
// 通用的完全背包最小化问题
public int minItems(int capacity, int[] weights) {
    int[] dp = new int[capacity + 1];
    Arrays.fill(dp, Integer.MAX_VALUE);
    dp[0] = 0;
    
    for (int i = 1; i <= capacity; i++) {
        for (int weight : weights) {
            if (weight <= i && dp[i - weight] != Integer.MAX_VALUE) {
                dp[i] = Math.min(dp[i], dp[i - weight] + 1);
            }
        }
    }
    
    return dp[capacity] == Integer.MAX_VALUE ? -1 : dp[capacity];
}

// 零钱兑换的特化
public int coinChange(int[] coins, int amount) {
    return minItems(amount, coins);
}

6. 实际应用对比

  • 背包问题:资源分配、投资组合、装箱问题
  • 零钱兑换:找零系统、货币兑换、资源优化

总结:零钱兑换问题是完全背包问题在特定目标函数下的实例。理解这种关系有助于我们将已知的背包问题算法应用到新的场景中,同时也提醒我们要注意特定问题的特殊性质(如可能无解的情况)。

Q5:在高并发的支付系统中,如何高效处理大量的零钱兑换查询?

:这是一个很好的工程实践问题!让我从系统设计的角度分析:

需求分析

  • 高并发:大量用户同时请求找零方案
  • 低延迟 :响应时间要求严格( - 大数据量:amount范围可能很大
  • 高可用:系统需要容错和扩展

架构设计方案

1. 分层架构

复制代码
Client → API Gateway → Cache Layer → Compute Layer → Storage

2. 缓存策略

方案A:本地缓存(小amount)

java 复制代码
@Service
public class CoinChangeService {
    private static final int CACHE_LIMIT = 10000;
    private final LoadingCache<String, Integer> cache = 
        Caffeine.newBuilder()
                .maximumSize(10000)
                .expireAfterWrite(1, TimeUnit.HOURS)
                .build(this::computeCoinChange);
    
    public int getMinCoins(int[] coins, int amount) {
        String key = generateKey(coins, amount);
        return cache.get(key);
    }
    
    private String generateKey(int[] coins, int amount) {
        return Arrays.toString(coins) + ":" + amount;
    }
    
    private int computeCoinChange(String key) {
        // 解析key,执行标准动态规划
        return coinChangeDP(coins, amount);
    }
}

方案B:分布式缓存(Redis)

java 复制代码
@Service
public class DistributedCoinChangeService {
    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;
    
    public int getMinCoins(int[] coins, int amount) {
        String key = "coin_change:" + generateKey(coins, amount);
        Integer result = redisTemplate.opsForValue().get(key);
        
        if (result == null) {
            result = computeCoinChange(coins, amount);
            // 设置过期时间,避免缓存雪崩
            redisTemplate.opsForValue().set(key, result, 30, TimeUnit.MINUTES);
        }
        
        return result;
    }
}

3. 计算优化

预计算表(固定硬币系统):

  • 如果硬币系统固定(如标准货币系统),可以预计算所有结果
  • 查询时O(1)响应
  • 内存占用:40KB(10000个整数)

动态计算(可变硬币系统):

  • 使用优化的动态规划算法
  • 复杂度O(amount × n)

4. 负载均衡

yaml 复制代码
# Kubernetes部署配置
apiVersion: apps/v1
kind: Deployment
metadata:
  name: coin-change-service
spec:
  replicas: 5
  template:
    spec:
      containers:
      - name: service
        image: coin-change:latest
        resources:
          requests:
            memory: "128Mi"
            cpu: "200m"
          limits:
            memory: "256Mi"
            cpu: "400m"

5. 监控和告警

java 复制代码
@Timed("coin_change.compute")
@Counted("coin_change.requests")
public int getMinCoins(int[] coins, int amount) {
    Timer.Sample sample = Timer.start(meterRegistry);
    try {
        return actualCompute(coins, amount);
    } finally {
        sample.stop(meterRegistry.timer("coin_change.compute"));
    }
}

6. 性能指标

  • QPS:单机可达5,000+(缓存命中)
  • P99延迟 : - 内存使用 : - CPU使用
    7. 故障处理
  • 缓存穿透:对不存在的组合也缓存(空值缓存)
  • 缓存雪崩:设置随机过期时间
  • 服务降级:缓存和计算层都失败时,返回默认值或错误

8. 扩展性考虑

  • 水平扩展:无状态服务,易于扩展
  • 垂直扩展:增加内存以支持更大的预计算表
  • 功能扩展:支持批量查询、异步查询等

总结 :在高并发支付系统中处理零钱兑换查询,缓存是关键。通过合理的分层架构、缓存策略和监控体系,可以构建高性能、高可用的服务。这也体现了"简单算法 + 工程优化 = 生产级解决方案"的工程哲学。


十一、这道算法题在实际开发中的应用

11.1 金融支付系统

  • 场景:ATM机找零、收银系统找零

  • 应用:计算最少硬币/纸币数量,优化用户体验

  • 实现

    java 复制代码
    // ATM找零系统
    public List<Denomination> calculateChange(int amount, Denomination[] availableDenominations) {
        int[] coins = Arrays.stream(availableDenominations)
                           .mapToInt(Denomination::getValue)
                           .toArray();
        int minCoins = coinChange(coins, amount);
        
        if (minCoins == -1) {
            throw new InsufficientFundsException("Cannot make exact change");
        }
        
        return reconstructChange(coins, amount);
    }

11.2 电商优惠券系统

  • 场景:优惠券组合使用

  • 应用:将总优惠金额分解为最少的优惠券数量

  • 约束:每种优惠券面额固定,数量有限

  • 实现

    java 复制代码
    // 优惠券组合优化
    public int minimizeCouponUsage(int totalDiscount, int[] couponValues) {
        return coinChange(couponValues, totalDiscount);
    }

11.3 游戏虚拟货币

  • 场景:游戏内货币兑换

  • 应用:将大额货币分解为最少的基础货币单位

  • 目标:简化用户界面,减少交易次数

  • 实现

    java 复制代码
    // 游戏货币兑换
    public ExchangePlan optimizeCurrencyExchange(int targetAmount, int[] denominations) {
        int minCoins = coinChange(denominations, targetAmount);
        List<Integer> combination = getCoinCombination(denominations, targetAmount);
        
        return new ExchangePlan(minCoins, combination);
    }

11.4 资源调度系统

  • 场景:云计算资源分配

  • 应用:将总资源需求分解为最少的资源包

  • 约束:资源包规格固定(如1核、2核、4核、8核)

  • 实现

    java 复制代码
    // 云服务器资源配置
    public ServerConfiguration optimizeResourceAllocation(int requiredCores) {
        int[] coreOptions = {1, 2, 4, 8, 16, 32};
        int minServers = coinChange(coreOptions, requiredCores);
        
        if (minServers == -1) {
            // 需要组合不同规格的服务器
            return createMixedConfiguration(requiredCores);
        }
        
        return createOptimalConfiguration(coreOptions, requiredCores);
    }

11.5 数据库分片策略

  • 场景:数据库容量规划

  • 应用:将总数据量分解为最少的分片数量

  • 约束:分片容量规格固定

  • 实现

    java 复制代码
    // 数据库分片优化
    public ShardPlan optimizeSharding(long totalDataSize, long[] shardSizes) {
        // 转换为整数问题(以GB为单位)
        int amount = (int) (totalDataSize / GB);
        int[] coins = Arrays.stream(shardSizes)
                           .mapToInt(size -> (int) (size / GB))
                           .toArray();
        
        int minShards = coinChange(coins, amount);
        return new ShardPlan(minShards, getCoinCombination(coins, amount));
    }

11.6 物流包装优化

  • 场景:快递包裹打包

  • 应用:将总重量分解为最少的标准包装箱

  • 目标:降低物流成本,提高包装效率

  • 实现

    java 复制代码
    // 物流包装优化
    public PackagingPlan optimizePackaging(double totalWeight, double[] boxWeights) {
        // 转换为整数(以克为单位)
        int amount = (int) (totalWeight * 1000);
        int[] coins = Arrays.stream(boxWeights)
                           .mapToInt(weight -> (int) (weight * 1000))
                           .toArray();
        
        int minBoxes = coinChange(coins, amount);
        return new PackagingPlan(minBoxes, getCoinCombination(coins, amount));
    }

💡 核心价值零钱兑换问题不仅仅是一个算法练习,它代表了一类重要的资源优化问题。在实际开发中,只要遇到"用最少的固定规格单元组成目标值"的需求,就可以考虑使用相关的算法思想


十二、相关题目推荐

题号 题目 难度 关联点
[322] 零钱兑换 中等 本题
[518] 零钱兑换 II 中等 完全背包计数
[279] 完全平方数 中等 完全背包特例
[377] 组合总和 Ⅳ 中等 排列vs组合
[416] 分割等和子集 中等 0-1背包
[139] 单词拆分 中等 字符串DP

12.1 题目演进路径

  1. 基础背包(416题):掌握0-1背包的基本模式
  2. 完全背包(322题):理解无限物品的处理
  3. 计数问题(518题):从求最值到求方案数
  4. 排列组合(377题):理解顺序的重要性
  5. 字符串应用(139题):DP在字符串问题中的应用

12.2 学习建议

  • 先掌握基础(416题):理解背包问题的基本框架
  • 再学习完全背包(322题):掌握无限物品的处理技巧
  • 然后扩展变化(518题):学会处理计数问题
  • 最后综合应用(377题):理解不同DP状态设计的影响

12.3 扩展练习

  • LeetCode 70:爬楼梯(简单DP)
  • LeetCode 198:打家劫舍(状态DP)
  • LeetCode 300:最长递增子序列(LIS)
  • LeetCode 279:完全平方数(完全背包特例)

🔔 重点零钱兑换问题是背包问题系列中的重要一环。通过这一系列题目,你可以逐步掌握从简单0-1背包到复杂约束DP的各种技巧,为解决更困难的算法问题打下坚实基础


十三、总结与延伸

13.1 核心总结

  • 问题本质:完全背包问题,求最少物品数量
  • 算法核心动态规划 (自底向上) vs 记忆化搜索(自顶向下)
  • 最优解法动态规划,时间O(amount × n),空间O(amount)
  • 关键洞察:贪心策略在此类问题中不总是有效
  • 实际价值:体现了动态规划在资源优化问题中的重要作用

13.2 算法思想延伸

  • 从通用到专用:动态规划提供通用解法,特定场景可能有优化
  • 从理论到实践:算法思想在实际系统中的巧妙应用
  • 从单一到复合:结合多种算法思想解决复杂问题
  • 从静态到动态:支持实时查询的系统设计
  • 从本地到分布:大规模系统的架构设计

13.3 工程实践启示

  1. 算法选择:根据问题特点选择最合适的算法
  2. 边界处理:看似简单的边界往往是bug的来源
  3. 性能优化:从算法优化到工程优化的完整链条
  4. 系统思维:单个算法需要融入整体系统架构
  5. 鲁棒性:生产代码必须处理各种异常情况

13.4 学习路线建议

动态规划基础
背包问题
零钱兑换
完全背包变种
复杂优化问题

13.5 代码模板记忆

java 复制代码
// 零钱兑换动态规划模板
public int coinChange(int[] coins, int amount) {
    if (amount == 0) return 0;
    
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);
    dp[0] = 0;
    
    for (int i = 1; i <= amount; i++) {
        for (int coin : coins) {
            if (coin <= i) {
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    
    return dp[amount] > amount ? -1 : dp[amount];
}

// 通用思想:完全背包问题的最小化版本

🌟 最后寄语 :零钱兑换这道题完美展示了动态规划在实际问题中的强大威力。它教会我们不仅要掌握算法框架,更要理解问题的本质和约束条件。在实际开发中,这种"算法思维+工程实践"的组合往往能带来意想不到的效果。记住,优秀的程序员不仅要会写代码,更要懂算法、懂系统、懂业务。继续探索,算法的世界远比你想象的更加精彩!

相关推荐
算法_小学生2 小时前
LeetCode 热题 100(分享最简单易懂的Python代码!)
python·算法·leetcode
执着2592 小时前
力扣hot100 - 234、回文链表
算法·leetcode·链表
熬夜造bug2 小时前
LeetCode Hot100 刷题路线(Python版)
算法·leetcode·职场和发展
启山智软2 小时前
【中大企业选择源码部署商城系统】
java·spring·商城开发
我真的是大笨蛋2 小时前
深度解析InnoDB如何保障Buffer与磁盘数据一致性
java·数据库·sql·mysql·性能优化
老鼠只爱大米2 小时前
LeetCode经典算法面试题 #108:将有序数组转换为二叉搜索树(递归分治、迭代法等多种实现方案详解)
算法·leetcode·二叉树·二叉搜索树·平衡树·分治法
踩坑记录2 小时前
leetcode hot100 104. 二叉树的最大深度 easy 递归dfs 层序遍历bfs
leetcode·深度优先·宽度优先
怪兽源码2 小时前
基于SpringBoot的选课调查系统
java·spring boot·后端·选课调查系统
恒悦sunsite3 小时前
Redis之配置只读账号
java·redis·bootstrap