Day 160-Q3 — 2026-09-25 — 内容定義チャンク分割(Gear ハッシュ・FastCDC の境界判定)で、1 バイト挿入しても重複排除が崩れないバックアップを作る

2026-09-25 赤色 Master (Phase 8+) ★★★★★★★★★ 文字列ハッシュの実務応用。境界を「位置」ではなく「内容」で決め、編集の影響を局所化する

問題

バイト列 $S$(旧版)と $T$(新版)、パラメータ $mn, bits, mx$ が与えられる。$G[c]=\mathrm{sm}(c)$($c=0..255$、$\mathrm{sm}$ は splitmix64 の finalizer)、$\mathrm{MASK}$ を **上位 $bits$ ビットだけが 1** の 64 ビット値とし、バイト列 $D$(長さ $n$)を次の手順で塊(チャンク)に切る。

  • 現在位置 $s$ から、残り $n-s\le mn$ なら残り全部を 1 塊にして終わる
  • そうでなければ $fp=0$ とし、$i=s+mn,\ s+mn+1,\dots,\ \min(n,s+mx)-1$ の順に

$fp\leftarrow(2\cdot fp+G[D_i])\bmod 2^{64}$ を計算し、$fp\ \&\ \mathrm{MASK}=0$ となった最初の $i$ で $D[s..i]$ を 1 塊とする

  • 見つからなければ長さ $\min(n-s,mx)$ で切る。次の $s$ は塊の直後

$S$ を切った塊をすべて保存済みとする。$T$ を切った塊を先頭から順に見て、保存済みの塊と内容が一致しないものを新たに保存する(保存したものは以後保存済み)。$S$ の塊数、$T$ の塊数、新たに保存したバイト数の合計を出力せよ。

入力形式

mn bits mx
S
T

制約

#条件
1$0\le mn<mx\le 10^6$、$1\le bits\le 30$
2$1\le |S|,|T|\le 5\times10^5$、$S,T$ は ASCII の !〜~(空白を含まない印字可能文字)からなる

入出力例

入力例1

4 2 16
djicfjhjbjaheiddhiihgcdcigabcjaeaehjgggjhcfbachdegegigjfijgj
djicfjhjbjaheiddhiihgcdciXgabcjaeaehjgggjhcfbachdegegigjfijgj

出力例1

10 10 24

($T$ は $S$ の 26 文字目に X を 1 文字挿入したもの。塊の長さは $S$: 5,5,5,11,7,5,5,5,10,2、$T$: 5,5,5,11,5,8,5,5,10,2。挿入を含む塊 dhiihgcdciX と、境界がずれた gabcj・aeaehjgg の 3 塊(11+5+8=24 バイト)だけが新規で、その後は gjhcf から境界が再び一致する。同じ入力を 6 バイト固定長で切ると、挿入位置以降が全部ずれて 61 バイト中 37 バイトが新規になる)

