“今者臣来,见人于大行,方北面而持其驾,告臣曰:’吾欲之楚。’臣曰:‘君之楚,将奚为北面?’曰:‘吾马良。’臣曰:‘马虽良,此非楚之路也。’曰:‘吾用多。’臣曰:‘用虽多,此非楚之路也。’曰:‘吾御者善。’此数者愈善,而离楚愈远耳。” ——《战国策·魏策四》
题目:丑数Ⅱ
设计一个算法,找出只含素因子2,3,5的第n小的数。
符合条件的数如:1,2,3,5,6,8,9,10,12,15…
由于观察可以发现,每一个丑数都和之前的一个丑数有关系,因为只含有这几个因子,那么下一个丑数一定是由之前的丑数乘以他的因子得到的。
暴力解法
因为就只想到了这么多的规律,所以,最初拿到这个题的时候,就以这个规律为解题的思路:对于之前存在的每一个满足条件的数乘以因子数得到之后的数。
所以最初的代码如下:
public int nthUglyNumber(int n){
ArrayList<Integer> al = new ArrayList<>();
al.add(1);
int i = 0;
while(al.size() < n * 2){
int n2,n3,n4,n5;
n2 = al.get(i) * 2;
n3 = al.get(i) * 3;
n4 = al.get(i) * 4;
n5 = al.get(i) * 5;
if(!al.contains(n2)){
al.add(n2);
}
if(!al.contains(n3)){
al.add(n3);
}
if(!al.contains(n4)){
al.add(n4);
}
if(!al.contains(n5)){
al.add(n5);
}
i++;
}
Collections.sort(al);
Iterator<Integer> it = al.iterator();
while(it.hasNext()){
System.out.println(it.next());
}
return al.get(n - 1);
}
这种解法看似合情合理,合乎丑数的定义规律,但是仍存在着一定的问题,其实做题时我也发现了,但是以为是能够克服的小问题,最初不太在意导致程序运行出现了问题。
求出的不是最小的丑数。这种方法虽然能够保证求出的都是丑数,但是无法保证这些数出现的顺序,不只是排列顺序的问题,较小的丑数可能会被排在后面的丑数求出。举个例子,由12求得的数为24,36,60,而由15求出的数为30,45,75,16求出的为32,48,80。由这几个数可以看出30,45,32,48都是小于由12求得的60的,而32也是小于45的。这还是数字比较小的时候就出现的顺序错乱,当数字变得很大,数字量很多的时候这种情况会变得更加严重,即使对已经求出的数字进行了排序的操作,也无法判断当前所需要的位置是否是正确的数。这样也就出现了第二个问题。
空间和时间的消耗过大。由上一个问题可以发现,如果使用这种方式进行计算,那么为了保证在所求位置的数是满足条件的,它的前面没有出现数据的缺失,那么他的计算量会增大好几倍,而随着数据量的增加,计算的开销也会变得很大。合适能够确定已经达到了所需条件也是一个问题。
在上面的那份代码中可以看到是进行过优化的,比如加入4这个因子,来减小因子差距过大带来的差距,包括把所求的数据扩大到2n也是为了在n处能够是满足条件的那个丑数,但是在数据量很大的时候还是出现了问题,我推断数据量进一步增大问题会更加严峻。而且如果继续扩大这个倍数,例如扩大到3n,那么在数据足够大时,int的长度就会不够用了,再用long的话感觉有点过分。(都是测试时出现的血和泪得来的经验啊T_T)
动态规划
说了那么多关于怎么做错的事情,接下来讲一讲正确的解法。这个解法也是利用丑数性质定义做的,只是思路有点不同:因为每个丑数都是由之前的一个丑数乘以因子得到的,那么下一个丑数就应该是之前的某个丑数乘以因子的最小值。
这其实感觉是用了动态规划的思想,当前位置的丑数求解看作是一个求之前某些数乘以因子后最小值的状态,每一次求得之后,状态发生了改变但是求解方法并没有发生变化。而且这样只需要求n个数就能够解决问题,是很不错的方法。
根据这种思路我写了一份代码如下:
public int nthUglyNumber(int n){
int[] nums = new int[n];
nums[0] = 1;
int n2 = 0;
int n3 = 0;
int n5 = 0;
for(int i = 1; i < n;i++){
int next = min(nums[n2] * 2,nums[n3] * 3,nums[n5] * 5);
if(next == nums[n2] * 2){
n2++;
}
if(next == nums[n3] * 3){
n3++;
}
if(next == nums[n5] * 5){
n5++;
}
nums[i] = next;
}
return nums[n - 1];
}
//求最小值
public int min(int i,int j,int k){
if(i < j){
return i < k ? i : k;
}else{
return j < k ? j : k;
}
}
这样一步一步求出来的值就是符合条件的丑数值。
使用遍历
其实看网上的博客我发现还有另一种解法,就是进行遍历,对从1开始的每个数挨个查看,检查是否为满足条件的数,检查的方式也是根据丑数的特点:只含有因子2,3,5。如果满足这个条件就认定为是丑数,把计数器加一:
private boolean IsUgly(int number){
while (number % 2 == 0){
number /= 2;
}
while (number % 3 == 0){
number /= 3;
}
while (number % 5 == 0){
number /= 5;
}
return number == 1 ? true : false;
这种解法虽然是能够求出结果的,但是在计算量上比我自己的那种解法还大,因为要对所有的数进行是否为丑数的判断,无用的计算量太大,当数字逐渐增大时,在时间上是明显不能够满足题目要求的。
问题扩展
素因子可以不为2,3,5,可以换做其他的数,求解的方法也是一样。
问题总结
做题时还是要带上脑子的,特别是一些需要使用所求数据的规律性质的时候要多转一个弯,就像这个题上一样,对于同一个性质,理解方式和侧重点的不同都会使解题的思路不同,结果导致所写出的解法千差万别。找准切入点,找到合适的解法很重要。不然你做的一切不过是南辕北辙。