一个赛事的小题目

本文讨论了一个编程题目,要求找到大于给定整数的下一个完美平方数。通常做法是开平方并取整,但作者指出在处理大整数时,`Math.Sqrt`方法由于long到double的隐式转换可能存在精度损失,可能导致计算错误。作者分析了double的表示方式和转换规则,提出当long值超过251时,转换误差可能导致计算误差大于1。尽管误差可能很小,但作者建议在处理此类问题时要谨慎,并提供了一种避免转换误差的计算方法。

nextPerfectSquare


这是一题摘自codewars上的训练题。

Write a function name nextPerfectSquare / next_perfect_square that returns the first perfect square that is greater than its integer argument. A perfect square is a integer that is equal to some integer squared. For example 16 is a perfect square because 16=4*4.
Caution! The largest number test is close to Int64.MaxValue

题目很简单,就是给定一个数字,求出其后第一个平方数。负数因为没有平方数,其后第一个平方数为0。

examle
n		next prefect square
6		9
36		49
0		1
-5		1

题目很简单,因为很多人都会想到,将nnn开平方,开方后的结果向下取整,其下数字再平方即为所求结果。我想信没有人会使用循环,从n+1n+1n+1开始,然后测试开方,得到整数即为下一平方数。

根据上文的叙述,差不多所有人都会考虑先开方的方式进行处理。那么程序也非常简单可以实现:

public static long NextPerfectSquare(long n)
{
	if(n<0) return 0;
	
	return (long)Math.Pow((long)Math.Sqrt(n+1),2);
}

严格来说没有问题的,Math.Sqrt(n+1)Math.Sqrt(n)+1的区别不大,都是解决若给字的数字是一个平方数时情况。这个问题解决起来也是三五分钟的事儿。运行也会通过测试,看起来天衣无缝,也几乎没有人注意到其中会存在的问题。

我的思路也是如此,先将nnn开平方后,将其平方根取整叠1后的平方数即是要求的值。但是我看到其Caution时多了一重的想法——Math.Sqrt(long)时不一定能得到正确的结果!

在使用Math.Sqrt我们必须知道,该方法的入参是一个double数据类型,该类型是64bit空间,而long类型也是64bit空间。事实上每个计算机从业人员都知道double使用的是阶码表示法,而long使用的是被码表示法。换句话来说,同样64bit空间内,我们肯定用一个double表示出所有long,也就是说这步转换存在一个值不精确的问题。那么直接使用Maht.Sqrt肯定会存在较大的bug!

double阶码表示

double类型的有效数字只有52bits,而其余的空间是阶数与符号的存储。换句话来说,其0~51是表示有效位数,而52-62则是阶数表示位置,63表示的是符号。如果不考虑阶码的情况下,其值只能表示为0002512^{51}251!而long不考虑符号位时表示的能力却是000263−12^{63}-12631,即然存在这个差别,那也就是说Math.Sqrt入参为double的情况下,使用long计算未必是正确的。

当然,这里涉及到一个隐式计算,就是long可以隐式转换double。因为double虽然使用是仍是64bit的空间,但因为阶码的存在,它下可表示2102^{10}210个小数点移位!也就是说可以左移小数点或右移小数点约1024/10∗3=3081024/10*3=3081024/103=308位!但是,同样其精度只有51位(最后一位为舍入位),也就是10进制下的17位精度。

long在向dobule隐式转换时,会出现精度损失,其表示值还是有较小的变化的。针对我们开方计算时,肯定也会受到精度的影响。把一个63bit转换成51bit的精度,很明显精度相差为12bit。也就意味着,我们开方后的值精度损失为6bit。

或者我们这样说明,如果一个数字a=(long)Math.Sqrt(n)a = (long)Math.Sqrt(n)a=(long)Math.Sqrt(n),那么可能会出现一种a±26a\pm2^6a±26内均是如此。

long与double之间的转换
long类型转换dobule类型其实是非常简单的,将需要将long前51位保留,第52位若是1则前51位的值加1,然后计算其位数填写到阶码中。最高位保留为long的符号位。最终就是double表示的值了。

微软允许long向double之间的隐式转换是因为double表示的能力比long大的多,只是损失了精度而已。

换句话来说,long之间的转换会出现这样的情况:

  1. long表示的值小于等于51位,则该值的低51位直接存在到double阶码中的精度块中(术语叫尾数),将其符号位填写到符号位中,计算其总位数填写到阶码阶数中。
  2. long表示的值大于51位数,则该值非符号位,第一个非0位开始的51bit存储到精度块中(尾数),检查第52位数字是1时,精度值加1。其他的处理相同。

那这样就导致出现问题了!根据上文转换规则我可以猜测,当n=long.MaxValue时,使用阶码表示是,其实是2632^{63}263,换句话来说,n=263−212n=2^{63}-2^{12}n=263212n=263+212−1n=2^{63}+2^{12}-1n=263+2121之间的数字都会被保存成2632^{63}263,因为底下12位精度肯定被忽视!而且这之间的值都会开成263\displaystyle\sqrt{2^{63}}263的值!

当然,只有long的值小于2512^{51}251时,因为没有丢弃的精度,所有值才是正确的。

所以在long类型的值小于2512^{51}251时,上式没有问题,否则上式计算出错误结果。当然,如果你后12bit全部是0的情况下也是正确的。同时,若我们将开方后的结果再进行long转换,小数部分的截断,导致的不确定性更大。

我们来验证一下我们的猜想

long n = long.MaxValue;
n >>=1;
double sqrt = Math.Sqrt(n);
Console.WriteLine(sqrt);
long sqrt2 = (long)sqrt;
Console.WriteLine(sqrt2);
Console.WriteLine(sqrt2*sqrt2);
Console.WriteLine(n);		
/*
output:
2147483648
2147483648
4611686018427387904
4611686018427387903

在 n>>=1;后再加一句 n -=255;
output:
2147483648
2147483648
4611686018427387904
4611686018427387648

在 n>>=1;后再加一句 n -=256;
2147483647.9999998
2147483647
4611686014132420609
4611686018427387647
*/

从上文的程序测试中可以看出,由于受到转换数值的影响,取出开方数影响已经大于1了。事实上我们使用的数字是2612^{61}261,使用Math.Sqrt方法时,long精度影响是约292^929,而开方后的精度影响是约252^525,这只是long隐式转换double的影响。加上double转long的影响,其影响的值不固定。两者相互影响可能会抵消一部分,也可能会相互增强影响。

换句话来说,问题分析到这里,我并没有在数学上证明求的开方数一定是比nnn的整数开方相比一定相等或大于1。当然我当前也无法证伪。

如果你相信,那么,基实上文中我们可以进行一部的验证,修改一下程序即可。

public static long NextPerfectSquare(long n)
{
	if(n<0) return 0;
	
	long sqrt = (long)Math.Sqrt(n);
	// 超大素数相差1的情况
	if(sqrt*sqrt<= n)
		sqrt ++;
	return sqrt*sqrt;
}

上文中我总觉得不可靠,因为我们无法证明sqrt = Math.Sqrt(n)得到的结果一定是真实的n\sqrt{n}n整数部分或n+1\sqrt{n}+1n+1的整数部分。如果能证明,那么上式可以说是简单的,如果不能证明,上式也是存在错误的!

当然,即使不是相差1,相差也不会太大,很多人可以考虑使用循环,从sqrt结果进行微调。嗯这是一种方法。

事实上在解决这个问题时,我并没有考虑过那么多,而本文只是在说明Math.Sqrt计算偏差时的体现而已。上文中我也说过,我使用的思路就是开平方,既然Math.Sqrt不能正常开平方,那么我们自己开平方以如何?

只不过针对Int64来开平方我实现的是一种简单的方法。

// 针对值大于int32.maxvalue的值进行开平方计算
public static long Sqrt(long n)
{
	// 针对高32位开平方
	long tmp = Math.Sqrt(n>>32);
	tmp <<= 16;
	// 计算低32位的值
	long remain = n - tmp*tmp;
	// 补充相应的值
	tmp += remain/(tmp*0x20000);
	if(tmp* tmp > n)
		tmp--;
	return tmp;
}

// 这种计算保证 tmp2<=n<(tmp+1)2tmp^2<=n<(tmp+1)^2tmp2<=n<(tmp+1)2,并返回了tmptmptmp的值。

所以我全文的处理方法就变成了:

public static long NextPerfectSquare(long n)
{
	// 小于0的处理
	if(n<0) return 0;
	// 小于32bit的处理
	if(n< 0x100000000)
		return (long)Math.Pow((long)Math.Sqrt(n+1),2);
	return (long)Math.Pow(Sqrt(n)+1,2);
}

我不保证Math.Sqrt(long)调用中long值接近于Int64.MaxValue的正确性,但我可以保证32bit以下开方调用是正确的。而且我想信自己的算法,当然,错了我也会认的。

其实我发现很多人提交代码时并没有考虑到long与double的转换问题,但测试都通过了,可见题目设计者也可能没有考虑到此问题的。根据上面的情况,其实很容易设计出测试Bug的案例。


数据隐式转换只是从表示范围小的类型向表示范围大且可覆盖类型范围小的类型的全部范围的类型转换。但是可能会出现清度损失。
int32向float转换时有精度损失,但向double转换时无精度损失。
~int64向double转换时有精度损失,但向decimal转换时无精度损失。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值