Comprehensive Guide to Bottom- Sketches
Unweighted, Weighted, Proofs & Implementations
1. Introduction to Bottom- Sketches#
A bottom- sketch (also known as a K-Minimum Values (KMV) sketch or a single-hash MinHash sketch) is a probabilistic data structure used to summarize massive datasets using fixed, tiny memory footprints. Instead of storing every unique item in a stream, it hashes each item onto the interval and retains only the smallest unique hash values.
From those values alone, one can estimate:
- The total number of distinct elements (cardinality),
- The similarity between two sets (Jaccard similarity), and
- Set operations (unions and intersections) without re-scanning raw data.
1.1 Overview & Construction#
Suppose you have a stream of elements and a uniform random hash function .
- Initialize an empty max-heap of capacity alongside a lookup structure to ignore duplicates.
- Hash each item to get a value .
- Update the sketch:
- If is already in the sketch, ignore it.
- If the sketch has fewer than items, insert .
- If the sketch has items and (the current maximum in the heap), evict and insert .
Because updates only require computing a hash and comparing against , processing each item takes expected time or worst-case time.
1.2 Primary Applications#
Cardinality Estimation (Count-Distinct)#
If distinct items are hashed uniformly into , they divide the interval into sub-intervals of expected length . The -th smallest hash value has expected value:
Inverting this yields the unbiased cardinality estimator :
The relative standard error of this estimate is approximately . Storing hashes yields an estimate with error regardless of whether or .
Set Merging (Unions)#
Given sketch and sketch built with the same hash function , the exact bottom- sketch of is:
Jaccard Similarity & Intersections#
The Jaccard similarity is estimated by taking the fraction of elements in present in both and :
The intersection size is then estimated as .
1.3 Comparison: Bottom- vs. MinHash vs. HyperLogLog#
| Feature | Bottom- Sketch (KMV) | -Hash MinHash | HyperLogLog (HLL) |
|---|---|---|---|
| Hash Functions per Item | |||
| Update Time Complexity | expected, worst | ||
| Memory Footprint | bits | bits | bits (~5–6 bits/register) |
| Best Use Case | Fast updates + set intersections, Jaccard, & subset sums | Locality-Sensitive Hashing (LSH) | Pure COUNT(DISTINCT) at minimal memory |
2. Weighted Extension: Priority Sampling & Exponential Scaling#
A weighted bottom- sketch generalizes count-distinct sketching to items with positive weights (e.g., revenue, byte volume, or duration). It samples heavy items with higher probability and yields unbiased Horvitz-Thompson estimates for total stream weight and arbitrary subset sums.
2.1 Core Intuition: Virtual Replication#
If an item has integer weight , an unweighted sketch could conceptually split into 100 sub-items , hashing each to and taking the minimum:
Computing hashes directly requires work and fails for continuous non-integer weights. We can simulate this minimum in time using logarithmic transformations or priority scaling.
2.2 Mathematical Formulations#
Given uniform random hashes and weights :
Method A: Exponential Scaling (PPSWOR)#
Transform into an exponential variable :
The probability that item achieves the minimum rank among set is strictly proportional to its weight:
Method B: Priority Sampling (Uniform Scaling)#
Scale the uniform float directly:
This avoids logarithmic evaluations while maintaining optimal variance bounds.
2.3 Horvitz-Thompson Estimator#
Let be the -th smallest rank in the sketch. The first items () are retained as active samples, while the -th item sets the threshold .
Under Exponential Scaling (): Inclusion probability: Adjusted weight:
Under Priority Sampling (): Inclusion probability: Adjusted weight:
In both schemes, the threshold-setting item receives an adjusted weight .
2.4 Subset Sum Queries#
Let denote the active sample set of size . For any predicate (e.g., region == 'EU'):
2.5 Worked Numerical Example ()#
Consider items evaluated with Priority Sampling ():
| Item | Weight () | Hash () | Rank () | Status () | Adjusted Weight |
|---|---|---|---|---|---|
| A | 1st () | ||||
| B | 3rd () | (Threshold setter: ) | |||
| C | 2nd () | ||||
| D | Evicted () |
- True Total Weight:
- Estimated Total Weight:
Heavy item A () retains its exact weight (), whereas light item C () is scaled up to to represent both itself and unsampled light items.
3. Mathematical Proof of Unbiasedness#
3.1 The Leave-One-Out Threshold #
Let be independent continuous random variables with CDFs . Let be the sorted ranks, and set global threshold .
For any item , define the leave-one-out threshold as the -th smallest rank among all items excluding :
Because ranks are mutually independent, is independent of .
Fundamental Equivalence:#
- Item is inside the top overall ranks if and only if :
- Whenever holds, inserting shifts the -th overall threshold to match :
3.2 General Unbiasedness Proof#
Define the indicator and the adjusted weight estimator:
Using the equivalences from Section 3.1:
Applying the Law of Total Expectation by conditioning on :
Evaluating the inner expectation with held fixed:
Since is independent of , . Thus:
Taking the outer expectation:
By linearity of expectation, for any subset :
3.3 Instantiating Distributions#
Evaluating for specific rank transformations:
Priority Sampling ():
Exponential Scaling ():
3.4 Why the -th Item acts as a Threshold Setter#
Evaluating using would require knowing , which corresponds to the -th rank in the full stream. Because a sketch of capacity discards all ranks beyond , is unobserved. The -th item must therefore serve solely as the threshold boundary , yielding .
3.5 Pairwise Zero Covariance Property#
For Priority Sampling, Duffield et al. proved that for any two distinct items :
As a result, the variance of a subset sum equals the sum of the individual variances:
4. High-Performance Implementations#
4.1 Systems Optimization Strategies#
- Branch-Light Fast-Path Rejection: Cache in a dedicated field. Over of items in large streams fail and are rejected immediately without touching heap memory.
- IEEE-754 Bit-Level Floating Point Generation: Construct uniform by masking 52 hash bits into the mantissa of double
1.0(0x3FF0000000000000) and subtracting1.0. - In-Place Heap Replacement: Overwrite root
heap[0]directly and runsift_down(0)instead of executing sequentialpop()andpush()calls.
4.2 Rust Implementation#
use std::cmp::Ordering;
/// A single entry in the Priority Sampling Bottom-k Sketch.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct SketchEntry {
pub rank: f64, // Priority rank r_i = u_i / w_i
pub weight: f64, // True item weight w_i
pub id: u64, // Item identifier
pub payload: u32, // Dimension tag / group ID for subset predicates
}
impl PartialEq for SketchEntry {
#[inline(always)]
fn eq(&self, other: &Self) -> bool {
self.rank == other.rank && self.id == other.id
}
}
impl Eq for SketchEntry {}
impl PartialOrd for SketchEntry {
#[inline(always)]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SketchEntry {
#[inline(always)]
fn cmp(&self, other: &Self) -> Ordering {
self.rank
.total_cmp(&other.rank)
.then_with(|| self.id.cmp(&other.id))
}
}
pub struct PrioritySketch {
k: usize,
seed: u64,
threshold: f64, // Cached r_(k) for O(1) fast-path rejection
heap: Vec<SketchEntry>,
}
impl PrioritySketch {
pub fn with_capacity(k: usize, seed: u64) -> Self {
assert!(k >= 2, "Capacity k must be >= 2");
Self {
k,
seed,
threshold: f64::INFINITY,
heap: Vec::with_capacity(k),
}
}
#[inline(always)]
fn hash_to_unit_float(&self, id: u64) -> f64 {
let mut z = id.wrapping_add(self.seed).wrapping_add(0x9E3779B97F4A7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^= z >> 31;
let bits = 0x3FF0_0000_0000_0000_u64 | (z >> 12) | 1;
f64::from_bits(bits) - 1.0
}
#[inline]
pub fn update(&mut self, id: u64, weight: f64, payload: u32) {
if weight <= 0.0 {
return;
}
let u = self.hash_to_unit_float(id);
let rank = u / weight;
// FAST PATH: Rejects >99% of stream items with a single register comparison
if rank >= self.threshold {
return;
}
let entry = SketchEntry { rank, weight, id, payload };
if self.heap.len() < self.k {
let idx = self.heap.len();
self.heap.push(entry);
self.sift_up(idx);
if self.heap.len() == self.k {
self.threshold = self.heap[0].rank;
}
} else {
self.heap[0] = entry;
self.sift_down(0);
self.threshold = self.heap[0].rank;
}
}
pub fn estimate_subset<F>(&self, mut predicate: F) -> f64
where
F: FnMut(&SketchEntry) -> bool,
{
if self.heap.len() < self.k {
return self.heap.iter().filter(|e| predicate(e)).map(|e| e.weight).sum();
}
let inv_tau = 1.0 / self.heap[0].rank;
let mut sum = 0.0;
for entry in &self.heap[1..] {
if predicate(entry) {
sum += entry.weight.max(inv_tau);
}
}
sum
}
#[inline]
pub fn estimate_total(&self) -> f64 {
self.estimate_subset(|_| true)
}
pub fn merge(&mut self, other: &Self) {
assert_eq!(self.seed, other.seed, "Seeds must match to merge sketches");
let mut combined = Vec::with_capacity(self.heap.len() + other.heap.len());
combined.extend_from_slice(&self.heap);
combined.extend_from_slice(&other.heap);
combined.sort_unstable();
combined.dedup_by_key(|e| e.id);
combined.truncate(self.k);
self.heap.clear();
self.threshold = f64::INFINITY;
for entry in combined {
let idx = self.heap.len();
self.heap.push(entry);
self.sift_up(idx);
}
if self.heap.len() == self.k {
self.threshold = self.heap[0].rank;
}
}
#[inline(always)]
fn sift_up(&mut self, mut idx: usize) {
let item = self.heap[idx];
while idx > 0 {
let parent = (idx - 1) >> 1;
if item <= self.heap[parent] {
break;
}
self.heap[idx] = self.heap[parent];
idx = parent;
}
self.heap[idx] = item;
}
#[inline(always)]
fn sift_down(&mut self, mut idx: usize) {
let len = self.heap.len();
let item = self.heap[idx];
let half = len >> 1;
while idx < half {
let mut child = (idx << 1) + 1;
let right = child + 1;
if right < len && self.heap[right] > self.heap[child] {
child = right;
}
if item >= self.heap[child] {
break;
}
self.heap[idx] = self.heap[child];
idx = child;
}
self.heap[idx] = item;
}
}4.3 C99 Implementation#
#include <stdint.h>
#include <stdlib.h>
#include <stdio.h>
#include <stdbool.h>
#include <math.h>
typedef struct {
double rank; // r_i = u_i / w_i
double weight; // w_i
uint64_t id; // Item key
uint32_t payload; // Subset filter tag
uint32_t _pad; // 32-byte alignment padding
} SketchEntry;
typedef struct {
size_t k;
size_t len;
uint64_t seed;
double threshold; // Cached heap[0].rank
SketchEntry heap[]; // Flexible array member
} PrioritySketch;
static inline double hash_to_unit_float(uint64_t id, uint64_t seed) {
uint64_t z = id + seed + 0x9E3779B97F4A7C15ULL;
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
z ^= z >> 31;
union { uint64_t u; double d; } conv;
conv.u = 0x3FF0000000000000ULL | (z >> 12) | 1ULL;
return conv.d - 1.0;
}
static inline bool entry_gt(const SketchEntry *a, const SketchEntry *b) {
return (a->rank > b->rank) || (a->rank == b->rank && a->id > b->id);
}
PrioritySketch *ps_create(size_t k, uint64_t seed) {
if (k < 2) return NULL;
PrioritySketch *ps = malloc(sizeof(PrioritySketch) + k * sizeof(SketchEntry));
if (!ps) return NULL;
ps->k = k;
ps->len = 0;
ps->seed = seed;
ps->threshold = INFINITY;
return ps;
}
static inline void sift_up(SketchEntry *restrict heap, size_t idx) {
SketchEntry item = heap[idx];
while (idx > 0) {
size_t parent = (idx - 1) >> 1;
if (!entry_gt(&item, &heap[parent])) break;
heap[idx] = heap[parent];
idx = parent;
}
heap[idx] = item;
}
static inline void sift_down(SketchEntry *restrict heap, size_t len, size_t idx) {
SketchEntry item = heap[idx];
size_t half = len >> 1;
while (idx < half) {
size_t child = (idx << 1) + 1;
size_t right = child + 1;
if (right < len && entry_gt(&heap[right], &heap[child])) {
child = right;
}
if (!entry_gt(&heap[child], &item)) break;
heap[idx] = heap[child];
idx = child;
}
heap[idx] = item;
}
void ps_update(PrioritySketch *restrict ps, uint64_t id, double weight, uint32_t payload) {
if (weight <= 0.0) return;
double u = hash_to_unit_float(id, ps->seed);
double rank = u / weight;
if (rank >= ps->threshold) return;
SketchEntry entry = { .rank = rank, .weight = weight, .id = id, .payload = payload };
if (ps->len < ps->k) {
size_t idx = ps->len++;
ps->heap[idx] = entry;
sift_up(ps->heap, idx);
if (ps->len == ps->k) {
ps->threshold = ps->heap[0].rank;
}
} else {
ps->heap[0] = entry;
sift_down(ps->heap, ps->k, 0);
ps->threshold = ps->heap[0].rank;
}
}
typedef bool (*ps_predicate_fn)(const SketchEntry *entry, void *ctx);
double ps_estimate_subset(const PrioritySketch *restrict ps, ps_predicate_fn pred, void *ctx) {
double sum = 0.0;
if (ps->len < ps->k) {
for (size_t i = 0; i < ps->len; ++i) {
if (!pred || pred(&ps->heap[i], ctx)) sum += ps->heap[i].weight;
}
return sum;
}
double inv_tau = 1.0 / ps->heap[0].rank;
for (size_t i = 1; i < ps->k; ++i) {
if (!pred || pred(&ps->heap[i], ctx)) {
double w = ps->heap[i].weight;
sum += (w > inv_tau) ? w : inv_tau;
}
}
return sum;
}4.4 Complexity & Memory Profile#
- Memory Usage: bytes. For , total memory is , fitting inside standard CPU L1 data caches.
- Stream Update Throughput: (Branch-light fast rejection path).
- Heap Replacements: Expected count is . For items and , fewer than items trigger heap re-sorting.