2117-一个区间内所有数乘积的缩写
给你两个正整数 left
和 right
,满足 left <= right
。请你计算 闭区间 [left, right]
中所有整数的 乘积 。
由于乘积可能非常大,你需要将它按照以下步骤 缩写 :
- 统计乘积中 后缀 0 的数目,并 移除 这些 0 ,将这个数目记为
C
。
* 比方说,1000
中有3
个后缀 0 ,546
中没有后缀 0 。 - 将乘积中剩余数字的位数记为
d
。如果d > 10
,那么将乘积表示为<pre>...<suf>
的形式,其中<pre>
表示乘积最 开始 的5
个数位,<suf>
表示删除后缀 0 之后 结尾的5
个数位。如果d <= 10
,我们不对它做修改。
* 比方说,我们将1234567654321
表示为12345...54321
,但是1234567
仍然表示为1234567
。 - 最后,将乘积表示为 字符串
"<pre>...<suf>eC"
。
* 比方说,12345678987600000
被表示为"12345...89876e5"
。
请你返回一个字符串,表示 闭区间 [left, right]
中所有整数 乘积 的 缩写 。
示例 1:
**输入:** left = 1, right = 4
**输出:** "24e0"
**解释:**
乘积为 1 × 2 × 3 × 4 = 24 。
由于没有后缀 0 ,所以 24 保持不变,缩写的结尾为 "e0" 。
因为乘积的结果是 2 位数,小于 10 ,所欲我们不进一步将它缩写。
所以,最终将乘积表示为 "24e0" 。
示例 2:
**输入:** left = 2, right = 11
**输出:** "399168e2"
**解释:** 乘积为 39916800 。
有 2 个后缀 0 ,删除后得到 399168 。缩写的结尾为 "e2" 。
删除后缀 0 后是 6 位数,不需要进一步缩写。
所以,最终将乘积表示为 "399168e2" 。
示例 3:
**输入:** left = 371, right = 375
**输出:** "7219856259e3"
**解释:** 乘积为 7219856259000 。
提示:
1 <= left <= right <= 104
大家好,今天再次给大家带来一篇团灭系列的文章。这里我会分享一些构造极端数据的心得,介绍误差分析的一些知识,以及如何在合理的精度下通过本题。
要想成功叉掉标程,首先要有不错的直觉:这题很可能出现精度问题。LC的不少出题人似乎不会误差分析的样子,出的数据范围也比较随意(常见问题是只考虑时限,不考虑数据精度的限制),所以这已经不是第一个被我叉掉的标程了…
误差分析
猜到标程可能出问题之后,我们就可以动手寻找使程序出现精度问题的极端情况。首先估算一下这题极限情况下的精度要求,这需要一些误差分析的知识:如果我们把 n 个随机数乘起来,每乘一次后四舍五入截断产生 \varepsilon 的(相对)误差,那么最终期望的相对误差为 \sqrt{n}\cdot \varepsilon 的量级。这是因为根据随机游走的分析,每次四舍五入时可能有一半的概率高估,一半的概率低估,绝大部分的高估与低估会互相抵消。注意在最坏情况下,如果我们每次都低估(截断时舍去),那么最终的相对误差会是 n\cdot \varepsilon 量级的,所以四舍五入会比手动舍去尾数的做法精度更好。注意我这里为了方便分析把 [l,r] 内的数看成随机的了。
如果我们用 double 进行计算的话,\varepsilon大概是 1e-16 的量级(2^{-52)。那么对于 n=10^6,期望的误差是\sqrt{n}\cdot \varepsilon\approx 1e-13 的量级。
极端数据的构造
下一步是构造一个极端情况下的数据,使得计算过程中微小的精度误差可以影响最终的输出结果。比如如果真实值是 xxxx40001… 的形式,而我们的(绝对)误差低估了 2… 的形式(尾部省略的位数相同),那么我们实际输出的数会变成 xxxx39999… 的形式,就错了。也就是说我们要让构造的真实值的从高往低数第6位开始尽可能地接近 0000…或者9999… 的形式(具体是哪种需要根据想叉掉的程序是如何舍入的来确定)。
那么最坏情况下我们在第6位后可以构造出几个连续的9呢?如果我们把 O(n^2) 种可能输出的数也看作是随机的(这样简化处理是因为受到了分析技巧的限制),那么对于最坏的一个输入我们大概可以构造出 12 个连续的 9,也就是说使得 10^{-16}\sim 10^{-17 量级的相对误差就可以产生错误。这远小于 1e-13,表明用double或者long long进行计算是不可靠的。有了量级估算之后,我写代码暴力枚举出了一些较坏的数据。下面就让我们来看看周赛前五名的代码能否顺利通过我们精心构造的数据吧!
第五名 @suspicious-keldyshlkl:
1 | 6 |
输出 78782,实际值为 7878300000012…。
第四名 @tsreaper:
以上数据正确。加大难度。
1 | 18 |
输出 76682,实际值为 76683000000061…。
第三名 @darrenhp:
以上数据正确。加大难度。
1 | 3940 |
输出 24931,标程输出24930,实际值为 249310000000000016…。标程挂了,让我们恭喜这位选手。不过我们还是要继续加大难度:
1 | 2965 |
输出 89071,实际值为 8907099999999951…。
第二名 @tonghuikang:
第二名用的是 python 啊,我很慌张。不过还是挂在叉掉第四名的数据上了。
第一名 @arignote:
第一名用的是 Java,虽然跟第三名的 C++ 代码计算方式是一样的(double 一直乘,超过 100000 就 /10),但是换成 Java 就神奇地过了。以上数据全部正确。继续加大难度。
1 | 6148 |
输出 36553,实际值为 36552999999999913…。至此前五名全部团灭。
如何通过本题
在平衡运算速度与精度后,可以使用C++的__int128或者__float128进行计算(对应的 \varepsilon 约为 1e-34 的量级),或者用python自动进行高精度计算。C++的long double似乎也是可行的,我暂时没有叉掉,但是理论精度正好处在将要挂掉的边界上。Java 的大整数类我不确定够不够快,或者手写高精度也可以。我看到了 Go 的高精度还是挺快的。其他语言就自寻出路吧。
更新:我花了两天搜索了所有容易出错的数据,没有卡掉正确实现的 long double。其中最难的数据大概是
1 | 468620 |
实际值为 1655700000000000135…,用 long double 相乘算出来的是 1655700000000000075…,偏差稍微差了那么一点,再翻一倍的话就有可能出错了。比赛中唯一通过的 C++ 选手的写法为
1 | long double pre = 1.0; |
我也试了用 long double 求 log 之和再 pow 恢复的方案,求完 log 之和精度就差很远了,算出的是 165569999999948…。尽力优化细节的话可以把精度提升两个数量级,但还是会差一些。
另外再更新一个 \log^{O(1)} n 复杂度的算法,思路是用 Stirling 近似公式来快速计算 \log (n!)。以下代码用的 long double,所以还是有些精度问题,但可以通过目前LC上的所有数据。我再手写了个 float128 版本,精度就有保障了,能算到三十几位。注意到 [l,r] 内的数乘积末尾的 L 位非零值也可以在 \mathrm{poly}(L,\log n) 的复杂度内算出(可以看[这里](https://emathgroup.github.io/blog/factorial-tail)),所以本题是有 \mathrm{polylog}~n 复杂度的做法的。
1 |
|
1 | typedef __float128 D; |
三更:现在数据范围被改小到 n\leq 10000 了,不过还是得小心处理细节才能用 double 通过本题。比如我测试了一下比赛中前 150 名中的所有 82 位 C++ 选手,其中只有 24 位通过了下面这个数据:
1 | 4838 |
1 | //格式为left, right, 实际值的前5位。 |
最后贴一下完整版的 O(\mathrm{poly}\log n) 复杂度的代码,可以跑到 0ms:
1 | #define double long double |