Problem

Let d∈N+d \in \mathbb{N}^+ be a positive integer and f=(0,…,0)∈Rdf = (0, \ldots, 0) \in \mathbb{R}^d be an all-zero vector. There is a sequence SS of updates, each of which consists of a pair (x,w)∈[d]×R+(x, w) \in [d] \times \mathbb{R}^+, increasing f[x]f[x] by ww.

Model Description

  1. f←(0,…,0)∈Rdf \leftarrow (0, \ldots, 0) \in \mathbb{R}^d;
  2. For (x,w)∈S(x, w) \in S do
  3. \qquad f[x]←f[x]+wf[x] \leftarrow f[x] + w;

After witnessing the stream SS, we call ff the frequency vector. We are interested in answering the following query.

Rank Query
Input: x∈[d]x \in [d]
Returen: r[x]←∑i=1x−1f[i]r[x] \leftarrow \sum_{i = 1}^{x - 1} f[i].

We formally define the problem of approximate rank estimation.

Problem. Let ϵ\epsilon be an error parameter. Design an algorithm, such that for a rank query with a given element x∈[d]x \in [d], the summary will return an approximate rank r^[x]\hat r [x], such that

r[x]−ϵ⋅W≤r^[x]≤r[x]+ϵ⋅W. r[x] − \epsilon \cdot W \le \hat r [x] \le r[x] + \epsilon \cdot W.

QQ-Digest

The naive solution maintain the vector f∈Rdf \in \mathbb{R}^d explicitly. Assuming that each entry of ff can always be stored in one word, the space usage of the is O(d)O(d).

Theorem. There exists an algorithm, QQ-digest, that has space usage of O(1ϵlog⁡d)O( \frac{1}{\epsilon} \log d) words, insertion time O(log⁡d)O(\log d) and query time of O(log⁡d)O(\log d).

We first study a prototype which requires O(d)O(d) words.

Prototype

The algorithm maintains an array of size dd to keep all values of ff. Further, it construct a binary tree on the array, such that

  1. Each node vv keeps the sum of frequencies of leaves in its subtree, denote as s[v]s[v].

By definition, if vv is a leaf node, then s[v]s[v] equals the frequency of the node. For each node vv, denote L[v]L[v] and R[v]R[v] its left and right children respectively.

The figure below is an example with d=8d = 8 and f=(0,1,1,0,2,1,0,1)f = (0, 1, 1, 0, 2, 1, 0, 1).

Insertion

To construct the tree, we can initialize a tree built on the domain [d][d] with all counters s[v]s[v] equal to 00. Then on each pair of update (x,w)∈S(x, w) \in S, we invoke the following algorithm.

Update: (x,w)(x, w)

  1. v←v \leftarrow root;
  2. while vv is not a leaf do
  3. \qquad s[v]←s[v]+ws[v] \leftarrow s[v] + w;
  4. \qquad if xx is in the left tree of vv then
  5. \qquad \qquad v←L[v]v \leftarrow L[v]
  6. \qquad else
  7. \qquad \qquad v←R[v]v \leftarrow R[v]
  8. s[v]←s[v]+ws[v] \leftarrow s[v] + w;

The update takes time O(log⁡d)O(\log d).

Query

The query algorithm is also very simple. Given an input xx, the algorithm walks down the path from the root to the leaf that contains xx. The rank of xx equals the sum of of s[v]s[v] for the vv such that

  1. it is a left child of some node on the path;
  2. it is not on the path.

Rank Query
Input: x∈[d]x \in [d]

  1. v←rootv \leftarrow root;
  2. r^←0\hat r \leftarrow 0;
  3. while vv is not a leaf node do
  4. \qquad if xx is in left subtree of vv then
  5. \qquad \qquad v←L[v]v \leftarrow L[v]
  6. \qquadelse
  7. \qquad \qquad r^←r^+s[L[v]]\hat r \leftarrow \hat r + s[ L[v] ];
  8. \qquad \qquad v←R[v]v \leftarrow R[v]
  9. return r^\hat r

The query algorithm has time complexity O(log⁡d)O(\log d).

QQ-Digest

In this section, we show how to reduce the space usage to O(1ϵlog⁡d)O(\frac{1}{\epsilon} \log d).

In the previous section, when the algorithm perform a rank query for x∈[d]x \in [d], it walks down a path (v1,…,vlog⁡d)(v_1, \ldots, v_{\log d} ), where v1v_1 is the root of the tree, and vlog⁡dv_{\log d} is the leaf node that contains xx.

There are log⁡d\log d nodes in the path in total. If each node introduces an error of O(ϵW/log⁡d)O(\epsilon W / \log d) to the estimation of r[x]r[x], then the final estimation has error bounded by O(ϵW)O(\epsilon W).

