二分查找做爲程序員的一項基本技能,是面試官最常使用來考察程序員基本素質的算法之一,也是解決不少查找類題目的經常使用方法,它能夠達到O(log n)的時間複雜度。 java
通常而言,當一個題目出現如下特性時,你就應該當即聯想到它可能須要使用二分查找:git
二分查找有不少種變體,使用時須要注意查找條件,判斷條件和左右邊界的更新方式,三者配合很差就很容易出現死循環或者遺漏區域,本篇中咱們將介紹常見的幾種查找方式的模板代碼,包括:程序員
本文的內容來自於筆者我的的總結,事實上二分查找有不少種等價的寫法,本文只是列出了筆者認爲的最容易理解和記憶的方法。面試
首先給出標準二分查找的模板:算法
class BinarySearch { public int search(int[] nums, int target) { int left = 0; int right = nums.length - 1; while (left <= right) { int mid = left + ((right - left) >> 1); if (nums[mid] == target) return mid; else if (nums[mid] > target) { right = mid - 1; } else { left = mid + 1; } } return -1; } }
left <= right
mid = left + ((right -left) >> 1)
left = mid + 1
right = mid - 1
mid / -1
這裏有幾點須要注意:數組
left == right
的狀況,則咱們必須在每次循環中改變 left
和 right
的指向,以防止進入死循環循環終止的條件包括:ide
left > right
(這種狀況發生於當left, mid, right指向同一個數時,這個數還不是目標值,則整個查找結束。)left + ((right -left) >> 1)
其實和 (left + right) / 2
是等價的,這樣寫的目的一個是爲了防止 (left + right)
出現溢出,一個是用右移操做替代除法提高性能。left + ((right -left) >> 1)
對於目標區域長度爲奇數而言,是處於正中間的,對於長度爲偶數而言,是中間偏左的。所以左右邊界相遇時,只會是如下兩種狀況:性能
left/mid
, right
(left, mid 指向同一個數,right指向它的下一個數)left/mid/right
(left, mid, right 指向同一個數)即由於mid
對於長度爲偶數的區間老是偏左的,因此當區間長度小於等於2時,mid
老是和 left
在同一側。code
We are playing the Guess Game. The game is as follows:I pick a number from 1 to n. You have to guess which number I picked.ip
Every time you guess wrong, I'll tell you whether the number is higher or lower.
You call a pre-defined API guess(int num) which returns 3 possible results (-1, 1, or 0):
-1 : My number is lower
1 : My number is higher
0 : Congrats! You got it!
Example :Input: n = 10, pick = 6
Output: 6
這題基本是能夠直接照搬二分查找的,出題者沒有作任何包裝,咱們直接使用標準二分查找:
public class Solution extends GuessGame { public int guessNumber(int n) { int left = 1; int right = n; while (left <= right) { int mid = left + ((right - left) >> 1); if (guess(mid) == 0) { return mid; } else if (guess(mid) == -1) { right = mid - 1; } else { left = mid + 1; } } return -1; } }
Implement int sqrt(int x).
Compute and return the square root of x, where x is guaranteed to be a non-negative integer.
Since the return type is an integer, the decimal digits are truncated and only the integer part of the result is returned.
這一題實際上是二分查找的應用,乍一看好像和二分查找沒有關係,可是咱們能夠用二分查找的思想快速定位到目標值的平方根,屬於二分查找的一個簡單運用:
class Solution { public int mySqrt(int x) { if (x <= 1) return x; int left = 1; int right = x - 1; while (left <= right) { int mid = left + ((right - left) >> 1); if (mid > x / mid) { right = mid - 1; } else if (mid < x / mid) { if (mid + 1 > x / (mid + 1)) return mid; left = mid + 1; } else { return mid; } } return -1; // only for return a value } }
雖然是簡單的題目,可是仍是要注意對溢出的處理,例如咱們使用 mid > x / mid
而不是 mid * mide > x
做爲判斷條件,由於後者可能會致使溢出,這與咱們使用 left + ((right - left) >> 1)
而不是 (left + right) / 2
做爲mid
的值是一個道理,這是由於 left + right
也可能溢出。
利用二分法尋找左邊界是二分查找的一個變體,應用它的題目經常有如下幾種特性之一:
類型1包括了上面說的第一種,第二種狀況。
既然要尋找左邊界,搜索範圍就須要從右邊開始,不斷往左邊收縮,也就是說即便咱們找到了nums[mid] == target
, 這個mid
的位置也不必定就是最左側的那個邊界,咱們仍是要向左側查找,因此咱們在nums[mid]
偏大或者nums[mid]
就等於目標值的時候,繼續收縮右邊界,算法模板以下:
class Solution { public int search(int[] nums, int target) { int left = 0; int right = nums.length - 1; while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { left = mid + 1; } else { right = mid; } } return nums[left] == target ? left : -1; } }
left < right
mid = left + ((right -left) >> 1)
left = mid + 1
right = mid
nums[left] == target ? left : -1
與標準的二分查找不一樣:
首先,這裏的右邊界的更新是right = mid
,由於咱們須要在找到目標值後,繼續向左尋找左邊界。
其次,這裏的循環條件是left < right
。
由於在最後left
與right
相鄰的時候,mid
和left
處於相同的位置(前面說過,mid
偏左),則下一步,不管怎樣,left
, mid
, right
都將指向同一個位置,若是此時循環的條件是left <= right
,則咱們須要再進入一遍循環,此時,若是nums[mid] < target
還好說,循環正常終止;不然,咱們會令right = mid
,這樣並無改變left
,mid
,right
的位置,將進入死循環。
事實上,咱們只須要遍歷到left
和right
相鄰的狀況就好了,由於這一輪循環後,不管怎樣,left
,mid
,right
都會指向同一個位置,而若是這個位置的值等於目標值,則它就必定是最左側的目標值;若是不等於目標值,則說明沒有找到目標值,這也就是爲何返回值是nums[left] == target ? left : -1
。
左邊界查找的第二種類型用於數組部分有序且包含重複元素的狀況,這種條件下在咱們向左收縮的時候,不能簡單的令 right = mid
,由於有重複元素的存在,這會致使咱們有可能遺漏掉一部分區域,此時向左收縮只能採用比較保守的方式,代碼模板以下:
class Solution { public int search(int[] nums, int target) { int left = 0; int right = nums.length - 1; while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid; } else { right--; } } return nums[left] == target ? left : -1; } }
它與類型1的惟一區別就在於對右側值的收縮更加保守。這種收縮方式能夠有效地防止咱們一會兒跳過了目標邊界從而致使了搜索區域的遺漏。
關於這種類型的例子,能夠看下面的實戰5。
這道題的題目比較長,原題就不貼了,大意就是說:有這麼一個數組:
[false, false, false, ..., fasle, true, true, ..., true]
求最左側的那個true
的位置。
這就是一個典型的查找左邊界的問題:數組中包含重複元素,咱們須要找到最左側邊界的位置。直接使用二分查找左邊界的模板就好了:
public class Solution extends VersionControl { public int firstBadVersion(int n) { int left = 0; int right = n - 1; while (left < right) { int mid = left + ((right - left) >> 1); if (!isBadVersion(mid + 1)) { left = mid + 1; } else { right = mid; } } return isBadVersion(left + 1) ? left + 1 : -1; } }
與之相似的例子還有:LeetCode 744 等,都是Easy級別的題目,簡單的使用二分查找左邊界的模板就好了,你們能夠自行練習。
固然,除了這種顯而易見的題目,對於一些變體,咱們也應該要有能力去分辨,好比說這一題:LeetCode 658 。
Suppose an array sorted in ascending order is rotated at some pivot unknown to you beforehand.(i.e., [0,1,2,4,5,6,7] might become [4,5,6,7,0,1,2]).
Find the minimum element.
You may assume no duplicate exists in the array.
這一題看上去沒有重複元素,可是它也是查找左邊界的一種形式,便可以看作是查找旋轉到右側的部分的左邊界,有了這個思想,直接用二分查找左邊界的模板就好了:
class Solution { public int findMin(int[] nums) { if (nums.length == 1) return nums[0]; int left = 0; int right = nums.length - 1; while (left < right) { int mid = left + ((right - left) >> 1); if (nums[mid] > nums[nums.length - 1]) { left = mid + 1; } else { right = mid; } } return nums[left]; } }
這道題目和上面的實戰2相似,只是多了一個條件——數組中可能包含重複元素,這就是咱們以前說的二分查找左邊界的第二種類型,在這種狀況下,咱們只能採用保守收縮的方式,以規避重複元素帶來的對於單調性的破壞:
class Solution { public int findMin(int[] nums) { if (nums.length == 1) return nums[0]; int left = 0; int right = nums.length - 1; while (left < right) { int mid = left + ((right - left) >> 1); if (nums[mid] > nums[right]) { // mid 位於旋轉點左側 left = mid + 1; } else if (nums[mid] < nums[right]) { // mid 位於旋轉點右側 right = mid; } else { // 注意相等的時候的特殊處理,由於要向左查找左邊界,因此直接收縮右邊界 right--; } } return nums[left]; } }
有了尋找左邊界的分析以後,再來看尋找右邊界就容易不少了,畢竟左右兩種狀況是對稱的嘛,關於使用場景這裏就再也不贅述了,你們對稱着理解就好。咱們直接給出模板代碼:
class Solution { public int search(int[] nums, int target) { int left = 0; int right = nums.length - 1; while (left < right) { int mid = left + ((right - left) >> 1) + 1; if (nums[mid] > target) { right = mid - 1; } else { left = mid; } } return nums[right] == target ? right : -1; } }
left < right
mid = left + ((right -left) >> 1) + 1
left = mid
right = mid - 1
nums[right] == target ? right : -1
這裏大部分和尋找左邊界是對稱着來寫的,惟獨有一點須要尤爲注意——中間位置的計算變了,咱們在末尾多加了1。這樣,不管對於奇數仍是偶數,這個中間的位置都是偏右的。
對於這個操做的理解,從對稱的角度看,尋找左邊界的時候,中間位置是偏左的,那尋找右邊界的時候,中間位置就應該偏右唄,可是這顯然不是根本緣由。根本緣由是,在最後left
和right
相鄰時,若是mid
偏左,則left
, mid
指向同一個位置,right
指向它們的下一個位置,在nums[left]
已經等於目標值的狀況下,這三個位置的值都不會更新,從而進入了死循環。因此咱們應該讓mid
偏右,這樣left
就能向右移動。這也就是爲何咱們以前一直強調查找條件,判斷條件和左右邊界的更新方式三者之間須要配合使用。
右邊界的查找通常來講不會單獨使用,若有須要,通常是須要同時查找左右邊界。
前面咱們介紹了左邊界和右邊界的查找,那麼查找左右邊界就容易不少了——只要分別查找左邊界和右邊界就好了。
Given an array of integers nums sorted in ascending order, find the starting and ending position of a given target value.
Your algorithm's runtime complexity must be in the order of O(log n).
If the target is not found in the array, return [-1, -1].
這是一道特別標準的查找左右邊界的題目,咱們只須要分別查找左邊界和右邊界就好了:
class Solution { public int[] searchRange(int[] nums, int target) { int[] res = new int[]{-1, -1}; if(nums == null || nums.length == 0) return res; // find the left-end int left = 0; int right = nums.length - 1; while (left < right) { int mid = left + ((right - left) >> 1); if (nums[mid] < target) { left = mid + 1; } else { right = mid; } } res[0] = nums[left] == target ? left : -1; // find right-end if (res[0] != -1) { if (left == nums.length - 1 || nums[left + 1] != target) { res[1] = left; } else { right = nums.length - 1; while (left < right) { int mid = left + ((right - left) >> 1) + 1; if (nums[mid] > target) { right = mid - 1; } else { left = mid; } } res[1] = right; } } return res; } }
二分查找還有一種有趣的變體是二分查找極值點,以前咱們使用nums[mid]
去比較的時候,經常是和給定的目標值target
比,或者和左右邊界比較,在二分查找極值點的應用中,咱們是和相鄰元素去比,以完成某種單調性的檢測。關於這一點,咱們直接來看一個例子就明白了。
A peak element is an element that is greater than its neighbors.
Given an input array nums, where nums[i] ≠ nums[i+1], find a peak element and return its index.
The array may contain multiple peaks, in that case return the index to any one of the peaks is fine.
You may imagine that nums[-1] = nums[n] = -∞.
這一題的有趣之處在於他要求求一個局部極大值點,而且整個數組不包含重複元素。因此整個數組甚至能夠是無序的——你可能很難想象咱們能夠在一個無序的數組中直接使用二分查找,可是沒錯!咱們確實能夠這麼幹!誰要人家只要一個局部極大值便可呢。
class Solution { public int findPeakElement(int[] nums) { int left = 0; int right = nums.length - 1; while (left < right) { int mid = left + ((right - left) >> 1); if (nums[mid] < nums[mid + 1]) { left = mid + 1; } else { right = mid; } } return left; } }
這裏尤爲注意咱們的判斷條件nums[mid] < nums[mid + 1]
,這其實是在判斷處於mid
處的相鄰元素的單調性。
除了本文所介紹的二分查找的應用方式,二分查找其實還有不少其餘的變體和應用,但它們基本上是循環條件,判斷條件,邊界更新方法的不一樣組合,例如,有的二分查找的循環條件能夠是 while(left + 1 < right)
,有的邊界的更新的條件須要依賴 nums[left]
, nums[mid]
, nums[mid+1]
, nums[right]
四個值的相互關係。
可是不管如何,代碼模板只是給你們一個理解問題的角度,生搬硬套老是很差的。實際應用中,咱們只要記住循環條件,判斷條件與邊界更新方法三者之間的配套使用就好了,基於這一點原則,你就可使用你本身習慣的方式來實現二分搜索。
可是,若是你真的只是想應付面試,我想下面這個表的總結應該就差很少足夠用了:
查找方式 | 循環條件 | 左側更新 | 右側更新 | 中間點位置 | 返回值 |
---|---|---|---|---|---|
標準二分查找 | left <= right |
left = mid - 1 |
right = mid + 1 |
(left + right) / 2 |
-1 / mid |
二分找左邊界 | left < right |
left = mid - 1 |
right = mid |
(left + right) / 2 |
-1 / left |
二分找右邊界 | left < right |
left = mid |
right = mid - 1 |
(left + right) / 2 + 1 |
-1 / right |
最後,但願你們在理解二分查找的思想後都可以寫出適合本身的搭配方式,共勉!
(完)