
在算法学习的道路上,总有一些经典题目如同“拦路虎”,既考验我们对数学定义的理解,又要求我们跳出常规思维寻找优化方案。“丑数问题”就是这样一道兼具数学性与编程性的经典题目,今天我们就从定义出发,一步步拆解问题本质,带你掌握高效求解思路,让你再遇到这类问题时能轻松应对!
一、先搞清楚:到底什么是丑数?
首先,我们得明确丑数的数学定义:只包含因子2、3和5的正整数称为丑数,并且习惯上把1作为第一个丑数。
为了让大家更直观地理解,我们来看几个例子:
符合条件的丑数:6(2×3)、8(2³)、10(2×5),这些数的所有质因子都只在2、3、5的范围内。
不符合条件的数:14(包含因子7)、21(包含因子7),因为它们存在2、3、5之外的质因子。
通过简单枚举,我们能发现前6个丑数依次是1、2、3、4、5、6,这个规律可是后续优化解法的重要基础,大家一定要记牢哦!
二、问题分析:别再用暴力法浪费时间啦!
我们常见的丑数问题是“按从小到大的顺序找到第N个丑数”。如果一开始就想到“暴力法”,也就是对每个数依次判断是否为丑数,那可就麻烦了。
比如当N比较大(像N=1000)时,我们需要遍历大量的非丑数(如7、11、13等),这会导致时间复杂度极高,效率特别低,在实际面试或工程应用中根本行不通。
这时候,我们就得转换思路,从丑数的生成规律入手。根据丑数的定义,除了1之外,所有的丑数都是由更小的丑数乘以2、3或5得到的。比如:
4 = 2×2(这里的2是第2个丑数)
6 = 2×3(2是第2个丑数,3是第3个丑数)
9 = 3×3(3是第3个丑数)
既然有这样的规律,那我们就可以“主动生成丑数”,而不是“被动判断丑数”。具体来说,就是用一个数组存储已经排好序的丑数,后面的丑数都由数组中前面的元素推导而来,这样就能大大提高效率。
三、核心难点:如何保证生成的丑数有序?
不过,新的问题又出现了。如果我们直接把数组中所有元素分别乘以2、3、5,再筛选最小值,不仅会有重复计算(比如6既可以由2×3生成,也可以由3×2生成),效率也依然不高。
那该怎么解决呢?关键在于跟踪“有效乘数位置”。对于因子2、3、5,我们分别设置一个“临界位置”,记为t2、t3、t5。这三个临界位置需要满足:
位置t2之前的丑数乘以2,结果都小于当前数组中最大的丑数,这些结果已经存在于数组中了,不需要再考虑,避免重复;
位置t2及之后的丑数乘以2,结果大于当前最大的丑数,这些结果是候选的新丑数。
t3和t5的作用也类似,分别对应因子3和5的临界位置。每次生成新的丑数后,我们就更新这三个临界位置,这样就能确保后续生成的丑数始终是有序的。
四、高效解法实现:多语言代码示例
基于上面的思路,我们可以设计出时间复杂度为O(N)、空间复杂度为O(N)的解法。下面分别给大家展示C++和Python的实现代码,并解析关键步骤。
1. C++实现
#include <vector>
#include <algorithm> // 用于min函数
using namespace std;
class Solution {
public:
int GetUglyNumber_Solution(int index) {
// 前6个丑数是1-6,直接返回index
if (index < 7) {
return index;
}
// 定义数组存储有序丑数,大小为index
vector<int> ugly_nums(index);
// 初始化前6个丑数
for (int i = 0; i < 6; ++i) {
ugly_nums[i] = i + 1;
}
// 初始化三个临界位置:t2对应因子2,t3对应因子3,t5对应因子5
int t2 = 3, t3 = 2, t5 = 1;
// 从第7个丑数开始生成(索引6)
for (int i = 6; i < index; ++i) {
// 新丑数是三个候选值中的最小值
ugly_nums[i] = min(ugly_nums[t2] * 2, min(ugly_nums[t3] * 3, ugly_nums[t5] * 5));
// 更新t2:确保ugly_nums[t2] * 2是下一个大于当前最大丑数的值
while (ugly_nums[i] >= ugly_nums[t2] * 2) {
t2++;
}
// 同理更新t3
while (ugly_nums[i] >= ugly_nums[t3] * 3) {
t3++;
}
// 同理更新t5
while (ugly_nums[i] >= ugly_nums[t5] * 5) {
t5++;
}
}
// 返回第index个丑数(数组索引从0开始)
return ugly_nums[index - 1];
}
};2. Python实现
Python语法更简洁,不需要手动管理数组大小,直接用列表动态追加元素即可:
# -*- coding:utf-8 -*-
class Solution:
def GetUglyNumber_Solution(self, index):
# 前6个丑数直接返回
if index < 7:
return index
# 初始化有序丑数列表
ugly_nums = [1, 2, 3, 4, 5, 6]
# 初始化三个临界位置
t2, t3, t5 = 3, 2, 1
# 生成第7个到第index个丑数
for i in range(6, index):
# 计算候选值并取最小作为新丑数
new_ugly = min(ugly_nums[t2] * 2, min(ugly_nums[t3] * 3, ugly_nums[t5] * 5))
ugly_nums.append(new_ugly)
# 更新临界位置
while ugly_nums[t2] * 2 <= new_ugly:
t2 += 1
while ugly_nums[t3] * 3 <= new_ugly:
t3 += 1
while ugly_nums[t5] * 5 <= new_ugly:
t5 += 1
# 返回第index个丑数
return ugly_nums[index - 1]3. 关键步骤解析
初始化优化:因为前6个丑数固定是1-6,所以当index < 7时,我们可以直接返回index,避免进行多余的计算,节省时间。
临界位置初始化:
t2=3:对应的丑数是4(索引3),4×2=8,这是下一个由因子2生成的丑数;
t3=2:对应的丑数是3(索引2),3×3=9,这是下一个由因子3生成的丑数;
t5=1:对应的丑数是2(索引1),2×5=10,这是下一个由因子5生成的丑数。
循环更新:每次生成新的丑数后,我们通过while循环移动临界位置,确保下一次生成的候选值始终大于当前最大的丑数,这样就能避免重复生成丑数,同时保证丑数的有序性。
五、解法验证:用实例说话
光说不练假把式,我们以“求第7个丑数”为例,来验证一下算法的逻辑是否正确。
初始状态:数组是[1,2,3,4,5,6],t2=3、t3=2、t5=1;
计算候选值:4×2=8、3×3=9、2×5=10,这三个候选值中的最小值是8,把8加入数组后,数组变成[1,2,3,4,5,6,8];
更新临界位置:
因为8 >= 4×2,所以t2更新为4(对应的丑数是5,5×2=10);
8 < 3×3,所以t3保持不变;
8 < 2×5,所以t5保持不变。
最终得到第7个丑数是8,和我们预期的结果一致。如果再进一步求第8个丑数,候选值就是5×2=10、3×3=9、2×5=10,最小值是9,把9加入数组后更新t3=3,逻辑也完全成立。
六、总结:掌握思路,举一反三
丑数问题的核心在于“从生成规律出发,通过跟踪临界位置保证有序性”。和暴力法相比,这种解法把时间复杂度从O(N×K)(其中K是判断一个数是否为丑数的时间)优化到了O(N),空间复杂度是O(N)(用于存储已经生成的丑数)。
而且,这个思路不仅仅适用于丑数问题,还可以推广到“由固定因子生成有序序列”的同类问题中,比如寻找只包含因子2和3的数。这是算法学习中“找规律+优化”思维的典型应用,掌握了这种思维,以后再遇到类似的算法题,就能轻松找到突破口啦!
希望今天的分享能帮助大家彻底搞懂丑数问题,在后续的算法学习和面试中都能更上一层楼!如果大家有其他疑问或者更好的解法,欢迎在评论区留言讨论~

被折叠的 条评论
为什么被折叠?



