Day 160-Q1 — 2026-09-25 — Jump Consistent Hash(Lamping–Veach 2014・バケツ数を変えたとき移動するキーを 1/(n+1) に抑える、メモリ 0 の一貫性ハッシュ)

2026-09-25 赤色 Master (Phase 8+) ★★★★★★★★★ 分散システムのハッシュ。「キー → バケツ」の割り当てが、バケツ数の変化で最小限しか動かないことを、ジャンプ列の単調性から示す

問題

$K$ 個のキー $x_1,\dots,x_K$(64 ビット符号なし整数)と、バケツ数の列 $n_0,n_1,\dots,n_Q$ が与えられる。キー $x$ をバケツ数 $n$ のもとで次の関数 $\mathrm{JCH}(x,n)\in[0,n)$ に割り当てる(Lamping, Veach「A Fast, Minimal Memory, Consistent Hash Algorithm」Figure 1 のコードそのもの。演算は uint64 の wrap-around、double は IEEE 754 倍精度)。

int32_t JCH(uint64_t key, int32_t n) {
  int64_t b = -1, j = 0;
  while (j < n) {
    b = j;
    key = key * 2862933555777941757ULL + 1;
    j = (b + 1) * (double(1LL << 31) / double((key >> 33) + 1));
  }
  return b;
}

各 $i=1,\dots,Q$ について、バケツ数を $n_{i-1}$ から $n_i$ に変えたときに 割り当て先が変わるキーの個数 $\#\{t : \mathrm{JCH}(x_t,n_{i-1})\ne \mathrm{JCH}(x_t,n_i)\}$ を出力せよ。

入力形式

K Q
x_1 x_2 ... x_K
n_0 n_1 ... n_Q

制約

#条件
1$1\le K\le 10^5$、$1\le Q\le 20$
2$0\le x_t<2^{64}$
3$1\le n_i\le 10^5$(増えても減ってもよい)

入出力例

入力例1

8 3
0 1 2 3 4 5 6 7
3 4 5 3

出力例1

2
1
3

(各キーの割り当て: $n=3,4,5$ の順に key0: 0,0,0 / key1: 0,0,0 / key2: 0,3,3 / key3: 2,3,3 / key4: 1,1,1 / key5: 1,1,4 / key6: 2,2,2 / key7: 0,0,0。$3\to4$ で動くのは新バケツ 3 へ移る 2 個だけ、$4\to5$ は新バケツ 4 へ移る 1 個だけ、$5\to3$ はバケツ 3・4 にいた 3 個が元の場所に戻る)

