2022年10月1日

再帰とスタック

関数に戻り、より詳細に調べてみましょう。

最初のトピックは再帰です。

プログラミング初心者でない場合は、おそらく馴染みがあり、この章をスキップしても構いません。

再帰は、タスクを自然に同じ種類のいくつかのより単純なタスクに分割できる場合、またはタスクを簡単な操作と同一タスクのより単純なバリアントに単純化できる場合に役立つプログラミングパターンです。あるいは、すぐにわかるように、特定のデータ構造を扱うためにも使用できます。

関数がタスクを解決する際、その過程で多くの他の関数を呼び出すことができます。その部分的なケースとして、関数が自分自身を呼び出すというものがあります。それが再帰と呼ばれます。

2つの考え方

まずは簡単なものとして、xを自然数n乗する関数pow(x, n)を書いてみましょう。言い換えれば、xn回自身で乗算します。

pow(2, 2) = 4
pow(2, 3) = 8
pow(2, 4) = 16

実装するには2つの方法があります。

  1. 反復的な考え方:`for`ループ

    function pow(x, n) {
      let result = 1;
    
      // multiply result by x n times in the loop
      for (let i = 0; i < n; i++) {
        result *= x;
      }
    
      return result;
    }
    
    alert( pow(2, 3) ); // 8
  2. 再帰的な考え方:タスクを単純化して自己呼び出しを行う

    function pow(x, n) {
      if (n == 1) {
        return x;
      } else {
        return x * pow(x, n - 1);
      }
    }
    
    alert( pow(2, 3) ); // 8

再帰的なバリアントが根本的に異なることに注意してください。

pow(x, n)が呼び出されると、実行は2つのブランチに分岐します。

              if n==1  = x
             /
pow(x, n) =
             \
              else     = x * pow(x, n - 1)
  1. n == 1の場合、すべてが自明です。これは再帰の基底と呼ばれ、自明な結果pow(x, 1)xに等しくなります。
  2. それ以外の場合は、pow(x, n)x * pow(x, n - 1)として表すことができます。数学では、xn = x * xn-1と書きます。これは再帰ステップと呼ばれ、タスクをより単純な操作(xによる乗算)と同一タスクのより単純な呼び出し(nが小さいpow)に変換します。次のステップでは、n1に達するまで、さらに単純化されます。

n == 1になるまで、pow自分自身を再帰的に呼び出すともいえます。

たとえば、pow(2, 4)を計算するために、再帰的なバリアントはこれらのステップを実行します。

  1. pow(2, 4) = 2 * pow(2, 3)
  2. pow(2, 3) = 2 * pow(2, 2)
  3. pow(2, 2) = 2 * pow(2, 1)
  4. pow(2, 1) = 2

したがって、再帰は関数呼び出しをより単純なものに、そしてさらに単純なものに減らし、結果が自明になるまで続けます。

再帰は通常短い

再帰的なソリューションは、反復的なソリューションよりも短いのが普通です。

ここでは、ifの代わりに条件演算子?を使用して、pow(x, n)をより簡潔で読みやすいものにすることができます。

function pow(x, n) {
  return (n == 1) ? x : (x * pow(x, n - 1));
}

ネストされた呼び出しの最大数(最初の呼び出しを含む)は、再帰深度と呼ばれます。この場合、正確にnになります。

再帰深度はJavaScriptエンジンによって制限されています。10000とすることができますが、一部のエンジンではそれ以上を許容しますが、100000はほとんどのエンジンではおそらく制限を超えています。これを軽減する自動最適化(「テールコール最適化」)がありますが、まだどこでもサポートされているわけではなく、単純なケースでのみ機能します。

これは再帰の適用を制限しますが、それでも非常に広範なままです。再帰的な考え方がよりシンプルで保守しやすいコードを提供する多くのタスクがあります。

実行コンテキストとスタック

次に、再帰呼び出しがどのように機能するかを調べましょう。そのためには、関数の内部を調べます。

実行中の関数の実行プロセスに関する情報は、その実行コンテキストに格納されます。

実行コンテキストは、関数の実行に関する詳細(制御フローの現在位置、現在の変数、thisの値(ここでは使用していません)、その他のいくつかの内部詳細)を含む内部データ構造です。

1つの関数呼び出しには、それに関連付けられた実行コンテキストが正確に1つあります。

