Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
n0099 opened this issue Dec 27, 2022 · 42 comments

Comments

@n0099
Copy link
Owner

n0099 commented Dec 27, 2022

对于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

@langyo
Copy link

langyo commented Dec 29, 2022

粗略看了一下,这有点类似我很久以前依赖大进制数字的进制位空余空间进行数据压缩的想法。

简单讲下,大概是这样的:

  1. 先确定一个起始进制,起始进制应当在可计算范围下足够的大,并且能够妥善分组容纳你需要压缩的数据。通常来讲,这应当是 2^8 的次幂进制,便于计算,例如 2^16(0x1_0000_0000)进制。
  2. 将你所要压缩的数据分组存储到起始进制中。例如0x0001FFFF, 0x0001FFFF, 0x00110000, 0x00FF_1111
  3. 确定一个目标进制。在最优情况下,你应当指定刚刚分组数据中数字最大的一组再加一作为目标进制。在这个例子中,最大的一组是0x00FF_1111,那么下面要用的目标进制最小就可以选择0x00FF_1112
  4. 将原来的以0x1_0000_0000进制的数据直接视为0x00FF_1112进制的数据,然后直接向0x1_0000_0000进制转换。0x1ffff * 0xff1112 ^ 3 + 0x1ffff * 0xff1112 ^ 2 + 0x110000 * 0xff1112 ^ 1 + 0xff1111 * 0xff1112 ^ 0,最终结果为0x01FA_699B, 0x2290_FF92, 0x25DF_0905

可以明显看到,整个数据直接少了一位数字(相对于这个大进制而言)。

如果有别的手段压缩或者记录目标进制,确实可以做到压缩数据。但这其实只是一个数学上的小把戏,因为本质上我只是先假定这些数据占据有多大的进制空间,然后将它们收缩起来而已,实际上数据的熵还是不变的。

别误会,这只是我初中那会儿想出来的东西,放到现在肯定有很多不完善的地方。我当初确实是希望它能作为一个新的数据压缩方法来使用的,但后来发现它可能还是更适合拿来重新排列数据、然后再交给字典压缩算法进行压缩罢了。

有很多思路可以优化这个方法,从而进一步提升压缩数据的可能性,例如目标进制记录时不再直接记录具体进制的数字,而是记录这个目标进制与起始进制的差(例如0x1_0000_0000进制转换到0xAAFF_1234进制,取差后就变为0x5500_EDCC),或者丢失一部分精度的目标进制信息、再用汉明码来补充纠正信息差造成的目标进制歧义等等。

不多说了,我也只是提个思路。

最后,您真要讨论,不抵回群里,一群人念叨你都没处念叨……隐退不是这么个隐退法子,很多都是你一手创办的东西,是跟你个人绑定的,你不可能完全丢掉它们。

@n0099
Copy link
Owner Author

n0099 commented Dec 30, 2022

  1. 例如 2^16(0x1_0000_0000)进制

0x1_0000_00002^32=4294967296,所以您是说base65535还是base4294967296?我猜是后者

  1. 例如0x0001FFFF, 0x0001FFFF, 0x00110000, 0x00FF_1111

131071, 131071, 1114112, 16716049

0x1ffff * 0xff1112 ^ 3 + 0x1ffff * 0xff1112 ^ 2 + 0x110000 * 0xff1112 ^ 1 + 0xff1111 * 0xff1112 ^ 0

在这里就已经去除了原输入中每组数最左侧的无意义的leading zeros

记录目标进制

然而额外记录0xFF1112也需要空间

本质上我只是先假定这些数据占据有多大的进制空间

您已经找出了最大值是多少,自然可以将原先base4294967296的巨大取值集合映射到一个有限的base0xFF1112取值集合中

实际上数据的熵还是不变的

各种编码转换也是保证信息熵不变的,因为他们都是双射函数(而有损压缩那样的就不是)
而所以本文的总体思路不过就是砍掉无意义padding值(定长int的0x00,base64的=)后对仅剩的significant figures做编码转换而已
所以才会在处理IEEE 754时失败,因为他的结构是三个不相关的部分(sign, exponent, mantissa)组成的

重新排列数据、然后再交给字典压缩算法进行压缩罢了

然而输出0x01FA_699B, 0x2290_FF92, 0x25DF_0905很明显比输入0x0001FFFF, 0x0001FFFF, 0x00110000, 0x00FF_1111更乱更难以压缩(不论是huffman还是字典)

记录这个目标进制与起始进制的差

之前我跟杨博文阁下讨论protobuf的varint时提到过把一个unix timestamp减去固定值(如1672435424-1600000000=72435424)从而使他更能被varint缩小
protobuf的varint是通过增加 较大输入数的输出位 来换取 减少较小输入数的输出位
杨博文阁下进一步指出根据香农第三定理不可能有编码能够缩小任何可能输入的长度而不是增长他
而四叶TG群的新创无际哲学家晓x_东方道明不需要信息论:将任意编码视作函数,当输出值集合的基数小于输入值集合时,他不可能做到对于任何输入集合都双射到另一个输出集合中,最多做到满射(也就是不同输入可能有相同输出,这就使得他是不可逆的)

然而做减法对于本文的这个编码而言没用,因为根据上文中的下表

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

可见除非您的输入大到减去固定值后比输入小
65535-255=65280
16777215-65535=16711680
4294967295-16777215=4278190080
1099511627775-4294967295=1095216660480
...
不然并不会减少输出位数,而如果差值是负数那还

需要额外标记原输入是否为负数从而决定应该删除或添加0x00或0xFF作为padding


再用汉明码来补充纠正信息差造成的目标进制歧义

https://en.wikipedia.org/wiki/Hamming_code 指出:

汉明码的最小距离为 3,这意味着解码器可以检测并纠正单个错误,但无法区分某个码字的双比特错误和不同码字的单个比特错误。因此,某些双位错误将被错误地解码,就好像它们是单位错误一样,因此不会被检测到,除非不尝试进行更正。
为了弥补这个缺点,可以通过额外的奇偶校验位来扩展汉明码。这样,可以将汉明码的最小距离增加到 4,从而使解码器能够区分单比特错误和双比特错误。因此,解码器可以检测并纠正单个错误,同时检测(但不纠正)双重错误。
如果解码器不尝试纠正错误,它可以可靠地检测到三位错误。如果解码器确实纠正了错误,一些三重错误将被误认为是单一错误并“纠正”为错误值。因此,纠错是确定性(可靠地检测三位错误的能力)和弹性(面对单个位错误时保持运行的能力)之间的权衡。

@n0099
Copy link
Owner Author

n0099 commented Dec 30, 2022

https://github.com/qntm/base65536
https://github.com/qntm/base2048
https://github.com/keith-turner/ecoji

Encoding Efficiency Bytes per Tweet *
UTF‑8 UTF‑16 UTF‑32
ASCII‑constrained Unary / Base1 0% 0% 0% 1
Binary 13% 6% 3% 35
Hexadecimal 50% 25% 13% 140
Base64 75% 38% 19% 210
Base85 † 80% 40% 20% 224
BMP‑constrained HexagramEncode 25% 38% 19% 105
BrailleEncode 33% 50% 25% 140
Base2048 56% 69% 34% 385
Base32768 63% 94% 47% 263
Full Unicode Ecoji 31% 31% 31% 175
Base65536 56% 64% 50% 280
Base131072 53%+ 53%+ 53% 297

https://github.com/qntm/hexagram-encode

将二进制数据转换为易经卦象的字符串并返回。原始数据中的位在生成的六边形中直观地表示为断线或未断线。例如二进制序列0b00000000、0b01010101、0b11111111就变成了“䷁代䷄䷀”。
这本质上是 Base64 编码,用不同的代码点替换字母数字字符,使原始位可见。每个六角形代表一个 Base64 字符(即从 0 到 63 的二进制数),最高位在顶部。类比电子电路,虚线代表0,实线代表1。

alphabet:

䷁䷗䷆䷒䷎䷣䷭䷊
䷏䷲䷧䷵䷽䷶䷟䷡
䷇䷂䷜䷻䷦䷾䷯䷄
䷬䷐䷮䷹䷞䷰䷛䷪
䷖䷚䷃䷨䷳䷕䷑䷙
䷢䷔䷿䷥䷷䷝䷱䷍
䷓䷩䷺䷼䷴䷤䷸䷈
䷋䷘䷅䷉䷠䷌䷫䷀

Base64 使用第 65 个字符进行填充;在这里,我们使用 U+262F YIN YANG,"☯️"。与 Base64 一样,"☯️" 在卦编码字符串的末尾表示“忽略最终卦中最下面的两个虚线”,并且存在“☯️☯️”结尾的意思是“忽略最下面的四个虚线”。
显然,这几乎没有实际用途。生成的字符串明显大于 UTF-8 中的原始 Base64(尽管 UTF-16 或 UTF-32 中的大小相同),它不会在大多数字体中呈现,并且原始的 8 位字节被混淆在 Base64 中的 6 位切割。

https://github.com/qntm/braille-encode

将二进制数据转换为盲文并返回。这个想法是盲文文本在视觉上类似于原始二进制文件。例如,二进制序列 0b11110001 0b10100101 变为“⣇⢕”。每列代表每个半字节,最高有效位在顶部。

alphabet:

⠀⢀⠠⢠⠐⢐⠰⢰⠈⢈⠨⢨⠘⢘⠸⢸
⡀⣀⡠⣠⡐⣐⡰⣰⡈⣈⡨⣨⡘⣘⡸⣸
⠄⢄⠤⢤⠔⢔⠴⢴⠌⢌⠬⢬⠜⢜⠼⢼
⡄⣄⡤⣤⡔⣔⡴⣴⡌⣌⡬⣬⡜⣜⡼⣼
⠂⢂⠢⢢⠒⢒⠲⢲⠊⢊⠪⢪⠚⢚⠺⢺
⡂⣂⡢⣢⡒⣒⡲⣲⡊⣊⡪⣪⡚⣚⡺⣺
⠆⢆⠦⢦⠖⢖⠶⢶⠎⢎⠮⢮⠞⢞⠾⢾
⡆⣆⡦⣦⡖⣖⡶⣶⡎⣎⡮⣮⡞⣞⡾⣾
⠁⢁⠡⢡⠑⢑⠱⢱⠉⢉⠩⢩⠙⢙⠹⢹
⡁⣁⡡⣡⡑⣑⡱⣱⡉⣉⡩⣩⡙⣙⡹⣹
⠅⢅⠥⢥⠕⢕⠵⢵⠍⢍⠭⢭⠝⢝⠽⢽
⡅⣅⡥⣥⡕⣕⡵⣵⡍⣍⡭⣭⡝⣝⡽⣽
⠃⢃⠣⢣⠓⢓⠳⢳⠋⢋⠫⢫⠛⢛⠻⢻
⡃⣃⡣⣣⡓⣓⡳⣳⡋⣋⡫⣫⡛⣛⡻⣻
⠇⢇⠧⢧⠗⢗⠷⢷⠏⢏⠯⢯⠟⢟⠿⢿
⡇⣇⡧⣧⡗⣗⡷⣷⡏⣏⡯⣯⡟⣟⡿⣿

这是对盲文的劫持/重新利用,就像 Base64 重新利用字母数字 ASCII 一样——它很可能对盲文用户没有用
给定 1MB 的输入,braille-encode返回 3.00MB 的 UTF-8、2.00MB 的 UTF-16 或 4.00MB 的 UTF-32。
比较 Base64,它返回 1.33MB 的 UTF-8、2.67MB 的 UTF-16 或 5.33MB 的 UTF-32。

疑似BCD8421 https://en.wikipedia.org/wiki/Binary-coded_decimal
image

@langyo
Copy link

langyo commented Dec 31, 2022

  1. 例如 2^16(0x1_0000_0000)进制

0x1_0000_00002^32=4294967296,所以您是说base65535还是base4294967296?我猜是后者

  1. 例如0x0001FFFF, 0x0001FFFF, 0x00110000, 0x00FF_1111

131071, 131071, 1114112, 16716049

0x1ffff * 0xff1112 ^ 3 + 0x1ffff * 0xff1112 ^ 2 + 0x110000 * 0xff1112 ^ 1 + 0xff1111 * 0xff1112 ^ 0

在这里就已经去除了原输入中每组数最左侧的无意义的leading zeros

记录目标进制

然而额外记录0xFF1112也需要空间

本质上我只是先假定这些数据占据有多大的进制空间

您已经找出了最大值是多少,自然可以将原先base4294967296的巨大取值集合映射到一个有限的base0xFF1112取值集合中

实际上数据的熵还是不变的

各种编码转换也是保证信息熵不变的,因为他们都是双射函数(而有损压缩那样的就不是) 而所以本文的总体思路不过就是砍掉无意义padding值(定长int的0x00,base64的=)后对仅剩的significant figures做编码转换而已 所以才会在处理IEEE 754时失败,因为他的结构是三个不相关的部分(sign, exponent, mantissa)组成的

重新排列数据、然后再交给字典压缩算法进行压缩罢了

然而输出0x01FA_699B, 0x2290_FF92, 0x25DF_0905很明显比输入0x0001FFFF, 0x0001FFFF, 0x00110000, 0x00FF_1111更乱更难以压缩(不论是huffman还是字典)

记录这个目标进制与起始进制的差

之前我跟杨博文阁下讨论protobuf的varint时提到过把一个unix timestamp减去固定值(如1672435424-1600000000=72435424)从而使他更能被varint缩小 protobuf的varint是通过增加 较大输入数的输出位 来换取 减少较小输入数的输出位 杨博文阁下进一步指出根据香农第三定理不可能有编码能够缩小任何可能输入的长度而不是增长他 而四叶TG群的新创无际哲学家晓x_东方道明不需要信息论:将任意编码视作函数,当输出值集合的基数小于输入值集合时,他不可能做到对于任何输入集合都双射到另一个输出集合中,最多做到满射(也就是不同输入可能有相同输出,这就使得他是不可逆的)

