Skip to content

如何压缩数字:一种基于对有效位做base64的比数字字符串更短的人类可读字符串表达 #24

Description

@n0099

对于uint64范围 $0\ to\ 2^{64}-1, \text{inclusive}$ 的输入:

rtrim(base64_encode(rtrim(pack('P', $i), "\x00")), '=')

对于int64范围中负数部分 $-2^{63}\ to \ -1, \text{inclusive}$ 的输入:

rtrim(base64_encode(rtrim(pack('q', $i), "\xFF")), '=')

您可以判断输入$i >= 0来选择使用哪个版本

如果您的输入上下限超出了uint64(正)和int64(负)的范围 $\{-2^{63}, ..., 2^{64}-1\}$ ,您可以将pack()替换为通过gmp得出的其他bigint类型的字节表达输出

编码步骤

其中pack()是php从perl中借鉴的函数(perl的pack()文档: https://perldoc.perl.org/functions/pack https://perldoc.perl.org/perlpacktut
在这里的目的是将输入的int64转换为小端序byte[]
提供的Pq是pack format参数

  • P意味着将输入作为uint64解释输出小端序的byte[]
  • q意味着将输入作为uint64解释输出运行时平台环境的端序(对于x86也是小端序)的byte[]

rtrim()的目的是将小端序byte[]中靠右(也就是高位)的所有0x00(当输入为正数时)或0xFF(当输入为负数时)删除,也就是只保留其有效位

然后对这些有效位byte[]做base64 encode
最后将base64字符串末尾所有用于padding=删除
https://stackoverflow.com/questions/4080988/why-does-base64-encoding-require-padding-if-the-input-length-is-not-divisible-by/26632221#26632221 早已道明:

始终可以根据(base64)编码序列的长度明确地确定输入的长度

示例输入

0 1 2147483647
pack('P', $i) 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 FF FF FF 7F 00 00 00 00
rtrim(..., "\x00") (空字节序列) 01 FF FF FF 7F
base64_encode(...) AA== AQ== ////fw==
rtrim(..., '=') AA AQ ////fw
strlen(out) - strlen(in) 1 1 -4
pack('q', $i) 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 FF FF FF 7F 00 00 00 00
rtrim(..., "\xFF") 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 FF FF FF 7F 00 00 00 00
base64_encode(...) AAAAAAAAAAA= AQAAAAAAAAA= ////fwAAAAA=
rtrim(..., '=') AAAAAAAAAAA AQAAAAAAAAA ////fwAAAAA

strlen(out) - strlen(in)代表着输入比最终输出长了多少个字符

请注意输入不同的format Pqpack()的结果是相同的
实际上对于有无符号的不同版本int的pack format,只在unpack()反向转换时有不同效果
但我测试过对于"\xfe\xff\xff\xff\xff\xff\xff\xff"unpack('P或q')都给出了正确的-2,这可能是特定于平台的: https://3v4l.org/V5une#veol
image
https://3v4l.org/Ve5F0#veol

var_dump(array_map(fn ($i) => unpack($i[0], $i[1]), [
    ['P', str_pad("\xFE", 8, "\x00")],
    ['q', str_pad("\xFE", 8, "\x00")],
    ['P', str_pad("\xFE", 8, "\xFF")],
    ['q', str_pad("\xFE", 8, "\xFF")],
]));

image

php的pack()文档也指出了这一点:

Note that the distinction between signed and unsigned values only affects the function unpack(), where as function pack() gives the same result for signed and unsigned format codes.

-1 -2147483647
pack('P', $i) FF FF FF FF FF FF FF FF 01 00 00 80 FF FF FF FF
rtrim(..., "\x00") FF FF FF FF FF FF FF FF 01 00 00 80 FF FF FF FF
base64_encode(...) //////////8= AQAAgP////8=
rtrim(..., '=') //////////8 AQAAgP////8
strlen(out) - strlen(in) 0 -5
pack('q', $i) FF FF FF FF FF FF FF FF 01 00 00 80 FF FF FF FF
rtrim(..., "\xFF") (空字节序列) 01 00 00 80
base64_encode(...) AA== gAAAAQ==
rtrim(..., '=') AA gAAAAQ

注意对于输入-10pack()结果做不同的rtrim(0x00或0xFF),产生了相同的空字节序列,导致其base64结果也是相同的AA==
实际上对于任何输入,都有着另一符号相反的输入,他们删除掉不同的0x000xFF后会产生相同的输出

因此您需要额外标注您trim掉的是0x00还是0xFF,这样才能在解码时正确padding对应的0x000xFF
而如果不做rtrim()删除0x000xFF,最终输出将始终是对定长的64bit的base64字符串,其也是定长11(12当您没有删除=时)个字符

反向解码

只需对最终输出反过来执行所有编码步骤便可得到原始输入:

unpack('P或q', str_pad(base64_decode($i), 8, "\x00"或"\xFF"))

如果您没有额外标记原始输入是否为负数,由于错误的str_pad()值(0x000xFF),您会得到符号反转并偏移了padding位的值,例如:

-2 254 2147483647 -2147483649
pack('P或q', $i) FE FF FE 00 FF FF FF 7F 00 00 00 00 FF FF FF 7F FF FF FF FF
rtrim(..., "\x00") FE FF FF FF 7F
rtrim(..., "\xFF") FE FF FF FF 7F
base64_encode(...) /g== /g== ////fw== ////fw==
str_pad(... "\x00") FE 00 FF FF FF 7F 00 00 00 00
str_pad(... "\xFF") FE FF FF FF FF 7F FF FF FF FF
unpack('P或q', ...) 254 -2 -2147483649 2147483647

位数趋势

示例输入表中的strlen(out) - strlen(in)行指出了对于某些输入,输出可能反而比输入更长,例如0的输出是AA,其比输入长了一个字符

以下fp思维php代码片段可以求出[0,1000000]输入范围的所有输出位数:

$r = range(0,1000000);
echo json_encode(array_unique(array_map(
    static fn (int $a, int $b) => "$a $b",
    array_map(static fn (int $i) => strlen(rtrim(base64_encode(rtrim(pack('P', $i), "\x00")), '=')), $r),
    array_map(static fn (int $i) => strlen($i), $r)
)));

json格式:{"输入": "输出位数 输入位数"}

{
"0":       "0 1",
"1":       "2 1",
"10":      "2 2",
"100":     "2 3",
"256":     "3 3",
"1000":    "3 4",
"10000":   "3 5",
"65536":   "4 5",
"100000":  "4 6",
"1000000": "4 7"
}

更快的命令式思维0xFF版本用以计算[-100000000,0]输入范围:

$d = [];
for ($i = -100000000; $i <= 0; $i++) {
    $b = strlen(rtrim(base64_encode(rtrim(pack('q', $i), "\xFF")), '='));
    $o = strlen($i);
    $l = "$b $o";
    if (end($d) !== $l) $d["$i "] = $l;
}
echo json_encode($d);
{
"-100000000 ": "6 10",
"-99999999 ":  "6 9",
"-16777216 ":  "4 9",
"-9999999 ":   "4 8",
"-999999 ":    "4 7",
"-99999 ":     "4 6",
"-65536 ":     "3 6",
"-9999 ":      "3 5",
"-999 ":       "3 4",
"-256 ":       "2 4",
"-99 ":        "2 3",
"-9 ":         "2 2",
"-1 ":         "0 2",
"0 ":          "11 1"
}

其中-1的输出是0长度是因为上文提到的空字节序列,而0的输出是11字符长的AAAAAAAAAAA是因为只删除了0xFF而没有删除0x00

交互式图表: https://jsfiddle.net/x6wp2kno/

代码备份
<div id="chart1"></div>
<div id="chart2"></div>
#chart1, #chart2 {
  width: 100%;
  height: 80vh;
}
// https://github.com/n0099/TiebaMonitor/issues/24
const chart1Data = {
"0":       "0 1",
"1":       "2 1",
"10":      "2 2",
"100":     "2 3",
"256":     "3 3",
"1000":    "3 4",
"10000":   "3 5",
"65536":   "4 5",
"100000":  "4 6",
"1000000": "4 7"
};
const chart2Data = {
"-100000000 ": "6 10",
"-99999999 ":  "6 9",
"-16777216 ":  "4 9",
"-9999999 ":   "4 8",
"-999999 ":    "4 7",
"-99999 ":     "4 6",
"-65536 ":     "3 6",
"-9999 ":      "3 5",
"-999 ":       "3 4",
"-256 ":       "2 4",
"-99 ":        "2 3",
"-9 ":         "2 2",
"-1 ":         "0 2"
};
const baseOption = {
  tooltip: {trigger: 'axis'},
  legend: {},
  xAxis: {
    type: 'log',
    name: '输入数字',
    minorSplitLine: {show: true}
  },
  yAxis: {name: '字符串长度'},
  series: [
    {
      type: 'line',
      step: 'end',
      name: '输出'
    },
    {
      type: 'line',
      step: 'end',
      name: '原始输入'
    }
  ]
};
const chart1 = echarts.init(document.getElementById('chart1'))
chart1.setOption(baseOption);
chart1.setOption({
  title: {text: '正数输入'},
	xAxis: {logBase: 2000000},
  grid: {tooltip: {axisPointer: {label: {precision: 0}}}},
  series: [
  {data: _.map(chart1Data, (i, k) => [k, i.split(' ')[0]])},
  {data: _.map(chart1Data, (i, k) => [k, i.split(' ')[1]])},
  {
  	type: 'line',
    step: 'end',
    name: '不省略正号的原始输入',
  	data: _.map(chart1Data, (i, k) => [k, +i.split(' ')[1]+1])
  }
  ]
});
const chart2 = echarts.init(document.getElementById('chart2'));
chart2.setOption(baseOption);
chart2.setOption({
  title: {text: '负数输入'},
	xAxis: {
  	logBase: 20000,
    axisLabel: {formatter: i => -i}
  },
  grid: {tooltip: {axisPointer: {label: {formatter: p => '-'+p.value}}}},
  series: [
  {data: _.map(chart2Data, (i, k) => [Math.abs(k), i.split(' ')[0]])},
  {data: _.map(chart2Data, (i, k) => [Math.abs(k), i.split(' ')[1]])}
  ]
});

image
点击图例隐藏系列原始输入后不省略正号的正数(+1)跟必须带负号的负数(-1)的长度分布是完全相同的
image

不难看出当 $-9\geq\text{输入}\leq10, \text{输入}\neq\{0, -1\}$ 时输出有着2个字符而输入有着1或2个字符
而往后输入每次达到 $2^{8n}$ 时输出的长度才会增加

使用以下php代码片段我们可以计算出 $n\in\{1,8\}$$2^{8n}$ 以及 $2^{8n}-1$ 的输出长度,并假定这就是对于任何 $\leq n$ 的输入的输出位数集合:

echo json_encode(array_map(static fn ($p) =>
    array_combine([$p - 1, $p], array_map(static fn ($i) =>
        strlen(rtrim(base64_encode(rtrim(pack('P', $i), "\x00")), '=')), [$p - 1, $p])),
    array_map(static fn ($n) => 2 ** (8 * $n), range(1, 7))));
n 2^(8*n)-1 strlen 2^(8*n) strlen
1 255 2 256 3
2 65535 3 65536 4
3 16777215 4 16777216 6
4 4294967295 6 4294967296 7
5 1099511627775 7 1099511627776 8
6 281474976710655 8 281474976710656 10
7 72057594037927935 10 72057594037927936 11

负数版本:

echo json_encode(array_map(static fn ($p) =>
    array_combine([$p - 1, $p], array_map(static fn ($i) =>
        strlen(rtrim(base64_encode(rtrim(pack('q', $i), "\xFF")), '=')), [$p - 1, $p])),
    array_map(static fn ($n) => -2 ** (8 * $n), range(1, 7))));
n -2^(8*n)-1 strlen -2^(8*n) strlen
1 -256 2 -257 3
2 -65536 3 -65537 4
3 -16777216 4 -16777217 6
4 -4294967296 6 -4294967297 7
5 -1099511627776 7 -1099511627777 8
6 -281474976710656 8 -281474976710657 10
7 -72057594037927936 10 -72057594037927937 11

我们不难发现不可能存在长度为5或9个字符的输出,建议对此进行充实有力的数字论证

而这与以下两个OEIS数列相对应:
https://oeis.org/A175824 可以存储在 n 个字节中的最大无符号整数

n > 0 的所有 a(n) 都是梅森数。没有一个是梅森素数。

https://oeis.org/A133752 a(n) = 256^n

n 字节的文件可以具有的不同可能值的数量;每个字节有 8 个位,每个位可以是 0 或 1。该序列显示即使数据量非常少(只有几个字节)也可以存在多少个不同的文件。仅用 5 个字节的数据,就有 1099511627776 个不同的可能文件。- MF Hasler,2012 年 11 月 5 日
a(1) = 256^1 = 256 --> 有 256 个可能的 1 字节文件;
a(2) = 256^2 = 65536 --> 有 65536 个可能的 2 字节文件;
a(3) = 256^3 = 16777216 --> 有 16777216 个可能的 3 字节文件;
a(4) = 256^4 = 4294967296 --> 有 4294967296 个可能的 4 字节文件;
a(5) = 256^5 = 1099511627776 --> 有 1099511627776 个可能的 5 字节文件。

n		a(n)
0		1
1		256
2		65536
3		16777216
4		4294967296
5		1099511627776
6		281474976710656
7		72057594037927936
8		18446744073709551616
9		4722366482869645213696
10		1208925819614629174706176
11		309485009821345068724781056

其他算法

https://carlmastrangelo.com/blog/lets-make-a-varint 文中指出protobuf与utf8 encoding中都使用了两种不同的varint实现

编码 varint 的两种常见方式是长度前缀和连续位。
Google 的 Protobuf使用后一种技术,使用每个字节的最高位来指示是否有更多字节到来。每个字节的低 7 位用于编码实际数字。
UTF-8字符编码利用前一种编码技术,在数字前加上长度。虽然通常被认为效率低下,但长度是以一元编码的。前导 1 位的数量表示即将到来的额外字节数

对整数进行编码的一个流行想法是使用有限的字符集。例如,base64 编码虽然通常用于二进制数据,但也适用于编码 varint。它为每个 8 位字节获取 6 位数据。这几乎和 protobuf varints 一样密集。将其与数字 12345 的正常编码进行比较,后者只是“12345”,每个字节只有 10 个可能的值,而不是 64 个。它也比十六进制编码更好,后者每个字节仅提供 16 个值。

他使用base32对protobuf版本varint的输出进行encode从而实现了一种可排序 分布密集的数字压缩算法:

我们需要选择一种方法将我们的数字编码到这个字母表中。理想的特质:
数值接近的值应该有接近的编码。
按字母顺序比较编码值应该与按数字排序解码值相同。
必须能够存储至少 64 位数字。
不能含糊不清。
首先,接近值需要被编码为接近。请注意 12345 和 12346 如何共享一个公共前缀?当人们查看两个值时,这是一个很好的属性。由于图片通常会获得自动递增的 ID,因此如果它们的文件名导致放置在同一目录中会很好。
其次,将编码值与解码值进行比较意味着您不必为了执行某些操作而解码值(昂贵)。判断编码数字是否会在解码时溢出比必须解码数字并检查要容易得多。这就像将编码的最大数字与要解码的值进行比较一样简单。例如,如果我们想检查编码后的数字是否大于 8,我们可以比较“9” > “8”,而无需对其进行解码。
第三,它必须能够存储我们CPU的最大字长。如上所述,Varint 可以存储任意大的值,但出于我们的目的,如果它能让我们在其他地方的生活更轻松,我们可以将自己限制为仅 64 位值。
第四,编码不能有歧义。UTF-8 通过声明不能有前导零来处理这个问题。数字 000000001 = 1 吗?数字上是,但编码为否,因为“000000001”!=“1”。UTF-8 禁止诸如编码错误之类的值。Protobuf 根本不解决这个问题,并允许它发生。
模棱两可的编码的一个不幸的副作用是它也意味着效率低下。不再有数字到编码的 1-1 and onto 映射,这意味着一些编码是浪费的。
这里的解决方案就是一开始就没有问题!我们的编码就可以直接把这些原本有歧义的编码作为唯一值,所以没有问题。编码将是密集的,我们不必进行错误检查。
现在我们已经列出了所有要求,让我们看看一个运行良好的解决方案。

  "a" - 10
  "b" - 11
  "c" - 12
  "d" - 13
  "e" - 14
  "f" - 15
  "g0" - 16
  "g1" - 17
  ...
  "gz" - 47
  "h00" - 48

