ハノイの塔を攻略せよ!

概要:再帰呼び出しを使うアルゴリズムとして超有名な「ハノイの塔」を詳細に解説します。

C言語の習得における最大の難関であると言っても過言ではない再帰呼び出し。
2年前、プログラム自体初心者だった私がC言語を学んでいたあの頃、
「明解C言語」の内容はほぼ完璧に理解できたというのにどうしても再帰だけは、
これだけはいまいちピンと来なかった……。

もちろん、全くわからなかったわけじゃーない。
どのような動作をするのか、どのようなときに使うのが有効か、
それくらいはわかった。

なんとなく読める……でも書けない。
そんなレベルだった。

理解できないままプログラムの勉強は続けたが、
殆ど困ることはなく、必要性さえ疑わしい今日この頃。
もしかしたら絶滅したりして!?って言うか、して下さい……(泣)。
みたいなことを願いつつ、やっぱり再帰だけ理解できないのは悔しい!
という思いもあり……。

そんな折、「月刊アスキー」にハノイの塔の解説記事が掲載された。
2年越しの雪辱を晴らすため、このページだけは真剣に読んだ私。
そうしたら……わかったー(笑)!
わかっちゃったよ、やったー!

というわけで、長い前置きですみません。
今回は、今度こそ本気で再帰を理解しようとする方の一助となるように
「ハノイの塔」のアルゴリズムについて詳しく解説します。

■ハノイの塔

地面に棒を3本立てて、左から順に A、B、C と呼ぶことにする。
棒Aに穴の空いた円盤を何枚か重ねてはめておく。
円盤のサイズは、それぞれ異なり、大きい円盤の上に小さい円盤を載せる。
円盤の枚数を3枚とすれば、下図のようになる。

パズルの内容は、棒Aにある全ての円盤を棒Cに移動すること。
ただし、以下のルールを守らなければならない。

■ルール

・一度に1枚だけ円盤を移動する。
・小さい円盤の上に大きな円盤を載せてはいけない。
・棒A、棒B、棒C以外の場所に円盤を置いてはならない。

■再帰を理解するコツ

再帰は処理の流れを追うのが非常に困難です。
for文みたくループが一周するたびに制御変数の変動を考え、処理を理解する方法は通じません。

そもそも再帰の利点は「繰り返し処理をスマートに記述する」ことであり、
「人間の大まかな考えをそのままプログラムに置き換えられる」ことです。
細かい事を考えるのはナンセンスなんですね。

再帰を理解するのに大切なこと……それはイメージを掴むこと。
途中経過はブラックボックスのままで構いません。
ただ、どういう処理を繰り返したいのかということだけを考えれば良いのです。

もし深いところへ行っちゃったあなたは……戻ってきて下さい(笑)。
深追いせず、イメージを掴むことを決して忘れず、以降の解説をお読み下さいませ。

■3枚の円盤を移動する手順



個々の移動規則は見つけられましたか?
ダメですよ、見つけようとしては(笑)。
そうやって、深いところへ行ってしまったあなたは戻ってきて下さいね。

ここで重要なのは「3回」の時です。
移動させるべき3枚の円盤の中から
2枚の円盤を棒Aから棒Bに移動させた状態ですね。

同様に「5回」の時は……
移動させるべき2枚の円盤の中から
1枚の円盤を棒Bから棒Aに移動させた状態ですね。

つまり……
移動させる円盤が n 枚ならば、
まずは n-1 枚を棒Aから棒Bに移動しておかなければならない。

という法則が成り立つのです。

そこに至るまでの過程は無視して下さい。

あと、もう一つ重要なのは、
n-1 枚の円盤を棒Aから棒Bへ移動する場合と
棒Bから棒Aへ移動する場合は交互に訪れる。

という法則です。

1回から3回、4回から5回までの目標は「一番下の円盤を棒Cに移動する」事ですが、
その為には上に載っている全ての円盤を、空いている棒へ移動させなければなりません。
今、棒Aに移動させる円盤があるのならば、棒Bが空いているはずです。
だから、上に載っている円盤を全て棒Bへ移動させます。
そして次の瞬間から棒Bに移動させるべき全ての円盤があることになり、
棒Aが空いていることになります。
したがって、上に載っている円盤を全て棒Aへ移動させます。
以降も同じ事の繰り返しです。

この考えが方が非常に重要です。
もう一度まとめてみると以下の繰り返しであることがわかります。