Gear ハッシュによる境界判定は Xia ら「FastCDC: a Fast and Efficient Content-Defined Chunking Approach for Data Deduplication」(USENIX ATC 2016、https://www.usenix.org/conference/atc16/technical-sessions/presentation/xia 、2026-09-25 参照)の Algorithm 1(最小長の読み飛ばし+上位ビットのマスク)を簡略化したもの。論文はオープンソースの Rabin 方式 CDC より約 10 倍速いと報告している。

概念図:内容定義チャンクと固定長の比較

入力例1: 26 文字目に X を 1 文字挿入(1 マス = 1 バイト) S(CDC) 保存済み 5 5 5 11 7 5 5 5 10 2 T(CDC) 新規 24 B 5 5 5 11 5 8 5 5 10 2 挿入位置 ← ここから境界が再同期(gjhcf) T(固定 6B) 新規 37 B 6 6 6 6 6 6 6 6 6 6 1 境界は「直前 ≤64 バイトの内容」で決まる(Gear: fp ← 2fp + G[c]、上位 bits ビットが全部 0 で切る) 実データ 4 MB(CPython 標準ライブラリ): 先頭 1 バイト挿入で 固定長 100% 新規 vs CDC 0.06% 新規 赤 = 新たに保存する塊、青緑 = 保存済みの塊と内容が一致

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

ヒント1(方向性)

固定長で切ると、先頭に 1 バイト入っただけで以降の全ての塊の中身がずれ、重複排除が全滅する。境界を「この位置の直前の数十バイトの内容」だけで決めれば、編集から離れた場所の境界は同じ内容の前で同じように引かれ、塊が一致に戻る。

ヒント2(アプローチ)

Gear ハッシュ $fp\leftarrow 2fp+G[c]$ では、64 ステップ前のバイトは左シフトで押し出される。よって $fp$ の 上位ビットは直近 64 バイト、下位 $t$ ビットは直近 $t$ バイトにしか依存しない(ローリングハッシュを 1 回のシフトと加算で実現)。上位 $bits$ ビットが全部 0 になる確率は $2^{-bits}$ なので、塊の長さはおよそ $mn+2^{bits}$。

ヒント3(誘導)
mask = ((1 << bits) - 1) << (64 - bits)
fp = 0; cut = end
for i in range(s + mn, end):
    fp = ((fp << 1) + GEAR[data[i]]) & M64
    if fp & mask == 0:
        cut = i + 1; break
# 保存: set(bytes) に入れ、T の塊が無ければ len を足して追加

模範解答 (Python)

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

M64 = (1 << 64) - 1


def splitmix64(x):
    x = (x + 0x9E3779B97F4A7C15) & M64
    x = ((x ^ (x >> 30)) * 0xBF58476D1CE4E5B9) & M64
    x = ((x ^ (x >> 27)) * 0x94D049BB133111EB) & M64
    return x ^ (x >> 31)


GEAR = [splitmix64(c) for c in range(256)]


def chunks(data, mn, bits, mx):
    mask = ((1 << bits) - 1) << (64 - bits)   # 上位 bits ビット(直近 64 バイトに依存)
    n = len(data)
    out = []
    s = 0
    while s < n:
        rem = n - s
        if rem <= mn:
            out.append(data[s:]); break
        end = s + min(rem, mx)
        fp = 0
        cut = end
        for i in range(s + mn, end):          # 先頭 mn バイトは読み飛ばす(cut-point skipping)
            fp = ((fp << 1) + GEAR[data[i]]) & M64
            if fp & mask == 0:
                cut = i + 1
                break
        out.append(data[s:cut])
        s = cut
    return out


def main():
    it = sys.stdin.buffer.read().split()
    mn, bits, mx = int(it[0]), int(it[1]), int(it[2])
    S, T = it[3], it[4]
    cs, ct = chunks(S, mn, bits, mx), chunks(T, mn, bits, mx)
    stored = set(cs)
    new_bytes = 0
    for c in ct:
        if c not in stored:
            new_bytes += len(c)
            stored.add(c)                     # T の中で繰り返す塊も 1 回だけ保存
    print(len(cs), len(ct), new_bytes)


main()

計算量: 時間 $O(|S|+|T|)$(各バイトを高々 1 回ハッシュに通す。塊の bytes を辞書に入れる費用も長さの総和)、空間 $O(|S|+|T|)$。実務では塊そのものではなく SHA-256 などの指紋を索引に入れる。

Step-by-Step 解説

1なぜ固定長は壊れるか

固定長 $L$ では境界が $L,2L,\dots$ の 位置 に固定される。先頭に 1 バイト入ると全ての塊が 1 バイトずつずれ、中身が 1 つも一致しない。実測(CPython 標準ライブラリの .py を連結した 4 MB): 10 KiB 固定長で先頭 1 バイト挿入 → 新規 4,000,001 B(100%)。

2内容で境界を引く

境界の条件を「直近のバイト列のハッシュが特定の形」にすると、編集点から 64 バイト以上離れた場所の境界判定は旧版と同じ入力を見る。編集を含む塊と、その影響で位置がずれた 1〜2 塊が変わるだけで、その後は 同じ内容の前で同じ境界 に戻る(入力例1 の gjhcf)。実測で先頭 1 バイト挿入 → 新規 2,205 B(0.06%)。

3Gear ハッシュ — Rabin より速い理由

Rabin 指紋は多項式の剰余で、1 バイトごとに「出て行くバイト」の寄与を引く表引きも要る。Gear は $fp=2fp+G[c]$ だけで、出て行くバイトは 左シフトで自然に消える(64 ビット整数の上から溢れる)。代わりに窓の長さは 64 バイト固定で、しかも 下位ビットほど窓が短い。

4上位ビットのマスクが要る理由(実測)

下位 13 ビットを見るマスクだと、境界は直近 13 バイトだけで決まる。ソースコードのように同じ 13 バイト(字下げの空白など)が繰り返すデータでは、条件を満たさない文脈がまとめて続き、塊が伸びる。

マスク塊数平均長上限 64 KiB 到達
上位 13 ビット(本問、窓 64 バイト)38410,417 B(期待 $mn+2^{13}=10{,}240$)0
下位 13 ビット(窓 13 バイト)27614,493 B2

FastCDC 論文が「マスクの 1 を上位側に散らす(zero padding)」と書いているのはこの窓を広げるため。

5最小長の読み飛ばし(cut-point skipping)

各塊の先頭 $mn$ バイトはハッシュすら計算しない。平均 $mn+2^{bits}$ のうち $mn$ 分の計算が消えるので速い(本問の条件で 20%)。代わりに境界の決定が「塊の開始位置」に依存するようになり、厳密な内容定義性が少し落ちる。実測では再同期に支障は出なかった(Step 2 の 0.06%)。

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

案採否理由
固定長チャンク比較用最速だが、挿入・削除で全滅(実測 99.7〜100% が新規)
Rabin 指紋の CDC(LBFS 2001、restic など)不採用内容定義性は同等だが 1 バイトあたりの演算が多い。FastCDC は Rabin 系より約 10 倍速いと報告(論文値、本セッションで Rabin は実装していない)
Gear + 上位ビットマスク + 最小長の読み飛ばし(本解)採用1 バイトあたりシフト・加算・AND 各 1 回
FastCDC の正規化チャンキング(塊が短い間は厳しいマスク、長くなったら緩いマスク)扱わない塊の長さの分布を平均の周りに寄せ、重複排除率を保ったまま平均長を上げられる。本問の学習目的(再同期の原理)には不要。塊の長さのばらつきが索引サイズの問題になったら入れる
差分(rsync のローリングチェックサム)不採用2 版の間の差分転送には最適だが、多数の版・多数のファイルにまたがる重複排除の索引には向かない

前提が変わったら: 平均長 $2^{bits}$ は「重複排除率」と「索引の大きさ」のトレードオフ。小さくすると細かく一致を拾えるが、塊の数(=指紋の数)が増える。何 TB を何 GB の索引で扱うか、という運用の制約から逆算して決める(本問の 10 KiB は 4 MB の実験用の値で、実運用の推奨値ではない)。

よくあるミス

ミス原因正しい書き方
下位ビットでマスクするGear の下位 $t$ ビットは直近 $t$ バイトにしか依存しない上位 $bits$ ビット ((1<<bits)-1) << (64-bits)
fp を塊の途中で持ち越す/塊ごとに 0 にしない本問の定義は塊ごとに $fp=0$ から塊の開始で fp = 0
& M64 を忘れるPython の int は溢れず、古いバイトが押し出されない(窓が無限になる)毎ステップ & M64
条件成立の位置 $i$ を含めずに切る定義は $D[s..i]$($i$ を含む)cut = i + 1
$T$ の中で繰り返す塊を毎回数える1 回保存すれば以後は保存済み新規を保存したら stored.add(c)

次のステップ

  • 発展1: 正規化チャンキング($mn\le$ 長さ $<$ 平均 では $bits+2$ ビット、平均以上では $bits-2$ ビット)を入れ、塊の長さの標準偏差と重複排除率を本問版と比べる
  • 発展2: 100 箇所のランダム挿入で新規が 30.4%(実測)になった。平均長を 2 KiB/10 KiB/64 KiB と変えて、新規バイト数と塊数の積(索引コスト)の関係をグラフにする
  • 扱わなかった範囲: 指紋の衝突(SHA-256 で実質無視)、塊の圧縮と暗号化、削除された塊のガベージコレクション

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

  • 入力例1: 塊の長さは上記のとおり、出力 10 10 24。6 バイト固定長なら新規 37 バイト(手計算ではなくスクリプトで計算)
  • 定義どおりの素朴実装(各候補長について $fp$ を最初から計算し直す $O(L^2)$ 版)との一致(実測): ランダム 300 ケースで不一致 0
  • 実データ(実測): CPython 3.14 標準ライブラリ直下の .py 155 本を連結した先頭 4,000,000 B、$mn=2048, bits=13, mx=65536$。塊 384、平均 10,417 B、中央値 7,568 B、最大 64,419 B、上限到達 0、0.40 s(9.9 MB/s)
  • 編集への強さ(実測): 先頭 1 バイト挿入 → 固定長 4,000,001 B(100.0%)vs CDC 2,205 B(0.06%)。ランダム 100 箇所に 1〜20 バイト挿入 → 固定長 3,990,772 B(99.7%)vs CDC 1,217,007 B(30.42%。1 編集あたり約 1.2 塊)
  • 下位ビットマスク(実測): 塊 276、平均 14,493 B、上限到達 2(Step 4 の表)
  • 出所ラベル: 塊数・バイト数・秒数は実測(Python 3.14.7)。「Rabin 系より約 10 倍速い」は FastCDC 論文の要旨の記述(本セッションで Rabin 版は実装・計測していない)
  • 検証スクリプト: スクラッチパッドの d160/q3_test.py

自己評価

自分の回答

気づき・メモ