神话世界
印度教三大主神梵天、毗湿奴和湿婆,分别掌管宇宙的创造、维护和毁灭。印度人相信宇宙如恒河沙数,循环往复。据说混沌未开之时,毗湿奴沉睡在千头蛇阿南塔身上,漂浮在茫茫的宇宙之海。毗湿奴做了一个梦,结果他的肚脐中就长出一朵莲花,而花中就诞生出了创造之神梵天。创世后毗湿奴就会醒来并维护世界,而当莲花枯萎,世界毁灭之时,所有的众生庙宇和梵塔都将毁灭;此时阿南塔就会用一千个头将沉睡的毗湿奴包裹起来,继续漂浮在一无所有的混沌中,等待新一轮的创世…这个印度教的创造之神梵天,在泰国就是大名鼎鼎的四面佛。(左图:上方为梵天,下方为毗湿奴和其妻子吉祥天;右图为毁灭之神湿婆)
在印度贝那勒斯有座梵天寺,门前立着左中右三根柱子。据说创世之初,湿婆来找毗湿奴约定世界毁灭之期,结果毗湿奴显然已经酣然入梦。湿婆神只好与毗湿奴的小跟班梵天约定期限。梵天的妻子是智慧女神辨才天女,她就让梵天与湿婆约定:梵天寺门前3根金刚柱上的从下往上按照大小顺序摞着的64片黄金圆盘、被一次一片从最左边的柱子移动到最右边的柱子上时,世界将在一声霹雳中由湿婆神毁灭,到时众生和庙宇以及梵天都将同归于尽。也就从那个时候开始,不管白天还是黑夜,梵天寺总有一个婆罗门在按照誓约移动黄金圆盘:一次一片,且任何时候三根金刚柱上必须保持大盘在下,小盘在上。
虽然婆罗门僧侣都是智者,但他们对解决梵天的要求争论不休。最重要的问题是,要移动64个金盘子到第三根柱子上,第一步究竟是将最小的盘子移动到第二根柱子,还是第三根柱子上?也难怪,正所谓一步错,步步错!其中一个最聪明的婆罗门站了出来,说你们都别唧唧歪歪了,先让我们试验几个小盘子看看吧!所有婆罗门长者都没有表示反对。于是:
1) 如果最左边的柱子上只1个金盘子要移动到第三根柱子,只需要1步即可:1->3。Duang!毁灭之神湿婆就会立马上门收水费!
2) 如果最左边的柱子上有2个金盘子要移动到第三根柱子,依然比较简单,只需要把小的盘子先挪到中间的那根柱子上暂时放着,然后把露出来的大金盘子移动到第三根柱子上,最后把较小的盘子从第二根柱子移动到第三根上:1->2, 1->3, 2->3,共3个步骤!
婆罗门又继续试验了3、4、个金盘子的情况,然后他们聚在一起召开了总结大会。除了表彰搬动金盘子汗如雨下的劳模们,他们发现了一个重要规律:如果盘子数是奇数1、3…第一步一定是将最小的盘子挪到第三根柱子(目标柱子)上:1->3。否则如果盘子数是偶数2、4…第一步一定是将最小的盘子挪到第二根柱子上:1->2。而且他们还发现,如果一旦开始挪动后,不选择回退的话,下一步挪动将几乎是一个唯一选择!于是…婆罗门们欣然向梵天汇报了试验成果,决定正式开工:将摞着第一根柱子上的64个金盘子最上面的一个,挪动到了中间的那根柱子上(1->2),第二大的盘子被移动到了第三根柱子上(1-3)…于是,世界万物开始动起来了——据说宇宙大爆炸就是从彼时开始,一直演化到我们今天这个样子!
斗转星移,梵天寺的僧侣们换了一代又一代,可是到现在那64个金盘子好像从来没有移动过似的。为了那个神圣的誓言,僧侣们确实从来没有偷懒停止过工作,世界也依然很美好没有终结!…时间一下子到了上世纪四十年代末的某一天,大洋彼岸的美利坚合众国一帮人搞出了电脑这个玩意儿,然后用电脑计算了移动64个金盘子所需要的时间:假如每次移动耗时1秒钟,移动64个盘子到第三根柱子需要的时间是 2^64-1=18446744073709551615秒,换算成时间是5845.54亿年(根据这个时间长度和《薄伽梵往世书》可以推算出梵天的寿命,也就是当下世界的寿命约为梵天大神67.65天),看来僧侣们的任务任重道远, 因为地球到目前为止才45亿年,估计到金盘移完的时候,世界早就灰飞烟灭了!
程序世界
在计算机编程教程中,汉诺塔问题经常被视为函数递归求解的经典案例。其思路非常简单直观:将除了最底下盘子外的所有盘子借助第三根柱子c(目标柱子)移动到第二根柱子b;然后将最底下的那个盘子从第一根柱子a(来源柱子)移动到第三根柱子c(目标柱子);最后将第二根柱子b上的所有盘子,按照同样的方法借助第一根柱子a移动到第三根柱子c上。这种目标导向的抽象解法一方面体现了人类高度的抽象能力,但人们依然不能明确知道第一步到底是该将最小的盘子从a移动到b还是c?是的,思想正确,行动模糊!
下面就让我们在SAS里用函数来实现汉诺塔递归求解吧。在SAS中PROC FCMP 是用来封装函数最中正可靠的方法。原因很简单,它具有过程式语言的函数框架,而且变量具有和别的语言一样的作用域功能。函数内局部变量和调用堆栈是实现递归函数的基石!
/*HANOI(1)– FCMP by yinliangwu@gmail.com SAS Institute Inc.*/
proc fcmp outlib=work.funcs.math;
function move( from $, to $);
put from "->" to;
return(1);
endsub;
function hanoi(n, from $, mid $, to $);
count=0;
if n=1 then do;
/*当柱子上只有一个盘子时,从 from 柱移动到 to 柱*/
count=move (1, from, to);
end;
else do;
/*否则先将上面n-1个盘子,从 from 柱借助 to 柱移动到 mid 柱*/
count=count+ hanoi( n-1, from, to, mid);
/*将 from 柱上露出的最大盘子移动到 to 柱*/
count=count+ move(from, to);
/*最后将 mid 柱上的n-1个盘子,接住 from 柱移动到 to 柱上*/
count=count+ hanoi( n-1, mid, from, to);
end;
return (count);
endsub;
run;
options cmplib=work.funcs;
data mydata;
input n from $ mid $ to $;
totalcount=hanoi(n, from, mid, to);
put "Total count: " totalcount;
cards;
3 a b c
;
run;
系统输出:
a -> c
a -> b
c -> b
a -> c
b -> a
b -> c
a -> c
SAS语言为我们提供强大的宏功能,所以我们也可以使用宏来实现汉诺塔递归函数,代码如下:
/* HANOI(2)– MACRO by yinliangwu@gmail.com SAS Institute Inc. */
%macro move(from, to );
%put &from -> &to;
%mend;
%macro hanoi( n, from, mid, to );
%if &n=1 %then %do;
%move( &from, &to);
%end;
%else %do;
%hanoi(%eval(&n-1), &from, &to, &mid);
%move( &from, &to);
%hanoi(%eval(&n-1), &mid, &from, &to);
%end;
%mend;
%hanoi(3, a,b,c );
在作者已经写完的《从程序员到数据科学家》系列文章中,介绍了代码复用技术的多个层次。其中讲到过结构化编程思想诞生之前,第一代程序员是如何利用类似GOTO语句来编写函数的。当然在SAS里,语言内置了一种类似GOTO的LINK语句,配合RETURN来实现函数封装。 另外,SAS数据步中变量没有作用域,只用肯下工夫,我们一样可以实现类似的机制!下面是作者编写的超级烧脑版本的汉诺塔递归求解程序。
/* HANOI(3)– LINK by yinliangwu@gmail.com SAS Institute Inc. */
data _null_;
n=3; one="a"; two="b"; three="c";
array harg_n(2048) ; array harg_one(2048) $; array harg_two(2048) $; array harg_three(2048) $;
%StackDefine(stackName = mystack);
arg_n=n; arg_one=one; arg_two=two; arg_three=three;
link hanoi;
%StackDelete(stackName = mystack );
return;
hanoi:
hanoi_count+1;
harg_n(hanoi_count)=arg_n; harg_one(hanoi_count)=arg_one; harg_two(hanoi_count)=arg_two; harg_three(hanoi_count)=arg_three;
if harg_n(hanoi_count)>1 then do;
arg_n=harg_n(hanoi_count)-1; arg_one=harg_one(hanoi_count); arg_two=harg_three(hanoi_count); arg_three=harg_two(hanoi_count);
%StackPush(stackName = mystack, InputData = hanoi_count);
link hanoi;
%StackPop(stackName = mystack, OutputData = hanoi_count);
marg_from=harg_one(hanoi_count); marg_to=harg_three(hanoi_count);
link move;
arg_n=harg_n(hanoi_count)-1; arg_one=harg_two(hanoi_count); arg_two=harg_one(hanoi_count); arg_three=harg_three(hanoi_count);
%StackPush(stackName = mystack, InputData = hanoi_count);
link hanoi;
%StackPop(stackName = mystack, OutputData = hanoi_count);
end;
else do;
marg_from=harg_one(hanoi_count); marg_to=harg_three(hanoi_count);
link move;
end;
return;
move:
move_count+1;
put marg_from "->" marg_to;
return;
run;
系统输出与前面其他算法一样,其中 %StackDefine/Delete/Pop/Push 为系列辅助宏代码,只是为了辅助实现虚拟作用域的概念。
非递归算法
前面的递归代码中,我们固然用抽象的、目标导向的思维实现了汉诺塔的所有求解步骤。然而,递归思路固并不直观,人类的思维并不能直观地想象汉诺塔每一步的移动过程,因此也有一些非递归的求解算法,网友们可以访问维基百科或自行GOOGLE其算法。然而,可以说大部分非递归求解算法并不容易记住,也不直观。比如最经典的就是使用栈来代替递归进行汉诺塔求解:
/* HANOI(4)–Non-Recursive:STACK by yinliangwu@gmail.com SAS Institute Inc. */
data _null_;
n=3; from="a"; middle="b"; to="c";
%StackDefine(stackName = stk_n); %StackDefine(stackName = stk_f, dataType = c); %StackDefine(stackName = stk_m, dataType = c);%StackDefine(stackName = stk_t, dataType = c);
%StackPush(stackName = stk_n, InputData = n); %StackPush(stackName = stk_f, InputData = from); %StackPush(stackName = stk_m, InputData = middle); %StackPush(stackName = stk_t, InputData = to);
%StackLength(stackName = stk_n,StackLength = stk_n_len);
do while (stk_n_len^=0);
%StackPop(stackName = stk_n, OutputData = stk_n_current, StackLength=stk_n_len);
%StackPop(stackName = stk_f, OutputData = stk_f_current, StackLength=stk_f_len);
%StackPop(stackName = stk_m, OutputData = stk_m_current, StackLength=stk_m_len);
%StackPop(stackName = stk_t, OutputData = stk_t_current, StackLength=stk_t_len);
if stk_n_current=1 then do;
put stk_f_current "->" stk_t_current;
end;
else do;
stk_x_current=stk_n_current-1;
%StackPush(stackName = stk_n, InputData = stk_x_current, StackLength=stk_n_len);
%StackPush(stackName = stk_f, InputData = stk_m_current, StackLength=stk_m_len);
%StackPush(stackName = stk_m, InputData = stk_f_current, StackLength=stk_f_len);
%StackPush(stackName = stk_t, InputData = stk_t_current, StackLength=stk_t_len);
put stk_f_current "->" stk_t_current;
stk_x_currentX=stk_n_current-1;
%StackPush(stackName = stk_n, InputData = stk_x_current, StackLength=stk_n_len);
%StackPush(stackName = stk_f, InputData = stk_f_current, StackLength=stk_n_len);
%StackPush(stackName = stk_m, InputData = stk_t_current, StackLength=stk_n_len);
%StackPush(stackName = stk_t, InputData = stk_m_current, StackLength=stk_n_len);
end;
end;
%StackDelete(stackName = stk_n);
%StackDelete(stackName = stk_f);
%StackDelete(stackName = stk_m);
%StackDelete(stackName = stk_t);
run;
笔者在对汉诺塔的输出结果利用强大的SAS进行模式分析的时候,意外发现了所有移动步骤前后步之间的隐藏模式,从而逆向推导出了一个直观的生成式。我的思路总结起来就是:
l 对于 1 个黄金盘子的情况,移动步骤总是从源柱子到目标柱子,即 a-> c;
l 基于此初始状态,对于 n 个盘子的求解可以由如下生成模式构造:
n 对 n-1 个盘子的所有移动步骤,交换目标和中间柱子;即 定义 TX =交换(b,c);
n 插入从源柱到目标柱子的移动步骤:a->c;
n 对 n-1 个盘子的所有移动步骤,交换来源和中间柱子,即 定义 TY=交换(b,a);
这个思路很像递归求解,但它并非递归,而是一种可预见的基于范式的生成算法。比如对于1到3个盘子的情况,直接构建如下:
l 移动1个盘子的步骤:seq(1) = {from->to} = {a->c};
l 移动2个盘子的步骤:seq(2)
= TX( seq(1) ) + {from->to} + TY ( seq(1) )
= {from->middle} + {from->to} + {middle->to}
= {a->b, a->c, b->c}
l 移动3个盘子的步骤:seq(3)
= TX (seq(2) ) + {from->to} + TY ( seq(2) )
= TX ( {a->b, a->c, b->c} ) + (a->c) + TY ( {a->b, a->c, b->c} )
= {a->c, a->b, c->b,
a->c , b->a, b->c, a->c}
l 以此类推盘子数为 n 的情况:
seq(n)= TX (seq(n-1))+ {from->to} + TY( seq(n-1));
这种非递归汉诺塔求解算法是笔者在对汉诺塔输出结果的分析统计时发现的隐藏模式,从而重新定义了一种新的可预见的、非递归的汉诺塔求解步骤生成式。 在SAS 语言中,我们可以利用数据步轻松实现,也能在其他计算机编程语言中快速实现。
/* HANOI(5) – Non-Recursive:Pattern Base by yinliangwu@gmail.com SAS Institute Inc. */
%macro hanoi(n=1,from=1, middle=2, to=3);
data init; from="&from"; to="&to"; run;
data seq; set init; run;
%DO I = 1 %TO &n -1 ;
data seq_x; set seq; /*TX: 交换目标和中间柱子 middle<>to */
if from="&to" then from="x"; if from="&middle" then from="&to";if from="x" then from="&middle";
if to="&to" then to="x"; if to="&middle" then to="&to";if to="x" then to="&middle";
run;
data seq_y; set seq; /*TY: 交换来源和中间柱子 middle<>from*/
if from="&from" then from="x"; if from="&middle" then from="&from";if from="x" then from="&middle";
if to="&from" then to="x"; if to="&middle" then to="&from";if to="x" then to="&middle";
run;
/*将 TX 结果,from->to,TY结果合并*/
data seq; set seq_x init seq_y; run;
%END;
proc datasets nolist; delete init seq_x seq_y;
%mend;
%hanoi(n=3,from=a,middle=b,to=c);
data _null_;
set seq;
put from "->" to;
run;
proc print data=seq;run;
结语:本文是作者对计算机领域经典问题汉诺塔的感性演绎和理性分析,仁者见仁,智者见智。若干年前笔者因SAS工作之需曾访问过印度南部的阿里巴格,看见了到处供奉的湿婆和毗湿奴,确实很少看到梵天神庙。然而,也许作为创造之神梵天和妻子智慧女神辩才天女已将创造的智慧隐藏在经典的汉诺塔问题中,展现了对一生三,三生万物永恒奔流宇宙长河的思考!