十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

动态规划三指针法:从丑数问题到数的组合模板精讲

动态规划三指针法:从丑数问题到数的组合模板精讲 1. 从一道“丑数”题说起为什么它值得你花时间如果你正在准备算法竞赛或者刷LeetCode、牛客网那么“丑数”这个题目你一定不陌生。它常常以“Ugly Number”或“Humble Numbers”的名字出现题目要求是找出第N个只包含质因数2、3、5的正整数。乍一看这题似乎很简单——不就是生成一堆数然后排序吗但当你真正动手去实现尤其是当N的规模达到几千甚至上万时你才会发现一个高效的解法远比你想象的要精妙。这道题之所以被冠以“模板”的称号是因为它完美地诠释了动态规划中一种经典的思想利用已知状态递推生成未知状态。它不像背包问题那样有复杂的决策过程也不像最长公共子序列那样需要二维的状态转移。它的状态转移方程清晰、直接但构建这个方程的思路却是解决一大类“数的组合”问题的金钥匙。掌握了这个模板你不仅能秒杀丑数问题还能触类旁通解决诸如“超级丑数”质因数扩展、“第K个与2、3、5乘积相关的数”等一系列变种。今天我们就来彻底拆解这个“丑数/数的组合”模板。我不会只给你一段可以“复制粘贴”的代码而是要带你走一遍完整的思考路径从最直观的暴力解法开始分析其瓶颈然后引入动态规划的核心思想一步步推导出最优解最后我们还会探讨这个模板的通用性以及在实际编码中那些容易让你“翻车”的边界条件和调试技巧。无论你是算法新手还是想巩固动态规划思想的老手这篇文章都能让你有所收获。2. 暴力法的困境为什么“生成后排序”走不远面对“找出第N个丑数”这个问题最直接的想法是什么很多人第一反应是我不断地用2、3、5去乘生成一大堆数去掉重复的然后排序最后取第N个不就行了这个思路完全正确并且极其容易实现。我们可以写一个循环用一个集合Set来存储已生成的数以避免重复用一个最小堆优先队列来保证每次都能取出当前最小的数进行扩展。代码大概长这样以Python为例import heapq def nthUglyNumber_naive(n): if n 0: return 0 factors [2, 3, 5] seen {1} heap [1] ugly 1 for _ in range(n): ugly heapq.heappop(heap) # 取出当前最小的丑数 for factor in factors: new_ugly ugly * factor if new_ugly not in seen: seen.add(new_ugly) heapq.heappop(heap, new_ugly) return ugly这个方法在逻辑上无懈可击对于小的N比如N100它运行得很快。但是它的时间复杂度是O(N log N)因为每次堆操作插入和删除是O(log N)并且我们生成了远多于N个的数因为每个数会生成3个新数很多是重复的。空间复杂度则是O(N)因为我们需要存储所有生成的丑数。当N变大比如N1500时这个方法的效率瓶颈就非常明显了。堆里会维护大量元素每次弹出和插入的代价都在增长。更重要的是这种方法没有利用丑数序列的内在规律它只是在盲目地生成和排序是一种“广度优先”的暴力搜索。在算法竞赛中这样的解法通常无法通过所有测试用例因为时间限制会卡得很紧。那么规律是什么我们观察一下丑数序列1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, ... 你会发现除了1以外每一个丑数都是由另一个更小的丑数乘以2、3或5得到的。例如4 2 * 2这里的2是丑数26 2 * 3丑数2和丑数310 2 * 5丑数2和丑数5。这个观察是突破的关键。它意味着我们不需要维护一个庞大的堆和集合去盲目生成而是可以有序地、一个接一个地构造出丑数。这就是动态规划思想的用武之地。3. 动态规划的精髓三指针法与状态转移既然每个丑数都是由更小的丑数乘上2、3、5得来那么如果我们已经知道了前k个丑数第k1个丑数怎么找它一定是某个已知丑数乘以2、3或5后得到的比当前最大丑数稍大一点的那个最小值。这里就引出了动态规划解法的核心维护三个指针或索引。我们定义三个指针p2,p3,p5它们分别表示下一个将要乘以2、3、5的丑数在丑数序列中的位置。同时我们维护一个数组dp其中dp[i]表示第i1个丑数dp[0] 1。算法的过程可以形象地理解为有三条“生产线”生产线2专门生产dp[p2] * 2生产线3专门生产dp[p3] * 3生产线5专门生产dp[p5] * 5每一轮我们从这三条生产线的“待出厂产品”中选出最小的那个作为下一个丑数放入dp数组。然后关键的一步来了是哪条或哪几条生产线产出了这个最小丑数就把那条生产线的指针向后移动一位。因为这条生产线当前用的“原材料”dp[p2],dp[p3],dp[p5]已经用过了下一个产品应该用更新后的、稍大一点的丑数作为原材料来生产。3.1 逐步推演与状态转移方程让我们手动推演前几个丑数的生成过程来理解这个精妙的机制。初始化dp [1](第一个丑数是1)p2 p3 p5 0(三个指针都指向第一个丑数1)生成第二个丑数候选值dp[p2]*2 1*2 2,dp[p3]*3 1*3 3,dp[p5]*5 1*5 5最小值为2。所以dp[1] 2。最小值2来自“生产线2”所以p2向后移动一位p2 1。此时dp [1, 2],p21, p30, p50。生成第三个丑数候选值dp[p2]*2 2*2 4,dp[p3]*3 1*3 3,dp[p5]*5 1*5 5最小值为3。所以dp[2] 3。最小值3来自“生产线3”所以p3向后移动一位p3 1。此时dp [1, 2, 3],p21, p31, p50。生成第四个丑数候选值dp[p2]*2 2*2 4,dp[p3]*3 2*3 6,dp[p5]*5 1*5 5最小值为4。所以dp[3] 4。最小值4来自“生产线2”所以p2向后移动一位p2 2。此时dp [1, 2, 3, 4],p22, p31, p50。生成第五个丑数候选值dp[p2]*2 3*2 6,dp[p3]*3 2*3 6,dp[p5]*5 1*5 5最小值为5。所以dp[4] 5。最小值5来自“生产线5”所以p5向后移动一位p5 1。此时dp [1, 2, 3, 4, 5],p22, p31, p51。生成第六个丑数注意去重候选值dp[p2]*2 3*2 6,dp[p3]*3 2*3 6,dp[p5]*5 2*5 10最小值为6。所以dp[5] 6。这里有一个至关重要的细节6同时是“生产线2”和“生产线3”的产物。如果我们只移动一个指针比如只移动p2那么下一轮“生产线2”的候选值将变成dp[3]*24*28而“生产线3”的候选值还是dp[1]*32*36。这会导致下一轮我们又选出了6造成重复。正确的做法是对于所有产出当前最小值的生产线它们的指针都应该后移。所以p2和p3都要移动p2 3,p3 2。此时dp [1, 2, 3, 4, 5, 6],p23, p32, p51。通过这个推演我们可以总结出状态转移方程和算法步骤状态定义dp[i]表示第 i1 个丑数。初始化dp[0] 1,p2 p3 p5 0。状态转移对于i从 1 到 n-1 1.next2 dp[p2] * 2,next3 dp[p3] * 3,next5 dp[p5] * 52.dp[i] min(next2, next3, next5)3. 如果dp[i] next2则p24. 如果dp[i] next3则p35. 如果dp[i] next5则p5最终结果dp[n-1]这个算法的时间复杂度是严格的 O(N)因为我们需要循环N次每次循环内的操作都是常数时间。空间复杂度也是 O(N)用于存储丑数序列。相比暴力法这是一个质的飞跃。4. 代码实现与关键细节剖析理解了原理代码实现就水到渠成了。但魔鬼藏在细节里有几个地方如果不注意很容易写出有Bug的程序。4.1 基础版本实现Pythondef nthUglyNumber(n: int) - int: if n 0: return 0 # 或者根据题目要求返回-1等 dp [0] * n dp[0] 1 # 第一个丑数是1 p2 p3 p5 0 for i in range(1, n): # 计算三条生产线的下一个候选值 next2 dp[p2] * 2 next3 dp[p3] * 3 next5 dp[p5] * 5 # 选出最小值作为下一个丑数 dp[i] min(next2, next3, next5) # 关键哪个些生产线产出了这个最小值其指针就后移 # 必须用独立的if而不是elif以处理重复值如62*3 if dp[i] next2: p2 1 if dp[i] next3: p3 1 if dp[i] next5: p5 1 return dp[n-1]4.2 必须注意的“坑”去重逻辑上面代码中用三个独立的if语句而不是if-elif-else这是处理像6这样的由多个质因数乘积构成的丑数的关键。如果使用elif当dp[i]同时等于next2和next3时只会移动p2导致p3停滞下一轮又会生成重复的6。整数溢出问题虽然丑数增长很快第1690个丑数已经接近2^31但在Python中整数是任意精度的所以不用担心。然而如果你用C或Java等语言实现dp[p2]*2这样的计算可能导致32位整数溢出。一个常见的技巧是使用长整型long long或者在判断最小值时先比较dp[p2]、dp[p3]、dp[p5]与INT_MAX / factor的关系但这在丑数问题中通常不是问题因为题目给定的N范围有限。不过养成检查数据范围的习惯是好的。初始化与边界dp[0]必须初始化为1。对于n0的情况需要根据题目要求返回特定值如0或-1。在循环中i从1开始确保我们生成的是第2到第N个丑数。指针的语义一定要明确p2,p3,p5指向的是已经存在于dp数组中的丑数的索引。它们表示“下一个将要乘以2/3/5的基数”。这个基数是动态更新的确保了我们可以用O(N)的时间线性生成序列。5. 模板的威力从“丑数”到“超级丑数”的泛化掌握了三指针法你就掌握了一类问题的通解。这个模板的精髓在于维护多个指针每个指针指向一个已生成的“基础数”然后通过乘以不同的“因子”来生成候选值每次选取最小的候选值加入序列并更新对应的指针。让我们来看一个直接的变种超级丑数。题目描述变为找出第N个超级丑数。超级丑数的定义是所有质因数都出现在一个给定的质数列表primes中。例如primes [2, 7, 13, 19]那么超级丑数序列的前几项是1, 2, 4, 7, 8, 13, 14, 16, 19, 26, 28, 32, ...你会发现这和标准丑数问题完全一样只不过因子从固定的[2, 3, 5]变成了一个长度可变的列表primes。我们的解法只需要做一个小小的泛化将三个指针p2, p3, p5扩展为一个长度与primes相同的指针数组indicesindices[k]表示下一个将要乘以primes[k]的超级丑数在dp中的索引。在每一轮中我们计算len(primes)个候选值dp[indices[k]] * primes[k]。选出最小值作为新的超级丑数。遍历所有候选值将那些等于最小值的候选值所对应的指针indices[k]加1。代码实现如下def nthSuperUglyNumber(n: int, primes: List[int]) - int: if n 0 or not primes: return 0 dp [0] * n dp[0] 1 # 指针数组长度等于质因数个数 indices [0] * len(primes) for i in range(1, n): # 计算所有生产线的候选值 candidates [dp[indices[k]] * primes[k] for k in range(len(primes))] # 选出最小值 dp[i] min(candidates) # 更新所有产出最小值的生产线的指针 for k in range(len(primes)): if dp[i] candidates[k]: indices[k] 1 return dp[n-1]看模板的威力显现了。我们几乎没怎么改动核心逻辑就解决了一个更一般化的问题。时间复杂度是 O(N * K)其中K是质因数列表的长度。空间复杂度是 O(N K)。注意在K很大时比如成百上千每一轮计算候选值和查找最小值min(candidates)会成为瓶颈时间复杂度是O(K)。一个常见的优化是使用**最小堆优先队列**来动态维护当前最小的候选值。堆中每个元素是一个元组(value, prime, index)表示由质因数prime乘以第index个超级丑数得到的值value。每次从堆中弹出最小值将其作为新的超级丑数然后为这个质因数生成下一个候选值index1并推入堆中。这样可以避免每一轮都遍历所有K个候选值将时间复杂度优化到 O(N log K)。这是模板的又一次进阶应用。6. 举一反三模板在“数的组合”问题中的其他应用“丑数”模板的本质是有序生成由一组基数和一组乘数通过乘法组合而成的序列。这个思想可以迁移到许多其他场景。场景一合并K个有序链表这是LeetCode上的一道经典题。你有K个升序排列的链表需要将它们合并成一个新的有序链表。最直观的解法是每次比较K个链表当前头节点的值取出最小的。这和我们从K个候选值K条生产线中选取最小值的过程何其相似我们可以维护一个大小为K的最小堆堆中存放每个链表当前的节点值。每次弹出堆顶最小值将其加入结果链表然后将该节点所在链表的下一个节点如果存在加入堆中。这其实就是“超级丑数”解法中提到的堆优化思路。场景二查找和最小的K对数字给定两个升序数组要求找出和最小的K个数对每个数对来自两个数组各一个元素。我们可以将第一个数组的每个元素与第二个数组的第一个元素配对得到N个初始数对和。然后每次取出和最小的数对(u, v)那么下一个潜在的更小数对很可能是(u, v的下一个元素)。这又形成了一个“多指针”推进的模型可以用最小堆来高效管理。场景三第K个与给定质因数相关的数这是丑数问题的直接变体可能要求找出第K个只包含质因数[a, b, c]的数或者第K个可以被表示为a^i * b^j * c^k形式的数i, j, k为非负整数。解法完全套用模板只是因子的组合方式可能更复杂但核心的“多指针递推”思想不变。通过这些例子我希望你看到学习算法不是死记硬背代码而是理解其背后的模式Pattern和思想。“丑数”模板教给我们的是当问题可以分解为多个子序列或生产线有序合并时用多指针最小值选取的策略往往能在线性或近似线性的时间内解决问题。7. 实战调试与性能考量理论很美好但把代码写对、写快还需要一些实战经验。7.1 如何验证你的算法对于丑数问题一个简单的验证方法是输出前N个丑数与已知序列如1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 27, 30, 32, 36...进行比对。你可以写一个小的测试函数def test_ugly(n): result [] for i in range(1, n1): result.append(nthUglyNumber(i)) print(result) # 对比已知序列 known [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 27, 30, 32, 36] for i in range(min(n, len(known))): if result[i] ! known[i]: print(fError at position {i1}: expected {known[i]}, got {result[i]}) return print(Test passed for first, n, numbers.)对于边界情况要重点测试n1应返回1和n0或负数根据题目要求处理。7.2 性能分析与优化我们实现的动态规划解法时间复杂度是O(N)空间复杂度是O(N)。对于竞赛或面试场景这通常已经足够优秀。但在某些极端情况下比如N非常大或者需要在线查询很多次第K个丑数我们还可以考虑一些优化预计算与缓存如果问题需要多次查询不同的N我们可以预先计算一个足够大的丑数数组比如前10000个然后每次查询直接返回dp[N-1]。这是一种典型的“空间换时间”策略。堆优化版的动态规划如前所述对于超级丑数且质因数列表K很大的情况用堆维护候选值可以将每轮选取最小值的时间从O(K)降到O(log K)。虽然总体复杂度变为O(N log K)但在K很大时这比O(NK)要好得多。数学方法了解即可丑数问题实际上有更深的数学背景与“正则数”有关。理论上第N个丑数的大小增长是O(N log N / log log N)级别的并且有公式可以近似估计。但在编程竞赛中动态规划解法是绝对的主流和首选因为它简单、可靠、高效。7.3 一个常见的思维误区有些初学者可能会想我能不能用三个独立的队列分别存放乘以2、3、5得到的数然后每次从三个队首取最小值这个想法很接近但实现起来会发现你仍然需要处理重复值并且队列会无限增长管理起来不如指针数组清晰。三指针法本质上是“隐式”地维护了这三个队列dp数组就是那个最终合并后的有序序列p2, p3, p5就是这三个队列的“读指针”。这种抽象使得代码非常简洁。8. 从模板到思维动态规划的“状态”与“选择”最后让我们跳出这道题看看它对我们理解动态规划有什么帮助。动态规划的核心是定义“状态”和找到“状态转移方程”。在丑数问题中状态dp[i]表示第 i1 个丑数。这是一个很自然的状态定义。选择为了得到dp[i]我们可以从哪些已有的状态转移过来根据定义dp[i]必须是某个更小的丑数乘以2、3或5。但具体是哪一个呢这就是难点。三指针法的精妙之处在于它没有显式地去遍历所有更小的丑数来尝试乘以2、3、5那样是O(N^2)而是维护了三个“最有可能”产生下一个最小丑数的位置。这背后的思想是“贪心”与“动态规划”的结合我们确信下一个丑数一定是由当前某个指针指向的丑数乘以对应因子得到的并且我们每次只推进产出最小值的那个些指针。这种“多指针维护候选集每次选取最优”的模式在动态规划中被称为多路归并Multi-way Merge。它适用于状态转移依赖于多个有序子序列的情况。所以当你以后再遇到类似“有序生成符合某种规则的序列”的问题时不妨问问自己新的元素能否由旧的元素通过某种规则如乘法、加法、拼接生成生成规则是否涉及多个“来源”或“因子”我能否维护几个指针或索引来跟踪每个“来源”下一个可能产生新元素的位置如果答案是肯定的那么“丑数”模板很可能就是你的解题钥匙。回过头看标题中的“【数的组合模板】”这个概括非常精准。它不仅仅是“丑数”的模板更是一类通过乘法或其它运算组合生成有序序列问题的通用解法框架。理解并熟练运用这个框架你的动态规划武器库里就又多了一件趁手的兵器。
返回列表