読み込み中...

JavaScriptにおける再帰関数の最適な実装方法10選

JavaScriptの再帰関数を解説! JS
この記事は約20分で読めます。

【サイト内のコードはご自由に個人利用・商用利用いただけます】

この記事では、プログラムの基礎知識を前提に話を進めています。

説明のためのコードや、サンプルコードもありますので、もちろん初心者でも理解できるように表現してあります。

本記事のサンプルコードを活用して機能追加、目的を達成できるように作ってありますので、是非ご活用ください。

※この記事は、一般的にプロフェッショナルの指標とされる『実務経験10,000時間以上』を満たす現役のプログラマチームによって監修されています。

※Japanシーモアは、常に解説内容のわかりやすさや記事の品質に注力しております。不具合、分かりにくい説明や不適切な表現、動かないコードなど気になることがございましたら、記事の品質向上の為にお問い合わせフォームにてご共有いただけますと幸いです。
(送信された情報は、プライバシーポリシーのもと、厳正に取扱い、処分させていただきます。)

●再帰関数とは?

再帰関数とは、自分自身を呼び出す関数のことです。

一見、ややこしく感じるかもしれませんが、実は私たちが日常的に行っている思考プロセスに似ています。

例えば、「朝起きたら、昨日の自分より少しでも成長していたい」と思うことがあります。

この思考プロセスは、「今日の自分」が「昨日の自分」を呼び出して、少し変化を加えているといえます。

プログラミングにおける再帰関数も同様に、関数が自分自身を呼び出し、少しずつ変化を加えていくことで、複雑な問題を解決していきます。

○再帰関数の基本概念

再帰関数の基本的な構造は次のようになります。

  1. 再帰の終了条件を定義する
  2. 再帰呼び出しを行う
  3. 再帰呼び出しの結果を処理する

再帰の終了条件を定義することで、無限ループを防ぎます。

再帰呼び出しでは、関数が自分自身を呼び出しますが、その際に引数を変化させることで、徐々に問題を小さくしていきます。

最後に、再帰呼び出しの結果を処理することで、最終的な結果を得ることができます。

○再帰呼び出しの仕組み

再帰呼び出しでは、関数が自分自身を呼び出すたびに、新しい関数の実行コンテキストがスタック上に積まれていきます。

これをコールスタックと呼びます。

再帰の終了条件を満たすと、スタックに積まれた関数の実行コンテキストが1つずつポップされ、最終的な結果が返されます。

○再帰関数のメリットとデメリット

再帰関数のメリットは、複雑な問題を簡潔に記述できることです。

特に、木構造やグラフ構造のような階層的なデータを扱う際に、再帰関数は非常に有効です。

また、再帰的に定義された数学的な問題を解く際にも、再帰関数が役立ちます。

一方、デメリットとしては、再帰呼び出しが深くなりすぎると、スタックオーバーフローを引き起こす可能性があります。

また、再帰関数は、反復処理に比べて、実行速度が遅くなる傾向があります。

●再帰関数の実装方法

前章では、再帰関数の基本概念と仕組み、そしてメリットとデメリットについて解説しました。

ここからは、実際に再帰関数を実装する方法について、具体的なサンプルコードを交えて説明していきます。

再帰関数を実装する際に重要なのは、再帰の終了条件を適切に設定することです。

終了条件が適切でないと、無限ループに陥ってしまう可能性があります。

また、再帰呼び出しを行う際には、問題を徐々に小さくしていくことが大切です。

これにより、最終的に終了条件を満たすことができます。

では早速、サンプルコードを見ていきましょう。

○サンプルコード1:単純な再帰関数

まずは、簡単な再帰関数の例として、階乗を計算する関数を実装してみます。

階乗とは、1からある自然数までの全ての自然数の積のことです。

例えば、5の階乗は、1 * 2 * 3 * 4 * 5 = 120 となります。

function factorial(n) {
  if (n === 0) {
    return 1;
  } else {
    return n * factorial(n - 1);
  }
}

console.log(factorial(5)); // 120

この関数では、再帰の終了条件は n === 0 です。

nが0の場合、階乗の定義より1を返します。

nが0でない場合は、nfactorial(n - 1)の積を返します。

これにより、nを徐々に小さくしていき、最終的に n === 0 となって再帰が終了します。

○サンプルコード2:末尾再帰の実装

次に、末尾再帰の実装方法について見ていきましょう。

末尾再帰とは、再帰呼び出しが関数の最後で行われる再帰のことです。

末尾再帰を使うと、関数呼び出しのオーバーヘッドを削減できる場合があります。

先ほどの階乗の例を、末尾再帰を使って実装してみます。

function factorial(n, acc = 1) {
  if (n === 0) {
    return acc;
  } else {
    return factorial(n - 1, n * acc);
  }
}

console.log(factorial(5)); // 120

この実装では、累積値accを引数に追加しています。

accは、再帰呼び出しの度に更新されていき、最終的に階乗の結果となります。

末尾再帰を使うことで、関数呼び出しのオーバーヘッドを削減できる場合があります。

○サンプルコード3:メモ化を用いた最適化

再帰関数の実行速度を改善する方法の1つに、メモ化があります。

メモ化とは、関数の結果をキャッシュすることで、同じ引数に対する関数呼び出しを繰り返さないようにする手法です。

フィボナッチ数列を計算する関数を、メモ化を用いて最適化してみましょう。

フィボナッチ数列とは、0, 1, 1, 2, 3, 5, 8, 13, … のように、前の2つの数の和が次の数になる数列のことです。

function fib(n, memo = {}) {
  if (n in memo) {
    return memo[n];
  }
  if (n <= 1) {
    return n;
  }
  memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
  return memo[n];
}

console.log(fib(10)); // 55

この実装では、memoオブジェクトを引数に追加しています。

memoは、計算済みのフィボナッチ数をキャッシュするためのオブジェクトです。

nmemoに存在する場合は、キャッシュされた値を返します。

これにより、同じnに対する計算を繰り返さないようにしています。

○サンプルコード4:相互再帰の例

最後に、相互再帰の例を見ていきましょう。

相互再帰とは、複数の関数が相互に呼び出しあう再帰のことです。

次のサンプルコードは、偶数と奇数を判定する関数を相互再帰で実装したものです。

function isEven(n) {
  if (n === 0) {
    return true;
  } else {
    return isOdd(n - 1);
  }
}

function isOdd(n) {
  if (n === 0) {
    return false;
  } else {
    return isEven(n - 1);
  }
}

console.log(isEven(4)); // true
console.log(isOdd(5)); // true

isEven関数は、nが0の場合は偶数なのでtrueを返し、そうでない場合はisOdd(n - 1)を呼び出します。

一方、isOdd関数は、nが0の場合は奇数ではないのでfalseを返し、そうでない場合はisEven(n - 1)を呼び出します。

このように、2つの関数が相互に呼び出しあうことで、偶数と奇数を判定しています。

相互再帰は、複雑な問題を解く際に有効な手法の1つです。

ただし、相互再帰を使う場合は、再帰の終了条件を適切に設定することが大切です。

●再帰関数のよくあるエラーと対処法

再帰関数を実装する際には、いくつかの注意点があります。

適切に実装しないと、エラーが発生したり、意図しない動作をしてしまうことがあります。

ここでは、再帰関数を使う際によくあるエラーと、その対処法について解説します。

○スタックオーバーフローへの対処

再帰関数を使う際に最も注意すべきエラーの1つが、スタックオーバーフローです。

スタックオーバーフローとは、関数呼び出しが深くなりすぎて、スタックと呼ばれるメモリ領域が枯渇してしまう現象のことです。

スタックオーバーフローを防ぐためには、再帰の深さを制限することが重要です。

再帰の深さが深くなりすぎないように、適切な終了条件を設定しましょう。

また、再帰関数の代わりに反復処理を使うことで、スタックオーバーフローを回避できる場合もあります。

例えば、次のコードは、再帰の深さが深くなりすぎてスタックオーバーフローが発生する可能性があります。

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

console.log(sum(100000)); // RangeError: Maximum call stack size exceeded

この問題を解決するには、再帰の代わりに反復処理を使うことができます。

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

console.log(sum(100000)); // 5000050000

○無限ループの回避方法

再帰関数を実装する際には、無限ループに陥らないように注意が必要です。

無限ループとは、再帰呼び出しが永遠に続いてしまう状態のことです。

無限ループを回避するためには、再帰の終了条件を適切に設定することが大切です。

再帰呼び出しを行う前に、必ず終了条件をチェックするようにしましょう。

次のコードは、終了条件が適切でないため、無限ループに陥ってしまいます。

function countDown(n) {
  console.log(n);
  countDown(n); // 終了条件がないため、無限ループになる
}

countDown(10); // RangeError: Maximum call stack size exceeded

この問題を解決するには、終了条件を追加する必要があります。

function countDown(n) {
  console.log(n);
  if (n > 0) {
    countDown(n - 1); // 終了条件を追加
  }
}

countDown(10);
// 10
// 9
// 8
// ...
// 1
// 0

○再帰の終了条件の設定

再帰関数を正しく動作させるためには、適切な終了条件を設定することが重要です。

終了条件とは、再帰呼び出しを終了するための条件のことです。

終了条件を設定する際には、再帰呼び出しが確実に終了するようにしなければなりません。

また、終了条件は、できるだけシンプルで分かりやすいものにすることが大切です。

次のコードは、終了条件が適切でないため、意図しない動作をしてしまいます。

function factorial(n) {
  if (n === 1) {
    return 1;
  }
  return n * factorial(n - 1);
}

console.log(factorial(0)); // RangeError: Maximum call stack size exceeded

この問題を解決するには、終了条件を修正する必要があります。

function factorial(n) {
  if (n <= 1) {
    return 1;
  }
  return n * factorial(n - 1);
}

console.log(factorial(0)); // 1
console.log(factorial(5)); // 120

再帰関数を使う際は、スタックオーバーフロー、無限ループ、終了条件の設定など、注意すべきポイントがいくつかあります。

このポイントを押さえておくことで、再帰関数を正しく実装できるようになります。

●再帰関数の応用例

再帰関数は、一見すると難しく感じるかもしれません。

しかし、再帰関数を使いこなせるようになると、複雑な問題をシンプルに解決できるようになります。

ここでは、再帰関数の応用例をいくつか紹介します。

実際のプログラミングでどのように再帰関数が活用されているのか、具体的なサンプルコードを交えて解説していきます。

○サンプルコード5:フィボナッチ数列の計算

フィボナッチ数列は、再帰関数を使って簡潔に表現できる代表的な例です。

フィボナッチ数列とは、0, 1, 1, 2, 3, 5, 8, 13, … のように、前の2つの数の和が次の数になる数列のことです。

再帰関数を使ってフィボナッチ数列を計算してみましょう。

function fibonacci(n) {
  if (n <= 1) {
    return n;
  }
  return fibonacci(n - 1) + fibonacci(n - 2);
}

console.log(fibonacci(0)); // 0
console.log(fibonacci(1)); // 1
console.log(fibonacci(2)); // 1
console.log(fibonacci(3)); // 2
console.log(fibonacci(4)); // 3
console.log(fibonacci(5)); // 5
console.log(fibonacci(6)); // 8
console.log(fibonacci(7)); // 13

この関数では、nが0以下の場合はnを返し、そうでない場合はfibonacci(n - 1)fibonacci(n - 2)の和を返しています。

再帰呼び出しを繰り返すことで、フィボナッチ数列の値を計算しています。

ただし、この実装では、nが大きくなると関数呼び出しが急激に増えてしまうため、効率が悪くなります。

実際のアプリケーションでは、メモ化などの最適化手法を用いることが一般的です。

○サンプルコード6:ディレクトリ構造の走査

再帰関数は、ディレクトリ構造のような階層的なデータを扱う際にも威力を発揮します。

次の例では、再帰関数を使ってディレクトリ構造を走査し、ファイルとディレクトリの一覧を表示します。

const fs = require('fs');
const path = require('path');

function printDirectory(dirPath, depth = 0) {
  const files = fs.readdirSync(dirPath);

  files.forEach((file) => {
    const filePath = path.join(dirPath, file);
    const stats = fs.statSync(filePath);

    console.log('  '.repeat(depth) + file);

    if (stats.isDirectory()) {
      printDirectory(filePath, depth + 1);
    }
  });
}

const currentDir = process.cwd();
console.log(currentDir);
printDirectory(currentDir);

この関数では、fs.readdirSync()を使ってディレクトリ内のファイルとサブディレクトリを取得し、forEach()でそれぞれを処理しています。

ファイルの場合は、そのままファイル名を表示します。ディレクトリの場合は、再帰的にprintDirectory()を呼び出し、サブディレクトリ内のファイルとディレクトリを表示します。

再帰関数を使うことで、ディレクトリ構造を簡潔に走査できます。

同様の処理を反復処理で実装する場合、ディレクトリの深さに応じて複雑なループ処理が必要になります。

再帰関数を使えば、シンプルで読みやすいコードで実装できます。

○サンプルコード7:二分探索の実装

二分探索は、ソートされた配列から特定の値を効率的に探索するアルゴリズムです。

再帰関数を使うと、二分探索を簡潔に実装できます。

function binarySearch(arr, target, left = 0, right = arr.length - 1) {
  if (left > right) {
    return -1;
  }

  const mid = Math.floor((left + right) / 2);

  if (arr[mid] === target) {
    return mid;
  } else if (arr[mid] < target) {
    return binarySearch(arr, target, mid + 1, right);
  } else {
    return binarySearch(arr, target, left, mid - 1);
  }
}

const numbers = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19];

console.log(binarySearch(numbers, 7)); // 3
console.log(binarySearch(numbers, 15)); // 7
console.log(binarySearch(numbers, 20)); // -1

この関数では、探索範囲の左端leftと右端rightを引数に取ります。

探索範囲の中央値midを計算し、arr[mid]targetを比較します。

arr[mid]targetと等しい場合は、そのインデックスを返します。

arr[mid]targetより小さい場合は、mid + 1からrightまでの範囲で再帰的に探索します。

arr[mid]targetより大きい場合は、leftからmid - 1までの範囲で再帰的に探索します。

二分探索は、配列のサイズが大きくなるほど、線形探索に比べて探索効率が高くなります。

再帰関数を使うことで、二分探索のアルゴリズムを直感的に表現できます。

○サンプルコード8:グラフの深さ優先探索(DFS)

グラフの探索アルゴリズムの1つに、深さ優先探索(DFS)があります。

DFSは、再帰関数を使って簡潔に実装できます。

const graph = {
  A: ['B', 'C'],
  B: ['A', 'D', 'E'],
  C: ['A', 'F'],
  D: ['B'],
  E: ['B', 'F'],
  F: ['C', 'E'],
};

function dfs(graph, startNode, visited = new Set()) {
  console.log(startNode);
  visited.add(startNode);

  const neighbors = graph[startNode];
  for (const neighbor of neighbors) {
    if (!visited.has(neighbor)) {
      dfs(graph, neighbor, visited);
    }
  }
}

dfs(graph, 'A');
// A
// B
// D
// E
// F
// C

この関数では、グラフを表すオブジェクトgraphと、探索を開始するノードstartNodeを引数に取ります。

visitedは、探索済みのノードを記録するためのセットです。

まず、現在のノードを表示し、visitedに追加します。

次に、現在のノードに隣接するノードをneighborsに取得します。

neighborsの各ノードについて、まだ探索されていない場合は、再帰的にdfs()を呼び出します。

再帰関数を使うことで、グラフの探索を簡潔に表現できます。

DFS以外にも、幅優先探索(BFS)などのグラフ探索アルゴリズムを再帰関数で実装できます。

再帰関数は、複雑な問題をシンプルに解決するための強力なツールです。

フィボナッチ数列、ディレクトリ構造の走査、二分探索、グラフの探索など、さまざまな場面で再帰関数が活用されています。

再帰関数を使いこなすためには、再帰の仕組みを理解し、適切な終了条件を設定することが大切です。

●TypeScriptにおける再帰関数の型定義

TypeScriptは、JavaScriptに静的型付けを追加したプログラミング言語です。

TypeScriptを使うことで、コンパイル時にエラーを検出でき、コードの品質と保守性を高めることができます。

再帰関数をTypeScriptで実装する際は、適切な型定義を行うことが重要です。

TypeScriptで再帰関数を定義する際は、関数の戻り値の型を明示的に指定する必要があります。

また、再帰呼び出しを行う箇所でも、引数の型を正しく指定しなければなりません。

型定義を適切に行うことで、コンパイラがエラーを検出してくれるため、バグを未然に防ぐことができます。

では、TypeScriptにおける再帰関数の型定義について、具体的に見ていきましょう。

○再帰型の宣言方法

再帰関数を定義する際は、再帰型(recursive type)を宣言する必要があります。

再帰型とは、型の定義の中で自分自身を参照する型のことです。

例えば、次のような再帰的なデータ構造を考えてみましょう。

type Node<T> = {
  value: T;
  next: Node<T> | null;
};

この Node型は、valueプロパティとnextプロパティを持つオブジェクト型です。

nextプロパティは、Node型自身を参照しています。

このように、型の定義の中で自分自身を参照することを再帰型と呼びます。

再帰型を使って、再帰関数の型を定義することができます。

例えば、リストの長さを求める再帰関数の型は次のように定義できます。

function listLength<T>(node: Node<T> | null): number {
  if (node === null) {
    return 0;
  }
  return 1 + listLength(node.next);
}

この関数は、Node型またはnullを受け取り、リストの長さを返します。

再帰呼び出しの箇所でも、node.nextNode型またはnullであることを型で表現しています。

○ジェネリック型を用いた汎用的な実装

再帰関数をより汎用的に実装するために、ジェネリック型を使うことができます。

ジェネリック型を使うことで、様々な型に対応した再帰関数を定義できます。

例えば、ツリー構造を表現するTree型を次のように定義し、ツリーの高さを求める再帰関数を実装してみましょう。

type Tree<T> = {
  value: T;
  children: Tree<T>[];
};

function treeHeight<T>(tree: Tree<T>): number {
  if (tree.children.length === 0) {
    return 1;
  }
  const childHeights = tree.children.map(child => treeHeight(child));
  return 1 + Math.max(...childHeights);
}

const tree: Tree<number> = {
  value: 1,
  children: [
    {
      value: 2,
      children: [],
    },
    {
      value: 3,
      children: [
        {
          value: 4,
          children: [],
        },
      ],
    },
  ],
};

console.log(treeHeight(tree)); // 3

この例では、Tree型をジェネリック型Tを使って定義しています。

treeHeight関数は、Tree<T>型の引数を受け取り、ツリーの高さを計算して返します。

再帰呼び出しの箇所では、tree.childrenの各要素に対してtreeHeight関数を再帰的に呼び出しています。

ジェネリック型を使うことで、number型以外の要素を持つツリーに対しても、同じ関数を適用できます。

TypeScriptにおける再帰関数の型定義について解説してきました。

再帰型を適切に宣言し、ジェネリック型を活用することで、型安全な再帰関数を実装できます。

型定義を行うことで、コンパイラがエラーを検出してくれるため、バグを未然に防ぐことができます。

まとめ

本記事では、JavaScriptにおける再帰関数について、基本概念から実装方法、エラー対処法、応用例、TypeScriptでの型定義まで、幅広く解説してきました。

再帰関数は、自分自身を呼び出す関数であり、複雑な問題をシンプルに解決するための強力なツールです。

再帰関数を適切に使いこなすためには、再帰の仕組みを理解し、適切な終了条件を設定することが重要です。

また、スタックオーバーフローや無限ループなどのエラーに注意し、必要に応じて最適化手法を検討しましょう。

TypeScriptと組み合わせることで、型安全性を確保しながら、再帰関数の力を最大限に引き出すことができます。

再帰関数を使いこなすことで、より効率的で可読性の高いコードを書くことができるでしょう。

ぜひ、本記事で紹介した知識を活かして、再帰関数を使ったプログラミングにチャレンジしてみてください。