Day 160-Q4 — 2026-09-25 — Powersort の併合方針(CPython の list.sort が使う「run の中点の二進展開」による、ほぼ最適な併合順)

2026-09-25 赤色 Master (Phase 8+) ★★★★★★★★★ 適応的ソート。既存の昇順区間(run)を活かす併合順を、スタック 1 本と $O(1)$ の判定で最適に近づける

問題

長さ $N$ の整数列 $A$ が与えられる。$A$ を 極大な広義単調増加区間(run) $R_1,R_2,\dots,R_m$ に分ける($A_{i}<A_{i-1}$ の位置で区切る)。$R_j$ の開始位置を $s_j$(0 始まり)、長さを $\ell_j$ とする。

隣り合う run $R_j,R_{j+1}$ の境界の power を次で定める。2 つの中点 $a=(s_j+\ell_j/2)/N$、$b=(s_{j+1}+\ell_{j+1}/2)/N$ に対し

$$\mathrm{power}_j=\min\{k\ge1 : \lfloor a\cdot2^k\rfloor\ne\lfloor b\cdot2^k\rfloor\}$$

次の手順(Powersort, Munro–Wild 2018)で run を併合する。長さ $x$ と $y$ の列の併合のコストを $x+y$ とし、コストの総和 を出力せよ。

cur = R_1;  stack = []
for j = 1 .. m-1:
    p = power_j
    while stack が空でなく、stack の一番上の power > p:
        top = stack.pop();  cur = merge(top, cur)
    stack.push((cur, p));  cur = R_{j+1}
while stack が空でない:
    top = stack.pop();  cur = merge(top, cur)

入力形式

N
A_1 A_2 ... A_N

制約

#条件
1$1\le N\le 2\times10^5$、$0\le A_i\le10^9$

入出力例

入力例1

15
1 2 3 4 5 6 7 2 3 4 5 6 3 2 1

出力例1

28

(run の長さは 7, 5, 1, 1, 1。中点は $3.5/15=0.233$、$9.5/15=0.633$、$0.833$、$0.900$、$0.967$。境界 1 は 0.5 をまたぐので power 1、境界 2 は 0.75 をまたいで 2、境界 3 は 0.875 で 3、境界 4 は 0.9375 で 4。power が右へ単調に増えるので全部積まれ、右から $1+1=2$、$1+2=3$、$5+3=8$、$7+8=15$ と併合してコスト 28。これは最適な併合順のコスト 28 と一致する。Timsort の併合規則では 38、隣同士を 2 つずつ併合する素朴な方式では 43)

