數據結構與算法之“二分查找”

  • 2020-6-26
    Obtained from human history lesson is: never remember the lessons of history of mankind。
    人類從歷史中吸取的教訓就是,人類從來都不會從歷史中吸取教訓。
    德國哲學家 – 黑格爾

一、概述

在這裏插入圖片描述
二分查找(Binary Search)算法,也叫折半查找算法。二分查找的思想非常簡單,很多非計算機專業的同學很容易就能理解,但是看似越簡單的東西往往越難掌握好,想要靈活應用就更加困難。

二分查找針對的是一個有序的數據集合,查找思想有點類似分治思想。每次都通過跟區間的中間元素對比,將待查找的區間縮小爲之前的一半,直到找到要查找的元素,或者區間被縮小爲 0。

二分查找是一種非常高效的查找算法,高效到什麼程度呢?我們來分析一下它的時間複雜度。

我們假設數據大小是 n,每次查找後數據都會縮小爲原來的一半,也就是會除以 2。最壞情況下,直到查找區間被縮小爲空,才停止。
在這裏插入圖片描述
可以看出來,這是一個等比數列。其中 n/2k=1 時,k 的值就是總共縮小的次數。而每一次縮小操作只涉及兩個數據的大小比較,所以,經過了 k 次區間縮小操作,時間複雜度就是 O(k)。通過 n/2k=1,我們可以求得 k=log2n,所以時間複雜度就是 O(logn)。

二分查找是時間複雜度爲 O(logn) 的算法,堆、二叉樹的操作等等,它們的時間複雜度也是 O(logn)。O(logn) 這種對數時間複雜度。這是一種極其高效的時間複雜度,有的時候甚至比時間複雜度是常量級 O(1) 的算法還要高效。爲什麼這麼說呢?

因爲 logn 是一個非常“恐怖”的數量級,即便 n 非常非常大,對應的 logn 也很小。比如 n 等於 2 的 32 次方,這個數很大了吧?大約是 42 億。也就是說,如果我們在 42 億個數據中用二分查找一個數據,最多需要比較 32 次。

用大 O 標記法表示時間複雜度的時候,會省略掉常數、係數和低階。對於常量級時間複雜度的算法來說,O(1) 有可能表示的是一個非常大的常量值,比如 O(1000)、O(10000)。所以,常量級時間複雜度的算法有時候可能還沒有 O(logn) 的算法執行效率高。

反過來,對數對應的就是指數。有一個非常著名的“阿基米德與國王下棋的故事”,你可以自行搜索一下,感受一下指數的“恐怖”。這也是爲什麼我們說,指數時間複雜度的算法在大規模數據面前是無效的。

二、二分查找的實現

2.1、非遞歸實現與遞歸實現

簡單的二分查找並不難寫,注意我這裏的“簡單”二字。二分查找的變體問題,那纔是真正燒腦的。
如何來寫最簡單的二分查找?

最簡單的情況就是有序數組中不存在重複元素,我們在其中用二分查找值等於給定值的數據。

