组合总和IV------动态规划与高性能优化学习笔记
在算法问题中,组合总和IV是完全背包问题的经典变形,核心差异在于需要统计"排列数"而非"组合数"。当处理大规模的目标值(target)时,基础的动态规划解法虽能保证逻辑正确性,但在运行效率上会遇到瓶颈。这篇笔记将从基础解法入手,逐步延伸到高性能计算视角下的优化策略,理解算法设计与实际运行效率的关联。
一、问题引入
给定由不同整数组成的数组 nums(元素可无限重复选取)和目标整数 target,需要计算选取元素组成总和为 target 的排列个数(顺序不同的排列视作不同结果,如 [1,2] 和 [2,1] 算两个排列)。
该问题具备完全背包"元素可重复选取"的核心特征,但区别于普通完全背包的"组合数统计",本题需聚焦"排列数统计",这也是解法设计和性能优化的核心出发点。
二、基础动态规划解法
1. 核心思路
通过定义状态数组记录"组成特定和的排列数",利用子问题的解推导原问题的解------组成和为 j 的排列数,可由"组成和为 j-num 的排列数"推导(在所有 j-num 的排列末尾追加 num,即得到和为 j 的新排列)。
2. 详细步骤
(1)状态定义
设 dp[j] 表示从 nums 中选取元素(可重复选)组成总和为 j 的排列个数。这是整个解法的核心,后续所有推导均围绕该数组展开。
(2)初始状态
dp[0] = 1:这是启动状态转移的关键边界条件,表示"组成总和为 0 的排列只有 1 种方式------不选任何元素"。若无此初始值,后续所有状态转移都无法启动。
(3)遍历顺序(核心)
为统计"排列数"(而非组合数),遍历顺序需严格遵循:
- 外层遍历目标值 :从
1到target,逐个计算每个和的排列数,确保中间状态不遗漏; - 内层遍历数组元素 :对每个目标和
j,尝试用数组中的每个元素num凑数。该顺序能覆盖所有顺序不同的排列(例如计算j=3时,先试1再试2,会同时统计[1,2]和[2,1])。
注:若颠倒遍历顺序(先数组、后目标值),会退化为统计"组合数",无法满足题目对排列数的要求。
(4)状态转移方程
对于当前目标和 j 及数组元素 num,若 j ≥ num(避免数组负索引访问),则:
dp[j]+=dp[j−num]dp[j] += dp[j - num]dp[j]+=dp[j−num]
含义:组成和为 j 的排列数 = 原有排列数 + 新增排列数(新增部分是"组成 j-num 的所有排列后追加一个 num",恰好得到和为 j 的新排列)。
(5)最终结果
dp[target] 即为组成总和为 target 的排列总数。
3. 基础实现代码(C++)
cpp
#include <vector>
using namespace std;
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
// 定义dp数组:dp[j]表示组成和为j的排列数
// 使用unsigned long long避免target较大时的整数溢出
vector<unsigned long long> dp(target + 1, 0);
// 关键初始状态:组成和为0的排列数为1
dp[0] = 1;
// 外层遍历目标值(1到target),逐个计算每个和的排列数
for (int j = 1; j <= target; j++) {
// 内层遍历数组元素,尝试用当前元素凑出和为j
for (int num : nums) {
// 避免访问dp数组的负索引
if (j - num >= 0) {
// 状态转移:累加新增的排列数
dp[j] += dp[j - num];
}
}
}
// 返回最终结果
return dp[target];
}
};
4. 基础复杂度分析
- 时间复杂度 :O(n×target)O(n \times target)O(n×target)。
n是nums数组长度,外层循环遍历target次,内层循环遍历n次,每个状态的更新操作是常数时间。 - 空间复杂度 :O(target)O(target)O(target)。仅使用长度为
target + 1的一维 DP 数组,无额外空间开销。
三、高性能场景下的优化策略
当 target 规模极大(例如达到 10610^6106 及以上)时,基础解法的运行效率会受限于内存访问效率、循环开销等。以下从高性能计算的核心角度,给出适配该问题的优化策略。
1. 内存布局优化------提升缓存命中率
CPU 缓存的访问速度远高于内存,优化数据的内存布局,让访问模式匹配缓存的工作机制,能大幅减少内存访问的耗时,这是高性能计算中最基础也最有效的优化手段。
优化点
- 内存对齐 :定义 DP 数组时,使用内存对齐的分配方式(如 C++ 的
aligned_alloc)。对齐后的数组能被 CPU 缓存更高效地读取,减少缓存行的浪费。 - 连续内存访问:基础解法中一维 DP 数组的设计本身已保证内存连续访问,避免了二维数组"行/列优先"访问导致的缓存不命中问题,这也是该解法天然的性能优势。
优化示例代码片段
cpp
// 以64字节对齐分配DP数组(适配多数CPU缓存行大小)
const int ALIGNMENT = 64;
unsigned long long* dp = (unsigned long long*)aligned_alloc(ALIGNMENT, (target + 1) * sizeof(unsigned long long));
// 初始化DP数组
for (int i = 0; i <= target; i++) {
dp[i] = 0;
}
dp[0] = 1;
// 后续遍历逻辑与基础解法一致...
// 注意:手动分配的内存需释放
free(dp);
2. 循环优化------减少指令开销
循环是程序执行的"热点区域",优化循环结构可减少 CPU 指令执行次数,降低不必要的开销。
优化点
- 过滤无效元素 :内层遍历数组时,先过滤掉大于当前
j的num,避免无效的j - num ≥ 0判断,减少分支指令的执行次数。 - 循环展开 :对内层的数组遍历循环进行展开(例如每次处理 2 个/4 个元素),减少循环控制语句(如
for循环的条件判断、自增操作)的开销。
优化示例代码片段(循环展开+无效元素过滤)
cpp
for (int j = 1; j <= target; j++) {
int i = 0;
int n = nums.size();
// 循环展开:每次处理2个元素,减少循环迭代次数
for (; i + 1 < n; i += 2) {
int num1 = nums[i];
int num2 = nums[i + 1];
// 过滤无效元素,直接跳过大于j的num
if (num1 <= j) {
dp[j] += dp[j - num1];
}
if (num2 <= j) {
dp[j] += dp[j - num2];
}
}
// 处理剩余的单个元素
for (; i < n; i++) {
int num = nums[i];
if (num <= j) {
dp[j] += dp[j - num];
}
}
}
3. 并行化优化------利用多核算力
当 target 规模极大时,单线程计算的效率有限,可通过并行化将计算任务分配到多个 CPU 核心上,提升整体运行速度。
优化思路
该问题的 DP 计算存在数据依赖 :计算 dp[j] 仅依赖 dp[0...j-1],因此可将 target 划分为多个区间,每个线程负责一个区间的计算(需保证先计算完前序区间,再计算后续区间)。也可直接对内层循环做并行化,通过原子操作避免数据竞争。
并行化示例代码片段(OpenMP)
cpp
#include <omp.h>
// ... 其他头文件与定义
int combinationSum4(vector<int>& nums, int target) {
vector<unsigned long long> dp(target + 1, 0);
dp[0] = 1;
// 外层遍历目标值,内层并行遍历数组元素
for (int j = 1; j <= target; j++) {
// 启用4个线程并行处理内层循环
#pragma omp parallel for num_threads(4) reduction(+:dp[j])
for (int i = 0; i < nums.size(); i++) {
int num = nums[i];
if (j - num >= 0) {
// reduction指令自动处理dp[j]的累加竞争,无需手动加原子操作
dp[j] += dp[j - num];
}
}
}
return dp[target];
}
注:使用
reduction(+:dp[j])比手动加#pragma omp atomic更高效,它会为每个线程创建局部副本,最后合并结果,减少原子操作的开销。
4. 架构特定优化------GPU 加速
对于超大规模的 target(如 10710^7107 级别),CPU 并行的效率仍有上限,此时可利用 GPU 的大规模并行计算能力(GPU 拥有数千个计算核心),适配该问题"数据并行度高"的特征。
优化思路
将 DP 数组拷贝到 GPU 显存中,把"计算每个 dp[j]"的任务映射到 GPU 的线程上(每个线程负责一个或多个 j 的计算)。核心是最大化 GPU 线程利用率,同时减少 CPU 与 GPU 之间的数据传输开销(数据传输速度远低于 GPU 内部计算速度)。
四、关键注意事项
- 溢出防护 :使用
unsigned long long定义 DP 数组是避免溢出的核心手段。在高性能计算中,溢出不仅会导致结果错误,还可能引发程序崩溃,增加调试成本,因此必须从数据类型层面提前规避。 - 遍历顺序:先遍历目标值、后遍历数组元素是统计"排列数"的核心,同时该顺序也能保证 DP 数组的访问符合"从前到后"的连续模式,提升缓存命中率;若颠倒顺序,既会导致结果错误,也会破坏缓存的连续访问特性。
- 并行化数据竞争 :并行优化时,需通过
reduction或原子操作处理dp[j]的累加竞争,否则多个线程同时修改dp[j]会导致结果错误,这是并行优化的核心注意点。 - 大规模内存管理 :当
target极大时,需注意内存/显存的限制------CPU 端可使用内存映射文件,GPU 端需合理分配显存,避免内存不足导致程序异常。
五、进阶思考:数组包含负数的场景优化
若 nums 中包含负数,问题会出现"无限循环"风险(如 nums=[1,-1]、target=1 时,排列数无限多),需添加约束条件(如限制排列长度、负数使用次数)。从高性能角度,补充以下优化思路:
- 限制排列长度 :将 DP 数组扩展为二维
dp[k][j](k为排列长度,j为和),此时可通过"分块计算"优化内存------仅保留当前长度和前一长度的 DP 数组,将空间复杂度从 O(k×target)O(k \times target)O(k×target) 降至 O(target)O(target)O(target)。 - 并行化约束条件:添加约束后,可将"不同长度的排列计算"分配到不同线程/GPU 核心,利用并行化抵消二维 DP 数组带来的计算开销。
六、总结
组合总和IV的核心是完全背包模型下"排列数统计"的动态规划解法,其高性能优化需围绕内存访问效率、循环开销、并行算力利用展开:
- 基础解法的核心是"先遍历目标值、后遍历数组元素"的遍历顺序,以及
dp[0]=1的初始条件,保证结果正确性; - 高性能优化需"按需进行"------小规模
target无需优化,大规模场景下可通过内存对齐、循环展开、并行化等手段提升效率; - 溢出防护、并行数据竞争处理、内存管理是高性能优化的核心注意点,需在保证结果正确的前提下提升效率。