Abstract 1 Introduction 2 Preliminaries 3 Approximating LZ77 via PFP 4 Implementation and Engineering 5 Evaluation 6 Conclusions and Outlook References

Efficient Large-Scale Text Precompression via Approximate LZ77 Parsings

Patrick Dinklage ORCID TU Dortmund University, Germany
Abstract

The LZ77 [Lempel and Ziv, 1977] compression scheme is ubiquitous: it lies at the core of everyday general-purpose standard compressors such as gzip or zstd, but also behind the scenes of many applications such as the compression of payloads transmitted in networks.

Computing the exact LZ77 parsing is largely solved in theory: it can be done in sublinear time and space, in compressed space and in external memory, to name but some scenarios. However, these approaches are often impractical for everyday use due to their intensive time or space requirements. Standard compressors tackle this issue by introducing heuristics that go hand in hand with sophisticated encoding schemes to achieve very good compression fast and in small space, however, they only have a local view (e.g., a sliding window) on the input, potentially missing out on long-range repetitions that may be located far apart from one another.

In this work, we design and implement – in C++ and leveraging shared-memory parallelism – compression pipelines that first precompress the input using an approximate LZ77 parsing taking care of long-range repetitions. This then serves as an assist to standard compressors for producing a succinct encoding of the remaining short and local repetitions. Similar approaches have been considered by [Kosolobov et al., 2020] and [Nalbach, 2024], respectively using Relative Lempel Ziv [Kuruppu et al. 2010] or the string synchronizing set [Kempa & Kociumaka, 2019].

We fill a gap taking the route via the prefix-free parsing [Boucher et al., 2019], using an intermediate result of [Hong et al., 2023]. On large repetitive inputs of tens of gigabytes, our pipelines are orders of magnitudes faster than the state of the art for computing the exact LZ77 parsing, use space less than the input size and still – despite producing more phrases – achieve the best overall compression in comparison to related work.

Keywords and phrases:
compression, algorithm engineering, parallel computation
Funding:
Patrick Dinklage: Funded by the Deutsche Forschungsgemeinschaft (DFG) under the Research Grants programme (project No. 501086801).
Copyright and License:
[Uncaptioned image] © Patrick Dinklage; licensed under Creative Commons License CC-BY 4.0
2012 ACM Subject Classification:
Theory of computation Data compression
; Theory of computation Pattern matching ; Theory of computation Shared memory algorithms
Supplementary Material:
Software  (Source Code): https://github.com/pdinklag/alz
  archived at Software Heritage Logo swh:1:dir:3c8c4ad7ec6e2415cfac8ae0eae41aa3cf5cf4f1
Editors:
Martin Aumüller and Irene Finocchi

1 Introduction

Lempel-Ziv 77 (LZ77) [46] is a fundamental compression scheme that lies at the core of countless compression utilities and is thus ubiquitous in modern computer systems, be it in popular tools such as gzip or the more modern zstd, or behind the scenes in the compression of payloads transmitted from web servers to clients (e.g., Brotli or lz4). However, out of practical requirements and limitations, theory and practice differ greatly.

In theory, the LZ77 parsing can be computed in time and space sublinear in the input size [13, 29] (assuming that multiple characters can be packed into a computer word). When compressing very large files, however, it may be prohibitive to construct the required data structures indexing the entire input. Policriti and Prezza [43] gave an algorithm to compute LZ77 in compressed space 𝒪(r), where r denotes the number of runs in the Burrows-Wheeler transform [7] of S, a well-established measure of compressibility. However, this requires superlinear time. A way to greatly reduce memory usage is doing compression in external memory as proposed by Kärkkäinen et al. [26]. While this, indeed, enables very large-scale compression even on low-memory systems, the detours required to optimize the number of I/O operations result in ultimately impractical running times.

From everyday compression utilities, users expect fast processing even of (reasonably) large inputs, and at the same time a compressed output that is small as possible. Standard compressors, like those named earlier, implement heuristics that yield a reasonable trade-off between those three criteria: running time, memory usage and compression ratio. Apart from sophisticated encoding schemes, to the best of our knowledge, all of them share one common idea that ages back to the original work of Lempel and Ziv themselves: instead of processing the input as a whole, they only consider a window of some controllable and manageable size. While this enables respecting any memory limitation (only information about the window needs to be stored) and improves speed (only the window is considered, independent windows can be processed in parallel), this comes at the cost of being unable to find long-range repetitions that are located possibly far apart from one another in the input.

This constitutes the motivation behind our work: is there a way of getting a global view of the input without fully indexing or even loading it, and fast? Dinklage et al. [12] considered this scenario and found a solution based on heavy hitters, giving an algorithm to maintain – approximately and in online fashion – the k most frequent patterns that occur in the (entire) input while streaming and compressing it for some k. This is combined with the classic approach of blockwise compression of the input in an attempt to get the best out of both worlds. A limitation of their approach, however, is that this favors short repetitions.

In this work, we conceptually separate the problem. We consider the handling of short and local repetitions solved and focus instead on finding long-range repetitions as a means of precompression. The key to our approach is an intermediate result of Hong et al. [22], who compute the LZ77 parsing using a prefix-free parsing (PFP) [6] of the input. If S is repetitive, then the PFP is much smaller than the input, allowing them to compute the parsing even for very large inputs. For general-purpose compression, however, this is impractically slow.

We ask the question: is it necessary to compute the exact LZ77 parsing to achieve competitive compression? To answer this, one could look at approximations, and several approximations of LZ77 have been proposed in the literature: Fischer et al. [15] give algorithms to compute a 2- and (1+ϵ)-approximation, respectively, Bille et al. [4] give an (1+ϵ)-approximation of rightmost LZ77 and the LZ-End scheme due to Kreft and Navarro [33] has been shown to approximate LZ77 [23, 30, 16].

Here, as previously stated, we do not care so much about approximating LZ77 with guarantees, but are more interested in practically efficient precompression. Two recent works also fit this description and are similar to ours. Nalbach [38] implemented a variant of Ellert’s 3-approximation of LZ77 [13] based on the string synchronizing set [28]. However, their approach requires the input to be loaded fully into memory. Kosolobov et al. propose ReLZ [32], using Relative Lempel-Ziv [34] to the LZ77 parsing. To address potential memory limitations, their algorithm can run several recursive rounds of precompression in external memory to then compute an approximate LZ77 parsing in main memory.

Our Contributions

