前端學數據結構與算法(十一):看似簡單又讓人抓狂的二分查找算法

前言

二分查找法是一種高效的查找算法,它的思想很是好理解,但編寫正確的二分查找並不簡單。本章從最基礎的二分查找實現開始,講解其編寫技巧,接下來介紹它的四個常見變種的編寫,最後應用二分查找來解決真正的面試題,學以至用的同時更加深對其的理解,真正掌握它。面試

什麼是二分查找?

這是一種在有序的數組裏快速找到某個元素的高效算法。例如第一章舉過的例子:你借了一摞書準備走出書店,其中有一本忘了登記,如何快速找出那本書?你能夠一本本的嘗試,也能夠每一次直接就檢測半摞書,再剩下的半摞書依然重複這個過程,這樣12本書,你只用4次便可。像這樣每次將數據一分爲二的進行查找的算法,就是二分查找法。算法

二分查找的侷限性

雖說查找的效率很高,跟全部的算法同樣,也有其侷限性。數組

1. 必須使用數組

須要藉助數組經過下標訪問元素只須要O(1)的特性。這一點鏈表就沒法作到,由於訪問某個元素必需要遍歷,會徒增複雜度。性能

2. 數據必須是有序的

由於每次是與中間元素進行比較,結果是捨棄一半的查找範圍,因此這個中間元素須要起到臨界值的做用。code

3. 數據量過小不必使用

數據量過小,和遍歷的速度區別不大,那就不必使用二分查找。也有例外,若是比較操做很耗時,二分查找能夠有效減小比較次數,即便數據量小也可使用二分查找。blog

基礎:二分查找的實現

每次咱們拿順序數組的中間值與須要查找的值進行比對,若是中間值比要查找的值大,就是小區間裏查找,反之亦然。思路清楚後,那就上代碼,跟着代碼更好理解:排序

function binarySearch(arr, val) {
  let l = 0; // 左側邊界
  let r = arr.length - 1; // 右側邊界
  // 在[l ... r - 1]範圍內查找
  
  while (r >= l) { // 終止條件
    const mid = (l + (r - l) / 2) | 0; // 取中間值
    if (arr[mid] === val) { // 正好找到了
      return mid; // 返回對應下標
    } else if (arr[mid] > val) { // 若是當前值大於查找值,去左邊界找
      r = mid - 1; // 從新定義右邊界,爲mid - 1
    } else {
      l = mid + 1; // 從新定義左邊界
    }
    
  }
  return -1; // 沒找到返回-1
}

const arr = [1, 2, 3, 4, 5, 6, 9, 10];
binarySearch(arr, 10); // 7

這裏,也包括以前章節的中間取值都是採用的l + (r - l) / 2 | 0方式,而沒采用(r + l) / 2 | 0,是由於若是lr都很是大,相加會出現整型溢出的bug遞歸

首先須要獲得mid,讓數組以它爲中心一分爲二,左側是[l ... mid - 1]小區間,右側是[mid + 1 ... r]大區間。索引

由於是順序數組,假如當arr[mid] > val時,就能夠直接忽略大區間,要查找的值確定在小區間裏,因此讓右側的邊界爲r = mid - 1,這裏爲何要- 1,由於以前的判斷已經代表此時arr[mid]是不等於val的,因此須要將當前元素mid刨除在下次查找的範圍內。反之亦然,每次查找均可以忽略一半的範圍。直至最終l > r,表示沒有找到。get

注意開區間與閉區間的區別

爲何二分查找燒腦?根本緣由在於它的邊界問題,因此開頭的時候要定義好邊界的含義。

上面版本的二分查找使用的是閉區間定義,右側邊界的定義爲r = arr.length - 1,也就是在[l ... r - 1]的範圍內進行查找,因此循環的終止條件爲r >= l,由於它們相等時數組裏還有一個元素沒有參與查找。

你也能夠採用開區間的定義方式,也就是右側邊界爲r === arr.length,在[l ... r )的範圍內進行查找。此時循環的截止條件就不能是r >= l了,由於r做爲下標存在數組越界的狀況,條件須要調整爲r > l。並且右側邊界的從新定義就不能是mid - 1了,右邊界必須大於要查找元素的下標。開區間二分查找代碼以下:

function binarySearch(arr, val) {
  let l = 0;
  let r = arr.length; // 開區間定義
  // 在[l ... r)範圍內查找
  
  while (r > l) { // 注意結束條件
    const mid = (l + (r - l) / 2) | 0;
    if (arr[mid] === val) {
      return mid;
    } else if (arr[mid] > val) {
      r = mid; // 從新定義再也不 - 1
    } else {
      l = mid + 1;
    }
  }
  return -1;
}

因此書寫定義好邊界,以及熟悉它的含義,再寫出準確無誤的二分查找相信也不是難事。

遞歸版二分查找

其實遞歸版本的更好理解,由於子過程的重複性是一目瞭然的,只是性能會比循環略微差一丟丟,不過都是一個複雜度級別,區別很小。代碼以下:

function binarySearch(arr, val) {
  const _helper = (arr, l, r) => {
    if (l > r) { // 由於是閉區間,相等時還有元素要比較
      return -1;
    }
    const mid = (l + (r - l) / 2) | 0;
    if (arr[mid] === val) {
      return mid;
    } else if (arr[mid] > val) {
      return _helper(arr, l, mid - 1);
    } else {
      return _helper(arr, mid + 1, r);
    }
  };
  return _helper(arr, 0, arr.length - 1); // 使用閉區間
}

進階:二分查找的變種

以上的二分查找是創建在數組裏沒有重複數據的狀況下,但假如在一個重複數據的數組裏,要返回第一個匹配的元素,上面實現的二分查找就不適應了。這也就是二分查找讓人抓狂的變種問題,都須要在基礎的二分查找上進行改造以知足需求,常見的有四種變種。

1. 查找第一個匹配的元素

這個查找規則是創建在已經找到了匹配的元素以後,由於是找到第一個,因此首先是判斷這個元素是否是數組的第一個元素,而後是已經找到的元素的上一個元素是不匹配的才行。改造的代碼以下:

function binarySearch(arr, val) {
  let l = 0;
  let r = arr.length - 1; // 閉區間
  while (r >= l) {
    const mid = (l + (r - l) / 2) | 0;
    if (arr[mid] < val) {
      l = mid + 1;
    } else if (arr[mid] > val) {
      r = mid - 1;
    } else { // 若是已經找到了匹配的元素
      if (mid === 0 || arr[mid - 1] !== val) { 
        // 是數組第一個或前面的元素不匹配,表示找到了
        return mid;
      } else {
        // 不然上一個元素做爲右區間繼續
        r = mid - 1
      }
    }
  }
  return -1;
}

const arr = [1, 2, 2, 2, 2, 3, 4];
binarySearch(arr, 2) // 1

2. 查找最後一個匹配的元素

跟上一個變種相似,在找到了匹配的元素以後,須要判斷這個元素是否是數組的最後一位,還須要判斷匹配元素的後一位是不匹配的才行,改造代碼以下:

function binarySearch(arr, val) {
  let l = 0;
  let r = arr.length - 1; // 閉區間
  while (r >= l) {
    const mid = (l + (r - l) / 2) | 0;
    if (arr[mid] < val) {
      l = mid + 1;
    } else if (arr[mid] > val) {
      r = mid - 1;
    } else { // 若是已經找到了匹配的元素
      if (mid === arr.length - 1 || arr[mid + 1] !== val) {
        // 是數組最後一個元素或它的後面的不匹配
        return mid;
      } else {
        // 不然下一個元素做爲左區間繼續
        l = mid + 1
      }
    }
  }
  return -1;
}

const arr = [1, 2, 2, 2, 2, 3, 4];
binarySearch(arr, 2); // 4

3. 查找第一個大於等於匹配的元素

須要在已經找到的大於或等於的位置進行判斷,若是這個元素是第一個或它前面的元素是小於要匹配的元素時,說明已經找到了。代碼以下:

function binarySearch(arr, val) {
  let l = 0;
  let r = arr.length - 1; // 閉區間
  while (r >= l) {
    const mid = (l + (r - l) / 2) | 0;
    if (arr[mid] >= val) { // 已經找打了大於或等於的元素
      if (mid === 0 || arr[mid - 1] < val) { 
      // 若是是數組的第一個元素或它的前面的元素不匹配 
        return mid;
      } else {
        r = mid - 1; // 縮小右邊界
      }
    } else {
      l = mid + 1;
    }
  }
  return -1;
}

const arr = [1, 5, 5, 7, 8, 9];
console.log(binarySearch(arr, 4)); // 1

4. 查找最後一個小於等於匹配的元素

和上一個變種問題相似,重寫小於或等於的元素集合便可,代碼以下:

function binarySearch(arr, val) {
  let l = 0;
  let r = arr.length - 1; // 閉區間
  while (r >= l) {
    const mid = (l + (r - l) / 2) | 0;
    if (arr[mid] >= val) { // 已經找打了大於或等於的元素
      if (mid === 0 || arr[mid - 1] < val) { 
      // 若是是數組的第一個元素或它的前面的元素不匹配 
        return mid;
      } else {
        r = mid - 1; // 縮小右邊界
      }
    } else {
      l = mid + 1;
    }
  }
  return -1;
}

const arr = [1, 2, 2, 7, 8, 9];
console.log(binarySearch(arr, 5)); // 2

實踐:求解力扣真題

你覺得寫出上面幾個二分查找法就算掌握了麼?真正二分查找的題目細節更講究,來感覺下二分查找爲何讓人抓狂吧。

35 - 搜索插入位置

給定一個排序數組和一個目標值,在數組中找到目標值,並返回其索引。
若是目標值不存在於數組中,返回它將會被按順序插入的位置。

你能夠假設數組中無重複元素。

示例 1:
輸入: [1, 3, 5, 6], 5
輸出: 2

示例 2:
輸入: [1, 3, 5, 6], 2
輸出: 1

示例 3:
輸入: [1, 3, 5, 6], 7
輸出: 4

示例 4:
輸入: [1, 3, 5, 6], 0
輸出: 0

這題的示例已經描述的很清楚了,若是正好右這個元素,就返回這個元素的下標。有序、查找、無重複,這些關鍵詞徹底就是爲二分查找量身定作,不過有一點和常規的二分查找不一樣,沒有這個元素時返回前一個元素的下標,代碼以下:

var searchInsert = function (nums, target) {
  let l = 0;
  let r = nums.length - 1;
  while (r >= l) {
    const mid = (l + (r - l) / 2) | 0;
    if (target > nums[mid]) {
      l = mid + 1;
    } else if (target < nums[mid]) {
      r = mid - 1;
    } else {
      return mid; // 若是等於那就正好
    }
  }
  return l; // 不然返回左側元素
};

540 - 有序數組中的單一元素

給定一個只包含整數的有序數組,每一個元素都會出現兩次,惟有一個數只會出現一次,找出這個數。

示例1:
輸入: [1,1,2,3,3,4,4,8,8]
輸出: 2

示例2:
輸入: [3,3,7,7,10,11,11]
輸出: 10

注意: 您的方案應該在 O(log n)時間複雜度和 O(1)空間複雜度中運行。

若是是使用暴力解法,那這道中等的題目就太簡單了,最後的注意裏面有強調,要保證這個解法在O(logn)級別。題目有交代是有序數組,因此這題很天然會想到使用二分查找法,不過上面寫的二分查找好像不適用,麻煩的地方在於怎麼肯定要查找的元素在哪一邊,如何每次拋一半不須要使用的數據。

通過觀察,這數組有個特徵,長度是奇數,由於只有一個數出現一次,其餘數都出現兩次。還有就是即便是都是奇數,若是按中間下標均分,它又分爲兩種狀況:

1. 分割後兩側的數組長度爲奇數

例如數組長度爲7的數組被均分爲兩個長度3的數組,此時咱們要把兩個相同的數字做爲一個數字處理。這裏又分爲兩種狀況:

1.1 - 中間元素與前一個相同,此時惟一不一樣的那個元素必然是在右側,左側是能夠忽略的,並且下一次開始的下標從mid + 1開始便可。

1.2 - 中間元素與後一個相同,惟一不一樣的那個元素必然是在左側,右側能夠忽略,且下一次截止的位置爲mid - 1

2. 分割後兩側的數組長度爲偶數

此時仍是分爲兩種狀況來處理,與以前分割爲奇數的偏偏相反:

2.1 - 中間元素與前一個元素相同時,惟一不一樣的那個元素在左側部分,由於既然都是偶數,哪邊有相同的元素,哪邊就是奇數數組了,下一次查找截止的位置爲mid - 2

2.2 - 同理,中間元素與後一個元素相同,惟一不一樣的元素就在右側,並且下一次開始的位置爲mid + 2

