1. 程式人生 > >巧用迴圈不變式書寫正確的二分查詢演算法(看不懂我撞牆)

巧用迴圈不變式書寫正確的二分查詢演算法(看不懂我撞牆)

1.二分查詢介紹

在進行開始之前,我們縣要來正確的認識一下什麼是二分查詢演算法 上過數值分析這門課的同學一定在迭代那一刻裡面清楚的瞭解過一個名詞叫做對分法 實際上,對分法的本質就是二分查詢 下面我們來介紹一下二分查詢演算法 Binary-Search 是不同於順序掃描的一種極其高效的查詢演算法 首先我們縣要來了解一下二分查詢演算法相對於樸素的順序查詢演算法的優劣 對於樸素的順序查詢演算法(不侷限於資料的邏輯儲存方式,鏈式或者順序都可以),我們從順序表的頭開始一直遍歷到順序表的尾知道找到我們需要查詢的元素即可跳出,時間複雜度是O(n),對於資料量一旦過於龐大的話,該演算法會顯得力不從心 二分查詢演算法(受限制與資料的邏輯儲存方式,僅限制於順序儲存結構),前提是被查詢的記憶體區域資料域必須是有序的,這是二分法開始的先決條件(對於交換式排序的O(n*logn)的時間複雜度可能大家會覺得還不如樸素的演算法,但是實際中我們常常會遇到類似插入排序那樣的情況,我們的二分查詢開始就沒必要進行排序,所以說僅從查詢的角度來看的話,二分還是非常高效的) 每一次,我們以中點作為分界點,明確我們的待插元素的位置,捨棄另一半區域從而將我們的演算法的查詢區域大大縮小 計算其時間複雜度的話
T(n)=T(n/2)+O(C)
...一共進行了k=logn次迭代
T(n)=T(1)+O(C)*logn=O(logn)
因為是對數的原因,在資料兩大的情況下,我們的比較次數依然會變的無限趨近於常數階,所以說,二分查詢是非常的高效的

2.二分查詢為什麼難

雖然我們看著二分查詢非常的簡單,但是實際上,二分查詢的正確書寫需要一個非常精細的數學和邏輯推導過程(我在下面會用幾個例題來模擬一下思考的思路)

《程式設計珠璣》第四章提到:提供充足的時間,僅有約10%的專業程式設計師能夠完成一個正確的二分查詢。

為什麼難在於這麼幾點:

1.終止條件不清晰

2.不正確的轉換邊界容易導致死迴圈

3.二分查詢的變體相當龐雜,死記硬背絕對不可能

既然二分查詢這麼難寫,有沒有好一點的方法可以幫助我們理解

下面我引入一種思路叫做迴圈不變式

3.迴圈不變式

什麼是迴圈不變式,在《演算法導論》和《程式設計之美》中大神都有隊迴圈不變式進行了詳細的講解 個人在這裡只是描述個人的見解,如有紕漏和誤解,還煩請大神指出 迴圈不變式相當於是嚴謹的附加了終止條件的數學歸納法 學過數學歸納法的同學應該都知道,數學歸納法處理的問題往往都是沒有上界的,但是作為一個演算法,我們的第一根本原則就是有窮性 定義如下: 初始:迴圈第一次迭代開始之前,我們的描述和假設必須正確 維護(也有的叫保持):在某次迭代是正確的,在下次迭代還是正確的 終止:迴圈可以終止,並返回正確的結果 迴圈不變式其實不僅僅在本問題中可以大顯身手,其實在 迴圈不變式 是一種思想,在以後的很多問題中,我們要證明演算法的正確性等等都需要用到這個強有力的工具

4.若干變體描述以及程式碼示例

我們二分查詢描述操作的區域設定為1-right的陣列區域 迴圈不變式的初始是left-right

1.二分查詢值為key的下標不存在則返回-1

初始:待查陣列範圍是left(1)-right(n),待查元素key如果存在必定在該範圍內 保持:
if(data[mid]>key) right=mid-1;   //1
else if(data[mid]<key) left=mid-1;    //2
else if(data[mid]==key) return mid;    //3
解釋:1.當data[mid]>key說明mid-right中必定沒有key,那麼我們的超照範圍就變成了left-(mid-1),該判斷語句是查詢範圍縮小了right-mid+1             2.同理,查詢範圍縮小了mid-left+1             3.超找到了位置,直接返回 在保持環節中,顯然,我們每次都確保了key必定在我們的查詢範圍內,保持性證明完成 我們始終保證了待插元素落在left-right區域範圍內
終止: 本例題中,因為每次只要沒有找到的話我們的待查區域是必定會至少減少1個長度的,所以說,我們的程式必定會正確的終止,不會出現死迴圈的情況 在最後,如果left>right的話,我們返回-1就好 示例程式碼:
int Binary_Search(int left,int right)
{
	int mid=left+(right-left)/2;
	while(left<=right)
	{
		if(data[mid]<key) left=mid+1;
		else if(data[mid]>key) right=mid-1;
		else return mid;
	} 
	return -1;
}

2.二分查詢key第一次出現的下標(可能有重複),不存在返回-1

