文章目錄
一、前言二分尋找本身是個簡單的演算法,但是正是因為其簡單,更容易寫錯。甚至於在二分尋找演算法剛出現的時候,也是存在bug的(溢出的bug),這個bug直到幾十年後才修複(見《編程珠璣》)。本文打算對二分尋找演算法進行總結,並對由二分尋找引申出來的問題進行分析和匯總。若有錯誤,請不吝賜教。二、二分尋找是這樣的相信大家都知道二分尋找的基本演算法,如下所示,這就是二分尋找演算法:
int bisearch(int a[], int n, int t) //數組a有序,長度為n, 待尋找的值為t{ int l = 0, u = n - 1; while (l <= u) { int m = l + (u - l) / 2; //同(l+u)/ 2,這裡是為了溢出 if (t > a[m]) l = m + 1; else if (t < a[m]) u = m - 1; else return m; } return -(l+1);}
演算法的思想就是:從數組中間開始,每次排除一半的資料,時間複雜度為O(lgN)。這依賴於數組有序這個性質。如果t存在數組中,則返回t在數組的位置;否則,不存在則返回-(l+1)。這裡需要解釋下為什麼t不存在數組中時不是返回-1而要返回-(l+1)。首先我們可以觀察l的值,如果尋找不成功,則l的值恰好是t應該在數組中插入的位置。舉個例子,假定有序數組a={1, 3, 4, 7, 8}, 那麼如果t=0,則顯然t不在數組中,則二分尋找演算法最終會使得l=0 > u=-1 退出迴圈;如果t=9,則t也不在數組中,則最後l=5 > u=4退出迴圈。如果t=5,則最後l=3 > u=2退出迴圈。因此在一些演算法中,比如DHT(一致性雜湊)中,就需要這個傳回值來使得新加入的節點可以插入到合適的位置中,在求最長遞增子序列的NlgN演算法中,也用到了這一點,參見博文最長遞增子序列演算法。還有一個小點就是之所以返回-(l+1)而不是直接返回-l是因為l可能為0,如果直接返回-l就無法判斷是正常返回位置0還是尋找不成功返回的0。三、二分尋找數字第一次出現的位置現在考慮一個稍微複雜點的問題,如果有序數組中有重複數字,比如數組a={1, 2, 3, 3, 5, 7, 8},需要在其中找出3第一次出現的位置。這裡3第一次出現位置為2。這個問題在《編程珠璣》第九章有很好的分析,這裡就直接用了。演算法的精髓在於迴圈不變式的巧妙設計,代碼如下:
int bsearch_first(int a[], int n, int t){ int l = -1, u = n; while (l + 1 != u) { /*迴圈不變式a[l]<t<=a[u] && l<u*/ int m = l + (u - l) / 2; //同(l+u)/ 2 if (t > a[m]) l = m; else u = m; } /*assert: l+1=u && a[l]<t<=a[u]*/ int p = u; if (p>=n || a[p]!=t) p = -1; return p;}
演算法分析:設定兩個不存在的元素a[-1]和a[n],使得a[-1] < t <= a[n],但是我們並不會去訪問者兩個元素,因為(l+u)/2 > l=-1, (l+u)/2 < u=n。迴圈不變式為l<u && t>a[l] && t<=a[u] 。迴圈退出時必然有l+1=u, 而且a[l] < t <= a[u]。迴圈退出後u的值為t可能出現的位置,其範圍為[0, n],如果t在數組中,則第一個出現的位置p=u,如果不在,則設定p=-1返回。該演算法的效率雖然解決了更為複雜的問題,但是其效率比初始版本的二分尋找還要高,因為它在每次迴圈中只需要比較一次,前一程式則通常需要比較兩次。舉個例子:對於數組a={1, 2, 3, 3, 5, 7, 8},我們如果尋找t=3,則可以得到p=u=2,如果尋找t=4,a[3]<t<=a[4], 所以p=u=4,判斷a[4] != t,所以設定p=-1.一種例外情況是u>=n, 比如t=9,則u=7,此時也是設定p=-1.
特別注意的是,l=-1,u=n這兩個值不能寫成l=0,u=n-1。雖然這兩個值不會訪問到,但是如果改成後面的那樣,就會導致二分尋找失敗,那樣就訪問不到第一個數字。如在a={1,2,3,4,5}中尋找1,如果初始設定l=0,u=n-1,則會導致尋找失敗。擴充如果要尋找數字在數組中最後出現的位置呢?其實這跟上述演算法是類似的,稍微改一下上面的演算法就可以了,代碼如下:
int bsearch_last(int a[], int n, int t){ int l = -1, u = n; while (l + 1 != u) { /*迴圈不變式, a[l] <= t < a[u]*/ int m = l + (u - l) / 2; if (t >= a[m]) l = m; else u = m; } /*assert: l+1 = u && a[l] <= t < a[u]*/ int p = l; if (p<=-1 || a[p]!=t) p = -1; return p;}
當然還有一種方法可以將查詢數字第一次出現和最後一次出現的代碼寫在一個程式中,只需要對原始的二分尋找稍微修改即可,代碼如下:
int binary_search_first_last(int arr[], int p, int q, int value, bool firstflag = true){ int begin = p; int end = q; while(begin <= end) { int mid = (begin + end)/2; if(arr[mid] == value) //找到了,判斷是第一次出現還是最後一次出現 { if(firstflag) //查詢第一次出現的位置 { if(mid != p && arr[mid-1] != value) return mid; else if(mid == p) return p; else end = mid - 1; } else //查詢最後一次出現的位置 { if(mid != q && arr[mid+1] != value) return mid; else if(mid == q) return q; else begin = mid + 1; } } else if(arr[mid] < value) begin = mid + 1; else end = mid - 1; } return -1;} 四、旋轉數組元素尋找問題
題目:把一個有序數組最開始的若干個元素搬到數組的末尾,我們稱之為數組的旋轉。例如數組{3, 4, 5, 1, 2}為{1, 2, 3, 4, 5}的一個旋轉。現在給出旋轉後的數組和一個數,旋轉了多少位不知道,要求給出一個演算法,算出給出的數在該數組中的下標,如果沒有找到這個數,則返回-1。要求尋找次數不能超過n。
分析:由題目可以知道,旋轉後的數組雖然整體無序了,但是其前後兩部分是部分有序的。由此還是可以使用二分尋找來解決該問題的。
解法一::2次二分尋找。首先確定數組分割點,也就是說分割點兩邊的數組都有序。比如例子中的數組以位置2分割,前面部分{3,4,5}有序,後半部分{1,2}有序。然後對這兩部分分別使用二分尋找即可。代碼如下:
int split(int a[], int n){ for (int i=0; i<n-1; i++) { if (a[i+1] < a[i]) return i; } return -1;}int bsearch_rotate(int a[], int n, int t){ int p = split(a, n); //找到分割位置 if (p == -1) return bsearch_first(a, n, t); //如果原數組有序,則直接二分尋找即可 else { int left = bsearch_first(a, p+1, t); //尋找左半部分 if (left == -1) { //左半部分沒有找到,則尋找右半部分 int right = bsearch_first(a+p+1, n-p-1, t); //尋找右半部分 if (right != -1) return right+p+1; //返回位置,注意要加上p+1 return -1; } return left; //左半部分找到,則直接返回 }}
解法二:1次二分尋找。
二分尋找演算法有兩個關鍵點:1)數組有序;2)根據當前區間的中間元素與x的大小關係,確定下次二分尋找在前半段區間還是後半段區間進行。
仔細分析該問題,可以發現,每次根據low和high求出mid後,mid左邊([low, mid])和右邊([mid, high])至少一個是有序的。
a[mid]分別與a[left]和a[right]比較,確定哪一段是有序的。
如果左邊是有序的,若x<a[mid]且x>a[left], 則right=mid-1;其他情況,left =mid+1;
如果右邊是有序的,若x> a[mid] 且x<a[right] 則left=mid+1;其他情況,right =mid-1;
代碼如下:
int bsearch_rotate(int a[], int n, int t){ int low = 0, high = n-1; while (low <= high) { int mid = low + (high-low) / 2; if (t == a[mid]) return mid; if (a[mid] >= a[low]) { //數組左半有序 if (t >= a[low] && t < a[mid]) high = mid - 1; else low = mid + 1; } else { //數組右半段有序 if (t > a[mid] && t <= a[high]) low = mid + 1; else high = mid - 1; } } return -1; }
參考資料
旋轉數組的二分尋找編程珠璣第二版第九章