We design and implement – in C++ and using shared-memory parallelism – a pipeline that computes an approximation of LZ77 based on the PFP. We achieve competitive running times and, based on the repetitiveness of the input, use space possibly smaller than the input itself. For a succinct encoding, we then pass the (deliberately naïvely encoded) output to a standard compressor to form a symbiosis: precompression takes care of finding long-range repetitions in the input – a discipline at which standard compressors typically fail due to having only a local view on the input. They are then, however, used for what they excel at: capturing and succinctly encoding remaining local repetitions.

2 Preliminaries

We argue about running times in the word RAM model of computation [19], where we model the memory as a sequence of words of size ω=Ω(lgn) bits each, where n is the size of the problem at hand. Arithmetic and similar operations on words can be done in constant time. By default, we give logarithms to the base of two.

Let SΣn be a string of length n over an integer alphabet Σ with |Σ|<2ω. For some i[1,n], we denote by S[i] the i-th character of S. Given additionally some j[i,n], we denote by S[i..j] the substring S[i]S[j], where juxtaposition of strings means their concatenation. For clarity, we sometimes use the explicit operator to concatenate strings or write i=1NSi to denote the concatenation of N strings S1,,SN.

VByte Code

Let x be a natural number that we want to store in ω-bit words. If lgxω, we can naturally store x in exactly one word. For larger x, we can use the following variable-length code to store x in lgxω1 words. Let (x)2{0,1} denote the binary representation of x. We partition (x)2 into blocks of size ω1. In case |(x)2| does not divide ω1, we pad the final block with 0-bits. For every block from least to highest significance, we now store a word that contains the ω1 bits of the respective block, as well as an extra bit that indicates whether the block is the last block. As an example, we can encode the number 317 with |(317)2|=|101110011|=9 using 4-bit words as 0¯0110¯1101¯101 (indicator bits underlined).

This code is commonly referred to as the VByte code and used in contexts where larger numbers are to be encoded using a variable number of bytes (8-bit words).

Rabin-Karp Fingerprinting

Let b|Σ| be an integer (the base) chosen uniformly at random and q a prime. We call

ϕ(S)=(i=0|S|1S[i]b|S|1i)modq

the fingerprint of S [27]. Let w be a window size and let Sw,i=S[i..i+w1] denote the length-w substring (window) of S starting at some position i[1,nw]. It is well known that from ϕ(Sw,i) and with access on S, we can compute ϕ(Sw,i+1) in constant time. This is commonly referred to as rolling hashing.

Prefix-Free Parsing

Figure 1: Prefix-free parsing of an example string S of length n=32 for w=2. The fingerprint function ϕ and sampling parameter s are chosen so that aa and bb are trigger strings; their occurrences in S are underlined. The parsing P=X1X2X3X4X5X4X5X3X2X2X4 of length m=11 consists of the distinct metacharacters X1=abb, X2=bbabb, X3=bbababb, X4=bbaa and X5=aabb. The size of the dictionary is |𝒟|=|X1|+|X2|+|X3|+|X4|+|X5|=23.

The prefix-free parsing (PFP) due to Boucher et al. [6] of S is a parsing of S into overlapping metacharacters as follows. Let s>1 be an integer sampling parameter. We call a window Sw,i a trigger string if ϕ(Sw,i)0(mods) or either i=1 or i+w=n. Let t1,,tm+1[1,nw] denote the starting positions of all trigger strings of S. The prefix-free parsing P is the sequence X1Xm of metacharacters with Xj:=S[tj..tj+1+w1] for every j[1,m]. Intuitively, a metacharacter begins and ends with a trigger string. This gives us the eponymous property that the set 𝒳={X1,,Xm} of distinct metacharacters is prefix-free. In the PFP, two neighbouring metacharacters share one common trigger string (the suffix of one is the prefix of the next). Figure 1 shows an example.

Fingerprinting using a uniformly random base gives us mn/s in expectation. We define the dictionary as the concatenation 𝒟=X𝒳X of the distinct metacharacters. P can be represented conceptually as pointers into 𝒟. We can thus store P and 𝒟 in 𝒪(|𝒟|lg|Σ|+nslg|𝒟|) bits of space in expectation. If S is repetitive and s is aptly chosen, then this can be much less than storing S in its uncompressed form (e.g., nlg|Σ| bits).

Lempel-Ziv 77 Parsing

The Lempel-Ziv 77 (LZ77) parsing [46] of S is a factorization of S into zn phrases f1,,fz such that f1fz=T. The i-th phrase fi is either the first occurrence of some character α=fi in S, or it is the longest possible prefix of S[|f1fi1|..n1] that occurs in S[0..|f1fi|1]. If fi is a single character, we call it a literal phrase, otherwise we call it a copy phrase, because we can encode it as an instruction for the decoder to copy its contents from an already decoded occurrence.

We use the representation introduced by Storer and Szymanski [45]: if fi=α is a literal phrase, we represent it simply by the character α. If fi is a copy phrase, we represent it as the tuple (s,), where s<|f1fi1| is the source position (the starting position of the previous occurrence in S that we refer to) and =|fi|>1 is the length of the phrase.

To compute the LZ77 parsing, Kärkkäinen et al. [25] gave the classic KKP algorithms that use the suffix array of S (a listing of all the suffixes of S in lexicographic order [36]) and auxiliary data, which can be computed in linear time and space [39].

3 Approximating LZ77 via PFP

In this section, we give a brief overview of our LZ77 approximation before switching to a much more practical perspective in the following Section 4.

Given the input string SΣn, we first compute the PFP parsing P𝒳m of S. Let lexrank:𝒳[1,|𝒳|] be a function assigning to each metacharacter its lexicographic rank (in ascending order) among all metacharacters in 𝒳, which we can obtain by sorting. Then, let P=i=1mlexrank(P[i])[1,𝒳]m be the PFP represented using these ranks. We now compute the suffix array of P and its inverse and use these to compute the LZ77 parsing of P, all in time and space 𝒪(m). Thanks to the preliminary lexicographic sorting of metacharacters, the suffix array of P is a sparse suffix array of S.

