ARTICLE DETAIL

资讯详情

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

动态规划解丑数问题:多路归并模板与代码实现详解

动态规划解丑数问题:多路归并模板与代码实现详解 1. 项目概述从“丑数”问题看动态规划的模板化思维看到这个项目标题很多刚接触算法竞赛或者准备面试的朋友可能会有点懵。SHNU_RUSHer、Cloned这些前缀可能代表某个刷题仓库或者个人练习集但核心其实是后半部分动态规划解决“丑数”问题并提炼出“数的组合”模板。这实际上是一个经典的算法学习案例它把看似复杂的数学问题通过动态规划DP拆解成了可复用的解题模式。“丑数”问题是这样的只包含质因数2、3、5的正整数被称为丑数。比如1, 2, 3, 4, 5, 6, 8, 9, 10, 12... 问题是如何高效地找出第n个丑数最直观的暴力方法是逐个数字判断但效率极低。而动态规划提供了一种“用空间换时间”的优雅解法其核心思想不是判断每个数是不是丑数而是主动“生成”下一个丑数。这个生成过程恰恰就是标题中提到的“数的组合模板”的典型应用——通过维护多个指针对应质因数2、3、5从已生成的丑数序列中选择乘以各自因子后最小的那个数作为下一个丑数。这个过程完美体现了动态规划“最优子结构”和“重叠子问题”的特性。所以这个项目标题的价值在于它不仅仅是在解一道题而是在教我们如何把一道经典题目的解法抽象成一种思维框架和代码模板。掌握这个模板你就能解决一系列类似“由特定因子组合生成有序序列”的问题比如“超级丑数”质因数不止2、3、5或者“第n个只有某些质因数的数”。接下来我会彻底拆解这个模板背后的每一个技术细节、设计思路、实现步骤以及那些容易踩坑的地方。2. 核心思路拆解为什么动态规划是丑数问题的“天选之子”2.1 问题本质与暴力法的瓶颈我们首先得理解为什么不能简单地用循环判断的方法。判断一个数是否为丑数的逻辑很简单不断除以2、3、5直到无法整除看最后结果是否为1。但是要找到第1500个丑数这个数可能非常大你从1开始逐个判断需要判断的次数远超过1500次因为越往后丑数的分布越稀疏。这种方法的复杂度几乎是无法接受的尤其是在算法竞赛或面试的时限内。这时就需要转换思路。丑数序列本质上是一个有序序列每个新数都是由序列中已有的某个较小的丑数乘以2、3或5得到的。关键点在于下一个丑数一定是当前某个丑数乘以2、或乘以3、或乘以5的结果并且是大于当前最大丑数的最小值。这个描述是不是很像我们在已知状态中寻找下一个最优状态这正是动态规划可以发挥作用的场景。2.2 动态规划的状态定义与递推关系动态规划解题第一步永远是定义状态。对于丑数问题最直接的状态定义就是设dp[i]表示第i个丑数i从1开始。那么初始状态dp[1] 1因为第一个丑数约定俗成是1。接下来是最关键的递推关系。我们知道dp[i]应该等于min(dp[p2]*2, dp[p3]*3, dp[p5]*5)。这里的p2,p3,p5是三个指针它们初始都指向1即dp[1]。这三个指针的含义是指针指向的丑数是尚未被用于生成乘以对应因子后大于当前最大丑数的最小候选丑数。具体来说p2指向的丑数dp[p2]是第一个乘以2后大于dp[i-1]的丑数。p3指向的丑数dp[p3]是第一个乘以3后大于dp[i-1]的丑数。p5同理。每当我们通过min函数选出了下一个丑数dp[i]后我们需要检查是哪个或哪几个乘积得到了这个最小值。然后将产生这个最小值的指针向前移动一位。因为当前指针所指的丑数已经用过了生成了当前的dp[i]下一个可能由该因子生成的最小丑数就需要用指针下一个位置的丑数来尝试。注意这里有一个非常容易出错的细节。如果dp[i]同时等于dp[p2]*2和dp[p3]*3例如当dp[i]6时可能是3*2也可能是2*3那么p2和p3指针都需要向前移动。这是为了保证每个丑数只被生成一次避免序列中出现重复值。很多初学者实现的版本会在序列中出现重复的6就是因为没有处理好这个“并列最小值”的情况。2.3 从“丑数”到“数的组合模板”的抽象理解了丑数的解法我们就可以把它抽象成一个通用模板。我称之为“多路归并”模板。它的核心场景是你需要生成一个有序序列序列中的每个数都是由之前某些特定的数经过几种固定的“操作”转化而来。在丑数问题里“操作”就是乘以2、3、5。在“超级丑数”问题里“操作”就是乘以一个给定的质数数组。甚至在一些字符串或路径问题中“操作”也可以是添加特定字符或移动方向。这个模板的通用步骤可以归纳为定义状态数组dp[]用于存储生成的有序序列。定义指针数组ptr[]长度等于“操作”的种类数每个指针初始指向序列的起始位置通常是第一个元素。定义因子/操作数组factors[]存储每个操作对应的乘数或更广义的转换规则。循环生成对于i从 2 到 n a. 计算所有候选值candidates[j] dp[ptr[j]] * factors[j]。 b. 找出候选值中的最小值minVal作为dp[i]。 c. 遍历所有候选值将那些值等于minVal的指针ptr[j]加一。这个模板的精髓在于它通过多个指针的并行推进避免了重复计算和排序将时间复杂度从可能的 O(n log n) 或更高降低到了严格的 O(n * k)其中 k 是操作因子的个数。空间复杂度是 O(n k)。对于丑数问题k3这就是一个 O(n) 的完美解法。3. 代码实现与逐行解析理论讲清楚了我们来看代码。这里我会用 Python 和 C 两种语言实现并详细解释每一行代码的意图和容易踩的坑。我们以求解第 n 个丑数为目标。3.1 Python 实现详解def nthUglyNumber(n: int) - int: 返回第n个丑数。 丑数是只包含质因数 2, 3, 5 的正整数。 if n 0: return 0 # 1. 状态定义dp数组存储丑数序列 dp [0] * (n 1) dp[1] 1 # 第一个丑数是1 # 2. 初始化三个指针都指向第一个丑数 p2, p3, p5 1, 1, 1 # 3. 开始动态规划递推 for i in range(2, n 1): # 计算三个候选值 num2 dp[p2] * 2 num3 dp[p3] * 3 num5 dp[p5] * 5 # 选出最小值作为下一个丑数 min_val min(num2, num3, num5) dp[i] min_val # 关键步骤哪个或哪些指针产生了这个最小值就移动哪个指针 # 使用独立的if语句而不是if-elif以处理并列最小值的情况 if min_val num2: p2 1 if min_val num3: p3 1 if min_val num5: p5 1 return dp[n]逐行解析与避坑指南边界处理 (if n 0)这是良好的编程习惯。虽然题目通常保证 n 为正但自己处理边界能防止意外输入导致程序崩溃。dp数组大小我们分配n1的空间并让dp[1]作为起点。这样下标和序号对应更直观。你也可以分配n的空间让dp[0]作为第一个丑数但这样容易在指针和下标计算上出错。指针初始化p2, p3, p5 1, 1, 1。指针的值是dp数组的下标初始都指向dp[1]值为1。这意味着我们准备用第一个丑数去乘以各自的因子。循环中的候选值计算num2 dp[p2] * 2。这里dp[p2]是当前指针指向的丑数。这个丑数乘以2就是由“乘以2”这个操作可能生成的下一个候选丑数。最小值选取min_val min(num2, num3, num5)。这是动态规划状态转移的核心。指针更新的逻辑重中之重这里使用了三个独立的if语句而不是if-elif-else。为什么假设num26,num36那么min_val6。我们需要同时移动p2和p3。如果用了if-elif当min_val num2成立后就不会再去判断min_val num3导致p3指针没有移动。下一次循环dp[p3]可能还是3计算出的num3还是6这就会导致dp数组中再次插入一个6产生重复。这是这个算法最容易出错的地方务必牢记。返回值直接返回dp[n]。测试一下print(nthUglyNumber(10)) # 输出12 print(nthUglyNumber(1)) # 输出1 print(nthUglyNumber(1500)) # 可以快速计算出一个大数3.2 C 实现与性能考量对于追求极致性能的竞赛场景C是更常见的选择。实现逻辑完全一致但需要注意数据类型的选取。#include vector #include algorithm using namespace std; class Solution { public: int nthUglyNumber(int n) { if (n 0) return 0; // 使用vector动态数组初始化为0 vectorint dp(n 1, 0); dp[1] 1; int p2 1, p3 1, p5 1; for (int i 2; i n; i) { // 小心整数溢出使用long long存储中间结果 long long num2 (long long)dp[p2] * 2; long long num3 (long long)dp[p3] * 3; long long num5 (long long)dp[p5] * 5; long long minVal min(num2, min(num3, num5)); dp[i] (int)minVal; // 转换回int存储 // 并列最小值处理 if (minVal num2) p2; if (minVal num3) p3; if (minVal num5) p5; } return dp[n]; } };C实现的特殊注意事项整数溢出这是C实现中最大的坑。当 n 很大时比如第1690个丑数丑数值本身可能超过int的范围虽然本题通常保证在32位有符号整数内但中间计算dp[p2]*2时dp[p2]可能已经很大乘法可能导致临时结果溢出int。因此候选值的计算必须使用更大范围的数据类型如long long。这是很多人在LeetCode上提交C代码出错的主要原因。min函数嵌套C标准库的std::min只接受两个参数。要取三个数的最小值需要嵌套调用min(a, min(b, c))。类型转换计算时用long long存回dp数组时再转换回int。dp数组本身可以保持为int因为最终结果在int范围内。容器选择使用vectorint比原生数组更安全方便。初始化时指定大小和初始值(n1, 0)可以避免未定义行为。实操心得在算法竞赛中遇到这种涉及乘法和可能大数的DP问题养成习惯先把中间计算变量定义为long long。这能帮你省下大量调试时间。4. 模板的威力解决“超级丑数”问题掌握了丑数的模板我们几乎可以秒杀其升级版问题——“超级丑数”。题目定义变为超级丑数是一个正整数它的所有质因数都在给定的质数列表primes中。现在要求第 n 个超级丑数。你会发现这就是我们抽象出来的“多路归并”模板的直接应用。因子从固定的[2,3,5]变成了动态的primes数组。指针从一个变成len(primes)个。Python 实现def nthSuperUglyNumber(n: int, primes: List[int]) - int: if n 0 or not primes: return 0 # dp数组 dp [0] * (n 1) dp[1] 1 # 指针数组长度等于质因数个数初始都指向1 m len(primes) pointers [1] * m for i in range(2, n 1): # 计算所有候选值 candidates [dp[pointers[j]] * primes[j] for j in range(m)] # 找出最小值 min_val min(candidates) dp[i] min_val # 更新所有产生最小值的指针 for j in range(m): if min_val candidates[j]: pointers[j] 1 return dp[n]代码解析通用性代码结构和丑数问题如出一辙。我们把固定的p2, p3, p5换成了长度可变的pointers列表。列表推导式candidates [dp[pointers[j]] * primes[j] for j in range(m)]这行代码优雅地生成了所有候选值是Python简洁性的体现。循环更新指针内层for循环遍历所有指针判断并更新。这保证了即使有多个相同的候选最小值所有对应的指针都会被移动。这个实现的时间复杂度是 O(n * m)其中 m 是质数列表的长度。空间复杂度是 O(n m)。如果 m 很大比如有上百个质数每次循环求min(candidates)的 O(m) 操作可能成为瓶颈。一个优化思路是使用**优先队列最小堆**来动态维护候选最小值可以将每次获取最小值的时间复杂度降到 O(log m)。但即便如此其核心的“多指针归并”思想依然不变。5. 常见问题与深度排查指南在实际编写和调试这类动态规划代码时你肯定会遇到一些典型问题。下面我把自己和学生们常踩的坑整理出来并给出排查思路。5.1 问题一序列中出现重复数字症状运行程序打印出的丑数序列里出现了重复的数字例如[1, 2, 3, 4, 5, 6, 6, 8, ...]。根本原因指针更新逻辑错误使用了if-elif-else而不是多个独立的if。正如之前强调的当多个候选值并列最小时必须同时移动所有对应的指针。排查与修复检查指针更新部分的代码。确保是如下结构if min_val num2: p2 1 if min_val num3: p3 1 if min_val num5: p5 1绝对不要写成if min_val num2: p2 1 elif min_val num3: # 错误如果num2和num3相等p3就不会移动 p3 1 else: p5 15.2 问题二结果错误或溢出C特有症状对于较大的 nC程序输出的结果错误甚至是负数。根本原因整数溢出。在计算dp[p2] * 2时dp[p2]可能已经接近int最大值约21亿乘以2后直接溢出变成一个很小的负数或乱码导致后续min函数选取了错误的值。排查与修复将所有中间计算变量num2,num3,num5,minVal的类型从int改为long long。在乘法运算前进行强制类型转换确保计算在long long范围内进行。修改后的正确计算方式long long num2 (long long)dp[p2] * 2; long long num3 (long long)dp[p3] * 3; long long num5 (long long)dp[p5] * 5; long long minVal min(num2, min(num3, num5)); dp[i] (int)minVal; // 存回时转换5.3 问题三性能低下针对超级丑数症状当质数列表primes很长时例如几百个求解第 n 个超级丑数速度很慢。根本原因每次循环中计算min(candidates)需要 O(m) 的时间总共 O(n*m)当 m 很大时效率低。优化方案使用优先队列最小堆思路是我们不每次都计算全部候选值再求最小而是维护一个最小堆堆中每个元素是一个三元组(value, prime, pointer_idx)表示由第pointer_idx个指针、乘以质数prime所能生成的下一个候选值value。每次从堆顶取出最小值放入dp数组然后根据取出的元素更新对应的指针计算新的候选值并压入堆中。Python优化代码示例import heapq def nthSuperUglyNumber_heap(n: int, primes: List[int]) - int: dp [0] * (n 1) dp[1] 1 m len(primes) # 最小堆元素为 (候选值, 质因数, 指针下标) heap [] for j in range(m): # 初始用第一个丑数1乘以各个质因数生成初始候选 heapq.heappush(heap, (primes[j], primes[j], j)) # 指针数组记录每个质因数当前指向的丑数下标 pointers [1] * m for i in range(2, n 1): # 取出当前最小候选值 val, prime, idx heapq.heappop(heap) dp[i] val # 移动产生该值的指针 pointers[idx] 1 # 计算新的候选值并加入堆中 next_candidate dp[pointers[idx]] * prime heapq.heappush(heap, (next_candidate, prime, idx)) # 关键堆顶可能还是相同的值因为不同路径可能生成相同丑数 # 我们需要跳过重复值 while heap and heap[0][0] dp[i]: val, prime, idx heapq.heappop(heap) pointers[idx] 1 next_candidate dp[pointers[idx]] * prime heapq.heappush(heap, (next_candidate, prime, idx)) return dp[n]堆优化要点去重逻辑while循环是关键。因为dp[pointers[idx]] * prime可能再次生成刚刚被取出的dp[i]例如6可以由2*3和3*2生成。我们需要不断弹出堆顶的重复值并更新指针直到堆顶是一个新的最小值。复杂度每次堆操作是 O(log m)总体复杂度约为 O(n log m)在 m 较大时优势明显。5.4 问题四对“第一个丑数是1”的理解偏差这是一个概念性问题。为什么1是丑数因为1没有质因数按照定义“所有质因数都在集合{2,3,5}中”空集是任何集合的子集所以1符合定义。这是一个数学上的约定也是我们动态规划能够启动的“初始状态”。没有这个1整个递推链条就无法开始。在面试中明确说出这一点能体现你对问题本质的理解。6. 模板的延伸与思维训练掌握了“丑数”模板你的武器库里就多了一件解决组合生成类问题的利器。我们可以做几个思维练习看看这个模板思想还能用在什么地方。练习1第n个只有质因数2和3的数这太简单了直接把模板里的因子5去掉只用p2和p3两个指针即可。练习2第n个“光滑数”光滑数是指质因数全部小于等于某个给定数 k 的正整数。例如5-光滑数就是丑数。对于更大的 k比如求第n个 7-光滑数质因数只有2,3,5,7。这就是我们模板的直接应用因子数组为[2,3,5,7]四个指针。练习3生成排序的幂序列假设你有三个排序数组如何生成所有可能的a[i] b[j] c[k]的和并按升序输出前n个这个问题可以看作是“丑数”问题的三维扩展。我们可以维护三个指针i, j, k但候选值变成了a[i]b[j]c[k]的各种组合不这样太复杂。更通用的思路是使用优先队列BFS思想。初始将(a[0]b[0]c[0], 0,0,0)入堆。每次弹出最小值(sum, i,j,k)然后将(a[i1]b[j]c[k], i1,j,k)、(a[i]b[j1]c[k], i,j1,k)、(a[i]b[j]c[k1], i,j,k1)这三个可能的下一个状态入堆需去重。这其实是“多路归并”思想在更高维度上的应用。通过这些练习你会发现动态规划模板的价值不在于死记硬背代码而在于理解其背后的状态定义思想和多指针或多源推进的优化策略。当你遇到一个新问题时先问自己这个问题能否被看作是在生成一个有序序列序列中的下一个元素是否能由已有元素的某种固定组合方式得到如果答案是肯定的那么“丑数”模板的变体很可能就是你的解题钥匙。最后关于代码风格和实战我个人习惯在写这类DP时一定会先写清楚状态定义dp[i]代表什么并用注释写明。在循环更新指针后可以加一行调试输出打印出i, dp[i], p2, p3, p5的值这对于验证算法正确性、尤其是排查重复值问题有奇效。记住清晰的逻辑和充分的测试比写出看似高深的一行代码要重要得多。
返回列表