Skip to content

Commit fc217cb

Browse files
committed
SQL: Basic Join 1084. Sales Analysis III
1 parent 6515c38 commit fc217cb

2 files changed

Lines changed: 691 additions & 0 deletions

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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

Comments
 (0)