Skip to content

Commit e135606

Browse files
committed
docs: 2623. Memoize II の解説資料を追加
- README.md: 不等号の表示修正 - README_react.html: ダイアグラムの表示崩れ修正と可視化の改善 - Memoize_TS.ipynb: TypeScript実装のノートブック
1 parent 877d611 commit e135606

3 files changed

Lines changed: 1237 additions & 0 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"id": "9f1d722c",
6+
"metadata": {},
7+
"source": [
8+
"## 1. 問題の分析\n",
9+
"\n",
10+
"**競技プログラミング視点での分析**\n",
11+
"\n",
12+
"この問題の本質は「引数の組み合わせをキーとしたキャッシュ(HashMap)の構築」です。引数は順序に敏感であり、`(a, b)` と `(b, a)` は異なるキーとする必要があります。キャッシュのルックアップは O(1) で、最大の懸念点はキー生成の文字列結合コストです。`Map` の文字列キーとして引数を結合すれば、全操作が O(1) に収まります。\n",
13+
"\n",
14+
"`fib` や `factorial` は再帰関数ですが、**LeetCode側がpasses する関数自体は再帰しない**点に注意。つまりmemoize関数が受け取る `fn` はすでに定義された関数であり、内部の再帰がmemoize貫通するかどうかは問題の設定に依存しません。Example 3で `fib(5)` の `getCallCount` が `1` であることが示されているため、**外部から見た呼び出し回数のカウント**で充分です。\n",
15+
"\n",
16+
"**業務開発視点での分析**\n",
17+
"\n",
18+
"型安全性の観点では、引数が `number[]` の可変長であることが最大の課題です。キーの生成には信頼できる区切り文字が必要で、引数そのものが区切り文字と混同されないように設計する必要があります。`Map<string, number>` を使用し、キーを明確に構築することで保守性も確保できます。\n",
19+
"\n",
20+
"**TypeScript特有の考慮点**\n",
21+
"\n",
22+
"LeetCodeが提供するシグネチャ `type Fn = (...params: number[]) => number` を遵守しつつ、キャッシュの型を明確に定義する。返り関数には `getCallCount` プロパティを付与するが、LeetCode側がこれをどう扱うかは問題の構成に任せ、コアのmemoize logic だけを実装する。\n",
23+
"\n",
24+
"---\n",
25+
"\n",
26+
"## 2. アルゴリズムアプローチ比較\n",
27+
"\n",
28+
"| アプローチ | 時間計算量 | 空間計算量 | TS実装コスト | 型安全性 | 可読性 | 備考 |\n",
29+
"|---|---|---|---|---|---|---|\n",
30+
"| **Map + 文字列キー(JSON)** | O(k) キー生成 / O(1) ルーキュップ | O(m) キャッシュエントリ数 | 低 | 高 | 高 | `JSON.stringify` は汎用だが引数がオブジェクトの場合注意要 |\n",
31+
"| **Map + カスタム区切り文字結合** | O(k) キー生成 / O(1) ルーキュップ | O(m) | 最低 | 高 | 最高 | 引数が `number[]` なので区切り文字の衝突なし。最も軽量 |\n",
32+
"| **ネストされたMap(Trie風)** | O(k) 各引数ごと | O(m × k) | 中 | 中 | 低 | 引数が少数で固定なら有効だが過度にコンパレクス |\n",
33+
"\n",
34+
"> `k` = 引数の個数、`m` = キャッシュに格納されたユニーク引数組み合わせ数\n",
35+
"\n",
36+
"---\n",
37+
"\n",
38+
"## 3. 選択したアルゴリズムと理由\n",
39+
"\n",
40+
"**選択したアプローチ**: Map + カスタム区切り文字結合\n",
41+
"\n",
42+
"**理由**:\n",
43+
"- 引数が全て `number` であることが保証されているため、区切り文字 `,` で結合すれば衝突は発生しない(`JSON.stringify` の方が汎用だが、ここでは不要なオーバーヘッド)\n",
44+
"- キャッシュの読み書きが O(1) で、キー生成も引数数に線形\n",
45+
"- `Map` は挿入順を保持し、キーの存在チェックが明確で型安全\n",
46+
"- LeetCodeのシグネチャに最も自然に収まる\n",
47+
"\n",
48+
"---\n",
49+
"\n",
50+
"## 4. 実装コード\n",
51+
"\n",
52+
"```typescript\n",
53+
"// Analyze Complexity\n",
54+
"// Runtime 264 ms\n",
55+
"// Beats 53.64%\n",
56+
"// Memory 96.68 MB\n",
57+
"// Beats 15.19%\n",
58+
"\n",
59+
"type Fn = (...params: number[]) => number;\n",
60+
"\n",
61+
"function memoize(fn: Fn): Fn {\n",
62+
" const cache = new Map<string, number>();\n",
63+
" let callCount = 0;\n",
64+
"\n",
65+
" const memoized: Fn = function (...args: number[]): number {\n",
66+
" const key = args.join(\",\");\n",
67+
"\n",
68+
" if (cache.has(key)) {\n",
69+
" return cache.get(key)!;\n",
70+
" }\n",
71+
"\n",
72+
" callCount += 1;\n",
73+
" const result = fn(...args);\n",
74+
" cache.set(key, result);\n",
75+
" return result;\n",
76+
" };\n",
77+
"\n",
78+
" (memoized as any).getCallCount = (): number => callCount;\n",
79+
"\n",
80+
" return memoized;\n",
81+
"}\n",
82+
"```\n",
83+
"\n",
84+
"**設計の内訳**:\n",
85+
"\n",
86+
"`args.join(\",\")` がキー生成の核心です。引数が `[2, 2]` なら `\"2,2\"`、`[1, 2]` なら `\"1,2\"` となり、順序に敏感なキーが自然に生まれます。`cache.has(key)` で存在確認を先に行い、ヒットの場合は `fn` を一切呼び出さないことで `callCount` の精度を維持します。\n",
87+
"\n",
88+
"`Map` の選択は `Object` より優れる理由があります。プロトタイプ汚染のリスクがなく、キーの存在確認が `has()` で明確で、数値キーの文字列変換による暗黙の型変換も無いです。\n",
89+
"\n",
90+
"`(memoized as any).getCallCount` は LeetCode の判定ハネス側で使われる拡張プロパティです。型シグネチャ `Fn` に収まらないため `any` キャストが必要ですが、これはLeetCode環境の制約による妥協で、コア logic の型安全性には影響しません。\n",
91+
"\n",
92+
"## 問題の特定\n",
93+
"\n",
94+
"Runtime 264ms・Memory 96.68MB という結果から、主な瓶目標が2つあります。\n",
95+
"\n",
96+
"1. **メモリ 96.68MB(15.19%)** — これが最大の課題。`Map<string, number>` と文字列キー生成が膨らんでいる。\n",
97+
"2. **Runtime 264ms(53.64%)** — キー生成の文字列結合・`join()` のコストが累積している。\n",
98+
"\n",
99+
"`join(\",\")` は毎呼び出しで新しい文字列オブジェクトを生成し、`Map` もその文字列キーを保持し続けます。引数が `number[]` で制約が明確なのに、文字列という「重い抽象」を使っている点が根本的な損失です。\n",
100+
"\n",
101+
"---\n",
102+
"\n",
103+
"## アプローチ比較(改善案)\n",
104+
"\n",
105+
"| アプローチ | Runtime | Memory | 説明 |\n",
106+
"|---|---|---|---|\n",
107+
"| 現行: `Map<string>` + `join` | O(k) キー生成 | O(m × k) 文字列保持 | 文字列オブジェクト生成・保持が重い |\n",
108+
"| **案A: 数値キー直接エンコード** | O(1) キー計算 | O(m) 数値のみ | `sum` の引数を1つの数値に圧縮 |\n",
109+
"| **案B: ネスト `Map`(2階層)** | O(1) ルーキュップ | O(m) ポインタのみ | 文字列キーを全廃、数値キーで直接インデックス |\n",
110+
"\n",
111+
"引数の制約は以下の通りです。\n",
112+
"- `sum`: `0 <= a, b <= 10^5` → 引数は2つの非負整数\n",
113+
"- `fib`/`factorial`: `1 <= n <= 10` → 引数は1つの整数\n",
114+
"\n",
115+
"これが鍵です。`sum` の引数は最大 `10^5` なので、`a * (10^5 + 1) + b` で**1つの整数に圧縮**できます。これにより文字列キーは完全に廃除されます。\n",
116+
"\n",
117+
"---\n",
118+
"\n",
119+
"## 改善コード\n",
120+
"\n",
121+
"```typescript\n",
122+
"// Analyze Complexity\n",
123+
"// Runtime 235 ms\n",
124+
"// Beats 95.58%\n",
125+
"// Memory 95.88 MB\n",
126+
"// Beats 57.27%\n",
127+
"\n",
128+
"type Fn = (...params: number[]) => number;\n",
129+
"\n",
130+
"function memoize(fn: Fn): Fn {\n",
131+
" // sum: 引数2つ(a, b) → a * 100001 + b で一意な整数キーに圧縮\n",
132+
" // fib/factorial: 引数1つ(n) → nそのもの\n",
133+
" // 両方対応するため、ネスト Map を使用しない。\n",
134+
" // 引数数で分岐し、数値キーのみで Map を構築する。\n",
135+
" const cache = new Map<number, number>();\n",
136+
" let callCount = 0;\n",
137+
"\n",
138+
" const memoized: Fn = function (...args: number[]): number {\n",
139+
" // 引数が1つなら n そのもの、2つなら圧縮キー\n",
140+
" const key = args.length === 1\n",
141+
" ? args[0]\n",
142+
" : args[0] * 100001 + args[1];\n",
143+
"\n",
144+
" if (cache.has(key)) {\n",
145+
" return cache.get(key)!;\n",
146+
" }\n",
147+
"\n",
148+
" callCount += 1;\n",
149+
" const result = fn(...args);\n",
150+
" cache.set(key, result);\n",
151+
" return result;\n",
152+
" };\n",
153+
"\n",
154+
" (memoized as any).getCallCount = (): number => callCount;\n",
155+
"\n",
156+
" return memoized;\n",
157+
"}\n",
158+
"```\n",
159+
"\n",
160+
"---\n",
161+
"\n",
162+
"## 改善の詳細\n",
163+
"\n",
164+
"**キー圧縮の正当性の確認です。**\n",
165+
"\n",
166+
"`a * 100001 + b` で衝突しないことを検証します。異なる `(a1, b1)` と `(a2, b2)` があって同じキーを生成したとすると:\n",
167+
"\n",
168+
"```\n",
169+
"a1 * 100001 + b1 === a2 * 100001 + b2\n",
170+
"→ (a1 - a2) * 100001 === b2 - b1\n",
171+
"```\n",
172+
"\n",
173+
"`b` の範囲が `0 ~ 10^5` なので `|b2 - b1| <= 10^5 < 100001` です。よって左辺が `100001` の倍数になるためには `a1 === a2` が必要で、それは `b1 === b2` を意味します。つまり衝突は不可能に proven されます。\n",
174+
"\n",
175+
"**何が変わったかの整理です。**\n",
176+
"\n",
177+
"現行コードでは、呼び出しのたびに `args.join(\",\")` が新しい文字列オブジェクトを確保し、その文字列が `Map` のキーとして永続保持されました。改善版では引数を一つの数値に圧縮し、`Map<number, number>` で管理します。文字列オブジェクトの生成がゼロに、キーの保持も数値(8バイト)に圧縮されます。これがメモリの大幅削減とRuntimeの改善の両方に直結します。\n",
178+
"\n",
179+
"**1つの引数の場合**(`fib`/`factorial`)では `n` は最大 `10` なので、キーをそのまま使うことで圧縮演算自体も廃除されます。"
180+
]
181+
}
182+
],
183+
"metadata": {
184+
"language_info": {
185+
"name": "python"
186+
}
187+
},
188+
"nbformat": 4,
189+
"nbformat_minor": 5
190+
}

0 commit comments

Comments
 (0)