聊聊一看就會一寫就跪的二分查找

2021-01-14 IT夜航船

圖書館自習的時候,一個女生背著一堆書走進閱覽室,結果警報響了。阿姨讓女生看是哪本書把警報弄響了,女生把書倒出來,一本一本地測。阿姨見狀急了,把書分成兩份,第一份過了一下,響了。又把這一份分成兩份接著測,三回就找到了。阿姨用鄙視的眼神看著女生,仿佛在說 O(n) 和 O(log n) 都分不清。

要說哪個算法的知名度較高,二分查找一定排得上號。以至於在面試的時候,如果應聘者簡歷寫了擅長算法或者參加過ACM競賽之類,我都會讓他現場寫一道二分查找的題目:

// 已知array數組的元素為單調非遞減,給定一個target,返回array數組裡面
// 第一個大於或等於target的元素下標,如果沒有合法結果則返回len(array)
func FirstGreaterOrEqual(array []int, target int) int {
// TODO: write your code here
return len(array)
}

一開始我覺得如果對方真的擅長算法,這題應該很基礎並不算為難應聘者(紙上寫只需要偽代碼即可/電腦上寫則需要通過編譯),直到幾十輪面試之後幾乎90%的人都在這道題上敗北。

後來我認真反思了這個事情,發現從思路上來講,絕大部分人都是沒有問題的:用l和r來表示一個待查找區間,將區間中點m對應的元素同target相比較,根據比較結果來決定繼續查找哪一半。也就是說,大家幾乎都能寫出如下的「骨架」:

func FirstGreaterOrEqual(array []int, target int) int {
// 初始化區間左端點:-1 || 0 || 1 ?
l := 0
// 初始化區間右端點:len(array) - 1 || len(array) || len(array) + 1 ?
r := len(array)
// 當區間不為空時循環:l + 1 < r || l < r || l <= r || l <= r + 1 ?
for l < r {
// 計算區間中點:l + (r - l) / 2 || l + (r - l + 1) / 2 ?
m := l + (r - l) / 2
// 將中點對應的元素同target比較:> || >= || < || <= ?
if array[m] < target {
// 繼續查找右側這一半:m - 1 || m || m + 1 ?
l = m + 1
} else {
// 繼續查找左側這一半:m - 1 || m || m + 1 ?
r = m
}
}
// 這裡應該是 l - 1 || l || l + 1 ?
// 這裡應該是 r - 1 || r || r + 1 ?
return l
}

但是你可能已經注意到了我加的這些注釋,幾乎每一行代碼都有很多的「抉擇」,這使得許多應聘者最終「栽」在了這短短十來行代碼上。後來我又在維基百科上發現了這樣一段話:

儘管二分查找的基本思想相對簡單,但細節可以令人難以招架 —— 高德納

當喬恩·本特利將二分查找問題布置給專業編程課的學生時,90%的學生在花費數小時後還是無法給出正確的解答,主要因為這些錯誤程序在面對邊界值的時候無法運行,或返回錯誤結果。1988年開展的一項研究顯示,20本教科書裡只有5本正確實現了二分查找。不僅如此,本特利自己1986年出版的《編程珠璣》一書中的二分查找算法存在整數溢出的問題,二十多年來無人發現。Java語言的庫所實現的二分查找算法中同樣的溢出問題存在了九年多才被修復。

所以我寫下了這篇文章,希望能夠徹徹底底地講明白我心目中的「二分查找」到底是什麼。在弄懂它的本質以後不管遇到什麼類型的二分查找問題,都能夠準確無誤地寫出正確的代碼。

問題重新定義

我們要解決的第一個問題,就是重新定義「二分查找」。為什麼要重新定義,因為它的變種問題實在是太多了:在嚴格單調遞增/非嚴格單調遞增/嚴格單調遞減/非嚴格單調遞減的數組中查找大於/大於等於/小於 /小於等於target的最前一個/最後一個的元素下標。其實這還只是基本變種,一些更為複雜的變種就不在這裡一一列舉了。如果我們針對特定問題去設計特定算法,那你必然會迷失在茫茫多的變種問題裡面。

但是這些問題的解決其實都可以依賴於一個基本問題的解決,可以不嚴格地陳述為:

在一個左邊全是 false 右邊全是 true 的數組中,有且只有一個從 false 突變為 true 的點,二分查找其實就是要找到這個突變點。

我認為比起嚴格的陳述,每當說到二分查找,腦海中自然而然地浮現出上面這幅圖更為重要。在這幅圖所表示的數組中,紅色的格子表示false,藍色的格子表示true。其中格子為深紅色和深藍色表示顏色已經確定,它們是向兩側無限延伸沒有盡頭的。整個數組只有中間淺色的一段還未確定,二分查找其實就是要確定出紅色到藍色的突變點( 這裡不用著急解決各種變種問題,後面會講到如何將它們轉化為這個基本問題)。

區間如何表示

問題看起來像是定義清楚了,其實並沒有。阻礙我們的第一個絆腳石就是:待查找區間應該如何表示?當我們說使用l和r來表示這個區間的時候,其實有四種可能

前開後開(l, r)

前開後閉(l, r]

前閉後閉[l, r]

前閉後開[l, r)

到底應該使用哪種來表示其實並沒有關係,理論上使用任何一種都行,只要做到前後一致即可。例如你使用了前閉後閉[l, r],判斷區間非空就應該寫成r - l + 1 > 0,相反如果你使用了前閉後開[l, r),判斷區間非空則應該寫成r - l > 0 。就個人喜好而言,我更偏向最後一種:前閉後開[l, r)。

首先說說前開有什麼缺點。例如一個數組array,我們想要表示array[0], array[1], array[2]這段區間,用前開就意味著l = -1 。對於Golang這種語言來說也許算不上什麼問題,但是如果遇到數組下標嚴格要求為無符號整型的語言,你可能將會面臨無窮無盡的類型轉換。相反,選擇前閉則不存在這個問題。

再來說說後閉有什麼缺點。嚴格來說後閉其實沒什麼缺點,只不過當我們計算區間長度的時候,用前閉後閉是d = r - l + 1,用前閉後開則是d = r - l。或者從另一個角度來看,當我們固定l = 0 時,如果想要表示空區間,前閉後閉的情況下r = -1,依然會面臨著無符號整型問題。

因此我偏向使用前閉後開[l, r)。這種情況下,用l < r判斷區間非空,用r - l計算區間長度,用m = l + (r - l) / 2計算區間中點。再次強調,這僅僅代表了個人偏好。

突變點如何表示

區間的問題我們算是解決(或者說達成共識)了,下一個問題是突變點如何表示。從之前的圖可以看得出來,我們可以有兩種選擇

用最後一個紅色(F)表示突變點。

用最前一個藍色(T)表示突變點。

同樣的,這兩種選擇誰也沒有錯,但選最後一個紅色作為突變點依然會面臨著F = -1的無符號整型問題,因此我更傾向於選擇最前一個藍色表示突變點。而且由於F和T之間有著明顯的關係F + 1 = T,所以真正需要F 的時候我們也可以通過T將它輕易算出來。

算法核心邏輯

當我們把問題重新定義、區間如何表示、突變點如何表示解決以後,整個算法其實已經呼之欲出了。在這之前我們再來嚴格地陳述下這個問題:

這個陳述其實無關緊要,腦袋裡裝下這幅圖,並且理解它,你一定會自己」定義」出這個問題以及」發現」對應的算法實現。

一圖勝千言,我們直接把這幅圖「翻譯」成代碼。儘管只有短短幾行,請務必對照上圖仔細體會。

func BSearch(l, r int, f func(int) bool) int {
for l < r {
// 注意這裡是為了避免溢出
m := l + (r - l) / 2
if !f(m) {
l = m + 1
} else {
r = m
}
}
return l
}

正確性和複雜度

關於正確性和複雜度分析,在這裡說」顯然」其實一點都不為過。不過我還是用數學歸納法來證明一下正確性,至於複雜度分析就留給讀者了。

當d = r - l = 0時,代碼不會進入for循環,直接返回l。由問題嚴格陳述的第3點我們知道f(l - 1) = false和f(r) = true,又因為此時l = r,所以直接返回l就是正確的答案。

假設對於所有的d = 0, 1, 2, ..., k - 1,該算法都能返回正確的答案l。那麼當d = k時,代碼將會進入for循環,此時中點m ∈ [l, r)。這時候分兩種情況:

綜上,在這兩種情況下,我們都可以在滿足問題嚴格陳述的第3點前提下,將區間長度d變小,轉化為一個已經被證明可解決的問題。

解決變種問題

回到最開始的面試問題

// 已知array數組的元素為單調非遞減,給定一個target,返回array數組裡面
// 第一個大於或等於target的元素下標,如果沒有合法結果則返回len(array)
func FirstGreaterOrEqual(array []int, target int) int {
// TODO: write your code here
return len(array)
}

這再簡單不過了吧,我們只需要將f(x)定義為array[x] >= target是不是就可以了,因此它的實現代碼可以是這樣

func FirstGreaterOrEqual(array []int, target int) int {
return BSearch(0, len(array), func(x int) bool {
return array[x] >= target
})
}

至於其他變種問題呢,如法炮製,核心就是想辦法把問題轉化成基本問題中的布爾數組。掌握了這一點,就可以說是掌握了二分查找的精髓。

// 問題: array 非嚴格遞增,返回第一個大於 target 的元素下標。
// 補充: 如果查找不到合法結果,則返回 len(array) 即可。
func FirstGtInAsc(array []string, target string) int {
return BSearch(0, len(array), func(x int) bool {
return array[x] > target
})
}

// 問題: array 非嚴格遞增,返回第一個大於等於 target 的元素下標。
// 補充: 如果查找不到合法結果,則返回 len(array) 即可。
func FirstGteInAsc(array []string, target string) int {
return BSearch(0, len(array), func(x int) bool {
return array[x] >= target
})
}

// 問題: array 非嚴格遞增,返回最後一個小於等於 target 的元素下標。
// 補充: 如果查找不到合法結果,則返回 -1 即可。
func LastLtInAsc(array []string, target string) int {
return BSearch(0, len(array), func(x int) bool {
return array[x] > target
}) - 1
}

// 問題: array 非嚴格遞增,返回最後一個小於 target 的元素下標。
// 補充: 如果查找不到合法結果,則返回 -1 即可。
func LastLteInAsc(array []string, target string) int {
return BSearch(0, len(array), func(x int) bool {
return array[x] >= target
}) - 1
}

// 問題: array 非嚴格遞減,返回第一個小於 target 的元素下標。
// 補充: 如果查找不到合法結果,則返回 len(array) 即可。
func FirstLtInAsc(array []string, target string) int {
return BSearch(0, len(array), func(x int) bool {
return array[x] < target
})
}

// 問題: array 非嚴格遞減,返回第一個小於等於 target 的元素下標。
// 補充: 如果查找不到合法結果,則返回 len(array) 即可。
func FirstLteInAsc(array []string, target string) int {
return BSearch(0, len(array), func(x int) bool {
return array[x] <= target
})
}

// 問題: array 非嚴格遞減,返回最後一個大於等於 target 的元素下標。
// 補充: 如果查找不到合法結果,則返回 -1 即可。
func LastGtInAsc(array []string, target string) int {
return BSearch(0, len(array), func(x int) bool {
return array[x] < target
}) - 1
}

// 問題: array 非嚴格遞減,返回最後一個大於 target 的元素下標。
// 補充: 如果查找不到合法結果,則返回 -1 即可。
func LastGteInAsc(array []string, target string) int {
return BSearch(0, len(array), func(x int) bool {
return array[x] <= target
}) - 1
}

當然除了基本變種,還有很多其他類型的變種,未必都能完全套到這個基礎問題上來。但是大差不差,做一些輕微的調整大多數都是能輕易解決的。如果實在遇到解決不了的問題,希望這篇文章中的某些點可以為你提供啟發性的思路。

寫在最後

我真的不是標題黨,二分查找確確實實是個一看就會一寫就跪的問題。包括幾乎90%的面試者都在這道題上敗北,也同樣是真實數據。寫這篇文章主要參考了Golang的sort.Search和C++的std::lower_bound,後者的實現考慮得更為全面一些,只需要迭代器是ForwardIterator就行了,不依賴隨機尋址甚至不依賴向前尋址,相當優雅。

