ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

丑数问题深度解析:从动态规划到多路归并的算法本质

丑数问题深度解析:从动态规划到多路归并的算法本质 1. 从一道经典题看“丑数”与“数的组合”的本质最近在整理算法模板时又翻到了这道经典的“丑数”问题。题目本身不难很多教材和题解里都有现成的答案但每次重看总觉得有些地方讲得不够透。比如为什么这道题能同时作为“丑数”和“数的组合”的模板它的动态规划解法和更直观的“多路归并”思路到底哪个更本质这次上传代码时还遇到了点小插曲正好也借这个机会把这道题里里外外重新梳理一遍分享一些我自己的理解和在实战中容易踩的坑。所谓“丑数”通常指的是质因数只包含2、3、5的正整数。序列 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15... 就是前几个丑数。题目“Humble Numbers”就是要求我们找出第N个这样的数。这问题之所以经典是因为它完美地融合了几个核心的算法思想动态规划的状态定义与转移、多指针或多路归并的技巧以及对“数的组合”这一抽象问题的具体建模。很多人学会了套模板但未必真正理解其背后的“为什么”。接下来我们就抛开模板从问题本身出发一步步推导出最优解并看看在实现过程中有哪些细节需要特别注意。2. 问题拆解丑数序列是如何“生长”出来的要生成丑数序列最朴素的想法是从1开始不断地用2、3、5去乘以前面已经生成的丑数然后从乘积中按顺序选取最小的、尚未被选中的数加入序列。这个思路听起来简单但直接实现效率极低因为会产生大量重复和无效的计算。这里的关键洞察在于每一个新的丑数必然是由某个更小的丑数乘以2、3或5得到的。假设我们已经有了一个按顺序排列的丑数序列dp[1...i]其中dp[1] 1。那么下一个丑数dp[i1]应该是什么它一定是下面三个候选数中的最小值dp[p2] * 2其中p2是这样一个指针它指向的丑数dp[p2]满足dp[p2] * 2是第一个大于dp[i]的、由2相乘得到的丑数。dp[p3] * 3定义类似。dp[p5] * 5定义类似。为什么需要指针p2,p3,p5它们的作用是确保我们总是用每个质因子2,3,5去乘上“尚未被该因子生成过更大丑数”的那个最小的丑数。这样可以避免重复计算并且保证生成的序列是严格递增的。我们可以用一个简单的例子来模拟这个过程。初始状态dp[1]1,p2p3p51。候选1:dp[1]*2 2候选2:dp[1]*3 3候选3:dp[1]*5 5最小的是2所以dp[2]2。此时因为候选12被选中了这意味着指针p2所指向的旧丑数dp[1]已经完成了“乘以2生成新丑数”的使命。如果我们不移动p2下一轮再用dp[1]*2得到的还是2就重复了。所以我们必须将p2向后移动一位指向dp[2]这样下一轮的候选1就变成了dp[2]*24。更新后p22,p31,p51。候选为4, 3, 5。最小是3所以dp[3]3并移动p3。以此类推。这个过程本质上是一个三路归并我们有三个有序的“虚拟”列表列表L2: 所有丑数乘以2构成的序列[1*2, 2*2, 3*2, 4*2, ...]列表L3: 所有丑数乘以3构成的序列[1*3, 2*3, 3*3, 4*3, ...]列表L5: 所有丑数乘以5构成的序列[1*5, 2*5, 3*5, 4*5, ...]而最终的丑数序列就是这三个有序列表归并后的结果同时需要去重。p2,p3,p5就是分别指向这三个列表当前最小候选元素的指针。2.1 为什么这是动态规划从状态转移的角度看我们可以定义dp[i]表示第i个丑数。那么状态转移方程就是dp[i] min(dp[p2]*2, dp[p3]*3, dp[p5]*5)在计算出dp[i]之后我们需要更新那些乘积等于dp[i]的指针。注意这里不是if-else if而是三个独立的if判断。因为有可能最小值同时由多个因子产生例如dp[1]*22和dp[1]*33不会同时等于最小值但dp[2]*36和dp[3]*26就会产生重复值。用三个if可以同时移动多个指针从而自动去重。// 伪代码示例 vectorlong long dp(n1); dp[1] 1; int p2 1, p3 1, p5 1; for (int i 2; i n; i) { long long num2 dp[p2] * 2, num3 dp[p3] * 3, num5 dp[p5] * 5; dp[i] min({num2, num3, num5}); // 关键用独立的if更新指针处理重复值 if (dp[i] num2) p2; if (dp[i] num3) p3; if (dp[i] num5) p5; }这就是该问题的动态规划解法。它的“状态”是序列本身而“决策”就是每一步从三个候选数中选择最小的一个并更新相应的指针。这个模型非常简洁高效时间复杂度 O(N)空间复杂度 O(N)。3. 从“丑数”到“数的组合”模型的泛化思考题目标签里还有“数的组合模板”这怎么理解我们可以把问题抽象一下给定一个质因数集合 S {a, b, c, ...}请生成一个正整数序列使得序列中的每个数的质因数都来自集合 S并且序列是递增的。丑数问题就是 S {2, 3, 5} 的特例。这个抽象模型的应用非常广泛。例如超级丑数质因数集合 S 不再局限于2,3,5而是任意一组给定的质数。第K个质因数只有某些特定数字的数比如找出第N个质因数只包含{2, 7, 13, 19}的数。对于这个泛化问题上述的三指针动态规划方法可以自然地扩展为K 指针法其中 K 是集合 S 的大小。我们为集合 S 中的每个质因数维护一个指针每个指针都指向当前丑数序列中的某个位置。每一步我们都计算 K 个候选值当前指针指向的丑数乘以对应的质因数选取最小值作为下一个数并更新所有产生该最小值的指针。// 泛化模板伪代码 (超级丑数) int nthSuperUglyNumber(int n, vectorint primes) { vectorlong long dp(n1); dp[1] 1; int k primes.size(); vectorint pointers(k, 1); // 每个质因数一个指针初始都指向dp[1] for (int i 2; i n; i) { long long nextNum LLONG_MAX; // 计算所有候选值 for (int j 0; j k; j) { nextNum min(nextNum, dp[pointers[j]] * primes[j]); } dp[i] nextNum; // 更新所有产生最小值的指针 for (int j 0; j k; j) { if (dp[i] dp[pointers[j]] * primes[j]) { pointers[j]; } } } return dp[n]; }这个模板就是“数的组合”问题的核心解法。它解决的是如何系统性地、不重不漏地生成由一组给定“因子”通过乘法组合而成的有序序列。这里的“因子”在丑数里是质数在更广义的问题里可以是任何能够通过乘法组合出新元素的“基”。理解了这个你就掌握了这一类问题的通解。3.1 一个容易忽略的细节整数溢出在实现这个模板时有一个极其重要但容易被忽略的坑整数溢出。丑数增长得很快第1500个丑数已经很大了。如果我们使用int类型来存储dp[i]和中间乘积很容易在计算dp[pointer] * prime时溢出导致程序产生错误结果通常是负数或零进而使指针更新逻辑混乱得到错误的序列。避坑经验在处理这类生成序列的问题时只要涉及乘法且序列可能很长无脑使用long long(C) 或long(Java) 来存储数据和中间计算结果。这是一个成本极低但能避免大量诡异Bug的好习惯。在定义dp数组和计算候选值时务必使用long long。4. 算法实现中的“刺头”指针更新的正确性与去重让我们再深入看一下指针更新的逻辑。为什么必须用三个独立的if而不是if-else if假设某一时刻dp[p2]*2 6,dp[p3]*3 6且6是当前最小值。如果使用if-else ifif (dp[i] num2) p2; else if (dp[i] num3) p3; else if (dp[i] num5) p5;那么只会增加p2p3不动。下一轮计算候选时dp[p3]*3仍然等于6因为p3没变dp[p3]还是2这个6会再次成为候选并因为比新的候选比如dp[p2]*28小而被再次选中导致序列中出现重复的6。使用三个独立的ifif (dp[i] num2) p2; if (dp[i] num3) p3; if (dp[i] num5) p5;当dp[i]6且同时等于num2和num3时p2和p3会同时增加。这样下一轮中dp[p2]和dp[p3]都指向了更大的丑数从而避免了重复值的产生。这个细节是算法正确性的核心保障。它确保了每个丑数只被生成一次三个“虚拟列表”的归并过程是干净且高效的。4.1 另一种理解状态机与决策的独立性我们可以把每个指针p2看作一个独立的状态机它的状态是“当前由因子2负责生成的进度”。它的唯一任务就是确保dp[p2] * 2这个候选值是所有尚未被纳入最终序列的、由因子2生成的数中最小的那个。一旦这个候选值被选中即成为dp[i]就意味着“由dp[p2]通过乘以2来生成新数”的这个机会已经被消耗掉了。p2必须向前推进去查看下一个丑数dp[p21]乘以2会不会产生新的、更大的候选值。p3和p5同理。这三个状态机的运行是完全独立的它们只在“贡献候选值”和“判断自己的候选值是否被选中”时与主流程交互。当多个状态机的候选值同时被选中时它们都应该独立地推进自己的状态。这正是三个独立if语句所表达的语义。5. 实战演练与代码上传的“惊险一刻”理论讲完了我们来点实际的。下面是一个经典的、健壮的丑数问题解法实现C。我特意加上了详细的注释并处理了溢出问题。#include iostream #include vector #include algorithm #include climits using namespace std; /** * 获取第n个丑数质因数仅为2,3,5 * param n 丑数的序号 * return 第n个丑数 */ long long getNthUglyNumber(int n) { if (n 0) return 0; // 使用long long防止溢出 vectorlong long ugly(n 1); ugly[1] 1; // 第一个丑数是1 // 三个指针分别指向下一个将要乘以2、3、5的丑数位置 int idx2 1, idx3 1, idx5 1; for (int i 2; i n; i) { // 计算三个候选值 long long candidate2 ugly[idx2] * 2; long long candidate3 ugly[idx3] * 3; long long candidate5 ugly[idx5] * 5; // 下一个丑数是三个候选值中的最小值 long long nextUgly min({candidate2, candidate3, candidate5}); ugly[i] nextUgly; // 关键独立更新所有产生最小值的指针实现自动去重 if (nextUgly candidate2) idx2; if (nextUgly candidate3) idx3; if (nextUgly candidate5) idx5; } return ugly[n]; } int main() { int n; // 题目通常要求读取到n为0结束 while (cin n n ! 0) { // 一个有趣的转换英文中序数词后缀 string suffix th; if (n % 10 1 n % 100 ! 11) suffix st; else if (n % 10 2 n % 100 ! 12) suffix nd; else if (n % 10 3 n % 100 ! 13) suffix rd; cout The n suffix humble number is getNthUglyNumber(n) . endl; } return 0; }关于代码上传时“可能出问题liao”我猜可能是遇到了在线判题系统OJ的一些常见陷阱输出格式题目要求输出完整的英文句子包括“th”、“st”、“nd”、“rd”这些序数词后缀。这是一个经典的“陷阱”考察细心程度。如果只输出数字肯定会Wrong Answer。输入终止条件题目没说只读一个n而是说“输入包含多组测试数据直到n为0结束”。如果代码只读一次后面的测试数据就会被忽略导致错误。数据类型如前所述使用int可能导致溢出。OJ上的测试数据往往会测到第1500个甚至更靠后的丑数int肯定扛不住。初始化与边界dp[1]1和指针从1开始这是正确的初始化。要确保n为正数时的处理。这些细节看似微小但在OJ上就是“对”与“错”的天壤之别。每次提交前务必对照题目描述逐字逐句检查输入输出格式、边界条件和数据范围。6. 举一反三相关变种问题与思维延伸掌握了丑数问题的核心模型后我们可以轻松解决一系列变种问题。变种1超级丑数如前所述将固定的三个质因数{2,3,5}扩展为任意给定的质数数组primes。解法完全一样只是将3个指针扩展为primes.size()个指针。时间复杂度 O(N * K)其中K是质因数个数。变种2只包含特定因子的第K小数例如找出第N个质因数只包含{2,7}的数。这其实就是primes {2, 7}的超级丑数问题。模型通用。变种3使用堆优先队列的解法我们也可以用小根堆来解。初始将1放入堆中。每次弹出堆顶元素x它就是当前最小的丑数。然后将x*2,x*3,x*5放入堆中。为了避免重复可以用一个哈希集合visited来记录已经生成过的数。long long nthUglyNumber(int n) { priority_queuelong long, vectorlong long, greaterlong long pq; unordered_setlong long seen; vectorint factors {2, 3, 5}; pq.push(1L); seen.insert(1L); long long curr 1; for (int i 0; i n; i) { curr pq.top(); pq.pop(); for (int f : factors) { long long next curr * f; if (!seen.count(next)) { seen.insert(next); pq.push(next); } } } return curr; }堆解法思路直观但时间和空间复杂度都比指针法高每次弹出插入堆操作是O(log M)M是堆大小且需要额外哈希集合去重。在面试中如果先给出堆解法再优化到指针DP解法能很好地展示你的思维深度。思维延伸这为什么是动态规划再回过头看这满足动态规划的两个核心性质最优子结构第i个丑数全局最优序列的第i项可以由前i-1个丑数子问题的最优解通过固定的决策乘2、3、5并取最小得到。无后效性在确定dp[i]时我们只关心当前三个指针的位置而指针如何移动到当前位置的历史信息与未来无关。 因此虽然它看起来不像经典的背包或路径DP但它确实是一个线性DP其“状态”是丑数序列本身“决策”是选择哪个因子来生成下一个数。7. 总结与个人心得丑数问题是一个绝佳的学习案例它麻雀虽小五脏俱全。通过它我们可以深刻理解如何将一个问题建模为有序序列的生成问题。多指针/多路归并技巧在维护多个候选列表时的妙用。动态规划思想如何应用于生成类问题其状态设计和转移方程如何提炼。算法细节的重要性如独立if更新指针以实现去重、使用long long防溢出。代码的鲁棒性包括输入输出格式、边界处理等。我个人在多次实现这道题后最大的体会是理解算法背后的“为什么”远比记住模板代码更重要。比如理解了三个指针分别代表三个虚拟的、有序的乘积序列你就能自然推导出状态转移和指针更新逻辑而不需要死记硬背。再比如理解了去重的本质是多个因子可能生成同一个数你就能明白为什么必须用三个独立的if。最后关于上传代码的“小插曲”这提醒我们在算法竞赛或日常开发中“正确”的代码不仅仅是逻辑正确还必须严格符合问题规范输入/输出格式和环境约束数据范围。养成在实现核心逻辑后反复审视题目描述中的每一个字眼的习惯能帮你避开很多不必要的失分。把这道题吃透无论是作为模板储备还是作为思维训练都大有裨益。
返回列表