1. 程式人生 > 實用技巧 >二分查詢的實際應用

二分查詢的實際應用

一、什麼是二分查詢?

  二分查詢針對的是一個有序的資料集合,每次通過跟區間中間的元素對比,將待查詢的區間縮小為之前的一半,直到找到要查詢的元素,或者區間縮小為0。

二、驚人的查詢速度 O(logn)

  我們假設資料大小是 n,每次查詢後資料都會縮小為原來的一半,也就是會除以 2。最壞情況下,直到查詢區間被縮小為空,才停止。

  可以看出來,這是一個等比數列。其中 n/2k=1 時,k 的值就是總共縮小的次數。而每一次縮小操作只涉及兩個資料的大小比較,所以,經過了 k 次區間縮小操作,時間複雜度就是 O(k)。通過 n/2k=1,我們可以求得 ,所以時間複雜度就是 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) 的演算法執行效率高。

  反過來,對數對應的就是指數。指數時間複雜度的演算法在大規模資料面前是無效的。

三、二分查詢的遞迴與非遞迴實現

  最簡單的情況就是有序陣列中不存在重複元素,使用二分查詢值等於給定值的資料。程式碼如下:

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;
}

著重強調一下容易出錯的 3 個地方。

  1. 迴圈退出條件:注意是 low<=high,而不是 low<high
  1. mid 的取值:mid=(low+high)/2 這種寫法是有問題的。因為如果 low 和 high 比較大的話,兩者之和就有可能會溢位。改進的方法是將 mid 的計算方式寫成 low+(high-low)/2。更進一步,如果要將效能優化到極致的話,我們可以將這裡的除以 2 操作轉化成位運算 low+((high-low)>>1)。因為相比除法運算來說,計算機處理位運算要快得多。
  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);
  }
}

  

四、二分查詢應用場景的侷限性

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

  • 首先,二分查詢依賴的是順序表結構,簡單點說就是陣列。

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

  • 其次,二分查詢針對的是有序資料。

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

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

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

  • 再次,資料量太小不適合二分查詢。

  資料量很小時,順序遍歷就足夠了,完全沒有必要用二分查詢。只有資料量比較大的時候,二分查詢的優勢才會比較明顯。

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

  • 最後,資料量太大也不適合二分查詢。

  二分查詢的底層需要依賴陣列這種資料結構,而陣列為了支援隨機訪問的特性,要求記憶體空間連續,對記憶體的要求比較苛刻。比如,我們有 1GB 大小的資料,如果希望用陣列來儲存,那就需要 1GB 的連續記憶體空間。

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

五、如何在 1000 萬個整數中快速查詢某個整數?

  我們的記憶體限制是 100MB,每個資料大小是 8 位元組,最簡單的辦法就是將資料儲存在陣列中,記憶體佔用差不多是 80MB,符合記憶體的限制。我們可以先對這 1000 萬資料從小到大排序,然後再利用二分查詢演算法,就可以快速地查詢想要的資料了。

六、二分查詢之變形問題

1、查詢第一個值等於給定值的元素

    比如下面這樣一個有序陣列,其中,a[5],a[6],a[7]的值都等於 8,是重複的資料。我們希望查詢第一個等於 8 的資料,也就是下標是 5 的元素。

    

  極致簡潔的寫法:

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 {
      low = mid + 1;
    }
  }
  if (low < n && a[low]==value){ return low;}
  else {return -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;
}

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、查詢第一個大於等於給定值的元素

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;
}

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;
}