压缩具有特定顺序的正整数 (int32) 向量

Compressing a vector of positive integers (int32) that have a specific order

我正在尝试压缩长向量(它们的大小范围从 1 到 1 亿个元素)。向量具有正整数,值范围从 0 到 1 或 1 亿(取决于向量大小)。因此,我使用 32 位整数来包含大数,但这会占用太多存储空间。 这些向量具有以下特征:

  1. 所有的值都是正整数。它们的范围随着矢量大小的增长而增长。
  2. 值在增加,但较小的数字确实经常干预(见下图)。
  3. None 特定索引之前的值大于该索引(索引从零开始)。例如,索引 6 之前出现的值中有 none 大于 6。但是,较小的值可能会在该索引之后重复出现。这适用于整个数组。
  4. 我通常处理很长的数组。因此,当数组长度超过 100 万个元素时,即将到来的数字大多是混合了先前重复出现的数字的大数字。较短的数字通常比较大的数字重复出现。当您通过它时,新的更大的数字会添加到数组中。

这里是数组中值的示例:{初始填充..., 0, 1, 2, 3, 4, 5, 6, 4, 7, 4, 8, 9, 1, 10 , ... 后来..., 1110, 11, 1597, 1545, 1392, 326, 1371, 1788, 541,...}

这是矢量的一部分的图:

我想要什么? : 因为我使用的是 32 位整数,所以这会浪费大量内存,因为可以用少于 32 位表示的较小数字也会重复。我想最大程度地压缩此向量以节省内存(理想情况下,压缩 3 倍,因为只有减少该数量或更多数量才能满足我们的需求!)。实现该目标的最佳压缩算法是什么?或者有没有办法利用数组的上述特征将数组中的数字可逆地转换为8位整数?

我尝试过或考虑过的事情:

  1. Delta 编码:这在这里不起作用,因为矢量并不总是增加。
  2. 霍夫曼编码:这里似乎没有帮助,因为数组中唯一数字的范围非常大,因此,编码 table 将是一个很大的开销。
  3. 使用变量 Int 编码。即对较小的数字使用 8 位整数,对较大的数字使用 16 位整数......等等。这已将矢量大小减小到 size*0.7(不令人满意,因为它没有利用上述特定特征)
  4. 我不太确定下面link中描述的这种方法是否适用于我的数据:http://ygdes.com/ddj-3r/ddj-3r_compact.html 我不太了解该方法,但它鼓励我尝试类似的事情,因为我认为数据中存在一些可以利用的顺序。 例如,我尝试将任何大于 255 的数字 (n) 重新分配给 n-255,以便我可以将整数保留在 8 位域中,因为我知道在该索引之前没有数字大于 255。但是,我无法区分重新分配的数字和重复的数字......所以这个想法不起作用,除非采取更多技巧来扭转重新分配......

这里是 link 到第 24000 个元素的数据供感兴趣的人使用: data

非常感谢任何意见或建议。非常感谢。

编辑 1:

这是增量编码后的数据图。如您所见,它不会缩小范围!

编辑2:

我希望我能在数据中找到一种模式,使我能够将 32 位向量可逆地更改为单个 8 位向量,但这似乎不太可能。 我试图将 32 位向量分解为 4 x 8 位向量,希望分解后的向量有助于更好地压缩。 下面是 4 个向量的图。现在它们的范围是 0-255。 我所做的是递归地将向量中的每个元素除以 255,并将提醒存储到另一个向量中。要重建原始数组,我需要做的是: ( ( (vec4*255) + vec3 )*255 + vec2 ) *255 + vec1...

如您所见,对于当前显示的数据长度,最后一个向量全为零。实际上,从第 2^24 个元素开始一直为零。如果我的矢量总长度少于 1600 万个元素,这将减少 25%,但由于我处理的是更长的矢量,因此影响要小得多。 更重要的是,第三个向量似乎也有一些可压缩的特征,因为它的值在每 65,535 步后增加 1。 看来现在我可以按照建议从霍夫曼编码或可变位编码中受益。非常感谢任何允许我最大限度地压缩这些数据的建议。 如果有人感兴趣,我在这里附上了更大的数据样本:

https://drive.google.com/file/d/10wO3-1j3NkQbaKTcr0nl55bOH9P-G1Uu/view?usp=sharing

编辑3:

非常感谢所有给出的答案。我从他们身上学到了很多。对于那些有兴趣修改更大数据集的人,以下 link 具有类似数据集的 1100 万个元素(压缩 33MB)

https://drive.google.com/file/d/1Aohfu6II6OdN-CqnDll7DeHPgEDLMPjP/view

解压缩数据后,您可以使用以下 C++ 片段将数据读入向量

    const char* path = "path_to\compression_int32.txt";
    std::vector<int32_t> newVector{};
    std::ifstream ifs(path, std::ios::in | std::ifstream::binary);
    std::istream_iterator<int32_t> iter{ ifs };
    std::istream_iterator<int32_t> end{};
    std::copy(iter, end, std::back_inserter(newVector));

通过使用 属性 3,可以很容易地对示例数据进行两倍压缩,我在这里使用 属性 3 表示每个值都必须小于其索引,索引从 1 开始。只需使用 ceiling(log2(i)) 位来存储索引 处的数字i(其中 i 从 1 开始)。对于具有 24,977 个值的第一个示例,使用 32 位整数将其压缩为向量大小的 43%。

所需的位数仅取决于向量的长度,n。位数为:

1 - 2上限(log2(n)) + n 上限(log2(n))

正如 Falk Hüffner 所指出的,一种更简单的方法是为所有 ceiling(log2(n)).可变位数将始终小于该位数,但不会比大 n.

少很多

如果开头有一个 运行 个零是很常见的,那么用一个计数压缩它们。余数中只有少数两个或三个数字的 运行,因此 运行 长度编码将无济于事,除了最初的 运行 个零。

考虑到索引 k 处的每个值(从零开始的索引),可以使用算术编码方法削减另外 2% 左右(对于大集合)一个非常大的整数的基数 k+1。这将占用 ceiling(log2(n!)) 位。

这是算术编码、每个样本编码的可变位和每个样本编码的固定位的压缩率图,所有样本都与每个样本 32 位的表示法(序列长度在对数上)成比例比例):

算术方法需要对压缩数据的长度进行整数乘法和除法运算,这对于大型向量来说非常慢。下面的代码将整数的大小限制为 64 位,以压缩比为代价,以换取它非常快。这段代码给出的压缩比比上图中的算术高出约 0.2% 到 0.7%,远低于可变位。数据向量必须具有属性每个值都是非负的 并且每个值都小于它的位置(位置从 1 开始)。 压缩效果仅 取决于 属性,如果初始 运行 为零,则压缩效果会小一些。 在提供的示例中似乎有更多冗余 压缩方法不利用。

#include <vector>
#include <cmath>

// Append val, as a variable-length integer, to comp. val must be non-negative.
template <typename T>
void write_varint(T val, std::vector<uint8_t>& comp) {
    while (val > 0x7f) {
        comp.push_back(val & 0x7f);
        val >>= 7;
    }
    comp.push_back(val | 0x80);
}

// Return the variable-length integer at offset off in comp, updating off to
// point after the integer.
template <typename T>
T read_varint(std::vector<uint8_t> const& comp, size_t& off) {
    T val = 0, next;
    int shift = 0;
    for (;;) {
        next = comp.at(off++);
        if (next > 0x7f)
            break;
        val |= next << shift;
        shift += 7;
    }
    val |= (next & 0x7f) << shift;
    return val;
}

// Given the starting index i >= 1, find the optimal number of values to code
// into 64 bits or less, or up through index n-1, whichever comes first.
// Optimal is defined as the least amount of entropy lost by representing the
// group in an integral number of bits, divided by the number of bits. Return
// the optimal number of values in num, and the number of bits needed to hold
// an integer representing that group in len.
static void group_ar64(size_t i, size_t n, size_t& num, int& len) {
    // Analyze all of the permitted groups, starting at index i.
    double min = 1.;
    uint64_t k = 1;                 // integer range is 0..k-1
    auto j = i + 1;
    do {
        k *= j;
        auto e = log2(k);           // entropy of k possible integers
        int b = ceil(e);            // number of bits to hold 0..k-1
        auto loss = (b - e) / b;    // unused entropy per bit
        if (loss < min) {
            num = j - i;            // best number of values so far
            len = b;                // bit length for that number
            if (loss == 0.)
                break;              // not going to get any better
            min = loss;
        }
    } while (j < n && k <= (uint64_t)-1 / ++j);
}

// Compress the data arithmetically coded as an incrementing base integer, but
// with a 64-bit limit on each integer. This puts values into groups that each
// fit in 64 bits, with the least amount of wasted entropy. Also compress the
// initial run of zeros into a count.
template <typename T>
std::vector<uint8_t> compress_ar64(std::vector<T> const& data) {
    // Resulting compressed data vector.
    std::vector<uint8_t> comp;

    // Start with number of values to make the stream self-terminating.
    write_varint(data.size(), comp);
    if (data.size() == 0)
        return comp;

    // Run-length code the initial run of zeros. Write the number of contiguous
    // zeros after the first one.
    size_t i = 1;
    while (i < data.size() && data[i] == 0)
        i++;
    write_varint(i - 1, comp);

    // Compress the data into variable-base integers starting at index i, where
    // each integer fits into 64 bits.
    unsigned buf = 0;       // output bit buffer
    int bits = 0;           // number of bits in buf (0..7)
    while (i < data.size()) {
        // Find the optimal number of values to code, starting at index i.
        size_t num;  int len;
        group_ar64(i, data.size(), num, len);

        // Code num values.
        uint64_t code = 0;
        size_t k = 1;
        do {
            code += k * data[i++];
            k *= i;
        } while (--num);

        // Write code using len bits.
        if (bits) {
            comp.push_back(buf | (code << bits));
            code >>= 8 - bits;
            len -= 8 - bits;
        }
        while (len > 7) {
            comp.push_back(code);
            code >>= 8;
            len -= 8;
        }
        buf = code;
        bits = len;
    }
    if (bits)
        comp.push_back(buf);
    return comp;
}

// Decompress the result of compress_ar64(), returning the original values.
// Start decompression at offset off in comp. When done, off is updated to
// point just after the compressed data.
template <typename T>
std::vector<T> expand_ar64(std::vector<uint8_t> const& comp, size_t& off) {
    // Will contain the uncompressed data to return.
    std::vector<T> data;

    // Get the number of values.
    auto vals = read_varint<size_t>(comp, off);
    if (vals == 0)
        return data;

    // Get the number of zeros after the first one, and write all of them.
    auto run = read_varint<size_t>(comp, off) + 1;
    auto i = run;
    do {
        data.push_back(0);
    } while (--run);

    // Extract the values from the compressed data starting at index i.
    unsigned buf = 0;       // input bit buffer
    int bits = 0;           // number of bits in buf (0..7)
    while (i < vals) {
        // Find the optimal number of values to code, starting at index i. This
        // simply repeats the same calculation that was done when compressing.
        size_t num;  int len;
        group_ar64(i, vals, num, len);

        // Read len bits into code.
        uint64_t code = buf;
        while (bits + 8 < len) {
            code |= (uint64_t)comp.at(off++) << bits;
            bits += 8;
        }
        len -= bits;                    // bits to pull from last byte (1..8)
        uint64_t last = comp.at(off++); // last byte
        code |= (last & ((1 << len) - 1)) << bits;
        buf = last >> len;              // save remaining bits in buffer
        bits = 8 - len;

        // Extract num values from code.
        do {
            i++;
            data.push_back(code % i);
            code /= i;
        } while (--num);
    }

    // Return the uncompressed data.
    return data;
}

正如我在评论中所建议的那样,您可以将数据表示为 8 位。有一些简单的方法可以有效地做到这一点,不需要模块化算法..