相關焦點

  • 二分查找會更快嗎?Python中的二分查找與線性查找性能測試
    可以通過線性查找和二分查找來完成,但是要猜測哪個更快。為什麼?如果你最近參加過面試,你就會知道二分查找是面試官的最愛。您為什麼要花時間學習二分查找? C ++編程朋友可能已經告訴過您。 Python很慢。 您想確保自己的程序不會比所需的速度慢。學習Python時,您將學習進行線性查找以檢查元素是否在列表中。
  • 入門計算機必會的算法——二分查找法
    就比如今天要介紹的第一個查找算法——二分查找法。假設要在電話薄中找一個名字為K大頭的人,很習慣的辦法就是從頭開始翻頁,直到進入以K打頭的部分。但是這種方法的缺陷就是要逐一查找,時間較長。於是我們可以有另外一個思路就是每次從中間開始查找,而以K打頭的名字就在電話簿中間。
  • 二分查找大集合(媽媽再也不用擔心我的二分查找了)
    來自:韓子遲 - 博客園作者:韓子遲【github:https://github.com/hanzichi 求關注】連結:http://www.cnblogs.com/zichi/p/5118032.html已獲轉載授權什麼是二分查找就不多說了
  • 二分查找法(折半查找法)
    /*二分查找法說明:如果搜尋的數列已經有排序,應該儘量利用它們已排序的特性,以減少搜尋比對的次數 ,這是搜尋的基本原
  • HBO《西部世界》第一季:有趣的二分心智理論
    HBO《西部世界》第一季:有趣的二分心智理論最近不知道該聊什麼,突然就想到了《西部世界》。那麼接下來幾篇就聊聊《西部世界》第一季吧。這篇主要來簡單聊聊《西部世界》裡一個很有趣的理論:二分心智理論。二分心智理論源於書籍《二分心智的崩潰:人類意識的起源》,作者是朱利安.傑恩斯,這本書的理論認為一個人的腦子分為兩個部分:左腦和右腦。右腦主要是負責講述,而左腦則負責去理論右腦的講述,從而進行接受與行動。人類的行動都是一側頭腦在聽從另一側頭腦的訴說去指引,而這種幻覺的聲音稱之為「心的獨白」。
  • 寫論文如何查找論文資料
    寫論文的時候論文資源有哪些,怎麼樣查找論文資料?寫論文並不是靠自己閉門造車就可以得的,想寫好一篇論文是需要很多資料的,而論文又包括學年論文、畢業論文、學位論文、科技論文、成果論文等。那麼論文資源通常有哪些呢?查找論文資料是不是很麻煩,該如何查找論文資料呢?
  • 使用Python和C語言實現二分法查找(折半查找)
    二分查找 叫折半查找,它對要查找的序列有兩個要求,一是該序列必須是有序的,二是該序列必須是順序存儲的。二分法查找算法的原理如下:1、 如果待查對象為空返回-1,表示查找不到目標元素2、 如果待查序列不為空,則將中間元素與要查找的目標元素進行匹配看是否相等,如果相等表示 查找成功,返回該中間元素的索引如果不相等再比較這兩個元素的大小
  • JAVA編程——二分法查找
    一、二分法檢索過程二分法檢索(binary search)又稱折半檢索,二分法檢索的基本思想是設數組中的元素從小到大有序地存放在數組(array)中(註:二分法查找的關鍵,首先數組元素必須從小到大有序排列),(1)首先將給定值 key 與數組中間位置上元素的關鍵碼(key)比較,如果相等,則檢索成功
  • 一篇文章讓你了解二分搜索樹的數據結構的實現過程(Java 實現)
    舉個例子:比如對於查找一個數據,在線性結構中如果不知道具體位置的話需要在一堆數據裡一個一個地去尋找;而對於樹結構,因為樹結構分支的形式,各個數據可以存在不同的分支中,在查找時就可以依據這些分支快速地找到想要的數據。
  • 和跪筆彈鋒齊名的三角一肚,田英章詳解永字八法之楷書側法點畫
    和跪筆彈鋒齊名的三角一肚,田英章詳解永字八法之楷書側法點畫首先,寫楷書應在方格內,「永」字的點筆應在字格縱向中心線的上方,而且必須居中,不可偏左或偏右。點筆不可太大,也不可太小,要稜角分明而飽滿,要求是「三角一肚」。行筆的方法是:下筆要虛而快,虛,就是露鋒,要輕入筆,要乾淨,要把第一個「角」寫出來;快,就是邊落筆邊行筆,要準確、堅定、快捷。
  • 三年級習作《看圖畫,寫一寫》,寫好開頭的三種方法
    小學生同步作文,助力同學們早點學會寫作文今天,我突然發現,寫作文最好的學習方法,不是某位老師傳授的寫作技巧,不是書店排行榜單上的作文圖書,不是網上查找的作文範文。在學校裡,每篇課文老師都逐字逐句地講過,不過,可能從寫作文的角度講得不多,不深入。今天,老師就帶領大家回顧一下我們剛剛學過的課文,乘著記憶猶新,分析作者的寫作思路和方法,然後跟隨老師一些運用這些方法,完成二單元的習作《看圖畫,寫一寫》。今天,先學習怎麼寫好開頭。
  • 一看就會的時間相對論
    快看:一看就會的時間相對論不少人可能有這樣的感觸,有時候希望時間過得快一點,有時候希望時間過得慢一點。此時此刻,我正在一個高鐵站上寫這篇文章。我還需要等待一個小時,所以和大部分人一樣,我渴望時間過得快一點。我希望的我的一小時變成十分鐘,或者更短。這樣,我就沒有等待和無趣所帶來的痛苦。為了實現這個目標,我根據時間相對論原理,迅速制訂了一個目標。只不過恰好今天,我覺得有必要把這篇文章分享給大家。這就是此時此刻的目標。
  • 一分天道,二分人情,三分歡喜,四分閒情!
    一顆簡單的心,承載著赤子注視世界的溫柔目光,包含著一分天道,二分人情,三分歡喜,四分閒情。1一分天道天意從來高難問,人生由命非由他。唐朝李淳風是歷史上著名的預言家,他告訴唐太宗將來要有個姓武的人來奪他天下,而且人已經在宮中。唐太宗說:「那我就殺掉宮中所有姓武的,這樣就斷了禍根。」
  • 可愛的簡筆畫,一看就會系列!
    小白是小雲原創的一組表情包,最近越看小白越可愛,就畫了一組簡筆畫系列,很簡單的一看就會系列。以下是部分過程圖:過程圖一:過程圖二:如果你也喜歡小雲的作品,歡迎點讚轉發哦~筆芯!!
  • 一看就會的電動葫蘆限位器接線示意圖
    一看就會的電動葫蘆限位器接線示意圖溫馨提示,上圖僅供參考,不同規格可參考電動葫蘆說明書。一看就會的電動葫蘆限位器接線示意圖5噸以下的包括5噸的一般在拉杆右側是上限位,左側是下限位。一看就會的電動葫蘆限位器接線示意圖鋼絲繩電動葫蘆限位組成:連杆、定位卡子、導繩器、限位器(限位開關),四者缺一不可
  • 論文文獻去哪裡查找?怎麼查找?
    論文文獻去哪裡查找?怎麼查找?如何高效地從閱讀的文獻中獲取需要的知識和內容?作為論文服務平臺小編為您總結了一下幾種查找文獻的方法和渠道,有疑問的朋友可以做下參考:論文文獻怎麼查找如果您還是學生,可以向老師求助可以請老師推薦幾個跟自己寫論文密切相關的文獻,老師推薦的文獻可能是自己的,也可能是引用次數比較多的。
  • 分享檸檬醬的做法,方法簡單,一看就會
    導語:分享檸檬醬的做法,方法簡單,一看就會。檸檬富含豐富的植物酸和維生素C,具有開胃提神,生津止咳,美白養顏等作用。其中酸性成分有較強的殺菌作用,通常在烹飪海鮮或者肉類時,可適當滴上幾滴檸檬汁,即可起到殺菌作用,還可提香去腥味,豐富口感。
  • 二分之派等於多少 cos二分之π等於多少
    二分之π約等於1.5707963,因為π約等於3.1415926,除以2就約等於1.5707963。cos二分之π等於0,sin二分之π等於1。  二分之π是分數嗎  二分之π不是分數,分數是一個整數a和一個正整數b的不等於整數的比。
  • 成長是「二分心智」的崩潰與內在敘事的覺醒
    在人類構建內在敘事能力之前,人的決斷與行為依賴於「二分心智」。簡單來說,就是本能與聲音的結合。