真的很久很久没来写博客了呢。倒也不是完全没东西写,只是写过博客才发现要把自己的知识清楚地表达出来其实不是那么简单的事情,感觉这件事情太费心力了,就一直没有抽出时间。

所以为什么突然又出现在了这里呢,只是因为想要分享一道编程问题。这个问题特别有意思,我刚开始读题的时候心想「这种小儿科问题,我五分钟都不用就写完了」,可实际动手才发现远没有那么简单。最终,在这个问题上我花费了超过 6 个小时,汗流浃背了小老弟。

问题的原博客见这个链接。下面是问题的概述:给出一个 64 位的无符号整数 num,把「num 个字节」转换为可阅读的格式并返回。举个很简单的例子:如果传入的参数是 666,888,就表示 666,888 个字节,它对应的是 666.888 kB,所以应该返回字符串 "666.9 kB"

前排提示:本文包含了较多数学公式,而很不幸的是它们在部分浏览器上无法被正常渲染。如果你发现文中的数学公式显示为 LaTeX 源代码导致难以阅读,可以尝试更换浏览器。


一些前置知识

关于参数

函数的参数 num 是一个 64 位无符号整数,意味着它的值在 $[0,2^{64}-1]$ 区间内,对于该区间内的任何整数,我们都应该能够正确处理。

有小伙伴可能会问:Java 之类没有 64 位无符号整数的语言怎么办?对于有符号的 64 位整数(例如 Java 的 long),我们规定非负数正常处理,而负数取绝对值,所以此时的范围区间是 $[0,2^{63}-1]$。

本文的代码均使用 Go 语言编写,它包含原生的 uint64 类型。不用担心看不懂 Go 代码,把它看成语法简化一点的 Java 就可以。

关于单位

计算机存储容量的计量单位有两种标准:国际单位制 SI 以及二进制制式

在 SI 中,1 kB 等于 1000 B,注意这里的 k 是小写。此后每 1000 倍分别对应 MB, GB, TB, PB, EB。比 EB 还大的单位我们可以不用管,因为本题的数据最大不超过 20 EB。

在二进制制式中,1 KiB 等于 1024 B,其中 Ki 是 “Kibibyte” 的缩写。此后每 1024 倍分别对应 MiB, GiB, TiB, PiB, EiB

关于有效数字

本题暗含了如下要求:如果最终结果的单位是 B,则数值直接写成整数,如 123 B;而如果是其他单位,则应该对数值四舍五入并保留小数点后 1 位,如 1.0 kB, 999.9 MB 等等,且该数值的范围应该最小是 1.0 而最大是 999.9 或者 1023.9

保留一位小数最符合我们日常的阅读习惯,同时也让这个问题的难度处于恰到好处的状态。


十进制总搞得定吧

轻轻松松拿下

好的,现在让我们开始解题。

既然有两套标准,那我们就应该写一个函数兼容这两套标准。换言之,我们应该添加一个新的参数 si,表示是否采用国际单位制。最终的函数签名如下:

func HumanReadableByteCount(num uint64, si bool) string

在本文中,我们先讲解当 si == true 时的解法,再讲解二进制制式的解法。读到后面你就会明白为什么拆开会更方便。所以我们要添加一个子函数:

func HumanReadableByteCountSI(num uint64) string

对于这样一个简单的处理十进制数的问题,聪明的你应该很容易想到解题思路:

  1. 首先判断单位,如果是 B 则直接返回;
  2. 除以该单位对应的字节数 $base_\text{unit}$,例如让 111,222,333 除以 $base_\text{MB}$ ($10^6$),得到 111.222333;
  3. 保留一位小数,例如从 111.222333 四舍五入到 111.2;
  4. 把数值和单位拼起来并返回,例如 111.2 MB

聪明的你也很快写出了代码:

func HumanReadableByteCountSI(num uint64) string {
    if num < 1000 {
        return strconv.FormatUint(num, 10) + " B"
    }
    units := []string{"kB", "MB", "GB", "TB", "PB", "EB"}
    i := -1
    val := float64(num)
    for val >= 1000.0 {
        i++
        val /= 1000.0
    }
    return strconv.FormatFloat(val, 'f', 1, 64) + " " + units[i]
}

如此简单,也如此优雅。

四舍五入你干嘛哎哟

然而,HumanReadableByteCountSI(999_999) 会是多少呢?它小于 $base_\text{MB}$,因此这个函数得到的单位是 kB,除以 $base_\text{kB}$ 得到 999.999 kB,四舍五入之后的返回值就是……1000.0 kB

不,不该是这样。虽然实际数量离 1 MB 还差一个字节,但单看字符串,1000.0 kB 已经达到一个更大的单位 MB,因此正确的返回值是 1.0 MB

聪明的你心想,居然敢阴我,讨厌。不过如此小小陷阱不足为惧,只要最后判断一下值是否大于进位临界点 999.95 即可:

func HumanReadableByteCountSI(num uint64) string {
    if num < 1000 {
        return strconv.FormatUint(num, 10) + " B"
    }
    units := []string{"kB", "MB", "GB", "TB", "PB", "EB"}
    i := -1
    val := float64(num)
    for val >= 1000.0 {
        i++
        val /= 1000.0
    }
    if val >= 999.95 {
        i++
        val = 1.0
    }
    return strconv.FormatFloat(val, 'f', 1, 64) + " " + units[i]
}

如此精准,也如此优雅。

浮点数,我跟你没完

然而问题又发生了:HumanReadableByteCountSI(999_949_999_999_999_999) 的结果居然是 1.0 EB?明明应该是 999.9 PB 才对的。

你登时发现了问题:会不会是 999.949...9 这么「麻烦」的小数用双精度浮点没办法精确表示,以至于它叛变到了不小于 999.95 的敌军阵营?

你别说,你还真别说,你还真真别说,确实是这样的。都怪万恶的浮点数。

既然浮点数喜欢作妖,有没有什么办法绕开浮点数?那当然是不可能的,毕竟结果要求保留一位小数。除非你打算分别求出整数和小数部分然后把它们手动拼在一起,但那实在有点滑稽。

不过俗话说得好,车到山前必有路。虽然没办法完全避免使用浮点数,但我们能够很容易发现,对于 999,949,999,999,999,999 这种妖孽,我们只需要观察它的 999 后面跟的是 94 还是 95,不就能断定它该不该进位了?这样一来,后面哪怕有一万个 9 也是不在话下。体现到在代码里,我们可以先不用浮点数,直接使用整数除法,抹掉后面那堆没用的数字,最后要计算字符串里的小数时才用浮点数。

聪明的你心想,事不过三,这一次,我要赢下所有:

func HumanReadableByteCountSI(num uint64) string {
    if num < 1000 {
        return strconv.FormatUint(num, 10) + " B"
    }
    units := []string{"kB", "MB", "GB", "TB", "PB", "EB"}
    var i int
    for num >= 999_950 {
        i++
        num /= 1000
    }
    return strconv.FormatFloat(float64(num)/1000, 'f', 1, 64) + " " + units[i]
}

呼……终于……总算……结束了吧。

的确,上面就是 HumanReadableByteCountSI 函数的标准解法,看着是不是非常简洁。

顺带一提,为什么在转换字符串的时候非要用繁琐 strconv 的函数而不是简单的 fmt.Printf?因为 fmt.Printf 函数在执行过程中会产生很多类型转换,性能上不如直接拼接字符串。其他语言也最好选择处理字符串效率更高的方法。

硬要说还有什么可优化的地方,那就是确定单位的过程可以不使用循环,而是通过直接求对数来完成,例如当 num 等于 111,111,111 时, $2<\log_{1000}num<3$,因此可以直接断定单位是 MB。但对数运算又会牵扯到浮点数,此时处理精度损失的问题会更加麻烦,感兴趣的话可以自己尝试着实现。

总之,到此为止我们已经成功解决了这个「简单」问题的「二分之一」,可喜可贺!


头一回看 1024 这么不顺眼

二进制,拟态 baby 辣

好了,十进制的各路妖孽已经伏诛,二进制还不是手到擒来?

第一步,上签名:

func HumanReadableByteCountBinary(num uint64) string

首先当然是看能不能照搬前面的做法。这时我们遇到了一个问题——之前的 999_950,在二进制里面会是个什么东西?