这是歧义的重要之处。请注意“g0”是如何表示有一个后缀字节,并且该字节是 0 + 先前值的数量。“h00”是相同的方式,其中额外的 00 不仅仅是浪费空间或非法值,就像它们在 Protobuf 或 UTF-8 中一样。这种方法的缺点是它意味着解码不像移位那么简单。然而,由于一个方便的身份,这个开销证明只是一个常量的加法。这与检查超长编码的成本差不多,所以结果并没有那么糟糕。
前缀是有序的。以“h”开头的任何数字将始终严格大于从“0”到“g”开头的任何数字。这正是我们想要的,因为这意味着我们可以比较编码后的数字。这很特别,因为它不仅仅是使用最重要编码的副作用。考虑常规数字,它们首先用最重要的组编码:8、9、10、11,……如果我们对它们进行排序,它们将是“10”、“11”、“8”、“9”。必须采取特殊措施在没有长度前缀的情况下对这些进行正确排序。
长度前缀还为我们提供了所需的正确文件放置。例如,文件“0.jpg”、“h00.jpg”和“h01.jpg”将存储为:

  objects/
    0.jpg
    h/
      0/
        h00.jpg
        h01.jpg

文件将彼此靠近存储,以便轻松找出要查找的位置。目录永远不会变得太大。这在扫描远程目录时非常重要,例如使用 SFTP 或 Webdav。目录的数量严格来说是一个受文件数量限制的函数。
假设文件没有被删除或更改,比较本地和远程目录也很容易。遍历远程文件树中的最后一个目录将告诉您远程目录领先或落后多远。
利用 varint 可以连接的事实,我们可以做另一个很酷的功能:缩略图。假设每张图片都有一个缩略图。每张图片都有一个唯一的 ID,每个缩略图都有一个自动递增的索引。例如,对于id为49的图片(编码为“h01”),缩略图索引为0(编码为“0”),我们可以制作文件“h01.jpg”和“h010.jpg”。缩略图的编码名称被明确解析为两个整数 49 和 0。缩略图将放置在与原始文件相同的目录中,并且与其他文件相比具有正确的排序顺序。
我们可以用这样的 varint 存储多少个值?最大数是所有小于“z”数的数加上所有“z”数的总和。“z”数有 16 个字节,每个字节有 5 位熵,这意味着我们有大约 80 位可以使用。这也可以方便地包含 x86 CPU 使用的 80 位“long double”数字。

并进一步指出也可以使用 https://en.wikipedia.org/wiki/Golomb_codinghttps://en.wikipedia.org/wiki/Elias_gamma_coding 代替这两种varint实现然后再套base32

更多的选择:

https://en.wikipedia.org/wiki/LEB128

LLVM,在其覆盖映射格式[8]中,LLVM 对 LEB128 编码和解码的实现与上面的伪代码一起很有用。[9]
Minecraft在其协议中使用 LEB128 来测量数据包中的数据长度。[10]
mpatrol 调试工具在其跟踪文件格式中使用 LEB128。[11]
奥苏!在其 osu 中使用 LEB128!重播 (.osr) 格式。[12]

https://en.wikipedia.org/wiki/Variable-length_quantity