然而做减法对于本文的这个编码而言没用,因为根据上文中的下表

n 2^(8n)-1 strlen 2^(8n) 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
可见除非您的输入大到减去固定值后比输入小 65535-255=6528016777215-65535=167116804294967295-16777215=42781900801099511627775-4294967295=1095216660480 ... 不然并不会减少输出位数,而如果差值是负数那还

需要额外标记原输入是否为负数从而决定应该删除或添加0x00或0xFF作为padding

再用汉明码来补充纠正信息差造成的目标进制歧义

https://en.wikipedia.org/wiki/Hamming_code 指出:

汉明码的最小距离为 3,这意味着解码器可以检测并纠正单个错误,但无法区分某个码字的双比特错误和不同码字的单个比特错误。因此,某些双位错误将被错误地解码,就好像它们是单位错误一样,因此不会被检测到,除非不尝试进行更正。
为了弥补这个缺点,可以通过额外的奇偶校验位来扩展汉明码。这样,可以将汉明码的最小距离增加到 4,从而使解码器能够区分单比特错误和双比特错误。因此,解码器可以检测并纠正单个错误,同时检测(但不纠正)双重错误。
如果解码器不尝试纠正错误,它可以可靠地检测到三位错误。如果解码器确实纠正了错误,一些三重错误将被误认为是单一错误并“纠正”为错误值。因此,纠错是确定性(可靠地检测三位错误的能力)和弹性(面对单个位错误时保持运行的能力)之间的权衡。

是这样的,所以我强调了这只是一个数学上的小把戏,现在只能当作一种玩具算法来用,而且相比较于其它的数据重排算法来说,这个算法要的算力明显不占优势——别的算法大多可以并行计算,这个不好并行

@yangbowen
Copy link

yangbowen commented Jan 8, 2023

关于端序

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