Metacharacter lex. rank
X1=abb 2
X2=bbabb 5
X3=bbababb 4
X4=bbaa 3
X5=aabb 1
Figure 2: Computing the approximate LZ77 parsing of the text from Figure 1. We transform P to P using the lexicographic ranks of the metacharacters that we list in the table. The LZ77 parsing of P consists of 5 copy phrases indicated by the underlines and arrows of different colors. At the arrows, we write the tuples (j,), where j is the (absolute) position we copy from, and is the number of metacharacters we copy. When we translate these copy phrases to the corresponding positions in S, due to overlaps of metacharacters and due to left and right extensions (indicated by dotted lines), we may end up with overlapping copy phrases. This is handled by the encoder, which greedily chops off the affected parts as visualized by the dashed vertical lines. The resulting approximate parsing is then abbabbababba(9,8)(8,5)(2,6)a (16 phrases).

As a consequence, copy phrases in the LZ77 parsing of P correspond to valid copy phrases in S. This is where we diverge from the work of Hong et al. [22], who proceed to compute the exact LZ77 parsing of S. We, instead, (1) translate copy phrases of P to the corresponding copy phrases in S and then (2) extend these to the left and right as far as possible. This is visualized in Figure 2. It is important to see that we do not need random access to S in order to do the left and right extensions; access to P and 𝒟 suffices. However, it is as important to note that we rely on the fact that these fit into memory, and thus implicitly on the fact that S is repetitive. We visualize our compression algorithm in Figure 2.

4 Implementation and Engineering

We break down the process described in the previous section into the following steps:

  1. 1.

    Compute a preliminary parsing by identifying all trigger strings and computing the fingerprints of the resulting metacharacters.

  2. 2.

    Find the set of distinct metacharacters and compute the actual PFP.

  3. 3.

    Load the dictionary 𝒟 into RAM and sort the metacharacters lexicographically.

  4. 4.

    Factorize the input via the LZ77 parsing of the PFP.

  5. 5.

    Encode the approximate LZ77 parsing.

We assume that random access to S is forbidden, which may be a requirement when processing a very large file. This makes the explicit step to load the dictionary (step 3) necessary. In section 4.6, we address potential advantages in the case that the whole input fits into RAM. In the following, we elaborate on each of the steps and give insight into our engineering. We use p to denote the number of threads.

4.1 Blockwise and Parallel Preliminary Parsing

We process S blockwise using block size B=𝒪(n/s) such that a block fits into RAM. Each block is further partitioned into p workloads, i.e., one per thread.

Every thread scans its workload using a sliding window of length w to identify trigger strings. To enable fast modulus operations for computing fingerprints and for identifying trigger strings, we (1) require q, the prime for computing Karp-Rabin fingerprints, be a Mersenne prime (one less than a power of two) and (2) require s, the sampling parameter for trigger string detection, be a power of two. For every input character, we update two fingerprints. We maintain a rolling trigger fingerprint for the sliding window to identify a trigger string. We chose to represent these in 32 bits, as these are very fast to compute and fingerprint collisions can safely be ignored for this use case. For this, we choose the prime qtrigger:=2311, the largest Mersenne prime to fit into a 32-bit word.

Furthermore, we maintain a metacharacter fingerprint that represents a metacharacter – from the beginning of the last trigger string until the end of the next – in later steps. We do care about collisions here: Collisions of metacharacter fingerprints will cause different metacharacters to be treated as being equal, ultimately leading to an output that cannot be decoded back to the original input (our implementation is Monte Carlo and produces correct results only w.h.p.). Thus, we represent these as 64-bit integers using the Mersenne prime qmeta:=2611, which is the largest to fit into 64 bits and large enough to lower the chance of collisions to the point where we never observed any in practice111If the fingerprint base is a power of two (e.g., 256, a common choice for byte alphabets), then modular arithmetics with the Mersenne prime 2611 (a sequence of 61 set bits, the most significant byte being 31), in combination with the ASCII encoding of capital versus small letters (e.g. ASCII(A)ASCII(a)=32), cause a clustering of fingerprint collisions for ASCII-encoded text. This may seem surprising, but follows directly from the definitions. As an example, consider the strings The␣Work and the␣Worl, which have the same fingerprint modq with base 256. We therefore enforce the base is not a power of two. .

Special care needs to be taken at block boundaries as well as workload boundaries. Simply cutting off metacharacters at these boundaries would be harmful to our goal of compressing S, thus each thread may scan beyond its own workload until it finds the next trigger string (𝒪(s) extra steps in expectation). At a block boundary, the last thread leaves information (starting position and fingerprint of the current metacharacter’s prefix up to the boundary) for the first workload of the next block to smoothly detect metacharacters across blocks.

The result of this step is a preliminary parsing that consists of pn/B sequences of metacharacters defined by their starting positions, lengths (including the trigger strings at the borders) and metacharacter fingerprints. Because the threads do not communicate, there has been no deduplication regarding equal metacharacters yet. We address this next.

4.2 Finding Distinct Metacharacters and Computing the Parsing

The next step is finding the set 𝒳 of distinct metacharacters. At the same time, we want to produce the parsing P as a sequence of pointers into 𝒳, preferably represented as integers from [1,|𝒳|]. To solve this in parallel, we looked at a similar problem from a preliminary step of constructing wavelet trees, which we can formalize as follows.

Given a sequence 𝒮=s1snN of some length N, we want to

  1. 1.

    identify the smallest set X such that 𝒮XN (effective alphabet),

  2. 2.

    precompute a bijective function f:X[1,|X|] (effective mapping) and then

  3. 3.

    produce the sequence f(S)=f(s1)f(sn)[1,|X|]N (effective transform).

To make use of multiple threads, we borrow the solution from [10, Section 7] for distributed memory. We first partition the metacharacter sequences from the previous step in p parts and independently compute p hash tables representing the sets 𝒳i of distinct metacharacters for every part i[1,p] (in the hash tables, we map metacharacter fingerprints to their position of occurrence and length). We then merge the hash tables in a parallel all-reduce operation. Conceptually, this can be done using a merge tree of height 𝒪(lgp) [44]; in our implementation, we use the reduction framework of OpenMP [42] When merging two hash tables, we tie-break metacharacters with the same fingerprints by preferring the leftmost occurrence. The bijection f is computed in sequential, but that can already be done in time 𝒪(|𝒳|). The parsing P is then the effective transform, which can straightforwardly be computed in parallel by applying f to every metacharacter.

4.3 Loading and Sorting the Dictionary

We now load the dictionary 𝒟 from the input file which stores S. The size |𝒟| can be computed as a byproduct of the previous step so that we can accurately pre-allocate memory. Since we want to make no assumption about the storage (e.g., hard disk, SSD, NVMe), we load sequentially. First, we sort the metacharacters of 𝒳 by their starting position in S in parallel (using std::sort and the parallel execution policy), then we load them in this order to ensure sequential reads of the input file.

