問題
長さ $N$ の整数列 $A_1,\dots,A_N$ と精度パラメータ $b$ が与えられる。以下に定める HyperLogLog 推定値 $E$ を計算し、$\lfloor E\rfloor$ を出力せよ(ハッシュと計算手順を固定するので答えは一意に決まる)。
ハッシュ: 64 ビット演算($\bmod 2^{64}$)で
h(x): x += 0x9E3779B97F4A7C15
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9
x = (x ^ (x >> 27)) * 0x94D049BB133111EB
return x ^ (x >> 31)
(splitmix64 の finalizer。Vigna, http://xorshift.di.unimi.it/splitmix64.c )
手順: $m=2^b$ 個のレジスタ $M[0..m-1]$ を 0 で初期化。各 $A_i$ について $h=h(A_i)$ とし、上位 $b$ ビットを添字 $j$、残り $64-b$ ビットを $w$ とする。$\rho(w)$ を「$w$ の最上位側から見て最初に 1 が現れる位置(1-indexed)」とし($w=0$ なら $65-b$)、$M[j]\leftarrow\max(M[j],\rho(w))$。
$$E_{raw}=\alpha_m\, m^2 \Big/ \sum_{j} 2^{-M[j]},\qquad \alpha_{16}=0.673,\ \alpha_{32}=0.697,\ \alpha_{64}=0.709,\ \alpha_m=\frac{0.7213}{1+1.079/m}\ (m\ge128)$$
$E_{raw}\le 2.5m$ かつ $V=\#\{j: M[j]=0\}>0$ なら $E = m\ln(m/V)$(線形計数)、それ以外は $E=E_{raw}$。
入力形式
N b
A_1 A_2 ... A_N
制約
| # | 条件 |
|---|---|
| 1 | $1\le N\le 10^6$、$4\le b\le 16$、$1\le A_i\le 10^{18}$ |
入出力例
入力例1
10 4
1 2 3 2 1 4 5 5 5 6出力例1
4(真の相異なる要素数は 6。$m=16$ では 6 要素が 4 個のレジスタにしか当たらず $V=12$、$E=16\ln(16/12)=4.60$。$m=16$ の理論誤差は $1.04/\sqrt{16}=26\%$ なので、この程度の外れは想定内——後述の実測表参照)
この問題は Flajolet, Fusy, Gandouet, Meunier「HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm」(AofA 2007、https://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf 、2026-09-24 参照)の Fig.2 のアルゴリズムを、ハッシュを固定して決定的にしたものである。Redis の PFADD/PFCOUNT は $b=14$(16384 レジスタ、12 KB)でこれを実装している(https://redis.io/docs/latest/commands/pfcount/ 、同上参照。Redis は Google の HLL++ 由来のバイアス補正も併用)。
概念図:ハッシュの分割とレジスタ
ヒント(クリックで段階的に開示)
ヒント1(方向性)
「相異なる要素数」を厳密に数えるには集合が要り、$N$ に比例したメモリを食う。推定でよいなら、ハッシュ値の「先頭に 0 が何個続くか」の最大値 $R$ を見るだけで、要素数はおよそ $2^R$ と分かる($n$ 個の一様乱数のうち先頭 $k$ 個が 0 のものが 1 個くらいあるのは $n\approx2^k$ のとき)。ただし 1 個の最大値は分散が大きい。
ヒント2(アプローチ)
ハッシュの上位 $b$ ビットで $m=2^b$ 個の「箱」に振り分け、箱ごとに最大値 $M[j]$ を持てば $m$ 個の独立な推定が得られる。単純な算術平均 $2^{\bar M}$ は外れ値に弱いので、調和平均($m/\sum 2^{-M[j]}$)を使うのが HyperLogLog の要点で、これで相対誤差が $1.04/\sqrt m$ に落ちる(LogLog 法の $1.30/\sqrt m$ から改善)。$\alpha_m$ は調和平均のバイアスを打ち消す定数。
ヒント3(誘導)
shift = 64 - b
for v in A:
h = splitmix64(v)
j = h >> shift # 上位 b ビット
w = h & ((1 << shift) - 1) # 残り
r = shift - w.bit_length() + 1 # 先頭 0 の数 + 1(w == 0 なら shift + 1)
if r > M[j]: M[j] = r
E = alpha(m) * m * m / sum(2.0 ** -x for x in M)
if E <= 2.5 * m and (V := M.count(0)) > 0: E = m * log(m / V)模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys, math
MASK = (1 << 64) - 1
def splitmix64(x):
x = (x + 0x9E3779B97F4A7C15) & MASK
x = ((x ^ (x >> 30)) * 0xBF58476D1CE4E5B9) & MASK
x = ((x ^ (x >> 27)) * 0x94D049BB133111EB) & MASK
return x ^ (x >> 31)
def alpha(m):
if m == 16: return 0.673
if m == 32: return 0.697
if m == 64: return 0.709
return 0.7213 / (1 + 1.079 / m)
def main():
data = sys.stdin.buffer.read().split()
n, b = int(data[0]), int(data[1])
m = 1 << b
shift = 64 - b
low_mask = (1 << shift) - 1
M = [0] * m
for i in range(n):
h = splitmix64(int(data[2 + i]))
j = h >> shift
w = h & low_mask
r = shift - w.bit_length() + 1 # w == 0 のとき bit_length() == 0 で r = shift + 1
if r > M[j]:
M[j] = r
Z = 0.0
for x in M:
Z += 2.0 ** (-x)
E = alpha(m) * m * m / Z
if E <= 2.5 * m:
V = M.count(0)
if V > 0:
E = m * math.log(m / V) # 線形計数(小さい基数の補正)
print(math.floor(E))
main()
計算量: 時間 $O(N + m)$、空間 $O(m)$——$N$ や相異なる要素数に依存しない。$b=14$ なら $m=16384$ レジスタ、各レジスタは最大 $65-b=51$ なので 6 ビットで足り、詰めれば 12 KB(Redis の実装値)。
Step-by-Step 解説
一様なハッシュ値の先頭 $k$ ビットが全て 0 である確率は $2^{-k}$。$n$ 個の相異なる要素を入れると、$\rho$ の最大値 $R$ は $\log_2 n$ の周辺に分布する(Flajolet–Martin 1985)。同じ要素を何度入れても $\max$ は変わらないので、重複は自動的に無視される——これが「相異なる要素数」を数えられる理由。
$m$ 個の独立なハッシュ関数を使う代わりに、1 つのハッシュの上位 $b$ ビットで箱を選ぶ(stochastic averaging)。各箱には $n/m$ 個程度が入り、$M[j]\approx\log_2(n/m)$。$m$ 個の推定を平均して $n$ を復元する。
$2^{M[j]}$ の算術平均は、たまたま大きい $M[j]$ が 1 つあるだけで大きく振れる(右に裾が重い)。調和平均 $m/\sum2^{-M[j]}$ は大きい値の影響を抑える。その期待値は $n/m$ の定数倍にずれるので、$\alpha_m=\big(m\int_0^\infty(\log_2\frac{2+u}{1+u})^m du\big)^{-1}$ で補正する(論文 Theorem 1)。$m\ge128$ では $0.7213/(1+1.079/m)$ でよく近似できる。
$n\ll m$ のときは多くの箱が空で、調和平均の近似が崩れる。空箱の数 $V$ からは「$n$ 個を $m$ 箱に投げて $V$ 箱が空」という占有問題の最尤推定 $n\approx m\ln(m/V)$ が使える。閾値 $2.5m$ は論文の推奨値。実測($b=10$, $n=5$)では線形計数のおかげで誤差 0.24% と、大きい $n$ の 2〜3% より良い。
| $b$ | $m$ | 試行 | 相対誤差 RMSE(実測) | $1.04/\sqrt m$(理論) | 最大誤差 |
|---|---|---|---|---|---|
| 4 | 16 | 100 | 25.5% | 26.0% | 103% |
| 8 | 256 | 100 | 6.39% | 6.50% | 21.5% |
| 10 | 1024 | 100 | 2.92% | 3.25% | 8.19% |
| 12 | 4096 | 100 | 2.20% | 1.62% | 5.77% |
| 14 | 16384 | 30 | 0.56% | 0.81% | 1.58% |
$b=12$ で理論より悪いのは、試行の基数 $n\in\{10^3,10^4,10^5\}$ のうち $n=10^3\ll m=4096$ が線形計数と $E_{raw}$ の 境界付近($2.5m\approx10^4$)で、HLL++ 論文(Heule, Nunkesser, Hall 2013)が指摘するバイアスが出る領域だから。Redis がバイアス補正表を持つのはこのため。
迷った他案と採らなかった理由
| 案 | 空間 | 採否 | 理由 |
|---|---|---|---|
set() で厳密に数える | $O(n)$ | 比較用 | $N=10^6$ で 0.09 s と Python では HLL(0.84 s)より 速い。メモリは 50 万要素で数十 MB。メモリが許すなら厳密に数えるべきで、HLL はメモリ/ネットワーク(分散マージ)が制約のときの道具 |
| ビットマップ(値域 $10^{18}$) | $2^{60}$ ビット | 不採用 | 値域が広すぎる。ハッシュで潰しても衝突で不可 |
| Linear Counting 単体 | $O(n)$ ビット | 不採用 | 誤差は良いがビットマップの長さが $n$ に比例。HLL は小基数側でこれを取り込んでいる |
| LogLog / SuperLogLog(算術平均・切り詰め平均) | $O(m\log\log n)$ | 不採用 | 同じレジスタで誤差 $1.30/\sqrt m$。調和平均に変えるだけで $1.04/\sqrt m$ になるので HLL を選ばない理由が無い |
| HyperLogLog(本解) | $m$ レジスタ | 採用 | 誤差が理論どおり出ることを実測で確認(上表) |
| HLL++(64 ビットハッシュ・疎表現・経験的バイアス補正) | 同上 + 補正表 | 扱わない | Google(Heule ら 2013)の改良。バイアス補正表は 200 点の経験値で、本問の学習目的(原理の理解)から外れる。基数が $m$ の 2〜5 倍の領域の精度が要件になったら必要 |
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| $\rho$ を「末尾の 0 の数」で数える | 論文は先頭(最上位側)。どちらでも統計的には同じだが、本問は答えが固定なので定義どおりに | shift - w.bit_length() + 1 |
$w=0$ で bit_length() が 0 になり $\rho$ が $65-b$ になることを扱わない | 上式で自然に $shift+1$ になる。ただし C で clz(0) は未定義 | Python の bit_length() は 0 を返すので式のままで正しい |
| 上位 $b$ ビットを添字にした後、同じビットを $\rho$ にも使う | 添字と $\rho$ が相関し推定がずれる | 添字に使ったビットは捨て、残り $64-b$ ビットで $\rho$ を取る |
| 32 ビットハッシュを使う | $n>2^{32}/30$ で衝突補正が要る(原論文の large range correction) | 64 ビットハッシュなら実用範囲で不要(HLL++ の第 1 の改良点) |
| 線形計数の閾値判定を忘れる | 小基数で $E_{raw}$ が大きく偏る | $E_{raw}\le2.5m$ かつ $V>0$ で切り替える |
64 ビット演算で & MASK を忘れる | Python の整数は溢れないので値が伸び続ける | 乗算・加算のたびにマスク |
次のステップ
- 発展1: 2 つの HLL を レジスタごとの max でマージすると和集合の推定になる(分散カウントの根拠)。100 個に分割して個別に作りマージした推定と、1 本で作った推定が ビット単位で一致 することを確かめる
- 発展2: 包除 $|A\cap B|=|A|+|B|-|A\cup B|$ で積集合を推定し、誤差が和集合の誤差の差で膨らむことを実測する(積集合には MinHash の方が向く)
- 扱わなかった範囲: HLL++ の疎表現とバイアス補正表、レジスタを 6 ビットに詰めるメモリ最適化、削除(HLL は削除不可。必要なら Counting 版や Sliding HLL)
検証ログ(本セッションで実行した数値)
- 入力例1: レジスタ
[0,1,0,0,0,0,3,0,0,4,0,1,0,0,0,0]、$V=12$、$E_{raw}=14.1\le40$ で線形計数に切替、$E=4.603$、出力4(真値 6) - 誤差(実測、乱数 $10^{18}$ 以下・重複 50% 混入、真値は
set()で確認): 上表のとおり。$b=8,10,14$ では RMSE が理論値 $1.04/\sqrt m$ の内側、$b=12$ は境界領域のバイアスで理論値を超えた(原因は Step 5)。全 $b$ で平均バイアスは $\pm1\%$ 以内 - 小基数(実測、$b=10$): $n=5$ で RMSE 0.24%、$n=50$ で 1.96%、$n=500$ で 1.99%
- 性能(実測、$N=10^6$、相異なる 50 万、$b=14$): 推定 495,906 vs 真値 500,000(誤差 −0.82%、理論 0.81% と同水準)。HLL 0.84 s /
set()0.09 s - 出所ラベル: 誤差表・秒数は実測(Python 3.14)。$\alpha_m$ の式・閾値 $2.5m$・$1.04/\sqrt m$ は原論文からの引用。Redis の 12 KB・$b=14$ は公式ドキュメントからの引用(本セッションで Redis は実行していない)
- 検証スクリプト: スクラッチパッドの
q4.py