一個偶然的機會,我想起以前還在谷歌上班的時候,有時候大家會在飯桌上討論最新想出來的一些面試題。在眾多有趣又有難度的題目中,有一道老題卻是大家都紛紛選擇避開的,那就是去實現二分查詢。
因為它很好寫,卻很難寫對。可以想象問了這道題後,在5分鐘之內面試的同學會相當自信的將那一小段程式碼交給我們,剩下的就是考驗面試官能否在更短的時間內看出這段程式碼的bug了。
二分查詢是什麼呢,這個不只程式設計師,其他很多非技術人員也會。比如我想一個1到100以內的數,你來猜,我告訴你每次猜的是大了還是小了,你會先猜50,然後25, 然後。。。用不了幾個問題就猜出來了。1到100範圍太小的話,我們放大點猜個人名,你問中國人外國人,古代人現代人,男的女的,用不了幾個問題也問出來了。在計算機裡,則是在一個有序陣列裡面,不斷通過二分的方法縮小關鍵字的可能下標範圍。當然了,我們不一定在一個有序陣列裡查詢,也可以在一個很大的狀態空間裡,去查詢一個單調函式的取值。這樣的做法,似乎編個程式很容易實現,但是,
D.Knuth大神說了:Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky 雖然二分查詢的基本思想相對來說很直接,但具體實現起來有特別多的坑。
另一位大神,程式設計珠璣的作者Jon Bentley,他做了我們在文章開頭不敢做的事,他佈置作業讓他的學生們寫二分查詢,然後他一個個來看。結果呢,他發現90%是錯的。因此在他的程式設計珠璣這本書中,專門有一章講解了二分查詢,雖然他的範例仍然是錯的,見下面的Java Bug。埋下這個bug的人,也正式Jon Bentley的學生。
還有好事者,更是找了許多教科書,發現20本教科書裡面,只有5本是寫對了的,於是他發了一篇文章到ACM。當然這是早在1988年的時候。
然而這些都不算啥,更能讓人感覺幸災樂禍的是,Java庫裡面的二分查詢,有一個埋藏了10年之久的bug。這個bug呢,在 java.util.Arrays.binarySearch 裡面,雖然這個bug的修復也已經是10年前的事了。那麼我們來看下當年的錯誤程式碼吧。
大家可能很難看出來,那畢竟這個bug藏了10年,不太容易發現。問題就在於
1
int mid = (low + high) / 2;
這裡。low + high 是會溢位的。只要這個陣列我們開的足夠大,比如1100000000,就能重現這個問題,雖然這需要我們費點記憶體。因此正確的解法是:int mid = (low + high) >>> 1; 三個>,無符號位移的意思。正如修復bug的同學說的那樣:
1
"Can't even compute average of two ints" is pretty embarrassing.
這個bug的連結在這裡。
那麼我們究竟如何來把二分查詢寫正確呢?我們二分查詢中常見的錯誤除了上面的溢位之外,最多的是下面幾類:
差1錯誤。我們的左端點應該是當前可能區間的最小範圍,那麼右端點是最大範圍呢,還是最大範圍+1呢。我們取了中間值之後,在縮小區間時,有沒有保持左右端點的這個假設的一致性呢? 死迴圈。我們做的是整數運算,整除2了之後,對於奇數和偶數的行為還不一樣,很有可能有些情況下我們並沒有減小取值範圍,而形成死迴圈。 退出條件。到底什麼時候我們才覺得我們找不到呢? 我很喜歡二分查詢這個案例。一個在理論上這麼簡單直接的演算法,在計算機裡實現卻要考慮那麼多實際的情況,除了理論的細化比如差1錯誤和退出條件,還有計算機的實際問題如整除2,死迴圈,以及上面提到的溢位。正如我們以前同事每天掛在嘴邊的
You know the difference between in theory and in practice? In theory there’s no difference but in practice there are.
軟體工程師,就是把現實生活用理論進行建模,然後再用現實來實現這樣的理論。寫出好的程式碼不容易,寫出既讓使用者滿意又好的程式碼,那更不容易。也許有時候,成就感就來自於此吧。
歡迎工作一到五年的Java工程師朋友們加入Java架構師:697558955
群內提供免費的Java架構學習資料(裡面有高可用、高併發、高效能及分散式、Jvm效能調優、Spring原始碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!