関数がネストされた呼び出しを行うと、次のようになります。

  • 現在の関数が一時停止します。
  • それに関連付けられた実行コンテキストは、実行コンテキストスタックと呼ばれる特別なデータ構造に記憶されます。
  • ネストされた呼び出しが実行されます。
  • それが終了すると、古い実行コンテキストがスタックから取得され、外部関数は中断した場所から再開されます。

pow(2, 3)呼び出し中に何が起こるかを見てみましょう。

pow(2, 3)

pow(2, 3)呼び出しの開始時、実行コンテキストには変数x = 2, n = 3が格納され、実行フローは関数の1行目になります。

それを次のようにスケッチできます。

  • コンテキスト:{ x: 2, n: 3, 1行目 } pow(2, 3)

関数が実行を開始します。条件n == 1は偽なので、フローはifの2番目のブランチに進みます。

function pow(x, n) {
  if (n == 1) {
    return x;
  } else {
    return x * pow(x, n - 1);
  }
}

alert( pow(2, 3) );

変数は同じですが、行が変わるため、コンテキストは次のようになります。

  • コンテキスト:{ x: 2, n: 3, 5行目 } pow(2, 3)

x * pow(x, n - 1)を計算するには、新しい引数pow(2, 2)powのサブ呼び出しを行う必要があります。

pow(2, 2)

ネストされた呼び出しを行うために、JavaScriptは現在の実行コンテキストを実行コンテキストスタックに記憶します。

ここでは同じ関数powを呼び出しますが、まったく問題ありません。すべての関数でプロセスは同じです。

  1. 現在のコンテキストはスタックの一番上に「記憶」されます。
  2. サブ呼び出しのために新しいコンテキストが作成されます。
  3. サブ呼び出しが終了すると、前のコンテキストがスタックからポップされ、その実行が再開されます。

サブ呼び出しpow(2, 2)に入ったときのコンテキストスタックを次に示します。

  • コンテキスト:{ x: 2, n: 2, 1行目 } pow(2, 2)
  • コンテキスト:{ x: 2, n: 3, 5行目 } pow(2, 3)

新しい現在の実行コンテキストが一番上にあり(太字)、以前に記憶されたコンテキストはその下にあります。

サブ呼び出しが終了すると、変数と停止したコードの正確な場所の両方を保持しているため、前のコンテキストを簡単に再開できます。

注意

図では「行」という言葉を使用していますが、例では1行に1つのサブ呼び出ししかありませんが、一般的には1行のコードに複数のサブ呼び出しが含まれる場合があります(例:pow(…) + pow(…) + somethingElse(…))。

したがって、「サブ呼び出しの直後」に実行が再開されると言う方が正確です。

pow(2, 1)

プロセスが繰り返されます。新しいサブ呼び出しが5行目で、引数はx=2n=1になります。

新しい実行コンテキストが作成され、前のコンテキストがスタックの一番上にプッシュされます。

  • コンテキスト:{ x: 2, n: 1, 1行目 } pow(2, 1)
  • コンテキスト:{ x: 2, n: 2, 5行目 } pow(2, 2)
  • コンテキスト:{ x: 2, n: 3, 5行目 } pow(2, 3)

現在、pow(2, 1)に対して2つの古いコンテキストと1つの実行中のコンテキストがあります。

終了

pow(2, 1)の実行中、前とは異なり、条件n == 1は真なので、ifの最初のブランチが機能します。

function pow(x, n) {
  if (n == 1) {
    return x;
  } else {
    return x * pow(x, n - 1);
  }
}

ネストされた呼び出しはもうないので、関数は終了し、2を返します。

関数が終了すると、その実行コンテキストはもう必要ないので、メモリから削除されます。前のコンテキストがスタックの一番上から復元されます。

  • コンテキスト:{ x: 2, n: 2, 5行目 } pow(2, 2)
  • コンテキスト:{ x: 2, n: 3, 5行目 } pow(2, 3)

pow(2, 2)の実行が再開されます。サブ呼び出しpow(2, 1)の結果があるので、x * pow(x, n - 1)の評価も終了し、4を返すことができます。

次に、前のコンテキストが復元されます。

  • コンテキスト:{ x: 2, n: 3, 5行目 } pow(2, 3)

