Day 159-Q4 — 2026-09-24 — HyperLogLog(基数推定・「先頭の 0 の長さの最大値」から相異なる要素数を 16 KB で当てる)

2026-09-24 赤色 Master (Phase 8+) ★★★★★★★★★ 確率的データ構造。厳密な集合を持たずに相異なる要素数を推定し、誤差 $1.04/\sqrt m$ を理論と実測で突き合わせる

問題

長さ $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++ 由来のバイアス補正も併用)。

概念図:ハッシュの分割とレジスタ

64 ビットハッシュを「箱の番号」と「先頭 0 の長さ」に分ける(b=4) 上位 b=4 残り 60 ビット w: 0 0 0 1 x x x x … → ρ(w) = 4(先頭 0 が 3 個 + 1) j = 添字 M[j] = max(M[j], ρ(w)) 同じ要素を何度入れても max は不変 → 重複が消える 入力例1 のレジスタ(m=16) 0 1 1 2 3 4 5 6 3 7 8 9 4 10 11 1 12 13 14 15 j E_raw = α₁₆ · 16² / Σ 2^(−M[j]) = 0.673 · 256 / 12.1875 = 14.1 ≤ 2.5·16 = 40 → 線形計数へ V = 空レジスタ数 = 12 → E = 16 · ln(16/12) = 4.60 → 出力 4(真値 6、m=16 の理論誤差 26%) 調和平均 m/Σ2^(−M[j]) が大きい外れ値を抑える。実測 b=14: RMSE 0.56%(理論 1.04/√16384 = 0.81%)

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

ヒント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 解説

1「先頭の 0」と基数の関係

一様なハッシュ値の先頭 $k$ ビットが全て 0 である確率は $2^{-k}$。$n$ 個の相異なる要素を入れると、$\rho$ の最大値 $R$ は $\log_2 n$ の周辺に分布する(Flajolet–Martin 1985)。同じ要素を何度入れても $\max$ は変わらないので、重複は自動的に無視される——これが「相異なる要素数」を数えられる理由。

2確率的平均化(箱に分ける)

$m$ 個の独立なハッシュ関数を使う代わりに、1 つのハッシュの上位 $b$ ビットで箱を選ぶ(stochastic averaging)。各箱には $n/m$ 個程度が入り、$M[j]\approx\log_2(n/m)$。$m$ 個の推定を平均して $n$ を復元する。

3調和平均と $\alpha_m$

$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)$ でよく近似できる。

4小さい基数の補正(線形計数)

$n\ll m$ のときは多くの箱が空で、調和平均の近似が崩れる。空箱の数 $V$ からは「$n$ 個を $m$ 箱に投げて $V$ 箱が空」という占有問題の最尤推定 $n\approx m\ln(m/V)$ が使える。閾値 $2.5m$ は論文の推奨値。実測($b=10$, $n=5$)では線形計数のおかげで誤差 0.24% と、大きい $n$ の 2〜3% より良い。

5誤差の実測と理論の突き合わせ
$b$$m$試行相対誤差 RMSE(実測)$1.04/\sqrt m$(理論)最大誤差
41610025.5%26.0%103%
82561006.39%6.50%21.5%
1010241002.92%3.25%8.19%
1240961002.20%1.62%5.77%
1416384300.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

自己評価

自分の回答

気づき・メモ