整数(十进制) 整数(十六进制) 整数(二进制) 变长数量(十六进制) 变长数量(二进制)
0 0x00000000 00000000 00000000 00000000 00000000 0x00 00000000
127 0x0000007F 00000000 00000000 00000000 01111111 0x7F 01111111
128 0x00000080 00000000 00000000 00000000 10000000 0x81 0x00 10000001 00000000
8192 0x00002000 00000000 00000000 00100000 00000000 0xC0 0x00 11000000 00000000
16 383 0x00003FFF 00000000 00000000 00111111 11111111 0xFF 0x7F 11111111 01111111
16 384 0x00004000 00000000 00000000 01000000 00000000 0x81 0x80 0x00 10000001 10000000 00000000
2 097 151 0x001FFFFF 00000000 00011111 11111111 11111111 0xFF 0xFF 0x7F 11111111 11111111 01111111
2 097 152 0x00200000 00000000 00100000 00000000 00000000 0x81 0x80 0x80 0x00 10000001 10000000 10000000 00000000
134 217 728 0x08000000 00001000 00000000 00000000 00000000 0xC0 0x80 0x80 0x00 11000000 10000000 10000000 00000000
268 435 455 0x0FFFFFFF 00001111 11111111 11111111 11111111 0xFF 0xFF 0xFF 0x7F 11111111 11111111 11111111 01111111

https://github.com/tsunaminoai/baseEmoji
image

小数输入

以上讨论都将输入限制为了整数,如果您的输入可能或必定是小数,您可以将其转换为32/64位IEEE 754的二进制再进行encode:

rtrim(base64_encode(rtrim(pack('E', $i), "\x00")), '=')

pack format E表示转换为32或64位(根据运行时php是32还是64位构建)IEEE 754的大端序字节表达
请注意这里换成了大端序以便能够继续进行rtrim(),因为 https://stackoverflow.com/questions/5242589/would-float-point-format-be-affected-by-big-endian-and-little-endian/5242783#5242783 中指出小端序排列的IEEE 754相较sign, exponent, mantissa是倒过来的:

struct
      {
#if     __BYTE_ORDER == __BIG_ENDIAN
        unsigned int negative:1;
        unsigned int exponent:8;
        unsigned int mantissa:23;
#endif                          /* Big endian.  */
#if     __BYTE_ORDER == __LITTLE_ENDIAN
        unsigned int mantissa:23;
        unsigned int exponent:8;
        unsigned int negative:1;
#endif                          /* Little endian.  */
      } ieee

如果您换成了小端序的e那么需要改成[ltrim()],并在解码时为str_pad()指定STR_PAD_LEFT

unpack('e', str_pad(base64_decode($a), 8, "\x00", STR_PAD_LEFT))

与整数版本的

因此您需要额外标注您trim掉的是0x00还是0xFF,这样才能在解码时正确padding对应的0x000xFF

相比您不再需要额外标记原输入是否为负数从而决定应该删除或添加0x000xFF作为padding
因为IEEE 754本身就包含了符号位来表明其是否是负数: http://www.c-jump.com/bcc/common/Talk2/Cxx/IEEE_754_fp_standard/IEEE_754_fp_standard.html
image


但如果您的输入大多数时候都是整数只有少数输入是小数那么将其转为IEEE 754并没有位数优势:

$d = [];
for ($i = 0; $i <= 1000; $i+=1) {
    $b = strlen(rtrim(base64_encode(rtrim(pack('E', $i), "\x00")), '='));
    $o = strlen($i);
    $l = "$b $o";
    if (end($d) !== $l) $d[$i] = $l;
}
echo json_encode($d, JSON_PRETTY_PRINT);
{
...
"77": "4 2",
"80": "3 2",
"81": "4 2",
"84": "3 2",
"85": "4 2",
"88": "3 2",
"89": "4 2",
"92": "3 2",
"93": "4 2",
"96": "3 2",
"97": "4 2",
"100": "3 3",
"101": "4 3",
"104": "3 3",
"105": "4 3",
"108": "3 3",
"109": "4 3",
"112": "3 3",
"113": "4 3",
"116": "3 3",
"117": "4 3",
"120": "3 3",
"121": "4 3",
"124": "3 3",
"125": "4 3",
"128": "3 3",
"129": "4 3",
"136": "3 3",
"137": "4 3",
"144": "3 3",
"145": "4 3",
"152": "3 3",
"153": "4 3",
"160": "3 3",
"161": "4 3",
"168": "3 3",
"169": "4 3",
"176": "3 3",
"177": "4 3",
"184": "3 3",
"185": "4 3",
"192": "3 3"
...
}

因为对于整数人类通常会只保留其有效位数,对于整数是省略无意义的.0小数部分
而IEEE 754则是基于2幂的组合公式来迫近输入值(见上图和下文),所以对于整数输入总会比输出短
这跟整数输入时省略正号的正数是类似的:

点击图例隐藏系列原始输入后不省略正号的正数(+1)跟必须带负号的负数(-1)的长度分布是完全相同的

部分输入是小数时:

$d = [];
for ($i = 0; $i <= 1000; $i+=0.5) {
    $b = strlen(rtrim(base64_encode(rtrim(pack('E', $i), "\x00")), '='));
    $i2 = number_format($i, 1, thousands_separator: '');
    $o = strlen($i2);
    $l = "$b $o";
    if (end($d) !== $l) $d[$i2] = $l;
}
echo json_encode($d, JSON_PRETTY_PRINT);
{
...
"72.0": "3 4",
"72.5": "4 4",
"76.0": "3 4",
"76.5": "4 4",
"80.0": "3 4",
"80.5": "4 4",
"84.0": "3 4",
"84.5": "4 4",
"88.0": "3 4",
"88.5": "4 4",
"92.0": "3 4",
"92.5": "4 4",
"96.0": "3 4",
"96.5": "4 4",
"100.0": "3 5",
"100.5": "4 5",
"104.0": "3 5",
"104.5": "4 5",
"108.0": "3 5",
"108.5": "4 5",
"112.0": "3 5",
"112.5": "4 5",
"116.0": "3 5",
"116.5": "4 5",
"120.0": "3 5",
"120.5": "4 5",
"124.0": "3 5",
"124.5": "4 5",
"128.0": "3 5",
"128.5": "4 5",
"136.0": "3 5",
"136.5": "4 5",
"144.0": "3 5",
"144.5": "4 5",
"152.0": "3 5",
"152.5": "4 5",
"160.0": "3 5",
"160.5": "4 5",
"168.0": "3 5",
"168.5": "4 5",
"176.0": "3 5",
"176.5": "4 5",
...
}
$d = [];
for ($i = 0; $i <= 1000; $i+=0.1) {
    echo $i . "\n";
    $b = strlen(rtrim(base64_encode(rtrim(pack('E', $i), "\x00")), '='));
    $i2 = number_format($i, 1, thousands_separator: '');
    $o = strlen($i2);
    $l = "$b $o";
    if (end($d) !== $l) $d[$i2] = $l;
}
echo json_encode($d, JSON_PRETTY_PRINT);
{
"0.0": "0 3",
"0.1": "11 3",
"0.5": "3 3",
"0.6": "11 3",
"4.5": "3 3",
"4.6": "11 3",
"10.0": "11 4",
"19.0": "3 4",
"19.1": "11 4",
"31.8": "10 4",
"31.9": "11 4",
"44.6": "10 4",
"44.7": "11 4",
"100.0": "11 5",
"152.3": "10 5",
"152.4": "11 5",
"177.9": "10 5",
"178.0": "11 5",
"203.5": "10 5",
"203.6": "11 5",
"229.1": "10 5",
"229.2": "11 5",
"254.7": "10 5",
"254.8": "11 5",
"531.9": "10 5",
"532.0": "11 5",
"557.5": "10 5",
"557.6": "11 5",
"583.1": "10 5",
"583.2": "11 5",
"608.7": "10 5",
"608.8": "11 5",
"634.3": "10 5",
"634.4": "11 5",
"659.9": "10 5",
"660.0": "11 5",
"685.5": "10 5",
"685.6": "11 5",
"711.1": "10 5",
"711.2": "11 5",
"736.7": "10 5",
"736.8": "11 5",
"762.3": "10 5",
"762.4": "11 5",
"787.9": "10 5",
"788.0": "11 5",
"813.5": "10 5",
"813.6": "11 5",
"839.1": "10 5",
"839.2": "11 5",
"864.7": "10 5",
"864.8": "11 5",
"890.3": "10 5",
"890.4": "11 5",
"915.9": "10 5",
"916.0": "11 5",
"941.5": "10 5",
"941.6": "11 5",
"967.1": "10 5",
"967.2": "11 5",
"992.7": "10 5",
"992.8": "11 5"
}

请注意输出长度是如何由于浮点精度丢失而退化到定长的10/11字符长的


例如32和64位IEEE 754的2幂公式都不可能准确表达十进制小数1.2
image
https://www.exploringbinary.com/floating-point-converter/
所以需要对IEEE 754的2幂公式的结果做舍入才能迫近原来的1.2https://en.wikipedia.org/wiki/IEEE_754#Rounding_rules

而这就导致其base64输出是11字符长的P/MzMzMzMzMhttps://3v4l.org/QdLQB

$i = rtrim(base64_encode(rtrim(pack('E', 1.2), "\x00")), '=');
var_dump($i);
var_dump(unpack('E', str_pad(base64_decode($i), 8, "\x00")));

image

image
https://cryptii.com/pipes/base64-to-hex
也就是
image
https://www.exploringbinary.com/floating-point-converter/


再例如对于32位IEEE 754,2147483647会迫近到2147483648
image
https://www.h-schmidt.net/FloatConverter/IEEE754.html

因为2147483648=1 * 2^31
image
https://www.exploringbinary.com/floating-point-converter/

而换成64位IEEE 754表达就可以通过1.999999999068677425384521484375 * 2^30(十进制)来迫近输入2147483647
image
https://www.binaryconvert.com/result_double.html?decimal=050049052055052056051054052055

十进制的1.999999999068677425384521484375是mantissa的值(其二进制为1111111111111111111111111111110000000000000000000000),而30是exponent(二进制10000011101,十进制1053)的求值:1053-1023=30
1053这个magic num来自 https://en.wikipedia.org/wiki/Exponent_bias

对于双精度数,指数存储在 1 .. 2046 范围内(0 和 2047 具有特殊含义),并通过减去 11 位指数(1023)的偏差来得到范围 -1022 .. +1023内的指数值。

最后计算 $-1^{sign}*1.\text{mantissa}*2^{\text{exponent}-1023}$ (sign位为0表示输入是正数-1^0=1,为1是负-1^1=-1)就是最接近原输入的整/小数值

这也就是为什么0.1 - 0.2 != 0.3https://0.30000000000000004.com


您也可以选择仍然将小数删除小数点转为整数输入,然后额外注明小数点位于整数的第几位,也就是使用定点数而不是浮点数: https://en.wikipedia.org/wiki/Fixed-point_arithmetic
而且定点数不会像浮点数那样对于超出范围的整/小数都丢失了精度

如果您的确不在乎精度丢失,您可以选择其他比IEEE 754精度更差但更短范围更大的浮点数表达: https://en.wikipedia.org/wiki/Minifloat 其相当于8位版本的IEEE 754

1 字节的 minifloats 的表达范围为 ±122 880,而不是具有-128 到 +127 范围的二进制补码整数。较大的范围由较差的精度补偿,因为只有 4 个尾数位,相当于略多于一位小数。它们的范围也比范围为 ±65 504 的半精度 minifloats 更大,这也弥补了缺少分数和精度差的问题。


这个编码方式目前在tbm中用于表述会嵌入前端页面url querystring中的在 44be32e 中从传统的偏移分页(offset pagination)切换为游标分页(cursor pagination)的帖子查询接口所产生的至多6个游标值:
44be32e...673b7c3
https://github.com/n0099/TiebaMonitor/blob/673b7c35f03d56d4fa1b04dad01e5059a2e597b4/be/app/Http/PostsQuery/BaseQuery.php#L101
https://github.com/n0099/TiebaMonitor/blob/673b7c35f03d56d4fa1b04dad01e5059a2e597b4/be/app/Http/PostsQuery/BaseQuery.php#L156
有关为何从传统的偏移分页(offset pagination)切换为游标分页(cursor pagination)可以查阅上海贵族信安底层壬上壬杨博文阁下 @yangbowen 此前与我的私聊记录,其优缺点将在下一篇文章中介绍:
https://medium.com/swlh/how-to-implement-cursor-pagination-like-a-pro-513140b65f32
https://slack.engineering/evolving-api-pagination-at-slack/
http://mysql.rjweb.org/doc.php/pagination

cc @Starry-ovo @Juicpt @BANKA2017 @kokoro-aya @ControlNet @FeiBam @langyo

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions