groupby集計 + ROUND_HALF_UP(SQL互換) — O(N) 実装
| query_name | result | position | rating |
|---|---|---|---|
| Dog | Golden Retriever | 1 | 5 |
| Dog | German Shepherd | 2 | 5 |
| Dog | Mule | 200 | 1 |
| Cat | Shirazi | 5 | 2 |
| Cat | Siamese | 3 | 3 |
| Cat | Sphynx | 7 | 4 |
🔴 赤行: rating < 3(poor query)
| query_name | quality | poor_query_% |
|---|---|---|
| Dog | 2.50 | 33.33 |
| Cat | 0.66 | 33.33 |
📐 quality = AVG(rating / position)
Dog: (5/1 + 5/2 + 1/200) / 3 = 2.50
Cat: (2/5 + 3/3 + 4/7) / 3 = 0.66
📐 poor_% = COUNT(rating<3) / COUNT(*) × 100
Dog: 1/3 × 100 = 33.33
import pandas as pd
import numpy as np
def queries_stats(queries: pd.DataFrame) -> pd.DataFrame:
"""
各 query_name の quality と poor_query_percentage を返す。
quality = AVG(rating / position)
poor_query_percentage = COUNT(rating < 3) / COUNT(*) × 100
両値とも小数点以下2桁・ROUND_HALF_UP(SQL ROUND() 互換)
Returns:
pd.DataFrame: ['query_name', 'quality', 'poor_query_percentage']
"""
# ── Step 1: マスク作成(NULL query_name を明示除外)─────────────────
mask = queries["query_name"].notna().to_numpy()
# ── Step 2: 必要な3列のみ numpy 配列として抽出 ──────────────────────
# result 列(長文字列)を完全にスキップ → コピーコスト大幅削減
names = queries["query_name"].to_numpy()[mask]
pos = queries["position"].to_numpy(dtype="float64")[mask]
rat = queries["rating"].to_numpy(dtype="float64")[mask]
# ※ float32 は精度落ちで ROUND_HALF_UP が狂うため float64 固定
# ── Step 3: ベクトル演算(NumPy SIMD 最適化)───────────────────────
score = rat / pos # quality の各行スコア
poor = (rat < 3).astype("float64") # poor フラグ (0.0 or 1.0)
# bool のまま mean() → float64 で 0.0〜1.0 の割合が得られる
# ── Step 4: 最小 DataFrame を copy=False で構築 ──────────────────────
# numpy 配列を参照渡し(内部コピーゼロ)
tmp = pd.DataFrame({"q": names, "s": score, "p": poor}, copy=False)
# ── Step 5: groupby + .mean()(Cython 最適化パス)──────────────────
# sort=False でハッシュ集計のみ(ソートコストゼロ)
# named agg() より .mean() 直接呼び出しのほうが高速
agg = tmp.groupby("q", sort=False, as_index=False).mean()
# ── Step 6: ROUND_HALF_UP(SQL ROUND() と同動作)───────────────────
# Python round() / pandas .round() は ROUND_HALF_EVEN(銀行家丸め)
# 例: round(0.625, 2) = 0.62 ← LeetCode の期待値 0.63 と不一致!
# np.floor(x*100+0.5)/100 で ROUND_HALF_UP を手動実装
v = agg[["s", "p"]].to_numpy() # pandas オーバーヘッド排除
return pd.DataFrame({
"query_name": agg["q"],
"quality": np.floor(v[:, 0] * 100 + 0.5) / 100,
"poor_query_percentage": np.floor(v[:, 1] * 10000 + 0.5) / 100,
# poor は mean (0〜1) なので ×10000 して floor し /100 → %換算
})
フローの説明:
1. notna().to_numpy() で
NULL の query_name を除外し bool マスクを生成
2. result 列を完全スキップして必要な3列だけを numpy
配列として抽出(メモリ最大50%削減)
3. ベクトル演算で score と poor フラグを一括生成(NumPy SIMD 最適化)
4. copy=False で numpy
配列を参照渡しし、内部コピーゼロの DF を構築
5. groupby().mean() は
Cython 最適化パスを通り named agg より高速
6.
np.floor(x*100+0.5)/100 で
SQL の ROUND() と完全一致する ROUND_HALF_UP を実装
# 0.625 の場合(偶数 0.62 に丸める)
round(0.625, 2) # → 0.62 ❌
pd.Series([0.625]).round(2) # → 0.62 ❌
np.round(0.625, 2) # → 0.62 ❌
# LeetCode の期待値: 0.63
# np.floor で手動実装
val = 0.625
np.floor(val * 100 + 0.5) / 100
# step1: 0.625 * 100 = 62.5
# step2: 62.5 + 0.5 = 63.0
# step3: floor(63.0) = 63
# step4: 63 / 100 = 0.63 ✅
(1/2 + 1/1 + 3/8) / 3 =
0.625(float64 で正確に表現される)。 ちょうど 0.5
の境界にある値なので、銀行家丸めと ROUND_HALF_UP の結果が
0.62 vs 0.63 で分岐する。 LeetCode のジャッジは SQL
ROUND() の結果を正解とするため Python round() は不合格になる。
slim = queries[["query_name","pos","rating"]]
# CoW lazy copy ← ここが問題
slim = slim.assign(
score = slim["rating"] / slim["position"],
# ↑ assign 内で slim を参照
# CoW 下では評価タイミング不定
)
df = queries.dropna(subset=["query_name"]).copy()
# ↑ .copy() で CoW を完全断切
df["score"] = df["rating"] / df["position"]
# ↑ 実体への直接代入(安全)
np.float32(0.07)
# → 0.07000000029802322 ← ズレている!
# ROUND_HALF_UP の結果が狂う可能性
np.float64(0.07)
# → 0.07000000000000000 ← 安全
# ROUND_HALF_UP も正確に動作
| 処理ステップ | 時間計算量 | 空間計算量 | 備考 |
|---|---|---|---|
| notna().to_numpy()[mask] | O(N) | O(N) | result 列スキップでメモリ節約 |
| ベクトル演算(score, poor) | O(N) | O(N) | NumPy SIMD 最適化 |
| groupby(sort=False).mean() | O(N) | O(G) | ハッシュ集計、Cython最適パス |
| np.floor ROUND_HALF_UP | O(G) | O(G) | G = ユニーク query_name 数(G ≪ N) |
| 合計 | O(N) | O(N) | フルスキャン実質1回で完結 |
ローカルベンチマーク値。LeetCode 環境では入力データの特性により変動します。