零钱兑换——动态规划与高性能优化学习笔记

零钱兑换------动态规划与高性能优化学习笔记

在日常的算法问题中,零钱兑换是一道典型的完全背包问题,而当我们需要处理大规模的计算场景时,就需要结合高性能计算的思路对基础解法进行优化。这篇笔记会从基础的动态规划解法入手,逐步延伸到高性能场景下的优化策略,帮助理解算法设计与性能提升的关联。

一、问题引入

给定一个整数数组 coins 表示不同面额的硬币(每种硬币数量无限),以及一个整数 amount 表示总金额。需要计算凑成总金额所需的最少硬币个数;若无法凑出,返回 -1

这个问题的核心特征是硬币可重复选取,属于完全背包问题的范畴,动态规划是解决该问题的基础且高效的方法。

二、基础动态规划解法

1. 核心思路

通过定义状态数组,利用子问题的最优解推导原问题的最优解。简单来说,凑成金额 i 的最少硬币数,可由凑成金额 i - coin 的最少硬币数推导而来(coin 为当前选取的硬币面额)。

2. 详细步骤

(1)定义 DP 数组

dp[i] 表示凑成金额 i 所需的最少硬币数量。这个定义是整个解法的核心,后续的所有推导都围绕这个数组展开。

(2)初始化 DP 数组
  • 基准状态:dp[0] = 0,因为凑成金额 0 不需要任何硬币。
  • 其他状态:初始化为 INT_MAX,表示初始情况下该金额无法被凑成,后续会通过状态转移更新有效值。
(3)确定遍历顺序
  • 外层循环遍历硬币面额:逐个考虑每一种硬币,相当于完全背包问题中逐个选择"物品"。
  • 内层循环正序遍历 金额:从当前硬币面额 coin 到目标金额 amount。正序遍历的目的是允许同一硬币被重复选取------当计算 dp[j] 时,dp[j - coin] 已经被更新过,意味着当前硬币可以被多次使用。 注:如果是 01 背包(物品只能选一次),内层需要逆序遍历,这是完全背包和 01 背包遍历顺序的核心区别。
(4)状态转移方程

对于当前遍历的硬币 coin 和金额 j,如果 dp[j - coin] 是一个有效值(即 dp[j - coin] ≠ INT_MAX),那么可以通过选取这枚硬币来更新 dp[j]
d p [ j ] = m i n ( d p [ j ] , d p [ j − c o i n ] + 1 ) dp[j] = min(dp[j], dp[j - coin] + 1) dp[j]=min(dp[j],dp[j−coin]+1)

其中 dp[j - coin] + 1 的含义是:凑成 j - coin 金额的最少硬币数,加上当前这一枚 coin 硬币,就是凑成 j 金额的一种可能方案,我们需要在所有可能方案中选取最小值。

(5)结果判断

最终如果 dp[amount] 仍然是 INT_MAX,说明没有任何硬币组合能凑成目标金额,返回 -1;否则返回 dp[amount]

3. 基础实现代码(C++)

cpp 复制代码
#include <vector>
#include <climits>
#include <algorithm>
using namespace std;

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        // 定义dp数组,长度为amount+1,初始值为INT_MAX
        vector<int> dp(amount + 1, INT_MAX);
        // 基准状态初始化
        dp[0] = 0;

        // 外层遍历硬币(物品)
        for (int coin : coins) {
            // 内层正序遍历金额,允许重复选取当前硬币
            for (int j = coin; j <= amount; j++) {
                // 防护:避免INT_MAX + 1导致的整数溢出
                if (dp[j - coin] != INT_MAX) {
                    dp[j] = min(dp[j], dp[j - coin] + 1);
                }
            }
        }

        // 无法凑成目标金额则返回-1
        return dp[amount] == INT_MAX ? -1 : dp[amount];
    }
};

4. 基础复杂度分析

  • 时间复杂度 : O ( n × a m o u n t ) O(n \times amount) O(n×amount)。n 是硬币的种类数,外层循环遍历 n 次,内层循环遍历 amount 次,每个状态的更新操作是常数时间。
  • 空间复杂度 : O ( a m o u n t ) O(amount) O(amount)。仅使用了一个长度为 amount + 1 的一维 DP 数组,没有额外的空间开销。

三、高性能场景下的优化策略

amount 的规模非常大(例如达到 10 6 10^6 106 甚至更高)时,基础解法的效率会遇到瓶颈。此时可以结合高性能计算的思路,从内存、循环、并行化等角度进行优化,提升算法的实际运行效率。

1. 内存布局优化------缓存友好性提升

在高性能计算中,缓存命中率是影响程序运行速度的关键因素。CPU 的缓存速度远快于内存,若能让数据的访问模式匹配缓存的工作机制,就能大幅减少内存访问的开销。

优化点
  • 数组内存对齐 :在定义 DP 数组时,使用内存对齐的分配方式(例如 C++ 中的 aligned_alloc)。对齐后的数组能更高效地被 CPU 缓存读取,减少缓存行的浪费。
  • 减少不必要的内存访问:基础解法中 DP 数组是一维的,本身已经是最优的空间布局,避免了二维数组的行优先/列优先访问带来的缓存不命中问题。一维数组的连续内存访问模式天然对缓存友好。
优化示例代码片段
cpp 复制代码
// 内存对齐的DP数组分配(以64字节对齐为例,适配多数CPU缓存行)
const int ALIGNMENT = 64;
int* dp = (int*)aligned_alloc(ALIGNMENT, (amount + 1) * sizeof(int));
// 初始化
for (int i = 0; i <= amount; i++) {
    dp[i] = INT_MAX;
}
dp[0] = 0;

// 后续遍历逻辑与基础解法一致...

// 注意:使用aligned_alloc分配的内存需要手动释放
free(dp);

2. 循环优化------减少指令开销

循环是程序执行的热点区域,优化循环结构可以减少 CPU 的指令执行次数,提升运行效率。

优化点
  • 循环展开 :对于内层的金额遍历循环,可以采用循环展开的方式,减少循环控制语句(如 j++、条件判断)的开销。例如,每次循环处理 2 个或 4 个金额的计算,降低循环迭代的次数。
  • 消除冗余判断 :在遍历硬币时,可以先过滤掉面额大于 amount 的硬币,避免无效的循环遍历。
优化示例代码片段(循环展开)
cpp 复制代码
for (int coin : coins) {
    if (coin > amount) continue; // 过滤无效硬币
    int j;
    // 循环展开:每次处理2个元素,减少循环次数
    for (j = coin; j + 1 <= amount; j += 2) {
        if (dp[j - coin] != INT_MAX) {
            dp[j] = min(dp[j], dp[j - coin] + 1);
        }
        if (dp[j + 1 - coin] != INT_MAX) {
            dp[j + 1] = min(dp[j + 1], dp[j + 1 - coin] + 1);
        }
    }
    // 处理剩余的单个元素
    for (; j <= amount; j++) {
        if (dp[j - coin] != INT_MAX) {
            dp[j] = min(dp[j], dp[j - coin] + 1);
        }
    }
}

3. 并行化优化------利用多核算力

在高性能计算中,并行化是提升大规模问题计算效率的核心手段。对于零钱兑换问题,当 amount 规模极大时,可以将金额区间进行分块,利用多线程并行计算不同区间的 DP 值。

优化思路

DP 数组的计算存在数据依赖 :计算 dp[j] 依赖于 dp[j - coin],但对于不同的硬币面额,或者不同的金额区间,这种依赖关系可以被打破。例如,可以将金额区间划分为多个子区间,每个线程负责一个子区间的计算,通过同步机制处理区间之间的依赖。

在实际应用中,可以使用 OpenMP 等并行编程框架实现多线程并行,示例如下:

并行化示例代码片段(OpenMP)
cpp 复制代码
#include <omp.h>
// ... 其他头文件与定义

