縦持ち → 横持ち ピボット変換|条件付き集計 O(N)
縦持ち(EAV形式)で格納された Department テーブルを、部門ID × 月 の2次元テーブル(横持ち)にピボット変換する問題です。 SQLでは GROUP BY id と
条件付き集計 MAX … FILTER
を組み合わせて12列を一度に展開します。
| id | revenue | month |
|---|---|---|
| 1 | 8000 | Jan |
| 2 | 9000 | Jan |
| 3 | 10000 | Feb |
| 1 | 7000 | Feb |
| 1 | 6000 | Mar |
| id | Jan_Revenue | Feb_Revenue | Mar_Revenue | … |
|---|---|---|---|---|
| 1 | 8000 | 7000 | 6000 | null |
| 2 | 9000 | null | null | null |
| 3 | null | 10000 | null | null |
MAX /
first で確定
crosstab() 不要、FILTER
句12個で完結
O(N)・空間 O(#dept × 12) —
N行を1パス集計で完了
WITH pivoted AS (
SELECT
id,
MAX(revenue) FILTER (WHERE month = 'Jan') AS "Jan_Revenue",
MAX(revenue) FILTER (WHERE month = 'Feb') AS "Feb_Revenue",
MAX(revenue) FILTER (WHERE month = 'Mar') AS "Mar_Revenue",
MAX(revenue) FILTER (WHERE month = 'Apr') AS "Apr_Revenue",
MAX(revenue) FILTER (WHERE month = 'May') AS "May_Revenue",
MAX(revenue) FILTER (WHERE month = 'Jun') AS "Jun_Revenue",
MAX(revenue) FILTER (WHERE month = 'Jul') AS "Jul_Revenue",
MAX(revenue) FILTER (WHERE month = 'Aug') AS "Aug_Revenue",
MAX(revenue) FILTER (WHERE month = 'Sep') AS "Sep_Revenue",
MAX(revenue) FILTER (WHERE month = 'Oct') AS "Oct_Revenue",
MAX(revenue) FILTER (WHERE month = 'Nov') AS "Nov_Revenue",
MAX(revenue) FILTER (WHERE month = 'Dec') AS "Dec_Revenue"
FROM Department
GROUP BY id
)
SELECT *
FROM pivoted
ORDER BY id;
MAX を使うのか?
主キー制約により各 (id, month) グループは必ず1行以下。MAX
は単一値をそのまま返し、行が存在しない場合は NULL を返します。
SUM や MIN でも同結果になりますが、「1つの値を選ぶ」という語義が最も正確なのは MAX
です。
FILTER (WHERE ...) はオプティマイザが集計前に条件を適用でき、CASE WHEN
より高効率です。
SELECT
id,
MAX(CASE WHEN month = 'Jan' THEN revenue END) AS "Jan_Revenue",
MAX(CASE WHEN month = 'Feb' THEN revenue END) AS "Feb_Revenue",
MAX(CASE WHEN month = 'Mar' THEN revenue END) AS "Mar_Revenue",
-- ... 残り9ヶ月分
MAX(CASE WHEN month = 'Dec' THEN revenue END) AS "Dec_Revenue"
FROM Department
GROUP BY id
ORDER BY id;
import pandas as pd
_MONTHS = ["Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"]
def reformat_department(department: pd.DataFrame) -> pd.DataFrame:
"""
縦持ちの Department テーブルを横持ちにピボットする。
Args:
department (pd.DataFrame): columns = [id, revenue, month]
Returns:
pd.DataFrame: [id, Jan_Revenue, Feb_Revenue, ..., Dec_Revenue]
売上なし月は NaN。
"""
# ① (id, month) が主キー → 集計不要、直接ピボット軸を構築
# unstack は Cython レベルで動作し Python 集計呼び出しなし
out = (
department
.set_index(["id", "month"])["revenue"] # MultiIndex Series: O(N)
.unstack("month") # Series → 2D DataFrame: O(N)
.reindex(columns=_MONTHS) # 欠損月補完 + カレンダー順: O(12)
)
# ② 列名を一括リネーム(リスト代入はコピーなし)
out.columns = [f"{m}_Revenue" for m in out.columns]
# ③ id を通常列に戻す
out = out.reset_index()
out.columns.name = None
return out
pivot_table
aggfunc 呼び出し発生。Memory Beats 5.66%
set_index + unstack
MAX 集計が安全GROUP BY id で部門ごとにグループを形成するMAX(revenue) FILTER (WHERE month = 'X')
を月ごとに12回適用(紫ループ)NULL が格納される| 手法 | 時間計算量 | 空間計算量 | 中間オブジェクト | 備考 |
|---|---|---|---|---|
| FILTER句(推奨) | O(N) | O(#dept × 12) | 最小 | Cython集計前フィルタ |
| CASE WHEN | O(N) | O(#dept × 12) | 少 | 行ごとにWHEN評価 |
| unstack (Pandas) | O(N) | O(#dept × 12) | 1個 | Memory最小、Beats↑ |
| pivot_table (Pandas) | O(N) | O(#dept × 12) | 3〜4個 | Python aggfunc呼び出し |
| crosstab() | O(N log N) | O(#dept × 12) | 多 | 動的列が必要な場合のみ |
GROUP BY id はハッシュ集計で O(N)。
FILTER 条件評価は O(12×N) = O(N)。
インデックスがある場合は Index Scan → Hash Agg で線形近似。
出力行列は O(#dept × 12) の固定サイズ。
N が大きくなっても出力サイズは部門数 ×
12列に収束するため、メモリ効率は高い。