This motivates to associate each node vv in the tree with an error counter c[v]c[v], which is interpreted as the maximum error it can introduce to the rank estimation to a given element xx. It is initialized with 00 and is guaranteed to be bounded by some threshold CC (except the leaf node). The threshold CC is updated along with the insertion of elements into the tree. The algorithm maintains the invariant that

ϵ⋅W2log⁡d≤C≤ϵ⋅Wlog⁡d. \frac{\epsilon \cdot W}{2 \log d} \le C \le \frac{\epsilon \cdot W}{\log d}.

Under this invariant, the estimation of r[x]r[x] is always bounded by ϵ⋅W\epsilon \cdot W.

Insertion

The insertion algorithm is very simple. When an update (x,w)(x, w) arrives, the algorithm walks through the path from the root to the leaf that contains xx. For each internal node vv in the path, the amount of additional error it can make is C−c[v]C - c[v]. If this amount is greater than ww, then we just increase c[v]c[v] by ww, and stops the algorithm. Intuitively, we insert xx into this node. On the other hand, if C−c[v]C - c[v] is less than ww, then we can still partial insert xx into vv, detailed by the pseudo-code below. Doing so increase the rank estimation error only for the element greater than xx, but not for the elements less than or equal to xx. Finally, if the invariant of CC does not hold, we need to update CC and the tree.

Update: (x,w)(x, w)

  1. W←W+wW \leftarrow W + w;
  2. v←v \leftarrow root;
  3. while w>0w > 0 and vv is not a leaf do
  4. \qquad s[v]←s[v]+ws[v] \leftarrow s[v] + w;
  5. \qquad t←min⁡{C−c[v],w}t \leftarrow \min\{ C - c[v], w\};
  6. \qquad c[v]←c[v]+tc[v] \leftarrow c[v] + t;
  7. \qquad w←w−tw \leftarrow w - t;
  8. \qquad if xx is in the left tree of vv then
  9. \qquad \qquad v←L[v]v \leftarrow L[v]
  10. \qquad else
  11. \qquad \qquad v←R[v]v \leftarrow R[v]
  12. s[v]←s[v]+ws[v] \leftarrow s[v] + w;
  13. c[v]←c[v]+wc[v] \leftarrow c[v] + w;
  14. if C≤ϵ⋅W2log⁡dC \le \frac{\epsilon \cdot W}{2 \log d} then
  15. \qquad C←ϵ⋅Wlog⁡dC \leftarrow \frac{\epsilon \cdot W}{\log d};
  16. \qquad Compress(root, 0) // update the tree

Query

The query algorithm is the same as the one in the previous section, except that if algorithm encounters a node vv with c[v]=0c[v] = 0, then the algorithm can terminate earlier.

Maintenance

Finally, we discuss how to update the tree in a top-down approach. For a node vv, we try to increase c[v]c[v] to CC, by borrowing weights from the its descendent. There are two possible cases. First, if s[v]≤Cs[v] \le C, then we can set c[v]=Cc[v] = C and delete all descendants of vv. Otherwise, setting c[v]=Cc[v] = C incurs a "debt" of d≐C−c[v]d \doteq C - c[v] that vv's descendants need to pay. We assign as much debt as possible to the left child.

Compress (v,d)(v,d)

  1. if c[v]=0c[v] = 0 then return;
  2. if v is a leaf node then
  3. \qquad c[v]←c[v]−dc[v] \leftarrow c[v] − d
  4. else
  5. \qquad if s[v]>C+ds[v] > C + d then
  6. \qquad \qquad d←d+C−c[v]d \leftarrow d + C - c[v];
  7. \qquad \qquad c[v]←Cc[v] \leftarrow C;
  8. \qquad \qquad u←L[v]u \leftarrow L[v];
  9. \qquad \qquad Compress (L[v],min⁡(d,s[u]))(L[v], \min(d , s[u] ) );
  10. \qquad \qquad Compress (R[v],max⁡(d−s[u],0))(R[v], \max(d − s[u] , 0 ) );
  11. \qquad else
  12. \qquad \qquad c[v]←s[v]−dc[v] \leftarrow s[v] − d;
  13. \qquad \qquad Set weight of all descendants of vv to 00;

By induction, we can show that d≤s[v]d \le s[v].

Space Usage

A node is called "saturated" if c[v]=Cc[v] = C. By the invariant of CC, there are at most W/C=O((1/ϵ)log⁡d)W / C = O((1 / \epsilon ) \log d) "saturated" nodes. By the algorithm, each node vv in the tree with nonzero c[v]c[v], either "saturated", or has a "saturated" parent. However, each "saturated" can have at most two children. Therefore, the total number of nodes with nonzero c[v]c[v] is bounded by O((1/ϵ)log⁡d)O((1 / \epsilon ) \log d).

Reference

[1] G. Cormode and K. Yi, Small Summaries for Big Data, 1st ed. Cambridge University Press, 2020.