With 𝒟, we proceed to sort the metacharacters lexicographically in parallel, again using the C++ STL. (We are aware of sophisticated parallel string sorters (e.g., [5]), but decided not to put to use an external implementation for a task that accounts only for a tiny fraction of the total running time.) With this, we have effectively computed function lexrank from Section 3 and can compute P from P in parallel by partitioning P into equisized workloads.

4.4 Factorizing the Parsing

Before we can finally compute the approximate LZ77 parsing of S, we need to compute the LZ77 parsing of P. We first construct its suffix array using libsais [18], a highly engineered implementation of the SA-IS [39] algorithm that features integer alphabets (which we have in [1,𝒳|]). We do use libsais’ parallel implementation for OpenMP, but the author claims that it is essentially memory-bound and no noteworthy speedups are to be expected. (We are aware of Bertram et al.’s modification of GSACA [1], which features good parallel speedup [2]. In our experiments, however, it was still clearly outperformed by libsais). From the suffix array, we can compute the inverse in parallel.

We now partition P into p workloads. Every thread processes its workload using an algorithm similar to KKP [25] and produces a sequence of potential copy phrases for S. The differences to KKP are twofold: first, we work only using the suffix array and its inverse, which allows us to operate in parallel. We do not precompute next/previous smaller values or the LCP array, but compute the necessary values on the fly by scanning the suffix array or the parsing, respectively. Second, we inline the conversion of copy phrases as follows.

Assume that at some position i[1,m] we want to output a copy phrase (j,) with j<i. Note that i, j and pertain to the parsing P, i.e., metacharacters. We can translate i and j to their corresponding positions i and j in S using a mapping computed (and stored alongside P) as a byproduct of computing the parsing (Section 4.2). Furthermore, let =k=ii+1|Xk|, which we can compute directly from the parsing. By the fact that the suffix array of P is a sparse suffix array of S, the copy phrase (j,) indeed corresponds to a repeated occurrence of S[j..j+1] at position i in S.

Even though the set 𝒳 of metacharacters is prefix-free, it can occur that the prefix of one metacharacter is the suffix of another or vice versa even beyond the the trigger string that they may have in common. If this occurs at the beginning or the end of the copy phrase, we can greedily expand the copy phrase accordingly on the character level (as shown in the bottom part of Figure 2). The necessary accesses to S can be simulated via P and 𝒟 if we store a mapping from lexicographic rank to the corresponding entry in 𝒟, which we can compute as a byproduct when sorting the dictionary (Section 4.3).

It is important to note that around workload boundaries, because threads do not communicate, we may introduce multiple copy phrases replacing the same position. This will be handled in the following step.

4.5 Encoding

It remains to encode the copy phrases produced in the previous step. Again, we make no assumption about the storage (which may be a pipe) and thus do this in sequential. Because the workloads were assigned left to right and each thread processed its workload left to right, it holds that the copy phrases are in input order. We can thus simultaneously stream the input and the copy phrases and greedily encode a copy phrase whenever possible, or otherwise copy literal characters directly from the input. Overlapping copy phrases around workload boundaries in the previous step are truncated accordingly in the process. If more than one copy phrase is available starting at the current position, we greedily take one that maximizes the copy length (as shown in Figure 2).

We implement only a straightforward encoding where we keep uncompressed characters byte-aligned and encode copy phrases using VByte codes (a VByte code for the copy length followed by a VByte code for the copy source, given as the distance from the phrase’s starting position). The intention is that a downstream standard compressor (such as gzip) takes care of a succinct encoding. We consider two different ways of encoding strings of uncompressed characters. The straightforward approach is to collapse them: we first encode the number of uncompressed characters to be decoded, followed by the string. This works best for LZ-based encoders (e.g., gzip and zstd). Interestingly, for encoders based on block sorting (e.g., bzip2 and bsc), we achieved better overall compression by preceding every uncompressed character by a zero byte individually, generating higher redundancy.

4.6 Taking Advantage of Text Access

If the input S is sufficiently small to be loaded in RAM, we can allow random access on S. In this case, there is no need to process S blockwise in the first step (Section 4.1). Furthermore, we can skip loading the dictionary for sorting the metacharacters (Section 4.3), as we can instead use pointers into S. Lastly, because we no longer need to simulate accesses via P and 𝒟, but simplify left and right extension when computing copy phrases (Section 4.4) accessing S directly, potentially saving a number of cache misses.

5 Evaluation

We evaluate our implementation in several experiments that we present in the following sections after describing our setup.

5.1 Experimental Setup

Our experiments are conducted on a Ubuntu 24.04 system with two AMD EPYC 7452 CPUs (32/64x 2.35-3.35GHz, 2/16/128MB L1/2/3 cache) featuring up to 128 threads, and 1TB of RAM (3200 MT/s DDR4). All software is compiled using the GNU compiler collection (gcc) version 13.3.0 with flags for maximum optimization (-O3 -DNDEBUG -march=native).

Table 1: The input files used in our experiments. For each file, we give the size n, the number z of LZ77 phrases and the ratio n/z as a measure of repetitiveness.
File n z n/z
cere 439.92 MiB 1 700 630 271.24
einstein.de.txt 88.46 MiB 34 572 2683.05
einstein.en.txt 445.96 MiB 89 467 5226.80
Escherichia-Coli 107.47 MiB 2 078 512 54.22
influenza 147.64 MiB 769 286 201.24
kernel 246.01 MiB 793 915 324.92
para 409.38 MiB 2 332 657 184.02
world-leaders 44.79 MiB 175 740 267.26

File n z n/z sars.1Gi 1.00 GiB 838 628 1280.36 sars.2Gi 2.00 GiB 1 458 247 1472.65 sars.4Gi 4.00 GiB 2 698 538 1591.59 sars.8Gi 8.00 GiB 4 340 868 1978.85 sars.16Gi 16.00 GiB 7 505 550 2288.96 sars.32Gi 32.00 GiB 14 114 963 2434.28 sars 56.78 GiB 22 162 470 2750.94

Input Files

We do our experiments on two sets of input files. First, we use the Pizza & Chili repetitive corpus [14], a well-established input corpus in the field of compression benchmarks also used in related work [22, 38]. Second, denoted in the following by sars, we downloaded around 60 GB of nucleotide data of sars-COV-2 genomes (TaxID 2697049) in FASTA format from NCBI Virus [3]. It is the concatenation of all nucleotides released date between July 31, 2021 and December 31, 2021; it features low entropy (the alphabet is dominated by A,C,G,T) and high repetitiveness. We consider several prefixes of sars of increasing length. Table 1 lists relevant statistics for all input files that we use.

Competitors

Our C++ implementation of Section 4, called alz (approximate LZ77) in the following, is publicly available (see Supplementary Material). We compare it against the following competitors from related work:

  • pfp-lz77 [21, 22] – LZ77 parsing via the PFP. We leave the sampling rate to the default s=100, which is recommended regarding the time/space trade-off according to [6].

  • rle-lz77-o [40, 41] – LZ77 parsing in space 𝒪(r) via online RLBWT computation.

  • relz [31, 32] – approximate or exact LZ77 parsing using Relative Lempel-Ziv to recursively precompress the input. It computes the exact parsing if all data structures fit into the configured amount of memory right away (8000 MB by default, which we did not change).

  • ssszip [37, 38] – approximate LZ77 based on the string synchronizing set (with τ hardcoded to 512). By default, it encodes the result using zstd.

  • topk-lz77 [11, 12] – approximate LZ77 in constrained space based on maintaining frequent patterns. In preliminary experiments, we found that the space requirement is 58k+12B bytes, where k is the number of maintained frequent patterns and B is the block size. We set B and k such that at most n bytes (the input size) are used, and such that 75 % of the memory is used for blocks and the remaining 25 % for the sketch, which yields a good general trade-off. For large inputs, we limit B to 2311. Concretely, we set B:=min{n16,2311} and k:=n12B58.

(We conciously excluded KKP [25] from the experiments because only 32-bit implementations are available and the results are rather foreseeable. Indeed, in preliminary experiments on inputs <2 GiB, KKP2, the most space-efficient implementation, was faster than the competitors by at least an order of magnitude, but it requires 9n bytes of memory.)

(a) Relative running time for different sampling
parameters s using all available threads.
(b) Speedup for different numbers p of threads.
The sampling parameter was fixed to 26.
Figure 3: Breakdown of (a) the relative running time for the individual steps depending on the sampling rate and (b) the speedup depending on the number of threads; for alz on sars.

5.2 Impact of Sampling Rate and Multithreading

Before comparing alz to the competitors, we evaluate the impact of the main parameters to the running time of the individual steps of alz according to Section 4: the sampling parameter s that governs the size of the PFP (and the average length of a metacharacter), and the number p of available threads. We evaluate this on the largest possible input, which is sars (approximately 57 GiB). The results are shown in Figure 3.

We first look at Figure 3(a), showing the impact of the sampling parameter s on the relative running times of each step in relation to the total running time (later given and discussed in Section 5.3) using the maximum number of available threads (128). Recall that the parsing size is n/s in expectation; for sars, this holds almost precisely for all s. For small s, we see that computing the suffix array of the parsing dominates the running time (91 % for s=2), rendering the other steps nearly negligible except for computing the LZ77 parsing. This is the expected behaviour as the parsing approaches the size of the input. On the other extreme that we consider (s=29), suffix array construction takes even less time than loading the dictionary from disk or computing the set of trigger strings that later defines the parsing. Here, encoding becomes the dominating step taking about 42 % of the running time, which is also expected since we have fewer copy phrases and encoding is mostly reduced to Huffman-coding large uncompressed portions of the input.

Figure 3(b) shows the parallel speedup of the individual steps. Here, we fixed an exemplary sampling rate of s=26, which yields a reasonable balance of work to do in every step when using all threads, as seen in Figure 3(a). The preliminary parsing that detects and takes note of trigger strings benefits the most from multiple threads, which is expected given the linearity of the task (computing fingerprints and pushing metacharacter borders to a thread-private list). Still, at p=128, we only achieve a speedup of about 16, where it also appears to plateau, likely because of being memory-bound. Using a binary merge tree to find the set of distinct metacharacters appears to achieve the desired affect of logarithmic speedups up to p=12. The speedups for representing the parsing as lexicographic ranks (concurrent reading from a hash table) as well as computing the approximate LZ77 parsing (via suffix array and dictionary) are rather disappointing, but given the fact that these steps involve mostly random access, this is likely an indication that these steps are memory-bound. This is also the case for suffix sorting, confirming the statement of the author of (libsais) that no speedup can be expected. At the very least, computing the inverse suffix array does benefit somewhat from parallelization. Overall, we can reduce the running time of alz to almost a third using 32 threads; using more, however, does not yield much benefit. In a productive environment, it may be advisable to do some steps in sequential to reduce potential overhead coming from parallelization222We thank the anonymous reviewer for noting this. .

5.3 Performance Comparison

We now evaluate alz with different sampling parameters s against the competitors (pfp-lz77, relz and ssszip) regarding running time, peak memory usage and approximation of the LZ77 parsing for all input files. We set the block size for the preliminary parsing (Section 4.1) to B:=min{n/2,1 GiB} (and thus do not take advantage of Section 4.6). Recall how alz does not do any sophisticated encoding in itself but is designed to have its output encoded by a downstream successor (see Section 4.5). For a fair running time comparison, in this experiment, we configure ssszip to use pigz (parallel gzip) as its encoder with the -0 flag for no compression. The encodings of pfp-lz77 and relz are already straightforward: pfp-lz77 writes phrases as source and length pairs of logn bits each, while relz encodes these pairs using VByte codes. Similarly, rle-lz77-o encodes phrases of pairs of 32-bit integers333This does mean that the output of rle-lz77-o cannot be decoded for input files 2 GiB. We conjecture this to be an oversight in the implementation, but double-checked that it affects only the output. The entire prior computation of the parsing is done using 64-bit integers, i.e., the results are still meaningful. .

Figure 4 shows the average running times over three executions (except for pfp-lz77 and rle-lz77-o on inputs of size 16 GiB and up, where they required over 10 hours and were only executed once). Even for the rather impractical sampling rate of 2, alz is faster than pfp-lz77 by an order of magnitude on prefixes of sars. On the smaller inputs, pfp-lz77 is always slower when our sampling rate is 4 or higher. For different sampling rates, the running time of our implementation behaves consistently for almost every input as already seen in Figure 3(a) for sars. For very high sampling rates (s29), the encoding begins to dominate the overall running time (see Section 5.2) causing the alz to become slower in some cases (e.g., cere). For the large inputs, ssszip is about as fast as alz with a sampling rate of 25; for other inputs, this varies. Their repetitiveness seems to benefit relz, which becomes competitive only for the large inputs but runs relatively slowly for the smaller corpus. On all inputs, topk-lz77 is comparatively slow and rle-lz77-o is the slowest for most inputs as expected. Remarkably, however, rle-lz77-o outperforms pfp-lz77 on longer prefixes of sars despite using only a fraction of the memory.

Next, we look at peak memory consumption shown in Figure 5. For small sampling rates (s23), alz requires more memory than pfp-lz77, which is excessive considering that we only compute an approximation of LZ77, albeit much faster. The memory footprint becomes more reasonable at around s=27, where for many inputs, it approaches or falls below the size of the input. We see that our strategy to pre-parse blockwise and only load the dictionary into RAM pays off: The space is dominated by the size of the PFP and the dictionary. For suitable s, this allows us to work in memory less than the input size (e.g., sars for s26). However, it must be noted that this behaviour depends on the size of the dictionary, and hence ultimately on the repetitiveness of the input. In any event, we have an advantage over ssszip, which always fully loads the input into RAM. In relz, memory consumption is constrained to 8000 MB by making use of external memory if needed. This becomes apparent for the longer prefixes of sars. Unsurprisingly, rle-lz77-o uses the least memory in all instances as it works in compressed space. Furthermore, topk-lz77 always uses space somewhat less than the input size as configured.

Judging by the results thus far, it appears that s=26 is a good time/space trade-off for alz, yielding acceptable running times and memory consumption for all inputs.

Preparing to evaluate compression in the next section, we look at the approximation of the LZ77 parsing displayed in Figure 6. Already for s=24, the number of phrases produced by alz is more than ten times that of the exact LZ77 parsing (which is computed by pfp-lz77). This effect is somewhat diminishing: when doubling the sampling rate (e.g., 25), we do not double the number of phrases, which is also thanks to left and right extension of copy phrases as described in Section 4.4.

We recall that ssszip follows a similar strategy as our implementation. It uses the (hardcoded) parameter τ=512 for computing the string synchronizing set. This leads us to conjecture that the resulting parsing should be roughly comparable to alz using a sampling rate of s=512=29. In direct comparison to that, ssszip is significantly slower on most inputs and has a greater memory footprint. However, it appears to be better than alz at capturing long copy phrases, producing less phrases overall. As stated earlier, relz computes the exact LZ77 parsing for sufficiently small inputs and only approximates it if a memory threshold would be exceeded. This is the case, e.g., for the prefixes of sars. However, the approximation ratio clearly remains very small (1.45 for sars).

Figure 4: Running time required to (pre)compress the input files.
Figure 5: Peak memory consumption while (pre)compressing the input files. The dashed black lines indicate the size of the respective input file.
Figure 6: Number of phrases (literal and copy) produced for the input files. The dashed black lines indicate the (exact) number z of LZ77 phrases for the respective input file. Note that rle-lz77-o computes less than z phrases as there, every phrase contains also the following character. This is a common alternative way to define LZ77 and results in fewer phrases up to a constant factor.
Figure 7: Compression time and ratio for the competitors on all input files. The black dashed lines indicate the lowest compression ratio achieved for the corresponding file. Names following the | symbol denote the standard compressor used for encoding (e.g., alz|bsc means that the output of alz is encoded by bsc). Best viewed in color.

5.4 Compression Comparison

Given the results from the previous section, particularly regarding the number of produced phrases, we now get to the most interesting experiment that evaluates the compression ratio, which we define as the size of the output file divided by the size of the input file.

For this experiment, we use alz with sampling parameter s=26 and encode its output using the following the standard compressors zstd (Facebook’s ZStandard compressor [9]) and bsc (a highly engineered block-sorting compressor [17]). They are run with flags to achieve the best compression (i.e., -19 for zstd and -b2047e2 for bsc). We apply our observations from Section 4.5 and produce a slightly different intermediate encoding for the LZ-based compressor (zstd) and that based on sorting (bsc). Note that we considered further standard compressors (namely pigz, xz and bzip2) in preliminary experiments. They are omitted for the sake of readability of the plot because they were not competitive. Because the output of rle-lz77-o is correct only for inputs of size less than 2 GiB (see footnote 3) and the encoding is naïve, we omit it in this experiment as well.

By default, ssszip, uses zstd for encoding (we also pass -19 here for best compression). To level the playing field, we also plug in bsc as its encoder (passing also -b2047e2). Furthermore, we also apply zstd and bsc to the output of ReLZ. To argue about the usefulness of precompression via alz (or ssszip) in the first place, we also compress all files with just those standard compressors.

Figure 7 shows compression time versus compression ratio for all competitors and inputs. Our pipeline alz|bsc produces the smallest outputs for all files except influenza, where it is only marginally surpassed by zstd (albeit much slower) and bsc itself (on par). As expected, computing the exact LZ77 factorization is always a comparatively slow approach (pfp-lz77, but also relz for the files from the Pizza & Chili corpus). On the largest input, sars, we can clearly see that computing the exact parsing is completely impractical, pfp-lz77 taking several orders of magnitude longer than all other compressors.

Our conceptually close competitor ssszip yields varying results: encoding using bsc appears to be faster than the default zstd, but the compression ratio appears to be indifferent to that. On some inputs, ssszip is competitive (namely influenza, kernel and para) while on others, it is clearly slower than our alz pipelines (e.g., einstein.en.txt or sars). Similar observations can be made for relz and topk-lz77. While they achieve competitive compression, they are overall slower than alz.

6 Conclusions and Outlook

To restate our initial question: Is it necessary to compute the exact LZ77 parsing to achieve competitive compression? Our answer, based on our experiments, is no.

In fact, we go further than that: using an approximate LZ77 parsing, we can achieve running times several orders of magnitudes faster than the state of the art for exact LZ77 and still output smaller files than those compressed using exact LZ77. If the output is sufficiently repetitive (e.g., like sars), then we can do so using memory less than the input size.

Moreover, our idea of forming a symbiosis of a precompressor that takes care of long-range repetitiveness and a standard compressor that excels at capturing local repetitiveness has been implemented successfully. Our alz pipelines yield a competitive trade-off between compression time and ratio for large, repetitive inputs that is both faster and produces smaller output files than similar state-of-the-art implementations.

