前言
二分查找法是一種高效的查找算法,它的思想很是好理解,但編寫正確的二分查找並不簡單。本章從最基礎的二分查找實現開始,講解其編寫技巧,接下來介紹它的四個常見變種的編寫,最後應用二分查找來解決真正的面試題,學以至用的同時更加深對其的理解,真正掌握它。面試
什麼是二分查找?
這是一種在有序的數組裏快速找到某個元素的高效算法。例如第一章舉過的例子:你借了一摞書準備走出書店,其中有一本忘了登記,如何快速找出那本書?你能夠一本本的嘗試,也能夠每一次直接就檢測半摞書,再剩下的半摞書依然重複這個過程,這樣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
,是由於若是l
和r
都很是大,相加會出現整型溢出的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²)
。
最後
本章咱們手寫實現了二分查找以及它的四個變種,對於如何書寫正確的二分查找,也說明了定義邊界時的開閉區間問題。還有就是當遇到有序、查找這兩個關鍵詞,很容易就能想到使用二分查找法先試試。若是是無序的呢?那就和最後一個題目同樣,排序以後再找便可。