你可能会想,既然 $999,950=1000\times999.95$,那我们如法炮制地算出 $1024\times1023.95$ 不就行了?

很抱歉,事情并没有那么容易。且不说 $1024\times1023.95$ 不是个整数,就算你向上取整得到了应该进位到 1 MiB 的最小值 1,048,525,那么 1 GiB 呢?

在十进制里,1 MB 的边界 950,000,乘以 1000 之后刚好是 1 GB 的边界 950,000,000,毕竟「四舍五入」本就是十进制世界的规则。来到二进制的四舍五入可没有这种好事。

怎么样,至今为止都坚信着「1 KB 就是 1024 个字节而绝不是 1000 个」的人,有没有头一回觉得 1024 是如此碍事、如此丑陋,如此欲除之而后快?

原来你也是有弱点的嘛

此时一种暴力的方式是手动求出 1 GiB, 1 TiB, 1 PiB, 1 EiB 的边界,再在代码里依次拿它们和 num 比较。但那样可太不优雅了(虽然在大公司的开发过程里这种不优雅的方案往往是最省事且最高效的方案)。

不过完全不用慌。虽说二进制和十进制好像有些水火不容,但它们本质上还是心灵相通的(不然二进制早就该灭绝了)。一个非常棒的消息是,所有十进制的有理数也一定是二进制的「有理数」,换句话说,0.95 这个看似 binary-unfriendly 的数实际上是一个二进制里的无限循环小数,它的二进制是 0.1111 0011 0011...,也就是 $(0.11\overline{1100})_2$。进而,1023.95 的二进制是 $(1111111111.1111\overline{0011})_2$,小数点前面是 10 个 1。

有了 1023.95 的二进制,那么往它的前面每乘以一个 1024,仅仅只需要把二进制的小数点往右挪 10 位就可以。

更具体地说,$\left\lceil1024\times1023.95\right\rceil$ 的十六进制是 FFFCD;$\left\lceil1024^2\times1023.95\right\rceil$ 的十六进制是 3FFF3334,它右移 10 位恰好等于 FFFCC;$\left\lceil1024^3\times1023.95\right\rceil$ 右移 10 位则恰好等于 3FFF3333……

也就是说我们得到了一个非常有用的规律:GiB 的边界右移 10 位刚好等于 MiB 的边界减 1,以此类推

到此为止,我们就可以顺利地写出想要的代码了:

func HumanReadableByteCountBinary(num uint64) string {
    if num < 1024 {
        return strconv.FormatUint(num, 10) + " B"
    }
    units := []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
    var i int
    for offset := 40; offset >= 0 && num > 0xFFF_CCCC_CCCC_CCCC>>offset; offset -= 10 {
        i++
    }
    num >>= i * 10
    return strconv.FormatFloat(float64(num)/1024, 'f', 1, 64) + " " + units[i]
}

需要注意的是,由于边界的特性不同,十进制的做法是「初始化 MB 的边界,然后不断砍掉 num 的尾数直到它小于 MB 的边界」,而二进制的做法则是「初始化 EiB 的边界,然后不断恢复被砍掉的边界直到它不小于 num」。

嗯,经过了一通猛如虎的分析,又经过了一通猛如虎的修改,再经过了一通猛如虎的测试,我们欣喜地发现这段代码连面对 1,152,865,209,611,504,844 和 1,152,865,209,611,504,845 这两个最棘手的输入时都能够面无表情地输出 1023.9 PiB1.0 EiB 这两个正确无比的结果。至此你宣布:二进制也已经狠狠拿下!完结撒花!


还有高手?

丢失的毫厘

1023.9 PiB1.0 EiB 的难题就此顺利解决。甚至连原文也止步于此万事大吉了。一切看上去都如此美好。

正当你觉得这道题也不过如此时,一个测试用例从阴影中突然出现:HumanReadableByteCountBinary(1101005)。经过计算你会发现 1,101,005 B 约等于 1.053 MiB,但上面的函数却输出了 1.0 MiB

聪明的你突然发现自己大意了。虽然不同单位之间的临界点能够正确处理,但普通的进位却经常出错,原因是 num >>= i * 10 这个语句直接丢掉了 num 除以 1024 的余数,导致原本大于 xxx.x5 的值却有可能被扔到 xxx.x4 的序列,从而无法得到进位。

聪明的你心想,那还不简单,我直接浮点数精准计算,起飞:

func HumanReadableByteCountBinary(num uint64) string {
    if num < 1024 {
        return strconv.FormatUint(num, 10) + " B"
    }
    units := []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
    var i int
    for offset := 40; offset >= 0 && num > 0xFFF_CCCC_CCCC_CCCC>>offset; offset -= 10 {
        i++
    }
    val := float64(num) / math.Pow(1024, float64(i+1))
    return strconv.FormatFloat(val, 'f', 1, 64) + " " + units[i]
}

如此简单,也如此优雅。

死去的记忆

经过简单的测试,你看着测试通过的结果高兴地宣布:区区 1.05 MiB 也不过如此。一切看上去都如此美好。

但是还记得上个版本对于 1,152,865,209,611,504,844 的输出是什么来着吗?1023.9 PiB

现在呢?1024.0 PiB

此之谓背刺也。

鲁迅先生曾说过,永远不要认为测试用例通过就代表能高枕无忧,它们大抵是要背刺的。

言归正传,问题出在哪里?其实我们在十进制里已经讨论过了,原因是浮点数会造成精度丢失。但有意思的是,十进制里面可以通过观察小数点后第二位是否达到 5 来判断要不要进位,而二进制里呢?想屁吃

或许,是时候和浮点数正面交锋了。我们只有搞清楚浮点数到底不精确在哪里,才有可能彻底解决这个问题。

粗糙的精密

我们把一个实数集合中相邻两个数之间的差称为「刻度距离」,例如一把直尺的刻度距离一般是 1mm,64 位无符号整数的刻度距离是 1。

IEEE 754 标准中的 64 位双精度浮点数是由 1 位符号、11 位指数和 52 位尾数组成的。它的刻度距离比较特殊,会根据所处的范围而变化。

在 $[0,2^{-1021})$ 这个区间内,刻度距离等于 $2^{-1074}$,这是双精度浮点数能达到的最小刻度距离。

在此之后的规律是:在 $[2^x,2^{x+1})$ 内,刻度距离等于 $2^{x-52}$,其中 $x$ 是 $[-1021,1022]$ 范围内的整数。

例如在 $[1,2)$ 内,刻度距离是 $2^{-52}$,此时的浮点数可以说非常精确。

在 $[2^{52},2^{53})$ 内,刻度距离恰好等于 1,该范围内的整数都能够被精确表示。比 $2^{53}$ 还要更大的数如果仍然使用 64 位浮点数表示,则很有可能出现精度损失。

浮点数之所以呈现出这样的特性,正是因为它的小数点会「浮」动。前文提到了 64 位浮点数的尾数是固定的 52 位,再加上 1 位隐藏位,有效数字的位数只有 53 位,想要让浮点数的表示范围足够大,浮动小数点是有效且简便的方案。因此,当整数部分过大时,浮点数的小数点只能被迫向右挤压,留给小数点右侧的空间越来越小,以至于到最后小数点后的位数变为「负数」,也就是连整数都无法精确表示的情况。

浮点数所能表示的大约 922 亿亿个正实数里,有一半都小于 1,可以想象这个 1 里会有多么热闹。浮点数这种表示方法就像一座一维的城市,市中心人流如织,到了荒野则渺无人烟。

由于接近 1 EiB 的字节数处于 $[2^{59},2^{60})$ 内,对应的浮点数刻度距离是 128,所以强制转换为浮点数会产生至多 64 的误差。当超过 1EiB 之后,误差将进一步增大。

不安的气息

如何解决?

聪明的你应该能想到一种方法:分而治之。既然非常大的 num 转换为浮点数不准确,那么完全可以把它拆成两部分: num 除以 1024 的余数 $r$,以及 $num-r$。这时,$r$ 小于 1024 而 $num-r$ 被 1024 整除,二者转换为浮点数都是精确的,让它们分别除以 $base_\text{unit}$ 再相加,不就精准了?就像下面这样:

func HumanReadableByteCountBinary(num uint64) string {
    if num < 1024 {
        return strconv.FormatUint(num, 10) + " B"
    }
    units := []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
    var i int
    for offset := 40; offset >= 0 && num > 0xFFF_CCCC_CCCC_CCCC>>offset; offset -= 10 {
        i++
    }
    quo := num >> 10
    remainder := num - quo<<10
    base := math.Pow(1024, float64(i))
    val := float64(quo)/base + float64(remainder)/(base*1024)
    return strconv.FormatFloat(val, 'f', 1, 64) + " " + units[i]
}

如此精准,也如此优雅。

虽然两个除法都很精确,但十分可惜的是,把两个商加在一起之后,刻度距离会向大数看齐,这会使得 $r$ 的结果不准确,最后仍然不能正确进位。例如用浮点数计算 $2^{30}+2^{-30}$,刻度距离会变成 $2^{-22}$,如此「大」的刻度距离无法容纳 $2^{-30}$ 这样微小的值,结果只会是 $2^{30}$。

不用浮点数会丢掉余数,用了浮点数又有误差……这么看,好像要陷入无解的境地了。

沉默的谜底

回想一下之前是如何解决十进制的 1024.0 PB 的?是精确地计算出最终值然后四舍五入吗?不是,恰恰相反,是把 num 换成一个不精确的值,再去除以 $base_\text{unit}$,最后四舍五入。

为什么不精确的值反而能够算出正确结果?关键在于不精确的方向

对于 999,949,999,999,999,999,当初正是把它当成 999,949,000,000,000,000 这样一个不精确的值,才把问题解决的。这里,我们的目的是在对除以 $base_\text{PB}$ 后的值进行四舍五入时避免进位,因此不精确的方向是小于。相反,如果我们把 999,949,999,999,999,999 不精确成一个大于它的数,例如 999,950,000,000,000,000,那么天王老子来了它都难逃一「进」。

换言之,对于应该进位的值,不精确的方向是更大,而对于不应该进位的值,不精确的方向是更小。只要我们自己把握不精确的方向,而不是对浮点数的舍入规则听之任之,就能够顺利解决问题。

最终我们的任务变为:

  1. 判断 num 转换为 float64 的过程是否存在精度损失;
  2. 如果存在精度损失,判断它实际上是否应该进位
  3. 如果应该进位,让不精确的方向变为更大;否则变为更小

第 2 步和第 3 步怎么实现?

第 2 步:判断精度损失只需要比较 num 和 $2^{53}$,而判断是否应该进位则可以通过计算 $20\cdot(r\cdot2^{-50})$ 来完成,其中 $r$ 表示 num 除以 $base_\text{PiB}$ 的余数。这样做的原理是 $[0,0.05)$ 内的数乘以 20 的结果属于 $[0,1)$,而 $[0.05,0.1)$ 内的数乘以 20 的结果属于 $[1,2)$,因此可以通过上面式子结果的整数部分的奇偶性,判断是否应该进位。

第 3 步:为了避免 xxx.x5 附近的数值被划分到 xxx.x4,我们可以在第 2 步确认需要进位之后给这个值加上一个偏置,这个偏置可以是 0.1 以内随便一个数,例如 0.05;同理,不该进位的值都减去一个偏置即可。这里为了编写代码方便,我们选择 $2^{-4}$ 也就是 0.0625 这个值。

具体代码如下:

func HumanReadableByteCountBinary(num uint64) string {
    if num < 1024 {
        return strconv.FormatUint(num, 10) + " B"
    }
    units := []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
    var i int
    for offset := 40; offset >= 0 && num > 0xFFF_CCCC_CCCC_CCCC>>offset; offset -= 10 {
        i++
    }
    if i >= 4 {
        num = fixLargeByteNum(num)
    }
    val := float64(num) / math.Pow(1024, float64(i+1))
    return strconv.FormatFloat(val, 'f', 1, 64) + " " + units[i]
}

func fixLargeByteNum(num uint64) uint64 {
    if num <= 1<<53 {
        return num
    }
    remainder := num - (num >> 50) << 50
    val := uint64(float64(num))
    if uint64(float64(remainder)/float64(1<<50)*20)&1 == 1 {
        if val < num {
            num += 1 << 46
        }
    } else {
        if val > num {
            num -= 1 << 46
        }
    }
    return num
}

你拿着新的代码重新测试了一遍,发现之前所有出错的测试用例现在已经全都能够顺利通过。

你不由得长舒一口气。

但你随即意识到一个新的问题:fixLargeByteNum 函数只针对单位为 PiBnum,那么 EiB 呢?例如 1.05 EiB 这个边界能够正常处理吗?

你心想,这不成问题,把 fixLargeByteNum 里面的 50 都改成 60 就好了。

然而很遗憾,此时的 $r$(也就是代码中的 remainder)只是小于 $2^{60}$,但不保证小于 $2^{53}$,因此在向 float64 转换的路上,末尾的有效数字是无法逃离被吞噬的命运的。

64 位的浮点数原来这样渺小,在庞大的 EiB 面前显得如此苍白无力。

于是我们只好无奈地承认,这条路也宣告失败。

浩荡的奥秘

和浮点数硬碰硬这条路,被彻底堵死。

难道我们真的要屈服于一堆 xxx.x5 吗?明明只需要把小数点后的那一位关键数字算对就好。

等等。「小数点后的那一位关键数字」?那一位,关键数字?

一位数字有几种可能性?无非是从 0 到 9 而已。

还记得我之前说过什么:

此时一种暴力的方式是手动求出 1 GiB, 1 TiB, 1 PiB, 1 EiB 的边界,再在代码里依次拿它们和 num 比较。但那样可太不优雅了(虽然在大公司的开发过程里这种不优雅的方案往往是最省事且最高效的方案)。

有没有一种可能,此时一种暴力的方式是手动求出 0.0, 0.1, 0.2……这十种结果各自的边界,再在代码里依次拿它们和 num 比较?毕竟,暴力法虽然可耻,但有用,而且是非常有用。

众所周知,$(0.95)_{10}=(0.11\overline{1100})_2$,其他的 0.x5 也照样可以轻松写出:

十进制 二进制 十进制 二进制 十进制 二进制 十进制 二进制 十进制 二进制
0.05 $0.00\overline{0011}$ 0.15 $0.00\overline{1001}$ 0.25 $0.01$ 0.35 $0.01\overline{0110}$ 0.45 $0.01\overline{1100}$
0.55 $0.10\overline{0011}$ 0.65 $0.10\overline{1001}$ 0.75 $0.11$ 0.85 $0.11\overline{0110}$ 0.95 $0.11\overline{1100}$

这样一来,我们就能够计算 $base_\text{EiB}$ 乘以各种 0.x5 的精准边界值,例如 $\left\lceil0.05\cdot base_\text{EiB}\right\rceil-1$ 就等于 0xCC_CCCC_CCCC_CCCC。而且这个数不仅适用于最终单位是 EiB 的情况,结合之前找到的规律,它右移 10 位就等于 $\left\lceil0.05\cdot base_\text{PiB}\right\rceil-1$,以此类推。

因此,先计算结果的整数,再通过穷举法计算小数点的后一位,也许真的有可能创造奇迹:

func HumanReadableByteCountBinary(num uint64) string {
    if num < 1024 {
        return strconv.FormatUint(num, 10) + " B"
    }
    units := []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
    var i int
    for offset := 40; offset >= 0 && num > 0xFFF_CCCC_CCCC_CCCC>>offset; offset -= 10 {
        i++
    }
    return formatQuotient(num, (i+1)*10) + " " + units[i]
}

func formatQuotient(num uint64, offset int) string {
    intPart := num >> offset
    remainder := num - intPart<<offset
    offset = 60 - offset
    borders := []int{
        0x0CC_CCCC_CCCC_CCCC >> offset,
        0x266_6666_6666_6666 >> offset,
        0x3FF_FFFF_FFFF_FFFF >> offset,
        0x599_9999_9999_9999 >> offset,
        0x733_3333_3333_3333 >> offset,
        0x8CC_CCCC_CCCC_CCCC >> offset,
        0xA66_6666_6666_6666 >> offset,
        0xBFF_FFFF_FFFF_FFFF >> offset,
        0xD99_9999_9999_9999 >> offset,
        0xF33_3333_3333_3333 >> offset,
    }
    index := sort.SearchInts(borders, int(remainder))
    intStr := strconv.FormatUint(intPart+uint64(index/10), 10)
    decStr := strconv.FormatUint(uint64(index%10), 10)
    return intStr + "." + decStr
}

这里的 sort.SearchInts 可以在有序整数切片中利用二分法寻找一个值 v 的插入索引,准确地说,它会返回满足 v <= arr[i] 的最小索引 i,如果不存在这样的索引,则返回切片长度,也就是告诉调用者「直接把 v 扔到最后就行」。

在上面的代码中,val 的值为 0-9 分别代表整数部分不变,而四舍五入后小数点后 1 位的值应该等于 valval 的值为 10 则代表最终值的小数部分已经达到了 .95,需要向整数部分进位。

还记得我之前又说过什么:

既然浮点数喜欢作妖,有没有什么办法绕开浮点数?那当然是不可能的,毕竟结果要求保留一位小数。除非你打算分别求出整数和小数部分然后把它们手动拼在一起,但那实在有点滑稽。

虽然通过「拼在一起」的生硬方式拒绝浮点数的做法对于十进制来说确实有些舍近求远,但在完全不欢迎四舍五入的二进制世界里,这种「滑稽」的方式,恰是一张绝杀的底牌

这个问题的谜底是如此简单,如此纯粹,又是如此浩浩荡荡。

在这样的浩荡之中,又淡淡弥散着那二进制所独有的、不可端倪的、诡谲的美感。

愿你归来半生,仍是穷举。

尾声

对这个问题的讨论到这里终于已经要结束了。完整的代码如下所示:

func HumanReadableByteCount(num uint64, si bool) string {
    if si {
        return HumanReadableByteCountSI(num)
    }
    return HumanReadableByteCountBinary(num)
}

func HumanReadableByteCountSI(num uint64) string {
    if num < 1000 {
        return strconv.FormatUint(num, 10) + " B"
    }
    units := []string{"kB", "MB", "GB", "TB", "PB", "EB"}
    var i int
    for num >= 999_950 {
        i++
        num /= 1000
    }
    return strconv.FormatFloat(float64(num)/1000, 'f', 1, 64) + " " + units[i]
}

func HumanReadableByteCountBinary(num uint64) string {
    if num < 1024 {
        return strconv.FormatUint(num, 10) + " B"
    }
    units := []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
    var i int
    for offset := 40; offset >= 0 && num > 0xFFF_CCCC_CCCC_CCCC>>offset; offset -= 10 {
        i++
    }
    return formatQuotient(num, (i+1)*10) + " " + units[i]
}

func formatQuotient(num uint64, offset int) string {
    intPart := num >> offset
    remainder := num - intPart<<offset
    offset = 60 - offset
    borders := []int{
        0x0CC_CCCC_CCCC_CCCC >> offset,
        0x266_6666_6666_6666 >> offset,
        0x3FF_FFFF_FFFF_FFFF >> offset,
        0x599_9999_9999_9999 >> offset,
        0x733_3333_3333_3333 >> offset,
        0x8CC_CCCC_CCCC_CCCC >> offset,
        0xA66_6666_6666_6666 >> offset,
        0xBFF_FFFF_FFFF_FFFF >> offset,
        0xD99_9999_9999_9999 >> offset,
        0xF33_3333_3333_3333 >> offset,
    }
    index := sort.SearchInts(borders, int(remainder))
    intStr := strconv.FormatUint(intPart+uint64(index/10), 10)
    decStr := strconv.FormatUint(uint64(index%10), 10)
    return intStr + "." + decStr
}

但我的能力不足以定义这个问题的「结束」。也许被忽略的错误测试用例正伺机待发,也许还会出现更好的方法,也许哪一天 AI 已经强大到能够在一秒钟之内把这个问题分析透彻(可能那个时候人类对地球的统治都已经结束了)……

总之,感谢你能读到这里。

最后,特别鸣谢 Windows 计算器对本文的大力支持,如果离开了它纵享丝滑的进制转换以及超大整数运算,我甚至没有机会意识到十进制小数和二进制小数之间的美妙联系。

下次(比方说地球脱离太阳系的时候)见。