それが終了すると、pow(2, 3) = 8の結果が得られます。

この場合の再帰深度は3です。

上記の図からわかるように、再帰深度はスタック内のコンテキストの最大数に等しくなります。

メモリの要件に注意してください。コンテキストはメモリを消費します。この場合、n乗するには、nのすべての小さい値に対して、n個のコンテキストのメモリが必要です。

ループベースのアルゴリズムの方がメモリ効率が良い

function pow(x, n) {
  let result = 1;

  for (let i = 0; i < n; i++) {
    result *= x;
  }

  return result;
}

反復的なpowは、プロセスでiresultを変更する単一のコンテキストを使用します。そのメモリの要件は小さく、固定されており、nに依存しません。

再帰はすべてループとして書き直すことができます。ループのバリアントは通常、より効率的にすることができます。

…しかし、関数が条件に応じて異なる再帰的サブコールを使用し、その結果をマージする場合、または分岐がより複雑な場合は、書き換えが自明でない場合があります。そして、最適化は不要であり、努力に見合う価値がない可能性があります。

再帰は、より短く、理解しやすく、サポートしやすいコードを提供できます。最適化はあらゆる場所で必要というわけではなく、ほとんどの場合、良いコードが必要なので、再帰が使用されます。

再帰的トラバーサル

再帰のもう一つの優れた用途は、再帰的トラバーサルです。

会社があると想像してください。スタッフの構造はオブジェクトとして表現できます。

let company = {
  sales: [{
    name: 'John',
    salary: 1000
  }, {
    name: 'Alice',
    salary: 1600
  }],

  development: {
    sites: [{
      name: 'Peter',
      salary: 2000
    }, {
      name: 'Alex',
      salary: 1800
    }],

    internals: [{
      name: 'Jack',
      salary: 1300
    }]
  }
};

言い換えれば、会社には部門があります。

  • 部門にはスタッフの配列が含まれる場合があります。たとえば、`sales` 部門には、John と Alice の2人の従業員がいます。

  • または、部門はサブ部門に分割される場合があります。たとえば、`development` は `sites` と `internals` の2つの部門に分かれています。それぞれに独自のスタッフがいます。

  • サブ部門が成長すると、サブサブ部門(またはチーム)に分割されることも可能です。

    たとえば、将来、`sites` 部門は `siteA` と `siteB` のチームに分割される可能性があります。そして、それらはさらに分割される可能性があります。図には示されていませんが、念頭に置いておくべきことです。

さて、すべての給与の合計を取得する関数を作成したいとします。どのようにすればよいでしょうか?

構造が単純ではないため、反復的なアプローチは容易ではありません。最初のアイデアは、1レベル目の部門のネストされたサブループで`company`に対して`for`ループを作成することかもしれません。しかし、その後、`sites`のような2レベル目の部門のスタッフを反復処理するために、さらにネストされたサブループが必要になります…そして、将来表示される可能性のある3レベル目の部門に対して、それらの内部に別のサブループが必要になりますか?単一オブジェクトをトラバースするためにコードに3〜4個のネストされたサブループを配置すると、かなり見にくくなります。

再帰を試してみましょう。

ご覧のとおり、関数が合計する部門を取得すると、2つのケースが考えられます。

  1. それは、人々の_配列_を持つ「単純な」部門です。— その場合、単純なループで給与の合計を求めることができます。
  2. または、`N`個のサブ部門を持つ_オブジェクト_です。— その場合、各サブ部門の合計を取得するために`N`回の再帰呼び出しを行い、結果を組み合わせることができます。

最初のケースは再帰の基礎であり、自明なケースです。配列を取得した場合です。

オブジェクトを取得する2番目のケースは、再帰ステップです。複雑なタスクは、より小さな部門のサブタスクに分割されます。それらは順番に再び分割される可能性がありますが、遅かれ早かれ、分割は(1)で終了します。

アルゴリズムは、コードからさらに読みやすいかもしれません。

let company = { // the same object, compressed for brevity
  sales: [{name: 'John', salary: 1000}, {name: 'Alice', salary: 1600 }],
  development: {
    sites: [{name: 'Peter', salary: 2000}, {name: 'Alex', salary: 1800 }],
    internals: [{name: 'Jack', salary: 1300}]
  }
};

// The function to do the job
function sumSalaries(department) {
  if (Array.isArray(department)) { // case (1)
    return department.reduce((prev, current) => prev + current.salary, 0); // sum the array
  } else { // case (2)
    let sum = 0;
    for (let subdep of Object.values(department)) {
      sum += sumSalaries(subdep); // recursively call for subdepartments, sum the results
    }
    return sum;
  }
}

alert(sumSalaries(company)); // 7700

コードは短く、理解しやすいです(願わくば?)。それが再帰の力です。これは、任意のレベルのサブ部門のネストにも機能します。

これが呼び出しのダイアグラムです。

原則は簡単にわかります。オブジェクト`{...}`に対してはサブコールが行われ、配列`[...]`は再帰ツリーの「葉」であり、即座に結果を与えます。

コードは、以前に説明したスマートな機能を使用していることに注意してください。

  • 配列の合計を取得するための`arr.reduce`メソッドは、配列メソッドの章で説明されています。
  • オブジェクトの値を反復処理するためのループ`for(val of Object.values(obj))`:`Object.values`はそれらの配列を返します。

再帰的構造

再帰的(再帰的に定義された)データ構造とは、一部で自己複製する構造のことです。

これは、上記の会社の構造の例で既に見てきました。

会社の_部門_は

  • 人の配列です。
  • または、_部門_を持つオブジェクトです。

Web開発者にとって、HTMLとXMLドキュメントははるかによく知られた例です。

HTMLドキュメントでは、_HTMLタグ_には、次のリストが含まれる場合があります。

  • テキスト部分。
  • HTMLコメント。
  • 他の_HTMLタグ_(順番にテキスト部分/コメントまたはその他のタグなどを含む可能性があります)。

これもまた再帰的な定義です。

より良い理解のために、「連結リスト」というもう1つの再帰構造について説明します。これは、場合によっては配列のより良い代替手段となる可能性があります。

連結リスト

オブジェクトの順序付きリストを保存したいとします。

自然な選択は配列です。

let arr = [obj1, obj2, obj3];

…しかし、配列には問題があります。「要素の削除」と「要素の挿入」操作はコストがかかります。たとえば、`arr.unshift(obj)`操作では、新しい`obj`のためのスペースを作るためにすべての要素の番号を付け直す必要があり、配列が大きい場合は時間がかかります。`arr.shift()`でも同様です。

大量の番号付け直しを必要としない構造上の変更は、配列の末尾を操作するものだけです:`arr.push/pop`。そのため、配列の先頭で作業する必要がある場合、大きなキューに対して配列は非常に遅くなる可能性があります。

代わりに、本当に高速な挿入/削除が必要な場合は、連結リストと呼ばれる別のデータ構造を選択できます。

_連結リスト要素_は、次のもので再帰的に定義されたオブジェクトです。

  • .
  • 次の_連結リスト要素_を参照する`next`プロパティ、またはそれが最後の場合`null`。

たとえば

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

リストのグラフィカル表現

作成のための代替コード

let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
list.next.next.next.next = null;

ここでは、複数のオブジェクトがあり、それぞれに`value`があり、`next`が隣接要素を指していることがさらに明確にわかります。`list`変数はチェーンの最初のオブジェクトであるため、そこから`next`ポインタをたどると、任意の要素に到達できます。

リストは簡単に複数の部分に分割し、後で結合することができます。

let secondList = list.next.next;
list.next.next = null;

結合するには

list.next.next = secondList;

そしてもちろん、任意の場所にアイテムを挿入または削除できます。

たとえば、新しい値を先頭に追加するには、リストの先頭を更新する必要があります。

let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };

// prepend the new value to the list
list = { value: "new item", next: list };

中央から値を削除するには、前のものの`next`を変更します。

list.next = list.next.next;

`list.next`を`1`から`2`にジャンプさせました。値`1`はチェーンから除外されています。他の場所に保存されていない場合は、メモリから自動的に削除されます。

配列とは異なり、大量の番号付け直しは必要ありません。要素を簡単に並べ替えることができます。

もちろん、リストは常に配列よりも優れているわけではありません。そうであれば、誰もリストだけを使用するでしょう。

主な欠点は、番号で要素に簡単にアクセスできないことです。配列では簡単です:`arr[n]`は直接参照です。しかし、リストでは、最初のアイテムから開始し、`next`を`N`回移動してN番目の要素を取得する必要があります。

…しかし、そのような操作は常に必要というわけではありません。たとえば、キュー、またはデック(両端への要素の追加/削除を非常に高速に行う必要がある順序付けられた構造だが、中央へのアクセスは必要ない)が必要な場合などです。

リストは拡張できます。

  • 前の要素を参照する`next`に加えて`prev`プロパティを追加して、簡単に後ろに移動することができます。
  • リストの最後の要素を参照する`tail`という名前の変数を追加し(そして、末尾から要素を追加/削除するときにそれを更新します)、
  • …データ構造は、ニーズに合わせて変更できます。

要約

用語

  • _再帰_とは、関数が自分自身を呼び出すことを意味するプログラミング用語です。再帰関数は、エレガントな方法でタスクを解決するために使用できます。

    関数が自分自身を呼び出すことを、_再帰ステップ_と言います。再帰の_基礎_は、タスクを非常に単純にする関数引数であり、関数はそれ以上の呼び出しを行いません。

  • 再帰的に定義されたデータ構造とは、それ自体を使用して定義できるデータ構造のことです。

    たとえば、連結リストは、リスト(またはnull)を参照するオブジェクトで構成されるデータ構造として定義できます。

    list = { value, next -> list }

    HTML要素ツリーやこの章の部門ツリーのようなツリーも、自然に再帰的です。枝があり、すべての枝に他の枝を持つことができます。

    `sumSalary`の例で見たように、再帰関数はそれらをたどるために使用できます。

任意の再帰関数は、反復関数に書き直すことができます。そして、それは時々物事を最適化するのに必要です。しかし、多くのタスクでは、再帰的なソリューションは十分に高速であり、記述およびサポートが容易です。

タスク

重要度:5

数値`1 + 2 + ... + n`の合計を計算する関数`sumTo(n)`を作成します。

たとえば

sumTo(1) = 1
sumTo(2) = 2 + 1 = 3
sumTo(3) = 3 + 2 + 1 = 6
sumTo(4) = 4 + 3 + 2 + 1 = 10
...
sumTo(100) = 100 + 99 + ... + 2 + 1 = 5050

3つの解決策のバリアントを作成します。

  1. `for`ループを使用します。
  2. 再帰を使用します。`n > 1`の場合、`sumTo(n) = n + sumTo(n-1)`です。
  3. 等差数列の公式を使用します。

結果の例

function sumTo(n) { /*... your code ... */ }

alert( sumTo(100) ); // 5050

追伸:どの解決策のバリアントが最も速いですか?最も遅いですか?なぜですか?

追伸:再帰を使用して`sumTo(100000)`を計算できますか?

ループを使用したソリューション

function sumTo(n) {
  let sum = 0;
  for (let i = 1; i <= n; i++) {
    sum += i;
  }
  return sum;
}

alert( sumTo(100) );

再帰を使用したソリューション

function sumTo(n) {
  if (n == 1) return 1;
  return n + sumTo(n - 1);
}

alert( sumTo(100) );

公式を使用したソリューション:`sumTo(n) = n*(n+1)/2`

function sumTo(n) {
  return n * (n + 1) / 2;
}

alert( sumTo(100) );

追伸:当然、公式が最も速いソリューションです。任意の数`n`に対して、わずか3つの操作を使用します。数学が役立ちます!

ループのバリアントは、速度の点で2番目です。再帰とループの両方で、同じ数値を合計します。しかし、再帰にはネストされた呼び出しと実行スタックの管理が含まれます。これもリソースを消費するため、遅くなります。

追伸:一部のエンジンは「テールコール」最適化をサポートしています。再帰呼び出しが関数の最後の呼び出しであり、他の計算が実行されていない場合、外部関数は実行を再開する必要がないため、エンジンは実行コンテキストを覚える必要がありません。これにより、メモリの負担が軽減されます。しかし、JavaScriptエンジンがテールコール最適化をサポートしていない場合(ほとんどのエンジンはサポートしていません)、通常はスタックの合計サイズに制限があるため、「最大スタックサイズを超えました」というエラーが発生します。

重要度:4

自然数の階乗とは、数値に`"数値マイナス1"`を掛け、次に`"数値マイナス2"`を掛け、以下同様に`1`まで掛けた数値のことです。`n`の階乗は`n!`として表されます。