However, we found in preliminary experiments that alz fares much worse on inputs that are not repetitive on a large scale (e.g., a Wikipedia dump). In these cases, it may be more advisable, e.g., to run a standard compressor right away instead of wasting time on an attempt to precompress. It would be interesting to be able to, given a (potentially very large) input file, efficiently make a coarse but valid statement about its repetitiveness. While simple statistics such as the zeroth-order entropy do not suffice, the computation of known measures of repetitiveness (such as the number z of LZ77 phrases or the number r of runs in the Burrows-Wheeler transform) requires too many resources, both time and memory. An idea following immediately from this work could be to plug into the first step (Pre-Parse, see Section 4.1) an estimator for the cardinality of the set of distinct metacharacters. This could be done, e.g., via concurrent hashing (e.g., Intel TBB [24], growt [35] or techniques based on MPSC queues [8]) or cardinality estimating sketches (such as HyperLogLog [20]). If we find that the dictionary is sufficiently small, we can conclude that the input is repetitive and we continue. Otherwise, if we find the input to be not repetitive, we can cancel the precompression before wasting too much time.

References

  • [1] Uwe Baier. Linear-time suffix sorting-a new approach for suffix array construction. In 27th Annual Symposium on Combinatorial Pattern Matching (CPM), pages 23–1. Dagstuhl, 2016. doi:10.4230/LIPIcs.CPM.2016.23.
  • [2] Nico Bertram, Jonas Ellert, and Johannes Fischer. Lyndon words accelerate suffix sorting. In 29th European Symposium on Algorithms (ESA), pages 15–1. Dagstuhl, 2021. doi:10.4230/LIPIcs.ESA.2021.15.
  • [3] Bethesda (MD): National Library of Medicine (US), National Center for Biotechnology Information. NCBI Virus. https://www.ncbi.nlm.nih.gov/labs/virus/vssi/#/. Accessed April 14, 2026.
  • [4] Philip Bille, Patrick Hagge Cording, Johannes Fischer, and Inge Li Gørtz. Lempel-Ziv compression in a sliding window. In 28th Annual Symposium on Combinatorial Pattern Matching (CPM), volume 78 of LIPIcs, pages 15:1–15:11. Dagstuhl, 2017. doi:10.4230/LIPIcs.CPM.2017.15.
  • [5] Timo Bingmann, Andreas Eberle, and Peter Sanders. Engineering parallel string sorting. Algorithmica, 77(1):235–286, 2017. doi:10.1007/s00453-015-0071-1.
  • [6] Christina Boucher, Travis Gagie, Alan Kuhnle, Ben Langmead, Giovanni Manzini, and Taher Mun. Prefix-free parsing for building big BWTs. Algorithms Mol. Biol., 14(1):13:1–13:15, 2019. doi:10.1186/S13015-019-0148-5.
  • [7] Michael Burrows and David Wheeler. A block-sorting lossless data compression algorithm. Technical Report 124, Digital Equipment Corporation, 1994.
  • [8] Robert Clausecker, Florian Kurpicz, and Etienne Palanga. Practical parallel block tree construction: First results. CoRR (accepted at SEA 2026), abs/2512.23314, 2025. doi:10.48550/arXiv.2512.23314.
  • [9] Yann Collet and Murray S. Kucherawy. Zstandard compression and the ’application/zstd’ media type. RFC, 8878:1–45, 2021. doi:10.17487/RFC8878.
  • [10] Patrick Dinklage, Jonas Ellert, Johannes Fischer, Florian Kurpicz, and Marvin Löbel. Practical wavelet tree construction. Journal of Experimental Algorithms, 26:1.8:1–1.8:67, 2021. doi:10.1145/3457197.
  • [11] Patrick Dinklage, Johannes Fischer, and Nicola Prezza. top-k-compress. https://github.com/pdinklag/top-k-compress. Accessed April 14, 2026.
  • [12] Patrick Dinklage, Johannes Fischer, and Nicola Prezza. Top-k frequent patterns in streams and parameterized-space LZ compression. In 22nd International Symposium on Experimental Algorithms (SEA), volume 301 of LIPIcs, pages 9:1–9:20. Dagstuhl, 2024. doi:10.4230/LIPIcs.SEA.2024.9.
  • [13] Jonas Ellert. Sublinear time Lempel-Ziv (LZ77) factorization. In 30th International Symposium on String Processing and Information Retrieval (SPIRE), volume 14240 of Lecture Notes in Computer Science, pages 171–187. Springer, 2023. doi:10.1007/978-3-031-43980-3_14.
  • [14] Paolo Ferragina and Gonzalo Navarro. Pizza & Chili corpus – compressed indexes and their testbeds. http://pizzachili.dcc.uchile.cl/texts.html. Accessed April 14, 2026.
  • [15] Johannes Fischer, Travis Gagie, Pawel Gawrychowski, and Tomasz Kociumaka. Approximating LZ77 via small-space multiple-pattern matching. In 23rd European Symposium on Algorithms (ESA), volume 9294, pages 533–544. Springer, 2015. doi:10.1007/978-3-662-48350-3_45.
  • [16] Pawel Gawrychowski, Maria Kosche, and Florin Manea. On the number of factors in the LZ-End factorization. In 30th International Symposium on String Processing and Information Retrieval (SPIRE), volume 14240 of Lecture Notes in Computer Science, pages 253–259. Springer, 2023. doi:10.1007/978-3-031-43980-3_20.
  • [17] Ilya Grebnov. libbsc. https://github.com/IlyaGrebnov/libbsc. Accessed April 14, 2026.
  • [18] Ilya Grebnov. libsais. https://github.com/IlyaGrebnov/libsais. Accessed April 14, 2026.
  • [19] Torben Hagerup. Sorting and searching on the word RAM. In 15th Annual Symposium on Theoretical Aspects of Computer Science (STACS), volume 1373 of Lecture Notes in Computer Science, pages 366–398. Springer, 1998. doi:10.1007/BFb0028575.
  • [20] Stefan Heule, Marc Nunkesser, and Alexander Hall. HyperLogLog in practice: algorithmic engineering of a state of the art cardinality estimation algorithm. In Joint 2013 EDBT/ICDT Conferences, pages 683–692. ACM, 2013. doi:10.1145/2452376.2452456.
  • [21] Aaron Hong, Massimiliano Rossi, and Christina Boucher. PFP_LZ77. https://github.com/AaronHong1024/PFP_LZ77. Accessed April 14, 2026.
  • [22] Aaron Hong, Massimiliano Rossi, and Christina Boucher. LZ77 via prefix-free parsing. In 25th Workshop on Algorithm Engineering and Experiments (ALENEX), pages 123–134. SIAM, 2023. doi:10.1137/1.9781611977561.CH11.
  • [23] Takumi Ideue, Takuya Mieno, Mitsuru Funakoshi, Yuto Nakashima, Shunsuke Inenaga, and Masayuki Takeda. On the approximation ratio of LZ-End to LZ77. In 28th International Symposium on String Processing and Information Retrieval (SPIRE), volume 12944 of Lecture Notes in Computer Science, pages 114–126. Springer, 2021. doi:10.1007/978-3-030-86692-1_10.
  • [24] Intel Corporation. Intel®oneAPI Threading Building Blocks. https://www.intel.com/content/www/us/en/developer/tools/oneapi/onetbb.html. Accessed April 14, 2026.
  • [25] Juha Kärkkäinen, Dominik Kempa, and Simon J. Puglisi. Linear time Lempel-Ziv factorization: Simple, fast, small. In 24th Annual Symposium on Combinatorial Pattern Matching (CPM), volume 7922 of Lecture Notes in Computer Science, pages 189–200. Springer, 2013. doi:10.1007/978-3-642-38905-4_19.
  • [26] Juha Kärkkäinen, Dominik Kempa, and Simon J. Puglisi. Lempel-Ziv parsing in external memory. In 2014 Data Compression Conference (DCC), pages 153–162. IEEE, 2014. doi:10.1109/DCC.2014.78.
  • [27] Richard M. Karp and Michael O. Rabin. Efficient randomized pattern-matching algorithms. IBM J. Res. Dev., 31(2):249–260, 1987. doi:10.1147/rd.312.0249.
  • [28] Dominik Kempa and Tomasz Kociumaka. String synchronizing sets: sublinear-time BWT construction and optimal LCE data structure. In 51st Annual ACM Symposium on Theory of Computing (STOC), pages 756–767. ACM, 2019. doi:10.1145/3313276.3316368.
  • [29] Dominik Kempa and Tomasz Kociumaka. Lempel-Ziv (LZ77) factorization in sublinear time. In 65th Symposium on Foundations of Computer Science (FOCS), pages 2045–2055. IEEE, 2024. doi:10.1109/FOCS61266.2024.00122.
  • [30] Dominik Kempa and Barna Saha. An upper bound and linear-space queries on the LZ-End parsing. In ACM-SIAM Symposium on Discrete Algorithms (SODA), pages 2847–2866. SIAM, 2022. doi:10.1137/1.9781611977073.111.
  • [31] Dmitry Kosolobov, Daniel Valenzuela, Gonzalo Navarro, and Simon J. Puglisi. ReLZ. https://gitlab.com/dvalenzu/ReLZ. Accessed April 14, 2026.
  • [32] Dmitry Kosolobov, Daniel Valenzuela, Gonzalo Navarro, and Simon J. Puglisi. Lempel-Ziv-like parsing in small space. Algorithmica, 82(11):3195–3215, 2020. doi:10.1007/S00453-020-00722-6.
  • [33] Sebastian Kreft and Gonzalo Navarro. LZ77-like compression with fast random access. In 2010 Data Compression Conference (DCC), pages 239–248. IEEE, 2010. doi:10.1109/DCC.2010.29.
  • [34] Shanika Kuruppu, Simon J. Puglisi, and Justin Zobel. Relative Lempel-Ziv compression of genomes for large-scale storage and retrieval. In 17th International Symposium on String Processing and Information Retrieval (SPIRE), pages 201–206. Springer, 2010. doi:10.1007/978-3-642-16321-0_20.
  • [35] Tobias Maier, Peter Sanders, and Roman Dementiev. Concurrent hash tables: Fast and general(?)! ACM Trans. Parallel Comput., 5(4):16:1–16:32, 2019. doi:10.1145/3309206.
  • [36] Udi Manber and Eugene W. Myers. Suffix arrays: A new method for on-line string searches. SIAM J. Comput., 22(5):935–948, 1993. doi:10.1137/0222058.
  • [37] Lukas Nalbach. lz77-sss. https://github.com/LukasNalbach/lz77-sss. Accessed April 14, 2026.
  • [38] Lukas Nalbach. Implementing sublinear-time approximation algorithms for the Lempel-Ziv 77 factorization. Master’s thesis, TU Dortmund University, 2024. doi:10.17877/DE290R-25750.
  • [39] Ge Nong, Sen Zhang, and Wai Hong Chan. Linear suffix array construction by almost pure induced-sorting. In 2009 Data Compression Conference (DCC), pages 193–202. IEEE, 2009. doi:10.1109/DCC.2009.42.
  • [40] Tatsuya Ohno, Kensuke Sakai, Yoshimasa Takabatake, Tomohiro I, and Hiroshi Sakamoto. OnlineRlbwt. https://github.com/itomomoti/OnlineRlbwt. Accessed April 14, 2026.
  • [41] Tatsuya Ohno, Kensuke Sakai, Yoshimasa Takabatake, Tomohiro I, and Hiroshi Sakamoto. A faster implementation of online RLBWT and its application to LZ77 parsing. J. Discrete Algorithms, 52-53:18–28, 2018. doi:10.1016/J.JDA.2018.11.002.
  • [42] OpenMP ARB. OpenMP. https://www.openmp.org. Accessed April 14, 2026.
  • [43] Alberto Policriti and Nicola Prezza. LZ77 computation based on the run-length encoded BWT. Algorithmica, 80(7):1986–2011, 2018. doi:10.1007/S00453-017-0327-Z.
  • [44] Rolf Rabenseifner. Optimization of collective reduction operations. In 4th International Conference on Computational Science (ICCS), Lecture Notes in Computer Science, pages 1–9. Springer, 2004. doi:10.1007/978-3-540-24685-5_1.
  • [45] James A. Storer and Thomas G. Szymanski. Data compression via textual substitution. J. ACM, 29(4):928–951, 1982. doi:10.1145/322344.322346.
  • [46] Jacob Ziv and Abraham Lempel. A universal algorithm for sequential data compression. IEEE Trans. Inform. Theory, 23(3):337–343, 1977. doi:10.1109/TIT.1977.1055714.