二分查找,你真的懂嗎

轉載至:http://duanple.blog.163.com/blog/static/709717672009049528185/

作者:phylips@bmy

最近在練習動態規劃問題(DP),其中“最長遞增子序列”問題中要用到二分查找,突然發現二分查找並非我們想象的那麼簡單。例如如何確定停止條件,不同的問題如何增加下邊界和減少小邊界,如何確保問題規模變小而不進入死循環?下面這篇文章很好的解釋了二分查找的本質,用通用的方法求解二分問題。文章的最後是我自己參考該文章寫的編程示例。。。

歷史上,Knuth在其<<Sorting and Searching>>一書的第6.2.1節指出:儘管第一個二分搜索算法於1946年就出現,然而第一個完全正確的二分搜索算法直到1962年纔出現。

而不經仔細斟酌而寫出的一個二分查找經常遭遇off by one或者無限循環的錯誤。下面將討論二分查找的理論基礎,實現應用,及如何採用何種技術保證寫出一個正確的二分程序,讓我們免於思考麻煩的邊界及結束判斷問題。

在c++的stl中有如下函數 lower_bound, upper_bound, binary_search and equal_range,而這些函數就是我們要考慮如何實現的那些。通過實現這些函數,可以檢查你是否真的掌握了二分查找。

理論基礎:
當我們碰到一個問題,需要判斷它是否可以採用二分查找來解決。對於最一般的數的查找問題,這點很容易判斷,然而對於某些比如可以採用二分+貪心組合,二分解方程,即某些具有單調性的函數問題,也是可以利用二分解決的,然而有時它們表現的不那麼顯然。

考慮一個定義在有序集合S上的斷言,搜索空間內包含了問題的候選解。在本文中,一個斷言實際上是一個返回布爾值的二值函數。這個斷言可以用來驗證一個候選解是否是所定義的問題合法的候選解。

我們把下面的一條定理稱之爲main theorem: binary search can be used if and only if for all x in S, p(x) implies p(y) for all y > x. 實際上通過這個屬性,我們可以將搜索空間減半,也就是說如果我們的問題的解應用這樣的一個驗證函數,驗證函數的值可以滿足上述條件,這樣這個問題就可以用二分查找的方法來找到那個合適的解,比如最左側的那個合法解。以上定理還有一個等價的說法 !p(x) implies !p(y) for all y < x 。這個定理很容易證明,這裏省略證明。

實際上如果把這樣的一個p函數應用於整個序列,我們可以得到如下的一個序列
fasle false false ......true true....
如果用01表示,實際上就是如下這樣的一個序列0 0 0 0......1 1 1 1.......
而所有的二分查找問題實際都可以轉化爲這樣的一個01序列中第一個1的查找問題,實際上我們就爲二分查找找到了一個統一的模型。就像排序網絡中利用的01定理,如果可以對所有的01序列排序,則可以爲所有的序列排序。實際上二分查找也可以用來解決true true....fasle false false ......即1 1 1 1...... 0 0 0 0.....序列的查找問題。當然實際如果我們把p的定義變反,這個序列就變成了上面的那個,也就是可以轉化爲上面的模型。

這樣我們就把所有問題轉化爲求0011模式序列中第一個1出現的位置。當然實際中的問題,也可能是求1100模式序列最後一個1的位置。同時要注意對應這兩種情況下的實現有些許不同,而這個不同對於程序的正確性是很關鍵的

下面的例子對這兩種情況都有涉及,一般來說具有最大化要求的某些問題,它們的斷言函數往往具有1100模式,比如poj3258 River Hopscotch;而具有最小化要求的某些問題,它們的斷言函數往往具有0011模式,比如poj3273 Monthly Expense。

而對於數key的查找,我們可以利用如下一個斷言使它成爲上述模式。比如x是否大於等於key,這樣對於一個上升序列來說它的斷言函數值成爲如下模式:0 0 0 0......1 1 1 1.......,而尋找最左邊的key(類似stl中的lower_bound,則就是上述模型中尋找最左邊的1.當然問題是尋找最後出現的那個key(類似stl中的upper_bound),只需要把斷言修改成:x是否小於等於key,就變成了1 1 1 1...... 0 0 0 0.....序列的查找問題。

可見這樣的查找問題,變成了如何尋找上述序列中的最左或最右的1的問題。

類似的一個單調函數的解的問題,只要設立一個斷言:函數值是否大於等於0?也變成了如上的序列,如果是單調上升的,則變成了0011模式,反之則是1100模式。實際上當函數的自變量取值爲實數時,這樣的一個序列實際上變成了一種無窮數列的形式,也就是1111.....0000中間是無窮的,01的邊界是無限小的。這樣尋找最右側的1,一般是尋找一個近似者,也就是採用對實數域上的二分(下面的源代碼4),而用fabs(begin-end)來控制精度,確定是否停止迭代。比如poj 3122就是在具有1111.....0000模式的無窮序列中查找那個最右側的1,對應的自變量值。


實現:
一個判斷序列中是否存在某key的二分基本實現(返回-1表示不存在,否則代表出現位置的index):

源程序1:

int binary_search(int array[],int key){
 int begin = 0;
 int end = array.size()-1;
 while(begin < end){
  int mid = begin + (end-begin)/2;
  if(array[mid] < key)
   begin = mid+1;
  else if(array[mid] > key)
   end = mid-1;
  else return mid;
 }
 return -1;
}


 

下面的程序中我們都假設這樣的1是存在的,如果不存在,可以在最後加一句驗證,是否==1即可。

0011序列中最左側的1的查找

源程序2:

 int binary_search(int array[],int key){
 int begin = 0;
 int end = array.size()-1;
 while(begin < end){
  int mid = begin + (end-begin)/2;
  if(array[mid] == 1){
   end = mid;
  }
  else{
   begin= mid+1;
  }
 }
 return begin;
}

1100序列中最右側的1的查找

 源程序3:

 int binary_search(int array[],int key){
 int begin = 0;
 int end = array.size()-1;
 while(begin < end){
  int mid = begin + (end-begin)/2;//wrong!!!In fact it should be :int mid = begin + (end-begin+1)/2;
  if(array[mid] == 1){
   begin = mid;
  }
  else{
   end = mid-1;
  }
 }
 return begin;
}

二分浮點數值解單調函數(這種問題,比前幾種簡單,因爲不需要過多考慮begin,end的邊界問題,只要單純的=mid就可以了),fabs(begin-end) < 0.0000000001控制精度,有時會超時,需要調整 0.0000000001的值,或者設定一個迭代計數器,在一定步數內結束:

double binary_search(double){

double begin = min;

double end = max;

while(fabs(begin-end) < 0.0000000001){

      double mid = (end-begin)/2;

     if(f(mid) > 0) begin = mid;

     else end = mid;

}

return mid;

}

 理論與實現的對應:

如果細心,仔細的觀察,可以發現實現與理論中的斷言是有密切的對應關係的。實際上用在我們的實現中的循環內部的if條件語句便是上面理論基礎中所謂的斷言的一個程序語言表述。實際上尋找這樣的斷言,便可以指導我們的實現。

程序正確性的保證:

程序的正確性,主要依靠循環不變式來保證。

而對於二分查找,一般需要建立兩個不變性:

1.當前待查找序列,肯定包含目標元素 2.每次待查找序列的規模都會變小

1用來防止,把目標元素錯過,2可以保證程序最終會終止。每次循環的分支內,保證這樣的兩個不變性可以滿足,那麼這樣的二分查找程序一般不會含有邏輯錯誤。

觀察上面的源程序2和3,其中變化的時候有的mid+-1,有的沒有加,實質上就是爲了保證不變式1.比如源程序2中的   end = mid;之所以不寫成end = mid-1,是有原因的,因爲對於0011來說,而這個mid有可能就是那個最左側的1,如果讓end = mid-1,這樣這個1在下次迭代時實際上已經不在搜索序列中了,也就是不變性1不成立了。

仔細觀察上面的源程序3,如果給一個序列1 0會如何?

是的,實際會進入無限循環。原因何在呢?實際上違反了不變性2,也就是沒有保證序列規模下降,進一步的將這是由除法的特殊性決定的,比如3/2=1,比如begin=0 end=1,mid =0,最終不會造成序列長度的縮小。而這個問題,則是寫二分查找時,最常犯的off by one錯誤。解決方案在這,把int mid = begin + (end-begin+1)/2;因爲顯然end是必然減少的,而這樣也可以保證begin每次迭代後都會變大。這樣就保證了不變性2.

 

可能有人會問,問什麼源程序2沒有這樣的問題呢,實際上是因爲int mid = begin + (end-begin)/2;我們可以發現,如果begin !=end ,mid可以保證比原來的end小,而當end-begin=1是mid等於begin的。而在源程序2裏,begin=mid+1,保證了begin是變化的,而mid = begin + (end-begin)/2;又剛好保證了end是必然減少的,所以合起來保證了不變性2. 

寫程序的時候,考慮保證這兩個不變性,可以幫助寫出正確的二分查找。

基本例題

 poj 3233 3497 2104 2413 3273 3258 1905 3122

注:

poj1905 實際上解一個超越方程 L"sinx -Lx=0,可以利用源碼4,二分解方程

poj3258 尋找最大的可行距離,實際上是111000序列中尋找最右側的1,可以參考源碼3

poj3273 尋找最小的可行值,實際上是000111序列中尋找最左側的1,可以參考源碼2

總結

首先尋找進行二分查找的依據,即符合main 理論的一個斷言:0 0 0 ........111.......

確定二分的上下界,儘量的讓上下界鬆弛,防止漏掉合理的範圍,確定上界,也可以倍增法

觀察確定該問題屬於0011還是1100模式的查找

寫程序注意兩個不變性的保持

注意驗證程序可以處理01這種兩個序列的用例,不會出錯

注意mid = begin+(end-begin)/2,用mid=(begin+end)/2是有溢出危險的。實際上早期的java的jdk裏的二分搜索就有這樣的bug,後來java大師Joshua Bloch發現,才改正的。

// BS_BasicBinarySearchSelfTest.cpp : Defines the entry point for the console application.
//
// This file is the summary of binary search 
//而對於數key的查找,我們可以利用如下一個斷言使它成爲上述模式(00000011111...或111111100000...)。比如x是否大於等於key,
//這樣對於一個上升序列來說它的斷言函數值成爲如下模式:0 0 0 0......1 1 1 1.......,
//而尋找最左邊的key(類似stl中的lower_bound, 則就是上述模型中尋找最左邊的1.當然問題是尋找最後出現的
//那個key(類似stl中的upper_bound), 只需要把斷言修改成:x是否小於等於key,就變成了1 1 1 1...... 0 0 0 0.....序列的查找問題。

//而對於二分查找,一般需要建立兩個不變性:
//
//1.當前待查找序列,肯定包含目標元素 2.每次待查找序列的規模都會變小。
//
//1用來防止,把目標元素錯過,2可以保證程序最終會終止。每次循環的分支內,保證這樣的兩個不變性可以滿足,那麼這樣的二分查找程序一般不會含有邏輯錯誤。
#include "stdafx.h"
#include <iostream>
#include <vector>
using namespace std;
// 單調序列中判斷某個key是否存在,存在即返回位置,不存在則返回-1
int binarySearchIsKeyExists(vector<int> nums, int nKey){
	int nBegin = 0;
	int nEnd = nums.size() - 1;
	while (nBegin < nEnd){
		int nMid = nBegin + (nEnd - nBegin) / 2;
		if (nums[nMid] == nKey)
			return nMid;
		else if (nums[nMid] < nKey)
			nBegin = nMid + 1;
		else
			nEnd = nMid - 1;
	}
	return -1;
}
//下面的程序中我們都假設這樣的1是存在的,如果不存在,可以在最後加一句驗證,是否==1即可,
// 即如果我們以“以遞增序列中查找第一個大於key的元素的位置”爲例,那麼我們可以在最後判斷查找的
// 元素是否真的大於key

// 0011序列中最左側的1的查找(以遞增序列中查找第一個大於key的元素的位置)
int binarySearchLowerBound(vector<int> nums, int nKey){
	int nBegin = 0;
	int nEnd = nums.size() - 1;
	while (nBegin < nEnd){
		int nMid = nBegin + (nEnd - nBegin) / 2;
		if (nums[nMid] > nKey)
			nEnd = nMid;
		else
			nBegin = nMid + 1;
	}
	return nBegin;
}

// 1100序列中最右側的1的查找(以遞增序列中查找最後一個小於key的元素的位置)
int binarySearchUpperBound(vector<int> nums, int nKey){
	int nBegin = 0;
	int nEnd = nums.size() - 1;
	while (nBegin < nEnd){
		int nMid = nBegin + (nEnd - nBegin + 1) / 2;// Notice
		if (nums[nMid] < nKey)
			nBegin = nMid;
		else
			nEnd = nMid - 1;
	}
	return nBegin;
}
int _tmain(int argc, _TCHAR* argv[])
{
	vector<int> testArray = {1,2,3,3,4,5,6,7,8};
	for (int i = 0; i < testArray.size(); ++i){
		cout << testArray[i] << ',';
	}
	cout << endl;
	for (int i = 0; i < testArray.size(); ++i){
		cout << i << ',';
	}
	cout << endl;
	cout <<"is exists: "<<binarySearchIsKeyExists(testArray,4)<<endl;
	cout << "lower bound: " << binarySearchLowerBound(testArray, 4) << endl;
	cout << "upper bound: " << binarySearchUpperBound(testArray, 4) << endl;
	return 0;
}


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章