|
| 1 | +# LeetCode 21: Merge Two Sorted Lists |
| 2 | + |
| 3 | +## 1. 問題の分析 |
| 4 | + |
| 5 | +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 |
| 6 | +
|
| 7 | +**競技プログラミング視点での分析** |
| 8 | + |
| 9 | +- 実行速度を最優先するなら、各ノードを1回ずつ見るだけで済む**反復(ループ)処理**が最速。O(n+m) の1パスで完結し、余計な比較や再帰呼び出しのオーバーヘッドがない。 |
| 10 | +- メモリ使用量の最小化方針としては、**新しいノードを一切作らず、既存ノードの `next` 参照だけを付け替える**(この操作を「スプライシング(splicing、つなぎ替え)」と呼ぶ)。配列に変換してからソートするような方法は、リストがすでにソート済みという情報を無駄にしてしまう。 |
| 11 | + |
| 12 | +**業務開発視点での分析** |
| 13 | + |
| 14 | +- 型安全性の面では、この問題は `ListNode.val` が `int` に固定されているLeetCode標準シグネチャなので、ジェネリクス(=型をパラメータとして受け取れる仕組み)を導入する必要は**ありません**。もし「任意の `Comparable` な値を持つノードをマージする汎用ライブラリ」を作るなら `<T extends Comparable<T>>` を使う場面ですが、今回はメソッドシグネチャが固定されているため、無理に汎用化するとかえって可読性が落ちます。 |
| 15 | +- エラーハンドリングで注意すべき点:**`list1` や `list2` が `null` であることは「異常な入力」ではなく「空のリストを表す正常な状態」**です。Example 2 (`list1 = [], list2 = []`) がまさにそれです。テンプレートでよくある「`null` なら即 `IllegalArgumentException`」という防御的プログラミングをそのまま適用すると、この問題では**正しい入力を誤ってエラー扱いしてしまう**ので注意が必要です。 |
| 16 | + |
| 17 | +**Java特有の考慮点** |
| 18 | + |
| 19 | +- 静的型付けにより、`list1.val` は必ず `int` であることがコンパイル時に保証されており、実行時の型チェックは不要。 |
| 20 | +- `ListNode` はJava標準の `java.*` に属するクラスではなく、問題側で定義されたクラスなので、`Optional<T>` を挟むよりも「`null` はリストの終端(または空リスト)を意味する」という連結リストの一般的な慣習に従うほうが、他のJavaコードとの一貫性が高く読みやすくなります。 |
| 21 | + |
| 22 | +> 📖 **このセクションで登場した用語** |
| 23 | +> |
| 24 | +> - **スプライシング(splicing)**:新しいノードを作らず、既存のノードの参照(`next`)をつなぎ替えることでデータ構造を組み替える操作。メモリのコピーが発生しないため効率的。 |
| 25 | +> - **`NullPointerException`**:`null` の参照に対してメソッド呼び出しやフィールドアクセスを行おうとした際に発生する実行時例外。今回は `list1.val` にアクセスする前に必ず `list1 != null` を確認することで防ぐ。 |
| 26 | +> - **防御的プログラミング**:呼び出し元が誤った値を渡してもプログラムが壊れないように、事前に入力を検証する設計方針。ただし今回のように「`null` = 正常な空リスト」の場合は過剰な検証がバグのもとになる。 |
| 27 | +
|
| 28 | +--- |
| 29 | + |
| 30 | +## 2. アルゴリズムアプローチ比較 |
| 31 | + |
| 32 | +> 💡 同じ問題でも解き方は複数あります。「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」、そしてJavaらしい実装コストを比べて最適なものを選びます。 |
| 33 | +
|
| 34 | +| アプローチ | 時間計算量 | 空間計算量 | Java実装コスト | 型安全性 | 可読性 | 備考 | |
| 35 | +| --------------------------------------------- | ----------------- | ---------- | -------------- | -------- | ------ | ------------------------------------------ | |
| 36 | +| 方法A:反復(ダミーヘッド + 二本のポインタ) | O(n+m) | O(1) | 低 | 高 | 高 | 新規ノード生成なし。推奨 | |
| 37 | +| 方法B:再帰(先頭同士を比較して再帰呼び出し) | O(n+m) | O(n+m) | 低 | 高 | 高 | コールスタックがリスト長に比例して積まれる | |
| 38 | +| 方法C:全ノードを配列/リストに集めてソート | O((n+m) log(n+m)) | O(n+m) | 中 | 中 | 中 | すでにソート済みという前提を無駄にする | |
| 39 | + |
| 40 | +> 💡 **Big-O記法の読み方**(初学者向け) |
| 41 | +> |
| 42 | +> - `O(1)`:入力の大きさに関わらず、常に一定の手間で済む(最速・最小) |
| 43 | +> - `O(n+m)`:2つの入力の合計サイズに比例して手間が増える(線形) |
| 44 | +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える |
| 45 | +
|
| 46 | +> 📖 **このセクションで登場した用語** |
| 47 | +> |
| 48 | +> - **再帰(recursion)**:関数が自分自身を呼び出す処理方式。「小さい問題に分解して、その答えを組み合わせる」考え方に向いている。 |
| 49 | +> - **コールスタック**:関数呼び出しの「呼び出し履歴」を積んでおくメモリ領域。再帰が深くなるとここに積まれる分だけメモリを消費し、深すぎると `StackOverflowError` が起きる。 |
| 50 | +
|
| 51 | +--- |
| 52 | + |
| 53 | +## 3. 選択したアルゴリズムと理由 |
| 54 | + |
| 55 | +> 💡 選んだ理由を、他の方法と対比しながら説明します。 |
| 56 | +
|
| 57 | +- **選択したアプローチ**:方法A(ダミーヘッドを使った反復処理) |
| 58 | +- **理由**: |
| 59 | + - **計算量的な優位性**:方法B(再帰)と同じ O(n+m) の時間計算量でありながら、空間計算量は O(1) で済む(方法Bはコールスタックに O(n+m) 消費する)。 |
| 60 | + - **Java環境での安全性**:制約上ノード数は最大50なので方法Bでも `StackOverflowError` の危険は事実上ないが、実務コードとして「入力サイズに依存してスタックが伸びる」設計は将来的な拡張(例:ノード数が数万に増える)に対して脆い。方法Aはその制約自体が存在しない。 |
| 61 | + - **保守性・可読性**:ループ1本で完結し、条件分岐もシンプル。デバッガでも1行ずつ追いやすい。 |
| 62 | + - **方法Cを選ばなかった理由**:わざわざ配列に集めてソートするのは、「すでにソート済み」という問題の前提を無視した回り道であり、時間計算量も余分な O(log(n+m)) がかかるうえ、空間計算量も O(n+m) と余計にメモリを使う。 |
| 63 | +- **Java特有の最適化ポイント**: |
| 64 | + - `ListNode` はプリミティブ型 `int` を直接フィールドに持つ設計(`Integer` へのボクシングが発生しない)ので、比較 `list1.val <= list2.val` は最初からボクシングコストなしで行える。 |
| 65 | + - ダミーノード(番兵ノード)パターンを使うことで、「リストが空の場合の特別扱い」という条件分岐(`if (head == null) { ... }` のような処理)を1つも書かずに済み、コンパイル時にもコードパスが単純になる。 |
| 66 | + |
| 67 | +> 📖 **このセクションで登場した用語** |
| 68 | +> |
| 69 | +> - **ダミーヘッド(番兵ノード)**:リストの先頭に、実際のデータを持たない仮のノードを1つ置いておくテクニック。「リストが空かどうか」で処理を分岐させる必要がなくなる。 |
| 70 | +> - **ボクシング**:`int` のようなプリミティブ型を `Integer` のようなラッパークラスのオブジェクトに変換すること。オブジェクト生成のオーバーヘッドが発生するため、大量のデータを扱う場面では避けたい。今回の `ListNode.val` は `int` のままなので発生しない。 |
| 71 | +
|
| 72 | +--- |
| 73 | + |
| 74 | +## 4. 実装コード |
| 75 | + |
| 76 | +> 💡 コード全体の骨格: |
| 77 | +> |
| 78 | +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」を用意する |
| 79 | +> 2. `list1` と `list2` の先頭同士を比較し、小さい方を結果リストの末尾につなげる。つないだ方のリストを1つ進める |
| 80 | +> 3. どちらかが尽きるまで2を繰り返す |
| 81 | +> 4. 片方が尽きたら、残った方(すでにソート済み)をそのまま丸ごとつなげる |
| 82 | +> 5. ダミーノードの次(`dummy.next`)が本当の答えの先頭なので、それを返す |
| 83 | +
|
| 84 | +```java |
| 85 | +/** |
| 86 | + * Definition for singly-linked list. |
| 87 | + * public class ListNode { |
| 88 | + * int val; |
| 89 | + * ListNode next; |
| 90 | + * ListNode() {} |
| 91 | + * ListNode(int val) { this.val = val; } |
| 92 | + * ListNode(int val, ListNode next) { this.val = val; this.next = next; } |
| 93 | + * } |
| 94 | + */ |
| 95 | +class Solution { |
| 96 | + |
| 97 | + /** |
| 98 | + * 2つのソート済み連結リストを1つのソート済み連結リストにマージする。 |
| 99 | + * |
| 100 | + * 新しいノードは一切作成せず、既存ノードの next 参照をつなぎ替える |
| 101 | + * (スプライシング)ことでマージするため、空間計算量は O(1) となる。 |
| 102 | + * |
| 103 | + * @param list1 1つ目のソート済み連結リストの先頭ノード。 |
| 104 | + * null の場合は「空のリスト」を表す(異常値ではない) |
| 105 | + * @param list2 2つ目のソート済み連結リストの先頭ノード。 |
| 106 | + * null の場合は「空のリスト」を表す(異常値ではない) |
| 107 | + * @return マージ後のソート済み連結リストの先頭ノード |
| 108 | + * (両方が空リストの場合は null を返す) |
| 109 | + */ |
| 110 | + public ListNode mergeTwoLists(ListNode list1, ListNode list2) { |
| 111 | + // ダミーノード(番兵ノード)を用意する。 |
| 112 | + // このノード自体には意味のある値は入れない(値は0で仮置き)。 |
| 113 | + // こうすることで「結果リストがまだ空のとき、先頭をどう扱うか」 |
| 114 | + // という特別な分岐を書かずに済む。 |
| 115 | + ListNode dummy = new ListNode(0); |
| 116 | + |
| 117 | + // tail は「これまでにつなぎ終えた部分の末尾ノード」を指す変数。 |
| 118 | + // 最初はダミーノード自身を指しておく。 |
| 119 | + ListNode tail = dummy; |
| 120 | + |
| 121 | + // list1、list2 の両方にまだノードが残っている間、 |
| 122 | + // 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 |
| 123 | + while (list1 != null && list2 != null) { |
| 124 | + if (list1.val <= list2.val) { |
| 125 | + // list1の先頭値のほうが小さい(同値含む)ので、 |
| 126 | + // list1の現在ノードを結果リストの末尾につなげる |
| 127 | + tail.next = list1; |
| 128 | + // list1を1つ次に進める(つないだノードは処理済みなので進める) |
| 129 | + list1 = list1.next; |
| 130 | + } else { |
| 131 | + // list2の先頭値のほうが小さいので、list2側をつなげる |
| 132 | + tail.next = list2; |
| 133 | + list2 = list2.next; |
| 134 | + } |
| 135 | + // tailを、たった今つないだノードの位置まで進める |
| 136 | + tail = tail.next; |
| 137 | + } |
| 138 | + |
| 139 | + // ループを抜けた時点で、list1 か list2 のどちらか(または両方)が |
| 140 | + // null(尽きた状態)になっている。 |
| 141 | + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 |
| 142 | + // そのまま丸ごと tail の後ろにつなげてよい。 |
| 143 | + // 三項演算子:list1が残っていればlist1を、そうでなければlist2をつなげる |
| 144 | + tail.next = (list1 != null) ? list1 : list2; |
| 145 | + |
| 146 | + // dummyノード自体はデータを持たない仮のノードなので、 |
| 147 | + // 実際の答えの先頭は dummy.next である |
| 148 | + return dummy.next; |
| 149 | + } |
| 150 | +} |
| 151 | +``` |
| 152 | + |
| 153 | +> 💡 **コードの動作トレース**(初学者向け) |
| 154 | +> |
| 155 | +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` |
| 156 | +> |
| 157 | +> ``` |
| 158 | +> 初期状態: dummy -> (何も繋がっていない) |
| 159 | +> tail = dummy |
| 160 | +> list1 = [1,2,4], list2 = [1,3,4] |
| 161 | +> |
| 162 | +> Step 1: list1.val(1) <= list2.val(1) は true → list1の"1"をつなぐ |
| 163 | +> 結果: dummy -> 1(from list1) |
| 164 | +> list1は次の "2" に進む、tailは今つないだ "1" に進む |
| 165 | +> |
| 166 | +> Step 2: list1.val(2) vs list2.val(1) → list2の"1"のほうが小さい → list2をつなぐ |
| 167 | +> 結果: dummy -> 1 -> 1(from list2) |
| 168 | +> list2は次の "3" に進む、tailは今つないだ "1" に進む |
| 169 | +> |
| 170 | +> Step 3: list1.val(2) <= list2.val(3) → list1の"2"をつなぐ |
| 171 | +> 結果: dummy -> 1 -> 1 -> 2 |
| 172 | +> list1は次の "4" に進む |
| 173 | +> |
| 174 | +> Step 4: list1.val(4) vs list2.val(3) → list2の"3"のほうが小さい → list2をつなぐ |
| 175 | +> 結果: dummy -> 1 -> 1 -> 2 -> 3 |
| 176 | +> list2は次の "4" に進む |
| 177 | +> |
| 178 | +> Step 5: list1.val(4) <= list2.val(4) → list1の"4"をつなぐ(同値はlist1優先) |
| 179 | +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 |
| 180 | +> list1はnullになる(尽きた) |
| 181 | +> |
| 182 | +> Step 6: while条件 (list1 != null && list2 != null) が false になりループ終了 |
| 183 | +> list1 == null なので、残っている list2(= [4])を丸ごとつなぐ |
| 184 | +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 |
| 185 | +> |
| 186 | +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) |
| 187 | +> ``` |
| 188 | +
|
| 189 | +> 📖 **このセクションで登場した用語** |
| 190 | +> |
| 191 | +> - **三項演算子(`条件 ? A : B`)**:`if-else` を1行で書くための構文。「条件がtrueならA、falseならB」という値を返す。今回は「list1が残っていればlist1を、そうでなければlist2を」という選択に使っている。 |
| 192 | +> - **`this.next`(連結リストの `next` 参照)**:あるノードから次のノードへの「つながり」を表すフィールド。これを付け替えることでリストの構造そのものを変更できる。 |
| 193 | +
|
| 194 | +--- |
| 195 | +
|
| 196 | +## 5. 制約条件の確認 |
| 197 | +
|
| 198 | +- 外部ライブラリは使用していません(`ListNode` 以外は言語組み込みの機能のみ)。 |
| 199 | +- 追加のノードを1つも生成していないため、メモリ使用量は入力サイズによらず一定(O(1))です。 |
| 200 | +- `-Xlint:all` で警告が出るような未チェックキャストや生型(raw type)の使用はありません。 |
| 201 | +- クラス名 `Solution`・`ListNode` はパスカルケース、変数名 `list1`/`list2`/`dummy`/`tail` はキャメルケース(またはそれに準じる慣習的な命名)です。 |
| 202 | +
|
| 203 | +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数)。 |
0 commit comments