那么,为什么对于负数部分要换成 native endian 而不是 little endian ?如果您的目标是减少因数值的绝对值常常比整数类型能容纳的范围来得小而造成的冗余的话,那这里理应总是 little endian 而不是 native endian 。因为只有 little endian 的情况下,冗余的高位才出现在 pack 结果的右边部分。这对不论非负数(的高位 0 )还是(采用 Two's complement 编码的)负数(的高位 1 )来说都是这么回事。不但如此,这里采取 native endian 实际上还会破坏可移植性——在 little endian 的平台和 big endian 的平台之间无法保持相同的表达。

什么情况可以压缩

无损压缩算法不能凭空减少数据长度,只能减少本来就信息冗余的部分。不论采取何种设计,实际能达到的效果是取决于输入数据的分布情况的。具体来说,您的这个压缩因为省去的是高位多出的 0 或者 1 ,所以适用于 绝对值比较小的数值更频繁出现 的情况。
如果输入数据的分布完全是均匀的,那就完全没有压缩的余地。对 基于有效位的 这个算法来说也是这么回事:如果更靠近 0 的数值并没有更高的出现频率,那它只保留有效位是起不到任何压缩效果的。
压缩算法的设计决定它适用于输入数据的何种分布不均匀。例如对于这个算法而言:

  • 先确定一个起始进制,起始进制应当在可计算范围下足够的大,并且能够妥善分组容纳你需要压缩的数据。通常来讲,这应当是 2^8 的次幂进制,便于计算,例如 2^16(0x1_0000_0000)进制。

  • 将你所要压缩的数据分组存储到起始进制中。例如0x0001FFFF, 0x0001FFFF, 0x00110000, 0x00FF_1111

  • 确定一个目标进制。在最优情况下,你应当指定刚刚分组数据中数字最大的一组再加一作为目标进制。在这个例子中,最大的一组是0x00FF_1111,那么下面要用的目标进制最小就可以选择0x00FF_1112

  • 将原来的以0x1_0000_0000进制的数据直接视为0x00FF_1112进制的数据,然后直接向0x1_0000_0000进制转换。0x1ffff * 0xff1112 ^ 3 + 0x1ffff * 0xff1112 ^ 2 + 0x110000 * 0xff1112 ^ 1 + 0xff1111 * 0xff1112 ^ 0,最终结果为0x01FA_699B, 0x2290_FF92, 0x25DF_0905

它所适用的情况(只对于那样的情况能起到压缩的作用)就不一样:它适用于被压缩的数据(比起均匀分布而言)更可能在每个 起始进制分组 全都有一些高位 0 的情况。如果这样的输入并不比其它输入更频繁地出现的话,这个算法就毫无作用。
需要指出,不论对于怎样的设计而言,构造能被它有效地压缩的单个(或者多个)输入的个别例子,通常都是不难的。但要起到作用的话,重要的是它对它实际用于的输入数据的分布是适合的。很多场景下被压缩的数值确实更可能很接近 0 ,而不是均匀分布。而对于那个 分组 的算法来说,说实话我想不出什么场景下输入数据的分布真的会接近适合这个算法的情况——除开专门为它构造例子而言。

数字字符串和人类可读字符串表达

压缩的结果需要人类可读,或者需要由人类可读的字符组成,这样的要求同样是取决于具体场景的。而且数字字符串除了满足 人类可读 以外,其实还满足了 人类可算 。
Base64 可能是最常见的将任意二进制串编码为人类可读的、人类可输入的、仅 ASCII 字符的字符串表示,的编码方式。但其实并不难发明其它的人类可读字符串的编码方式,并实现不同的取舍目标。比方说如果您不要求仅 ASCII 字符的话,您也不难纳入同样人类可读的 西欧字符集 或者 CJK表意文字 。例如您可以预先确定将 Unicode 的哪些部分纳入您的编码当中,然后简单地对二进制串作进制分组,以 您的编码包含多少种字符 作为进制,然后用每个该进制的位从您的编码表中索引。取决于输出字符串的编码方式,这样有可能达到更好或更差的编码效率。事实上这和 Base64 的原理差不多—— Base64 无非就是以 64 进制分组,然后以每个 64 进制位从 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ 当中索引,最后填充 = 来表示空出的部分。
除了编码效率以外也有其它的取舍,例如算法实现难度、计算性能、避免特殊符号、避免易混淆字符等。例如 RFC4648 的 Base32 编码表只含大写字母且去除了视觉上易混淆的 0 1 8 。
总之,取决于您想要怎样的取舍,这个部分有一些自定义的空间。只不过 Base64 和 Base32 等对大部分情况而言都是比较好的选择了。

对 pack 结果的压缩——如何做得更好?

有符号数

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

比起通过额外的元数据来记录是否负数而言,我的建议是您仿照有符号数的 Sign extension 的过程,采用截断高位后的数值的最高位作为这个符号标记。即,截断时保证对正数保留至少一个高位 0 位,而对负数保留至少一个高位 1 位。解码时根据最高位是 0 还是 1 选择填充 0 还是填充 1 。

高位截断

从您的例子当中也许您也会注意到一件事:二进制数据中连续的 00 被 Base64 为连续的 A ,而二进制数据中连续的 FF 被 Base64 为连续的 / 。这当然不是巧合,而是因为 64 是 2 幂,而 A 和 / 刚好分别是 Base64 编码表的 0 和 63 。
这提示了对截断步骤的一个改进。比起在二进制字节串中去掉高位的连续 \x00 或 \xFF 而言,更合理的其实是对 Base64 输出去掉对应于高位的 A 或 / 。由于是 Base64 编码,所以, 由于是从二进制高位去掉 \x00 或 \xFF ,而截取的更多二进制高位,如果不反映为 Base64 右侧更少的 A 或 / 的话就意味着没有额外的压缩 而且 由于只从二进制高位去掉整个 \x00 或 \xFF ,而余留下的二进制高位,如果反映为 Base64 右侧额外的 A 或 / 的话就意味着不充分的压缩
作为例子,您的示例当中 0 编码为 AA ,而 1 编码为 AQ 。前者比起后者虽然在 rtrim(...,"\x00") 的步骤多删去了一个字节,但在 Base64 之后其实还是 2 个 Base64 字符长。

整数和字节

Base64 是将字节串编码为 Base64 字符串,而 pack 的步骤则将输入的整数填充为字节串。 Base64 的 = 填充正是由于它要把 8bit 一组的输入编码成 6bit 一组的输出而造成的,与此同时从上面说的可以看出,对 8bit 组(也就是字节)作高位截断其实并没有发挥充分的效果。所以如果考虑 Base64 的内部设计的话,就会发现这里实际上是迂回的——输入的整数先被 8bit 分组,然后 Base64 的原理又要重新将它看作整数。这个迂回也造成了 = 填充的问题。

总之……

综合上面说的,如果希望编码成的是 Base64 这样的以 6bit 为单位的编码表的话,建议的做法是这样:

  • 将输入整数拆分成每组 6bit 的分组,且对有符号数采用 Two's complement
  • 在保证截断后最高的组的最高 bit 符合输入数的符号的前提下,去掉对应高位的 0 或 63 。(如果输入数为正,那么只能去掉若干个 0 ,而且去掉之后最高的必须是 0~31 ;如果输入数为负,那么只能去掉若干个 63 ,而且去掉之后最高的必须是 32~63 )
  • 用得到的每个组的值,在 Base64 编码表中索引,产生一个输出字符

对应的解码过程就是:

  • 对每个输入字符,根据 Base64 编码表,将它反向映射为索引值
  • 每个索引值对应一个 6bit 分组。如果最高的组是 0~31 ,那么结果非负,填充 0 ;否则结果为负,填充 63 。
  • 组合这些分组,得到解码输出的整数。

如何实现?

Base64 只接受( 8bit 为单位的)字节流输入,解码时也只输出字节流。不满整字节边界的位流似乎不能被 Base64 解码出来,换言之即便不填充,由 Base64 编码表字符组成的排列组合,也有一些是不可能的 Base64 编码。对于压缩整数这个场景而言这是造成冗余的。所以我一下子想不出简单的办法来复用 Base64 编码解码函数。那么如果不复用的话,对于上面的方案,我试着用我比较熟悉的 C++ 来写一下。以 int64 范围为例,且假定 char 为有符号 8 位。

constexpr std::size_t length_max = ((64 - 1) / 6) + 1;
constexpr std::size_t bits_remaining = 64 % 6;
char arr_table_encode[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::uint8_t arr_table_decode[256];
void generate_table_decode() {
   for (unsigned int i = 0; i < 256; ++i) arr_table_decode[i] = 64;
   for (std::uint8_t i = 0; i < 64; ++i) arr_table_decode[static_cast<unsigned char>(arr_table_encode[i])] = i;
}
std::string encode_int64(std::int64_t value) {
   std::uint8_t arr_digit[length_max];
   for (std::size_t i = 0; i < length_max; ++i) {
      arr_digit[i] = value & 63;
      // signed bitwise-shift is arithmetic shift
      // do arithmetic shift, so that the highest remaining bits are sign-extended
      value >>= 6;
   }
   std::size_t length = length_max;
   if (arr_digit[length - 1] == 0) {
      while (length >= 2 && arr_digit[length - 1] == 0 && arr_digit[length - 2] < 32) --length;
      // length == 1 || arr_digit[length - 1] != 0 || arr_digit[length - 2] >= 32
      // remove the following line to avoid empty string output
      if (length == 1 && arr_digit[0] == 0) --length;
   } else if (arr_digit[length - 1] == 63) {
      while (length >= 2 && arr_digit[length - 1] == 63 && arr_digit[length - 2] >= 32) --length;
      // length == 1 || arr_digit[length - 1] != 63 || arr_digit[length - 2] < 32
   }
   std::string str;
   for (std::size_t i = 0; i < length; ++i) str.push_back(arr_table_encode[arr_digit[i]]);
   return str;
}
std::int64_t decode_int64(std::string str) {
   std::uint8_t arr_digit[length_max];
   assert(str.size() <= length_max);// may not exceed max length
   if (str.empty()) return 0;// empty string either decodes to 0 or is invalid
   {
      std::size_t i = 0;
      for (; i < str.size(); ++i) {
         assert(arr_table_decode[str[i]] < 64);// there's invalid character
         arr_digit[i] = arr_table_decode[str[i]];
      }
      // if the highest bit is 0, extend 0
      // otherwise, extend 63
      std::uint8_t digit_extend = ~((arr_digit[str.size() - 1] >> 5) - 1) & 63;
      for (; i < length_max; ++i) arr_digit[i] = digit_extend;
   }
   assert(arr_digit[length_max - 1] < (1 << (bits_remaining - 1)) || arr_digit[length_max - 1] >= -(1 << (bits_remaining - 1)));// may not exceed the range of int64_t
   std::uint64_t unsigned_value = 0;
   for (int i = length_max - 1; i >= 0; --i) {
      unsigned_value <<= 6;
      unsigned_value |= static_cast<std::uint64_t>(arr_digit[i]);
   }
   return unsigned_value;
}

@n0099
Copy link
Owner Author

n0099 commented Jan 9, 2023

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

那么,为什么对于负数部分要换成 native endian 而不是 little endian ?如果您的目标是减少因数值的绝对值常常比整数类型能容纳的范围来得小而造成的冗余的话,那这里理应总是 little endian 而不是 native endian 。因为只有 little endian 的情况下,冗余的高位才出现在 pack 结果的右边部分。这对不论非负数(的高位 0 )还是(采用 Two's complement 编码的)负数(的高位 1 )来说都是这么回事。

这是php人从perl人抄来的(un)pack()没抄完的问题,他对于有符号int和32/64位IEEE754只提供了php运行时平台端序(native endian)的format:
image
perl那边反而没有平台端序的format
如果php人真的像c人那样担心不同arch下的不同行为那应该像老c人那样运行时判断当前端序手写位运算以获得全平台兼容性

更何况我后来又发现连指定Pq都无法改变端序:

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

所以后文中都是说的P或q,因为对php的pack()而言没区别


不但如此,这里采取 native endian 实际上还会破坏可移植性——在 little endian 的平台和 big endian 的平台之间无法保持相同的表达。

现在消费级常见cpu arch中也就基于RISC的arm还是大端了(实际上现在是可变端序: https://developer.arm.com/documentation/ddi0406/cb/Appendixes/ARMv4-and-ARMv5-Differences/Application-level-memory-support/Endian-support )建议致电在树莓派上跑php的 @BANKA2017 和薅oracle arm服务器羊毛的hostloc主机佬来试试php的arm构建结果运行时到底用的小还是大端序


无损压缩算法不能凭空减少数据长度,只能减少本来就信息冗余的部分。不论采取何种设计,实际能达到的效果是取决于输入数据的分布情况的。具体来说,您的这个压缩因为省去的是高位多出的 0 或者 1 ,所以适用于 绝对值比较小的数值更频繁出现 的情况。

您前几个月在tg上跟我私聊时探讨protobuf所使用的varint实现时已经说过这些了


如果输入数据的分布完全是均匀的,那就完全没有压缩的余地。对 基于有效位的 这个算法来说也是这么回事:如果更靠近 0 的数值并没有更高的出现频率,那它只保留有效位是起不到任何压缩效果的。

一串md5 sha那样的hash(输出均匀分布)或是某种鉴权token那毫无规律可言也没人会专门去缩短他们的长度(除了币圈助记符),而且真压缩了反而可能有很难利用的旁信道攻击如timing attack,因为如果随机输出中真的由于巧合或是输入而出现了规律那mitm也会发现您利用规律压缩后传输的输出有所不同
不禁令我想起在百度全产品中使用的维持用户登录态默认有效期到2030年的经典cookie BDUSS中就必定有一大堆AAAAAAAAAAAAAAAAAAAAAAAA,大概率是base64encode后的0x00


它所适用的情况(只对于那样的情况能起到压缩的作用)就不一样:它适用于被压缩的数据(比起均匀分布而言)更可能在每个 起始进制分组 全都有一些高位 0 的情况。如果这样的输入并不比其它输入更频繁地出现的话,这个算法就毫无作用。

我只看懂了伊欧神举的那个例子本身是如何计算的,但我根本不知道为什么要这样做,也不知道什么特征的输入会输出更短,什么会更长
我甚至不知道例子中最初输入的base65535进制下的0x0001FFFF, 0x0001FFFF, 0x00110000, 0x00FF_1111转换为人类用的base10数字表达是多长,建议立即收录进大数wiki https://googophpy.fandom.com


需要指出,不论对于怎样的设计而言,构造能被它有效地压缩的单个(或者多个)输入的个别例子,通常都是不难的。但要起到作用的话,重要的是它对它实际用于的输入数据的分布是适合的。很多场景下被压缩的数值确实更可能很接近 0 ,而不是均匀分布。而对于那个 分组 的算法来说,说实话我想不出什么场景下输入数据的分布真的会接近适合这个算法的情况——除开专门为它构造例子而言。

所以1楼位数分布节中我对绝对值在 $2^(8*n)-1 n=\{1..7\}$ 之内的输入的输出位数分布做了统计 https://jsfiddle.net/x6wp2kno 得出而往后输入每次达到2^(8*n)时输出的长度才会增加一位的结论


压缩的结果需要人类可读,或者需要由人类可读的字符组成,这样的要求同样是取决于具体场景的。

我的需求场景就是url safe,因为cursor pagination的cursor值应该放在GET method的url里而不是经典POST的request body(还真有人想把url缩短就改成POST的结果f5一下浏览器就弹窗是否重复提交表单),不然就无法分享当前页面的canonical url给别人看,那cursor paging比起offset paging所带来的防止page shifting/drift优势就毫无用武之地了
而众所周知uri中只允许[A-Za-z0-9-_.~]直接保持原样存在而无需urlencode转义: https://en.wikipedia.org/wiki/Percent-encoding https://daniel.haxx.se/bphp/2022/09/08/http-http-http-http-http-http-http/ ,所以我还得把base64encode输出中的+/替换为-_https://en.wikipedia.org/wiki/Base64#URL_applications


而且数字字符串除了满足 人类可读 以外,其实还满足了 人类可算 。

准确地说是最常见的十进制阿拉伯数字,给您看一大坨hex或是大写汉字数字您也不好脑中做四则运算吧


Base64 可能是最常见的将任意二进制串编码为人类可读的、人类可输入的、仅 ASCII 字符的字符串表示,的编码方式。但其实并不难发明其它的人类可读字符串的编码方式,并实现不同的取舍目标。比方说如果您不要求仅 ASCII 字符的话,您也不难纳入同样人类可读的 西欧字符集 或者 CJK表意文字 。例如您可以预先确定将 Unicode 的哪些部分纳入您的编码当中,然后简单地对二进制串作进制分组,以 您的编码包含多少种字符 作为进制,然后用每个该进制的位从您的编码表中索引。取决于输出字符串的编码方式,这样有可能达到更好或更差的编码效率。事实上这和 Base64 的原理差不多—— Base64 无非就是以 64 进制分组,然后以每个 64 进制位从 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ 当中索引,最后填充 = 来表示空出的部分。

baseXX本就是XX进制的含义,只不过还额外修改了alphabet(用于表达这个encoding的可用字符集)
或者说人类用0-9也不过是印度-阿拉伯数字的定义(0也是后来发明的 https://en.wikipedia.org/wiki/0 ),建议学习真印度数字: https://mp.weixin.qq.com/s/a2UfKhOAIwAcxKp2n5-OPg ٠ ١ ٢ ٣ ٤ ٥ ٦ ٧ ٨ ٩
如同人类用的十进制是base10,alphabet是[0-9],而二进制是base2 01,hex是base16 [0-9a-z]
而在1楼和 #24 (comment) 中我已经考查过了其他使用更大范围unicode charset的子集作为alphabet的baseXX,我觉得他们适合用来整活就像同为针对推特经典140char hard limit脑瘫限制( https://developer.twitter.com/en/docs/counting-characters )的 https://github.com/tsunaminoai/baseEmoji
image
和HATETRIS(一个试图避免给您合适的下一个块总是给您S或Z-block的俄罗斯方块算法实现)作者发明的 https://github.com/qntm/base65536
以及贴吧近年来流行的用于加密传播敏感链接的兽音嗷呜语编码(基于十几年前的与佛论禅佛曰)


除了编码效率以外也有其它的取舍,例如算法实现难度、计算性能、避免特殊符号、避免易混淆字符等。例如 RFC4648 的 Base32 编码表只含大写字母且去除了视觉上易混淆的 0 1 8 。
总之,取决于您想要怎样的取舍,这个部分有一些自定义的空间。只不过 Base64 和 Base32 等对大部分情况而言都是比较好的选择了。

base32被btc用来做为钱包地址的编码,后来其他币也都是直接抄的
又发明出了币圈特色之助记符(用他们定义的常用en几k单词作为charset的encoding),在我看来不如emoji作为助记符,毕竟人脑更能记住形态多样的图案而不是更重复的字符


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

比起通过额外的元数据来记录是否负数而言,我的建议是您仿照有符号数的 Sign extension 的过程,采用截断高位后的数值的最高位作为这个符号标记。即,截断时保证对正数保留至少一个高位 0 位,而对负数保留至少一个高位 1 位。解码时根据最高位是 0 还是 1 选择填充 0 还是填充 1 。

我写了一遍: https://3v4l.org/PiE5u

<?php
// https://stackoverflow.com/questions/35536141/converting-binary-data-to-ascii-with-php-any-possibility/35536281#35536281
function bin2ascii($input) {
    $output = '';
    for($i=0; $i<strlen($input); $i+=8) {
        $output .= chr(intval(substr($input, $i, 8), 2));
    }
    return $output;
}
https://stackoverflow.com/questions/6382738/convert-string-to-binary-then-back-again-using-php/6382880#6382880
function ascii2bin($input) {
    return array_reduce(str_split($input), fn ($acc, $i) => $acc .= decbin(ord($i)));
    //return array_reduce(str_split($input), fn ($acc, $i) => $acc .= str_pad(decbin(ord($i)), 8, '0', STR_PAD_LEFT));
}

$i = -114514;
var_dump(                                                       decbin($i));
var_dump(                                                 ltrim(decbin($i), $i < 0 ? '1' : '0'));
var_dump(                          ($i < 0 ? '1' : '0') . ltrim(decbin($i), $i < 0 ? '1' : '0'));
var_dump(                bin2ascii(($i < 0 ? '1' : '0') . ltrim(decbin($i), $i < 0 ? '1' : '0')));
$o = rtrim(base64_encode(bin2ascii(($i < 0 ? '1' : '0') . ltrim(decbin($i), $i < 0 ? '1' : '0'))), '=');
var_dump($o);

$d = base64_decode($o);
var_dump($d);
$bits = ascii2bin($d);
$signBit = decbin(ord($d))[0];
$paddedBits = str_pad($bits, (strlen($bits) + (strlen($bits) % 8)) / 8, $signBit, STR_PAD_LEFT);
var_dump($bits);
var_dump($signBit);
var_dump($paddedBits);
var_dump(intval($paddedBits, 2));
string(64) "1111111111111111111111111111111111111111111111100100000010101110"
string(17) "00100000010101110"
string(18) "100100000010101110"
string(3) "�+�"
string(4) "kCsC"
string(3) "�+�"
string(16) "1001000010101110"
string(1) "1"
string(16) "1001000010101110"
int(37038)

如果您将输入的$i改成正数那么输出是完全正确的
但当输入是负数时直接往最高/低位追加一个10来代表符号很容易导致追加后的整个bin[]没有对齐到8位
实际上在ltrim(decbin($i), $i < 0 ? '1' : '0')时就已经没有对齐了,因为ltrim后只剩17位
所以追加1位变成18位也没有对齐到最近的8位
然后拿他去base64encode得到4位的kCsC
image

然后在解码时我们把base64decode结果byte[]拿去ascii2bin()就变成了1001000010101110

10010000 00101011 00000010 base64encode后的位
10010000   101011       10 ascii2bin()后的位

可见少了8位的0b0,怎么回事呢?
因为我们的ascii2bin()实现没有做padding:

array_reduce(str_split($input), fn ($acc, $i) => $acc .= decbin(ord($i)));

而decbin的输出本就是ltrim掉所有0b0了的,也就是只保留有效位
所以对于base64decode结果中的每个octet(8位字节),我们把每个字节开头的0b0都砍了导致输出缺少了8个位

那么我们把ascii2bin()换成额外做padding呢?

array_reduce(str_split($input), fn ($acc, $i) => $acc .= str_pad(decbin(ord($i)), 8, '0', STR_PAD_LEFT));

您会发现他的输出的确变成了base64decode的输出:

string(24) "100100000010101100000010"
string(1) "1"
string(24) "100100000010101100000010"
int(9448194)

但更关键的问题是我们要的根本不是24位长的100100000010101100000010,而是最开始18位长的100100000010101110
100100000010101110便乘100100000010101100000010实际上是发生在bin2ascii()之时:

// https://stackoverflow.com/questions/35536141/converting-binary-data-to-ascii-with-php-any-possibility/35536281#35536281
function bin2ascii($input) {
    $output = '';
    for($i=0; $i<strlen($input); $i+=8) {
        $output .= chr(intval(substr($input, $i, 8), 2));
    }
    return $output;
}

在这里是逐8个位去转换为对应的ascii码(chr())以便return后传给base64encode来编码(他只认字符串byte[]
于是:

100100000010101110   每8位拆开便乘了:
10010000 00101011 10 最后一个10不足8位于是被`intval(..., 2)`自动在高位填充了0b0

那1楼中的

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

为什么不会遇到这问题呢?
因为pack返回的已经是字符串byte[]了,每个成员都是octet
而rtrim掉的\x00也是一个octet,所以提供给base64encode的输入byte[]必定是对齐了的(这实际上是废话,没有对齐的bits无法放进byte,或者说放进去时就会自动补leading zeros)

所以我们需要手动完成正确的padding: https://3v4l.org/5Xj4i

<?php
// https://stackoverflow.com/questions/35536141/converting-binary-data-to-ascii-with-php-any-possibility/35536281#35536281
function bin2ascii($input) {
    $output = '';
    for($i=0; $i<strlen($input); $i+=8) {
        $output .= chr(intval(substr($input, $i, 8), 2));
    }
    return $output;
}
https://stackoverflow.com/questions/6382738/convert-string-to-binary-then-back-again-using-php/6382880#6382880
function ascii2bin($input) {
    //return array_reduce(str_split($input), fn ($acc, $i) => $acc .= decbin(ord($i)));
    return array_reduce(str_split($input), fn ($acc, $i) => $acc .= str_pad(decbin(ord($i)), 8, '0', STR_PAD_LEFT));
}
// https://stackoverflow.com/questions/11946696/php-signed-binary-strings/11946971#11946971
function bindec2($bin)
{
    if (strlen($bin) == 64 && $bin[0] == '1') {
        for ($i = 0; $i < 64; $i++) {
            $bin[$i] = $bin[$i] == '1' ? '0' : '1';
        }

        return (bindec($bin) + 1) * -1;
    }
    return bindec($bin);
}

$i = -114514;
var_dump(decbin($i));
$trimmedPadding = ltrim(decbin($i), $i < 0 ? '1' : '0');
var_dump($trimmedPadding);
$signBitsPading = $i < 0
    ? str_repeat('1', 8 - strlen($trimmedPadding) % 8)
    : str_repeat('0', 8 - strlen($trimmedPadding) % 8);
var_dump($signBitsPading . $trimmedPadding);
var_dump(bin2hex(bin2ascii($signBitsPading . $trimmedPadding)));
$o = rtrim(base64_encode(bin2ascii($signBitsPading . $trimmedPadding)), '=');
var_dump($o);

$d = base64_decode($o);
var_dump(bin2hex($d));
$bits = ascii2bin($d);
$signBit = $bits[0];
$paddedBits = str_pad($bits, 64, $signBit, STR_PAD_LEFT);
$restBits = substr($bits, 1);
var_dump($bits);
var_dump($signBit);
var_dump($restBits);
var_dump($paddedBits);
var_dump(bindec($paddedBits));
var_dump(intval($paddedBits, 2));
var_dump(bindec2($paddedBits));

$paddedHexs = str_pad($d, 8, $signBit === '0' ? "\x00" : "\xFF", STR_PAD_LEFT); // 保持decbin()输出的大端序octet
var_dump(bin2hex($paddedHexs));
var_dump(unpack('q', $paddedHexs)); // signed long long (always 64 bit, machine byte order)
var_dump(unpack('Q', $paddedHexs)); // unsigned long long (always 64 bit, machine byte order)
var_dump(unpack('J', $paddedHexs)); // unsigned long long (always 64 bit, big endian byte order)
var_dump(unpack('P', $paddedHexs)); // unsigned long long (always 64 bit, little endian byte order)
$paddedHexs = str_pad(strrev($d), 8, $signBit === '0' ? "\x00" : "\xFF", STR_PAD_RIGHT); // 大端序转小端序octet
var_dump(bin2hex($paddedHexs));
var_dump(unpack('q', $paddedHexs)); // signed long long (always 64 bit, machine byte order)
var_dump(unpack('Q', $paddedHexs)); // unsigned long long (always 64 bit, machine byte order)
var_dump(unpack('J', $paddedHexs)); // unsigned long long (always 64 bit, big endian byte order)
var_dump(unpack('P', $paddedHexs)); // unsigned long long (always 64 bit, little endian byte order)
string(64) "1111111111111111111111111111111111111111111111100100000010101110"
string(17) "00100000010101110"
string(24) "111111100100000010101110"
string(6) "fe40ae"
string(4) "/kCu"
string(6) "fe40ae"
string(24) "111111100100000010101110"
string(1) "1"
string(23) "11111100100000010101110"
string(64) "1111111111111111111111111111111111111111111111100100000010101110"
float(1.8446744073709437E+19)
int(9223372036854775807)
int(-114514)
string(16) "fffffffffffe40ae"
array(1) {
  [1]=>
  int(-5890427937135525889)
}
array(1) {
  [1]=>
  int(-5890427937135525889)
}
array(1) {
  [1]=>
  int(-114514)
}
array(1) {
  [1]=>
  int(-5890427937135525889)
}
string(16) "ae40feffffffffff"
array(1) {
  [1]=>
  int(-114514)
}
array(1) {
  [1]=>
  int(-114514)
}
array(1) {
  [1]=>
  int(-5890427937135525889)
}
array(1) {
  [1]=>
  int(-114514)
}

现在编码过程时输入给base64encoded的就已经是对齐好了的24位byte[],所以输出也是正确的

采用截断高位后的数值的最高位作为这个符号标记。即,截断时保证对正数保留至少一个高位 0 位,而对负数保留至少一个高位 1 位。解码时根据最高位是 0 还是 1 选择填充 0 还是填充 1 。

TL;DR:我们无法通过砍掉所有leadding 0/1后又只保留一个位就能保存符号信息,而是需要保留1~8个位
因为我们必须在base64decode前将bit[]通过padding对齐到byte[]octet,而这就意味着保存符号信息的位也会被padding(因为他在MSB)
除非正好遇到一个输入负数他的 $len(bit[]) \% 8 = 1$ (也就是二进制表达的负数长度是最近的8倍数-1),因此在保留了一个符号位后正好不需要做padding就已经对齐了octet

另外当用来保存为符号信息的位达到6位时就正好是base64 alphabet的最后一个字符/

输入位 base64encode
1 gA==
11 wA==
111 4A==
1111 8A==
11111 +A==
111111 /A==
1111111 /g==
新的循环……

@n0099
Copy link
Owner Author

n0099 commented Jan 9, 2023

从您的例子当中也许您也会注意到一件事:二进制数据中连续的 00 被 Base64 为连续的 A ,而二进制数据中连续的 FF 被 Base64 为连续的 / 。这当然不是巧合,而是因为 64 是 2 幂,而 A 和 / 刚好分别是 Base64 编码表的 0 和 63 。
这提示了对截断步骤的一个改进。比起在二进制字节串中去掉高位的连续 \x00 或 \xFF 而言,更合理的其实是对 Base64 输出去掉对应于高位的 A 或 / 。由于是 Base64 编码,所以,由于是从二进制高位去掉 \x00 或 \xFF ,而截取的更多二进制高位,如果不反映为 Base64 右侧更少的 A 或 / 的话就意味着没有额外的压缩 而且 由于只从二进制高位去掉整个 \x00 或 \xFF ,而余留下的二进制高位,如果反映为 Base64 右侧额外的 A 或 / 的话就意味着不充分的压缩 。

如果我们同时trim(不论ltrim还是rtrim还是双方向都trim)了A/,那就必须得有一个额外的元数据(或者按您之前提出的方式:一个追加在encoding开头/结尾的base64 alphabet中的字符位,但您会发现这个字符同样需要1个固定的字符位,尽管只需要1位比之前的无法通过砍掉所有leadding 0/1后又只保留一个位就能保存符号信息,而是需要保留1~8个位要好的多,但问题是输出中本就只能被trim掉至多一次A/(也就是不可能出现AAAA0x00 00 00)或////0xFF FF FF),因为这种base64encode的输入早就被上一步针对输入int的二进制trim掉0x00 0xFF了)(除非您遇到了某些输入其输出正好是一堆A/交错排列,如A/A/A/A/0x03 f0 3f 03 f0 3f),但这也同样只能被trim掉一个最后或开头的A/),那么为什么要trim这一个A/后又用一位来记住他?)来标识编码时trim的是A还是/,从哪个方向trim的(但方向可以通过编码时输入byte[]的端序已知,小端序下就是左,而如果解码时不知道编码时的端序那即便padding了也无法获得正确结果),这样才能在解码时给base64decode输入正确padding了A/的encode
变通方法是只trim掉更常见的A


作为例子,您的示例当中 0 编码为 AA ,而 1 编码为 AQ 。前者比起后者虽然在 rtrim(...,"\x00") 的步骤多删去了一个字节,但在 Base64 之后其实还是 2 个 Base64 字符长。

您解释了为什么在输入<10时输出没有长度优势:

不难看出当 $-9\geq\text{输入}\leq10, \text{输入}\neq\{0, -1\}$ 时输出有着2个字符而输入有着1或2个字符
image


Base64 是将字节串编码为 Base64 字符串,而 pack 的步骤则将输入的整数填充为字节串。 Base64 的 = 填充正是由于它要把 8bit 一组的输入编码成 6bit 一组的输出而造成的,与此同时从上面说的可以看出,对 8bit 组(也就是字节)作高位截断其实并没有发挥充分的效果。所以如果考虑 Base64 的内部设计的话,就会发现这里实际上是迂回的——输入的整数先被 8bit 分组,然后 Base64 的原理又要重新将它看作整数。这个迂回也造成了 = 填充的问题。

我也不知道为什么以base64为代表的各种baseXX编码内部都是使用sextet6位组而不是octet8位组,盲猜这是为了以前ascii环境下的7bit clean
https://en.wikipedia.org/wiki/Uuencoding image
然而也存在着反例: https://en.wikipedia.org/wiki/Ascii85

The Ascii85 encoding is compatible with 7-bit and 8-bit MIME, while having less overhead than Base64.
image


综合上面说的,如果希望编码成的是 Base64 这样的以 6bit 为单位的编码表的话,建议的做法是这样:

我的需求并不是编码内部的6位组,而是这个编码的alphabet排列组合出的输出能urlsafe就行
您用md5 sha rsa aes等算法时也不会关心他内部的chunk size吧?除非您在padding输入


将输入整数拆分成每组 6bit 的分组,且对有符号数采用 Two's complement
在保证截断后最高的组的最高 bit 符合输入数的符号的前提下,去掉对应高位的 0 或 63 。(如果输入数为正,那么只能去掉若干个 0 ,而且去掉之后最高的必须是 031 ;如果输入数为负,那么只能去掉若干个 63 ,而且去掉之后最高的必须是 3263 )

什么叫去掉若干个 0 ,而且去掉之后最高的必须是 031 ;如果输入数为负,那么只能去掉若干个 63 ,而且去掉之后最高的必须是 3263

用得到的每个组的值,在 Base64 编码表中索引,产生一个输出字符
对应的解码过程就是:
对每个输入字符,根据 Base64 编码表,将它反向映射为索引值
每个索引值对应一个 6bit 分组。如果最高的组是 0~31 ,那么结果非负,填充 0 ;否则结果为负,填充 63 。
组合这些分组,得到解码输出的整数。


Base64 只接受( 8bit 为单位的)字节流输入,解码时也只输出字节流。不满整字节边界的位流似乎不能被 Base64 解码出来,换言之即便不填充,由 Base64 编码表字符组成的排列组合,也有一些是不可能的 Base64 编码。对于压缩整数这个场景而言这是造成冗余的。

主要是因为这类编码都是将输入变得更长,如base64使用每4个bytes(32位)的输出对应3个bytes(24位)的输入
image
可以参考这个正则看出有哪些alphabet中的字符无法作为一个4bytes组的中间2bytes,因为如果作为了就会解码失败
https://stackoverflow.com/questions/475074/regex-to-parse-or-validate-base64-data/64467300#64467300


所以我一下子想不出简单的办法来复用 Base64 编码解码函数。那么如果不复用的话,对于上面的方案,我试着用我比较熟悉的 C++ 来写一下。以 int64 范围为例,且假定 char 为有符号 8 位。

我完完全全看不懂现代cpp代码,建议立即致电新创无际cpp老人ISO/IEC 14882 spec语法律师HKUST高材生新创刘予顺神 @DWVoid

@yangbowen
Copy link

现在消费级常见cpu arch中也就基于RISC的arm还是大端了

确实小端更为常见,包括 ARM 平台也常常是以小端运行的。但对程序来说不要假定平台只能是小端比较好吧。
事实上除了小端、大端以外,还有更罕见的 PDP 端序
所以说端序检测的结果可以有至少 3 种:大端、小端、都不是。


您前几个月在tg上跟我私聊时探讨protobuf所使用的varint实现时已经说过这些了

是。因为您这里这个 压缩 其实也是一种 varint 。


但当输入是负数时直接往最高/低位追加一个10来代表符号很容易导致追加后的整个bin[]没有对齐到8位

不,不是追加一个10,而是它本来就是原先的整数以补码表示的时候就存在的位。或者说,删去补码当中高位的10,直到只留一位,这一位就足以表示符号。
这个过程反过来就是 符号扩展 。例如从 int8 转换为 int32 时,就是将 int8 的最高位复制给多出的 24 个位。


TL;DR:我们无法通过砍掉所有leadding 0/1后又只保留一个位就能保存符号信息,而是需要保留1~8个位
因为我们必须在base64decode前将bit[]通过padding对齐到byte[]octet,而这就意味着保存符号信息的位也会被padding(因为他在MSB)

这其实就是我说的

Base64 只接受( 8bit 为单位的)字节流输入,解码时也只输出字节流。不满整字节边界的位流似乎不能被 Base64 解码出来,换言之即便不填充,由 Base64 编码表字符组成的排列组合,也有一些是不可能的 Base64 编码。

为了记住符号,只需要一个位,但 Base64 要求输入被 padding 到 8 个位,再被 padding 到 6 个位。即便如此仍旧是有办法的,就是不止保留一个 0 或 1 位,而是保留到填满 padding 后的字节那么多的位。
也是为了解决这个浪费所以我后面才没有直接用 Base64 而是自行实现了类似的操作,但不经过 字节流 这步。

如果我们同时trim(不论ltrim还是rtrim还是双方向都trim)了A/,那就必须得有一个额外的元数据(或者按您之前提出的方式:一个追加在encoding开头/结尾的base64 alphabet中的字符位,但您会发现这个字符同样需要1个固定的字符位,尽管只需要1位比之前的无法通过砍掉所有leadding 0/1后又只保留一个位就能保存符号信息,而是需要保留1~8个位要好的多,但问题是输出中本就只能被trim掉至多一次A/(也就是不可能出现AAAA0x00 00 00)或////0xFF FF FF),因为这种base64encode的输入早就被上一步针对输入int的二进制trim掉0x00 0xFF了)(除非您遇到了某些输入其输出正好是一堆A/交错排列,如A/A/A/A/0x03 f0 3f 03 f0 3f),但这也同样只能被trim掉一个最后或开头的A/),那么为什么要trim这一个A/后又用一位来记住他?)来标识编码时trim的是A还是/,从哪个方向trim的(但方向可以通过编码时输入byte[]的端序已知,小端序下就是左,而如果解码时不知道编码时的端序那即便padding了也无法获得正确结果),这样才能在解码时给base64decode输入正确padding了A/的encode
变通方法是只trim掉更常见的A

trim 的方向理应是对应输入数值的高位的方向,对于小端序来说就是右边。除非您有理由认为您的输入数据频繁地出现有很多低 0 位的数,比方说 48 、192 等,那种情况下 trim 低位才有帮助,但不在我前面说的的讨论范围内。
至于 这个字符同样需要1个固定的字符位 ,这就是为什么强调不是追加一个,而是 trim 到刚好不足以混淆正负数。换言之,如果 trim 删掉了若干个 A ,那么没被删掉的最高字符只能是 Af ;反之如果删掉了若干个 / ,那么没被删掉的最高字符只能是 g/ 。这样这里就有 1bit 的信息,正好就是符号的信息。


我也不知道为什么以base64为代表的各种baseXX编码内部都是使用sextet6位组而不是octet8位组,盲猜这是为了以前ascii环境下的7bit clean

使用 sextet 当然是因为 2 的 6 次方是 64 。每个 sextet (范围 0~63 )刚好对应 Base64 的一个编码字符。
至于为什么是 64 ,是的,一部分原因是 7bit clean 。以前的 SMTP/POP3 协议采用人类可读文本的命令格式进行交互,而为了支持非 ASCII 字符,解决办法就是用 Base64 编码;另一部分原因就是这样人类可读,而且可以输入。例如常用于密钥文件、证书文件的 PEM 格式也是 Base64 ,所以您可以将它复制粘贴到文本编辑器或者命令行当中而不用担心会有编码上的问题,您也可以用肉眼对这些文件进行比对。


我的需求并不是编码内部的6位组,而是这个编码的alphabet排列组合出的输出能urlsafe就行

这两件事是密切相关的,因为能 URL safe 的字符是有限的。之所以 Base64 为了达到 7bit clean 的目标要选择 64 而不是更大,显然是因为 ASCII 当中人类可读字符的数量介于 64 和 128 之间( ASCII 总共就 128 个,其中包含很多不可读字符),而采用 2 幂使得这个编码转换只需要用到位运算,不需要对于低端平台而言昂贵得多的乘除法。(除法非常昂贵,而乘法在现代 x86 上只比加减法慢一点点,而除数固定的整数除法可以优化成乘法。但对更低端或更古老的平台而言乘法可能昂贵得多)
Base64 的 alphabet 中只有 / 和 = 是不 URL safe 的,您当然可以将它替换成别的。


什么叫去掉若干个 0 ,而且去掉之后最高的必须是 031 ;如果输入数为负,那么只能去掉若干个 63 ,而且去掉之后最高的必须是 3263

我打了 tlide 符表示范围,但不幸的是它被 GitHub 识别为 strikethrough 了。而且我没发现。


我完完全全看不懂现代cpp代码,建议立即致电新创无际cpp老人ISO/IEC 14882 spec语法律师HKUST高材生新创刘予顺神 @DWVoid

我已经尽力避免现代cpp代码来照顾不擅长cpp的人的理解了,所以那个其实基本上就是 C ,只用了比较少的cpp特性。您应该可以试着当作 C 来阅读。

@n0099
Copy link
Owner Author

n0099 commented Jan 9, 2023

现在消费级常见cpu arch中也就基于RISC的arm还是大端了

确实小端更为常见,包括 ARM 平台也常常是以小端运行的。但对程序来说不要假定平台只能是小端比较好吧。 事实上除了小端、大端以外,还有更罕见的 PDP 端序 。 所以说端序检测的结果可以有至少 3 种:大端、小端、都不是。

我不知道有哪些android rom可以在arm保持大端序模式的情况下正常引导启动linux并进入launcher

https://en.wikipedia.org/wiki/PDP-11_architecture
PDP-11 架构[1]是由数字设备公司(DEC)开发的CISC 指令集架构(ISA )。它由PDP-11小型计算机中使用的中央处理器(CPU) 和微处理器实现。它在 1970 年代得到广泛使用,但最终在 1980 年代被更强大的VAX-11架构所掩盖。
十六位字以小尾数法存储(最低有效字节在前)。三十二位数据——支持作为基本架构的扩展,例如,FPU 指令集中的浮点数、扩展指令集中的双字或商业指令集中的长数据——以不止一种格式存储,包括一种不寻常的中间端格式[2] [3]有时称为“PDP-endian”。

那么我写的又不是要兼容几十年前各种cpu arch百花齐放时代的c程序,为什么不能假定只有小端序?
甚至可以合理怀疑 https://github.com/php/php-src 根本无法在指定大端序的./configure生成的makefile下正确编译,那您又如何在大端序模式的armcpu上跑php?

实际上假定1byte=8bit也是错误的,新创刘予顺神还在上海上学时就曾经给远古的1byte=7bit环境写过c程序
不如说byte本就是word的替代用语,而word字长在历史上一直都在随着电子技术发展而不断变动: https://en.wikipedia.org/wiki/Word_(computer_architecture)#Table_of_word_sizes

事实核查:截止2023年1月,以现代前端娱乐圈壬上壬伊欧神hifi手机3c数码圈头子556神为代表,大家都假定世界上只有一个小端序和8bit字节,可谓天无二日


您前几个月在tg上跟我私聊时探讨protobuf所使用的varint实现时已经说过这些了

是。因为您这里这个 压缩 其实也是一种 varint 。

我只不过是砍掉了最初的int二进制表达中无用的leading zeros来压缩长度又保留相同的信息熵,我不觉得这能跟那些完全为了缩短长度而设计的varint算法类比:

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

utf8编码的设计也是很巧妙的,他像baseXX使用sextet一样用更长的输出来彻底解决了以往的EASCII编码中把双字节序列截断成两部分字节就无法解析或正好解析成其他有效ASCII的问题
https://stackoverflow.com/questions/10143836/why-is-there-no-utf-24
https://stackoverflow.com/questions/6339756/why-utf-32-exists-whereas-only-21-bits-are-necessary-to-encode-every-character
http://doc.cat-v.org/bell_labs/utf-8_history
我以后可能会写篇文章详细讲述从历史性的(E)BCDIC (E)ASCII(如仏皇irol阁下仍在使用的ISO/IEC 8859家族,经典锟斤拷GB2312/GBK/GB18030,日本人flyboom整天骂的\的SHIFT-JIS)到历史终结之Unicode联盟产物 UCS(ISO/IEC 10646) UTF家族编码(7/8/16/32)之间的各种后宫关系,以及unicode带来的一系列恶俗抽象概念,如unihan、combining、normalization、collation,和最抽象特殊的恶魔鸡emoji(实际上这些概念在过去3年中我在四叶各个群都反复介绍过某些碎片信息,以前qq时代还向CS硕士PLT理论中级高手irol神介绍过这些概念)


但当输入是负数时直接往最高/低位追加一个1或0来代表符号很容易导致追加后的整个bin[]没有对齐到8位

不,不是追加一个1或0,而是它本来就是原先的整数以补码表示的时候就存在的位。或者说,删去补码当中高位的1或0,直到只留一位,这一位就足以表示符号。

从结果上来看

  1. 先trim掉所有高位的0或1,然后再往高位padding一些0或1使整个二进制序列对齐8bit字节
  2. trim掉高位的0或1,但保留最高8bit字节中的那些0或1

是一样的,而很明显第一种兜圈子的实现路径反而更容易写,如果在乎性能那自然是手写第二种trim的行为

这个过程反过来就是 符号扩展 。例如从 int8 转换为 int32 时,就是将 int8 的最高位复制给多出的 24 个位。

1楼中解码时

unpack('P或q', str_pad(base64_decode($i), 8, "\x00"或"\xFF"))
的str_pad也就是您说的 https://en.wikipedia.org/wiki/Sign_extension


为了记住符号,只需要一个位,但 Base64 要求输入被 padding 到 8 个位,再被 padding 到 6 个位。即便如此仍旧是有办法的,就是不止保留一个 0 或 1 位,而是保留到填满 padding 后的字节那么多的位。

base64编码算法内部把octet便乘sextet是他自己的实现,我们是没法改变他的

由于是从二进制高位去掉 \x00 或 \xFF ,而截取的更多二进制高位,如果不反映为 Base64 右侧更少的 A 或 / 的话就意味着没有额外的压缩 而且 由于只从二进制高位去掉整个 \x00 或 \xFF ,而余留下的二进制高位,如果反映为 Base64 右侧额外的 A 或 / 的话就意味着不充分的压缩 。
Base64 是将字节串编码为 Base64 字符串,而 pack 的步骤则将输入的整数填充为字节串。 Base64 的 = 填充正是由于它要把 8bit 一组的输入编码成 6bit 一组的输出而造成的,与此同时从上面说的可以看出,对 8bit 组(也就是字节)作高位截断其实并没有发挥充分的效果。所以如果考虑 Base64 的内部设计的话,就会发现这里实际上是迂回的——输入的整数先被 8bit 分组,然后 Base64 的原理又要重新将它看作整数。这个迂回也造成了 = 填充的问题。

如您之前所说这也的确造成他的压缩效率更低


也是为了解决这个浪费所以我后面才没有直接用 Base64 而是自行实现了类似的操作,但不经过 字节流 这步。

所以我们需要学习新创lys神数月前在新创无际主群回答新创老CS民科上火者提出的一个问题:如何实现一种他想出的特殊伪随机算法的重要指示精神:

您应该去请个数学家来给您设计这样的一种特殊算法

我当时类比他的问题相当于音乐播放器中常用的伪随机算法(即故意避免播放随机下一首时随机到之前已经播放过的歌)
而四叶CS硕士PLT理论高手irol阁下又进一步指出:这类似如何在一个1亿唯一成员的集合中快速检索出某个成员是否存在问题


trim 的方向理应是对应输入数值的高位的方向,对于小端序来说就是右边。除非您有理由认为您的输入数据频繁地出现有很多低 0 位的数,比方说 48 、192 等,那种情况下 trim 低位才有帮助,但不在我前面说的的讨论范围内。

默认trim的都是高位,所以我才要强调如果要trim两个方向那就必须得携带额外元数据来注明另一个方向trim掉了几个0或1
如果真有人的输入是二进制表达中频繁出现低位0的数字,那应该直接记录二进制有效位数(trim掉高位0或1后)中有几个1和0就足以解码


至于 这个字符同样需要1个固定的字符位 ,这就是为什么强调不是追加一个,而是 trim 到刚好不足以混淆正负数。换言之,如果 trim 删掉了若干个 A ,那么没被删掉的最高字符只能是 Af ;反之如果删掉了若干个 / ,那么没被删掉的最高字符只能是 g/ 。这样这里就有 1bit 的信息,正好就是符号的信息。

我之前也已经试出来不能只追加一个0或1(或者说trim到只剩下一个高位符号位的0或1)
因为如果追加或trim后的bit[]不对齐8bit字节那就无法拿去base64encode
也就是您指出的但 Base64 要求输入被 padding 到 8 个位,再被 padding 到 6 个位


我也不知道为什么以base64为代表的各种baseXX编码内部都是使用sextet6位组而不是octet8位组,盲猜这是为了以前ascii环境下的7bit clean

使用 sextet 当然是因为 2 的 6 次方是 64 。每个 sextet (范围 0~63 )刚好对应 Base64 的一个编码字符。
至于为什么是 64 ,是的,一部分原因是 7bit clean 。以前的 SMTP/POP3 协议采用人类可读文本的命令格式进行交互,而为了支持非 ASCII 字符,解决办法就是用 Base64 编码;另一部分原因就是这样人类可读,而且可以输入。例如常用于密钥文件、证书文件的 PEM 格式也是 Base64 ,所以您可以将它复制粘贴到文本编辑器或者命令行当中而不用担心会有编码上的问题,您也可以用肉眼对这些文件进行比对。

我记得现在那3大email协议还是只支持7bit ascii,unicode人以前也发明过utf7试图取代base64在email协议中的地位但失败了
建议学习btc钱包地址base32精神以及
image


我的需求并不是编码内部的6位组,而是这个编码的alphabet排列组合出的输出能urlsafe就行

这两件事是密切相关的,因为能 URL safe 的字符是有限的。之所以 Base64 为了达到 7bit clean 的目标要选择 64 而不是更大,显然是因为 ASCII 当中人类可读字符的数量介于 64 和 128 之间( ASCII 总共就 128 个,其中包含很多不可读字符)
Base64 的 alphabet 中只有 / 和 = 是不 URL safe 的,您当然可以将它替换成别的。

我们同样可以把url safe和ascii人类可读char的概念推广到unicode charset中
HATETRIS作者qntm曾经指出:
https://github.com/qntm/base131072

不。我们需要 131,586 个“安全”字符用于此编码,但截至 Unicode 9.0,仅存在 108,397 个。但是,未来版本的 Unicode 可能会添加足够的安全字符,使之成为可能。无论如何,肯定可以奠定基础。

https://github.com/qntm/base65536

Unicode 有 1,114,112 个代码点,我们没有使用其中的大部分。我们可以更进一步吗?
还没有。
要为每个代码点编码一个额外的位,我们需要将我们使用的代码点数量从 65,536增加一倍到 131,072。这将是一种新编码Base131072,其 UTF-32 编码效率为 53%,而 Base65536 为 50%。(请注意,在 UTF-16 中,Base32768明显优于任一选择,而在 UTF-8 中,Base64 仍然是首选。)
然而,从 Unicode 10.0 开始,safe-code-point总共只返回 116,813 个安全代码点。也许 Unicode 的未来版本最终会分配更多的字符并使之成为可能,但即使最终发生这种情况,字符似乎也不太可能整齐地排列在 256 个块中,这使得 Base65536 变得如此小和简单。这可能不值得麻烦......

https://qntm.org/safe 进一步指出:

是什么让 Unicode 字符在编码数据时可以安全使用?
我们对 Unicode 了解得越多,这个问题就越复杂。
也许开始回答这个问题的最好方法是列出被认为不安全的字符及其原因。
没有未分配的(也称为“保留”)代码点。如果未分配的代码点被分配,则它们具有不可预测的、潜在的不良特征(见下文)。从 Unicode 10.0 开始,这将我们限制在完整的 1,114,112 个代码点范围内的 276,271 个代码点。
没有非字符,因为这些字符保留供 Unicode 内部使用。
没有私人使用的角色,因为这些角色可能具有私人用户分配给他们的不良特征。
没有代理人。它们旨在成对使用以在 UTF-16 中编码非 BMP Unicode 字符;将它们用作我们编码的一部分可能会涉及单独使用它们,如果我们的编码字符串作为 UTF-16 发送给期望格式正确的接收者,或者如果我们的编码字符串使用实际的非 - BMP Unicode 字符本身。
没有格式字符。这包括零宽度空格、软连字符和双向控制。这些通常是不可打印的。
没有控制字符。这包括空值、响铃字符和其他奇怪的不可打印的 ASCII 字符,如 U+007F DELETE。通常,应避免任何不可打印的内容。
我们还将避免使用制表符、回车符和换行符等控制字符,以及空格等分隔符;通常我们会避免所有的空白字符。当文本通过 XML 文档时,空格可能会被消除或损坏。此外,尝试选择该文本的人可能会不小心错过空格,尤其是当空格位于前导或尾部时。另外,希望能够将文本分解,例如每 80 个字符用换行符换行,而不会破坏文本的含义。因此,理想情况下,我们应该能够在解码文本时忽略文本中的空格。
没有标点符号,包括连字符、左括号和右括号字符以及首尾引号。这意味着如果需要,我们编码的 Unicode 字符串可以安全地放在方括号或引号内,无需转义,不会导致歧义或无意中终止引号或方括号内的字符串。
没有组合字符,包括变音符号。如果我们的编码允许组合字符首先出现在文本中,那么这些都是危险的。完全丢弃它们更简单。
请注意,上述限制排除了几个完整的 Unicode 字符通用类别:“标记”、“标点符号”、“分隔符”和“其他”。这留下了常规类别“符号”、“数字”和“字母”。
还有一个限制,那就是字符必须在所有形式的规范化中存活下来。
正常化
最后一点是最难满足的。Unicode 有四种“规范化形式”:NFD、NFC、NFKD 和 NFKC。将这四种规范化过程中的任何一种应用于 Unicode 字符串都可能导致代码点序列发生变化,就我们的目的而言,这构成了数据损坏。我们希望我们的编码数据能够在所有形式的规范化中存活下来。
Unicode 标准附件 #15,UNICODE NORMALIZATION FORMS提供了更多相关信息,包括以下非常有价值的事实:
在一个版本的 Unicode 下规范化的字符串在未来的版本中保持规范化,前提是它不使用未分配的代码点。因此,如果我们一次做对了,就不必担心将来对 Unicode 的更改会再次出错。
规范化形式在字符串连接下不封闭。如果在文本的开头或结尾放置更多文本,它不仅会更改数据,还会破坏数据。但是,Base64 也有同样的问题。只要文本受分隔符/方括号/空格保护,就应该没问题。
规范化字符串的子串仍然是规范化的,这意味着一个“安全”的文本可以毫无风险地分解成几个更小的文本。
对于特定的规范化形式,许多代码点是稳定的。
什么使代码点稳定?
在 Unicode 标准中,每个单独的代码点都有大量与之关联的属性。有关这些属性的信息可在Unicode 字符数据库(文档)中找到。机器可读数据本身就在这里。
这些属性之一是Canonical_Combining_Class, (文档),它解释了字符如何与其他字符组合(如果有的话)。大多数字符具有默认的规范组合类Not_Reordered(0)。
其他四个属性 、NFD_Quick_Check和NFKD_Quick_Check( NFC_Quick_Checkdata )NFKC_Quick_Check是每个规范化表单的“快速检查”属性。“是”值表示该规范化形式未更改该字符。
正如我们在这里看到的,如果代码点具有规范组合类 0 和快速检查值“是”,则它在规范化形式下被认为是稳定的。所以我们需要做的就是解析这些数据并分析它以获得安全代码点的完整列表。
我们不关心什么?
屏幕上数据占用的可见空间。明智地使用 Zalgo 式变音符号可以减少文本在屏幕上占用的物理空间,以至于可以将任意数量的数据塞入单个字符。然而,这是以代码点长度为代价的,因为组合变音符号相对稀缺。它还会使编码更加复杂,并且很难针对规范化进行强化。
一种方法可能是有一个单一的“X”,上面有一个Base1编码的组合尖音符号。例如,带有 1,217 个重音符号的 X 表示 2 字节序列 [0x03 0xc0]。
人们试图在纸上手写数据,然后再次输入数据。仅限于通过某人的笔迹来回传输的字符,由于“l”、“L”和“1”、“n”之间的视觉相似性,即使是 Base64 也需要严格削减,“u”和“r”和“o”,“O”和“0”。有关在设计时考虑到此约束的编码示例,请参阅Base32。
任何特定编码中的字节长度。这不会影响任何特定代码点的“安全性”,尽管它确实限制了我们检查的代码点。


而采用 2 幂使得这个编码转换只需要用到位运算,不需要对于低端平台而言昂贵得多的乘除法。(除法非常昂贵,而乘法在现代 x86 上只比加减法慢一点点,而除数固定的整数除法可以优化成乘法。但对更低端或更古老的平台而言乘法可能昂贵得多)

那么反过来想,在这个场景中并不在乎性能(我甚至为了直观直接把bit[]变成10字符串来操作而不是用位运算了),在允许使用各种平台可用的数学运算的条件下能否发明更压缩的表达?建议学习新创lys神精神您应该去请个数学家来给您设计这样的一种特殊算法


什么叫去掉若干个 0 ,而且去掉之后最高的必须是 031 ;如果输入数为负,那么只能去掉若干个 63 ,而且去掉之后最高的必须是 3263?

我打了 tlide 符表示范围,但不幸的是它被 GitHub 识别为 strikethrough 了。而且我没发现。

您可以加个\转义如\~是~


我完完全全看不懂现代cpp代码,建议立即致电新创无际cpp老人ISO/IEC 14882 spec语法律师HKUST高材生新创刘予顺神 @DWVoid

我已经尽力避免现代cpp代码来照顾不擅长cpp的人的理解了,所以那个其实基本上就是 C ,只用了比较少的cpp特性。您应该可以试着当作 C 来阅读。

反转了我主要是看不懂套娃位运算,所以才会有为了直观直接把bit[]变成10字符串来操作而不是用位运算了的奇妙深刻罪恶行径
我还记得奥利金德皇帝lg神曾于去年在奥利金德群发起全员学习位运算,死记硬背位映射表的运动,而他们最近正忙着热衷于从PLT视角剖析新创无际生信壬西兔人最爱的rust思维语言设计,如dc神 @dylech30th 的最新论文《Rust的设计哲学:Trait与多态》https://sora.ink/archives/1574
四叶第二大学CS硕士PLT理论中级高手仏国irol阁下对此也早有预言:当您手里只有锤子时,看什么都像钉子
https://www.v2ex.com/t/907356#r_12552201 也曾指出:
image

@yangbowen
Copy link

yangbowen commented Jan 9, 2023

事实核查:截止2023年1月,以现代前端娱乐圈壬上壬伊欧神hifi手机3c数码圈头子556神为代表,大家都假定世界上只有一个小端序和8bit字节,可谓天无二日

实际上大部分代码根本不依赖也不应该依赖平台原生端序。当且仅当某个二进制数据(用 C++ 的话说叫 对象表示 )要同时被当作 多字节原生整数类型 和 字节数组 访问时,原生端序才产生影响。这对很多高级语言来说都是本就不存在的操作。而且从用途上讲也只有在序列化/反序列化之类的情况,而且要求性能的时候才用得上——假设您的数据是从 JSON 之类的格式转换来的,那么这个开销很容易就超过 将二进制字节流当作原生整数访问而不是手动排列每个字节 的节省了。
简单来说,您不应该对原生端序作出假定,您应该采取不依赖原生端序的写法例如 i = (arr[3] << 24) | (arr[2] << 16) | (arr[1] << 8) | arr[0];


您前几个月在tg上跟我私聊时探讨protobuf所使用的varint实现时已经说过这些了

是。因为您这里这个 压缩 其实也是一种 varint 。

我只不过是砍掉了最初的int二进制表达中无用的leading zeros来压缩长度又保留相同的信息熵,我不觉得这能跟那些完全为了缩短长度而设计的varint算法类比:

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

utf8编码的设计也是很巧妙的,他像baseXX使用sextet一样用更长的输出来彻底解决了以往的EASCII编码中把双字节序列截断成两部分字节就无法解析或正好解析成其他有效ASCII的问题 https://stackoverflow.com/questions/10143836/why-is-there-no-utf-24 https://stackoverflow.com/questions/6339756/why-utf-32-exists-whereas-only-21-bits-are-necessary-to-encode-every-character http://doc.cat-v.org/bell_labs/utf-8_history 我以后可能会写篇文章详细讲述从历史性的(E)BCDIC (E)ASCII(如仏皇irol阁下仍在使用的ISO/IEC 8859家族,经典锟斤拷GB2312/GBK/GB18030,日本人flyboom整天骂的\的SHIFT-JIS)到历史终结之Unicode联盟产物 UCS(ISO/IEC 10646) UTF家族编码(7/8/16/32)之间的各种后宫关系,以及unicode带来的一系列恶俗抽象概念,如unihan、combining、normalization、collation,和最抽象特殊的恶魔鸡emoji(实际上这些概念在过去3年中我在四叶各个群都反复介绍过某些碎片信息,以前qq时代还向CS硕士PLT理论中级高手irol神介绍过这些概念)

您只不过是砍掉了最初的int二进制表达中无用的leading zeros来压缩长度又保留相同的信息熵,而这跟那些完全为了缩短长度而设计的varint算法的基本原理是完全一致的。只不过后者多出了如何高效地同时存放长度信息,而不是依赖于外部的元数据来知道长度。
另外这里有个翻译问题。

虽然通常被认为效率低下,但长度是以一元编码的

原文 unary 应该译作 一进制 —— UTF-8 正是在前导字节中使用一进制编码需要多少个后续字节。
不难注意到, protobuf 这样用每个字节的最高位表示后续是否有更多字节到来,意味着每个字节“浪费”1个位,而用一进制表示字节的数量同样是为每个字节“浪费”1个位,是一样的。 UTF-8 额外的冗余是为了让前导字节、后续字节、单字节字符( ASCII )在 codeword 中占据的空间不重合。这个要求下不太能比现在这样做得更好了。


但当输入是负数时直接往最高/低位追加一个1或0来代表符号很容易导致追加后的整个bin[]没有对齐到8位

不,不是追加一个1或0,而是它本来就是原先的整数以补码表示的时候就存在的位。或者说,删去补码当中高位的1或0,直到只留一位,这一位就足以表示符号。

从结果上来看

1. 先trim掉所有高位的0或1,然后再往高位padding一些0或1使整个二进制序列对齐8bit字节

2. trim掉高位的0或1,但保留最高8bit字节中的那些0或1

是一样的,而很明显第一种兜圈子的实现路径反而更容易写,如果在乎性能那自然是手写第二种trim的行为

先 trim 光再 padding 跟 trim 到剩下一个确实是一样的,但这里的关键在于到底要 trim 多少的判断。假如您看懂了我的 C++ 代码的话就会看明白那个判断条件的问题。而不论1还是2都是一样面临这个问题的。

这个过程反过来就是 符号扩展 。例如从 int8 转换为 int32 时,就是将 int8 的最高位复制给多出的 24 个位。

1楼中解码时

unpack('P或q', str_pad(base64_decode($i), 8, "\x00"或"\xFF"))
的str_pad也就是您说的 https://en.wikipedia.org/wiki/Sign_extension

同样的,关键在于到底填充 \x00 还是 \xFF ,可以直接根据填充前的最高位。


所以我们需要学习新创lys神数月前在新创无际主群回答新创老CS民科上火者提出的一个问题:如何实现一种他想出的特殊伪随机算法的重要指示精神:

您应该去请个数学家来给您设计这样的一种特殊算法

我当时类比他的问题相当于音乐播放器中常用的伪随机算法(即故意避免播放随机下一首时随机到之前已经播放过的歌) 而四叶CS硕士PLT理论高手irol阁下又进一步指出:这类似如何在一个1亿唯一成员的集合中快速检索出某个成员是否存在问题

我不知道,但如何实现一种他想出的特殊伪随机算法听起来很有趣。
故意避免播放随机下一首时随机到之前已经播放过的歌我能想到的办法是先将整个播放列表 shuffle ,然后依次播放 shuffle 后的列表,播完之后重新 shuffle 一遍。
当然了,重新 shuffle 有可能前一轮的最后一首刚好是下一轮的第一首。但这可能只能在有限程度上改善——假如要求任意两首之间的间隔都尽可能远的话,也就是说任意两首中间都隔了所有其它的,那这就意味着它其实在做 列表循环 ,丧失了随机性。所以只能在兼顾随机性和间隔之间做一个取舍。


那么反过来想,在这个场景中并不在乎性能(我甚至为了直观直接把bit[]变成10字符串来操作而不是用位运算了),在允许使用各种平台可用的数学运算的条件下能否发明更压缩的表达?

这个问题的答案仍旧取决于您的输入数据的分布情况。越是有复杂但显著(远离均匀)的分布规律,就越有机会使用更昂贵的运算发明更压缩的表达。但我怀疑整数数据可能没那么多可以压缩的空间,尤其是您要对单个整数进行压缩,而不是许多个相关联的数。另外,既然您的整数本来也没那么大,那么本来占据的空间也不大了。
多媒体信息其实就是 许多个相关联的数 嘛。
在另一头也就是编码的 alphabet ,空间利用效率同样受限于您有多大的 alphabet 能用。而且正如您的引用当中指出的那样, 2 幂的设计已经很接近能达到的上限了。
从理论而言,假设 alphabet 中每个符号占据相同空间的话,那么最高效的压缩(基于输入数据的分布)要让 alphabet 中每个符号出现的概率是相等的。而假如 alphabet 中每个符号占据的空间并不相同的话,要让每个符号出现的概率反比于它占据的长度。换句话说,从前往后一个个读字节,每看到一个字节的时候,基于之前已经读到的字节,接下来的字节有可能出现的所有情况,概率是一样的。这种情况下最高效。
这里建议参见 哈夫曼编码算术码
这么说起来的话,对这个适用于 较小的数字更常出现 的压缩方式,也许也可以采用 算术码 的思路进行降低性能提高压缩比的改进。不过小心专利。


您可以加个\转义如\~是~

是的,主要是我之前没意识到这里有这个问题。


反转了我主要是看不懂套娃位运算,所以才会有为了直观直接把bit[]变成10字符串来操作而不是用位运算了的奇妙深刻罪恶行径

原来是这样。那我试着解释一下某些位运算套娃吧。
话说回来您既然能理解 子网掩码 ,我觉得对于位运算应该没有太大困难的吧?
总之呢,

constexpr std::size_t length_max = ((64 - 1) / 6) + 1;
constexpr std::size_t bits_remaining = 64 % 6;

这里length_max是 一个int64最多占到几个 sextet 。(a - 1) / b + 1就是向上取整的除法。而bits_remaining则是最高的那个没用满的 sextet 里用了几个 bit 。

char arr_table_encode[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::uint8_t arr_table_decode[256];
void generate_table_decode() {
   for (unsigned int i = 0; i < 256; ++i) arr_table_decode[i] = 64;
   for (std::uint8_t i = 0; i < 64; ++i) arr_table_decode[static_cast<unsigned char>(arr_table_encode[i])] = i;
}

arr_table_encode自然是编码的 alphabet ,而arr_table_decode是用generate_table_decode生成的反向查找的表。

std::string encode_int64(std::int64_t value) {
   std::uint8_t arr_digit[length_max];
   for (std::size_t i = 0; i < length_max; ++i) {
      arr_digit[i] = value & 63;
      // signed bitwise-shift is arithmetic shift
      // do arithmetic shift, so that the highest remaining bits are sign-extended
      value >>= 6;
   }

arr_digit就是 sextet 的数组,最多length_max个。这个循环每次将value的最低 6bit 赋给arr_digit[i] & 63相当于对 64 取模),然后做算术右移位来遍历下一个 6bit (>> 6相当于除以 64 )。
C++ 中有符号整数的右移位是算术右移位——假如原先的数是负数,也就是最高位是 1 ,那么移位填充进来的仍然全是 1 ,得到的仍然是负数。这样一来,负数的情况下最高的那个不完整的 sextet 会位于 56~63 而不是 8~15 ,简化了后面代码的处理。

   std::size_t length = length_max;

length一开始设为最大,逐渐减少它来表示截断后实际需要多少个 sextet 。

   if (arr_digit[length - 1] == 0) {
      while (length >= 2 && arr_digit[length - 1] == 0 && arr_digit[length - 2] < 32) --length;
      // length == 1 || arr_digit[length - 1] != 0 || arr_digit[length - 2] >= 32
      // remove the following line to avoid empty string output
      if (length == 1 && arr_digit[0] == 0) --length;
   } else if (arr_digit[length - 1] == 63) {
      while (length >= 2 && arr_digit[length - 1] == 63 && arr_digit[length - 2] >= 32) --length;
      // length == 1 || arr_digit[length - 1] != 63 || arr_digit[length - 2] < 32
   }

根据最高 sextet 判断正负。也可能最高 sextet 位于 1~7 或者 56~62 ,这种情况下根本不能压缩,必须保留所有 sextet ,这两个if会直接跳过。
里面的while条件就是我上面说的截断位置的判断。如果以下三项同时满足:

  • 还剩至少 2 个 sextet
  • 剩下的最高的 sextet 是 0 或 63 (不包含有效位)
  • 剩下的次高的 sextet 的范围满足原数值的符号

就可以继续 --length 去掉一个高 sextet 。该循环退出时保证的条件就是上面条件的否定,即以下三项至少满足一项:

  • 只剩 1 个 sextet 了
  • 剩下的最高的 sextet 包含有效位,不能被去掉
  • 剩下的次高的 sextet 的范围不满足原数值的符号,必须保留整个最高 sextet 作为符号标记
   std::string str;
   for (std::size_t i = 0; i < length; ++i) str.push_back(arr_table_encode[arr_digit[i]]);
   return str;
}

这个部分无非就是依次根据 sextet 的值在编码表中索引然后追加进字符串并返回。

std::int64_t decode_int64(std::string str) {
   std::uint8_t arr_digit[length_max];
   assert(str.size() <= length_max);// may not exceed max length
   if (str.empty()) return 0;// empty string either decodes to 0 or is invalid

同样地,arr_digit是 sextet 值的数组。如果 str.size() < length_max 的话它是非法的,因为 int64 不可能编码出这么多字符;如果 str.empty() 的话,要么您选择将 0 编码为空字符串(保留前面的那一行)那么必须在这里就返回,要么它是非法的。

   {
      std::size_t i = 0;
      for (; i < str.size(); ++i) {
         assert(arr_table_decode[str[i]] < 64);// there's invalid character
         arr_digit[i] = arr_table_decode[str[i]];
      }

依次从字符串中取出字符,反向查找得到对应的 sextet 值。先前初始化反向查找表的时候其它的全都会被初始化为 64 ,所以这里可以这样判断非法字符。

      // if the highest bit is 0, extend 0
      // otherwise, extend 63
      std::uint8_t digit_extend = ~((arr_digit[str.size() - 1] >> 5) - 1) & 63;
      for (; i < length_max; ++i) arr_digit[i] = digit_extend;
   }

这里包含可能是整个程序最难懂的一个位运算。
这里的目标是,根据从字符串中解码出的最高的 sextet 的最高位(也就是原数值的符号位),选择符号扩展的时候要将编码时截去的 sextet 扩展为 0 还是 63 。std::uint8_t digit_extend = ~((arr_digit[str.size() - 1] >> 5) - 1) & 63;std::uint8_t digit_extend = (arr_digit[str.size() - 1] >> 5) == 1 ? 63 : 0;的优化写法。

  • arr_digit[str.size() - 1]是字符串中的最高 sextet 。这里是一个无符号类型,所以右移的时候会做 zero extend 而不是 sign extend 。
  • ... >> 5取得它的最高位,可能是 0 也可能是 1 。
  • (...) - 1如果最高位是 0 的话,减去 1 会回卷到 255 ;如果最高位是 1 的话,减去 1 就是 0 。
  • ~(...)对它按位取反。如果最高位是 0 的话,这里得到 0 ;如果最高位是 1 的话,这里得到 255 。
  • ... & 63只取它的低 6 位,从而得到 0 或 63 。
   assert(arr_digit[length_max - 1] < (1 << (bits_remaining - 1)) || arr_digit[length_max - 1] >= -(1 << (bits_remaining - 1)));// may not exceed the range of int64_t

如同前面说的,最高的 sextet 只用到 4bit 所以只能是 0~7 或者 56~63 。这里判断超出范围的非法情况。

   std::uint64_t unsigned_value = 0;
   for (int i = length_max - 1; i >= 0; --i) {
      unsigned_value <<= 6;
      unsigned_value |= static_cast<std::uint64_t>(arr_digit[i]);
   }
   return unsigned_value;
}

最后这部分就是依次左移位然后按位或,来将填充完的各个 sextet 重新拼成 uint64 。先拼成无符号 uint64 再在返回时转换成有符号是为了避免这里有符号移位产生意想不到的结果。


我完完全全看不懂现代cpp代码,建议立即致电新创无际cpp老人ISO/IEC 14882 spec语法律师HKUST高材生新创刘予顺神 @DWVoid

我已经尽力避免现代cpp代码来照顾不擅长cpp的人的理解了,所以那个其实基本上就是 C ,只用了比较少的cpp特性。您应该可以试着当作 C 来阅读。

事实上正是为了避免太多现代cpp语法,所以才假定 char 是有符号 8bit 并且只写 int64 的例子的。否则完全可以用模板之类的方式写得更通用但更难懂。而且那个反查表也可以不用运行时生成,而是编译时生成。话说回来,“有符号右移位是算术移位”严格来说反而是现代特性,因为以前为了照顾负数不是 Two's complement 的平台,这个甚至是 implementation-defined 的。(虽说并没有听说过哪个实现真的不是这样的)

@yangbowen
Copy link

yangbowen commented Jan 9, 2023

小数输入

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

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

pack format E表示转换为32或64位(根据运行时php是32还是64位构建)IEEE 754的大端序字节表达

我觉得对于浮点数来说这种从一侧或两侧去除重复的 0 的方案恐怕很大程度上根本就不适用。与整数不同,浮点数固有的不精确性意味着它就是被设计成对较小的值和较大的值保留相近的精度(有效位数)的。如果您确实有必要区分 1000000 和 1000000.5 ,却认为 1 和 1.00006103515625 的差异是可以忽略的,那也许从一开始就不适合选择浮点数作为表示方式。再者,您也注意到很多十进制表示很简短的小数,由于在二进制中是循环小数所以有很长的既非全 0 也非全 1 的 mantissa 。
所以比起这个来说,我觉得也许有可能的压缩方式是基于 分数是循环小数 以及 输入值常常等于某个分母不大的分数。例如一个小数位的小数是分母 10 的分数。也许可以考虑对这样的小数,对它们重复的循环节进行压缩。而且小整数可以视作这个的一种特殊情况——循环节是重复的 0 。
一种可能的思路是找到重复最长的循环节然后记录循环节内容和长度以及从哪开始;另一种可能更好的思路是根据循环节找到分母,只处理不大的分母,记录分母和分子而不是循环节内容。
即便如此,在一个 float 就只有那么大的情况下,我很怀疑这样能省掉的那一点点空间是否值得这个复杂性。尤其是考虑到编码成 6 个或更多个 bit 为单位的符号,只有省去足够多的 bit 以至于能确实从结果中少掉一个字符的情况下才真的有用。


之所以说只匹配不大的分母可能更好,是因为注意到随着分母的提高,循环节长度也增长得很快,不久就不比 53bit 小太多了(意味着消除它的重复不一定划算了)。
例如虽然十进制一位小数=十分之多少,在二进制中有一个比 53bit 小得多的循环节长度 4 ;但是十进制两位小数=一百分之多少,在二进制中的循环节长度就有 20 了。循环节长度 20 的二进制小数都等于 2n*1048575 分之一个整数( 1048575=220-1 ),而 1048575 的素因数除了 5 以外还有 3 11 31 41 。意味着二进制循环节长度 20 的循环小数里除了可能比较值得优化的 十进制两位小数 以外还有大量可能不那么值得优化的分数例如 451 分之多少。

@yangbowen
Copy link

这里包含可能是整个程序最难懂的一个位运算。 这里的目标是,根据从字符串中解码出的最高的 sextet 的最高位(也就是原数值的符号位),选择符号扩展的时候要将编码时截去的 sextet 扩展为 0 还是 63 。std::uint8_t digit_extend = ~((arr_digit[str.size() - 1] >> 5) - 1) & 63;std::uint8_t digit_extend = (arr_digit[str.size() - 1] >> 5) == 1 ? 63 : 0;的优化写法。

* `arr_digit[str.size() - 1]`是字符串中的最高 sextet 。这里是一个无符号类型,所以右移的时候会做 zero extend 而不是 sign extend 。

* `... >> 5`取得它的最高位,可能是 0 也可能是 1 。

* `(...) - 1`如果最高位是 0 的话,减去 1 会回卷到 255 ;如果最高位是 1 的话,减去 1 就是 0 。

* `~(...)`对它按位取反。如果最高位是 0 的话,这里得到 0 ;如果最高位是 1 的话,这里得到 255 。

* `... & 63`只取它的低 6 位,从而得到 0 或 63 。

之所以要这样优化,是因为条件分支在性能上通常是比顺序的运算要差的。我不知道这里如果写? 63 : 0的话编译器会不会意识到这个优化,但更常见的

bool cond;
if (cond)
   return -1;
else
   return 0;

确实会被优化掉这个条件分支,变成减 1 再取非,或者什么。
我也是之前看反汇编代码,跟编译器学到的这个 减1回卷 的位运算技巧。

@n0099
Copy link
Owner Author

n0099 commented Jan 19, 2023

#24 (comment)

杨博文阁下进一步指出根据香农第三定理不可能有编码能够缩小任何可能输入的长度而不是增长他
而四叶TG群的新创无际哲学家晓x_东方道明不需要信息论:将任意编码视作函数,当输出值集合的基数小于输入值集合时,他不可能做到对于任何输入集合都双射到另一个输出集合中,最多做到满射(也就是不同输入可能有相同输出,这就使得他是不可逆的)

https://en.wikipedia.org/wiki/Pigeonhole_principle#Uses_and_applications 早已道明:

该原理可以用来证明任何无损压缩算法,只要它使一些输入变小(顾名思义压缩),也会使其他一些输入变大。否则,可以将不超过给定长度L的所有输入序列的集合映射到长度小于L的(小得多)所有序列的集合而不会发生冲突(因为压缩是无损的),鸽巢原理排除了这种可能性。

@n0099
Copy link
Owner Author

n0099 commented Jul 11, 2023

@langyo
Copy link

langyo commented Jul 12, 2023

https://github.com/niieani/hashids.js

如果仅需要考虑小规模数据的不碰撞而无需保证长期的唯一性,可以考虑使用 nanoid

@yangbowen
Copy link

https://github.com/niieani/hashids.js

如果仅需要考虑小规模数据的不碰撞而无需保证长期的唯一性,可以考虑使用 nanoid

不推荐这种 每次随机产生 的原理。为了省去额外占用的数据库字段,也为了避免碰撞的可能性(“发生碰撞的概率”遵循 生日问题 ,例如 64 位真随机只能提供 32 位的抗碰撞),建议从(可预测的、顺序的)唯一序号/主键 决定性地 产生这个 ID 。

这种情况下所需要的实际上就是一个秘密的 伪随机排列 ——在 序列ID 跟 混淆ID 之间的一个 双射 。该双射函数需要有合适的/可自定义的输入/输出宽度,以满足您希望的 ID 空间大小的要求。同时该双射函数需要是伪随机的。
值得一提的是, 块密钥 就是从秘密的密钥产生难以预测的 伪随机排列 的算法—— AES-128 根据任意一个 128 位密钥,产生 128 位空间上的一个伪随机排列,而且可以高效地计算两个方向的映射(加密和解密)。

但考虑到您可能不希望一个这么长的 ID ,您所需要的实际上就是一个 FPE算法 ——指定一个密钥以及自定义的取值空间,它可以产生密码学上安全的伪随机排列,而且可以高效地正向和反向计算(当然,在知道密钥的前提下)。
如此,您只需要对单调递增的顺序编号做一个 FPE 加密,然后采取适宜于 URL 的编码(例如 Base32 ),就可以作为这个 ID 。反过来,对它 FPE 解密,就得到了起初的编号。

@langyo
Copy link

langyo commented Jul 13, 2023

https://github.com/niieani/hashids.js

如果仅需要考虑小规模数据的不碰撞而无需保证长期的唯一性,可以考虑使用 nanoid

不推荐这种 每次随机产生 的原理。为了省去额外占用的数据库字段,也为了避免碰撞的可能性(“发生碰撞的概率”遵循 生日问题 ,例如 64 位真随机只能提供 32 位的抗碰撞),建议从(可预测的、顺序的)唯一序号/主键 决定性地 产生这个 ID 。

这种情况下所需要的实际上就是一个秘密的 伪随机排列 ——在 序列ID 跟 混淆ID 之间的一个 双射 。该双射函数需要有合适的/可自定义的输入/输出宽度,以满足您希望的 ID 空间大小的要求。同时该双射函数需要是伪随机的。 值得一提的是, 块密钥 就是从秘密的密钥产生难以预测的 伪随机排列 的算法—— AES-128 根据任意一个 128 位密钥,产生 128 位空间上的一个伪随机排列,而且可以高效地计算两个方向的映射(加密和解密)。

但考虑到您可能不希望一个这么长的 ID ,您所需要的实际上就是一个 FPE算法 ——指定一个密钥以及自定义的取值空间,它可以产生密码学上安全的伪随机排列,而且可以高效地正向和反向计算(当然,在知道密钥的前提下)。 如此,您只需要对单调递增的顺序编号做一个 FPE 加密,然后采取适宜于 URL 的编码(例如 Base32 ),就可以作为这个 ID 。反过来,对它 FPE 解密,就得到了起初的编号。

你说得对,所以这种库其实仅仅是用在诸如前端界面(不一定是 Web)的组件树的临时标记——只需要确保几万条以内不会发生重复就足以应对了(P.S. 如果你的应用场景可能会有几十万甚至百万个节点同时出现在一个屏幕上,你应该先认真考虑一下是不是你写的有问题,而不是去改这里的随机序列算法)

@yangbowen
Copy link

yangbowen commented Jul 14, 2023

你说得对,所以这种库其实仅仅是用在诸如前端界面(不一定是 Web)的组件树的临时标记——只需要确保几万条以内不会发生重复就足以应对了(P.S. 如果你的应用场景可能会有几十万甚至百万个节点同时出现在一个屏幕上,你应该先认真考虑一下是不是你写的有问题,而不是去改这里的随机序列算法)

界面组件的话确实没有这个 碰撞 的问题——真要是那么不巧的话检测到了就重新随机也很简单。我考虑的使用场景是网站用户、视频之类的的 ID ,就像B站的 BV 号那种。这种情况下不仅数量可以多到碰撞的概率不容忽略,而且编号是长期储存的,因此我认为从顺序编号伪随机地、可逆地映射而不是每次随机产生是有好处的。

另外,对一个 单调递增值 做 块密钥加密(例如 AES-CTR )相当于将该 块密钥 转化为了一个 流密钥 ,而 流密钥 的输出流确实可以直接用作随机数序列,而且事实上也经常被如此使用。
意思是说,只要你的密钥不泄露,这个伪随机映射并不能跟每次随机产生的 ID 相区分。甚至于你的随机数发生器可能本来就是这么工作的。

@yangbowen
Copy link

https://webapps.stackexchange.com/questions/54443/format-for-id-of-youtube-video

这个是关于编码到字符串吧。编码到字符串显然是 trivial 的,怎么都行。

@yangbowen
Copy link

@yangbowen 来玩 https://n0099.zulipchat.com/#narrow/stream/396079-ATM6-To-the-Sky

我没号呀。
或者您能否动用管理员大权帮我找回?

@n0099
Copy link
Owner Author

n0099 commented Jul 14, 2023

我没号呀。
或者您能否动用管理员大权帮我找回?

建议立即大脑升级自托管zulip实例迁出zulip cloud free plan实例,,,
然后我直接在pgsql里给您设置邮箱密码

建议直接下载我昨天做的懒人整合 https://n0099-my.sharepoint.cn/:f:/g/personal/n_n0099_partner_onmschina_cn/IgBum3ZatgoMR5nNq4wCczuwAV3xgWM-4RdX1dUx9JS5jFw

@n0099 n0099 closed this as not planned Won't fix, can't repro, duplicate, stale Nov 3, 2023
@n0099 n0099 reopened this Nov 3, 2023
@n0099
Copy link
Owner Author

n0099 commented Apr 22, 2024

https://en.wikipedia.org/wiki/Octal#By_Europeans

In 1716, King Charles XII of Sweden asked Emanuel Swedenborg to elaborate a number system based on 64 instead of 10. Swedenborg argued, however, that for people with less intelligence than the king such a big base would be too difficult and instead proposed 8 as the base. In 1718 Swedenborg wrote (but did not publish) a manuscript: "En ny rekenkonst som om vexlas wid Thalet 8 i stelle then wanliga wid Thalet 10" ("A new arithmetic (or art of counting) which changes at the Number 8 instead of the usual at the Number 10"). The numbers 1–7 are there denoted by the consonants l, s, n, m, t, f, u (v) and zero by the vowel o. Thus 8 = "lo", 16 = "so", 24 = "no", 64 = "loo", 512 = "looo" etc. Numbers with consecutive consonants are pronounced with vowel sounds between in accordance with a special rule.[6]
1716 年,瑞典国王查理十二世要求伊曼纽尔·史威登堡制定一个基于 64 而不是 10 的数字系统。然而,史威登堡认为,对于智力不如国王的人来说,这么大的基数太困难了,因此提出了 8 作为基地。 1718 年,史威登堡写了(但没有出版)一份手稿:“En ny rekenkonst som om vexlas wid Thalet 8 i stelle then wanliga wid Thalet 10”(“一种新的算术(或计数艺术),它在数字 8 处变化,而不是在数字 8 处变化)通常在 10 号)。数字 1-7 由辅音 l、s、n、m、t、f、u (v) 表示,零由元音 o 表示。因此,8 =“lo”,16 =“so”,24 =“no”,64 =“loo”,512 =“looo”等。具有连续辅音的数字按照特殊规则用元音发音。 [6]

@n0099
Copy link
Owner Author

n0099 commented May 7, 2024

@n0099
Copy link
Owner Author

n0099 commented Jun 10, 2024

@n0099
Copy link
Owner Author

n0099 commented Jul 1, 2024

#24 (comment)

现在消费级常见cpu arch中也就基于RISC的arm还是大端了

确实小端更为常见,包括 ARM 平台也常常是以小端运行的。但对程序来说不要假定平台只能是小端比较好吧。 事实上除了小端、大端以外,还有更罕见的 PDP 端序 。 所以说端序检测的结果可以有至少 3 种:大端、小端、都不是。

我不知道有哪些android rom可以在arm保持大端序模式的情况下正常引导启动linux并进入launcher

en.wikipedia.org/wiki/PDP-11_architecture
PDP-11 架构[1]是由数字设备公司(DEC)开发的CISC 指令集架构(ISA )。它由PDP-11小型计算机中使用的中央处理器(CPU) 和微处理器实现。它在 1970 年代得到广泛使用,但最终在 1980 年代被更强大的VAX-11架构所掩盖。
十六位字以小尾数法存储(最低有效字节在前)。三十二位数据——支持作为基本架构的扩展,例如,FPU 指令集中的浮点数、扩展指令集中的双字或商业指令集中的长数据——以不止一种格式存储,包括一种不寻常的中间端格式[2] [3]有时称为“PDP-endian”。

那么我写的又不是要兼容几十年前各种cpu arch百花齐放时代的c程序,为什么不能假定只有小端序? 甚至可以合理怀疑 php/php-src 根本无法在指定大端序的./configure生成的makefile下正确编译,那您又如何在大端序模式的armcpu上跑php?

实际上假定1byte=8bit也是错误的,新创刘予顺神还在上海上学时就曾经给远古的1byte=7bit环境写过c程序 不如说byte本就是word的替代用语,而word字长在历史上一直都在随着电子技术发展而不断变动: en.wikipedia.org/wiki/Word_(computer_architecture)#Table_of_word_sizes

事实核查:截止2023年1月,以现代前端娱乐圈壬上壬伊欧神hifi手机3c数码圈头子556神为代表,大家都假定世界上只有一个小端序和8bit字节,可谓天无二日

https://news.ycombinator.com/item?id=27085952
https://news.ycombinator.com/item?id=40838635

@n0099
Copy link
Owner Author

n0099 commented Jul 1, 2024

@DWVoid
Copy link

DWVoid commented Jul 1, 2024

其实讲道理我不是很明白有什么比直接十进制数字更容易记忆和读写的ID表达方式。硬要说的话你可以选择往里面encode一本英语字典然后用随机可读短语来表达编号,或许会更好记忆。压缩成不可简单拼读的字符串除了在跨设备抄写URL时可以少几个按键之外我认为完全是属于无用功。可能极少的更好记的极端情况就是如下这样?但是大多数时候大小写数字字符混合是一个非常难记忆和正确抄写的组合

9223372036854775807
f/////////8=

另外,如果单纯为了避免手打ID抄错而访问错页面的情况可以考虑引入校验位

@n0099
Copy link
Owner Author

n0099 commented Jul 1, 2024

-------- Original Message --------

Subject: 四叶GTNH服存档
Date 2024-05-23 21:03
From [email protected]
To [email protected]

请问新创lys神还有23年初的四叶GTNH服存档吗?

@n0099
Copy link
Owner Author

n0099 commented Jul 1, 2024

硬要说的话你可以选择往里面encode一本英语字典然后用随机可读短语来表达编号,或许会更好记忆

什么币圈助记符 https://en.wikipedia.org/wiki/Mnemonic

@n0099
Copy link
Owner Author

n0099 commented Jul 6, 2024

@n0099
Copy link
Owner Author

n0099 commented Jul 8, 2024

@n0099
Copy link
Owner Author

n0099 commented Jul 14, 2024

@n0099
Copy link
Owner Author

n0099 commented Jul 24, 2024

#24 (comment)

qntm/braille-encode

将二进制数据转换为盲文并返回。这个想法是盲文文本在视觉上类似于原始二进制文件。例如,二进制序列 0b11110001 0b10100101 变为“⣇⢕”。每列代表每个半字节,最高有效位在顶部。

https://codegolf.stackexchange.com/questions/274322/braille-based-base64

@n0099
Copy link
Owner Author

n0099 commented Aug 3, 2024

#24 (comment)

事实核查:截止2023年1月,以现代前端娱乐圈壬上壬伊欧神hifi手机3c数码圈头子556神为代表,大家都假定世界上只有一个小端序和8bit字节,可谓天无二日

https://stackoverflow.com/questions/32091992/is-char-bit-ever-8
https://lists.isocpp.org/std-proposals/2019/10/0614.php

#24 (comment)

我记得现在那3大email协议还是只支持7bit ascii,unicode人以前也发明过utf7试图取代base64在email协议中的地位但失败了
建议学习btc钱包地址base32精神以及

TON币圈合约壬:我们需要257bits https://lists.llvm.org/pipermail/llvm-dev/2019-October/136115.html

#24 (comment)

使用 sextet 当然是因为 2 的 6 次方是 64 。每个 sextet (范围 0~63 )刚好对应 Base64 的一个编码字符。

某俄毛提出的UTF-12
https://tapemark.narod.ru/comp/utf12en.html
https://www.unicode.org/mail-arch/unicode-ml/y2010-m06/0342.html
可以与base64对齐并且避免了utf8编码俄语所用西里尔字母 https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode 所在cp如 https://codepoints.net/U+0410 时会产生leading0xD(0|1)
因为该iso15924 https://en.wikipedia.org/wiki/Script_(Unicode) 中最初分配的block https://en.wikipedia.org/wiki/Cyrillic_(Unicode_block) 的cpU+0400~U+04FF在utf8编码为2字节的范围内 https://en.wikipedia.org/wiki/UTF-8#Encoding
image
而该block中最常用的大小写版本在U+0410~U+044F按照上表可得utf8下的第一个字节是U+04(1|2|3)x0b011010000 0xD0U+044x0b11010001 0xD1
因为只有0x4=0b100需要进位到第三位而0x01~0x03高2位始终为0b0
https://stackoverflow.com/questions/9818988/how-to-encode-cyrillic-characters-for-url-and-then-decode-them
https://old.reddit.com/r/russian/comments/14ktsbv/what_character_encoding_is_this/
https://ru.wikipedia.org/wiki/%D0%92%D1%82%D0%BE%D1%80%D0%B6%D0%B5%D0%BD%D0%B8%D0%B5_%D0%A0%D0%BE%D1%81%D1%81%D0%B8%D0%B8_%D0%BD%D0%B0_%D0%A3%D0%BA%D1%80%D0%B0%D0%B8%D0%BD%D1%83_(%D1%81_2022) 这一坨只是Вторжение России на Украину (с 2022)

@n0099
Copy link
Owner Author

n0099 commented Aug 5, 2024

@n0099
Copy link
Owner Author

n0099 commented Aug 9, 2024

@n0099
Copy link
Owner Author

n0099 commented Aug 12, 2024

@n0099
Copy link
Owner Author

n0099 commented Sep 7, 2024

@n0099
Copy link
Owner Author

n0099 commented Sep 17, 2024

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.

https://github.com/php/php-src/blob/e2da65de2acae5eb17de4dd6a34bd1f8f5d8c007/ext/standard/pack.c#L1023-L1134

@n0099
Copy link
Owner Author

n0099 commented Oct 18, 2024

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants