アルゴリズム概要

groupby.mean()
集計メソッド
np.floor(x*100+0.5)/100
ROUND_HALF_UP
copy=False
参照渡し(高速化)
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

⚠️ テーブル制約
  • 重複行あり(Duplicate rows may exist)→ 全行を集計対象とする
  • position: 1〜500、rating: 1〜5
  • query_name に NULL が含まれる可能性あり(groupbyで自動除外されるが明示的に処理)

ステップバイステップ解説

Python / Pandas 2.2.2 実装

✅ AC (13/13) 🔥 Beats ~80%+ Runtime 💾 Memory 最適化済
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 → %換算
    })

処理フローチャート

開始 Step 1 — マスク作成 mask = queries["query_name"].notna().to_numpy() Step 2 — 必要3列のみ抽出(result列スキップ) names = queries["query_name"].to_numpy()[mask] pos = queries["position"].to_numpy(dtype="float64")[mask] rat = queries["rating"].to_numpy(dtype="float64")[mask] Step 3 — ベクトル演算(NumPy SIMD) score = rat / pos poor = (rat < 3).astype("float64") Step 4 — 最小DF構築(copy=False 参照渡し) tmp = pd.DataFrame({"q":names,"s":score,"p":poor}, copy=False) Step 5 — groupby集計(Cython最適パス) agg = tmp.groupby("q", sort=False, as_index=False).mean() Step 6 — ROUND_HALF_UP(SQL互換) v = agg[["s","p"]].to_numpy() quality = np.floor(v[:,0] * 100 + 0.5) / 100 ← Python round(0.625,2)=0.62 ❌ → 0.63 ✅ 出力 DataFrame query_name | quality | poor_query_percentage 小数2桁・ROUND_HALF_UP 保証済 終了

フローの説明:
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 を実装

落とし穴と修正の軌跡

🐛 Bug 1(最重要): Python round() vs SQL ROUND() の丸め方式不一致 12/13 → 13/13
❌ Python の銀行家丸め (ROUND_HALF_EVEN)
# 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
✅ SQL 互換 ROUND_HALF_UP
# 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 ✅
なぜ 0.625 が問題になるか?
(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() は不合格になる。
⚠️ Bug 2: CoW (Copy-on-Write) 自己参照 pandas 2.2+ で挙動不定
❌ assign() 内で自己参照
slim = queries[["query_name","pos","rating"]]
# CoW lazy copy ← ここが問題

slim = slim.assign(
  score = slim["rating"] / slim["position"],
  # ↑ assign 内で slim を参照
  # CoW 下では評価タイミング不定
)
✅ notna + copy → 直接代入
df = queries.dropna(subset=["query_name"]).copy()
# ↑ .copy() で CoW を完全断切

df["score"] = df["rating"] / df["position"]
# ↑ 実体への直接代入(安全)
💾 落とし穴 3: float32 による精度落ち メモリ削減を狙うと精度が狂う
❌ float32 はメモリ有利だが精度が落ちる
np.float32(0.07)
# → 0.07000000029802322  ← ズレている!
# ROUND_HALF_UP の結果が狂う可能性
✅ float64 固定(精度保証)
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回で完結
📊 最適化前後の比較(N=100,000行・500クエリ名)
v1: full .copy() + named agg() 26.3 ms / 8.25 MB
v2: 3列抽出 + copy=False + .mean() 22.7 ms / 7.97 MB

ローカルベンチマーク値。LeetCode 環境では入力データの特性により変動します。