再帰呼び出しはなぜ必要か?

プログラミングの授業で『関数の再帰呼び出し』を習うと思う。実例で言うと、先生が↓なコードを示した上で「こういう関数を作れば階乗(n!)が計算できます」と説明する。

int fact(int n)
{
    if(n==0)
        return 1;
    return n*fact(n-1);
}

しかし学生は↓なコードを書いて全く納得がいかないと言う。

int fact(int n)
{
    int ans=1;
    for(int i=2;i<=n;i++)
        ans*=i;
    return ans;
}

「ループで済むから再帰は必要なくね?」「てか再帰はスタックオーバーフローするから使いにくくね?」と感じるわけだ。ここで再帰の利点を明快に答えれる先生はそんなに多くない。
そこで今日はその明快な説明を頑張ってみる。まず↓のQ1を考えてみよう。

Q1. 3桁のルーレットがある。各桁は0〜9まである。桁の合計がtotalでアタリ!となる。アタリは何パターンあるか?。

簡単な探索問題だ。探索ビギナーにはちょうどよい練習問題だと思うのでぜひ挑戦してみて。・・・で、オレは↓のコードを書きました。

int search(int total)
{
    int ans=0;
    for(int i=0;i<10;i++)
        for(int j=0;j<10;j++)
            for(int k=0;k<10;k++)
                if(i+j+k==total)
                    ans++;
    return ans;
}

3桁だから3重ループで全部調べ上げれば良い。これといって特に難しいところはないね。・・・じゃあ↓はどうでしょう。

Q2. r桁のルーレットがある。各桁は0〜9まである。桁の合計がtotalでアタリ!となる。アタリは何パターンあるか?。

さっきと一文字しか変わってない。しかし少し考えると、r重ループを簡単には作れないことに気づくはず。・・・でお分かりのように、ここで再帰がダダーン!と登場するわけだ。

int search(int r,int total)
{
    if(r==0)//全ての桁を処理し終わった
    {
        if(total==0)//ちょうどtotal分消化してるからパターンとしてカウント
            return 1;
        return 0;
    }
    
    int ans=0;
    for(int i=0;i<10;i++)
        ans+=search(r-1,total-i);//☆再帰呼び出し
    return ans;
}

コードが少し難しくなった。再帰を理解するうえで大事なのは、関数の中身を最初は考えずに何を渡すと何を返すのか?(引数と返値)だけに注目すること。例えば search(r,total) なら「r桁のルーレットで合計がtotalになるパターン数を取得できる」とだけ理解する。同様に search(r-1,total-i) なら「r-1桁のルーレットで合計がtotal-iになるパターン数を取得できる」と考える。自動販売機で中がどう動いてるか考えて使ってないでしょ?。それと同じで、「何を入れると何が出てくるのか」だけ考えよう。☆部のsearch()の中身を追っかけてはダメね。ビギナーが陥りがちなのが「えーと、ここでsearch()が再び呼ばれて、関数の頭に戻って、下のreturnで戻るときに、前のsearch()のfor文の中に戻るので・・・あれ?このansってどのans?」ってなる(笑)から、関数の中身を一行一行追わないことが凄く大事。
というわけでまとめですが、ループが何重になるか不明な場合に再帰が便利と覚えておきましょう。*1

*1:実際には、各桁ではなくルーレット全桁を一つの変数で扱うことで、ある程度シンプルに書くことができます。それでも再帰のほうがビギナーにとってはよりシンプルだとは思いますが。