最簡單的情況就是有序數組中不存在重複元素,我們在其中用二分查找值等於給定值的數據。我用 Java 代碼實現了一個最簡單的二分查找算法。

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
 
  while (low <= high) {
    int mid = (low + high) / 2;
    if (a[mid] == value) {
      return mid;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
 
  return -1;
}

low、high、mid 都是指數組下標,其中 low 和 high 表示當前查找的區間範圍,初始 low=0, high=n-1。mid 表示 [low, high] 的中間位置。我們通過對比 a[mid] 與 value 的大小,來更新接下來要查找的區間範圍,直到找到或者區間縮小爲 0,就退出。如果你有一些編程基礎,看懂這些應該不成問題。現在,我就着重強調一下容易出錯的 3 個地方。

  • 循環退出條件
    注意是 low<=high,而不是 low<high。否則如果最後一個數據是要查找的值,查找就失敗。
  • mid 的取值
    實際上,mid=(low+high)/2 這種寫法是有問題的。因爲如果 low 和 high 比較大的話,兩者之和就有可能會溢出。改進的方法是將 mid 的計算方式寫成 low+(high-low)/2。更進一步,如果要將性能優化到極致的話,我們可以將這裏的除以 2 操作轉化成位運算 low+((high-low)>>1)。因爲相比除法運算來說,計算機處理位運算要快得多。
  • low 和 high 的更新
    low=mid+1,high=mid-1。注意這裏的 +1 和 -1,如果直接寫成 low=mid 或者 high=mid,就可能會發生死循環。比如,當 high=3,low=3 時,如果 a[3] 不等於 value,就會導致一直循環不退出。

由於其類似分治的思想,所以可以使用遞歸來實現:

// 二分查找的遞歸實現
public int bsearch(int[] a, int n, int val) {
  return bsearchInternally(a, 0, n - 1, val);
}
 
private int bsearchInternally(int[] a, int low, int high, int value) {
  if (low > high) return -1;
 
  int mid =  low + ((high - low) >> 1);
  if (a[mid] == value) {
    return mid;
  } else if (a[mid] < value) {
    return bsearchInternally(a, mid+1, high, value);
  } else {
    return bsearchInternally(a, low, mid-1, value);
  }
}

2.2、二分查找應用場景的侷限性

二分查找的時間複雜度是 O(logn),查找數據的效率非常高。不過,並不是什麼情況下都可以用二分查找,它的應用場景是有很大侷限性的。那什麼情況下適合用二分查找,什麼情況下不適合呢?

  • 首先,二分查找依賴的是順序表結構,簡單點說就是數組。

    那二分查找能否依賴其他數據結構呢?比如鏈表。答案是不可以的,主要原因是二分查找算法需要按照下標隨機訪問元素。我們在數組和鏈表那兩節講過,數組按照下標隨機訪問數據的時間複雜度是 O(1),而鏈表隨機訪問的時間複雜度是 O(n)。所以,如果數據使用鏈表存儲,二分查找的時間複雜就會變得很高。

    二分查找只能用在數據是通過順序表來存儲的數據結構上。如果你的數據是通過其他數據結構存儲的,則無法應用二分查找。

  • 其次,二分查找針對的是有序數據,且最好是靜態數據。

    二分查找對這一點的要求比較苛刻,數據必須是有序的。如果數據沒有序,我們需要先排序。前面章節裏我們講到,排序的時間複雜度最低是 O(nlogn)。所以,如果我們針對的是一組靜態的數據,沒有頻繁地插入、刪除,我們可以進行一次排序,多次二分查找。這樣排序的成本可被均攤,二分查找的邊際成本就會比較低。

    但是,如果我們的數據集合有頻繁的插入和刪除操作,要想用二分查找,要麼每次插入、刪除操作之後保證數據仍然有序,要麼在每次二分查找之前都先進行排序。針對這種動態數據集合,無論哪種方法,維護有序的成本都是很高的。

    所以,二分查找只能用在插入、刪除操作不頻繁,一次排序多次查找的場景中。針對動態變化的數據集合,二分查找將不再適用。那針對動態數據集合,如何在其中快速查找某個數據呢?別急,等到二叉樹那一節我會詳細講。

  • 再次,數據量太小不適合二分查找。

    如果要處理的數據量很小,完全沒有必要用二分查找,順序遍歷就足夠了。比如我們在一個大小爲 10 的數組中查找一個元素,不管用二分查找還是順序遍歷,查找速度都差不多。只有數據量比較大的時候,二分查找的優勢纔會比較明顯。

    不過,這裏有一個例外。如果數據之間的比較操作非常耗時,不管數據量大小,我都推薦使用二分查找。比如,數組中存儲的都是長度超過 300 的字符串,如此長的兩個字符串之間比對大小,就會非常耗時。我們需要儘可能地減少比較次數,而比較次數的減少會大大提高性能,這個時候二分查找就比順序遍歷更有優勢。

  • 最後,數據量太大也不適合二分查找。(其實這是數組的限制)

    二分查找的底層需要依賴數組這種數據結構,而數組爲了支持隨機訪問的特性,要求內存空間連續,對內存的要求比較苛刻。比如,我們有 1GB 大小的數據,如果希望用數組來存儲,那就需要 1GB 的連續內存空間。

    注意這裏的“連續”二字,也就是說,即便有 2GB 的內存空間剩餘,但是如果這剩餘的 2GB 內存空間都是零散的,沒有連續的 1GB 大小的內存空間,那照樣無法申請一個 1GB 大小的數組。而我們的二分查找是作用在數組這種數據結構之上的,所以太大的數據用數組存儲就比較喫力了,也就不能用二分查找了。

三、二分查找的變形問題

唐納德·克努特(Donald E.Knuth)在《計算機程序設計藝術》的第 3 卷《排序和查找》中說到:“儘管第一個二分查找算法於 1946 年出現,然而第一個完全正確的二分查找算法實現直到 1962 年纔出現。”

前面的只是二分查找中最簡單的一種情況,在不存在重複元素的有序數組中,查找值等於給定值的元素。最簡單的二分查找寫起來確實不難,但是,二分查找的變形問題就沒那麼好寫了。

二分查找的變形問題很多,我只選擇幾個典型的來講解,其他的你可以藉助的思路自己來分析。
在這裏插入圖片描述

3.1、變體一:查找第一個值等於給定值的元素

有序數據集合中存在重複的數據,我們希望找到第一個值等於給定值的數據。

實現的代碼也比較簡單,就是增加一個判斷,找到給定值後,判斷該位置的前一個位置是否也爲給定值,如果是繼續二分查找,若不是,該值即爲所求值。

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == 0) || (a[mid - 1] != value)) return mid;
      else high = mid - 1;
    }
  }
  return -1;
}