您可以为此使用联合或指针,例如在 C++ 中,如果您有:

unsigned int data32[]={0,0,0,...};
unsigned char *data08=data32;

或者您可以将其复制到 4 BYTE 数组,但那样会更慢。

如果您出于任何原因必须使用模运算,那么您可能希望这样做:

 x     &255
(x>> 8)&255
(x>>16)&255
(x>>24)&255

现在 I have tried LZW 在您的新数据上,没有任何数据重新排序(单个 LZW)的压缩比结果为 81-82%(取决于字典大小,我建议使用 10 位 LZW 字典)这不是正如预期的那样好。因此,我将数据重新排序为 4 个数组(就像您所做的那样),因此第一个数组的 8 位最低,最后一个最高。 12 位字典的结果,其中:

ratio08: 144%
ratio08: 132%
ratio08:  26%
ratio08:   0%
total:    75%

10 位字典的结果,其中:

ratio08: 123%
ratio08: 117%
ratio08:  28%
ratio08:   0%
total:    67%

表明 LZW 对最低字节不利(随着大小的增加,对更高字节的影响也会更差)因此仅将其用于更高的字节,这将进一步提高压缩率。

不过我希望霍夫曼会带来更好的结果,所以 对于您的数据:

H32 = 5.371071 , H/8 = 0.671384
H08 = 7.983666 , H/8 = 0.997958
H08 = 7.602564 , H/8 = 0.950321
H08 = 1.902525 , H/8 = 0.237816
H08 = 0.000000 , H/8 = 0.000000
total: 54%

意思是单纯的单一霍夫曼编码的压缩率为 67%,而单独的 4 个数组将导致 54%,这要好得多,所以在你的情况下我会选择 霍夫曼编码 .我在这里实现后的结果是:

[Huffman]
ratio08 =  99.992%
ratio08 =  95.400%
ratio08 =  24.706%
ratio08 =   0.000%
total08 =  55.025%
ratio32 =  67.592%

这与预期的香农熵估计非常匹配(不考虑解码 table)...

然而,对于非常大的数据集,我希望朴素的霍夫曼会开始变得比单独的 4x 霍夫曼稍微好一些...

另请注意,结果被截断,因此 0% 不是零,而是小于 1% ...

[Edit1] 300 000 000 个条目估计

所以为了模拟你的 300M 32 位数字的条件,我使用你的数据的 16 位数字子部分,具有类似的“空 space”属性。

log2(300 000 000) = ~28
28/32 * 16 = 14

所以我只使用 2^14 个 16 位数字,它们应该与您的 300M 32 位数字具有相似的属性 8 位霍夫曼编码导致:

ratio08 =  97.980%
ratio08 =  59.534%
total08 =  78.757%

所以我估计 encoded/decoded 大小减少 ~1.25 之间的比例为 80%。 (希望我的假设没有搞砸)。

解决每个压缩问题都应该从分析开始。

我查看了包含前 24976 个值的原始数据文件。最小值为 0,最大值为 24950。数据的“斜率”约为 1。但是,如果最大值如前所述仅为 33M@100M 值,则它应该随时间减少。 slope=1 的假设有点悲观。

至于分布,

tr '[,]' '[\n]' <compression.txt | sort -n | uniq -c | sort -nr | head -n256

生产