Powersort は J. Ian Munro, Sebastian Wild「Nearly-Optimal Mergesorts: Fast, Practical Sorting Methods That Optimally Adapt to Existing Runs」(ESA 2018、https://arxiv.org/abs/1805.04154 、2026-09-25 参照)の手法で、CPython の Objects/listsort.txt(https://github.com/python/cpython/blob/main/Objects/listsort.txt 、同上参照)に「The code now uses the "powersort" merge strategy」とあり、list.sort()/sorted() の併合順に使われている(導入は bpo-34561)。

概念図:power と併合木

run の中点を [0,1) に置き、境界がまたぐ最も粗い二分割線の段数 = power k=1 k=2 k=2 k=3 k=4 k=4 len 7 len 5 len 1 len 1 len 1 p=1 p=2 p=3 p=4 併合木(power が大きい境界ほど深い → 右から併合) 2 3 8 15 7 5 1 1 1 併合コスト(実測) Powersort 28 最適 28 Timsort 38 隣同士 43 下界 nH 27.34

ヒント(クリックで段階的に開示)

ヒント1(方向性)

併合のコストは「各要素が何回併合に参加したか」の総和、つまり併合木での各 run の深さ × 長さの和。長い run を浅く、短い run を深く 置き、しかも run の並び順は変えられない(安定ソートなので隣同士しか併合できない)。これは最適アルファベット順二分木(Day158-Q3 の Garsia–Wachs)と同じ問題で、最適解は $O(m\log m)$ だが、Powersort は左から 1 回走査するだけで近い木を作る。

ヒント2(アプローチ)

$[0,1)$ を二分割し続ける完全二分木を考える。各 run を中点の位置に置くと、境界 $j$ が「何段目の分割線をまたぐか」が power。power が小さい境界ほど木の根に近い(後で併合する)。スタックには「右隣との境界の power」が単調増加になるように積み、より小さい power が来たら大きいものから併合する。

ヒント3(誘導)
def node_power(s1, n1, n2, n):
    a = 2 * s1 + n1               # 中点 × 2(分母 2n)で整数化
    b = 2 * (s1 + n1) + n2
    k = 0
    while True:
        k += 1
        if (a << k) // (2 * n) != (b << k) // (2 * n):
            return k

模範解答 (Python)

模範解答を表示する(まず自力で解いてから)
import sys


def node_power(s1, n1, n2, n):
    """境界の power: 2 つの run の中点 a, b([0,1) に正規化)の二進展開が初めて食い違う桁。"""
    a = 2 * s1 + n1            # 中点×2(分母 2n)
    b = 2 * (s1 + n1) + n2
    k = 0
    while True:
        k += 1
        # floor(a * 2^k / 2n) と floor(b * 2^k / 2n) が異なる最小の k
        if (a << k) // (2 * n) != (b << k) // (2 * n):
            return k


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    A = list(map(int, data[1:1 + n]))
    # 1) 極大な広義単調増加 run に分解
    runs = []            # (開始位置, 長さ)
    s = 0
    for i in range(1, n + 1):
        if i == n or A[i] < A[i - 1]:
            runs.append((s, i - s))
            s = i
    # 2) Powersort の併合方針。コスト = 併合した 2 列の長さの和の総和
    cost = 0
    stack = []           # (開始位置, 長さ, 右隣との境界の power)
    cs, cl = runs[0]
    for bs, bl in runs[1:]:
        p = node_power(cs, cl, bl, n)
        while stack and stack[-1][2] > p:   # 深い(power が大きい)境界から先に併合
            ts, tl, _ = stack.pop()
            cost += tl + cl
            cs, cl = ts, tl + cl
        stack.append((cs, cl, p))
        cs, cl = bs, bl
    while stack:
        ts, tl, _ = stack.pop()
        cost += tl + cl
        cs, cl = ts, tl + cl
    print(cost)


main()

計算量: run 分解 $O(N)$、power は 1 境界あたり $O(\log N)$ 回のループ(CPython はビット演算で $O(1)$)、スタック操作は償却 $O(1)$。合計 $O(N+m\log N)$。スタックの深さは power の最大値 $\lceil\log_2 N\rceil+1$ 以下。

Step-by-Step 解説

1併合コスト=併合木の重み付き深さ

run を葉とする二分木で、各内部節点が 1 回の併合。要素は根までの深さの回数だけ併合に参加するので、コスト $=\sum_j \ell_j\cdot\mathrm{depth}(R_j)$。下界はエントロピー $nH=\sum_j\ell_j\log_2(N/\ell_j)$(ハフマン符号の下界と同じ理屈)。入力例1 は $nH=27.34$、最適 28。

2中点を二分割の木に落とす

区間 $[0,1)$ を $1/2$、$1/4, 3/4$、… と分割する線を考える。長い run は幅が広いので、その中点と隣の中点の間には 浅い(粗い)分割線 が入りやすい。境界 $j$ の power は「中点 $a,b$ の間にある最も粗い分割線の段数」で、$\lfloor a2^k\rfloor\ne\lfloor b2^k\rfloor$ はそのまま「$k$ 段目の線がまたがれている」。

3power の Cartesian tree が併合木

境界の power 列について、最小の power の境界で左右に分け、再帰する木(Cartesian tree)が Powersort の併合木。隣り合う境界の power は必ず異なる(同じ段の線が 2 本入ると、間に 1 段粗い線が必ずあり、どちらかの power が小さくなる)ので木は一意。スタックの手順はこの木を左から 1 回の走査で作る方法で、実測で再帰版と 2000 ケース一致。

4最適との差(実測)

$m=2000$ 本の run、各 5 試行。最適は Garsia–Wachs(Day158-Q3)で計算:

run 長の分布Powersort / 最適Timsort / 最適隣同士 2 つずつ / 最適
一様 [1,100]1.00681.06381.0160
幾何 $p=0.02$1.01381.07391.0448
パレート $\alpha=1.1$(裾が重い)1.03731.07521.2252

山登りで最悪入力を探すと(24 本)、Timsort / 最適 は 1.383 まで悪化、Powersort / 最適 は 1.071 止まり。Buss–Knop(arXiv:1801.04641)は Timsort の併合コストに $(1.5-o(1))n\log n$ の下界を示しており、これと整合する。

5エントロピー上界

Munro–Wild は Powersort のコストが $nH+2n$ 以下であることを示した。実測でランダム 2000 ケースの $(\text{cost}-nH)/n$ の最大は 1.637(2 未満)。

迷った他案と採らなかった理由

案最適との比(実測・パレート)採否理由
隣同士 2 つずつ(ボトムアップ)1.225不採用run 長の偏りを無視する。偏りが大きい現実データで弱い
Timsort の不変条件(merge_collapse)1.075(最悪 1.38)不採用CPython が 2002 年(Python 2.3)から powersort 導入まで使った方式。2015 年に不変条件の抜けが形式検証(de Gouw ら, CAV 2015)で見つかり修正された経緯もある
最適(Garsia–Wachs / Hu–Tucker)1.000不採用全 run を見てから木を作るので、左から流しながら併合できない(キャッシュ効率が悪い)。定数も大きい
Peeksort(同論文のもう一方)—扱わない再帰でトップダウンに割る。性能は Powersort と同等と報告。スタック 1 本で済む Powersort の方が既存の Timsort 実装に入れやすい
Powersort(本解)1.037(最悪 1.07)採用左から 1 回走査・$O(\log N)$ スタック・上界 $nH+2n$

前提が変わったら: 本問は run の検出を単純化している。実際の list.sort は (1) 狭義減少の run を反転して使う (2) 短い run を二分挿入ソートで最小長 minrun まで伸ばす (3) 併合中に galloping で比較回数を減らす。入力がほぼランダムなら run は短く、コストの差はほぼ出ない($N=2\times10^5$ の乱数で run は約 $10^5$ 本)。

よくあるミス

ミス原因正しい書き方
中点を浮動小数で計算する$N$ が大きいと $a\cdot2^k$ の切り捨てがずれうる中点を 2 倍して分母 $2N$ の整数で比べる
条件を >= にする隣り合う power は異なるので結果は同じだが、「同じ power は右結合」という定義を読み違えている厳密に >。等号の扱いを説明できるようにする
最後にスタックを左から併合する併合順が変わりコストが変わる上(右)から pop して cur と併合
狭義減少区間を 1 本の run として扱う本問の run は広義増加のみ(反転は実際の list.sort の仕様)A[i] < A[i-1] で区切る
コストに比較回数を使う本問のコストは長さの和(要素の移動量)cost += tl + cl

次のステップ

  • 発展1: 最適な併合木(Garsia–Wachs)と Powersort の木を同じ入力で描き、深さが違う run を特定する
  • 発展2: Timsort の merge_collapse(修正版)を実装し、本問の入力例で 38 になることを確かめる。さらに 2015 年の修正前の条件(直前 3 本だけを見る)で不変条件が壊れる入力を探す
  • 扱わなかった範囲: galloping mode、minrun の決め方(listsort.txt には 2025 年の新方式の記述がある)、比較回数の解析

検証ログ(本セッションで実行した数値)

  • 入力例1: run 長 [7,5,1,1,1]、power [1,2,3,4]、出力 28。同じ run 列で Timsort(CPython の修正版 merge_collapse を移植)38、隣同士 43、最適($O(m^3)$ 区間 DP)28、$nH=27.34$
  • スタック版と Cartesian tree の再帰版の一致(実測): ランダム 2000 ケースで不一致 0。入力配列から run を切る部分も含めたプログラム全体で 300 ケース不一致 0
  • Garsia–Wachs と区間 DP の一致(実測): 300 ケースで一致(最適コストの計算に使う前の検算)
  • 最適比(実測, $m=2000$, 各 5 試行): Step 4 の表。最大値は一様 1.0073 / 1.0807、幾何 1.0151 / 1.1055、パレート 1.0576 / 1.1018(Powersort / Timsort)
  • 最悪入力の探索(実測, 24 本, 山登り 6000 回 × 3): Timsort/最適 1.3827、Powersort/最適 1.0714
  • 上界(実測): $(\text{cost}-nH)/n$ の最大 1.637(ランダム 2000 ケース)、$m=2000$ の分布別では 0.199/0.306/0.577
  • 性能(実測): $N=2\times10^5$ の乱数列(run 約 $10^5$ 本)で 0.36 s、出力 3,316,864
  • 出所ラベル: 比・秒数は実測(Python 3.14.7)。上界 $nH+2n$ は Munro–Wild の定理、$(1.5-o(1))n\log n$ は Buss–Knop の要旨、listsort.txt の記述は 2026-09-25 に原文を確認。de Gouw ら 2015 の経緯は文献の記述で本セッションでは再現していない
  • 検証スクリプト: スクラッチパッドの d160/q4_test.py・q4_common.py

自己評価

自分の回答

気づき・メモ