我來稍微解釋一下這段代碼。a[mid] 跟要查找的 value 的大小關係有三種情況:大於、小於、等於。對於 a[mid]>value 的情況,我們需要更新 high= mid-1;對於 a[mid]<value 的情況,我們需要更新 low=mid+1。這兩點都很好理解。那當 a[mid]=value 的時候應該如何處理呢?

如果我們查找的是任意一個值等於給定值的元素,當 a[mid] 等於要查找的值時,a[mid] 就是我們要找的元素。但是,如果我們求解的是第一個值等於給定值的元素,當 a[mid] 等於要查找的值時,我們就需要確認一下這個 a[mid] 是不是第一個值等於給定值的元素。

我們重點看第 11 行代碼。如果 mid 等於 0,那這個元素已經是數組的第一個元素,那它肯定是我們要找的;如果 mid 不等於 0,但 a[mid] 的前一個元素 a[mid-1] 不等於 value,那也說明 a[mid] 就是我們要找的第一個值等於給定值的元素。

如果經過檢查之後發現 a[mid] 前面的一個元素 a[mid-1] 也等於 value,那說明此時的 a[mid] 肯定不是我們要查找的第一個值等於給定值的元素。那我們就更新 high=mid-1,因爲要找的元素肯定出現在 [low, mid-1] 之間。

對比上面的兩段代碼,是不是下面那種更好理解?實際上,很多人都覺得變形的二分查找很難寫,主要原因是太追求第一種那樣完美、簡潔的寫法。而對於我們做工程開發的人來說,代碼易讀懂、沒 Bug,其實更重要,所以我覺得第二種寫法更好。

3.2、變體二:查找最後一個值等於給定值的元素

這個問題和變體一差不多,直接上代碼:

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] != value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

3.3、變體三:查找第一個大於等於給定值的元素

直接上代碼

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] >= value) {
      if ((mid == 0) || (a[mid - 1] < value)) return mid;
      else high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
  return -1;
}

3.4、變體四:查找最後一個小於等於給定值的元素

直接上代碼:

public int bsearch7(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

3.5、第三節小結

凡是用二分查找能解決的,絕大部分我們更傾向於用散列表或者二叉查找樹。即便是二分查找在內存使用上更節省,但是畢竟內存如此緊缺的情況並不多。那二分查找真的沒什麼用處了嗎?

實際上,上一節講的求“值等於給定值”的二分查找確實不怎麼會被用到,二分查找更適合用在“近似”查找問題,在這類問題上,二分查找的優勢更加明顯。比如今天講的這幾種變體問題,用其他數據結構,比如散列表、二叉樹,就比較難實現了。

變體的二分查找算法寫起來非常燒腦,很容易因爲細節處理不好而產生 Bug,這些容易出錯的細節有:終止條件、區間上下界更新方法、返回值選擇。所以今天的內容你最好能用自己實現一遍,對鍛鍊編碼能力、邏輯思維、寫出 Bug free 代碼,會很有幫助。

四、參考資料

  • 王爭 – 《極客時間|數據結構與算法之美》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章