动态规划的“生成”之美:三路指针,优雅构建「丑数」序列
哈喽,各位,我是前端小L。
我们的DP之旅,已经探索了求最优解、求路径、求可能性的各种问题。那些问题,大多是在一个“给定”的输入上,进行分析和计算。今天,我们将迎来一种全新的DP范式——“生成式DP”。我们的目标,不再是分析一个现成的序列,而是要亲手生成、构建一个满足特定规则的序列。
这,就是“丑数II”。它将向我们展示,动态规划如何像一个精密的“数字工厂”,按照“2, 3, 5”这三条核心生产线,源源不断地、按顺序地制造出我们想要的数字。
力扣 264. 丑数 II
https://leetcode.cn/problems/ugly-number-ii/
题目分析: “丑数”是指只包含质因数 2
, 3
, 5
的正整数。1
通常被认为是第一个丑数。 我们的目标是,找到第 n
个丑数。
前几个丑数是 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, ...
7
不是丑数(因为它有质因数7)。11
也不是。
核心洞察: 每一个丑数(除了1),都必然是由另一个更小的丑数乘以 2
, 3
, 或 5
得到的。
-
2 = 1 * 2
-
3 = 1 * 3
-
4 = 2 * 2
-
5 = 1 * 5
-
6 = 2 * 3
(或者3 * 2
)
这个“递推”的性质,是DP的沃土。
思路一:常规武器——最小堆 (Priority Queue)
既然每个丑数都能生成3个更大的“候选”丑数,而我们又总是想要当前“最小”的那个,最小堆这个数据结构就自然而然地浮现在我们脑海中。
算法流程:
-
初始化一个最小堆,并将第一个丑数
1
放入。 -
为了防止重复(比如
2*3
和3*2
都会生成6
),我们还需要一个哈希集合Set
来记录已经入过堆的数。 -
循环
n
次: a. 从堆中弹出最小的元素current_ugly
。这就是我们按顺序找到的第i
个丑数。 b. 将current_ugly
分别乘以2, 3, 5
,得到三个新的候选丑数。 c. 对于每个新的候选丑数,如果它没在Set
里出现过,就把它加入堆和Set
。
评价: 这个方法是正确的,思路也很清晰。时间复杂度是 O(n log n)(每次堆操作是log n),空间复杂度是 O(n)。这是一个非常不错的通用解法,但在追求极致的我们看来,还有提升空间!
思路二:“三路归并”的DP神之一手 (O(n))
让我们换个角度。丑数的序列 [1, 2, 3, 4, 5, 6, ...]
本身是一个有序序列。 这个有序序列,可以看作是由三个“子序列”归并而成的:
-
序列A:所有丑数
* 2
->[1*2, 2*2, 3*2, 4*2, ...]
=[2, 4, 6, 8, ...]
-
序列B:所有丑数
* 3
->[1*3, 2*3, 3*3, 4*3, ...]
=[3, 6, 9, 12, ...]
-
序列C:所有丑数
* 5
->[1*5, 2*5, 3*5, 4*5, ...]
=[5, 10, 15, 20, ...]
我们的目标,就是从这三个有序的“候选”序列中,不断地挑出最小的那个,来构建我们的主序列。这不就是“合并k个有序链表”的经典思想吗!
1. DP状态定义: dp[i]
表示第 i
个(1-indexed)丑数。我们的目标是 dp[n]
。
2. 状态转移的“三指针”: 为了高效地从三个“候选”序列中取最小值,我们不需要真的把它们都生成出来。我们只需要用三个指针,分别指向这三个序列中,下一个将要被考虑的“父丑数”的位置。
-
p2
: 指向序列A中,下一个该乘以2的丑数在dp
数组中的索引。 -
p3
: 指向序列B中,下一个该乘以3的丑数在dp
数组中的索引。 -
p5
: 指向序列C中,下一个该乘以5的丑数在dp
数组中的索引。
状态转移方程: 下一个丑数 dp[i]
,必然是三个候选者中的最小值: dp[i] = min(dp[p2] * 2, dp[p3] * 3, dp[p5] * 5)
指针的移动 (关键细节!): 在确定了 dp[i]
之后,我们需要检查这个最小值是由哪个(或哪些)候选者产生的,然后把对应的指针向前移动一步。
-
如果
dp[i] == dp[p2] * 2
,说明序列A的当前候选者被选中了,p2++
。 -
如果
dp[i] == dp[p3] * 3
,说明序列B的当前候选者被选中了,p3++
。 -
如果
dp[i] == dp[p5] * 5
,说明序列C的当前候选者被选中了,p5++
。
注意: 这里的 if
不能写成 else if
!因为可能会有相等的情况,比如 6 = 2 * 3 = 3 * 2
。此时,p2
和 p3
都需要向前移动,以避免将来产生重复的丑数。
代码实现 (三指针DP)
class Solution {
public:int nthUglyNumber(int n) {// dp[i] 表示第 i+1 个丑数vector<int> dp(n);dp[0] = 1;// 三个指针,指向下一个要被乘的丑数在dp数组中的索引int p2 = 0, p3 = 0, p5 = 0;for (int i = 1; i < n; ++i) {int next2 = dp[p2] * 2;int next3 = dp[p3] * 3;int next5 = dp[p5] * 5;// 找到三个候选者中的最小值dp[i] = min({next2, next3, next5});// 移动指针if (dp[i] == next2) {p2++;}if (dp[i] == next3) {p3++;}if (dp[i] == next5) {p5++;}}return dp[n - 1];}
};
总结:DP的“生成式”思维
今天这道题,为我们展示了动态规划的一种全新应用范式——生成式DP。 它不再是分析一个已有的输入,而是从一个初始状态(dp[0]=1
)开始,按照一套固定的生成规则,逐步构建出整个问题的解空间。
“三指针”技巧,是这种模型下的一个极其优雅的实现。它本质上是对“多路归并排序”思想的巧妙运用,将一个 O(n log n) 的问题,优化到了线性的 O(n)。
当你未来遇到一个需要“从小到大生成一个满足特定规则的序列”的问题时,希望你的脑海中,能够浮现出今天这个“三路指针”的优美身影。
咱们下期见~