知道了怎麼均分數組,也知道了邊界怎麼重定義,這個解法就很容易寫出來了。不過還有一個注意點就是,當數組裏只能一個元素的時候就不用比較了,一定是那個元素。完整代碼以下:

var singleNonDuplicate = function (nums) {
  let l = 0;
  let r = nums.length - 1;
  while (r > l) { // 閉區間保留最後一個元素
    const mid = (l + (r - l) / 2) | 0;
    const splitIntoEven = (mid - l) % 2 === 0; // 分割後是不是偶數長度
    if (nums[mid] === nums[mid + 1]) { // 若是與前一個相同
      if (splitIntoEven) { // 並且左右是偶數長度
        l = mid + 2; // 符合2.2的狀況
      } else {
        r = mid - 1; // 符合1.2
      }
    } else if (nums[mid] === nums[mid - 1]) { // 與後一個相同
      if (splitIntoEven) { // 是偶數長度
        r = mid - 2; // 符合2.1
      } else {
        l = mid + 1; // 符合1.1
      }
    } else {
      return nums[mid]; // 當前元素和先後都不相同,返回這個異類
    }
  }
  return nums[l]; // 返回最後一個元素
};

436 - 尋找右區間

有一個二維數組,裏面每個單獨的數組i都表示的是一個區間值,第一個元素是該區間的起始點,
第二個元素是該區間終點。問是否有另外一個區間j,它的起始點大於或等於區間i的終點,
這樣的區間能夠稱j是i的右區間。

對於每個區間i,你需找到它的下標值最小右區間,若是沒有則返回-1。

注意:
你能夠假設區間的終點老是大於它的起始點。
你能夠假定這些區間都不具備相同的起始點。

示例 1:
輸入: [ [3,4], [2,3], [1,2] ]
輸出: [-1, 0, 1]

解釋:
對於[3,4],沒有知足條件的「右側」區間。
對於[2,3],區間[3,4]具備最小的「右」起點;
對於[1,2],區間[2,3]具備最小的「右」起點。

對於每個區間i,須要找到另一個區間j,這個j的起始點要大於等於i的終點。可是這樣的區間可能會有多個,咱們須要找到符合這個要求且下標值最小的那一項。

咱們使用排序便可,按照起始點從升序排列,依次遍歷時,符合條件的第一項,絕對就是下標值最小的那一項。但此時會遇到另一個問題,就是排序以後的下標和最初的下標不一樣,此時咱們可使用map記錄下最初的下標值。找到符合的區間後,查看該區間最初的下標,添加到返回的集合裏便可。代碼以下:

var findRightInterval = function (intervals) {
  const res = []; // 最終返回的結果
  const map = new Map(); // 記錄最初的下標
  for (let i = 0; i < intervals.length; i++) {
    map.set(intervals[i], i); // 使用區間做爲key
  }
  intervals.sort((a, b) => a[0] - b[0]); // 按照起始點升序排列

  for (let i = 0; i < intervals.length; i++) {
    let minIndex = -1;
    let l = i;
    let r = intervals.length - 1;
    while (r >= l) { // 閉區間,最後一個元素也要比較
      const mid = (l + (r - l) / 2) | 0;
      if (intervals[mid][0] >= intervals[i][1]) { // 若是符合
        minIndex = map.get(intervals[mid]); // 取得原始下標
        r = mid - 1 // 換小的起始點繼續
      } else {
        l = mid + 1 // 不符合換大的起始點繼續
      }
    }
    res[map.get(intervals[i])] = minIndex; 
    // intervals[i]是排序後所在的位置
    // 而map.get(intervals[i])找到的又是它原始的下標位置
    // minIndex對應的是其原始項的最小右區間
  }
  return res; // 返回集合
};

由於二分查找只能做用於有序的數組,因此數組無序時,咱們能夠先對其進行排序。若是不使用二分查找,第二層循環依然遍歷,總體的複雜度就會變爲O(n²)

最後

本章咱們手寫實現了二分查找以及它的四個變種,對於如何書寫正確的二分查找,也說明了定義邊界時的開閉區間問題。還有就是當遇到有序、查找這兩個關鍵詞,很容易就能想到使用二分查找法先試試。若是是無序的呢?那就和最後一個題目同樣,排序以後再找便可。

相關文章
相關標籤/搜索