1.一番下の円盤を棒Cへ移動させたい。
2.その為に上に載っている全ての円盤を空いている棒(A or B)に移動させる。
3.一番下の円盤を棒Cへ移動させる。

これで、1枚の円盤を棒Cへ移動させたことになります。
移動させるべき残りの円盤は n-1 枚です。

■プログラムで表現する

それでは、プログラムで円盤の移動を表現してみましょう。
円盤を移動させると言っても配列などで表現するわけではありません。
"AからCへ"といった文字列を表示するだけです。

移動は後で考えるとして、関数の原型から決めましょう。

void Hanoi(int n,char *from,char *work,char *dest);

n … 移動させる円盤の枚数
from … 移動元の棒の名前
work … 作業用に使う棒の名前
dest … 移動先の棒の名前

この関数の意味するところは「第二引数から第四引数へ n 枚の円盤を移動させろ」
ということになります。

注意して欲しいのは、必ずしもA棒に円盤があるとは限らないことです。
しかし、from には円盤があります。
そうなるように再帰呼び出しを行うのですから。
というわけで、どの変数がどの棒の名前を指しているかはあまり気にしないで下さい。
抽象的な方が理解しやすいです。
また、何番の円盤を移動させるのかも取り敢えずは考えないことにして下さい。

■main 関数からの呼び出し

移動させる円盤は3枚、
移動元をA棒、移動先をC棒、作業棒をB棒とすれば
main 関数からの呼び出しは次のようになります。

Hanoi(3,"A","B","C");

■円盤を一枚移動させる

移動元は from 、移動先は dest でしたから、
"from から dest へ"という文字列を表示させればいいことになります。

printf("%s から %s へ\n",from,dest);

この時、度重なる再帰呼び出しによって from や dest の指すものが、
最初の from や dest とは異なっているかも知れませんが、
全く気にする必要ありません。
その時移動させたい円盤が from にあり、
その移動先が dest にあるように再帰呼び出しを行うのですから、
それぞれには適当なものが入っているのです。

■一番下の円盤を移動させるまで

一番下の円盤を移動させるためには
上に載っている全ての円盤( n-1 枚)を空いている棒に移動させなければなりません。

これは単純に以下のようなプログラムで表現します。

Hanoi(n-1,from,dest,work);

始めは第二引数に円盤が積まれているのですから、
上に載っている n-1 枚の円盤を第三引数に移動させればいいんでしたよね。

■いつまで繰り返すのか?

再帰呼び出しをいつまで繰り返すのかも理解に苦しむ点です。
それを理解するためには関数全体を見なければなりません。

関数は次のようになっています(一部省略)。

void Hanoi(int n,char *from,char *work,char *dest)
{
  if(?) Hanoi(n-1,from,dest,work);

  printf("%s から %s へ\n",from,dest);

  ……
}


見るべきは printf を実行するタイミング、
つまり、円盤を移動させるタイミングです。

この関数は、?の条件が成り立つ限り再帰呼び出しを続けます。
そして、?の条件が成り立たなくなった場合に円盤を移動させます。

ということは、Hanoi 関数の第一引数に渡す、移動させる円盤の枚数は1枚以上でなければなりません。
0枚の円盤を移動させる再帰呼び出しの中で更に再帰呼び出しが起きないことはもちろんですが、
移動させる枚数が0枚なのに printf を実行して、円盤を移動させてはマズイのです。

というわけで、?は n-1>=1 つまり n>=2 です。

void Hanoi(int n,char *from,char *work,char *dest)
{
  if(n>=2) Hanoi(n-1,from,dest,work);

  printf("%s から %s へ\n",from,dest);

  ……
}


ここでもイメージが重要です。
一行目で「上に載っている全ての円盤を移動させて下さい。」とお願いして、
それが終わったら二行目で「残っている一番下の円盤を移動させて下さい。」とお願いしているのです。
かなりアバウト指示ですが、
「人間の大まかな考えをそのままプログラムに置き換えられる」のが再帰の利点でしたね。

■同じ事を繰り返せ

この関数にはまだ三行目があります。
三行目のプログラムは一行目とよく似ているのですが、意味が異なります。
一行目は上に載っている円盤を移動させることが目的でした。
そして二行目で一番下の円盤を移動させました。
あとは同じ事を繰り返せばいいので、
三行目では main 関数から呼び出した時みたいに指示します。

今、移動させるべき円盤は一枚減り、n-1 枚になりました。
円盤は先ほど work だった棒にあります。
というわけで、最初にやったように第二引数から第四引数へ
全ての円盤( n-1 枚)を移動させるように指示します。

Hanoi(n-1,work,from,dest);

一行目の n-1 とは意味が異なる点に注意して下さい。
今度は一番下の円盤も数えて n-1 枚しかないのです。

また、work と from の場所が一行目とは異なりますが、
反転している、という事だけが重要です。
理論のところでも言いましたが、
移動させるべき全ての円盤はA棒とB棒を交互に移動するのでしたね。

再帰呼び出しの動作を考えれば、
これらの変数にどの棒の名前が入っているか分かるかも知れません。
でも考えないで下さい。
ただ、反転しているという事だけが重要なのです。

再帰呼び出しを行う条件は一行目と同じです。
0枚の移動は有り得ないということです。

void Hanoi(int n,char *from,char *work,char *dest)
{
  if(n>=2) Hanoi(n-1,from,dest,work);

  printf("%s から %s へ\n",from,dest);

  if(n>=2) Hanoi(n-1,work,from,dest);
}


■完成プログラム

#include<stdio.h>

void Hanoi(int n,char *from,char *work,char *dest)
{
  if(n>=2) Hanoi(n-1,from,dest,work);

  printf("%d を %s から %s へ\n",n,from,dest);

  if(n>=2) Hanoi(n-1,work,from,dest);
}

int main(void)
{
  Hanoi(4,"A","B","C");

  return 0;
}
これで、最も簡単なハノイの塔プログラムは完成です。

しかし、より理解を深めるために他のプログラムも見てみましょう。
いずれもハノイの塔を解くプログラムですが、ちょっとずつ違います。

■月刊アスキー
void Hanoi(int n,char *from,char *work,char *dest)
{
    if(n>=1){
        Hanoi(n-1,from,dest,work);

        printf("%d を %s から %s へ\n",n,from,dest);

        Hanoi(n-1,work,from,dest);
    }
}

int main(void)
{
    Hanoi(4,"A","B","C");

    return 0;
}
最初に示したプログラムと違うのは if 文でくくられる範囲と、
それに伴う条件式の変化です。

最初のプログラムで禁止されていた0枚を移動させろという
再帰呼び出しが何故行われているのかというと、
0枚の時は printf が実行されない、つまり、円盤が移動しないからです。
二行目の再帰呼び出しで0(= n-1)枚移動させろと指示したときの n の値は 1 です。
何もせずに再帰から帰ってきて、
三行目に達したときに円盤を1枚移動させるのです。
というのも、n=1 が示すように、その時の関数は1枚移動しろという指示を与えられているからです。
なんだか、深入りして話がややこしくなってしまいましたが、他は同じです。

■移動させる円盤の番号

円盤には小さい方から順に 1、2、3 という番号が付けられています。
この番号は変えられません。
と言うのも、この番号はたまたま付いたものだからです。

もう一度最初に示したプログラムを見てみましょう。
しかし、今度は円盤の番号を表示するようにします。

void Hanoi(int n,char *from,char *work,char *dest)
{
  if(n>=2) Hanoi(n-1,from,dest,work);

  printf("%d を %s から %s へ\n",n,from,dest);

  if(n>=2) Hanoi(n-1,work,from,dest);
}


n は移動させる円盤の枚数です。
それが何故移動させる円盤の番号を示すことになるのか?
それを理解するにはちょっと深入りしなくてはなりませんが、
全てを動作を把握することは不可能です。
大切なのはイメージなので、いくつかの事例から全体を推測する事にしましょう。

はじめに、円盤の番号は変えられないと言ったことについて説明します。
理由は n の取り得る範囲が決まっているからです。
例えば、main 関数から3が渡された場合の n の範囲は 1〜3 です( printf 実行時=円盤移動時)。
したがって、円盤の番号に0番を付けることは不可能なのです。

さて本題ですが、再帰呼び出しにおける n の値を追跡しましょう。
まずは main 関数から3が渡されて、n-1=2 で再帰呼び出し。
2が渡されて、n-1=1 で更に再帰呼び出し。
1が渡されて、if 文にはじかれ、printf を実行します。
これで(1回目に)1番の円盤が移動するという正しい答えが得られました。

printf 実行後は if 文にはじかれ、再帰の再帰は終了。
再帰に戻り(n=2)、printf を実行します。
これで(2回目に)2番の円盤が移動するという正しい答えが得られました。

2回目の printf 実行後は n-1=1 で更に再帰呼び出し。
1が渡されて、if 文にはじかれ、printf を実行します。
これで(3回目に)1番の円盤が移動するという正しい答えが得られました。

ここまでは全て正しい答えが得られました。
次は4回目の処理、一番下(3番)の円盤を移動させる時の処理を考えてみましょう。
同様に考えてもいいのですが、今度はちょっと違ったアプローチをしてみます。
今、一行目の再帰から完全に復帰して、main 関数から直接呼ばれた Hanoi 関数にいます。
この時の n の値は何でしょうか?
当たり前のことですが、n の値は(再帰呼び出しされたものであっても)関数の仮引数 n の値と同じです。
ということは、今の関数を呼び出したのは main 関数ですから、
main 関数から渡された 3 がこの時の関数内部における n の値です。
したがって、printf により3番の円盤を移動させるという処理が実行される事になります。
上に載っていた全ての円盤を移動させた(一行目の再帰呼び出し)後に3番の円盤(一番下)を
移動させたわけですから、これも正しい答えを示しています。

以上のことから、from や dest に適当な名前が入っていたように、
n にも適当な値が入っていると考えられます。

大切なのはイメージですから、最後に示した例をもう一度まとめてみます。
・一行目の再帰呼び出しで n-1=2 枚を移動させます。
・二行目で一番下の円盤(3番)を移動させます。
・この時の n=3 の値は偶然にも移動させる円盤の番号と一緒になりました。
・と言うことは、他の全てのパターンで成り立つに違いない!
という、短絡的なイメージでオッケーです(たぶん)。
そもそも円盤は正しく動くのですから(そのようにプログラムを組んだ)、
仮引数 n の値と移動させる円盤の番号が一つでも合えば、
他の全てのパターンでも成り立つと考えられます。

■一番下の円盤

ところで、一番下の円盤とは何でしょうか?
必ずしも(円盤が全部で三枚なら)3番の円盤のことではありません。
再帰呼び出しされた関数からしてみれば、n の値より大きな番号の円盤は見えないのです。

3枚移動させろという指令を与えられて呼び出された関数からしてみれば3番が一番下。
2枚移動させろという指令を与えられて呼び出された関数からしてみれば2番が一番下。
1枚移動させろという指令を与えられて呼び出された関数からしてみれば1番が一番下。

つまり、再帰呼び出しされた関数は移動元に現在ある“一番下の円盤”を常に移動させていることになるのです。
この時、移動元も最初に指定した移動元と同じであるとは限らない事に注意して下さい。

■明解C言語 例解演習

今までのとは違い、理解に苦しむプログラムです。
と言うか、良いプログラムではありません。
昔の俺がわからなかったはずだよーなんて言い訳をしてみたり(笑)。
#include<stdio.h>

void Hanoi(int n,int x,int y)
{
    if(n>=2) Hanoi(n-1,x,6-x-y);

    printf("%d を %d 軸から %d 軸へ\n",n,x,y);

    if(n>=2) Hanoi(n-1,6-x-y,y);
}

int main(void)
{
    Hanoi(3,1,3);

    return 0;
}
n 枚の円盤を1軸から3軸へ移動させろ、
という内容で main 関数から Hanoi 関数を呼び出しています。

このプログラムで理解不能なのは 6-x-y でしょう。
これは変数に値を入れて計算してみればわかります。

今、x=1、y=3 とすれば 6-x-y=2 です。
つまり、一行目の再帰呼び出しは Hanoi(n-1,1,2); となり、
1を from、2を dest とすれば、
最初に示したプログラムと同じになります。

同様に Hanoi(n-1,6-x-y,y); は、Hanoi(n-1,2,3); となり、
2を from、3を dest とすれば、
やはり最初に示したプログラムと同じになります。

■おわりに

お疲れさまでしたー!

再帰が完璧にわかったかと聞かれると自信ありませんが(汗)、
とりあえずハノイの塔は制覇したぞ、っと。

問題はこれをどうやって活かすか……ですね。。
ちなみに、再帰を使わないですむなら使わない方がいいです。
再帰は実行速度で劣ります。
そして、読めない方が沢山います(汗)。

だから、私も殆ど見たことがありません。
誰か活用法をご存知の方がいらっしゃったら、是非とも掲示板まで〜(笑)。

戻る / ホーム