164  0
131  8
111  1648
108  1342
104  725
103  11
 91  1475
 90  1446
 82  21
 82  1355
 78  69
 76  2
 75  12
 72  328
 71  24
 70  614
 70  416
 70  1608
 70  1266
 69  22
 67  356
 67  3
 66  1444
 65  19
 65  1498
 65  10
 64  2056
 64  16
 64  1322
 64  1182
 63  249
 63  1335
 61  43
 60  17
 60  1469
 59  33
 59  3116
 58  20
 58  1201
 57  303
 55  5
 55  4
 55  2559
 55  1324
 54  1110
 53  1984
 53  1357
 52  807
 52  56
 52  4321
 52  2892
 52  1
 50  39
 50  2475
 49  1580
 48  664
 48  266
 47  317
 47  1255
 46  981
 46  37
 46  3531
 46  23
 43  1923
 43  1248
 41  396
 41  2349
 40  7
 39  6
 39  54
 39  4699
 39  32
 38  815
 38  2006
 38  194
 38  1298
 38  1284
 37  44
 37  1550
 37  1369
 37  1273
 36  1343
 35  61
 35  3991
 35  3606
 35  1818
 35  1701
 34  836
 34  27
 34  264
 34  241
 34  1306
 33  821
 33  28
 33  248
 33  18
 33  15
 33  1017
 32  9
 32  68
 32  53
 32  240
 32  1516
 32  1474
 32  1390
 32  1312
 32  1269
 31  667
 31  326
 31  263
 31  25
 31  160
 31  1253
 30  3365
 30  2082
 30  18550
 30  1185
 30  1049
 30  1018
 29  73
 29  487
 29  48
 29  4283
 29  34
 29  243
 29  1605
 29  1515
 29  1470
 29  1297
 29  1183
 28  980
 28  60
 28  302
 28  242
 28  1959
 28  1779
 28  161
 27  811
 27  51
 27  36
 27  201
 27  1270
 27  1267
 26  979
 26  50
 26  40
 26  3111
 26  26
 26  2425
 26  1807
 25  825
 25  823
 25  812
 25  77
 25  46
 25  217
 25  1842
 25  1831
 25  1534
 25  1464
 25  1321
 24  730
 24  66
 24  59
 24  427
 24  355
 24  1465
 24  1299
 24  1164
 24  1111
 23  941
 23  892
 23  7896
 23  663
 23  607
 23  556
 23  47
 23  2887
 23  251
 23  1776
 23  1583
 23  1488
 23  1349
 23  1244
 22  82
 22  818
 22  661
 22  42
 22  411
 22  3337
 22  3190
 22  3028
 22  30
 22  2226
 22  1861
 22  1363
 22  1301
 22  1262
 22  1158
 21  74
 21  49
 21  41
 21  376
 21  354
 21  2156
 21  1688
 21  162
 21  1453
 21  1067
 21  1053
 20  711
 20  413
 20  412
 20  38
 20  337
 20  2020
 20  1897
 20  1814
 20  17342
 20  173
 20  1256
 20  1160
 19  9169
 19  83
 19  679
 19  4120
 19  399
 19  2306
 19  2042
 19  1885
 19  163
 19  1623
 19  1380
 18  805
 18  79
 18  70
 18  6320
 18  616
 18  455
 18  4381
 18  4165
 18  3761
 18  35
 18  2560
 18  2004
 18  1900
 18  1670
 18  1546
 18  1291
 18  1264
 18  1181
 17  824
 17  8129
 17  63
 17  52
 17  5138

作为最常见的 256 个值。

似乎有些价值观天生就更常见。检查时,这些共同值似乎也分布在整个数据中。

我提出以下建议:

将数据分成块。对于每个块,发送斜率的实际值,因此在对每个符号进行编码时我们知道它的最大值。

使用统计编码(霍夫曼等)对块中的公共值进行编码。在这种情况下,字母表为 256 的截止值将出现大约 17 次。

对于不太常见的值,我们保留一小部分字母表用于发送值中的位数。

当我们遇到一个稀有值时,它的位在没有统计建模的情况下被编码。最高位可以省略,因为我们知道它始终为 1(除非值为“0”)。

通常要编码的值范围不是2的幂。例如,如果我们有 10 个选择,这需要 4 位来编码,但是有 6 个未使用的位模式——有时我们只需要 3 位。前 6 个选择我们直接用 3 位编码。如果它是 7 或 8,我们会发送一个额外的位来指示我们是指 9 还是 10。

此外,我们可以从可能值列表中排除直接编码的任何值。否则我们有两种方法来编码相同的值,这是多余的。

您正在处理的数据“几乎”已排序,因此您可以将其用于 delta 编码以产生很好的效果。

简单的做法如下:

查找 运行s 数据,用 R_i = (v,l,N) 表示,其中 l 是 运行 的长度,N 是位深度需要对排序的 运行 进行增量编码,v 是(已排序的)运行 的第一个元素的值(增量编码需要)。 运行本身然后只需要为 运行 中的每个条目存储 2 条信息:运行 中每个排序元素的 idx 和增量。注意,要存储每个排序元素的 idx,每个 idx 只需要 log_2(l) 位,其中 l 是 运行.

的长度

与未压缩形式使用的字节数相比,编码的工作原理是尝试找到最少的位数来完全编码 运行。实际上,这可以通过找到最长的 运行 来实现,该 运行 编码为每个元素的固定字节数。

要解码,只需解码 运行-by-运行(按顺序)首先解码增量 coding/compression,然后取消排序。

下面是一些 C++ 代码,用于计算可以在您发布的数据样本上使用此方案获得的压缩率。该实现在选择 运行 时采用了贪婪的方法,如果使用更智能的方法,可能会获得更好的结果。

#include <algorithm>
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <map>
#include <queue>

#include "data.h"

template <int _N, int _M> struct T {
  constexpr static int N = _N;
  constexpr static int M = _M;
  uint16_t idx : N;
  uint16_t delta : M;
};

template <int _N, int _M>
std::pair<int32_t, int32_t> best_packed_run_stats(size_t idx) {
  const int N = 1 << _N;
  const int M = 1 << _M;
  static std::vector<int32_t> buffer(N);
  if (idx + N >= data.size())
    return {-1, 0};
  std::copy(&data[idx], &data[idx + N], buffer.data());
  std::sort(buffer.begin(), buffer.end());
  int32_t run_len = 0;
  for (size_t i = 1; i < N; ++i, ++run_len) {
    auto delta = buffer[i] - buffer[i - 1];
    assert(delta >= 0);
    if (delta >= M) {
      break;
    }
  }
  int32_t savings = run_len * (sizeof(int32_t) - sizeof(T<_N, _M>)) -
                    1    // 1 byte to store bit-depth
                    - 2; // 2 bytes to store run length
  return {savings, run_len};
}

template <class... Args>
std::vector<std::pair<int32_t, int32_t>> all_runs_stats(size_t idx) {
  return {best_packed_run_stats<Args::N, Args::M>(idx)...};
}

int main() {

  size_t total_savings = 0;
  for (size_t i = 0; i < data.size(); ++i) {
    auto runs =
        all_runs_stats<T<2, 14>, T<4, 12>, T<8, 8>, T<12, 4>, T<14, 2>>(i);
    auto best_value = *std::max_element(runs.begin(), runs.end());
    total_savings += best_value.first;
    i += best_value.second;
  }
  size_t uncomp_size = data.size() * sizeof(int32_t);
  double comp_ratio =
      (uncomp_size - (double)total_savings) / (double)uncomp_size;
  printf("uncomp_size: %lu\n", uncomp_size);
  printf("compression: %lf\n", comp_ratio);
  printf("size: %lu\n", data.size());
}

请注意,仅尝试 运行 中元素的 16 位表示的某些固定配置。因此,我们应该期望我们可以实现的最佳压缩是 50%(即 4 字节 -> 2 字节。)实际上,存在开销。

当您提供的数据样本 运行 时,此代码报告此压缩率:

uncomp_size: 99908
compression: 0.505785
size: 24977

非常接近此压缩算法的理论极限.5

另外,请注意,这略胜于另一个答案中报告的香农熵估计值。


编辑以解决 Mark Adler 在下方的评论。

在提供的更大数据集 (compression2.txt) 上重新运行压缩,并与 Mark Adler 的方法进行比较,结果如下:

uncomp_size: 2602628
compression: 0.507544
size: 650657
bit compression: 0.574639

其中 bit compression 是 Mark Adler 方法的压缩率。正如其他人所指出的,压缩每个条目的位对于大数据来说不会很好地扩展,我们应该期望 n.

的比率会变得更糟

同时,上述增量 + 排序压缩保持接近其理论最佳值 .5