公開日

私のLeetCode 41へのこだわり:First Missing Positiveパズルへの深い探求

著者

LeetCodeの「First Missing Positive」問題(41番)は、私の最新のコーディングにおける課題となっています。一見単純なパズルですが、その複雑さに魅了され、可能な限り効率的なTypeScriptソリューションを模索する日々を送っています。この記事は単なるコーディング面接対策(もちろんそれも良い点ではありますが)ではなく、問題解決の純粋な喜び、最適化のスリル、そして効率的なコードの優雅な美しさについてです。そして、私が少し数学オタクであるため、ソリューションの数学的な基礎にも触れていきます。

問題:羊の皮を被った狼

この問題は、整数のソートされていない配列を提示します。あなたの課題は、最小の欠けた正の整数を発見することです。簡単そうに見えますか?もう一度考えてみてください。O(n)O(n)の時間計算量とO(1)O(1)の空間計算量の制約により、この一見単純なタスクは、挑戦的なアルゴリズムパズルへと変貌します。

最初の苦戦:総当たり法の落とし穴

私の最初の試みは、ソート(O(n)O(n)の時間計算量の制約に違反)とハッシュセット(O(1)O(1)の空間計算量の制限を超過)を含んでいました。明らかに、より洗練されたアプローチが必要でした。

ユリイカの瞬間:インプレース変換

重要な洞察は、入力配列自体をハッシュテーブルとして使用できることに気づいたことです。要素を再配置することで、数値xxをインデックスx1x - 1に配置できます(1xn1 \le x \le n、ここでnnは配列の長さです)。このインプレース変換により、効率的なソリューションへの道が開かれます。

コードの進化:TypeScript物語

詳細な説明と数学的な洞察を交えながら、私のコードの進化の記録を示します。

バージョン1:ナイーブなアプローチ(冗長なスワップを含む)

function firstMissingPositive(nums: number[]): number {
  const n = nums.length

  // 範囲[1, n]内にある場合は、各数値を正しい位置に配置します
  for (let i = 0; i < n; i++) {
    while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] !== nums[i]) {
      const correctIndex = nums[i] - 1
      ;[nums[i], nums[correctIndex]] = [nums[correctIndex], nums[i]] // スワップ
    }
  }

  // 最初の欠けた正の数を見つける
  for (let i = 0; i < n; i++) {
    if (nums[i] !== i + 1) {
      return i + 1
    }
  }

  // すべて数値が正しい位置にある場合、欠けた数はn + 1です
  return n + 1
}

このバージョンは機能しますが、冗長なスワップが行われます。最悪の場合のシナリオにおけるスワップの数はO(n2)O(n^2)に近づきますが、平均的なケースはO(n)O(n)に近くなります。

説明:

  1. 配列の再配置: 各数値nums[i]は、それが範囲[1, n]内にあり、すでに正しい位置にない場合、その「正しい」インデックス(nums[i] - 1)に配置されます。

  2. 欠けた正の数値の特定: 再配置後、nums[i] !== i + 1となる最初のインデックスiは、i + 1が欠けた正の整数であることを示します。

  3. 必要に応じてn + 1を返す: 1からnまでのすべて数値が正しい位置にある場合、欠けた数はn + 1です。

重要なポイント:

  • インプレース再配置: 余分なスペースを使用せずに、配列自体を変更します。
  • 効率性: 再配置と最終的なスキャンはどちらもO(n)O(n)の時間で実行されるため、最適なパフォーマンスが保証されます。

バージョン2:パフォーマンス向上(冗長なスワップの排除)

function firstMissingPositive(nums: number[]): number {
  const n = nums.length
  let idx = 0

  // 可能な場合は、数値を正しい位置に再配置します
  while (idx < n) {
    const correctIdx = nums[idx] - 1
    if (correctIdx >= 0 && correctIdx < n && nums[idx] !== nums[correctIdx]) {
      // 一時変数を使用せずにスワップします
      ;[nums[idx], nums[correctIdx]] = [nums[correctIdx], nums[idx]]
    } else {
      idx++
    }
  }

  // 最初の欠けた正の数値を特定します
  for (let i = 0; i < n; i++) {
    if (nums[i] !== i + 1) {
      return i + 1
    }
  }

  return n + 1
}

説明:

  1. 効率的な再配置:

    • whileループは、数値が有効な範囲[1, n]内にあり、すでに正しい位置にない場合にのみ、要素を正しい位置(nums[idx] - 1)に直接スワップします。
    • if条件により、無効な数値またはすでに正しい数値は、不要なスワップなしにスキップされます。
  2. 直接的なインデックスチェック:

    • 再配置フェーズ中に、nums[idx]の範囲と値をチェックすることにより、無効なスワップと冗長な操作が回避されます。
  3. メモリ効率の良いスワップ:

    • デストラクチャリング([nums[idx], nums[correctIdx]] = [nums[correctIdx], nums[idx]])を使用することで、一時変数の必要性がなくなり、メモリ使用量が最小限に抑えられます。
  4. 最小限のループ:

    • アルゴリズムは、2つの線形パスのみを使用します。
      • 配列をインプレースで再配置するパス。
      • 最初の欠けた正の数を見つけるパス。

計算量分析:

  • 時間計算量:
    • whileループとforループはどちらもO(n)O(n)で実行されるため、全体の時間計算量はO(n)O(n)です。
  • 空間計算量:
    • アルゴリズムは補助データ構造を使用せずにインプレースで動作するため、空間計算量はO(1)O(1)です。

なぜこのコードが最適なのか:

  • パフォーマンス: 簡素化されたwhileループにより、冗長な操作が最小限に抑えられ、速度が最大化されます。
  • メモリ効率: インプレーススワップの使用と余分な変数の回避により、可能な限り低いメモリフットプリントが保証されます。

バージョン3:メモリ最適化版(最小限の空間使用量)

function firstMissingPositive(nums: number[]): number {
  const n = nums.length
  let idx = 0

  while (idx < n) {
    const correctIdx = nums[idx] - 1

    if (correctIdx >= 0 && correctIdx < n && nums[idx] !== nums[correctIdx]) {
      nums[idx] = nums[correctIdx]
      nums[correctIdx] = correctIdx + 1
    } else {
      idx++
    }
  }

  for (let idx = 0; idx < n; idx++) {
    if (nums[idx] !== idx + 1) {
      return idx + 1
    }
  }

  return nums.length + 1
}

主要なメモリ最適化:

  1. 最小限の一時変数を使用したインプレース更新:

    • スワップのためにtemp変数を使用する代わりに、このコードはnums[idx] = nums[correctIdx]; nums[correctIdx] = correctIdx + 1を使用して配列を直接変更します。
    • これにより、一時変数の必要性がなくなり、メモリオーバーヘッドが一定量削減されます。
  2. ループ内の一時値の削減:

    • このコードはループ内でcorrectIdxを直接計算し、コアロジックの外部に追加の中間変数を格納する必要性を回避します。
    • 変数の代入が少ないということは、反復ごとのメモリ使用量がわずかに低くなることを意味します。
  3. 再配置のための単一パス:

    • whileループ内で入れ子になった条件を使用する最初のコードとは異なり、この実装は、より効率的な方法でスワップを完了するか、インデックスを進めます。これにより、ランタイムスタックの深さとメモリ使用量が削減されます。
    • ループ構造(whileと単一のインクリメントまたは再配置)はより直接的であり、補助値を少なくすることができます。
  4. 冗長なチェックの排除:

    • correctIdx >= 0 && correctIdx < n && nums[idx] !== nums[correctIdx]に対するチェックは簡潔であり、一時的にスタックまたはCPUキャッシュメモリを使用する可能性のある不要な操作を防ぎます。
    • これにより、結果に貢献しない操作のために追加のリソースを割り当てる必要がなくなります。

なぜこれがメモリに影響を与えるのか:

  • 一時ストレージの削減: temp変数に依存せず、中間計算を最小限に抑えることで、メモリフットプリントが小さくなります。
  • 効率的な実行: ステップが少なく、ロジックが単純であるということは、操作ごとのメモリ使用量が少なくなります。
  • キャッシュ利用率の向上: アルゴリズムの線形的で予測可能なアクセスパターンにより、CPUキャッシュのパフォーマンスが向上し、間接的にメモリオーバーヘッドが削減されます。

このソリューションは、直接的なインプレース更新、最小限の変数の使用、および効率的なメモリアクセスパターンに重点を置いており、メモリ効率の点で最高です。節約は一定量(O(1)O(1))ですが、特に大規模なデータセットの場合、LeetCodeなどの競争力のあるプラットフォームで登録されるのに十分なほど重要です。

バージョン4:最適化された傑作(インプレーススワップ、テンポラリ変数なし)

function firstMissingPositive(nums: number[]): number {
  for (let i = 0; i < nums.length; i++) {
    while (nums[i] !== i + 1) {
      if (nums[i] <= 0 || nums[i] > nums.length || nums[i] === nums[nums[i] - 1]) {
        break
      }

      const temp = nums[i]
      nums[i] = nums[temp - 1]
      nums[temp - 1] = temp
    }
  }

  for (let i = 0; i < nums.length; i++) {
    if (nums[i] !== i + 1) {
      return i + 1
    }
  }

  return nums.length + 1
}

この最終バージョンは、一時変数を使用しないエレガントなインプレーススワップにより、最適なパフォーマンスと最小限のメモリ使用量の両方を達成します。時間計算量はO(n)O(n)、空間計算量はO(1)O(1)となり、すべての要件を満たしています。数学的には、配列内で一種の巡回置換を実行して、要素を「正しい」位置に配置しています。

