算法淺談——人人皆知卻很多人寫不對的二分法


本文始發於個人公眾號:TechFlow

 


  1  
二分法可以說是鼎鼎大名,哪怕是沒有學過編程的同學,也許說不上來二分法這個名字,但是對於其中的精髓應該都是有所了解的。不了解的同學也沒關系,我一句話就能交代清楚:我們每次將一個集合一分為二,每次舍棄其中一半。
早在兩千多年前,庄子就搞清楚了二分法的精髓,他說:一尺之棰,日取其半,萬世不竭。從這個角度來說,二分法可能是這個世界上最古老的算法之一了。
二分法不僅古老,而且在計算機系統當中非常常見,許多系統當中都用到了二分法的思想。除此之外,在面試的時候,二分法的算法題也是常客。因為二分法本身不復雜,幾乎人人都會,但是對二分法的使用能力卻各有不同。出二分法的題,可以真實考察面試者的算法能力和編程功底。
不說比較困難的算法題想不出思路,就說最簡單沒有任何難度的純二分,在面試的時候,出錯的寫出bug的也大有人在。
很多人會覺得奇怪,二分法這么簡單的算法,真的有人寫不出來嗎?
還真的有,原因也很簡單,恰恰就是二分法太簡單了。
無論是在算法導論還是在一些其他的算法教材當中,關於二分法的描述都不多,詳細的會有一些圖例展示一下二分法的思想,簡單的就用幾句話描述一下原理,最后再展示一下代碼,就完事了。讀者在學的時候也是一樣,看了一眼原理,哦,非常簡單,再看一眼代碼,只有三四行,差不多一眼就能記住,那就丟在一邊吧。到了真正上手的時候,問題一下就暴露了出來。

二分法最常見的問題有兩個,一個是二分的區間邊界不清楚,另一個是二分查找的結果不明確。我想,這兩個問題是前幾次實現二分法的時候,一定會遇到的。遺憾的是,目前的教材當中對於這兩個問題介紹都不多,都只有代碼,留給讀者自行揣摩。

 


  2  
我們先說第一個問題——邊界
早在小學我們就學過,用l表示區間左邊界,r表示區間右邊界,mid=(l + r) / 2表示二分的中間點。這個在數學里非常明確,但在編程的時候,有一個隱藏的問題被忽略了。究竟這個區間是閉區間呢,還是開區間呢,還是半開半閉區間或者是半閉半開區間?如果這個問題不想清楚,想要一次性寫出沒有bug的代碼,老實說很不容易。
首先,二分終止的條件究竟怎么寫,是while (l < r) 還是 while (l <= r) 還是別的?還有,在搜索的時候,我們究竟要不要將a[mid] == v的情況單獨判斷?我們是判斷a[mid] < v還是a[mid] <= v?假設我們選擇用a[mid] <= v,得到的結果為true。我們知道答案應該在區間的右半邊,我們需要舍棄左邊的區間。應該對l賦值,但是我們是賦值成l = m呢還是l=m + 1呢?又是為什么呢?
你看,如果l和r表示的區間不考慮清楚,我們在實際寫代碼的時候就會遇到這樣棘手的問題。坑爹的是,當我們為這些邊界頭疼的時候,我們並不能意識到這是因為我們沒有搞清楚表示區間的方法導致的。往往會覺得是自己不夠熟悉。
顯然,要解決這個問題需要確定l和r表示的區間種類。那么到底應該選擇什么區間呢?是左閉右開,還是全閉,還是左開右閉呢?
答案有點出人意料,都行
理論上來說,不論選什么樣的區間,只要代碼得當,都是可以的,可以說是完全看個人喜好。不過我個人推薦左閉右開,原因很簡單,這個和編程當中的數組定義的情況一致。我們都知道,在代碼的世界里,數組是從0開始的,一個長度為10的數組,最后一個元素的下標是9。如果使用左閉右開區間,我們將l=0,r=數組長度,就完成了初始化,如果用閉區間,r=長度-1,不免顯得有些多余。
假設我們確定了使用左閉右開區間,我們再來看前面說的兩個問題。
區間確定了,終止條件也就明確了,左閉右開區間[l, r)不為空的話,r 至少大於等於l + 1。我們要在區間長度大於1的時候執行二分,所以二分的循環條件應該是while (l + 1 < r)。

 


  3  
那么while里的判斷條件呢?
我們列舉一下,a[mid] 和v的大小關系無非只有三種。
第一種a[mid] = v,很簡單,mid就是我們要查找的結果,直接返回。
第二種a[mid] < v,說明我們應該取右邊的區間,由於l的位置可以取到,而mid已經不是答案了,所以l = mid + 1。
第三種a[mid] > v,應該取左邊的區間,mid不是答案,但是由於r指向的位置本身就不在候選區間里,所以r = mid,而不是mid-1,因為mid-1可能是答案,而r處的位置是取不到的。
到這里,似乎一切完美,我們可以很順利地寫出代碼了。但是還沒有結束,依然還有一個小問題。

前文說了,a[mid]和v的關系有三種,當a[mid] = v的時候,我們就找到了答案。從這個角度來看,我們二分的時候,通過l和r縮小區間的范圍,通過mid來尋找答案。但是既然我們已經折半區間的大小了,那么當區間長度為1的時候,剩下的就是答案,我們為什么還需要通過mid去查找答案呢?如果我們就想通過區間本身來查找答案,那么應該怎么辦呢?
也不難,我們需要把a[mid]小於和等於v的兩種情況合並,由於a[mid]可能等於v,所以我們不能跳過mid這個位置,l = mid + 1 應該寫成l = mid,於是整個代碼也就出來了:

  1. def binary_search(a, v):  

  2.     l, r = 0, len(a)  

  3.     while l + 1 < r:  

  4.         m = (l + r) // 2  

  5.         if a[m] <= v:  

  6.             l = m  

  7.         else:  

  8.             r = m  

  9.     # 通過a[l] == v判斷v不存在與a數組當中的情況  

  10.     return l  

     


  4  
可能會有同學好奇,如果我不使用左閉右開,而使用閉區間呢,代碼又該怎么寫?

其實只要把區間想清楚了,寫出來也不難。

  1. def binary_search(a, v):  

  2.     l, r = 0, len(a) - 1  

  3.     while l <= r:  

  4.         m = (l + r) // 2  

  5.         if a[m] == v:  

  6.             return m  

  7.         if a[m] < v:  

  8.             l = m + 1  

  9.         else:  

  10.             r = m - 1  

  11.     # 表示不存在  

  12.     return -1  


不過還有一個小問題,為什么閉區間形式的二分法的判斷推薦是while (l <= r)呢?換成while (l < r)行不行?這個問題就留給大家思考。
二分法雖然簡單,但這些細節都理解清楚也並不容易,在算法領域當中,如果細節沒有理解到位,陰溝里翻船是非常平常的事情。希望今天的文章能對大家有所幫助。

掃碼關注,獲取最新文章


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM