汉诺塔问题是一个经典的递归问题,要求将一组盘子从一个柱子移动到另一个柱子,同时遵守规则:任何时候都不能将较大的盘子放在较小的盘子上面。这个问题与一个众所周知的递归算法相关联。本文将展示如何使用JavaScript的setInterval函数来实现算法的动画演示。
虽然动画递归汉诺塔算法的想法并不新颖,但作者大约在15年前使用Visual Basic实现了它。相关视频可以在找到。
汉诺塔问题的著名递归算法可以用以下JavaScript函数表达:
        function Hanoi(n, from, to, via) {
            if (n == 0) return;
            Hanoi(n - 1, from, via, to);
            moveDisk(from, to);
            Hanoi(n - 1, via, to, from);
        }
    
算法的输入是四个整数参数,如下:
通常(如果用0,1,2表示柱子),n个盘子的初始调用将是Hanoi(n, 0, 2, 1)。
递归算法通常试图找到子问题(原始问题的实例,但问题规模更小)。然后以子问题的解决方案来表达原始问题的解决方案。汉诺塔的递归算法基于观察到的,“from”柱子顶部的n-1个盘子(连同其他两个柱子)代表了原始问题的较小规模实例,因此可以通过调用Hanoi(n-1, 0, 1, 2)来解决。这将把盘子移动到中间柱子(#1),使用另一个柱子(#2)作为中间。之后,可以将第n个盘子从柱子#0移动到柱子#2,然后通过调用Hanoi(n-1, 1, 2, 0)将所有n-1个盘子从中间柱子移动到最后一个柱子,使用柱子#0作为中间。
上述算法编码了前面的描述,但将柱子的参数保持为变量,而不是特定值。
通常动画代码可以利用JavaScript的定时器函数:setInterval或setTimeout。然而,事情并不那么简单。假设在上面的代码中,函数moveDisk()的调用使用setInterval()进行动画化。会出现一个问题,因为调用者的代码会继续执行并干扰(搞乱共享数据)动画。有人可能会建议调用者休眠一段时间以允许动画完成。然而,JavaScript缺乏“真实”的延迟或休眠机制。如果通过自旋循环来实现延迟,它会使事情停滞不前,因为整个JavaScript代码在单个线程上执行。
看来JavaScript动画使用的模式是:如果开始动画,不要做其他任何事情。对于汉诺塔算法,想要动画化一次调用moveDisk(),返回调用者,直到遇到下一次调用moveDisk(),动画化它,等等。但是,使用JavaScript动画,不得不遵循模式:动画化moveDisk(),动画化下一个moveDisk(),等等。换句话说,每次调用moveDisk()都应该在完成时发出下一次调用moveDisk()。但是,如何找到所有这些moveDisk()调用。经过一番思考,自问:为什么不使用栈(callStack)来保存“from,to”调用参数到moveDisk()。使用栈也会保留调用的顺序。这正是所做的,即在上面的代码注释中moveDisk();并取消注释callStack.push([from,to]);。现在修改后的Hanoi()函数是从函数executeHanoi()调用的,如下所示。
        var callStack;
        function executeHanoi() {
            // Some initialization code goes here
            callStack = [];
            Hanoi(diskCount, 0, 2, 1);
            moveDisk(); // moveDisk takes its parameters from callStack
        }
        function moveDisk() {
            if (callStack.length == 0) return;
            var param = callStack.shift();
            var fromBar = param[0];
            var toBar = param[1];
            var elem = document.getElementById(barsInfo[fromBar].disks.pop());
            var moveInfo = {
                elem: elem,
                fromBar: fromBar,
                toBar: toBar,
                whichPos: "top",
                dir: -1,
                state: "up",
                endPos: 60
            };
            var myTimer = setInterval(animateMove, speed); // Start animation
        }
    
在调用Hanoi()之后,callStack将有一个条目用于每个moveDisk()调用。这些条目的处理由函数moveDisk()处理。函数moveDisk()从callStack中检索一个条目,并设置一个对象moveInfo,用于传递animateMove()函数所需的数据。动画是通过行myTimer = setInterval(animateMove,speed)开始的。
以下代码列表显示了animateMove()函数的代码。
        function animateMove() {
            var elem = moveInfo.elem;
            var dir = moveInfo.dir;
            var styleInfo = window.getComputedStyle(elem);
            var pos = parseInt(styleInfo[moveInfo.whichPos]);
            if (((dir == 1) && (pos >= moveInfo.endPos)) || ((dir == -1) && (pos <= moveInfo.endPos))) {
                if (moveInfo.state == "up") {
                    moveInfo.state = "hor";
                    moveInfo.whichPos = "left";
                    moveInfo.dir = 1;
                    if (moveInfo.fromBar > moveInfo.toBar) moveInfo.dir = -1;
                    var toBar = document.getElementById("bar" + moveInfo.toBar);
                    moveInfo.endPos = toBar.offsetLeft - Math.floor(elem.offsetWidth / 2) + 15;
                    return;
                } else if (moveInfo.state == "hor") {
                    moveInfo.state = "down";
                    moveInfo.whichPos = "top";
                    moveInfo.dir = 1;
                    moveInfo.endPos = document.getElementById("bottombar").offsetTop - (barsInfo[moveInfo.toBar].disks.length + 1) * elem.offsetHeight;
                    return;
                } else {
                    clearInterval(myTimer);
                    barsInfo[moveInfo.toBar].disks.push(elem.id);
                    moveDisk();
                    return;
                }
            }
            pos = pos + dir * moveInc;
            elem.style[moveInfo.whichPos] = pos + "px";
        }