問題
$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)のリング方式で、分散キャッシュ・シャーディングでキーの再配置を最小にする目的で使われる。
概念図:ジャンプ列と割り当て
ヒント(クリックで段階的に開示)
ヒント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) 均等: 各バケツにほぼ $K/n$ 個。(2) 単調: $n\to n+1$ で動くキーは **新しいバケツ $n$ へ移るものだけ**で、その割合は $1/(n+1)$。mod n は (1) を満たすが (2) を満たさない(実測で $n=100\to101$ に 98.99% が移動)。
$\mathrm{ch}(x,n)$ を $n=1$ から 1 つずつ増やしながら決める: $n\to n+1$ で確率 $1/(n+1)$ で新バケツ $n$ へ移り、そうでなければ留まる。帰納法で各バケツの確率は $1/(n+1)$ ずつになり (1)(2) を満たす。ただしこれを素直にやると $O(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 ビット)。
乱数は key だけから決まり、ループは $j\ge n$ で止まるだけ。よって全 $n$ に共通の列 $b_0<b_1<\cdots$ があり、$\mathrm{JCH}(x,n)=\max\{b_i<n\}$。二分探索 1 回で任意の $n$ の割り当てが分かり、$n$ と $n'$ で変わるかは「間にジャンプ先があるか」だけで決まる。これが単調性 (2) の証明そのものでもある。
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)$ | 0 | 3.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