最新互聯網大廠面試真題、Java程序員面試策略(面試前的準備、面試中的技巧)請訪問GitHubgit
二分查找(Binary Search)算法,也叫折半查找算法。二分查找的思想很是簡單,不少非計算機專業的同窗很容易就能理解,可是看似越簡單的東西每每越難掌握好,想要靈活應用就更加困難。程序員
先來看看一道思考題。github
假設咱們有 1000 萬個整數數據,每一個數據佔 8 個字節,如何設計數據結構和算法,快速判斷某個整數是否出如今這 1000 萬數據中? 咱們但願這個功能不要佔用太多的內存空間,最多不要超過 100MB,你會怎麼作呢?帶着這個問題,讓咱們進入今天的內容吧!面試
二分查找是一種很是簡單易懂的快速查找算法,生活中處處可見。好比說,咱們如今來作一個猜字遊戲。我隨機寫一個 0 到 99 之間的數字,而後你來猜我寫的是什麼。猜的過程當中,你每猜一次,我就會告訴你猜的大了仍是小了,直到猜中爲止。你來想一想,如何快速猜中我寫的數字呢?算法
假設我寫的數字是 23,你能夠按照下面的步驟來試一試。(若是猜想範圍的數字有偶數個,中間數有兩個,就選擇較小的那個。)編程
7 次就猜出來了,是否是很快?這個例子用的就是二分思想,按照這個思想,即使我讓你猜的是 0 到 999 的數字,最多也只要 10 次就能猜中。不信的話,你能夠試一試。數組
這是一個生活中的例子,咱們如今回到實際的開發場景中。假設有 1000 條訂單數據,已經按照訂單金額從小到大排序,每一個訂單金額都不一樣,而且最小單位是元。咱們如今想知道是否存在金額等於 19 元的訂單。若是存在,則返回訂單數據,若是不存在則返回 null。性能優化
最簡單的辦法固然是從第一個訂單開始,一個一個遍歷這 1000 個訂單,直到找到金額等於19 元的訂單爲止。但這樣查找會比較慢,最壞狀況下,可能要遍歷完這 1000 條記錄才能找到。那用二分查找能不能更快速地解決呢?數據結構
爲了方便講解,咱們假設只有 10 個訂單,訂單金額分別是:8,11,19,23,27,33,45,55,67,98。數據結構和算法
仍是利用二分思想,每次都與區間的中間數據比對大小,縮小查找區間的範圍。爲了更加直觀,我畫了一張查找過程的圖。其中,low 和 high 表示待查找區間的下標,mid 表示待查找區間的中間元素下標。
看懂這兩個例子,你如今對二分的思想應該掌握得妥妥的了。我這裏稍微總結昇華一下,二分查找針對的是一個有序的數據集合,查找思想有點相似分治思想。每次都經過跟區間的中間元素對比,將待查找的區間縮小爲以前的一半,直到找到要查找的元素,或者區間被縮小爲 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) 的算法執行效率高。
反過來,對數對應的就是指數。有一個很是著名的「阿基米德與國王下棋的故事」,你能夠自行搜索一下,感覺一下指數的「恐怖」。這也是爲何咱們說,指數時間複雜度的算法在
大規模數據面前是無效的。
實際上,簡單的二分查找並不難寫,注意我這裏的「簡單」二字。下一節,咱們會講到二分查找的變體問題,那纔是真正燒腦的。今天,咱們來看如何來寫最簡單的二分查找。
最簡單的狀況就是有序數組中不存在重複元素,咱們在其中用二分查找值等於給定值的數據。我用 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 個地方。
1.循環退出條件
注意是 low<=high,而不是 low<high。
2.mid的取值
實際上,mid=(low+high)/2 這種寫法是有問題的。由於若是 low 和 high 比較大的話,二者之和就有可能會溢出。改進的方法是將 mid 的計算方式寫成 low+(high-low)/2。更進一步,若是要將性能優化到極致的話,咱們能夠將這裏的除以 2 操做轉化成位運算 low+((high-low)>>1)。由於相比除法運算來講,計算機處理位運算要快得多。
3.low和high的更新
low=mid+1,high=mid-1。注意這裏的 +1 和 -1,若是直接寫成 low=mid 或者high=mid,就可能會發生死循環。好比,當 high=3,low=3 時,若是 a[3] 不等於value,就會致使一直循環不退出。
若是你留意我剛講的這三點,我想一個簡單的二分查找你已經能夠實現了。實際上,二分查找除了用循環來實現,還能夠用遞歸來實現,過程也很是簡單。
我用 Java 語言實現了一下這個過程,正好你能夠藉此機會回顧一下寫遞歸代碼的技巧。
//二分查找的遞歸實現 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(1),而鏈表隨機訪問的時間複雜度是 O(n)。因此,若是數據使用鏈表存儲,二分查找的時間複雜就會變得很高。
二分查找只能用在數據是經過順序表來存儲的數據結構上。若是你的數據是經過其餘數據結構存儲的,則沒法應用二分查找。
其次,二分查找針對的是有序數據。
二分查找對這一點的要求比較苛刻,數據必須是有序的。若是數據沒有序,咱們須要先排序。前面章節裏咱們講到,排序的時間複雜度最低是 O(nlogn)。因此,若是咱們針對的是一組靜態的數據,沒有頻繁地插入、刪除,咱們能夠進行一次排序,屢次二分查找。這樣排序的成本可被均攤,二分查找的邊際成本就會比較低。
可是,若是咱們的數據集合有頻繁的插入和刪除操做,要想用二分查找,要麼每次插入、刪除操做以後保證數據仍然有序,要麼在每次二分查找以前都先進行排序。針對這種動態數據集合,不管哪一種方法,維護有序的成本都是很高的。
因此,二分查找只能用在插入、刪除操做不頻繁,一次排序屢次查找的場景中。針對動態變化的數據集合,二分查找將再也不適用。那針對動態數據集合,如何在其中快速查找某個數據呢?別急,等到二叉樹那一節我會詳細講。
再次,數據量過小不適合二分查找。
若是要處理的數據量很小,徹底沒有必要用二分查找,順序遍歷就足夠了。好比咱們在一個大小爲 10 的數組中查找一個元素,無論用二分查找仍是順序遍歷,查找速度都差很少。只有數據量比較大的時候,二分查找的優點纔會比較明顯。
不過,這裏有一個例外。若是數據之間的比較操做很是耗時,無論數據量大小,我都推薦使用二分查找。好比,數組中存儲的都是長度超過 300 的字符串,如此長的兩個字符串之間比對大小,就會很是耗時。咱們須要儘量地減小比較次數,而比較次數的減小會大大提升性能,這個時候二分查找就比順序遍歷更有優點。
最後,數據量太大也不適合二分查找。
二分查找的底層須要依賴數組這種數據結構,而數組爲了支持隨機訪問的特性,要求內存空間連續,對內存的要求比較苛刻。好比,咱們有 1GB 大小的數據,若是但願用數組來存儲,那就須要 1GB 的連續內存空間。
注意這裏的「連續」二字,也就是說,即使有 2GB 的內存空間剩餘,可是若是這剩餘的2GB 內存空間都是零散的,沒有連續的 1GB 大小的內存空間,那照樣沒法申請一個 1GB大小的數組。而咱們的二分查找是做用在數組這種數據結構之上的,因此太大的數據用數組存儲就比較吃力了,也就不能用二分查找了。
二分查找的理論知識你應該已經掌握了。咱們來看下開篇的思考題:如何在 1000 萬個整數中快速查找某個整數?
這個問題並不難。咱們的內存限制是 100MB,每一個數據大小是 8 字節,最簡單的辦法就是將數據存儲在數組中,內存佔用差很少是 80MB,符合內存的限制。藉助今天講的內容,咱們能夠先對這 1000 萬數據從小到大排序,而後再利用二分查找算法,就能夠快速地查找想要的數據了。
看起來這個問題並不難,很輕鬆就能解決。實際上,它暗藏了「玄機」。若是你對數據結構和算法有必定了解,知道散列表、二叉樹這些支持快速查找的動態數據結構。你可能會以爲,用散列表和二叉樹也能夠解決這個問題。其實是不行的。
雖然大部分狀況下,用二分查找能夠解決的問題,用散列表、二叉樹均可以解決。可是,咱們後面會講,不論是散列表仍是二叉樹,都會須要比較多的額外的內存空間。若是用散列表或者二叉樹來存儲這 1000 萬的數據,用 100MB 的內存確定是存不下的。而二分查找底層依賴的是數組,除了數據自己以外,不須要額外存儲其餘信息,是最省內存空間的存儲方式,因此恰好能在限定的內存大小下解決這個問題。
今天咱們學習了一種針對有序數據的高效查找算法,二分查找,它的時間複雜度是O(logn)。
二分查找的核心思想理解起來很是簡單,有點相似分治思想。即每次都經過跟區間中的中間元素對比,將待查找的區間縮小爲一半,直到找到要查找的元素,或者區間被縮小爲 0。可是二分查找的代碼實現比較容易寫錯。你須要着重掌握它的三個容易出錯的地方:循環退出條件、mid 的取值,low 和 high 的更新。
二分查找雖然性能比較優秀,但應用場景也比較有限。底層必須依賴數組,而且還要求數據是有序的。對於較小規模的數據查找,咱們直接使用順序遍歷就能夠了,二分查找的優點並不明顯。二分查找更適合處理靜態數據,也就是沒有頻繁的數據插入、刪除操做。