求圆周率pi的怪异程序 祖冲之圆周率算法绝技之迷
据说华罗庚曾讲过一个故事,说:有个教书先生喜欢喝酒,一天,他叫学生背圆周率,自己却提壶酒到山上庙里找老和尚喝酒去了。有个聪明的学生把圆周率编了个打油诗“山巅一寺一壶酒,尔乐苦煞吾,把酒吃;酒杀尔杀不死,乐尔乐”。其实是3.1415926535897932384626的谐音。先生一回来,学生居然背了下来,可一想,发现学生是在讽刺他。 究其“山巅一寺一壶酒”的来历,众所周之,可上溯至南北朝时的祖冲之(429—500年),当时获得的结果是圆周率在3.1415926与3.1415927之间。 那么,祖冲之是怎么算出来的呢? 《隋书·律历志》记载,祖冲之所用的方法叫“缀术”,并说祖冲之“所著之书名为《缀术》,学官莫能究其深奥”。传说唐时此书为朝庭钦定教科书,后来《缀术》失传。从而祖冲之的神奇算法成为千古之迷。 作为猜测,华先生曾说“他的算法也是极限的最好说明,他从单位圆的内接正六边形和外切正六边形出发……再作内接的和外切的正12边形、正24边形……边数愈多, 内接的和外切的正6·2n-1边形的面积就愈接近圆的面积,由此可以逐步地精确地算出圆周的长度。”内接和外切正多边形的方法是阿基米德的“穷竭法”。 但仔细一想,用“穷竭法”来算也是有问题的。要得到祖率的精度,需要算正24576边形,也就是从正六边形出发要剖分12次,即同一算法要迭代12次,在用算筹的手工年代要完成如此浩大的计算量其困难是很大的,也可能是很能难实现的。 于是,钱宝琮先生在主编《中国数学史》时猜测采用了与刘徽割圆术相仿的方法,钱先生猜测说:“祖冲之钻研了《九章算术》刘徽注之后,认为数学还应该有所发展,他写成了数十篇专题论文,附缀于刘徽注的后面,叫它‘缀述’。” “缀”有附着之意,故华中王能超先生同意钱先生的观点,认为“缀术”实际上是《九章算术》刘徽注的“祖冲之注”。但“缀术”决不是“缀述”。 然而,“缀”又有拼合、组合之意,故“缀术”可能是刘徽割圆术的组合之术。 王先生坚信“缀术”是割圆术的组合之术,并由此发现了“缀术”是与当今外推算法相类似的一种加速算法,祖冲之肯定是用这种加速算法减少了迭代次数,解决了计算量大的困难。这种加速算法的智慧是阿基米德穷竭法所不能比的。果真如此的话,王先生就破译了缀术的千古疑案,所以林群先生讲“我认为王教授的发现是数学史上的重大事件”。 那么,该如何“组合”呢? 从圆内接正六边形做起,第一次割得正12边形,用S(12)记其面积,刘徽称其为“觚之幂”,于是得: S(12),S(24), S(48), S(96),S(192),…… 刘徽称多边形的面积为幂,而称偏差 A(n)=S(2n)-S(n) 为差幂,并给出了双侧逼近公式,即圆面积介于S(2n)与S(2n)+A(n)之间,由此给出了一个计算圆面积的近似实例: 由此得到圆周率为3.1416,将此实例写成公式就是: 圆面积≈S(2n)+c[S(2n)-S(n)] 即是现代的组合加速技术,其关键是系数c的选取。为此,刘徽在割圆术中说了一句人称“十字文”的话:“以十二觚之幂为率消息”,一千多年来让多少行家费尽思量也不解其迷。王先生从刘徽的思想方式与语言行文特征出发,认为是人们传抄之误,实应为“以十二觚之幂率为消息”,意为系数c的消息在十二觚之幂率中。十二觚之幂率为 D(12)=A(12)/A(24)=[S(24)-S(12)]/[S(48)-S(24)]=3.95 可得系数c=1/(3.95-1)=1/2.95,这是刘徽的伟大之处,也是刘徽与3.1415926失之交臂而把更精致的结果留给了200年后的老祖的原因。如果把 D(12)、D(24)、D(48)、D(96) 都算出来,就会发现它们稳定在4.0上,从而系数c=1/3。如果取c=1/3,只需算到正96边形的S(96)和正192边形的S(192),再利用加速公式就可以得到3.141592646,这时只需要迭代5次而不是12次,也不需要算到正24576边形,大概这就是老祖的绝技之迷。 公元前300年,阿基米德为得到3.14,已经割到正96边形了,如果他老先生知道如何跑得快一点就能喝到一壶酒了,没有离“山巅一寺一壶酒”的一步之遥了。 这一绝技在现代已不重要了,在大学本科的计算方法中只是很简单的外推加速技术。所以,著名的克莱因在《古今数学思想》自叙称:“为着不使资料漫无边际,我忽略了几种文化,例如中国的文化,因为他们的工作对于数学思想的主流没有重大的影响。” 如果再问一个问题:老刘和老祖在那个时代是怎么想到加速与c=1/3的? 一个比较靠得住的猜想是,他们是从数字的分析中直觉地感悟到的,在逻辑上不是从分析逻辑上得到的,而是从直觉逻辑上得到的。有人把这类直觉、感悟叫做智慧。擅长数学主流分析逻辑的西方数学家伊恩·斯图尔特说:“数学的全部力量就在于直觉和严格性巧妙地结合在一起。受控制的精神和富有灵感的逻辑正是数学的魅力所在,也是数学教育者努力的方向。”他更是一语道破了数学的真谛:“直觉是真正的数学家赖以生存的东西”。 无独有偶,迪瓦多内也说:“这些富有创造性的科学家与众不同的地方,在于他们对研究的对象有一个活生生的构想和深刻的了解,这些构想和了解结合起来,就是所谓‘直觉’”。 也许对数学主流思想产生重大影响的恰好是人类的直觉思维,这才是今天人们仍然尊敬刘徽与祖冲之的原因。 如何让数学家经常带点直觉的灵感呢,德国数学家维尔斯特拉斯说:“不带点诗人味的数学家,绝不是一个完美的数学家。” 所以,一个完美而又简单的修练方法就是到学人亭来。 2004年,获王能超先生慧赠《千古绝技“割圆术”——刘徽的大智慧》,谨以此文答谢。 引言 网上流传着一个怪异的求pi程序,虽然只有三行却能求出pi值连小数点前共800位。这个程序如下: /*某年Obfuscated C Contest佳作选录:*/ #include < stdio.h> long a=10000, b, c=2800, d, e, f[2801], g; main(){ for(;b-c;)f[b++]=a/5; for(;d=0,g=c*2;c-=14,printf("%.4d",e+d/a),e=d%a) for(b=c;d+=f[b]*a,f[b]=d%--g,d/=g--,--b;d*=b); } /* (本程式可算出pi值连小数点前共800位) (本程式录自sci.math FAQ,原作者未详)*/ 咋一看,这程序还挺吓人的。别慌,下面就告诉你它是如何做到的,并且告诉你写怪异C程序的一些技巧。^_^ 展开化简 我们知道,在C语言中,for循环和while循环可以互相代替。 for(statement1;statement2;statement3){ statements; } 上面的for语句可以用下面的while语句来代替: statement1; while(statement2){ statements; statement3; } 而且要写怪异的C程序,逗号运算符无疑是一个好的助手,它的作用是: 从左到右依次计算各个表达式的值,并且返回最右边表达式的值。 把它嵌入for循环中是写怪异代码的常用技巧之一。所以,上面的程序可以展开为: #include < stdio.h> /*1*/ /*2*/ long a=10000, b, c=2800, d, e, f[2801], g; /*3*/ main(){ /*4*/ while(b-c!=0){ /*5*/ f[b]=a/5; /*6*/ b++; /*7*/ } /*8*/ d=0; /*9*/ g=c*2; /*10*/ while(g!=0){ /*11*/ b=c; /*12*/ d+=f[b]*a; /*13*/ f[b]=d%--g; /*14*/ d=d/g--; /*15*/ --b; /*16*/ while(b!=0){ /*17*/ d=d*b+f[b]*a; /*18*/ f[b]=d%--g; /*19*/ d=d/g--; /*20*/ --b; /*21*/ } /*22*/ c-=14; /*23*/ printf("%.4d",e+d/a); /*24*/ e=d%a; /*25*/ d=0; /*26*/ g=c*2; /*27*/ } /*28*/ } /*29*/ 现在是不是好看一点了? 进一步化简 你应该能注意到a的值始终是10000,所以我们可以把a都换成10000。再就是,仔细观察g,在外层循环中,每次循环用它做除法或取余时,它总是等于2*c-1,而b总是初始化为c。在内层循环中,b每次减少1,g每次减少2。你这时会想到了吧?用2*b-1代替g!代进去试试,没错!另外,我们还能做一点化简,第26行的d=0是多余的,我们把它合并到第13行中去,第13行可改写为: d=f[b]*a; 。所以程序可以改为: #include < stdio.h> long b, c=2800, d, e, f[2801]; main(){ while(b-c!=0){ f[b]=2000; b++; } while(c!=0){ b=c; d=f[b]*10000; f[b]=d%(b*2-1); d=d/(b*2-1); --b; while(b!=0){ d=d*b+f[b]*10000; f[b]=d%(b*2-1); d=d/(b*2-1); --b; } c-=14; printf("%.4d",e+d/10000); e=d%10000; } } 少了两个变量了! 深入分析 好了,马上进入实质性的分析了。一定的数学知识是非常有必要的。首先,必须知道下面的公式可以用来求pi: pi/2=1+1!/3!!+2!/5!!+3!/7!!+...+k!/(2*k+1)!!+... 只要项数足够多,pi就有足够的精度。至于为什么,我们留给数学家们来解决。 写过高精度除法程序的人都知道如何用整数数组来进行除法用法,而避免小数。其实很简单,回想一下你是如何徒手做除法的。用除数除以被除数,把得数放入结果中,余数乘以10后继续做下一步除法,直到余数是零或达到了要求的位数。 原程序使用的数学知识就那么多,之所以复杂难懂是因为它把算法非常巧妙地放到循环中去了。我们开始具体来分析程序。首先,我们从数学公式开始下手。我们求的是pi,而公式给出的是pi/2。所以,我们把公式两边同时乘以2得: pi=2*1+2*1!/3!!+2*2!/5!!+2*3!/7!!+...+2*k!/(2*k+1)!!+... 接着,我们把它改写成另一种形式,并展开: pi=2*1+2*1!/3!!+2*2!/5!!+2*3!/7!!+...+2*n!/(2*n+1)!! =2*(n-1)/(2*n-1)*(n-2)/(2*n-3)*(n-3)/(2*n-5)*...*3/7*2/5*1/3 +2*(n-2)/(2*n-3)*(n-3)/(2*n-5)*...*3/7*2/5*1/3 +2*(n-3)/(2*n-5)*...*3/7*2/5*1/3 +2*3/7*2/5*1/3 +2*2/5*1/3 +2*1/3 +2*1 对着公式看看程序,可以看出,b对应公式中的n,2*b-1对应2*n-1。b是从2800开始的,也就是说n=2800。(至于为什么n=2800时,能保证pi的前800位准确不在此讨论范围。)看程序中的相应部分: d=d*b+f[b]*10000; f[b]=d%(b*2-1); d=d/(b*2-1); d用来存放除法结果的整数部分,它是累加的,所以最后的d将是我们要的整数部分。而f[b]用来存放计算到b为止的余数部分。 到这里你可能还不明白。一是,为什么数组有2801个元素?很简单,因为作者想利用f[1]~f[2800],而C语言的数组下标又是从0开始的,f[0]是用不到的。二是,为什么要把数组元素除了f[2800]都初始化为2000?10000有什么作用?这倒很有意思。因为从printf("%.4d",e+d/10000); 看出d/10000是取d的第4位以前的数字,而e=d%10000; ,e是d的后4位数字。而且,e和d差着一次循环。所以打印的结果恰好就是我们想要的pi的相应的某4位!开始时之所以把数组元素初始化为2000,是因为把pi放大1000倍才能保证整数部分有4位,而那个2就是我们公式中两边乘的2!所以是2000!注意,余数也要相应乘以10000而不是10!f[2800]之所以要为0是因为第一次乘的是n-1也就是2799而不是2800!每计算出4位,c都要相应减去 14,也就保证了一共能够打印出4*2800/14=800位。但是,这竟然不会影响结果的准确度!本人数学功底不高,无法给出确切答案。(要是哪位达人知道,请发email到xiyou.wangcong@gmail.com告诉我哦。) 偶然在网上见到一个根据上面的程序改写的“准确”(这个准确是指没有漏掉f[]数组中的的任何一个元素。)打印2800位的程序,如下: long b,c=2800,d,e,f[2801],g; int main(int argc,char* argv[]) { for(b=0;b f[b] = 2; e=0; while(c > 0) { d=0; for(b=c;b>0;b--) { d*=b; d+=f[b]*10; f[b]=d%(b*2-1); d/=(b*2-1); } c-=1; printf("%d",(e+d/10)%10); e=d%10; } return 0; } 不妨试试把上面的程序压缩成3行。 结论 以Knuth图灵演讲中的一句话结束全文: We have seen that computer programming is an art, because it applies accumulated knowledge to the world, because it requires skill and ingenuity, and especially because it produces objects of beauty. A programmer who subconsciously views himself as an artist will enjoy what he does and will do it better. 与大家共勉!^_^ |