读本科时就对编程很感兴趣,最近在学习Java,据说学编程要写博文,那就来写写吧。
该问题源自《Thinking in Java》中的一道习题(找出4位数中所有的“吸血鬼”数字)。
所谓“吸血鬼”数字是这样定义的:
1、位数为偶数;
2、可由一对数字相乘得到,而这对数字各包含乘积的一半位数的数字,其选取的数字可采取任意顺序;
3、以两个00结尾的数字排除。
例如:1260 = 21*60,,1827 = 21 * 87,2187 = 27 * 81。
算法可以采取两种策略:
1、选取一个4位数,利用其各位数字组合出两个2位数,比较这两个2位数的积与4位数是否相等;
2、选取两个2位数,计算它们的乘积,比较乘积与原来的两个2位数的组成数字是否一致。
大概考虑了一下,就放弃了第一种策略,不说“利用其各位数字组合出两个2位数”可能会比较复杂,单从循环执行次数来考虑的话,
采用策略1:需要执行9999-1000+1=9000次,而且由于不知道“吸血鬼”数字具备什么样的性质,只能排除以00结尾的数字共10*9=90个,其他8910数字都要做判断;
采取策略2,需要执行(99-10+1)(99-10+1)= 8100次,可以进一步优化到(89+1)*90/2=4050次,还可以排除掉二者乘积小于1000的情况以及乘积是00结尾的情况,进一步减少需要执行的次数。
没有对这两种策略进行实际测试,但网上看到有对两种策略算法的比较,认为策略2的算法好于策略1,这里就不讨论策略1的具体算法了,有兴趣的人可以自己搜一下。
策略1的算法还有一个问题是“利用其各位数字组合出两个2位数”这步操作,网上提供的对4位数的算法中是直接枚举结果的,因而难以扩展到其他情况,更一般情况的算法估计会比较复杂,所以从考虑可以查找其他位数的“吸血鬼”数字的角度也应优先考虑策略2。
确定了采用策略2之后,就来解决具体问题了。上代码:
/**找出给定位数的数字中所有的吸血鬼数字
* @param digits int
* @return Set<Integer>
*/
public static Set<Integer> findVampireNumbers(int digits) throws Exception {
//按两数相乘算法,可能出现重复,6位数125460=204*615=246*510
//避免重复应使用Set来保存结果
if (digits % 2 != 0){
throw new Exception("arg digits must be even!");
}
Set<Integer> vampires = new TreeSet<>();
int min = IntHelper.minOfInt((byte)(digits/2));
int max = IntHelper.maxOfInt((byte)(digits/2));
int minOfProduct = IntHelper.minOfInt((byte)digits);
label:
for (int facter1 = min; facter1 <= max; facter1++){
//可以不考虑facter1==facter2的情况
for (int facter2 = max; facter2 > facter1; facter2--){
int product = facter1 * facter2;
if (product <= minOfProduct){
continue label;
}
//添加一个新的过滤条件:(product - facter1 - facter2) % 9 != 0
//运行时间可大大减少
if (product % 100 == 0 || (product - facter1 - facter2) % 9 != 0){
continue;
}
//此改进(改用统计个数的方法)可使运行时间大大减少
//if (isVampire(facter1,facter2,digits)){
//if (isVampire1(facter1,facter2)){
if (isVampire2(facter1,facter2)){
vampires.add(product);
}
}
}
return vampires;
}
判断是否为“吸血鬼”数字的方法:
public static boolean isVampire(int facter1, int facter2, int digits){
List<Byte> factorsDigits = IntHelper.getAllDigits(facter1);
factorsDigits.addAll(IntHelper.getAllDigits(facter2));
List<Byte> productDigits = IntHelper.getAllDigits(facter1*facter2);
Collections.sort(factorsDigits);
Collections.sort(productDigits);
int k;
for (k = 0; k <= digits-1; k++){
if (factorsDigits.get(k) != productDigits.get(k)){
break;
}
}
if (k == digits){
return true;
}
return false;
}
下面是网上看到的算法:
public static boolean isVampire1(int facter1, int facter2){
//网上方法,思路与isVampire()一致,使用数组代替列表,但测试结果时间反而加倍
String[] factorsDigits = (String.valueOf(facter1)+String.valueOf(facter2)).split("");
String[] productDigits = String.valueOf(facter1*facter2).split("");
Arrays.sort(factorsDigits);
Arrays.sort(productDigits);
if (Arrays.equals(factorsDigits,productDigits)){
return true;
}
return false;
}
思路基本是一致的,网上的算法采用的String数组来实现的,我是采用List<Integer>来实现的,从我在自己的电脑上测试的情况来说,List<Integer>实现的方法要快一些。
由于一开始没注意循环条件写的有误,导致程序进入死循环,让我误以为这个算法效率低,于是开始考虑是否有更高效的方法。这里对“吸血鬼”数字的验证,是通过获得各位数字之后,先进行排序,再一一比较而得出的结果,其实我们并不关心排序,排序只是为了能够进行一一比较,那么考虑另一种思路:我们完全不必去获取各位数字,只需要统计0-9的数字的出现次数,一样可以验证结果。下面是改进后的算法:
public static boolean isVampire2(int facter1, int facter2){
//采用统计0-9数字出现次数的方法
int[] facter1Digits = IntHelper.countDigits(facter1);
int[] facter2Digits = IntHelper.countDigits(facter2);
int[] productDigits = IntHelper.countDigits(facter1 * facter2);
for (int i = 0; i <= facter1Digits.length-1; i++){
facter1Digits[i] += facter2Digits[i];
}
if(Arrays.equals(facter1Digits, productDigits)){
return true;
}
return false;
}
经本地测试,该算法的速度更快,应该是一个比较不错的算法了,暂时就考虑到这里了。
这是我的辅助类:
class IntHelper {
/**输出给定位数的整数的最小值
* 处理范围为1-Integer.MAX_VALUE(2147483647)
* 超出范围返回-1
* @param digit byte value of 1-10
*/
public static int minOfInt(byte digit){
if (digit < 1 || digit > 10){
return -1; //结果超出int范围
}
int min = 1;
while (--digit > 0){
min *= 10;
}
return min;
}
/**输出给定位数的整数的最大值
* 处理范围为1-Integer.MAX_VALUE(2147483647)
* 超出范围返回-1
* @param digit byte value of 1-9
*/
public static int maxOfInt(byte digit){
if (digit < 1 || digit > 9){
return -1; //结果超出int范围
}
int max = 9;
while (--digit > 0){
max = max * 10 + 9;
}
return max;
}
/**输出给定数字的各位数字
* 给定数字小于等于0时返回空List
* @param num int
* @return List<Byte>
*/
public static List<Byte> getAllDigits(int num){
List<Byte> digits = new ArrayList<>();
while (num > 0){
digits.add((byte)(num % 10));
num /= 10;
}
return digits;
}
/**输出给定数字的各位数字出现次数的统计结果
* 给定数字小于等于0时返回全为0的数组
* @param num int
* @return int[]
*/
public static int[] countDigits(int num){
int[] digits = new int[10];
Arrays.fill(digits,0);
while (num > 0){
digits[num % 10]++;
num /= 10;
}
return digits;
}
本地测试比较结果:
运行时间ms | digits=4 | digits=6 | digits=8 |
---|---|---|---|
采用isVampire() | 15-32 | 78-94 | 5421-5860 |
采用isVampire1() | 16-32 | 188-203 | 15672-16156 |
采用isVampire2() | 0-16 | 31-47 | 1610-2031 |
关于此问题其他一些小点:
1、从网上看到的,对需要判断的数字进行筛除的另一个条件:(product - facter1 - facter2) % 9 != 0。
原因:若product为一个4位“吸血鬼”数字,则product可写成1000a+100b+10c+d,而facter1+facter2可写成xa+yb+wc+zd(其中x,y,w,z为10或1),则product -facter1 - facter2=(1000-x)a+(100-y)b+(10-w)c+(1-z)d,(1000-x)为999或990,(100-y)为99或90,(10-w)为9或0,(1-z)为0或-9,均可被9整除。
添加该筛除条件后,算法执行时间有明显减少,digits=4,6,8时采用isVampire2()不添加该筛除条件时的运行时间分别为0-16,125-141,12969-13547ms。
2、也是网上看到的,在执行二重循环时,facter2可以从facter1+1开始,不必从facter1开始,因为平方数不可能是“吸血鬼”数字。
3、采用策略2的算法存在的一个问题,会出现重复的结果,在6位数中存在一个:125460=204*615=246*510,开始时使用List来保存结果就出现问题了(查找6位“吸血鬼”数字时,总数为142,实际应为141),后来换成TreeSet(结果可以按序输出),执行起来会比List慢,未验证使用List获得结果后再剔除重复值的方法是否会比TreeSet快,考虑到虚拟机对TreeSet可能有内部优化,应该还是使用TreeSet好一些,而且Set本就是设计用来保存不重复值的,除非确定不可能出现重复值。
4、在对乘积太小、位数不符合要求的情况(如10*11=110 < 1000)做筛除时,我采用的是if条件判断,然后continue label跳到外层循环,网上的方法是通过
int from = Math.max( 1000 / facter1, facter1 + 1);
int to = Math.min( 1000*10 / facter1, 99);
提前设定facter2的范围来实现的,运行效果差别不大。