初始:待查陣列範圍是left(1)-right(n),待查元素key如果存在必定在該範圍內 保持:
if(data[mid]>key) right=mid-1;   //1
else if(data[mid]<key) left=mid+1;   //2
else right=mid;   //3
解釋: 1.data[mid]>key,說明必然第一次出現的下標在left-(mid-1)範圍內,該輪判斷查詢區域縮小了right-mid+1 2.同理,蓋倫判斷的查詢區域縮小了mid-left+1 3.當相同的時候,我們會發現我們要找第一次出現的下表,顯然第一次出現的下表必然在left-mid之間,該輪判斷查詢區域縮小了right-mid 其實1,3是可以合併的,合併起來我們可以減小我們的程式碼量和分支語句,提高判斷效率,並且合併之後是不會出現錯誤的,畢竟按照1來說合並之後right=mid,我們的待插元素key還在left-mid範圍內,只不過是收縮的精確度的問題,這個不影響結果 我們始終保證了待插元素落在left-right區域範圍內
終止: 在該例題中,我們會發現,第1,3,的分支檢查的區域縮小的範圍是right-mid,也就是說,我們有可能會存在一次判斷之後,待查區域大小沒有變化的情況,即當left=right的時候,我們的待查區域縮小量始終是right-mid=right-right=0 這種情況下,會造成死迴圈,滿足不了我們的迴圈不變式種植的要求,這時候,我們就要對迴圈的控制進入的條件進行巧妙地修改了 先來看: left=right-1:mid始終等於left,會1,3情況縮小範圍,可以正確執行 left=right:mid始終等於left和right,1,3情況縮小範圍是0,回不正確終止 這時候,我們只要讓終止條件是left<right就可以了 之後,我們對data[left]進行判斷,等於key,返回left,否則返回-1 示例程式碼:
int Binary_Search(int left,int right)
{
	int mid=left+(right-left)/2;
	while(left<right)
	{
		if(data[mid]<key) left=mid+1;
		else right=mid;
	} 
	if(data[left]==key) return left;
	else return -1;
}

3.二分查詢key(有可能重複)的最後一次出現的下表,沒有返回-1

1.初始:待查陣列範圍是left(1)-right(n),待查元素key如果存在必定在該範圍內 2.保持:
if(data[mid]>key) right=mid-1;   //1
else if(data[mid]<key) left=mid+1;   //2
else left=mid;   //3
解釋: 1.顯然,我們的待查範圍縮小了right-mid+1 2.同理,我們的待查範圍縮小了mid-left+1 3.相同的時候,我們發現我們要查詢最後一個元素,那麼顯然最後一個key的下表必然在mid-right之間,該輪縮小待查區域為mid-right 同上2,3可以合併,減少程式碼量 我們始終保證了待插元素落在left-right區域範圍內
3.終止 我們發現left=right-1,或者left=right的時候,我們的mid始終等於left,那麼對於判斷3,很容易出現死迴圈 我們對終止條件進行修正left<right-1 最後我們對left,right進行判斷就好了 程式碼示例:
int Binary_Search(int left,int right)
{
	int mid=left+(right-left)/2;
	while(left<right-1)
	{
		if(data[mid]>key) right=mid-1;
		else left=mid;
	} 
	if(data[right]==key) return right;
	else if(data[left]==key) return left;
	else return -1;
}

4.二分查詢剛好小於key的元素的下表,不存在返回-1

1.初始:待查陣列範圍是left(1)-right(n),待查元素key如果存在必定在該範圍內 2.保持:
if(data[mid]<key) left=mid;    //1
else if(data[mid]>=key) right=mid-1;    //2
對於1來說,顯然小於的話,我們的待插元素必定在mid-right中間,那麼縮小的區域大小就是mid-left 對於2,顯然縮小範圍是right-mid+1 我們始終保證了待插元素落在left-right區域範圍內 3.終止: 對於情況一來說,如果left=right-1/left=right的話,顯然會出現死迴圈的情況,所以說我們就需要修改終止情況left<right-1 最後判斷一下就好了 程式碼示例:
int Binary_Search(int left,int right)
{
	int mid=left+(right-left)/2;
	while(left<right-1)
	{
		if(data[mid]>=key) right=mid-1;
		else left=mid;
	} 
	if(data[right]<key) return right;
	else if(data[left]<key) return left;
	else return -1;
}

5.二分查詢幹好大於key的元素的下表不存在返回-1

1.初始:待查陣列範圍是left(1)-right(n),待查元素key如果存在必定在該範圍內 2.維護:
if(data[mid]>key) right=mid;
else left=mid+1;
3.終止: 對於情況left=right來說,容易出現死迴圈的情況,我們left<right就可以了 最後判斷 程式碼示例:
int Binary_Search(int left,int right)
{
	int mid=left+(right-left)/2;
	while(left<right)
	{
		if(data[mid]>key) right=mid;
		else left=mid+1;
	} 
	if(data[left]<key) return left;
	else return -1;
}

5.總結:

思路,利用迴圈不變式不斷的縮小我們的待查區域 最後我們需要對left=right和left=right-1等特殊情況進行考慮,避免因為縮小的區域大小可能為0導致的死迴圈的情況出現,修改終止條件 最後額外判斷就好了