階乗の定義を次のように記述できます。

n! = n * (n - 1) * (n - 2) * ...*1

さまざまな`n`に対する階乗の値

1! = 1
2! = 2 * 1 = 2
3! = 3 * 2 * 1 = 6
4! = 4 * 3 * 2 * 1 = 24
5! = 5 * 4 * 3 * 2 * 1 = 120

タスクは、再帰呼び出しを使用して`n!`を計算する関数`factorial(n)`を作成することです。

alert( factorial(5) ); // 120

追伸:ヒント:`n!`は`n * (n-1)!`と記述できます。たとえば:`3! = 3*2! = 3*2*1! = 6`

定義により、階乗`n!`は`n * (n-1)!`と記述できます。

言い換えれば、`factorial(n)`の結果は、`factorial(n-1)`の結果に`n`を掛けたものとして計算できます。そして、`n-1`の呼び出しは、`1`まで再帰的に下降することができます。

function factorial(n) {
  return (n != 1) ? n * factorial(n - 1) : 1;
}

alert( factorial(5) ); // 120

再帰の基礎は値`1`です。ここでは`0`を基礎にすることもできます。それほど重要ではありませんが、再帰ステップが1つ増えます。

function factorial(n) {
  return n ? n * factorial(n - 1) : 1;
}

alert( factorial(5) ); // 120
重要度:5

フィボナッチ数のシーケンスは、公式`Fn = Fn-1 + Fn-2`を持ちます。言い換えれば、次の数は前の2つの数の合計です。

最初の2つの数は`1`、次に`2(1+1)`、次に`3(1+2)`、`5(2+3)`などです:`1, 1, 2, 3, 5, 8, 13, 21...`。

フィボナッチ数は黄金比および私たちの周りの多くの自然現象に関連しています。

`n`番目のフィボナッチ数を返す関数`fib(n)`を作成します。

動作の例

function fib(n) { /* your code */ }

alert(fib(3)); // 2
alert(fib(7)); // 13
alert(fib(77)); // 5527939700884757

追伸:関数は高速である必要があります。`fib(77)`への呼び出しは、数分の一秒以内で行われる必要があります。

ここで最初に試すことができるソリューションは、再帰的なものです。

フィボナッチ数は、定義により再帰的です。

function fib(n) {
  return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}

alert( fib(3) ); // 2
alert( fib(7) ); // 13
// fib(77); // will be extremely slow!

…しかし、`n`の値が大きいと、非常に遅くなります。たとえば、`fib(77)`は、すべてのCPUリソースを消費して、エンジンをしばらくハングアップする可能性があります。

それは、関数が多数の副呼び出しを行っているためです。同じ値が何度も再評価されています。

例えば、fib(5)の計算例を見てみましょう。

...
fib(5) = fib(4) + fib(3)
fib(4) = fib(3) + fib(2)
...

ここで、fib(5)fib(4)の両方でfib(3)の値が必要であることがわかります。そのため、fib(3)は完全に独立して2回呼び出され、評価されます。

完全な再帰ツリーを以下に示します。

fib(3)が2回、fib(2)が3回評価されていることがはっきりとわかります。計算の総量はnよりもはるかに速い速度で増加し、n=77でも膨大になります。

既に評価された値を記憶することで、これを最適化できます。例えば、fib(3)の値を一度計算すれば、今後の計算で再利用できます。

別の方法としては、再帰を放棄して、ループベースの全く異なるアルゴリズムを使用することです。

nから小さい値へと進む代わりに、12から始まるループを作成し、それらの和としてfib(3)を取得し、2つの前の値の和としてfib(4)を取得し、fib(5)を取得し、必要な値になるまで繰り返します。各ステップで、前の2つの値だけを覚えておく必要があります。

新しいアルゴリズムの手順を詳細に示します。

開始

// a = fib(1), b = fib(2), these values are by definition 1
let a = 1, b = 1;

// get c = fib(3) as their sum
let c = a + b;

/* we now have fib(1), fib(2), fib(3)
a  b  c
1, 1, 2
*/

次に、fib(4) = fib(2) + fib(3)を取得したいとします。

変数をシフトしましょう:a,bfib(2),fib(3)を取得し、cはそれらの合計を取得します。

