原文(自己的Draft)標題:一個double引發的long (漫長)思考
- 前言:先說說刷題。我的好朋友去了某知名高薪大企業,跟我閒聊第一句話就是“你日常刷題麼?”他說,這個大企,除了工作任務,還要日常刷題。我說:“我要去這個大企,是不是現在就得刷起來?”,他說,去了再刷也不遲。我內心:雖然從不刷題,但我日常隨便寫寫代碼也算是刷題吧,練的是…手速,也是程序員引以爲豪常拿出來攀比的一個技能呢,哈哈。希望讀者也能日常動手。
問題
這個問題來自於C語言聖經《C Primer Plus》(第六版) 例8-7:
“使用輸入檢測爲一個進行算術運算的函數提供整數,該函數計算特定範圍內所有整數的平方和。程序限制了範圍的上限是10000000,下限是-10000000”
我的實現
我的源代碼可以參考github。
說明:SOS是平方和Sum Of Square的縮寫。
答案
其中有一段:
static long get_sum_square(long begin, long end)
{
long i = 0;
long sum_square = 0;
for (i=begin ; i<=end ; i++) {
sum_square += i*i;
}
return sum_square;
}
和書上的例子不一樣:
double sum_squares(long a, long b)
{
double total = 0;
long i;
for (i=a ; i<=b ; i++)
total += (double)i * (double)i;
return total;
}
探究
看上面兩段代碼,其本質區別是返回的值的類型,我用long形,答案用double型。那麼二者返回的結果會一樣麼?都是對的麼?
測試代碼:將兩個函數都放到代碼中,並同時輸出進行比較:
int main(void)
{
long begin = 0, end = 0;
do {
printf("Please input the range :\n");
//Get the range [begin,end]
printf("low limit:");
begin = get_long();
printf("high limit:");
end = get_long();
//Judge the range available.
} while(is_limit_bad(begin,end));
//Calculate the sum and output
long sum_square = get_sum_square(begin, end);
double sum_square_double = sum_squares(begin, end);
printf("\r\nlong sos = %ld\r\ndouble sos = %f\r\n", sum_square, sum_square_double);
printf("Same? %d\r\n",(sum_square == sum_square_double));
getchar();
getchar();
return 0;
}
測試用例:
low | high | long sos | double sos | Same? | Target |
---|---|---|---|---|---|
1 | 2 | 5 | 5.000000 | 1 | 通過一個可以口算的測試用例來確認當前寫的代碼是可行且基本正確的 |
-1 | -2 | RangeError | 通過一個不明顯的錯誤說明Error handling起效的 | ||
0 | 10000000 | -762584128 | 333, 333, 383, 333, 688, 000, 000.00 | 1 | 直接上半個範圍測 |
根據結果,很明顯long sos是錯的,但double sos不知道是不是正確的。這是一個瓶頸性的問題,算大數的時候,由於口算無法算出來,沒有辦法判斷正確性。於是尋求網絡,還真被我找到了SOS的計算器
測試得真知:
low | high | long sos | double sos | Same? | Sos Calculator | Target | Result |
---|---|---|---|---|---|---|---|
1 | 100 | 338350 | 338350 | 1 | 338350 | 測試正常計算結果,以自然數1開始 | 結果正確,該計算器可用 |
20 | 25 | 3055 | 3055.000000 | 1 | 3055 | 測試SOS calculator是否和我的程序相匹配,下限爲任意值,而不一定是自然數1 | 恰好滿足需求 |
1 | 10000000 | -762584128 | 333, 333, 383, 333, 688, 000, 000.00 | 0 | 333, 333, 383, 333, 335, 000, 000 | 大數stress 測試 | 這下情況不妙,無論是long的結果,還是double的結果,似乎都不正確 |
這時,我甚至懷疑SOS Calculator算出的結果。好在這個網頁給了下面的公式:
哦呵呵,勝造七級浮屠啊,公式就是不論你數多大,都得按我這個道理。所以,公式算出來的就是正確答案啊,於是將程序中的main函數再進行修改:
long sum_square = get_sum_square(begin, end);
double sum_square_double = sum_squares(begin, end);
printf("\r\nlong sos = %ld\r\ndouble sos = %f\r\n", sum_square, sum_square_double);
printf("Same? %d\r\n",(sum_square == sum_square_double));
printf("Right sos = %ld", right_sum_square(begin, end));
其中 right_sum_square ()的實現如下:
static long right_sum_square(long begin, long end)
{
return end * (end+1) * (2*end+1) / 6 - begin * (begin-1) * (2*begin-1) / 6;
}
但悲劇了,公式給我的結果竟是這:
喪心病狂的我決定帶進去手算一遍,得到的結果是:33333338333333500000,和網上計算器的結果一樣,看來還是網上計算器靠譜。
long 型出現了負數,八成是因爲越界了。這樣看,這可能是兩個問題混在一起了,一個是越界,另一個是long的結果和double結果的不同。下面分別進行討論。
long 越界
爲了便於探究和區分,將函數改名get_sum_square 修改爲 get_sum_square_long,將函數名sum_squares修改爲get_sum_square_double,這樣,如果認爲long類型會越界,咱就搞個long long類型,放到main()函數中:
printf("sum_square_double = %f\r\n", sum_square_double);
printf("sum_square_long(%d) = %ld\r\n", sizeof(long), sum_square_long);
printf("sum_square_long_long(%d) = %I64d\r\n", sizeof(long long int), get_sum_square_long_long(begin, end));
其中:
static long long get_sum_square_long_long(long begin, long end)
{
long long i=0;
long long sum_square = 0;
for (i=begin ; i<=end ; i++) {
sum_square += i*i;
}
return sum_square;
}
我用Eclipse沒能使用%lld輸出64位的整型數,%I64d參考了 printf和scanf處理long long int型數據。
悲劇,看起來還是越界了,用電腦計算器直接將結果打進去,發現打64位後,就沒有辦法再往裏輸入了,少了個0,怎麼也輸入不進去:
看來是“真的”越界了,這也有拓展到一個信息,在64位的計算機裏的Calculator限制了輸入內容要小於64位最大可以表示的數(9223372036854775807),共19位十進制數字:
這樣看來確實是double的結果相對更準確呢。
簡單分析一下,整形的存儲和浮點型不一樣。整形值越大,這個數的二進制位數就越多,相當於往左移,當左移到第64位的時候,移不動了,剩下的就捨棄了,相當於100和1000的差別。而浮點型,是存儲了一個小數與2的指數,這個小數是對原數不斷除2 的結果,後面放不下的捨棄了,會把這後面捨棄的位數加到前面2的階乘裏,捨棄的也只是小數後面影響最小的一些數。參考:關於C++ double浮點數精度丟失的分析。
long 和 double 結果不同的探究
根據上面的測試,可以猜測,[low,high]範圍越大就越有可能出現結果不同。那麼我就找到第一個“出軌”的位置,以方便探究,在main中加入下面代碼段:
double sum_square_double = get_sum_square_double(begin, end);
long long sum_square_long_long = get_sum_square_long_long(begin, end);
long i = 0;
for (i=begin ; i<=end ; i++) {
sum_square_double = get_sum_square_double(begin, i);
sum_square_long_long = get_sum_square_long_long(begin, i);
if (sum_square_double != sum_square_long_long) {
printf("i = %ld\r\n", i);
printf("sum_square_double = %f\r\n", sum_square_double);
printf("sum_square_long_long = %I64d\r\n", sum_square_long_long);
break;
}
}
Come on,我就是個瘋子,因爲計算機在輪詢計算沒到滿足條件的時候沒有打印,讓我沒有辦法確定是不是進入了死循環,所以我就把它們全打印出來了,也就是沒有了if (sum_square_double != sum_square_long_long)這句判斷,得到如下結果(另外,這裏建議將等號對齊,我沒有對齊,找起來有點慢):
可以確定,問題出在第300081項上。根據公式有:
SOS(300081)
= 300081*(300081+1)(3000812+1) /6
= 9,007,336,992,830,441。
所以是浮點型的結果錯了。
接下來,涉及到的是浮點型的表示和浮點數的加法,參考這篇文章:
浮點數的加減法運算。
看過文章後,動手!將兩個加數都寫成在計算機中的浮點數表示的形式:
300081*300081
= 90,048,606,561(Dec)
= 14 F750 B161(Hex)
=+ (2^36) * (1.4F750B161)
SOS(300080)
= 9007246944223880(Dec)
= 20 000B 1A83 C288(Hex)
=+(2^53) * (1.000058D41E1440)
按照上面給的參考內容有關浮點數的加法,第一步是對階,之後相加,即:
2^53 * (1.000058D41E1440) + 2^53 * (0.0000A7BA858B01)
= 2^53 * (1.000058D41E1440 + 0.0000A7BA858B01)
= 2^53 * 1.0001008EA39F41
= 9,007,336,992,830,440 (計算出來只是爲了說明咱們筆算的是沒錯的)
上面這個結果2^53 * 1.0001008EA39F41,在小數點後有56 bits,double最多能放52 bits,所以最後4位會被捨棄,也就是 2^53 * 1.0001008EA39F4 = 9,007,336,992,830,440。諾,這就是問題所在了。
結論
- 在浮點數加法中,如果有一個數的有效位數爲52bit,再加一個新的數就很容易越界,而產生舍入誤差。
- 在整形型和浮點型型所佔用位數相同的時候,求平方和,或者大數加法時,推薦使用整形。
- 但是在結果的範圍有可能造成整形越界的時候,就要使用浮點型,雖然結果的精度不夠,但是不會產生巨大誤差。
- 這有點類似於“粗調”和“細調”兩個旋鈕。
反思
孟子曰:“盡信書不如無書”。 書上還真沒寫錯,但是探究一下發現學問蠻深的。所以孔子又說:“學而不思則罔”,只是把書上代碼抄一遍,就真的只是練了練打字速度了(首尾呼應,有沒有!)。