|
5 | 5 |
|
6 | 6 | --- |
7 | 7 |
|
8 | | -# LeetCode 102 · Binary Tree Level Order Traversal |
| 8 | +# LeetCode 102 · Binary Tree Level Order Traversal — TypeScript版 |
9 | 9 |
|
10 | 10 | --- |
11 | 11 |
|
12 | | -## 1. 問題の分析 |
| 12 | +## 目次(Table of Contents) |
13 | 13 |
|
14 | | -> 💡 **この問題は一言で言うと「木を上から下へ、同じ高さのノードをまとめてグループ化する問題」です。** |
15 | | -> 木の「同じ深さ(階層)」にあるすべての値をひとつの配列にまとめ、その配列を深さ順に並べた2次元配列を返します。 |
| 14 | +- [1. Overview](#overview) |
| 15 | +- [2. Algorithm](#algorithm) |
| 16 | +- [3. Complexity](#complexity) |
| 17 | +- [4. Implementation](#implementation) |
| 18 | +- [5. Optimization](#optimization) |
16 | 19 |
|
17 | 20 | --- |
18 | 21 |
|
19 | | -### 🌳 問題が要求していること(視覚的確認) |
| 22 | +<h2 id="overview">1. Overview</h2> |
| 23 | + |
| 24 | +> 💡 **この問題は一言で言うと「木を上から下へ、同じ高さのノードをまとめてグループ化する問題」です。** |
| 25 | +> 木の「同じ深さ(階層)」にあるすべての値をひとつの配列にまとめ、その配列を深さ順に並べた2次元配列を返します。 |
20 | 26 |
|
21 | 27 | ``` |
22 | 28 | 3 ← 深さ0: [3] |
|
28 | 34 | 出力: [[3], [9, 20], [15, 7]] |
29 | 35 | ``` |
30 | 36 |
|
31 | | ---- |
32 | | - |
33 | | -### 競技プログラミング視点での分析 |
34 | | - |
35 | | -- ノード数は最大 2000 なので、O(n) のアルゴリズムで十分に余裕がある |
36 | | -- 各ノードを **ちょうど1回だけ** 訪問する手法が理想 |
37 | | -- 追加メモリは出力配列のみに抑えたい |
38 | | - |
39 | | -### 業務開発視点での分析 |
| 37 | +### 競技プログラミング・業務開発視点 |
40 | 38 |
|
41 | | -- `TreeNode | null` という **Union型(=複数の型のうちどれかを表す型)** を安全に扱う必要がある |
42 | | -- `null` チェックを怠ると実行時エラーになるため、型ガード(=実行時に型を絞り込む条件分岐)が必須 |
43 | | -- 結果配列はイミュータブル(=変更不可)に構築して副作用を防ぐ |
44 | | - |
45 | | -### TypeScript特有の考慮点 |
46 | | - |
47 | | -- `TreeNode | null` の null 安全性を TypeScript のコンパイラに保証させる |
48 | | -- キュー(後述)の要素型を明示することで、取り出した要素が必ず `TreeNode` 型であると保証できる |
| 39 | +- **ノード数**: 最大 2000 なので O(n) が必須。 |
| 40 | +- **型安全性**: `TreeNode | null` という **Union型** を安全に扱う必要がある。 |
| 41 | +- **JSの特性**: `Array.shift()` は $O(n)$ のため、キューとして使う場合はポインタ管理($O(1)$)を行う必要がある。 |
49 | 42 |
|
50 | 43 | > 📖 **このセクションで登場した用語** |
51 | 44 | > |
|
55 | 48 |
|
56 | 49 | --- |
57 | 50 |
|
58 | | -## 2. アルゴリズムアプローチ比較 |
| 51 | +<h2 id="algorithm">2. Algorithm</h2> |
59 | 52 |
|
60 | | -> 💡 同じ問題でも解き方は複数あります。「速さ(時間計算量)」と「メモリの使い方(空間計算量)」を比べて最適なものを選びます。 |
| 53 | +### アプローチ比較 |
61 | 54 |
|
62 | | -| アプローチ | 時間計算量 | 空間計算量 | TS実装コスト | 型安全性 | 可読性 | 備考 | |
63 | | -| -------------------------- | ---------- | ---------- | ------------ | -------- | ------ | -------------------------------- | |
64 | | -| **BFS(幅優先探索)** | O(n) | O(n) | 低 | 高 | 高 | ✅ 今回の選択 | |
65 | | -| DFS(深さ優先探索) | O(n) | O(n) | 中 | 高 | 中 | 再帰でも実装可能だが直感的でない | |
66 | | -| 総当たり(各深さでループ) | O(n²) | O(n) | 高 | 中 | 低 | 非推奨 | |
| 55 | +| アプローチ | 時間計算量 | 空間計算量 | 備考 | |
| 56 | +| -------------------------- | ---------- | ---------- | -------------------------------- | |
| 57 | +| **BFS(幅優先探索)** | O(n) | O(n) | ✅ 今回の選択 | |
| 58 | +| DFS(深さ優先探索) | O(n) | O(n) | 再帰でも実装可能だが直感的でない | |
| 59 | +| 総当たり(各深さでループ) | O(n²) | O(n) | 非推奨 | |
67 | 60 |
|
68 | | -> 💡 **BFS(幅優先探索)を例え話で理解する** |
69 | | -> BFS は「同じ階のすべての部屋を開けてから、次の階に進むエレベーター」のようなものです。 |
70 | | -> 木でいえば「同じ深さのノードをすべて処理してから、次の深さへ進む」動き方です。 |
71 | | -> これを実現するのが **キュー(=先に入れたものを先に出す「行列」のデータ構造)** です。 |
72 | | -> 📖 **このセクションで登場した用語** |
73 | | -> |
74 | | -> - **BFS(幅優先探索)**:グラフや木を「横方向に広がりながら」探索する方法 |
75 | | -> - **DFS(深さ優先探索)**:グラフや木を「縦方向に深く潜りながら」探索する方法 |
76 | | -> - **キュー**:先に入れたものを先に出す(FIFO: First In, First Out)データ構造。銀行の窓口の行列と同じ |
| 61 | +### BFS(幅優先探索)の仕組み |
77 | 62 |
|
78 | | ---- |
| 63 | +BFS は「同じ階のすべての部屋を開けてから、次の階に進むエレベーター」のようなものです。 |
| 64 | +これを実現するのが **キュー(FIFO: First In, First Out)** です。 |
79 | 65 |
|
80 | | -## 3. 選択したアルゴリズムと理由 |
| 66 | +- **核心テクニック**: キューから「今の階のノード数分だけ」取り出すことで、自然に「1階ぶんのグループ」が作れる。 |
| 67 | +- **TypeScript特有の工夫**: キューの型を `TreeNode[]` と明示することで、取り出した要素が必ず `TreeNode` 型になりコンパイル時に安全を保証。 |
81 | 68 |
|
82 | | -- **選択したアプローチ**: **BFS(幅優先探索)+ キュー** |
83 | | -- **理由**: |
84 | | - - **DFS を選ばなかった理由**: DFS は縦に深く潜るため、「同じ階のノードをまとめる」処理と相性が悪く、深さ情報を別途管理する手間が増える |
85 | | - - **総当たりを選ばなかった理由**: ノードを重複して走査するため O(n²) になり、ノード数 2000 でも無駄が大きい |
86 | | - - **BFS を選んだ理由**: キューから「今の階のノード数分だけ」取り出すことで、自然に「1階ぶんのグループ」が作れる |
| 69 | +--- |
87 | 70 |
|
88 | | -- **TypeScript特有の最適化ポイント**: |
89 | | - - キューの型を `TreeNode[]` と明示することで、取り出した要素が必ず `TreeNode` 型になりコンパイル時に安全を保証 |
90 | | - - `node.left` / `node.right` は `TreeNode | null` なので、`!== null` チェックで null を排除してからキューに追加する |
| 71 | +<h2 id="complexity">3. Complexity</h2> |
91 | 72 |
|
92 | | -> 📖 **このセクションで登場した用語** |
93 | | -> |
94 | | -> - **コンパイル時保証**:TypeScript がコードを変換する段階でエラーを検出すること。実行前にバグを防げる |
95 | | -> - **FIFO**:First In, First Out。最初に入れたものを最初に取り出す順序 |
| 73 | +| 項目 | 値 | 理由 | |
| 74 | +| -------------- | ---- | -------------------------------------------------------------------------------------------- | |
| 75 | +| **時間計算量** | O(n) | 各ノードをキューへの追加・取り出しでちょうど1回ずつ処理するため(ポインタ管理で各操作 O(1)) | |
| 76 | +| **空間計算量** | O(n) | キューに最大で「木の最も広い階のノード数」が入る。最悪ケースは全ノード数 n に比例 | |
96 | 77 |
|
97 | 78 | --- |
98 | 79 |
|
99 | | -## 4. 実装コード |
| 80 | +<h2 id="implementation">4. Implementation</h2> |
100 | 81 |
|
101 | | -> 💡 **コードの大まかな骨格**(コードを読む前にこの構造を頭に入れてください) |
102 | | -> |
103 | | -> 1. `root` が `null` なら即座に空配列 `[]` を返す |
104 | | -> 2. `root` をキューに入れて探索開始 |
105 | | -> 3. キューが空になるまで繰り返す |
106 | | -> - 今の階のノード数(`levelSize`)を記録する |
107 | | -> - ちょうど `levelSize` 個のノードを取り出し、その値を今の階の配列に追加 |
108 | | -> - 取り出したノードの左・右の子があればキューに追加(次の階の準備) |
109 | | -> 4. 各階の配列を `result` に追加して返す |
| 82 | +### 業務開発版(型安全・パフォーマンス最適化) |
110 | 83 |
|
111 | 84 | ```typescript |
112 | | -// Runtime 2 ms |
113 | | -// Beats 39.72% |
114 | | -// Memory 60.20 MB |
115 | | -// Beats 57.23% |
116 | | - |
117 | 85 | function levelOrder(root: TreeNode | null): number[][] { |
118 | | - // ───────────────────────────────────────────────────────── |
119 | | - // ① root が null(木が空)の場合は空配列を返す |
120 | | - // 後続でキューにアクセスするため、ここで弾かないと |
121 | | - // null に対して .left などへアクセスしてクラッシュする |
122 | | - // ───────────────────────────────────────────────────────── |
123 | 86 | if (root === null) return []; |
124 | 87 |
|
125 | | - // ───────────────────────────────────────────────────────── |
126 | | - // ② 結果を格納する2次元配列を用意する |
127 | | - // result[0] = 深さ0のノード値の配列、 |
128 | | - // result[1] = 深さ1のノード値の配列、... となる |
129 | | - // ───────────────────────────────────────────────────────── |
130 | 88 | const result: number[][] = []; |
131 | | - |
132 | | - // ───────────────────────────────────────────────────────── |
133 | | - // ③ キュー(行列)を用意し、root を最初の要素として入れる |
134 | | - // 型を TreeNode[] と明示することで、取り出した要素が |
135 | | - // null でないことをコンパイラが保証してくれる |
136 | | - // (null をキューに入れないよう、後続の追加時にチェックする) |
137 | | - // ───────────────────────────────────────────────────────── |
138 | 89 | const queue: TreeNode[] = [root]; |
| 90 | + let head = 0; // shift() の O(n) を避けるための先頭ポインタ |
139 | 91 |
|
140 | | - // ───────────────────────────────────────────────────────── |
141 | | - // ④ キューが空になるまでループ(= 全ノードを処理し終えるまで) |
142 | | - // ───────────────────────────────────────────────────────── |
143 | | - while (queue.length > 0) { |
| 92 | + while (head < queue.length) { |
144 | 93 | // ── 今この瞬間のキューの長さ = 「現在の階のノード数」 ── |
145 | | - // この値を先に固定するのが BFS の核心。 |
146 | | - // ループ中に queue.length は変化するため、 |
147 | | - // 先に変数に保存しておかないと「今の階」の範囲がズレる。 |
148 | | - const levelSize: number = queue.length; |
149 | | - |
150 | | - // 今の階のノード値を格納する一時配列 |
| 94 | + // 未処理分(queue.length - head)を変数に保存して固定する。 |
| 95 | + const levelSize: number = queue.length - head; |
151 | 96 | const levelValues: number[] = []; |
152 | 97 |
|
153 | | - // 今の階のノードを「levelSize 個分」だけ取り出す |
154 | 98 | for (let i = 0; i < levelSize; i++) { |
155 | | - // shift() でキューの先頭からノードを取り出す(FIFO) |
156 | | - // queue の要素は TreeNode[] なので null にならない(③で保証) |
157 | | - const node = queue.shift()!; // ! は「nullでないことをここで断言」 |
158 | | - |
159 | | - // 取り出したノードの値を今の階の配列に追加 |
| 99 | + // head インデックスでポインタを進めることで O(1) で取り出す |
| 100 | + const node = queue[head++]!; |
160 | 101 | levelValues.push(node.val); |
161 | 102 |
|
162 | | - // ── 次の階の準備:子ノードをキューの末尾に追加する ── |
163 | | - // null でない場合だけ追加する(null をキューに入れないルール) |
164 | | - if (node.left !== null) { |
165 | | - queue.push(node.left); |
166 | | - } |
167 | | - if (node.right !== null) { |
168 | | - queue.push(node.right); |
169 | | - } |
| 103 | + // 次の階の準備 |
| 104 | + if (node.left !== null) queue.push(node.left); |
| 105 | + if (node.right !== null) queue.push(node.right); |
170 | 106 | } |
171 | 107 |
|
172 | | - // 今の階分の値配列を結果に追加 |
173 | 108 | result.push(levelValues); |
174 | 109 | } |
175 | 110 |
|
176 | 111 | return result; |
177 | 112 | } |
178 | 113 | ``` |
179 | 114 |
|
180 | | ---- |
181 | | - |
182 | | -### 🔍 動作トレース(具体的な入力例での変数変化) |
183 | | - |
184 | | -**入力**: `root = [3, 9, 20, null, null, 15, 7]`(ツリー構造は下記) |
185 | | - |
186 | | -``` |
187 | | - 3 |
188 | | - / \ |
189 | | - 9 20 |
190 | | - / \ |
191 | | - 15 7 |
192 | | -``` |
| 115 | +### 🔍 動作トレース(`root = [3, 9, 20, null, null, 15, 7]`) |
193 | 116 |
|
194 | 117 | ``` |
195 | | -初期状態: |
196 | | - queue = [Node(3)] |
197 | | - result = [] |
198 | | -
|
199 | 118 | ━━━━━━━━━━ while ループ 1回目(深さ0) ━━━━━━━━━━ |
200 | | - levelSize = 1 ← 今の階のノード数は1 |
201 | | - levelValues = [] |
202 | | -
|
203 | | - i=0: node = queue.shift() → Node(3) queue = [] |
204 | | - levelValues.push(3) → [3] |
205 | | - node.left = Node(9) → queue.push(Node(9)) queue = [Node(9)] |
206 | | - node.right = Node(20) → queue.push(Node(20)) queue = [Node(9), Node(20)] |
207 | | -
|
208 | | - result.push([3]) → result = [[3]] |
| 119 | + levelSize = 1 ← queue.length(1) - head(0) = 1 |
| 120 | + i=0: node = queue[head++] → Node(3) queue = [3], head = 1 |
| 121 | + node.left = 9, node.right = 20 を queue に追加 |
| 122 | + result.push([3]) |
209 | 123 |
|
210 | 124 | ━━━━━━━━━━ while ループ 2回目(深さ1) ━━━━━━━━━━ |
211 | | - levelSize = 2 ← 今の階のノード数は2 |
212 | | - levelValues = [] |
213 | | -
|
214 | | - i=0: node = queue.shift() → Node(9) queue = [Node(20)] |
215 | | - levelValues.push(9) → [9] |
216 | | - node.left = null → スキップ |
217 | | - node.right = null → スキップ |
218 | | -
|
219 | | - i=1: node = queue.shift() → Node(20) queue = [] |
220 | | - levelValues.push(20) → [9, 20] |
221 | | - node.left = Node(15) → queue.push(Node(15)) queue = [Node(15)] |
222 | | - node.right = Node(7) → queue.push(Node(7)) queue = [Node(15), Node(7)] |
223 | | -
|
224 | | - result.push([9,20]) → result = [[3], [9, 20]] |
225 | | -
|
226 | | -━━━━━━━━━━ while ループ 3回目(深さ2) ━━━━━━━━━━ |
227 | | - levelSize = 2 ← 今の階のノード数は2 |
228 | | - levelValues = [] |
229 | | -
|
230 | | - i=0: node = queue.shift() → Node(15) queue = [Node(7)] |
231 | | - levelValues.push(15) → [15] |
232 | | - node.left = null → スキップ |
233 | | - node.right = null → スキップ |
234 | | -
|
235 | | - i=1: node = queue.shift() → Node(7) queue = [] |
236 | | - levelValues.push(7) → [15, 7] |
237 | | - node.left = null → スキップ |
238 | | - node.right = null → スキップ |
239 | | -
|
240 | | - result.push([15,7]) → result = [[3], [9, 20], [15, 7]] |
241 | | -
|
242 | | -━━━━━━━━━━ queue が空 → ループ終了 ━━━━━━━━━━ |
243 | | -戻り値: [[3], [9, 20], [15, 7]] ✅ |
| 125 | + levelSize = 2 ← queue.length(3) - head(1) = 2 |
| 126 | + i=0, 1: Node(9), Node(20) を処理 |
| 127 | + result.push([9, 20]) |
244 | 128 | ``` |
245 | 129 |
|
246 | 130 | --- |
247 | 131 |
|
248 | | -### ✅ LeetCode 提出フォーマット(そのままコピー可) |
249 | | - |
250 | | -```typescript |
251 | | -function levelOrder(root: TreeNode | null): number[][] { |
252 | | - if (root === null) return []; |
253 | | - |
254 | | - const result: number[][] = []; |
255 | | - const queue: TreeNode[] = [root]; |
256 | | - |
257 | | - while (queue.length > 0) { |
258 | | - const levelSize: number = queue.length; |
259 | | - const levelValues: number[] = []; |
260 | | - |
261 | | - for (let i = 0; i < levelSize; i++) { |
262 | | - const node = queue.shift()!; |
263 | | - levelValues.push(node.val); |
| 132 | +<h2 id="optimization">5. Optimization</h2> |
264 | 133 |
|
265 | | - if (node.left !== null) queue.push(node.left); |
266 | | - if (node.right !== null) queue.push(node.right); |
267 | | - } |
| 134 | +### パフォーマンス最適化:`Array.shift()` の回避 |
268 | 135 |
|
269 | | - result.push(levelValues); |
270 | | - } |
| 136 | +JavaScript の `Array.shift()` は、先頭要素を削除した後に残り全要素のインデックスを 1 つずつ前にずらすため、**$O(n)$ の計算量**がかかります。 |
| 137 | +キュー操作を伴う BFS で `shift()` をループ内で使用すると、全体の計算量が **$O(n^2)$** に悪化してしまいます。 |
271 | 138 |
|
272 | | - return result; |
273 | | -} |
274 | | -``` |
| 139 | +**解決策:** |
| 140 | +`head` 変数を用いて現在の先頭位置を指し示し、要素を取り出すたびに `head++` する方式を採用します。これにより、実質的な削除操作を伴わずに $O(1)$ で先頭要素を取得できます。 |
275 | 141 |
|
276 | 142 | > 📖 **このセクションで登場した用語** |
277 | 143 | > |
278 | | -> - **`shift()`**:配列の先頭要素を取り出して返すメソッド。キューの「取り出し」操作に使う |
279 | | -> - **`push()`**:配列の末尾に要素を追加するメソッド。キューの「追加」操作に使う |
280 | | -> - **`!`(非null断言)**:TypeScriptに「この値は絶対 null でない」と伝える記号。型チェックを強制的に通す代わりに、間違えると実行時エラーになるため、確信がある箇所にのみ使う |
281 | | -> - **FIFO**:First In First Out。先に入れたものを先に取り出す順序。キューはこの性質を持つ |
282 | | -> - **`readonly`(今回不使用だが参考)**:変数を変更不可にする TypeScript の修飾子。JavaScript には存在せず、コンパイル時の書き換えを防ぐために使う |
283 | | -
|
284 | | ---- |
285 | | - |
286 | | -## 5. 計算量まとめ |
287 | | - |
288 | | -| 項目 | 値 | 理由 | |
289 | | -| -------------- | ---- | --------------------------------------------------------------------------------- | |
290 | | -| **時間計算量** | O(n) | 各ノードをキューへの追加・取り出しでちょうど1回ずつ処理するため | |
291 | | -| **空間計算量** | O(n) | キューに最大で「木の最も広い階のノード数」が入る。最悪ケースは全ノード数 n に比例 | |
| 144 | +> - **`head` インデックス**:配列の先頭を指すポインタ。 |
| 145 | +> - **FIFO**:First In First Out。先に入れたものを先に取り出す順序。 |
| 146 | +> - **`!`(非null断言)**:TypeScriptに「この値は絶対 null でない」と伝える記号。 |
0 commit comments