a = b; // now a = fib(2)
b = c; // now b = fib(3)
c = a + b; // c = fib(4)

/* now we have the sequence:
   a  b  c
1, 1, 2, 3
*/

次のステップで、別の数列番号が得られます。

a = b; // now a = fib(3)
b = c; // now b = fib(4)
c = a + b; // c = fib(5)

/* now the sequence is (one more number):
      a  b  c
1, 1, 2, 3, 5
*/

…必要な値が得られるまで続きます。これは再帰よりもはるかに高速であり、重複した計算は含まれません。

完全なコード

function fib(n) {
  let a = 1;
  let b = 1;
  for (let i = 3; i <= n; i++) {
    let c = a + b;
    a = b;
    b = c;
  }
  return b;
}

alert( fib(3) ); // 2
alert( fib(7) ); // 13
alert( fib(77) ); // 5527939700884757

ループはi=3から始まります。なぜなら、最初の2つの数列の値は変数a=1b=1にハードコードされているからです。

このアプローチは、ボトムアップ動的計画法と呼ばれています。

重要度:5

(再帰とスタックの章で説明されている)単方向連結リストがあるとします。

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

リストのアイテムを1つずつ出力する関数printList(list)を作成します。

ソリューションを2つのバリアント作成します。ループを使用する方法と再帰を使用する方法です。

どちらが良いでしょうか?再帰を使用する方法か、使用しない方法か?

ループベースのソリューション

ソリューションのループベースのバリアント

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printList(list) {
  let tmp = list;

  while (tmp) {
    alert(tmp.value);
    tmp = tmp.next;
  }

}

printList(list);

リストを辿るために一時変数tmpを使用していることに注意してください。技術的には、代わりに関数パラメータlistを使用できます。

function printList(list) {

  while(list) {
    alert(list.value);
    list = list.next;
  }

}

…しかし、それは賢明ではありません。将来的に、関数を拡張して、リストで何か他のことをする必要があるかもしれません。listを変更すると、そのような機能を失います。

良い変数名について言えば、ここでのlistはリスト自体です。その最初の要素です。そして、それはそのままであるべきです。それは明確で信頼できます。

一方で、tmpの役割は、forループのiのように、リストのトラバーサルだけです。

再帰的ソリューション

printList(list)の再帰的なバリアントは、単純なロジックに従います。リストを出力するには、現在の要素listを出力し、次にlist.nextに対して同じ操作を行います。

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printList(list) {

  alert(list.value); // output the current item

  if (list.next) {
    printList(list.next); // do the same for the rest of the list
  }

}

printList(list);

では、どちらが良いでしょうか?

技術的には、ループの方が効率的です。これら2つのバリアントは同じことを行いますが、ループは入れ子になった関数呼び出しにリソースを費やしません。

一方で、再帰的なバリアントは短く、理解しやすい場合があります。

重要度:5

前のタスク単方向連結リストの出力の単方向連結リストを逆順に出力します。

2つのソリューションを作成します。ループを使用する方法と再帰を使用する方法です。

再帰の使用

ここの再帰的なロジックは少しトリッキーです。

まずリストの残りを出力し、その後現在の要素を出力する必要があります。

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printReverseList(list) {

  if (list.next) {
    printReverseList(list.next);
  }

  alert(list.value);
}

printReverseList(list);

ループの使用

ループのバリアントも、直接出力よりも少し複雑です。

listの最後の値を取得する方法はありません。「戻る」こともできません。

そのため、最初にアイテムを直接順に辿って配列に記憶し、次に記憶したものを逆順に出力することができます。

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printReverseList(list) {
  let arr = [];
  let tmp = list;

  while (tmp) {
    arr.push(tmp.value);
    tmp = tmp.next;
  }

  for (let i = arr.length - 1; i >= 0; i--) {
    alert( arr[i] );
  }
}

printReverseList(list);

再帰的なソリューションは実際にはまったく同じことを行います。リストを辿り、入れ子になった呼び出しのチェーン(実行コンテキストスタック)にアイテムを記憶し、次にそれらを出力します。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善点があれば、コメントする代わりにGitHub issueまたはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • コードを数語挿入するには<code>タグを使用し、数行の場合は<pre>タグで囲み、10行以上の場合にはサンドボックス(plnkrjsbincodepen…)を使用してください。