尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

SOS DP:从子集和到高维前缀和,掌握O(n*2^n)的算法优化利器

SOS DP:从子集和到高维前缀和,掌握O(n*2^n)的算法优化利器 1. 项目概述什么是SOS DP以及为什么你需要它如果你在刷算法题尤其是涉及“子集”和“位运算”的题目时感觉常规的枚举子集方法时间复杂度 O(3^n)已经让你TLE超时到怀疑人生那么SOS DPSum Over Subsets Dynamic Programming子集和动态规划就是你一直在寻找的“降维打击”武器。我第一次遇到它是在解决一个看似简单的计数问题时给定一个数组统计有多少对数字满足“按位与”的结果是某个特定值的子集。用暴力方法数据规模稍微大一点比如n20就直接超时。直到我发现了SOS DP才明白原来这类问题有如此优雅且高效的通用解法。简单来说SOS DP是一种用于高效处理“子集关系”上求和或其它可结合操作如最大值、最小值的动态规划技巧。它的核心思想不是去枚举每个元素的所有子集那是O(3^n)而是利用二进制位的视角动态地“构建”子集的和。想象一下你有一个巨大的仓库全集里面堆满了贴着二进制标签的箱子每个元素。SOS DP不是一个个打开箱子看里面有什么而是教会你一套“合并报表”的会计方法让你能快速知道任意一个标签所代表的管理区域子集内所有下属箱子子集的子集的货物总值。这听起来有点抽象但它的威力在于能将许多问题的复杂度从指数级O(3^n)直接降到接近线性O(n * 2^n)对于n20的情况这可能是从几秒到几毫秒的飞跃。那么谁需要学这个任何有志于深入算法竞赛如Codeforces, AtCoder或者在工作中需要处理状态压缩、组合优化问题的开发者。它不仅是解决“子集求和”问题的利器更是理解“高维前缀和”这一强大思想的敲门砖。掌握了它你再看那些标着“状压DP”、“FWT快速沃尔什变换”的难题会有一种豁然开朗的感觉。接下来我会用最直白的方式带你从原理到实现彻底搞懂SOS DP。我保证只要你跟着思路走一分钟可能有点夸张但十分钟内掌握核心思想绝对没问题。2. SOS DP的核心思想与状态定义拆解要理解SOS DP我们必须先忘掉那些复杂的数学符号从一个最直观的例子开始。假设我们有一个全集它只有3个元素我们可以用3位二进制数来表示所有子集000,001,010,011,100,101,110,111。现在假设每个子集i都有一个初始权重A[i]。我们的目标是什么是计算一个新的数组F[mask]它表示对于给定的二进制掩码mask求出所有submask的权重A[submask]之和其中submask满足submask mask submask。换句话说submask是mask的一个子集在二进制表示下submask的每一位如果是1那么mask对应的位也必须是1。最笨的方法就是枚举每个mask然后对于每个mask再枚举它的所有子集submask。这就是O(3^n)复杂度的来源。SOS DP的聪明之处在于它换了一个角度不是为每个mask去找它的子集而是考虑每个元素二进制位的贡献是如何一层层累加到包含它的mask上去的。我们来定义SOS DP最经典的状态dp[mask][i]表示考虑mask的前i位从第0位到第i位时所有满足以下条件的子集x的权重和x是mask的子集并且x与mask在高于i的位上是完全相同的。这定义有点绕我们用i作为阶段来理解。i从0遍历到n-1n是总位数。在阶段i我们只关心第i位是0还是1。如果mask的第i位是0那么任何x的第i位也必须是0因为x是mask的子集。所以dp[mask][i]的信息直接从dp[mask][i-1]继承过来因为第i位没得选只能是0。如果mask的第i位是1那么x的第i位就有两种选择可以是0也可以是1。如果x的第i位是0那么这种情况对应的状态就是dp[mask ^ (1 i)][i-1]。因为mask去掉第i位的1变成0然后看前i-1位。如果x的第i位是1那么这种情况对应的状态就是dp[mask][i-1]。因为第i位已经确定是1直接看前i-1位。因此我们可以得到状态转移方程 如果mask (1 i) 0dp[mask][i] dp[mask][i-1]如果mask (1 i) ! 0dp[mask][i] dp[mask][i-1] dp[mask ^ (1 i)][i-1]这个方程的物理意义非常清晰我们在动态地“构建”子集的和。dp[mask][i]比dp[mask][i-1]多考虑了第i位。当mask的第i位是1时新增的求和部分正好来自于那些“在第i位选择0”的子集而这些子集的状态恰恰就是mask去掉这一位mask ^ (1 i)在前i-1位下的结果。一个关键的心得很多教程一上来就扔出这个方程让人摸不着头脑。我的理解方式是把dp[mask][i]想象成一个不断“放宽限制”的过程。初始时i-1可以理解为没有任何限制dp[mask][-1]就是A[mask]本身因为它只包含mask这一个“子集”严格来说此时定义不同但可以这样辅助理解。然后我们一位一位地考虑如果mask在某位是1我们就允许以这一位为0的那些子集也加入进来。这样层层累加最终dp[mask][n-1]就包含了mask的所有子集。3. 从二维DP到一维优化的实现细节理解了二维的状态定义和转移我们就可以写出代码了。但直接使用二维数组dp[1n][n]空间复杂度是O(n * 2^n)在n20时大约是20 * 1百万 2000万量级尚可接受但不够优雅。SOS DP最巧妙的地方之一就是它可以像很多DP问题一样通过改变遍历顺序实现“滚动数组”优化将空间复杂度降为O(2^n)。观察状态转移方程dp[mask][i] dp[mask][i-1](如果mask的第i位是0)dp[mask][i] dp[mask][i-1] dp[mask ^ (1 i)][i-1](如果mask的第i位是1)你会发现dp[mask][i]只依赖于dp[?][i-1]也就是上一阶段前i-1位的结果。如果我们按照i从0到n-1的顺序来循环并且就地更新dp[mask]数组是不是就可以省掉i这个维度了答案是肯定的但有一个至关重要的细节对于每个固定的i我们必须按照特定的顺序来遍历mask。对于需要用到dp[mask ^ (1 i)][i-1]的情况我们必须保证在更新dp[mask]时dp[mask ^ (1 i)]的值还是上一阶段i-1的值而不是已经被当前阶段i更新过的值。这引导我们得出正确的遍历顺序对于每个位i我们遍历所有的mask从0到(1n)-1但只更新那些第i位是1的mask。因为对于第i位是0的mask转移就是直接继承不需要操作在一维数组中值保持不变即可。而对于第i位是1的mask我们用它第i位为0的那个兄弟状态mask ^ (1 i)来更新自己。由于我们遍历所有mask时mask ^ (1 i)这个数一定比mask小因为它把最高位的1变成了0所以只要我们按mask从小到大的顺序遍历就能保证当我们要用dp[mask ^ (1 i)]时它还没有被当前阶段i更新过因为它可能在第i位也是1但它的值更小会在当前循环中更早被处理吗这里需要仔细分析。实际上更稳妥且普遍采用的方法是对于每个位i遍历所有的mask如果mask的第i位是1则执行dp[mask] dp[mask ^ (1 i)]。并且遍历mask的顺序可以是任意的从0到最大值或从最大值到0只要这个操作对每个mask只进行一次。但为了清晰和避免思考顺序的麻烦我们通常就写一个从0到最大值的循环。让我们来看最经典的一维SOS DP实现代码用于计算子集和// 假设 n 是最大位数比如 20 // A[] 是初始数组大小为 (1n) // F[] 是最终要求的子集和数组大小为 (1n) for(int i 0; i (1n); i) F[i] A[i]; // 初始化每个mask最初只包含自己 for(int i 0; i n; i) { // 遍历每一位 for(int mask 0; mask (1n); mask) { if(mask (1 i)) { // 只处理第i位为1的mask F[mask] F[mask ^ (1 i)]; } } }代码解读与实操要点初始化F[mask]初始化为A[mask]这对应了二维DP中dp[mask][-1]的概念即只包含mask本身这一个“子集”的和。外层循环i这对应DP的“阶段”我们一位一位地处理。处理完第i位后F[mask]的含义就变成了所有满足“是mask的子集且高于i的位与mask相同”的那些子集的A值之和。当i遍历完所有位0到n-1F[mask]就是所有子集的A值之和。内层循环mask遍历所有状态。注意我们只更新第i位是1的mask。对于第i位是0的mask其子集在第i位也只能是0所以它的值在本阶段不需要改变因为F[mask]已经包含了所有第i位为0的子集的和。更新操作F[mask] F[mask ^ (1 i)]。这正是状态转移方程的核心。mask ^ (1 i)就是把mask的第i位从1变成0。F[mask ^ (1 i)]在当前时刻本阶段i的循环中存储的是什么值它存储的是“处理完前i-1位后”对于状态mask^(1i)的子集和。而这正好就是mask的所有子集中那些“在第i位选择0”的子集的和。把它们加到F[mask]上F[mask]就包含了“在第i位选择0”和“在第i位选择1”的所有子集的和了。注意这里有一个极其关键的细节也是新手最容易混淆的地方。我们是在就地更新F数组。当i1时我们正在用F数组它已经包含了i0阶段处理完的结果来更新自己。这之所以正确是因为我们利用了一个事实对于固定的i所有mask ^ (1 i)的值的第i位一定是0。而在本阶段i的循环中我们只更新第i位是1的mask所以mask ^ (1 i)这个状态在本轮循环中永远不会被更新因为它的第i位是0。这就保证了我们用来加的那个值一定是“上一阶段”的旧值逻辑完全正确。复杂度分析外层循环n次内层循环2^n次总时间复杂度为O(n * 2^n)。空间复杂度为O(2^n)。与暴力枚举子集的O(3^n)相比效率提升是指数级的。对于n203^20 ≈ 34亿而20 * 2^20 ≈ 2000万快了两个数量级。4. 经典应用场景与问题实战解析懂了原理和代码我们来看看SOS DP到底能解决哪些实际问题。它绝不仅仅是求个子集和那么简单。4.1 应用一子集和计数问题这是最直接的应用。题目通常这样描述给定一个数组a长度N和一个值mask范围在[0, 2^n)求数组中有多少个子序列或子集其元素的“按位或”结果恰好是mask的子集或者其“按位与”结果包含了mask例题抽象有2^n种“类型”用0到2^n-1表示。给定每个类型的数量cnt[type]。对于每个mask求有多少种类型的组合可重复通常是每个类型选一个代表其“按位与”的结果是mask的超集即包含了mask的所有位。解法初始化F[mask] cnt[mask]。对F数组做一遍SOS DP求和。现在F[mask]表示所有类型x满足x mask mask的数量之和等等这里要小心。SOS DP的标准定义是x是mask的子集x mask x。而我们这里需要的是x包含maskx mask mask。这其实是“超集”关系。如何用SOS DP求超集和有一个巧妙的变换求mask的超集和等价于求(~mask)mask的补集的子集和然后再从全集的角度来看。更简单的方法是我们定义G[mask] cnt[mask]然后做SOS DP求的是子集和。那么对于任意mask所有sup满足sup mask mask即sup是mask的超集的cnt[sup]之和就等于所有sub满足sub (~mask) 0即sub是(~mask)的子集的cnt[sub]之和吗这个关系有点绕。更通用的方法是使用“高维前缀和”的另一种形式SOS DP是其中一种。对于超集和我们只需将SOS DP的内层循环判断条件反过来if((mask (1 i)) 0)然后执行F[mask] F[mask | (1 i)]。这相当于从高位向低位“贡献”。代码稍作修改即可。实战技巧遇到“子集”、“超集”这类关键词要立刻联想到SOS DP。先明确题目要求的是子集和还是超集和然后对应地修改核心更新那行代码的判断条件和运算符号。4.2 应用二结合容斥原理解决复杂计数很多问题不能直接套用但SOS DP可以高效地计算出每个mask对应的某个基础值然后结合容斥原理得到答案。经典问题CF 1208F Bits And Pieces (CF Round #580 Div.1 F)题目大意给定数组a求最大的a[i] | (a[j] a[k])其中i j k。思路拆解核心难点在于快速判断对于某个值x是否存在两个下标j, k (j k)使得(a[j] a[k])包含x即(a[j] a[k]) x x。我们转换视角。固定一个mask我们想知道有多少个数对(a[j], a[k])其按位与的结果是mask的超集。这很难直接算。但我们可以用SOS DP来辅助。我们维护一个数组best[mask]它记录最后两个为了满足j k能够“覆盖”mask的数的下标。所谓“覆盖”就是这个数本身是mask的超集即num mask mask。如何用SOS DP更新best我们遍历原数组a对于每个数num它本身就是某些mask的超集。我们需要更新所有mask满足mask是num的子集。这正是SOS DP可以高效完成的我们可以从num出发枚举它的所有子集mask然后尝试用当前下标i去更新best[mask]维护最大的两个下标。有了best数组后对于每个a[i]我们从高位到低位贪心地尝试构造答案ans。对于每一位如果a[i]这一位已经是1那ans这一位肯定是1。如果a[i]这一位是0我们检查能否通过(a[j] a[k])补上这一位。即我们设cur ans | (1 bit)然后检查是否存在两个数即best[cur]中记录的两个下标都大于i使得它们的按位与结果是cur的超集。由于best[cur]记录的是能覆盖cur的最后两个数如果它们的下标都大于i就说明存在这样的j, k。如果可以就把这一位加到ans里。这个过程中best数组的预处理是关键而枚举一个数的所有子集如果暴力枚举是O(3^n)用SOS DP的思想可以优化。但更常见的做法是对于每个num我们直接使用for(int sub num; sub 0; sub (sub - 1) num)这个经典循环来枚举子集这个循环枚举所有子集的复杂度是O(2^k)其中k是num中1的位数在平均情况下比O(2^n)快很多。然后对于每个子集sub用当前下标i去更新best[sub]。这个例子展示了SOS DP如何作为一种预处理工具高效地维护“超集信息”为后续的贪心或容斥计算提供支持。4.3 应用三动态规划的优化DP over Subsets在一些状压DP问题中状态转移需要枚举当前状态的所有子集如果直接枚举复杂度是O(3^n)。利用SOS DP预处理出每个状态关于其子集的一些聚合信息如和、最值可以将转移优化到O(2^n)。例题旅行商问题TSP的变种经典TSP是求经过所有点一次回到起点的最短路径。考虑一个变种每个点有一个权值求一条路径使得经过的点的权值之和最大同时满足某些约束比如路径长度不超过L。 如果我们用dp[mask][i]表示最后走到点i经过的点集为mask时的最大权值和那么转移时需要枚举mask中i的前一个点j。这已经是O(n^2 * 2^n)。 但如果问题变成对于每个点集mask我们只关心这个点集的最大权值和而不关心终点那么dp[mask] max(dp[submask] value[mask ^ submask])其中submask是mask的非空真子集。直接枚举子集是O(3^n)。 此时我们可以用SOS DP来维护每个mask的某个最优值吗注意这里的转移是max操作并且是dp[submask]加上一个由mask和submask共同决定的附加值。这不符合标准的可加性。SOS DP适用于可结合、可交换的操作如加法、乘法、max、min、按位与/或等并且贡献是单向的从子集到超集。在这个TSP变种中附加值value[mask ^ submask]依赖于两个集合的差集不是简单的从子集到超集的贡献。因此标准的SOS DP可能不直接适用。更适用的场景如果问题简化为dp[mask] max(dp[submask] constant[mask])其中constant[mask]是一个只与mask有关的常数那么我们可以用SOS DP来维护dp数组关于子集的最大值。预处理max_sos[mask]为所有submask属于mask的dp[submask]的最大值。那么dp[mask] max_sos[mask] constant[mask]。而max_sos[mask]可以用SOS DP将求和换成取最大值在O(n * 2^n)时间内预处理出来。核心要点SOS DP优化状压DP的关键在于识别出状态转移是否可以被重写为“超集的值由它的所有子集的值经过某种可结合操作得到”。如果是那么就可以用SOS DP预处理将枚举子集的O(3^n)降至O(n * 2^n)。5. 常见误区、调试技巧与性能优化即使理解了算法在实战中还是会踩不少坑。这里我总结几个最常见的误区和个人调试心得。5.1 误区一混淆子集和与超集和这是最频繁的错误。务必时刻清楚你的F[mask]定义的是什么。子集和SOS标准形式F[mask] sum(A[sub])其中sub mask sub(即sub是mask的子集)。代码特征是if(mask (1i)) F[mask] F[mask ^ (1i)]。超集和G[mask] sum(A[sup])其中sup mask mask(即sup是mask的超集)。代码有两种写法对原数组A做子集和SOS得到F那么G[mask] F[ALL] - F[ALL ^ mask]不对这个等式不成立。更直接的方法是修改SOS DP的循环方向。标准超集和SOS代码for(i0; in; i) for(mask0; mask(1n); mask) if((mask (1i)) 0) G[mask] G[mask | (1i)]。注意判断条件是第i位为0更新方向是向mask添加这个位mask | (1i)。调试建议写代码前先用n3这样的小例子手工算出所有mask的子集和与超集和然后对比你的程序输出。这是最有效的验证方法。5.2 误区二就地更新的顺序问题在一维优化代码中我们强调了对于每个i循环mask从0到最大值。为什么不能从大到小让我们试试。 如果mask从大到小循环当处理一个很大的mask时它用到的mask ^ (1i)可能是一个比它小的数而这个小数可能在本轮循环中已经被更新过了因为它也满足第i位为1。这就导致了用“当前阶段”的新值去更新另一个状态破坏了DP的无后效性结果是错误的。 所以对于标准的子集和SOS DP内层循环顺序可以是任意顺序但通常使用从小到大的顺序。而对于超集和版本内层循环从大到小或从小到大都可以但通常也使用从小到大的顺序只要判断条件正确第i位为0。最安全的做法就是记住标准形式的代码模板。5.3 误区三对“位”的遍历顺序理解不透外层循环i从0到n-1代表我们依次处理最低位到最高位。这个顺序重要吗对于求和、求最大值、最小值这类可交换、可结合的操作顺序是不重要的。因为无论先处理哪一位最终结果都一样。但对于某些不可交换的操作虽然SOS DP典型应用不涉及顺序可能会有影响。在99%的情况下你不需要担心这个。5.4 性能优化与实战技巧空间优化一维数组实现已经是O(2^n)空间通常足够。对于n20120 ≈ 1e6数组大小是4MBint或8MBlong long完全可以接受。如果n达到22或更大就需要警惕内存限制如256MB内存n22时数组约16MBn23约32MB。此时可以考虑使用int而非long long或者使用滚动数组的思想处理更大的n但复杂度会上升。时间常数优化内层循环for(mask 0; mask (1n); mask)会遍历所有状态即使很多状态的if条件不满足。一个微小的优化是for(int mask (1n)-1; mask 0; mask--)反向遍历在某些编译器和架构上可能略有不同但差别不大。真正的优化来自于“剪枝”——如果问题性质特殊可能不需要处理所有位或所有状态。但通用模板就是O(n * 2^n)。与枚举子集方法的对比当n很小16时直接暴力枚举子集for(int sub mask; sub; sub (sub-1)mask)可能更简单直观且常数小。SOS DP的优势在n较大17-22时非常明显。你需要根据数据范围选择工具。调试输出在编写SOS DP时最好像我前面建议的那样用一个n3的例子把每一步循环后的F数组打印出来与手工计算对比。这是排查逻辑错误最快的方法。扩展到其他操作SOS DP不仅限于求和。只要操作满足结合律和交换律并且有一个“零元”如加法的0乘法的1max的负无穷min的正无穷按位与的(1n)-1全1按位或的0就可以套用。例如求子集最大值if(mask (1i)) F[mask] max(F[mask], F[mask ^ (1i)])初始化F[mask] A[mask]。6. 从SOS DP到更广阔的世界高维前缀和与FWT如果你理解了SOS DP那么恭喜你你已经掌握了“高维前缀和”High-Dimensional Prefix Sum在二进制维度上的特例。把每个二进制位看作一个维度每个维度只有0和1两种状态。那么F[mask]就是求在一个n维超立方体上从原点(0,0,...,0)到点mask所定义的“子立方体”内所有点的值之和。SOS DP的每一位处理就相当于在一个维度上做前缀和。这种思想可以推广到真正的多维数组计算其前缀和。更进一步的它与快速沃尔什变换FWT有着深刻的联系。FWT用于解决集合卷积问题例如C[mask] sum_{subm subm2 mask} A[subm1] * B[subm2](子集卷积)C[mask] sum_{subm | subm2 mask} A[subm1] * B[subm2](并集卷积)C[mask] sum_{subm ^ subm2 mask} A[subm1] * B[subm2](对称差卷积)而SOS DP子集和可以看作是FWT中“或卷积”的一种特殊情况当其中一个数组是全集指示函数时。学习SOS DP是理解FWT的一个非常好的阶梯。当你看到FWT的代码时会发现其核心结构三层循环与SOS DP惊人地相似只是更新规则略有不同。最后分享一个我个人的使用心得不要死记硬背代码模板。理解其“动态贡献”的核心思想——每一位的1都可以选择“保留”或“去掉”SOS DP高效地聚合了“去掉”的那些选择所带来的贡献。当你遇到一个新问题时先尝试用这个思想去建模思考是否能将问题转化为对每个mask求其所有子集或超集的某种聚合信息。如果能那么SOS DP很可能就是你的那把钥匙。多动手用小的n值模拟整个过程直到你能在脑子里清晰地画出每一位被处理时数据是如何流动和聚合的。这时SOS DP就真正成为你算法工具箱里一件得心应手的武器了。
返回列表