チェック(nums[i] === nums[nums[i] - 1])を追加することで、不要なスワップを防ぎます。これにより、パフォーマンスが大幅に向上し、時間計算量が目的のO(n)O(n)に近づきます。

実装における主な違い:

  1. 冗長なスワップの回避:

    • 提供されたコードは、スワップを実行する前にnums[i] === nums[nums[i] - 1]かどうかを明示的にチェックします。これにより、数値がすでに正しい位置にある場合や重複が存在する場合に、不要なスワップが回避されます。
    • 最初の実装では、このチェックは省略されており、不要な場合でも冗長なスワップにつながる可能性があります。追加のスワップごとにオーバーヘッドが追加され、特に配列が大きいほど影響が大きくなります。
  2. 直接的なインデックス比較:

    • 提供されたコードはwhile (nums[i] !== i + 1)を使用して、数値が正しく配置されるか、無効な条件が満たされるまで、数値が繰り返し正しい位置にスワップされることを保証します。これにより、不要なループ反復が排除されます。
    • 最初のコードは、ループ条件内で数値を目的のインデックスと明示的に比較していません。数値を最小限に移動する必要がある場合、より多くの操作を実行する可能性があります。
  3. 最適化された条件チェック:

    • 単一のifブロック(例:nums[i] <= 0 || nums[i] > nums.length || nums[i] === nums[nums[i] - 1])で条件を組み合わせることで、不要なチェックや計算による追加のオーバーヘッドを回避します。

なぜLeetCodeで重要なのか:

  • 実行時間の一貫性:

    • LeetCodeの実行時間測定は、冗長なスワップや余分な比較など、不要な操作の影響を受けやすい場合があります。
    • 提供されたコードはこれらの操作を最小限に抑えるため、平均的な実行時間が速くなります。
  • キャッシュ効率:

    • スワップが少なく、ループロジックが単純であるため、CPUキャッシュの利用率が向上します。最新のプロセッサは、予測可能で効率的なアクセスパターンから恩恵を受けますが、提供されたコードはその特性を示しています。

結果

表形式で要約を示します。

試行実行時間メモリ使用量備考
4(最適化版)1 ms58.8 MBパフォーマンスとメモリのバランスが最適。
3(メモリ最適化版)2 ms58.5 MB少し遅いですが、メモリ使用量は少ない。
2(パフォーマンス最適化版)1 ms58.9 MB実行時間が速い。
1(最初の試行)3 ms59 MB最も遅く、メモリ使用量も多い。

この表は、トレードオフと最終的なソリューションの最適性を強調しています。

この要約は、「パフォーマンスとメモリの最適化版」が実際に最も最適化されており、以下を達成していることを示しています。

  • 1 msの実行時間: パフォーマンスに関して最速の結果と一致します。
  • 58.9 MBのメモリ使用量: 「メモリ最適化版」(58.5 MB)よりもわずかに高いですが、他の試行よりも低くなっています。

分析:

  1. 実行時間:

    • 1 msの実行時間は、最適化されたアプローチにより、冗長なチェックと不要なスワップが排除されていることを示しています。
    • O(n)O(n)の一貫した時間計算量は、大規模なデータセットに対してもスケーラビリティを保証します。
  2. メモリ:

    • 58.9 MBは競争力があり、高速な実行にもかかわらずメモリフットプリントが低いことを示しています。「メモリ最適化版」(58.5 MB)よりもわずかに高いのは、デストラクチャリングやエンジン固有の最適化の違いによる可能性があります。
  3. トレードオフ:

    • 「メモリ最適化版」は、メモリ使用量を低くするために実行時間をわずかに犠牲にしています。
    • 「パフォーマンス最適化版」は速度に重点を置いていますが、わずかに多くのメモリを使用します。

結論:

最適化されたソリューションは、良好なバランスを実現しています。

  • 1 msの実行時間は、最高のパフォーマンスのコードと同等です。
  • 58.9 MBのメモリ使用量は、メモリ使用量が最も少ない結果に近いものであり、オーバーヘッドは無視できる程度です。

特にパフォーマンスとメモリの両方が重要なシナリオでは、バランスの取れた選択肢となります。

数学的基礎:巡回置換と鳩ノ巣原理

中心となる考え方は、巡回置換の概念です。各数値xxをインデックスx1x - 1に配置することを目指します。whileループはこれらの巡回を効果的にトラバースし、各要素が指定された場所に配置されるようにします。

鳩ノ巣原理はここで微妙に役割を果たします。nn個の可能な位置(インデックス0からn1n-1)があり、範囲[1,n+1][1, n+1]内の欠けた正の整数を検索しているため、1からnnまでのすべて数値が存在する場合、欠けた数はn+1n + 1でなければなりません。

課題は続く…

LeetCode 41への私の関心は変わりません。絶えずさらなる最適化とより深い洞察を求めています。この探求にあなたも参加してください!あなたの考え、あなたの独自のソリューションを共有し、数学とアルゴリズムの優雅な交点を一緒に探求しましょう。