|
| 1 | +{ |
| 2 | + "cells": [ |
| 3 | + { |
| 4 | + "cell_type": "markdown", |
| 5 | + "id": "50632a29", |
| 6 | + "metadata": {}, |
| 7 | + "source": [ |
| 8 | + "## 0) 前提\n", |
| 9 | + "\n", |
| 10 | + "* 環境: **Python 3.10.15 / pandas 2.2.2**\n", |
| 11 | + "* **指定シグネチャ厳守**\n", |
| 12 | + "* I/O 禁止、`print` / `sort_values` 使用禁止\n", |
| 13 | + "* 判定は `product_id` 基準、出力列・順序は `['product_id', 'product_name']`\n", |
| 14 | + "\n", |
| 15 | + "---\n", |
| 16 | + "\n", |
| 17 | + "## 1) 問題\n", |
| 18 | + "\n", |
| 19 | + "* `{{PROBLEM_STATEMENT}}`\n", |
| 20 | + " 2019-01-01〜2019-03-31(2019 年 Q1)の期間にのみ販売された商品を求める。\n", |
| 21 | + " 具体的には、各 `product_id` について:\n", |
| 22 | + "\n", |
| 23 | + " * Q1 期間に 1 回以上販売されている\n", |
| 24 | + " * かつ Q1 以外の期間では 1 回も販売されていない\n", |
| 25 | + "\n", |
| 26 | + " ものを抽出する。\n", |
| 27 | + "\n", |
| 28 | + "* 入力 DF: `{{INPUT_DATAFRAMES}}`\n", |
| 29 | + "\n", |
| 30 | + " ```text\n", |
| 31 | + " Product : 列 ['product_id', 'product_name', 'unit_price']\n", |
| 32 | + " Sales : 列 ['seller_id', 'product_id', 'buyer_id', 'sale_date', 'quantity', 'price']\n", |
| 33 | + " ```\n", |
| 34 | + "\n", |
| 35 | + "* 出力: `{{OUTPUT_COLUMNS_AND_RULES}}`\n", |
| 36 | + "\n", |
| 37 | + " * 列: `['product_id', 'product_name']`\n", |
| 38 | + " * 行: 上記条件を満たす `product_id` のみ\n", |
| 39 | + "\n", |
| 40 | + "---\n", |
| 41 | + "\n", |
| 42 | + "## 2) 実装(指定シグネチャ厳守)\n", |
| 43 | + "\n", |
| 44 | + "> 列最小化 → `groupby` で min/max 日付集約 → 条件抽出 → `isin` でセミジョイン、という素直なパターンで実装します。\n", |
| 45 | + "\n", |
| 46 | + "```python\n", |
| 47 | + "import pandas as pd\n", |
| 48 | + "\n", |
| 49 | + "def sales_analysis(product: pd.DataFrame, sales: pd.DataFrame) -> pd.DataFrame:\n", |
| 50 | + " \"\"\"\n", |
| 51 | + " Returns:\n", |
| 52 | + " pd.DataFrame: 列名と順序は ['product_id', 'product_name']\n", |
| 53 | + " \"\"\"\n", |
| 54 | + " # 1) 列最小化(この問題で必要なのは product_id と sale_date のみ)\n", |
| 55 | + " s = sales[['product_id', 'sale_date']]\n", |
| 56 | + "\n", |
| 57 | + " # 2) product_id ごとに最小日付・最大日付を集約\n", |
| 58 | + " agg = (\n", |
| 59 | + " s.groupby('product_id', as_index=False)['sale_date']\n", |
| 60 | + " .agg(min_date='min', max_date='max')\n", |
| 61 | + " )\n", |
| 62 | + "\n", |
| 63 | + " # 3) 「すべての販売日が 2019Q1 に収まっている」product_id を抽出\n", |
| 64 | + " q1_start = pd.Timestamp('2019-01-01')\n", |
| 65 | + " q1_end = pd.Timestamp('2019-03-31')\n", |
| 66 | + "\n", |
| 67 | + " mask = (agg['min_date'] >= q1_start) & (agg['max_date'] <= q1_end)\n", |
| 68 | + " q1_only_ids = agg.loc[mask, 'product_id']\n", |
| 69 | + "\n", |
| 70 | + " # 4) Product から該当 product_id のみセミジョイン\n", |
| 71 | + " out = product.loc[product['product_id'].isin(q1_only_ids), ['product_id', 'product_name']]\n", |
| 72 | + "\n", |
| 73 | + " return out\n", |
| 74 | + "\n", |
| 75 | + "Analyze Complexity\n", |
| 76 | + "Runtime 351 ms\n", |
| 77 | + "Beats 79.45%\n", |
| 78 | + "Memory 69.55 MB\n", |
| 79 | + "Beats 81.99%\n", |
| 80 | + "\n", |
| 81 | + "```\n", |
| 82 | + "\n", |
| 83 | + "* `sort_values` は一切使用せず、`groupby` 集約と `isin` のみで完結させています。\n", |
| 84 | + "* 出力列の順序も指定どおり `['product_id', 'product_name']` に固定しています。\n", |
| 85 | + "\n", |
| 86 | + "---\n", |
| 87 | + "\n", |
| 88 | + "## 3) アルゴリズム説明\n", |
| 89 | + "\n", |
| 90 | + "使用 API と流れを整理します。\n", |
| 91 | + "\n", |
| 92 | + "1. **列最小化**:\n", |
| 93 | + "\n", |
| 94 | + " ```python\n", |
| 95 | + " s = sales[['product_id', 'sale_date']]\n", |
| 96 | + " ```\n", |
| 97 | + "\n", |
| 98 | + " * 後続処理に不要な列(seller_id, buyer_id, quantity, price)はここで捨ててメモリと処理コストを削減。\n", |
| 99 | + "\n", |
| 100 | + "2. **グループ処理(最小/最大日付の集約)**:\n", |
| 101 | + "\n", |
| 102 | + " ```python\n", |
| 103 | + " agg = (\n", |
| 104 | + " s.groupby('product_id', as_index=False)['sale_date']\n", |
| 105 | + " .agg(min_date='min', max_date='max')\n", |
| 106 | + " )\n", |
| 107 | + " ```\n", |
| 108 | + "\n", |
| 109 | + " * 各 `product_id` について\n", |
| 110 | + "\n", |
| 111 | + " * `min_date`: その商品の最も古い販売日\n", |
| 112 | + " * `max_date`: その商品の最も新しい販売日\n", |
| 113 | + " * 「すべての販売日が Q1 にある」ことは\n", |
| 114 | + " `min_date >= '2019-01-01'` かつ `max_date <= '2019-03-31'`\n", |
| 115 | + " と同値になるので、`BOOL_OR` などのフラグ集計よりもシンプルです。\n", |
| 116 | + "\n", |
| 117 | + "3. **条件抽出**:\n", |
| 118 | + "\n", |
| 119 | + " ```python\n", |
| 120 | + " q1_start = pd.Timestamp('2019-01-01')\n", |
| 121 | + " q1_end = pd.Timestamp('2019-03-31')\n", |
| 122 | + "\n", |
| 123 | + " mask = (agg['min_date'] >= q1_start) & (agg['max_date'] <= q1_end)\n", |
| 124 | + " q1_only_ids = agg.loc[mask, 'product_id']\n", |
| 125 | + " ```\n", |
| 126 | + "\n", |
| 127 | + " * `mask` で「Q1 だけで売れている」product_id を絞り込み、その ID シリーズを取得。\n", |
| 128 | + "\n", |
| 129 | + "4. **軽量セミジョイン (`isin`)**:\n", |
| 130 | + "\n", |
| 131 | + " ```python\n", |
| 132 | + " out = product.loc[product['product_id'].isin(q1_only_ids),\n", |
| 133 | + " ['product_id', 'product_name']]\n", |
| 134 | + " ```\n", |
| 135 | + "\n", |
| 136 | + " * 単一キー → 行のフィルタには `merge` よりも `isin` が軽くて読みやすいパターン。\n", |
| 137 | + " * ここで Product 側から必要な列だけを投影して最終結果にしている。\n", |
| 138 | + "\n", |
| 139 | + "---\n", |
| 140 | + "\n", |
| 141 | + "### NULL / 重複 / 型の扱い\n", |
| 142 | + "\n", |
| 143 | + "* `sale_date` が NULL の行があった場合\n", |
| 144 | + "\n", |
| 145 | + " * `min` / `max` はデフォルトで非 NULL の値だけを対象にするため、NULL の存在で壊れにくい設計。\n", |
| 146 | + " * 全て NULL の場合は `min_date`, `max_date` が NULL となり、Q1 条件に該当しなくなる(妥当)。\n", |
| 147 | + "\n", |
| 148 | + "* 重複行(同じ `product_id`・`sale_date` の行が複数)\n", |
| 149 | + "\n", |
| 150 | + " * `groupby` 集約では重複があっても `min_date` / `max_date` の値は変わらないため、追加コストは O(重複数) だけでロジックは影響なし。\n", |
| 151 | + "\n", |
| 152 | + "* 型\n", |
| 153 | + "\n", |
| 154 | + " * LeetCode 想定では `sale_date` はすでに `datetime64[ns]` 相当の型で渡される想定。\n", |
| 155 | + " * 明示的に `pd.Timestamp` を使って比較することで、文字列比較ではなく日時比較で安全に評価している。\n", |
| 156 | + "\n", |
| 157 | + "---\n", |
| 158 | + "\n", |
| 159 | + "## 4) 計算量(概算)\n", |
| 160 | + "\n", |
| 161 | + "`N = Sales の行数`, `P = Product の行数`, `G = 異なる product_id の個数` とします。\n", |
| 162 | + "\n", |
| 163 | + "1. `groupby('product_id').agg(min, max)`\n", |
| 164 | + "\n", |
| 165 | + " * ハッシュグループ化想定で **O(N)**〜**O(N log G)** 近辺\n", |
| 166 | + " * `sale_date` の min/max 計算は 1 行あたり O(1)\n", |
| 167 | + "\n", |
| 168 | + "2. `isin` によるセミジョイン\n", |
| 169 | + "\n", |
| 170 | + " * `q1_only_ids` の長さを `K` とすると\n", |
| 171 | + "\n", |
| 172 | + " * ハッシュセット化で **O(K)**\n", |
| 173 | + " * Product 側の走査で **O(P)**\n", |
| 174 | + " * 合計は **O(P + K)** 程度(`K <= G <= N`)\n", |
| 175 | + "\n", |
| 176 | + "トータルで **O(N + P)** 近似の線形オーダーに収まり、\n", |
| 177 | + "`sort_values` や複雑な rank を使わない分、実装もパフォーマンスもバランスが良い構成です。\n", |
| 178 | + "\n", |
| 179 | + "---\n", |
| 180 | + "\n", |
| 181 | + "## 5) 図解(Mermaid 超保守版)\n", |
| 182 | + "\n", |
| 183 | + "```mermaid\n", |
| 184 | + "flowchart TD\n", |
| 185 | + " A[入力 Product DF]\n", |
| 186 | + " B[入力 Sales DF]\n", |
| 187 | + " C[Sales から product_id と sale_date のみ抽出]\n", |
| 188 | + " D[product_id ごとに min と max の sale_date を集約]\n", |
| 189 | + " E[集約結果から 2019Q1 だけで売れている product_id を抽出]\n", |
| 190 | + " F[Product DF から該当 product_id のみフィルタ]\n", |
| 191 | + " G[出力 product_id と product_name]\n", |
| 192 | + " B --> C\n", |
| 193 | + " C --> D\n", |
| 194 | + " D --> E\n", |
| 195 | + " A --> F\n", |
| 196 | + " E --> F\n", |
| 197 | + " F --> G\n", |
| 198 | + "```\n", |
| 199 | + "\n", |
| 200 | + "この実装は、PostgreSQL 版で行った「min/max 日付で Q1 に収まるか判定」という戦略を、そのまま Pandas 向けに最適化した形になっています。\n", |
| 201 | + "\n", |
| 202 | + "結論から言うと、そのスコアならかなり仕上がっています。\n", |
| 203 | + "アルゴリズム自体はほぼ最適クラスなので、残りは「微妙なオーバーヘッドを減らすかどうか」の世界です。\n", |
| 204 | + "\n", |
| 205 | + "それでもあえていじるとしたら、このくらいです。\n", |
| 206 | + "\n", |
| 207 | + "---\n", |
| 208 | + "\n", |
| 209 | + "## 1) ほぼ最終形のコード例\n", |
| 210 | + "\n", |
| 211 | + "* 中間の `s` をやめて `groupby` を直にかける\n", |
| 212 | + "* マスク計算時に `Series` ではなく `ndarray` を使って、インデックスアラインのオーバーヘッドを削る\n", |
| 213 | + "\n", |
| 214 | + "```python\n", |
| 215 | + "import pandas as pd\n", |
| 216 | + "\n", |
| 217 | + "def sales_analysis(product: pd.DataFrame, sales: pd.DataFrame) -> pd.DataFrame:\n", |
| 218 | + " \"\"\"\n", |
| 219 | + " Returns:\n", |
| 220 | + " pd.DataFrame: 列名と順序は ['product_id', 'product_name']\n", |
| 221 | + " \"\"\"\n", |
| 222 | + " # 1) product_id ごとに最小日付・最大日付を集約(列も最小限)\n", |
| 223 | + " agg = (\n", |
| 224 | + " sales.groupby('product_id', as_index=False)['sale_date']\n", |
| 225 | + " .agg(min_date='min', max_date='max')\n", |
| 226 | + " )\n", |
| 227 | + "\n", |
| 228 | + " # 2) 「すべての販売日が 2019Q1 に収まっている」product_id を抽出\n", |
| 229 | + " q1_start = pd.Timestamp('2019-01-01')\n", |
| 230 | + " q1_end = pd.Timestamp('2019-03-31')\n", |
| 231 | + "\n", |
| 232 | + " min_date = agg['min_date'].values\n", |
| 233 | + " max_date = agg['max_date'].values\n", |
| 234 | + " mask = (min_date >= q1_start) & (max_date <= q1_end)\n", |
| 235 | + "\n", |
| 236 | + " q1_only_ids = agg.loc[mask, 'product_id']\n", |
| 237 | + "\n", |
| 238 | + " # 3) Product から該当 product_id のみセミジョイン\n", |
| 239 | + " out = product.loc[\n", |
| 240 | + " product['product_id'].isin(q1_only_ids),\n", |
| 241 | + " ['product_id', 'product_name']\n", |
| 242 | + " ]\n", |
| 243 | + "\n", |
| 244 | + " return out\n", |
| 245 | + "\n", |
| 246 | + "Analyze Complexity\n", |
| 247 | + "Runtime 331 ms\n", |
| 248 | + "Beats 94.28%\n", |
| 249 | + "Memory 69.50 MB\n", |
| 250 | + "Beats 81.99%\n", |
| 251 | + "\n", |
| 252 | + "```\n", |
| 253 | + "\n", |
| 254 | + "### 微調整ポイント\n", |
| 255 | + "\n", |
| 256 | + "* `sales[['product_id', 'sale_date']]` という別変数を作らず、そのまま groupby している\n", |
| 257 | + " → ほんの少しだけ一時オブジェクトが減ります(効果はごく小さいですが無駄はない)。\n", |
| 258 | + "* `mask` 計算を `Series` 同士ではなく `ndarray` 同士にしている\n", |
| 259 | + " → インデックス合わせなどの内部処理がいらなくなる分、わずかに軽くなり得ます。\n", |
| 260 | + "\n", |
| 261 | + "---\n", |
| 262 | + "\n", |
| 263 | + "## 2) これ以上やるなら「可読性とのトレードオフ」\n", |
| 264 | + "\n", |
| 265 | + "アルゴリズムは\n", |
| 266 | + "\n", |
| 267 | + "* 1 回の `groupby` で min/max を取る\n", |
| 268 | + "* `isin` で Product とセミジョイン\n", |
| 269 | + "\n", |
| 270 | + "という構造で、計算量的にも実質 O(N) に近い形です。\n", |
| 271 | + "\n", |
| 272 | + "これ以上の改善は\n", |
| 273 | + "\n", |
| 274 | + "* dtype を事前に `datetime64[ns]` に統一しておく\n", |
| 275 | + "* 変数名・ローカル変数の数をさらに削る\n", |
| 276 | + "\n", |
| 277 | + "といったレベルになり、可読性とトレードオフになる割に LeetCode の ms 単位ではほぼ誤差です。\n", |
| 278 | + "\n", |
| 279 | + "今の 351 ms / 79%・メモリ 69.55MB / 81% なら、\n", |
| 280 | + "「アルゴリズムも実装も合格点、余力があれば micro-tuning で遊べる状態」と見てよいと思います。\n", |
| 281 | + "\n" |
| 282 | + ] |
| 283 | + } |
| 284 | + ], |
| 285 | + "metadata": { |
| 286 | + "language_info": { |
| 287 | + "name": "python" |
| 288 | + } |
| 289 | + }, |
| 290 | + "nbformat": 4, |
| 291 | + "nbformat_minor": 5 |
| 292 | +} |
0 commit comments