|
| 1 | +# 97. Interleaving String - 1D DP による空間最適化解法 |
| 2 | + |
| 3 | +<h2 id="toc">目次(Table of Contents)</h2> |
| 4 | + |
| 5 | +- [概要](#overview) |
| 6 | +- [アルゴリズム要点(TL;DR)](#tldr) |
| 7 | +- [図解](#figures) |
| 8 | +- [正しさのスケッチ](#correctness) |
| 9 | +- [計算量](#complexity) |
| 10 | +- [Python 実装](#impl) |
| 11 | +- [CPython最適化ポイント](#cpython) |
| 12 | +- [エッジケースと検証観点](#edgecases) |
| 13 | +- [FAQ](#faq) |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +<h2 id="overview">概要</h2> |
| 18 | + |
| 19 | +**問題要約** |
| 20 | + |
| 21 | +- 2つの文字列 `s1`, `s2` が与えられたとき、これらを interleave(交互に文字を取り出して結合)して `s3` を構成できるか判定する |
| 22 | +- interleaving の定義: `s1` と `s2` をそれぞれ部分文字列に分割し、順序を保ちながら交互に結合した結果が `s3` になること |
| 23 | + |
| 24 | +**制約** |
| 25 | + |
| 26 | +- `0 <= len(s1), len(s2) <= 100` |
| 27 | +- `0 <= len(s3) <= 200` |
| 28 | +- 全て小文字英字のみ |
| 29 | + |
| 30 | +**要件** |
| 31 | + |
| 32 | +- 正当性: `s1` と `s2` の文字順序を保ちつつ `s3` を構成できるか正確に判定 |
| 33 | +- Follow-up: 空間計算量を `O(len(s2))` に抑える |
| 34 | + |
| 35 | +--- |
| 36 | + |
| 37 | +<h2 id="tldr">アルゴリズム要点(TL;DR)</h2> |
| 38 | + |
| 39 | +- **戦略**: 1次元 DP(列方向圧縮)による動的計画法 |
| 40 | +- **データ構造**: `list[bool]` の 1次元配列 `dp` |
| 41 | + - `dp[j]`: `s1` の先頭 `i` 文字と `s2` の先頭 `j` 文字で `s3` の先頭 `i+j` 文字を構成できるか |
| 42 | +- **時間計算量**: `O(len(s1) * len(s2))` |
| 43 | +- **空間計算量**: `O(min(len(s1), len(s2)))` - Follow-up 要件を満たす |
| 44 | +- **最適化**: 短い方の文字列を DP の列方向に配置してメモリ効率を最大化 |
| 45 | + |
| 46 | +--- |
| 47 | + |
| 48 | +<h2 id="figures">図解</h2> |
| 49 | + |
| 50 | +### フローチャート |
| 51 | + |
| 52 | +```mermaid |
| 53 | +flowchart TD |
| 54 | + Start[Start solve] --> LenCheck{len s1 + len s2 == len s3} |
| 55 | + LenCheck -- No --> RetFalse[Return False] |
| 56 | + LenCheck -- Yes --> Swap{len s2 > len s1} |
| 57 | + Swap -- Yes --> SwapStr[Swap s1 and s2] |
| 58 | + Swap -- No --> InitDP[Initialize dp array] |
| 59 | + SwapStr --> InitDP |
| 60 | + InitDP --> InitRow[Initialize i=0 row] |
| 61 | + InitRow --> LoopI[For i=1 to len s1] |
| 62 | + LoopI --> UpdateJ0[Update dp 0 for col j=0] |
| 63 | + UpdateJ0 --> LoopJ[For j=1 to len s2] |
| 64 | + LoopJ --> CheckS1{s1 i-1 == s3 k and dp j} |
| 65 | + CheckS1 -- Yes --> SetTrue1[from_s1 = True] |
| 66 | + CheckS1 -- No --> SetFalse1[from_s1 = False] |
| 67 | + SetTrue1 --> CheckS2 |
| 68 | + SetFalse1 --> CheckS2{s2 j-1 == s3 k and dp j-1} |
| 69 | + CheckS2 -- Yes --> SetTrue2[from_s2 = True] |
| 70 | + CheckS2 -- No --> SetFalse2[from_s2 = False] |
| 71 | + SetTrue2 --> UpdateDP[dp j = from_s1 or from_s2] |
| 72 | + SetFalse2 --> UpdateDP |
| 73 | + UpdateDP --> NextJ{j < len s2} |
| 74 | + NextJ -- Yes --> LoopJ |
| 75 | + NextJ -- No --> NextI{i < len s1} |
| 76 | + NextI -- Yes --> LoopI |
| 77 | + NextI -- No --> RetResult[Return dp len s2] |
| 78 | +``` |
| 79 | + |
| 80 | +**説明**: |
| 81 | + |
| 82 | +- 最初に長さチェックで不可能なケースを除外 |
| 83 | +- `s2` が `s1` より長い場合は swap して、常に短い方を DP の列方向に配置 |
| 84 | +- 各セル `dp[j]` は「`s1` の文字を使う」または「`s2` の文字を使う」のいずれかで遷移可能かを判定 |
| 85 | +- 最終的に `dp[len(s2)]` が答え |
| 86 | + |
| 87 | +### データフロー図 |
| 88 | + |
| 89 | +```mermaid |
| 90 | +graph LR |
| 91 | + subgraph Input_Phase |
| 92 | + A[Input s1 s2 s3] --> B[Length validation] |
| 93 | + B --> C[Swap if needed] |
| 94 | + end |
| 95 | + subgraph DP_Phase |
| 96 | + C --> D[Initialize dp array] |
| 97 | + D --> E[Fill i=0 row] |
| 98 | + E --> F[Iterate i from 1 to n1] |
| 99 | + F --> G[Update dp j for each j] |
| 100 | + G --> H[Check s1 or s2 match] |
| 101 | + end |
| 102 | + subgraph Output_Phase |
| 103 | + H --> I[Return dp n2] |
| 104 | + end |
| 105 | +``` |
| 106 | + |
| 107 | +**説明**: |
| 108 | + |
| 109 | +- Input Phase: 入力検証と事前処理(swap含む) |
| 110 | +- DP Phase: DP テーブルの初期化と更新(1次元配列で in-place 更新) |
| 111 | +- Output Phase: 最終結果の返却 |
| 112 | + |
| 113 | +--- |
| 114 | + |
| 115 | +<h2 id="correctness">正しさのスケッチ</h2> |
| 116 | + |
| 117 | +**不変条件** |
| 118 | + |
| 119 | +- `dp[j]` は常に「現在の行 `i` における、`s1[:i]` と `s2[:j]` で `s3[:i+j]` を構成できるか」を保持 |
| 120 | +- 各更新で前の行の情報(`dp[j]`: 上から、`dp[j-1]`: 左から)を参照して遷移 |
| 121 | + |
| 122 | +**網羅性** |
| 123 | + |
| 124 | +- `i` を `0` から `len(s1)` まで走査し、各 `i` で `j` を `0` から `len(s2)` まで走査 |
| 125 | +- 全ての組み合わせ `(i, j)` について DP 遷移を実行 |
| 126 | + |
| 127 | +**基底条件** |
| 128 | + |
| 129 | +- `dp[0] = True`: 空文字列同士は interleave 可能 |
| 130 | +- `i = 0` 行: `s1` を使わず `s2` のみで `s3` の先頭部分を構成できるか |
| 131 | +- `j = 0` 列: `s2` を使わず `s1` のみで `s3` の先頭部分を構成できるか |
| 132 | + |
| 133 | +**終了性** |
| 134 | + |
| 135 | +- ループは有限回(`O(len(s1) * len(s2))`)で必ず終了 |
| 136 | +- 再帰なしの単純なループ構造 |
| 137 | + |
| 138 | +--- |
| 139 | + |
| 140 | +<h2 id="complexity">計算量</h2> |
| 141 | + |
| 142 | +**時間計算量**: `O(len(s1) * len(s2))` |
| 143 | + |
| 144 | +- 外側ループ: `len(s1)` 回 |
| 145 | +- 内側ループ: `len(s2)` 回 |
| 146 | +- 各ステップは定数時間の比較と代入のみ |
| 147 | + |
| 148 | +**空間計算量**: `O(min(len(s1), len(s2)))` |
| 149 | + |
| 150 | +- DP 配列のサイズ: `min(len(s1), len(s2)) + 1` |
| 151 | +- Follow-up の要件 `O(len(s2))` を満たす(短い方を列方向に配置するため実質 `O(min)`) |
| 152 | +- 入力文字列以外の追加メモリはほぼ DP 配列のみ |
| 153 | + |
| 154 | +**比較表: 2D vs 1D DP** |
| 155 | + |
| 156 | +| 手法 | 時間計算量 | 空間計算量 | 実装難度 | CPython最適化 | |
| 157 | +| --------------- | ---------- | ------------- | -------- | ------------- | |
| 158 | +| 2D DP | O(n1\*n2) | O(n1\*n2) | 低 | 中 | |
| 159 | +| 1D DP(本実装) | O(n1\*n2) | O(min(n1,n2)) | 中 | 高 | |
| 160 | + |
| 161 | +--- |
| 162 | + |
| 163 | +<h2 id="impl">Python 実装</h2> |
| 164 | + |
| 165 | +```python |
| 166 | +from __future__ import annotations |
| 167 | +from typing import List |
| 168 | + |
| 169 | + |
| 170 | +class Solution: |
| 171 | + """ |
| 172 | + Interleaving String 判定クラス(LeetCode 用) |
| 173 | +
|
| 174 | + Time Complexity: |
| 175 | + O(len(s1) * len(s2)) |
| 176 | +
|
| 177 | + Space Complexity: |
| 178 | + O(min(len(s1), len(s2))) # 1次元DP |
| 179 | + """ |
| 180 | + |
| 181 | + def isInterleave(self, s1: str, s2: str, s3: str) -> bool: |
| 182 | + """ |
| 183 | + s3 が s1 と s2 の interleaving で構成できるかどうかを判定する。 |
| 184 | +
|
| 185 | + Args: |
| 186 | + s1: 1つ目の文字列 |
| 187 | + s2: 2つ目の文字列 |
| 188 | + s3: 判定対象の文字列 |
| 189 | +
|
| 190 | + Returns: |
| 191 | + s3 が s1 と s2 の interleaving なら True、それ以外は False |
| 192 | + """ |
| 193 | + n1: int = len(s1) |
| 194 | + n2: int = len(s2) |
| 195 | + n3: int = len(s3) |
| 196 | + |
| 197 | + # 長さが合わなければ不可能 |
| 198 | + if n1 + n2 != n3: |
| 199 | + return False |
| 200 | + |
| 201 | + # dp の列方向(長さ)を常に「短い方の文字列」にする |
| 202 | + # → dp のサイズ縮小 + 内側ループ回数も減少 |
| 203 | + if n2 > n1: |
| 204 | + # s1 を「長い方」、s2 を「短い方」に揃える |
| 205 | + s1, s2 = s2, s1 |
| 206 | + n1, n2 = n2, n1 |
| 207 | + |
| 208 | + # dp[j]: s1 の先頭 i 文字と s2 の先頭 j 文字で s3 の先頭 i+j 文字を作れるか |
| 209 | + dp: List[bool] = [False] * (n2 + 1) |
| 210 | + |
| 211 | + # i = 0 行(s1 を 0 文字使用)の初期化 |
| 212 | + dp[0] = True |
| 213 | + for j in range(1, n2 + 1): |
| 214 | + dp[j] = dp[j - 1] and (s2[j - 1] == s3[j - 1]) |
| 215 | + |
| 216 | + # i >= 1 行の更新 |
| 217 | + for i in range(1, n1 + 1): |
| 218 | + # j = 0 列(s2 を 0 文字使用)の更新 |
| 219 | + dp[0] = dp[0] and (s1[i - 1] == s3[i - 1]) |
| 220 | + |
| 221 | + for j in range(1, n2 + 1): |
| 222 | + k: int = i + j - 1 # s3 のインデックス |
| 223 | + |
| 224 | + # 上から来る: s1 の文字を使う |
| 225 | + from_s1: bool = dp[j] and (s1[i - 1] == s3[k]) |
| 226 | + # 左から来る: s2 の文字を使う |
| 227 | + from_s2: bool = dp[j - 1] and (s2[j - 1] == s3[k]) |
| 228 | + |
| 229 | + dp[j] = from_s1 or from_s2 |
| 230 | + |
| 231 | + return dp[n2] |
| 232 | +``` |
| 233 | + |
| 234 | +**主要ステップのコメント** |
| 235 | + |
| 236 | +1. **長さチェック**: `n1 + n2 != n3` なら即座に `False` を返却(必須の枝刈り) |
| 237 | +2. **Swap 最適化**: 短い方の文字列を列方向に配置してメモリとループ回数を削減 |
| 238 | +3. **初期化**: `i = 0` 行を `s2` のみで `s3` を構成できるか判定 |
| 239 | +4. **DP 更新**: 各 `(i, j)` で「`s1` から遷移」と「`s2` から遷移」の OR を取る |
| 240 | +5. **結果**: `dp[n2]` が最終的な答え |
| 241 | + |
| 242 | +--- |
| 243 | + |
| 244 | +<h2 id="cpython">CPython最適化ポイント</h2> |
| 245 | + |
| 246 | +1. **ローカル変数キャッシュ** |
| 247 | + - `n1`, `n2`, `n3` を事前計算してループ外に配置 |
| 248 | + - `s1`, `s2`, `s3` も関数引数なので属性アクセスなし(すでに最適) |
| 249 | + |
| 250 | +2. **短い文字列を列方向に配置** |
| 251 | + - `if n2 > n1: s1, s2 = s2, s1` により、常に小さい方が内側ループ |
| 252 | + - メモリアクセスパターンが改善し、キャッシュヒット率向上 |
| 253 | + |
| 254 | +3. **in-place 更新** |
| 255 | + - `dp` 配列を破壊的に更新(新リスト作成なし) |
| 256 | + - Python の `list` は C 配列ベースで高速なインデックスアクセス |
| 257 | + |
| 258 | +4. **関数呼び出しの削減** |
| 259 | + - ループ内で `len()` などを呼ばない |
| 260 | + - 単純な算術演算とインデックスアクセスのみ |
| 261 | + |
| 262 | +5. **型ヒント** |
| 263 | + - pylance による静的解析で型エラーを事前検出 |
| 264 | + - 実行時のオーバーヘッドはないが、開発効率と保守性が向上 |
| 265 | + |
| 266 | +**追加の可能性(状況により)** |
| 267 | + |
| 268 | +- `numba` の `@jit`: 数値計算主体でない文字列処理では効果薄 |
| 269 | +- `lru_cache`: この問題は再帰でないため不要 |
| 270 | +- `bisect`: ソート済みデータの探索がないため不要 |
| 271 | + |
| 272 | +--- |
| 273 | + |
| 274 | +<h2 id="edgecases">エッジケースと検証観点</h2> |
| 275 | + |
| 276 | +1. **空文字列** |
| 277 | + - `s1 = ""`, `s2 = ""`, `s3 = ""` → `True` |
| 278 | + - `s1 = "a"`, `s2 = ""`, `s3 = "a"` → `True` |
| 279 | + - `s1 = ""`, `s2 = "b"`, `s3 = "b"` → `True` |
| 280 | + |
| 281 | +2. **長さ不一致** |
| 282 | + - `s1 = "ab"`, `s2 = "cd"`, `s3 = "abc"` → `False`(即座に判定) |
| 283 | + |
| 284 | +3. **同じ文字が複数存在** |
| 285 | + - `s1 = "aa"`, `s2 = "ab"`, `s3 = "aaba"` → 両方から `a` を取れるケースの正確な判定 |
| 286 | + |
| 287 | +4. **Example 1 (LeetCode)** |
| 288 | + - `s1 = "aabcc"`, `s2 = "dbbca"`, `s3 = "aadbbcbcac"` → `True` |
| 289 | + |
| 290 | +5. **Example 2 (LeetCode)** |
| 291 | + - `s1 = "aabcc"`, `s2 = "dbbca"`, `s3 = "aadbbbaccc"` → `False` |
| 292 | + |
| 293 | +6. **制約上限** |
| 294 | + - `len(s1) = 100`, `len(s2) = 100`, `len(s3) = 200` での性能確認 |
| 295 | + - この規模でも `O(10,000)` の DP で十分高速 |
| 296 | + |
| 297 | +7. **片方が極端に短い** |
| 298 | + - `s1 = "a"`, `s2 = "b" * 100`, `s3 = ...` → swap 最適化の効果確認 |
| 299 | + |
| 300 | +--- |
| 301 | + |
| 302 | +<h2 id="faq">FAQ</h2> |
| 303 | + |
| 304 | +**Q1: なぜ 2D DP ではなく 1D DP を使うのか?** |
| 305 | + |
| 306 | +A: Follow-up の要件「`O(s2.length)` の追加メモリ」を満たすため。2D DP は `O(len(s1) * len(s2))` のメモリを消費するが、1D DP は各行を更新しながら進めるため、列方向の配列 1つ分のメモリで済む。 |
| 307 | + |
| 308 | +**Q2: swap 処理(短い方を列方向に配置)の効果は?** |
| 309 | + |
| 310 | +A: 以下の2点で効率向上: |
| 311 | + |
| 312 | +- メモリサイズが `min(len(s1), len(s2)) + 1` になる |
| 313 | +- 内側ループの回数が減る(短い方が内側ループになる) |
| 314 | + |
| 315 | +実測では数 ms 〜 数 MB の改善だが、理論的に最適。 |
| 316 | + |
| 317 | +**Q3: 再帰 + メモ化ではダメなのか?** |
| 318 | + |
| 319 | +A: 動作はするが、以下の理由でループベース DP が推奨: |
| 320 | + |
| 321 | +- 再帰深度制限(Python のデフォルト 1000)に引っかかる可能性 |
| 322 | +- 関数呼び出しオーバーヘッド |
| 323 | +- メモリレイアウトがループより非効率 |
| 324 | + |
| 325 | +**Q4: LeetCode で Runtime が揺れる理由は?** |
| 326 | + |
| 327 | +A: オンラインジャッジの負荷やキャッシュ状態により、同じコードでも数 ms の揺れが発生。今回の実装は理論的に最適クラスなので、70% 〜 90% 程度の範囲で揺れるのは正常。 |
| 328 | + |
| 329 | +**Q5: 業務開発で使う際の注意点は?** |
| 330 | + |
| 331 | +A: |
| 332 | + |
| 333 | +- 型チェック(`isinstance(s1, str)` など)を追加 |
| 334 | +- 長さ制約チェックを追加 |
| 335 | +- docstring とログを充実させる |
| 336 | +- ドキュメント 1 の `isInterleave_production` を参考 |
| 337 | + |
| 338 | +**Q6: この問題の本質的な難しさは?** |
| 339 | + |
| 340 | +A: |
| 341 | + |
| 342 | +- 各位置で「`s1` の文字を使うか、`s2` の文字を使うか」の選択肢があり、全探索すると指数時間 |
| 343 | +- DP により「同じ状態 `(i, j)` に複数の経路で到達する」ことを活用して多項式時間に削減 |
| 344 | +- 1D DP への圧縮は「行方向の依存関係が1つ前の行のみ」という性質を利用 |
| 345 | + |
| 346 | +--- |
| 347 | + |
| 348 | +以上で、**LeetCode 97. Interleaving String** の 1D DP による最適化実装と、その背景にある理論・実装の詳細を網羅しました。この実装は Follow-up の要件を満たし、CPython での実行効率も高いトップレベルの解法です 🚀 |
0 commit comments