web-dev-qa-db-ja.com

マージソートアルゴリズムの再帰。このタイプの再帰を使用することはどのように明白ですか?

あまりにも多くのコードを入れたくないので、再帰を含むコードのみを入れます。このアルゴリズムはかなりよく知られているので、誰もが基本的なコードを知っていると思います。

void mergeSort(int array[], int l, int r) 
{
    if (l < r) 
    {
        int m = l + (r - l) / 2;
        mergeSort(array,l,m);
        mergeSort(array,m+1,r);

        merge(array,l,m,r);
    }
}

ここで、マージ関数は、2つのセットを比較して整理する典型的な関数です。

さて、ここでの再帰は「ブルートフォース」でしか理解できません。これにより、実際に動作することを確認するために、実際に各ステップを計算する必要があります。しかし、私はこの方法でコードを書くことは決して起こらなかっただろうと100%確信しています。

これらの2つの再帰呼び出しについて私がどのように考える必要があるか、そしてどのようにそれがこのようにならなければならないかについての洞察を誰かが提供してくれることを期待していました。これは広いように見えるかもしれませんが、私が上で述べた「力ずくの」アプローチを使用する必要がないように、誰かが私に洞察を提供してくれることを望んでいました。おそらく、あなたはこれらの再帰呼び出しについてどう思うか教えてくれます(それは非常に退屈なので、私はあなたが私のように各ステップを計算するとは思わないでしょう)。

3
DLV

再帰的なMergeSortは「分割統治」アルゴリズムです。あなたの例で提供したコードは基本的にこれを行います(平易な英語で):

  1. 中点を見つける
  2. 左半分を並べ替え、
  3. 右半分を並べ替えます。
  4. 2つの半分をマージして元に戻します。

これが再帰的にどのように機能するかを理解するための鍵は、左半分をソートするときに、最初の2つの半分と同様に、2つに分割することです。視覚的には、それをツリーのような構造と考えることができます。ルートは上部にあり、徐々に小さいブランチが下に伸びています。

この分割プロセスは、コードが終了条件に遭遇するまで続きます。その終了条件が発生すると、コードはツリーのブランチに戻り、各再帰呼び出しがツリーに戻るときにブランチをマージします。

再帰関数は常に3つの要素を共通に(通常はこの順序で)持っています。

  1. 終了条件
  2. 再帰呼び出し、および
  3. この再帰で行われる作業。

これは Wikipediaのより良い擬似コード表現 です。 3つの要素を見つけられるかどうかを確認します。

function merge_sort(list m)
    // Base case. A list of zero or one elements is sorted, by definition.
    if length of m ≤ 1 then
        return m

    // Recursive case. First, divide the list into equal-sized sublists
    // consisting of the even and odd-indexed elements.
    var left := empty list
    var right := empty list
    for each x with index i in m do
        if i is odd then
            add x to left
        else
            add x to right

    // Recursively sort both sublists.
    left := merge_sort(left)
    right := merge_sort(right)

    // Then merge the now-sorted sublists.
    return merge(left, right)

merge関数は、呼び出しツリーを返しながらリストをマージします。

function merge(left, right)
    var result := empty list

    while left is not empty and right is not empty do
        if first(left) ≤ first(right) then
            append first(left) to result
            left := rest(left)
        else
            append first(right) to result
            right := rest(right)

    // Either left or right may have elements left; consume them.
    // (Only one of the following loops will actually be entered.)
    while left is not empty do
        append first(left) to result
        left := rest(left)
    while right is not empty do
        append first(right) to result
        right := rest(right)
    return result

ペンジーのブログ には、このプロセスを視覚化するのに役立つ優れたアニメーションがいくつかあります。ここに私が言及したような木を実際に描くものがあります:

enter image description here

5
Robert Harvey

再帰の一般的な原則は、小さな問題は解決できると想定し、小さな問題と現在解決を求められている問題の違いを明らかにすることによって問題を解決することです。

多くの再帰的アルゴリズム。再帰的な階乗、1小さい方の問題を想定し、その答えが与えられた場合、その答え(1小さい方)と要求された答えの間のデルタを解決します。小さい問題の答えにNを掛けます。

あなたの質問の場合、アプローチは1つだけでなく2つの小さな問題を解決し、マージを使用して答えを組み合わせることです。小さい問題はmでの大きい問題の細分であり、解くよう求められている範囲の半分です。

ところで、マージソートをコード化する方法はたくさんあります。これは、言語が配列のスライスを効率的にサポートするかどうかに部分的に依存します。

そうである場合、マージソートは通常、呼び出しごとに特定の配列をソートするように求められます。ソートする配列は、実際にはより大きな配列のサブセットですが、引数として渡されると、0から.lengthに移動します。 。

質問の場合のように、言語がスライスを許可しない場合、マージソートは配列の明示的に識別されたサブ範囲をソートするように指示されます(追加の開始(l)と終了を渡すことにより) (r)パラメータ)別の配列(スライス)にソートする部分を回して、それだけを渡す代わりに。

いずれの場合でも、mlrのほぼ中間で選択されていることが比較的簡単にわかるはずです。したがって、2つの再帰呼び出しの1つ目はlからmへの部分範囲のソートを要求し、2つ目は_m+1_からrへの部分範囲のソートを要求します。マージされると、これにより、要求されたlからrの完全なサブ範囲の要求された並べ替えが実行されます。

最初の呼び出しでは、lが_0_として、rが完全な長さであるので、最初の呼び出しは配列全体をソートするように要求され、次にその再帰ヘルパーにソートを要求しますアレイの約半分。次に、ソートするように要求された配列のスライスを約半分に細分します。最終的に、元の配列の全範囲がソートされ、マージされます。

多くの再帰アルゴリズムは、複数の(自己)再帰呼び出しを使用します。たとえば、再帰的なツリートラバーサルは同様の方法で動作します。ポストオーダートラバーサルは、左右に再帰的に呼び出した後、最後にノード自体を訪問します。ツリートラバーサルの再帰を使用すると、2つの小さな問題へのサブディビジョンをより簡単に確認できます。これは、最初に解決する小さな問題を特定するのに必要な作業がはるかに少ないためです(左右にあります)。

ただし、マージソートは同じことを行います。ソートするように要求された範囲ごとに、最初に(再帰呼び出しを使用して)左半分をソートし、次に右半分をソートしてからマージします。半分の算術演算を使用して半分を識別する必要があります。これにより、その他の点では類似したパターン化されたツリートラバーサルと区別されます。


マージソートの変形では、配列を3つのスライスに分割し、それぞれを(再帰的に)ソートしてからマージすることができます。マージパーツには追加のパラメーターが必要です(選択した3番目の部分、たとえばmerge(array,l,m,n,r);)。

5
Erik Eidt

心理的には、プログラミングの再帰は数学の「帰納による証明」に似ています。 (帰納法による証明に慣れていない場合は、「n = mに対してPがtrueの場合、n = m + 1に対してtrueである」と証明し、n = 0の場合にPがtrueであることを確認します[またはn = 1、必要に応じて]、Pがすべてのnに当てはまると推定します。

数学的帰納法では、人によって考え方が異なります。たとえば、最初に小さなnをチェックするか、大きな写真を撮って下向きに作業します。

あなたの再帰のために、私はそれを次のように見ています:

  1. 「内部」mergeSortが機能するとします。

  2. コードを読み、mergeSortへの内部呼び出しが機能する場合はmergeSort関数が機能することを確認します。これは、数学の帰納的ステップに相当します。

  3. 「内部」mergeSortについて何かdifferentを見つけることによって、私の推論が循環的ではないことを自分に納得させます。この場合、「内部」のものは常に小さくなります。

  4. 「最も内側」、「n = 0」の場合があることに注意してください。あなたの例では、ソートする項目が残っていないときだと思います(または1:チェックできます)。

  5. 帰納的証明はこれで完了です。推論は循環的ではなくスパイラルであると言うかもしれません。

  6. サイズが有限であり、エンジニアの指を交差させて希望を推測することによってサイズが決定される、未知の制御されていない量のリソース(スタック)を使い果たすため、再帰は常にソフトウェアエンジニアリングの悪い習慣です。したがって、確認すべきことがもう1つあります。それが再帰の深さです。そして、ここが「中間点」mの選択の出番です。アルゴリズムは、すべてのmの任意の値で理論的に機能しますが、中間値が使用される理由はこれは、次の内部mergeSortを半分のサイズにすることです。アルゴリズムは一般的にまだエンジニアリングが悪いですが、ソートされる値の数が少ない場合、再帰の深さは浅くなります。たとえば、並べ替えを2 ^ 64以下の整数に制限すると、再帰の最大深度は64になり、許容できます。

4

これらの2つの再帰呼び出しについて私がどのように考える必要があるか、そしてどのようにそれがこのようにならなければならないかについての洞察を誰かが提供してくれることを期待していました。

基本的に、習得する必要のある2つの事項があります。

  1. 問題が再帰的な解決策に対して潜在的に受け入れられるであることを認識できる必要があります。

  2. 再帰的なソリューションを定式化できる必要があります。

アルゴリズムに関する本(特に関数型プログラミングの専門家によって書かれた本)を読んで実践することを除いて、これらのいずれかを学習するための特別な魔法の公式はないと思います。十分な練習をすることで、再帰の機会を見つけ、それをコーディングすることがより簡単になります。

(たとえば)クイックソートやマージソートのアルゴリズムを手がかりなしにゼロから考案することはできません。これらのことを最初に考案した人々は本当に賢い人々だったことを忘れないでください。私やあなたのような普通の人は、これらのアルゴリズムやクイックソートなどを読んだり教えたりしたので、この考え方を理解しています。次に、同じ知識を他の同様の問題に適用します。

3
Stephen C

非常に短い答えを提供するためだけに。これはほとんどの種類の再帰関数で機能します。

あなたは再帰を通過する必要はありませんあなたの頭です。与えられたパラメータ、たとえば要素のリストに対して関数を書くだけでよく、それより小さな要素のリストで関数を呼び出すと関数はすでに機能していると想定します。

同様に、mergeSortの例では、指定されたlおよびrパラメーターを使用して関数を作成または表示する場合、lrの小さな違いに対して関数がすでに機能していると想定します。

有限リストのサイズは、いつまでも小さくなり続けることはできません。また、lrの間のギャップも小さくなりません。そのため、再帰がいつまでも続くことを心配する必要はありません。

2
Zantier