int coinChange(vector<int>& coins, int amount) {
    vector<int> dp(amount + 1, INT_MAX);
    dp[0] = 0;

    // 外层遍历硬币,内层并行遍历金额(需注意依赖关系)
    for (int coin : coins) {
        if (coin > amount) continue;
        // 启用OpenMP并行,指定线程数
        #pragma omp parallel for num_threads(4)
        for (int j = coin; j <= amount; j++) {
            if (dp[j - coin] != INT_MAX) {
                // 原子操作:避免多线程竞争导致的结果错误
                #pragma omp atomic
                dp[j] = min(dp[j], dp[j - coin] + 1);
            }
        }
    }

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

注意:并行化需要谨慎处理数据竞争问题。上述代码中使用 #pragma omp atomic 确保 dp[j] 的更新操作是原子的,避免多个线程同时修改导致的错误。

4. 架构特定优化------GPU 加速

对于超大规模的 amount(如 10 7 10^7 107 级别),CPU 并行的效率仍然有限,此时可以利用 GPU 的大规模并行计算能力。GPU 拥有数千个计算核心,非常适合处理这种数据并行度高的任务。

优化思路

将 DP 数组的计算任务映射到 GPU 的线程上,每个线程负责计算一个或多个 dp[j] 的值。通过 CUDA 等框架实现 GPU 与 CPU 之间的数据传输和计算调度。这种优化方式的核心是最大化 GPU 线程的利用率,减少数据传输的开销(因为 GPU 与 CPU 之间的数据传输速度远低于 GPU 内部的计算速度)。

四、优化后的复杂度分析延伸

  • 时间复杂度 :基础解法的时间复杂度是 O ( n × a m o u n t ) O(n \times amount) O(n×amount),优化后的时间复杂度在理论上没有变化,但常数项被大幅减小 。例如,缓存优化减少了内存访问的时间,并行化将实际运行时间降低到 O ( ( n × a m o u n t ) / p ) O((n \times amount)/p) O((n×amount)/p)(p 为并行线程数或 GPU 核心数)。
  • 空间复杂度 :内存对齐和一维数组的设计保持了 O ( a m o u n t ) O(amount) O(amount) 的空间复杂度,没有额外的空间开销;GPU 加速需要额外的显存空间存储 DP 数组,空间复杂度仍为 O ( a m o u n t ) O(amount) O(amount)。

五、关键注意事项

  1. 溢出防护 :必须判断 dp[j - coin] != INT_MAX,否则 INT_MAX + 1 会导致整数溢出,破坏 DP 数组的正确性。这不仅是逻辑问题,在高性能计算中,溢出还可能导致程序崩溃,影响计算效率。
  2. 遍历顺序:完全背包的正序遍历是保证硬币可重复选取的关键,若改为逆序遍历,就会退化为 01 背包问题,不符合题意。同时,合理的遍历顺序也能提升缓存的命中率。
  3. 并行化数据竞争:在并行优化时,必须通过原子操作或同步机制处理 DP 数组的更新竞争,否则会得到错误的结果。
  4. 大规模数据的内存管理 :当 amount 极大时,需要注意内存的使用限制。例如,在 CPU 上使用内存映射文件,在 GPU 上合理分配显存,避免内存不足的问题。

六、总结

零钱兑换问题的核心是完全背包模型下的动态规划解法,而高性能优化则是在基础解法的框架上,从缓存、循环、并行化、架构适配等角度提升算法的实际运行效率。

在实际应用中,我们不需要盲目追求高性能优化------对于小规模的 amount,基础解法已经足够高效;只有当处理大规模数据时,才需要结合具体的硬件架构,选择合适的优化策略。这种"按需优化"的思路,也是高性能计算领域的核心思想之一。

相关推荐
充值修改昵称3 小时前
数据结构基础:B树磁盘IO优化的数据结构艺术
数据结构·b树·python·算法
程序员-King.10 小时前
day158—回溯—全排列(LeetCode-46)
算法·leetcode·深度优先·回溯·递归
wrj的博客10 小时前
python环境安装
python·学习·环境配置
优雅的潮叭10 小时前
c++ 学习笔记之 chrono库
c++·笔记·学习
星火开发设计10 小时前
C++ 数组:一维数组的定义、遍历与常见操作
java·开发语言·数据结构·c++·学习·数组·知识
月挽清风11 小时前
代码随想录第七天:
数据结构·c++·算法
星幻元宇VR11 小时前
走进公共安全教育展厅|了解安全防范知识
学习·安全·虚拟现实
小O的算法实验室11 小时前
2026年AEI SCI1区TOP,基于改进 IRRT*-D* 算法的森林火灾救援场景下直升机轨迹规划,深度解析+性能实测
算法·论文复现·智能算法·智能算法改进
知识分享小能手11 小时前
Oracle 19c入门学习教程,从入门到精通, Oracle 表空间与数据文件管理详解(9)
数据库·学习·oracle