趣味编程之计算相亲数(上)
一直想写这篇关于算法的文章,但是由于看到园子里众多研究算法的高手让我一直没有决心写下来,但高手归高手,不是高手也可以写出来让高手们拍砖,所以今天就在这里献丑了。相亲数和完全数作为数学问题的确是顶级难题,可是拿来做娱乐就不同了,从刚接触编程时C语言书上的课后习题到两年前的Intel多核编程大赛,这个题目一直伴随着我们,让我们来娱乐一下吧。
简单说一下概念,相亲数是指两个正整数中,彼此的全部约数之和(本身除外)与另一方相等。举例来说:
220的全部约数(除掉本身)相加是:1+2+4+5+10+11+20+22+44+55+110=284
284的全部约数(除掉本身)相加的和是:1+2+4+71+142=220
所以220和284就是一对相亲数。
那什么是完全数呢?即它所有的真因子(即除了自身以外的约数)的和恰好等于它本身。例如:
第一个完全数是6,它有约数1、2、3、6,除去它本身6外,其余3个数相加,1+2+3=6
第二个完全数是28,它有约数1、2、4、7、14、28,除去它本身28外,其余5个数相加,1+2+4+7+14=28
概念不多说了,直接上算法。
算法一:直接计算约数之和
这是最直接的方式了,循环计算所有可能成为约数的数字然后加和,直接到不用做过多的解释,只要会写程序,用任何语言都能实现!
1: /// <summary>
2: /// 直接计算约数之和(串行)
3: /// </summary>
4: public class Algorithm15: {6: private int GetSum(int num)7: {8: int sum = 1;
9: int limit = (int)Math.Sqrt(num);10: for (int i = 2; i <= limit; i++)11: if (num % i == 0) sum += i + num / i;
12: return sum;
13: }14:15: public void Run(int from, int to)16: {17: int perfertCount = 0;
18: int amicablePairCount = 0;
19: for (int num = from; num <= to; num++)20: {21: int sum1 = this.GetSum(num);22: if (sum1 == num)
23: {24: Console.WriteLine("{0}是完全数", num);
25: perfertCount++;26: }27: if (sum1 > num)
28: {29: int sum2 = this.GetSum(sum1);30: if (sum2 == num)
31: {32: Console.WriteLine("{0}和{1}是一对相亲数", sum1, sum2);
33: amicablePairCount++;34: }35: }36: }37: Console.WriteLine("在{0}到{1}中共有{2}个完全数和{3}对相亲数", from, to, perfertCount, amicablePairCount);
38: }39: }
测试代码,从2计算到5000000:
1: static void Main(string[] args)2: {3: var stopwatch = Stopwatch.StartNew();4: Algorithm1 algorithm = new Algorithm1();
5: algorithm.Run(2, 5000000);6: stopwatch.Stop();7: Console.WriteLine("计算完成共花费{0}秒", stopwatch.Elapsed.TotalSeconds);
8: Console.ReadKey();9: }
在我的ThinkPad R400上测试运行时间大概在51秒左右,速度能不能再提高呢,让我们看看.Net4.0为我们带来的并行计算的新特性表现如何。
1: /// <summary>
2: /// 直接计算约数之和(并行)
3: /// </summary>
4: public class Algorithm25: {6: private int GetSum(int num)7: {8: int sum = 1;
9: int limit = (int)Math.Sqrt(num);10: for (int i = 2; i <= limit; i++)11: if (num % i == 0) sum += i + num / i;
12: return sum;
13: }14:15: public void Run(int from, int to)16: {17: int perfertCount = 0;
18: int amicablePairCount = 0;
19: Parallel.For(from, to, num =>20: {21: int sum1 = this.GetSum(num);22: if (sum1 == num)
23: {24: Console.WriteLine("{0}是完全数", num);
25: perfertCount++;26: }27: if (sum1 > num)
28: {29: int sum2 = this.GetSum(sum1);30: if (sum2 == num)
31: {32: Console.WriteLine("{0}和{1}是一对相亲数", sum1, sum2);
33: amicablePairCount++;34: }35: }36: });37: Console.WriteLine("在{0}到{1}中共有{2}个完全数和{3}对相亲数", from, to, perfertCount, amicablePairCount);
38: }39: }
注意第19行,我们使用System.Threading.Tasks下的Parallel类取代传统的for循环,由于在该算法中每一次计算都是独立的,所以很适合并行,废话不多说,直接运行看结果,运行时间在26秒左右,由于我的机器是双核,所以同样是从2计算到5000000,并行的时间差不多是之前的(51秒)一半,看来Parallel真是不错的新工具啊!当然,这个是从技术上达到了速度的提升,算法本质还没有变,那能不能从算法本身提高计算效率呢?答案当然是肯定的!
算法二:通过计算所有质因数来计算约数之和
先说一下原理:记得小学的奥赛有一个题型是计算一个自然数的约数的个数(现在还有没有不清楚),截法很简单,先分解质因数,然后把所有质因子的幂加一再相乘就是约数的个数,例如数字36=2^2×3^2,那么36就有(2+1)×(2+1)=9个约数(1,2,3,4,6,9,12,18,36)。其实能算出有9个约数,自然可以计算9个约数是什么,是2个2和2个3排列组合的结果,剩下的就不用我废话了,该算法的思路就是先分解质因数然后在逐个计算约数并加和,上代码:
1: /// <summary>
2: /// 通过计算所有质因数来计算约数之和(串行)
3: /// </summary>
4: public class Algorithm35: {6: private int GetNextFactor(int num)7: {8: int limit = (int)(Math.Sqrt(num));9: for (int i = 2; i <= limit; i++)10: if (num % i == 0)
11: return i;
12: return num;
13: }14: private List<int> Decomposition(int num)15: {16: var factors = new List<int>();17: while (true)18: {19: var divisor = GetNextFactor(num);20: factors.Add(divisor);21: if (divisor == num) break;22: num = num / divisor;23: }24: return factors;
25: }26: private int Sum(List<int> divisors)27: {28: int sum = 0;
29: for (int i = 0, count = divisors.Count - 1; i < count; i++)30: sum += divisors[i];31: return sum;
32: }33: private int GetSum(List<int> factors)34: {35: if (factors.Count == 1) return 1;//质数36: var divisors = new List<int>() { 1 };37: var factorPows = new List<List<int>>() { new List<int>() { factors[0] } };38: for (int i = 1, count = factors.Count; i < count; i++)39: {40: var length = factorPows.Count;41: if (factors[i] == factorPows[length - 1][0])
42: factorPows[length - 1].Add(Convert.ToInt32(Math.Pow(Convert.ToDouble(factors[i]), Convert.ToDouble(factorPows[length - 1].Count + 1))));43: else
44: factorPows.Add(new List<int>() { factors[i] });45: }46: for (int f = 0, fCount = factorPows.Count; f < fCount; f++)47: for (int d = 0, dCount = divisors.Count; d < dCount; d++)48: for (int p = 0, pCount = factorPows[f].Count; p < pCount; p++)49: divisors.Add(divisors[d] * factorPows[f][p]);50: return Sum(divisors);
51: }52: public void Run(int from, int to)53: {54: int perfertCount = 0;
55: int amicablePairCount = 0;
56: for (var num = from; num < to; num++)
57: {58: int sum1 = this.GetSum(Decomposition(num));59: if (sum1 == num)
60: {61: Console.WriteLine("{0}是完全数", num);
62: perfertCount++;63: }64: if (sum1 > num)
65: {66: int sum2 = this.GetSum(Decomposition(sum1));67: if (sum2 == num)
68: {69: Console.WriteLine("{0}和{1}是一对相亲数", sum1, sum2);
70: amicablePairCount++;71: }72: }73: }74: Console.WriteLine("在{0}到{1}中共有{2}个完全数和{3}对相亲数", from, to, perfertCount, amicablePairCount);
75: }76: }
先看速度如何,是否比算法一更快,从2计算到5000000花费近27秒,几乎与算法一的并行版本接近,果然快很多,那么快在哪里了呢?算法一做了大量的取模和除法操作,相比于加、减、乘等操作这些操作都是很耗时的,而算法二先计算质因数,减少了很多取模和除法操作,取而代之的是很多乘法和指数运算,性能自然得到提升,但还算细心的我并没有就此下结论,我把计算范围缩小到2到100000,此时,算法一花费时间是0.18秒而算法而则是0.36秒,算法一反而取胜了,其实道理很简单,随着数字的增大,算法一计算取模和除法的操作增加也不断增加,而算法二随着数字增大计算次数却增加不明显,因为分解质因数其实是找质数的过程,但找到第一个质因数后,数字就变小了,计算量并没增加多少,相反,找到的质因数越多,计算约数的计算量就越大,所以在数字不大(相对不大)的领域里,试商次数不多所以算法一很快完成了计算,而算法二相对于算法一的却没什么太大优势,但随着数字的变大,算法二的优势会越来越明显!例如我从5000000计算到5500000,算法一就豪无优势了,落后算法二一半都不止,这让我想起了一个古老的但却众所周知的梵塔问题,算法一就是这样一个梵塔……
当然我也没忘记把这个算法改成并行版本,我就不贴代码了,只要改第56行的for就可以了,从2计算到5000000花费在16秒左右,这样我们已经从最初的51秒降低到16秒,还能更快吗?我绞尽脑汁暂时还没什么结果,因为我发现最后所花的时间都是在寻找质数上了,难怪数学界的顶级难题都跟质数有关或者围绕质数展开,还有我们程序员熟悉的加密算法RSA,也是基于大整数难以分解的“特性”上,如果不幸被我找到了快速分解算法我就不用在这写博客啦,扯远了,还是回归娱乐吧,我们还有没有办法让它再快点呢?
算法二SP1:之所以叫SP1是因为它并没有本质上的更改,只是在外围让它显得更快。话说算法界有两大秘籍,九阴真经和九阳神功——都不是,开个玩笑,其实没什么秘籍,而是方法论,无非就是时间换空间和空间换时间,根据我们的需要,时间和空间在我们的代码中转换来转换去,既然我追求的是速度,那自然是用空间来换时间喽。
算法一我还没想到哪里可以用空间来换取速度,倒是在对算法二的研究过程中我意识到大量的重复计算,最典型的是计算质数,如果缓存这些质数速度会不会快一些呢,其实是废话当然会快,花了空间速度还没提升的事情谁会愿意做呢,但仅仅缓存质数远远不够,因为最大量的计算根本不在这里,如果连续的看待分解的过程,你就会发现它是一个递归的过程,之前的计算结果对后面很有用,比如我们已经分解了36=2^2×3^2。那么当我们分解72的时候是怎样的过程呢,先找到了第一个因子2,然后得到36,继续分解,36的分解过程又做一次,重复量可想而知,有人说,你把2^2×3^2记录下来,在计算72的时候直接利用36的计算结果,说的没错,但我记录的信息是不是太大了,空间也不是这么挥霍的啊,于是我权衡再三,采用了下面的算法,先看代码:
1: /// <summary>
2: /// 通过计算所有质因数来计算约数之和(空间算法)
3: /// </summary>
4: public class Algorithm55: {6: public List<int> primeList = new List<int>();7: public int[] firstFactorList;8: public int[] remainingList;9: public int[] resultList;10: public int GetNextFactor(int num)11: {12: var max = (int)Math.Sqrt(num);
13: for (int i = 0; i < primeList.Count; i++)14: {15: var p = primeList[i];16: if (p > max) break;17: if (num % p == 0)
18: return p;
19: }20: primeList.Add(num);21: return num;
22: }23: public List<int> Decomposition(int num)24: {25: var divisors = new List<int>();26: var factor = firstFactorList[num] = GetNextFactor(num);27: if (factor == num)
28: remainingList[num] = 1;29: else
30: remainingList[num] = num / firstFactorList[num];31: while (true)32: {33: divisors.Add(firstFactorList[num]);34: if (remainingList[num] == 1) break;35: num = remainingList[num];36: }37: return divisors;
38: }39: private int Sum(List<int> divisors)40: {41: int sum = 0;
42: for (int i = 0, count = divisors.Count - 1; i < count; i++)43: sum += divisors[i];44: return sum;
45: }46: public int GetSum(List<int> factors)47: {48: if (factors.Count == 1) return 1;//质数49: var divisors = new List<int>() { 1 };50: var factorPows = new List<List<int>>() { new List<int>() { factors[0] } };51: for (int i = 1, count = factors.Count; i < count; i++)52: {53: var length = factorPows.Count;54: if (factors[i] == factorPows[length - 1][0])
55: factorPows[length - 1].Add(Convert.ToInt32(Math.Pow(Convert.ToDouble(factors[i]), Convert.ToDouble(factorPows[length - 1].Count + 1))));56: else
57: factorPows.Add(new List<int>() { factors[i] });58: }59: for (int f = 0, fCount = factorPows.Count; f < fCount; f++)60: for (int d = 0, dCount = divisors.Count; d < dCount; d++)61: for (int p = 0, pCount = factorPows[f].Count; p < pCount; p++)62: divisors.Add(divisors[d] * factorPows[f][p]);63: return Sum(divisors);
64: }65: public void Run(int limit)66: {67: firstFactorList = new int[limit];68: remainingList = new int[limit];69: resultList = new int[limit];70: int perfertCount = 0;
71: int amicablePairCount = 0;
72: for (var num = 2; num < limit; num++)
73: {74: var result = resultList[num] = this.GetSum(Decomposition(num));
75: if (result == num)
76: {77: Console.WriteLine("{0}是完全数", num);
78: perfertCount++;79: }80: else if (result < num && resultList[result] == num)81: {82: Console.WriteLine("{0}和{1}是一对相亲数", result, num);
83: amicablePairCount++;84: }85: }86: Console.WriteLine("在{0}到{1}中至少有{2}个完全数和{3}对相亲数", 2, limit, perfertCount, amicablePairCount);
87: }88: }
我缓存了质数,每个数字的第一个因子和剩余因子的乘积以及每个数字的约数和,代码之所以没有注释是因为我写不清楚,给大家举个例子,大家再对照代码看就好:比如分解72,先找到它的第一个因子2,和剩余因子的乘积36(其实就是72/2),然后缓存2和36做为72对应的缓存变量,然后在缓存列表中找到36的第一个因子(因为之前已经计算过),也是2,然后看看36剩余因子的乘积是18有找到了18的第一个因子9……就这样利用了原来的结果,把取模和除法变成了查(缓存)表,这样无疑有个弊端,我们不能从中间开始算了,必须从2开始计算,先不管了,看看速度再说,从2计算到5000000花费了13秒左右,注意这里计算次数跟之前不一样,之前的算法是算到5000000,但可以超过5000000,而现在是只算到5000000,不管怎样,速度比并行版本的算法二还要快一些(前提是从2开始计算),缓存的效果不错哦,不过内存也被吃去大片,空间换时间的代价……
算法二SP2:本来以上就是我要记录的全部内容,但由于迟迟未成文,所以最近我又发现一个“超级”快速的空间换时间的算法,比SP1快很多,请允许我先卖一个关子,先公布结果,从2计算到5000000只需要2.8秒,具体实现且听下回分解……(无耻的淫笑)
后记:郑重声明,本贴纯属娱乐帖,很多代码并没有在细节性能上过多纠缠(比如Math.Sqrt的性能还有提升空间,List申请空间处的性能提升等等……),另外卖关子不是吊胃口,而是要说明这个SP2算法所花费的篇幅可能比较长,实在写不动了,就卖了一个关子,小弟会尽快完成下一篇,敬请期待!不过通过把这篇文章写出来(很多代码多年前就写好了)还是感触颇深的,我们面对一个问题,可以从多个角度去分析优化解决,可以从根本上想办法(算法本身),也可以找工具(并行),还可以在外围和结构等方面想办法(缓存……),只要我们不停的思考,从多个角度想办法,总会有些手段给我们利用。最后,不知大家是不是也研究过此类问题,或许您有更好的算法,无论是出于兴趣还是其它,欢迎一起讨论and拍砖。
希望本文对您有帮助(完整代码下篇一起提供)。
转载请遵循此协议:署名 - 非商业用途 - 保持一致