この問題は Lamping, Veach(Google, 2014, https://arxiv.org/abs/1406.2294 、2026-09-25 参照)の Jump Consistent Hash を題材にしている。一貫性ハッシュ(consistent hashing)の原典は Karger ら(STOC 1997)のリング方式で、分散キャッシュ・シャーディングでキーの再配置を最小にする目的で使われる。

概念図:ジャンプ列と割り当て

キーごとのジャンプ列は n に依らない。JCH(x, n) = 「n 未満で最後のジャンプ先」 bucket 0 bucket 1 bucket 2 bucket 3 bucket 4 bucket 5 n=3 n=4 n=5 key 2 n=3,4,5 → 0 / 3 / 3 key 3 n=3,4,5 → 2 / 3 / 3 key 5 n=3,4,5 → 1 / 1 / 4 key 6 n=3,4,5 → 2 / 2 / 2 割り当てが変わる ⇔ n と n′ の間([min, max))にジャンプ先がある。入力例1: 3→4 は key2・key3、4→5 は key5 だけ ジャンプ先 j の分布: Pr[j ≥ i] = (b+1)/i → 列の長さの期待値は調和数 H_n(n=10^5 で実測 12.11)

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

ヒント1(方向性)

mod n で割り当てると $n\to n+1$ でほぼ全キー(割合 $n/(n+1)$)が動く。一貫性ハッシュの要件は「$n\to n+1$ で動くのは新バケツへ移る約 $1/(n+1)$ だけ」。JCH のループが何を表しているかを考える: b は「このキーが直近にジャンプした先のバケツ番号」、j は「次にジャンプする先」。

ヒント2(アプローチ)

ループ内の乱数列(key の LCG)は **$n$ に依存しない**。つまりキーごとに決まるジャンプ列 $0=b_0<b_1<b_2<\cdots$ があり、$\mathrm{JCH}(x,n)$ は「$n$ 未満で最大の $b_i$」にすぎない。$n$ を変えたときに割り当てが変わるのは、区間 $[\min(n,n'),\max(n,n'))$ にジャンプ先が 1 つでもあるときに限る。

ヒント3(誘導)
def jump_chain(key, n_max):
    chain, b, j = [], -1, 0
    while j < n_max:
        b = j; chain.append(b)
        key = (key * MUL + 1) & MASK
        j = int((b + 1) * (2147483648.0 / ((key >> 33) + 1)))
    return chain
# JCH(x, n) = chain[bisect_left(chain, n) - 1]
# 変わる ⇔ bisect_left(chain, n_prev) != bisect_left(chain, n_next)

模範解答 (Python)

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

MASK = (1 << 64) - 1
MUL = 2862933555777941757
TWO31 = float(1 << 31)


def jump_chain(key, n_max):
    """key に対するジャンプ列 b_0=0 < b_1 < ... (すべて n_max 未満)を返す。"""
    chain = []
    b, j = -1, 0
    while j < n_max:
        b = j
        chain.append(b)
        key = (key * MUL + 1) & MASK
        j = int((b + 1) * (TWO31 / ((key >> 33) + 1)))
    return chain


def main():
    data = sys.stdin.buffer.read().split()
    k, q = int(data[0]), int(data[1])
    keys = data[2:2 + k]
    ns = [int(x) for x in data[2 + k:2 + k + q + 1]]
    n_max = max(ns)
    moved = [0] * q
    for x in keys:
        ch = jump_chain(int(x), n_max)
        pos = [bisect_left(ch, n) for n in ns]   # bucket(n) = ch[pos-1]
        for i in range(q):
            if pos[i] != pos[i + 1]:
                moved[i] += 1
    sys.stdout.write("\n".join(map(str, moved)) + "\n")


main()

計算量: ジャンプ列の長さの期待値は調和数 $H_{n}\approx\ln n+0.577$($n=10^5$ で 12.1)なので、時間 $O(K(\ln n_{\max}+Q\log\ln n_{\max}))$、空間 $O(\ln n_{\max})$。バケツ表を持たないので メモリはキーあたり定数。

Step-by-Step 解説

1一貫性ハッシュの 2 条件

(1) 均等: 各バケツにほぼ $K/n$ 個。(2) 単調: $n\to n+1$ で動くキーは **新しいバケツ $n$ へ移るものだけ**で、その割合は $1/(n+1)$。mod n は (1) を満たすが (2) を満たさない(実測で $n=100\to101$ に 98.99% が移動)。

2確率的な定義 — 「バケツ $j$ が追加されたとき、確率 $1/(j+1)$ でそこへ移る」

$\mathrm{ch}(x,n)$ を $n=1$ から 1 つずつ増やしながら決める: $n\to n+1$ で確率 $1/(n+1)$ で新バケツ $n$ へ移り、そうでなければ留まる。帰納法で各バケツの確率は $1/(n+1)$ ずつになり (1)(2) を満たす。ただしこれを素直にやると $O(n)$。

3ジャンプで $O(\ln n)$ に

直近のジャンプ先が $b$ のとき、「次にジャンプする先 $j$ が $j\ge i$ となる確率」は、$b+1,\dots,i-1$ の各段でジャンプしない確率の積 $\prod_{t=b+2}^{i}\frac{t-1}{t}=\frac{b+1}{i}$。一様乱数 $r\in(0,1]$ を引いて $j=\lfloor (b+1)/r\rfloor$ とすれば一気に次のジャンプ先が得られる。コードの double(1LL<<31)/double((key>>33)+1) がまさに $1/r$(key>>33 は LCG の上位 31 ビット)。

4本問の要点 — ジャンプ列は $n$ に依存しない

乱数は key だけから決まり、ループは $j\ge n$ で止まるだけ。よって全 $n$ に共通の列 $b_0<b_1<\cdots$ があり、$\mathrm{JCH}(x,n)=\max\{b_i<n\}$。二分探索 1 回で任意の $n$ の割り当てが分かり、$n$ と $n'$ で変わるかは「間にジャンプ先があるか」だけで決まる。これが単調性 (2) の証明そのものでもある。

5Python で C と同じ値になる理由

Python の float は IEEE 754 倍精度、(key>>33)+1\le2^{31} と b+1 は倍精度で厳密に表せ、演算順序(先に割り算、次に掛け算、切り捨て)を C と揃えれば結果はビット単位で一致する。実測で C 版(論文のコードを clang++ -O2 でコンパイル)と 300 ケース完全一致。

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

案検索メモリ均等さ($n=100$, $K=10^5$ の負荷の標準偏差/平均、実測)採否・理由
hash(x) mod n$O(1)$0—(均等)不採用。$n\to n+1$ で 99% が移動(実測 0.9899)
リング(Karger 1997)仮想ノード $V$ 個$O(\log nV)$$O(nV)$$V=1$: 87.4%/$V=100$: 10.5%/$V=1000$: 4.28%均等にするには $V$ を 1000 近くまで増やし表が 10 万点になる。任意のバケツを削除できるのが利点
Rendezvous(HRW, Thaler–Ravishankar 1998)$O(n)$0均等任意の削除に強いが検索が $O(n)$
Jump Consistent Hash(本解)$O(\ln n)$03.36%(二項分布の理論値 3.15% と同水準)採用。ただし **バケツは $0..n-1$ の連番で、末尾以外を消せない**
Maglev hashing(Google NSDI 2016)$O(1)$ 表引き$O(M)$ 表均等表の再構築が要る。ロードバランサ向け。範囲外

前提が変わったら: ストレージのシャードのように「番号付きで増減は末尾だけ」ならジャンプ。キャッシュサーバのように「任意の 1 台が落ちる」ならリングか Rendezvous(ジャンプで途中を消すと、それ以降の全バケツの番号がずれて大量移動が起きる)。

よくあるミス

ミス原因正しい書き方
(b + 1) * 2**31 / ((key >> 33) + 1) と書く掛けてから割ると丸めが C と変わり、境界で結果がずれうるC と同じく 先に割る: (b + 1) * (2147483648.0 / ((key >> 33) + 1))
key * MUL + 1 を 64 ビットに丸めないPython の int は溢れない& ((1 << 64) - 1)
クエリごとに JCH を 2 回呼ぶ正しいが $Q$ 倍遅いジャンプ列を 1 回作って二分探索(本問の $K=10^5, Q=20$ で 0.73 s)
移動数を「バケツごとの個数の差の和」で数える個数が同じでも中身が入れ替わる場合を見落とす(JCH では起きないが一般には起きる)キーごとに割り当ての変化を見る
$n$ が減る方向を別扱いする対称性に気づいていない$[\min,\max)$ にジャンプ先があるかだけで判定

次のステップ

  • 発展1: 重み付きバケツ(容量 2 倍のサーバ)をジャンプで扱うには?(仮想バケツを割り当てる/Rendezvous の重み付き版 $-w/\ln h$ と比較)
  • 発展2: 途中のバケツ $d$ を消したい。「$d$ を末尾 $n-1$ と入れ替える写像表」を持つと移動は何個になるか実測する
  • 扱わなかった範囲: 複製(replica)の配置、負荷上限付き一貫性ハッシュ(Mirrokni ら 2018, bounded loads)、Maglev

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

  • 入力例1: ジャンプ列 key2=[0,3]、key3=[0,2,3]、key5=[0,1,4]、key6=[0,1,2,5] ほか。出力 2 1 3。論文のコードをそのまま C でコンパイルした版も 2 1 3
  • C 参照実装との一致(実測): ランダム 300 ケース($n$ は 5〜$2^{31}-1$、キーは 64 ビット乱数)で不一致 0
  • 移動率(実測, $K=10^5$): $n=10\to11$ で 0.09044(理論 $1/11=0.09091$)、$100\to101$ で 0.01001(0.00990)、$1000\to1001$ で 0.00099(0.00100)。同じキーで mod は 0.9087/0.9899/0.9990
  • 均等さ(実測, $n=100$, $K=10^5$): 標準偏差/平均 ジャンプ 3.36%・最大/平均 1.082。リング $V=1$: 87.4%・4.26、$V=100$: 10.5%・1.34、$V=1000$: 4.28%・1.11。連番キー $0..K-1$ を直接入れてもジャンプは 2.90%
  • ジャンプ列の長さ(実測, $n=10^5$): 平均 12.11($H_n\approx12.09$)
  • 性能(実測): $K=10^5, Q=20, n\le10^5$ で 0.73 s。クエリごとに JCH を呼び直す素朴版は $10^4$ キーで 0.59 s($10^5$ なら約 5.9 s)
  • 出所ラベル: 移動率・均等さ・秒数は実測(Python 3.14.7, Apple clang)。確率 $1/(n+1)$・$\frac{b+1}{i}$ は論文の導出。リング/Rendezvous/Maglev の特性は各原典の記述で、本セッションで実測したのはリングのみ
  • 検証スクリプト: スクラッチパッドの d160/q1_test.py

自己評価

自分の回答

気づき・メモ