算法總結(jié)之二分法模板

前言

二分查找算法作為一種常見的查找方法戏蔑,將原本是線性時(shí)間提升到了對(duì)數(shù)時(shí)間范圍,大大縮短了搜索時(shí)間年鸳,具有很大的應(yīng)用場(chǎng)景省撑,而在 LeetCode 中赌蔑,要運(yùn)用二分搜索法來解的題目也有很多,但是實(shí)際上二分查找法的查找目標(biāo)有很多種竟秫,而且在細(xì)節(jié)寫法也有一些變化娃惯。

問題形式:

二分查找算法充分利用了元素間的次序關(guān)系,采用分治策略鸿摇,可在最壞的情況下用 O(logN) 完成搜索任務(wù)石景。
題目問法大致有這幾種

  • 查找和目標(biāo)值完全相等的數(shù)
  • 查找區(qū)間的某個(gè)邊界值
  • 利用子函數(shù)對(duì)中間值進(jìn)行判斷劈猿,確定查找的方向
  • 利用二分思想查找函數(shù)的極大值

解題思路與模板

根據(jù)前面的描述可以看出拙吉,這類問題的核心就是在于確定三要素:搜索區(qū)間,終止條件和折半方向揪荣。

標(biāo)準(zhǔn)二分查找

/** 元素按升序排列筷黔,下同 */
int binarySearch(int[] arr, int target) {
    int left = 0;
    int right = arr.length - 1;
    while (left <= right) {
        int mid = (left + right) >>> 1; // 無符號(hào)右移,即使前面溢出也能得到正確結(jié)果
        if (arr[mid] == target) {
            return mid;
        } else if (arr[mid] < target) {
            left = mid + 1; // 當(dāng)前元素比目標(biāo)值小仗颈,則收縮左邊界佛舱,查找右半邊
        } else if (arr[mid] > target) {
            right = mid - 1; // 當(dāng)前元素比目標(biāo)值小,則收縮右邊界挨决,查找左半邊
        }           
    }
    return -1; // 未找到
}

Q:為什么 while 循環(huán)的終止條件是 left <= right 请祖?而我看到有的代碼里是 left < right ?
A:兩者都可以使用,但它們代表不同的搜索區(qū)間脖祈,需要配合不同的右側(cè)邊界肆捕。帶個(gè)例子來看,假如我們?cè)?[ 1 , 3 , 5 , 7 , 9 ] [1,3,5,7,9] [1,3,5,7,9] 中搜索元素 3 3 3 的索引位置盖高。我們?cè)诖a里加一些輸出語句來看看吧慎陵。

循環(huán)條件是 left <= right 時(shí)
第 1 次循環(huán)開始:left = 0, right = 4, mid = 2
第 1 次循環(huán)結(jié)束:left = 0, right = 1, mid = 2
第 2 次循環(huán)開始:left = 0, right = 1, mid = 0
第 2 次循環(huán)結(jié)束:left = 1, right = 1, mid = 0
第 3 次循環(huán)開始:left = 1, right = 1, mid = 1
找到了 target = 3 ,索引是 1

結(jié)果正確喻奥。下面我們把 <= 改為 <席纽,其他條件不變

循環(huán)條件是 left < right 時(shí)
第 1 次循環(huán)開始:left = 0, right = 4, mid = 2
第 1 次循環(huán)結(jié)束:left = 0, right = 1, mid = 2
第 2 次循環(huán)開始:left = 0, right = 1, mid = 0
第 2 次循環(huán)結(jié)束:left = 1, right = 1, mid = 0
未找到 target = 3

出現(xiàn)了錯(cuò)誤!我們注意到在第二次循環(huán)結(jié)束時(shí)已經(jīng)有 l e f t = r i g h t 撞蚕,不滿足第三次循環(huán)開始條件了润梯。導(dǎo)致了 m i d mid mid 遺漏了索引 1 的位置。如果我們一定要用left<right 作為循環(huán)條件呢?可以通過修改右側(cè)邊界的方式來實(shí)現(xiàn)纺铭,看下面的代碼抒和。

/** 使用 while (left < right) 時(shí)的正確代碼,注意 right 的取值 */
int binarySearch(int[] arr, int target) {
    int left = 0;
    int right = arr.length; // 注意這里
    while (left < right) { // 改用 "<"
        int mid = (left + right) >>> 1;
        if (arr[mid] == target) {
            return mid;
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else if (arr[mid] > target) {
            right = mid; // 注意這里
        }           
    }
    return -1;
}

這次我們看到了正確的結(jié)果彤蔽。

循環(huán)條件是 left < right 時(shí)
第 1 次循環(huán)開始:left = 0, right = 5, mid = 2
第 1 次循環(huán)結(jié)束:left = 0, right = 2, mid = 2
第 2 次循環(huán)開始:left = 0, right = 2, mid = 1
找到了 target = 3 摧莽,索引是 1

通過上面的比較不難發(fā)現(xiàn),當(dāng)使用 l e f t ≤ r i g h t 時(shí)顿痪,相當(dāng)于是在閉區(qū)間 [ l e f t , r i g h t ]進(jìn)行搜索镊辕;而當(dāng)使用left<right 時(shí),相當(dāng)于是在左閉右開區(qū)間[left,right) 進(jìn)行搜索蚁袭。對(duì)于以上兩種形式征懈,如果你已經(jīng)理解了邊界的概念,記住它們自然不在話下揩悄。

Q:標(biāo)準(zhǔn)算法有什么局限性卖哎?
A:至此,你應(yīng)該已經(jīng)掌握了該算法的所有細(xì)節(jié)删性,以及這樣處理的原因亏娜。但是,這個(gè)算法存在局限性蹬挺。比如說給你數(shù)組 [ 1 , 3 , 3 , 3 , 4 ] [1,3,3,3,4] [1,3,3,3,4]维贺,需要搜索元素 3 3 3 的索引,此算法返回的索引是 2巴帮,沒錯(cuò)溯泣。但是如果我想得到 target 的左側(cè)邊界,即索引 1榕茧,或者我想得到 target 的右側(cè)邊界垃沦,即索引 3,這樣的話此算法是無法處理的用押。然而這樣的需求很常見肢簿。你也許會(huì)說,找到一個(gè) target 索引只恨,然后向左或向右線性搜索不行嗎译仗?可以但是不好,假如有大量的重復(fù)元素官觅,那樣就難以保證二分查找對(duì)數(shù)級(jí)的復(fù)雜度了纵菌。下面我們就來討論邊界的二分查找算法。


模板2:尋找第一個(gè)不小于目標(biāo)值的位置

我們注意到在上面的例子中休涤,要找到元素 3 3 3 的左側(cè)邊界咱圆,相當(dāng)于是要找到第一個(gè)不小于元素 3 3 3 的位置笛辟,我們只需要修改兩處代碼就能實(shí)現(xiàn)。

int lowerBound(int[] arr, int target) {
    int left = 0;
    int right = arr.length;
    while (left < right) {
        int mid = (left + right) >>> 1;
        if (arr[mid] == target) {
            right = mid; // 注意這里
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else if (arr[mid] > target) {
            right = mid;
        }           
    }
    return right; // 注意這里序苏,也可以寫為left, 循環(huán)終止時(shí)它們是相等的
}

Q:為什么這樣能找到左邊界手幢?
A:關(guān)鍵在于對(duì)找到target 后這種情況的處理,我們并沒有直接返回索引值忱详,而是繼續(xù)收縮右邊界令 r i g h t = m i d 围来,最終達(dá)到鎖定左邊界的目的。

Q:為什么沒有返回 -1 的操作匈睁?如果數(shù)組不存在這樣的 target 該怎么辦监透?
A:我們繼續(xù)通過打印輸出結(jié)果來看,假如我們?cè)?[ 0 , 1 , 3 , 3 , 3 , 7 , 8 ] [0,1,3,3,3,7,8] [0,1,3,3,3,7,8] 中搜索元素 3 3 3

開始查找航唆,target = 3
第 1 次循環(huán)開始:left = 0, right = 7, mid = 3
第 1 次循環(huán)結(jié)束:left = 0, right = 3, mid = 3
第 2 次循環(huán)開始:left = 0, right = 3, mid = 1
第 2 次循環(huán)結(jié)束:left = 2, right = 3, mid = 1
第 3 次循環(huán)開始:left = 2, right = 3, mid = 2
第 3 次循環(huán)結(jié)束:left = 2, right = 2, mid = 2
結(jié)束查找胀蛮,索引是 2

結(jié)果是索引 2,也就是第一個(gè) 3 3 3 的位置糯钙,正確粪狼。然后我們來看下搜索元素 4 4 4

開始查找,target = 4
第 1 次循環(huán)開始:left = 0, right = 7, mid = 3
第 1 次循環(huán)結(jié)束:left = 4, right = 7, mid = 3
第 2 次循環(huán)開始:left = 4, right = 7, mid = 5
第 2 次循環(huán)結(jié)束:left = 4, right = 5, mid = 5
第 3 次循環(huán)開始:left = 4, right = 5, mid = 4
第 3 次循環(huán)結(jié)束:left = 5, right = 5, mid = 4
結(jié)束查找任岸,索引是 5

結(jié)果是索引 5再榄,也就是元素 7 7 7 的位置。不難看出我們找到了不小于目標(biāo)值的第一個(gè)位置演闭。實(shí)際上在 C++ 標(biāo)準(zhǔn)庫中有專門的函數(shù) lower_bound不跟,替我們實(shí)現(xiàn)了同樣的功能。此外米碰,該類問題還可以變形為尋找最后一個(gè)小于目標(biāo)值的位置。我們只需要在返回結(jié)果那里繼續(xù)左移一步购城,改為 return right - 1 就可以了吕座。


模板3:尋找第一個(gè)大于目標(biāo)值的位置

有了上面的分析,那么這里就很容易理解了瘪板。對(duì)于這種情況吴趴,我們需要不斷收縮左側(cè)邊界,來看代碼侮攀。

int upperBound(int[] arr, int target) {
    int left = 0;
    int right = arr.length;
    while (left < right) {
        int mid = (left + right) >>> 1;
        if (arr[mid] == target) {
            left = mid + 1; // 注意這里
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else if (arr[mid] > target) {
            right = mid;
        }           
    }
    return right; // 注意這里锣枝,也可以寫為left, 循環(huán)終止時(shí)它們是相等的
}

再來看下輸出結(jié)果吧,我們?nèi)匀辉?[ 0 , 1 , 3 , 3 , 3 , 7 , 8 ] [0,1,3,3,3,7,8] [0,1,3,3,3,7,8] 中搜索元素 3 3 3

開始查找兰英, target = 3
第 1 次循環(huán)開始:left = 0, right = 7, mid = 3
第 1 次循環(huán)結(jié)束:left = 4, right = 7, mid = 3
第 2 次循環(huán)開始:left = 4, right = 7, mid = 5
第 2 次循環(huán)結(jié)束:left = 4, right = 5, mid = 5
第 3 次循環(huán)開始:left = 4, right = 5, mid = 4
第 3 次循環(huán)結(jié)束:left = 5, right = 5, mid = 4
找到了撇叁,索引是 5

找到了第一個(gè)大于 3 3 3 的元素 7 7 7 了!同樣在 C++ 標(biāo)準(zhǔn)庫中有專門的函數(shù) upper_bound 實(shí)現(xiàn)了該功能畦贸。然后你可能會(huì)問陨闹,那我們要找最后一個(gè) 3 3 3 的位置(即右邊界)該怎么辦楞捂?非常簡單,只需要在返回值那里左移一步就行了趋厉,改為 return right - 1寨闹。


具體問題

搜索二維矩陣

編寫一個(gè)高效的算法來判斷 m x n 矩陣中,是否存在一個(gè)目標(biāo)值君账。該矩陣具有如下特性:

  • 每行中的整數(shù)從左到右按升序排列繁堡。
  • 每行的第一個(gè)整數(shù)大于前一行的最后一個(gè)整數(shù)。

示例

輸入:
matrix = [
  [1,   3,  5,  7],
  [10, 11, 16, 20],
  [23, 30, 34, 50]
]
target = 3
輸出::true

解題思路
注意題目中的關(guān)鍵字高效乡数,升序帖蔓,并且每行第一個(gè)整數(shù)大于前一行最后一個(gè)整數(shù)。那么假設(shè)我們把矩陣逐行排列拉成一條線瞳脓,不難看出其實(shí)它就變成了一個(gè)升序的數(shù)組塑娇。題目也就變成了在升序數(shù)組中找一個(gè)目標(biāo)值,說明就能愉快地二分查找了劫侧。我們可以通過一些簡單的坐標(biāo)計(jì)算來把矩陣模擬成一個(gè)數(shù)組埋酬。

public boolean searchMatrix(int[][] matrix, int target) {
    if (matrix == null || matrix.length == 0 || matrix[0].length == 0) return false;
    int m = matrix.length, n = matrix[0].length;
    // 根據(jù)題意不難發(fā)現(xiàn)矩陣內(nèi)第一個(gè)元素一定最小,最后一個(gè)元素一定最大烧栋,如果 target 不在范圍內(nèi)直接返回 false
    if (target < matrix[0][0] || target > matrix[m - 1][n - 1]) return false; 
    int left = 0, right = m * n;
    while (left < right) {
        int mid = (left + right) >>> 1;
        // 根據(jù)總元素的個(gè)數(shù)計(jì)算矩陣坐標(biāo)
        if (matrix[mid / n][mid % n] == target) {
            return true;
        } else if (matrix[mid / n][mid % n] < target) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    return false;
}

找到 K 個(gè)最接近的元素

給定一個(gè)排序好的數(shù)組写妥,兩個(gè)整數(shù) k 和 x,從數(shù)組中找到最靠近 x(兩數(shù)之差最猩笮铡)的 k 個(gè)數(shù)珍特。返回的結(jié)果必須要是按升序排好的。如果有兩個(gè)數(shù)與 x 的差值一樣魔吐,優(yōu)先選擇數(shù)值較小的那個(gè)數(shù)扎筒。

示例

輸入:[1,2,3,4,5], k=4, x=3
輸出:[1,2,3,4]

解題思路

題目中說到有序數(shù)組,并且是連續(xù)區(qū)間酬姆。區(qū)間長度是固定的嗜桌,并且 k k k 的值為正數(shù),且總是小于給定排序數(shù)組的長度 s i z e size size辞色。因此骨宠,只要我們找到了左邊界的索引,從左邊界開始取 k k k 個(gè)數(shù)相满,就找到了結(jié)果层亿。那么問題就變成了尋找最優(yōu)區(qū)間的左邊界。
首先我們討論左區(qū)間的取值范圍立美,使用具體的例子匿又,就很很清楚地找到規(guī)律:

  • 假設(shè)一共有 5 個(gè)數(shù), [ 0 , 1 , 2 , 3 , 4 ] [0,1,2,3,4] [0,1,2,3,4]悯辙,找 3 個(gè)數(shù)琳省,左邊界最多到元素 2 2 2迎吵。
  • 假設(shè)一共有 8 個(gè)數(shù), [ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 ] [0,1,2,3,4,5,6,7] [0,1,2,3,4,5,6,7]针贬,找 5 個(gè)數(shù)击费,左邊界最多到元素 3 3 3。

因此桦他,最優(yōu)區(qū)間的左邊界的索引的搜索區(qū)間為 [ 0 , s i z e ? k ] [0, size - k] [0,size?k]蔫巩,這也就是我們二分查找的范圍快压。然后每次取中間值 m i d mid mid 作為左邊界,則待選區(qū)間為 [ m i d , m i d + k ] [mid, mid + k] [mid,mid+k]蔫劣。 這里的二分查找方向比較難想,是通過比較區(qū)間兩端和 x x x 的距離來確定的脉幢,所有情況如下圖歪沃。

  • 如果 x x x 離 m i d mid mid 端更近,則說明我們可以進(jìn)一步收縮右邊界沪曙,即 r i g h t = m i d right = mid right=mid萎羔。特別地液走,題目中指出兩個(gè)值和 x x x 的差值一樣時(shí)取較小值(也就是 x x x 恰好位于區(qū)間中間)。那么這種情況下也需要收縮右邊界贾陷。
  • 如果 x x x 離 m i d + k mid+k mid+k 端更近缘眶,則說明我們可以進(jìn)一步收縮左邊界昵宇,即 l e f t = m i d + 1 left = mid + 1 left=mid+1
public List<Integer> findClosestElements(int[] arr, int k, int x) {
    int left = 0, right = arr.length - k;
    while (left < right) {
        int mid = (left + right) >>> 1;
        if (x - arr[mid] > arr[mid + k] - x) { // 通過比較區(qū)間端點(diǎn)和 x 的距離確定二分方向
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    List<Integer> list = new ArrayList<>();
    for (int i = left; i < left + k; i++) {
        list.add(arr[i]);
    }
    return list;
}

其它二分法的練習(xí)題:

  1. 尋找兩個(gè)有序數(shù)組的中位數(shù)
  2. 搜索旋轉(zhuǎn)排序數(shù)組
  3. 在排序數(shù)組中查找元素的第一個(gè)和最后一個(gè)位置
  4. Pow(x, n)
  5. x 的平方根
  6. 尋找旋轉(zhuǎn)排序數(shù)組中的最小值
  7. 尋找旋轉(zhuǎn)排序數(shù)組中的最小值 II
  8. 尋找峰值
  9. 第一個(gè)錯(cuò)誤的版本
  10. 尋找重復(fù)數(shù)
  11. 長度最小的子數(shù)組
  12. 兩個(gè)數(shù)組的交集
  13. 兩個(gè)數(shù)組的交集 II
  14. 有效的完全平方數(shù)
  15. 猜數(shù)字大小
  16. 分割數(shù)組的最大值
  17. 四數(shù)相加 II
  18. 找到 K 個(gè)最接近的元素
  19. 二分查找
  20. 找出第 k 小的距離對(duì)
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末瓦哎,一起剝皮案震驚了整個(gè)濱河市柔逼,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌愉适,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,273評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件剂买,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡瞬哼,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,349評(píng)論 3 398
  • 文/潘曉璐 我一進(jìn)店門较性,熙熙樓的掌柜王于貴愁眉苦臉地迎上來结胀,“玉大人,你說我怎么就攤上這事攀操〗崭В” “怎么了?”我有些...
    開封第一講書人閱讀 167,709評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵耸别,是天一觀的道長秀姐。 經(jīng)常有香客問我,道長省有,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,520評(píng)論 1 296
  • 正文 為了忘掉前任伸头,我火速辦了婚禮舷蟀,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘扫步。我一直安慰自己匈子,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,515評(píng)論 6 397
  • 文/花漫 我一把揭開白布游岳。 她就那樣靜靜地躺著,像睡著了一般胚迫。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上摩骨,一...
    開封第一講書人閱讀 52,158評(píng)論 1 308
  • 那天朗若,我揣著相機(jī)與錄音,去河邊找鬼灾馒。 笑死遣总,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的旭斥。 我是一名探鬼主播,決...
    沈念sama閱讀 40,755評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼花盐,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼菇爪!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起熙揍,我...
    開封第一講書人閱讀 39,660評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤氏涩,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后奖亚,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體析砸,經(jīng)...
    沈念sama閱讀 46,203評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡爆袍,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,287評(píng)論 3 340
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了弦疮。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,427評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡咏尝,死狀恐怖啸罢,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情扰才,我是刑警寧澤,帶...
    沈念sama閱讀 36,122評(píng)論 5 349
  • 正文 年R本政府宣布蕾总,位于F島的核電站琅捏,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏蚀浆。R本人自食惡果不足惜拦焚,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,801評(píng)論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望秕衙。 院中可真熱鬧僵刮,春花似錦据忘、人聲如沸搞糕。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,272評(píng)論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至晶伦,卻和暖如春啄枕,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背频祝。 一陣腳步聲響...
    開封第一講書人閱讀 33,393評(píng)論 1 272
  • 我被黑心中介騙來泰國打工常空, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留沽一,地道東北人窟绷。 一個(gè)月前我還...
    沈念sama閱讀 48,808評(píng)論 3 376
  • 正文 我出身青樓兼蜈,卻偏偏與公主長得像,于是被迫代替她去往敵國和親为狸。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,440評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容