有了這套模板,女友不再用擔憂我刷不動 LeetCode 了

全文包含 12000+ 字、30 張高清圖片,預計閱讀時間爲 40 分鐘,強烈建議先收藏再仔細閱讀。python

做者 | 李威程序員

整理 | 公衆號:五分鐘學算法算法

我的博客 | www.cxyxiaowu.com編程

來源 | www.liwei.party/數組


下面的動畫以 「力扣」第 704 題:二分查找 爲例,展現了使用這個模板編寫二分查找法的通常流程。安全

binary-search-template-new.gif

如下「演示文稿」展現了本文所要講解的主要內容,您能夠只看這部分的內容,若是您還想看得更仔細一點,能夠查看「演示文稿」以後的原文。bash

《十分好用的二分查找法模板》演示文稿

binary-search-template-1.png
binary-search-template-2.png
binary-search-template-3.png
binary-search-template-4.png
binary-search-template-5.png
binary-search-template-6.png
binary-search-template-7.png
binary-search-template-8.png
binary-search-template-9.png
binary-search-template-10.png
binary-search-template-11.png
binary-search-template-12.png
binary-search-template-13.png

(上面的「演示文稿」是對如下文字的歸納。)ide


一、導讀

本文介紹了我這半年以來,在刷題過程當中使用「二分查找法」刷題的一個模板,包括這個模板的優勢、使用技巧、注意事項、調試方法等。函數

雖然說是模板,但我不打算一開始就貼出代碼,由於這個模板根本沒有必要記憶,只要你可以理解文中敘述的知識點和注意事項,並加以應用(刷題),相信你會和我同樣喜歡這個模板,而且認爲使用它是天然而然的事情。測試

這個模板應該可以幫助你解決 LeetCode 帶「二分查找」標籤的常見問題(簡單、中等難度)。

只要你可以理解文中敘述的知識點和注意事項,並加以應用(其實就是多刷題),相信你會和我同樣喜歡這個模板,而且認爲使用它是天然而然的事情。

二、歷史上有關「二分查找法」的故事

二分查找法雖然簡單,但寫好它並無那麼容易。咱們能夠看看一些名人關於二分查找法的論述。

Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky ...

譯:「雖然二分查找的基本思想相對簡單,但細節可能會很是棘手」。來自維基百科 Binary_search_algorithm,請原諒本人可能很是不優雅的中文翻譯。

  • 一樣是高德納先生,在其著做《計算機程序設計的藝術 第 3 卷:排序和查找》中指出:

二分查找法的思想在 1946 年就被提出來了。可是第 1 個沒有 Bug 的二分查找法在 1962 年纔出現。

(因時間和我的能力的關係,我沒有辦法提供英文原文,若是能找到英文原文的朋友歡迎提供一下出處,在此先謝過。)

聽說這個 Bug 在 Java 的 JDK 中都隱藏了將近 10 年之後,才被人們發現並修復。

  • 《編程珠璣》的做者 Jon Bentley:

When Jon Bentley assigned binary search as a problem in a course for professional programmers, he found that ninety percent failed to provide a correct solution after several hours of working on it, mainly because the incorrect implementations failed to run or returned a wrong answer in rare edge cases.

譯:當 JonBentley 把二分查找做爲專業程序員課程中的一個問題時,他發現百分之九十的人在花了幾個小時的時間研究以後,沒有提供正確的解決方案,主要是由於錯誤的實現沒法正確運行(筆者注:可能返回錯誤的結果,或者出現死循環),或者是不能很好地判斷邊界條件。

三、「傳統的」二分查找法模板的問題

(1)取中位數索引的代碼有問題

int mid = (left + right) / 2 
複製代碼

這行代碼是有問題的,在 leftright 都比較大的時候,left + right 頗有可能超過 int 類型能表示的最大值,即整型溢出,爲了不這個問題,應該寫成:

int mid = left + (right - left) / 2 ;
複製代碼

事實上,int mid = left + (right - left) / 2right 很大、 left 是負數且很小的時候, right - left 也有可能超過 int 類型能表示的最大值,只不過通常狀況下 leftright 表示的是數組索引值,left 是非負數,所以 right - left 溢出的可能性很小。

更好的寫法是:

int mid = (left + right) >>> 1 ;
複製代碼

緣由在後文介紹,請讀者留意:

使用「左邊界索引 + 右邊界索引」,而後「無符號右移 1 位」是推薦的寫法。

(2)循環能夠進行的條件寫成 while (left <= right) 時,在退出循環的時候,須要考慮返回 left 仍是 right,稍不注意,就容易出錯

以本題(LeetCode 第 35 題:搜索插入位置)爲例。

分析:根據題意並結合題目給出的 4 個示例,不難分析出這個問題的等價表述以下:

一、若是目標值(嚴格)大於排序數組的最後一個數,返回這個排序數組的長度,不然進入第 2 點。

二、返回排序數組從左到右,大於或者等於目標值的第 1 個數的索引

事實上,當給出數組中有不少數和目標值相等的時候,咱們返回任意一個與之相等的數的索引值均可以,不過爲了簡單起見,也爲了方便後面的說明,咱們返回第 1 個符合題意的數的索引。

題目告訴你「排序數組」,其實就是在瘋狂暗示你用二分查找法。 二分查找法的思想並不難,但寫好一個二分法並不簡單,下面就藉着這道題爲你們作一個總結。

剛接觸二分查找法的時候,咱們可能會像下面這樣寫代碼,我把這種寫法容易出錯的地方寫在了註釋裏:

參考代碼:針對本題(LeetCode 第 35 題)

// 公衆號:五分鐘學算法
public class Solution3 {

    public int searchInsert(int[] nums, int target) {
        int len = nums.length;
        if (nums[len - 1] < target) {
            return len;
        }

        int left = 0;
        int right = len - 1;

        while (left <= right) {
            int mid = (left + right) / 2;
            // 等於的狀況最簡單,咱們應該放在第 1 個分支進行判斷
            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                // 題目要咱們返回大於或者等於目標值的第 1 個數的索引
                // 此時 mid 必定不是所求的左邊界,
                // 此時左邊界更新爲 mid + 1
                left = mid + 1;
            } else {
                // 既然不會等於,此時 nums[mid] > target
                // mid 也必定不是所求的右邊界
                // 此時右邊界更新爲 mid - 1
                right = mid - 1;
            }
        }
        // 注意:必定得返回左邊界 left,
        // 若是返回右邊界 right 提交代碼不會經過
        // 【注意】下面我嘗試說明一下理由,若是你不太理解下面我說的,那是我表達的問題
        // 但我建議你不要糾結這個問題,由於我將要介紹的二分查找法模板,能夠避免對返回 left 和 right 的討論

        // 理由是對於 [1,3,5,6],target = 2,返回大於等於 target 的第 1 個數的索引,此時應該返回 1
        // 在上面的 while (left <= right) 退出循環之後,right < left,right = 0 ,left = 1
        // 根據題意應該返回 left,
        // 若是題目要求你返回小於等於 target 的全部數裏最大的那個索引值,應該返回 right

        return left;
    }
}
複製代碼

說明

一、當把二分查找法的循環能夠進行的條件寫成 while (left <= right) 時,在寫最後一句 return 的時候,若是不假思索,把左邊界 left 返回回去,雖然寫對了,但能夠思考一下爲何不返回右邊界 right 呢?

二、可是事實上,返回 left 是有必定道理的,若是題目換一種問法,你可能就要返回右邊界 right,這句話不太理解沒有關係,我也不打算講得很清楚(在上面代碼的註釋中我已經解釋了緣由),由於實在太繞了,這不是我要說的重點。

由此,我認爲「傳統二分查找法模板」使用的痛點在於:

傳統二分查找法模板,當退出 while 循環的時候,在返回左邊界仍是右邊界這個問題上,比較容易出錯。

那麼,是否是能夠迴避這個問題呢?答案是確定的,答案就在下面我要介紹的「神奇的」二分查找法模板裏。

四、「神奇的」二分查找法模板的基本思想

(1)首先把循環能夠進行的條件寫成 while(left < right),在退出循環的時候,必定有 left == right 成立,此時返回 left 或者 right 均可以

或許你會問:退出循環的時候還有一個數沒有看啊(退出循環以前索引 left 或 索引 right 上的值)? 沒有關係,咱們就等到退出循環之後來看,甚至通過分析,有時都不用看,就能肯定它是目標數值。

(何時須要看最後剩下的那個數,何時不須要,會在第 5 點介紹。)

更深層次的思想是「夾逼法」或者稱爲「排除法」。

(2)「神奇的」二分查找法模板的基本思想(特別重要)

「排除法」即:在每一輪循環中排除一半以上的元素,因而在對數級別的時間複雜度內,就能夠把區間「夾逼」 只剩下 1 個數,而這個數是否是咱們要找的數,單獨作一次判斷就能夠了。

「夾逼法」或者「排除法」是二分查找算法的基本思想,「二分」是手段,在目標元素不肯定的狀況下,「二分」 也是「最大熵原理」告訴咱們的選擇。

仍是 LeetCode 第 35 題,下面給出使用 while (left < right) 模板寫法的 2 段參考代碼,如下代碼的細節部分在後文中會講到,所以一些地方不太明白沒有關係,暫時跳過便可。

參考代碼 1:重點理解爲何候選區間的索引範圍是 [0, size]

public class Solution {

    public int searchInsert(int[] nums, int target) {
        # 返回大於等於 target 的索引,有多是最後一個
        int len = nums.length;

        if (len == 0) {
            return 0;
        }

        int left = 0;
        # 若是 target 比 nums裏全部的數都大,則最後一個數的索引 + 1 就是候選值,所以,右邊界應該是數組的長度
        int right = len;
    	 # 二分的邏輯必定要寫對,不然會出現死循環或者數組下標越界
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return left;
    }
}
複製代碼

參考代碼 2:對因而否接在原有序數組後面單獨判斷,不知足的時候,再在候選區間的索引範圍 [0, size - 1] 內使用二分查找法進行搜索。

public class Solution {

    // 只會把比本身大的覆蓋成小的
    // 二分法
    // 若是有一連串數跟 target 相同,則返回索引最靠前的

    // 特例: 3 5 5 5 5 5 5 5 5 5
    // 特例: 3 6 7 8

    // System.out.println("嘗試過的值:" + mid);
    // 1 2 3 5 5 5 5 5 5 6 ,target = 5
    // 1 2 3 3 5 5 5 6 target = 4


    public int searchInsert(int[] nums, int target) {
        int len = nums.length;
        if (len == 0) {
            return -1;
        }
        if (nums[len - 1] < target) {
            return len;
        }
        int left = 0;
        int right = len - 1;
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
                // nums[mid] 的值能夠捨棄
                left = mid + 1;
            } else {
                // nums[mid] 不能捨棄
                right = mid;
            }
        }
        return right;
    }

    public static void main(String[] args) {
        int[] nums = {1, 2, 3, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6};
        int target = 4;
        Solution2 solution2 = new Solution2();
        int searchInsert = solution2.searchInsert(nums, target);
        System.out.println(searchInsert);
    }
}
複製代碼

五、細節、注意事項、調試方法

(1)前提:思考左、右邊界,若是左、右邊界不包括目標數值,會致使錯誤結果

例:LeetCode 第 69 題:x 的平方根

實現 int sqrt(int x) 函數。

計算並返回 x 的平方根,其中 x 是非負整數。

因爲返回類型是整數,結果只保留整數的部分,小數部分將被捨去。

分析:一個非負整數的平方根最小多是 0 ,最大多是它本身。 所以左邊界能夠取 0 ,右邊界能夠取 x。 能夠分析得再細一點,但這道題沒有必要,由於二分查找法會幫你排除掉不符合的區間元素。

例:LeetCode 第 287 題:尋找重複數

給定一個包含 n + 1 個整數的數組 nums,其數字都在 1 到 n 之間(包括 1 和 n),可知至少存在一個重複的整數。假設只有一個重複的整數,找出這個重複的數。

分析:題目告訴咱們「其數字都在 1 到 n 之間(包括 1 和 n)」。所以左邊界能夠取 1 ,右邊界能夠取 n。

要注意 2 點

  • 若是 leftright 表示的是數組的索引,就要考慮「索引是否有效」 ,即「索引是否越界」 是重要的定界依據;
  • 左右邊界必定要包括目標元素,例如 LeetCode 第 35 題:「搜索插入位置」 ,當 target 比數組中的最後一個數字還要大(不能等於)的時候,插入元素的位置就是數組的最後一個位置 + 1,即 (len - 1 + 1 =) len,若是忽略掉這一點,把右邊界定爲 len - 1 ,代碼就不能經過在線測評。

(2)中位數先寫 int mid = (left + right) >>> 1 ; 根據循環裏分支的編寫狀況,再作調整

理解這一點,首先要知道:當數組的元素個數是偶數的時候,中位數有左中位數和右中位數之分。

  • 當數組的元素個數是偶數的時候:

使用 int mid = left + (right - left) / 2 ; 獲得左中位數的索引;

使用 int mid = left + (right - left + 1) / 2 ; 獲得右中位數的索引。

  • 當數組的元素個數是奇數的時候,以上兩者都能選到最中間的那個中位數。

其次,

int mid = left + (right - left) / 2 ; 等價於 int mid = (left + right) >>> 1

int mid = left + (right - left + 1) / 2 ; 等價於 int mid = (left + right + 1) >>> 1

咱們使用一個具體的例子來驗證:當左邊界索引 left = 3,右邊界索引 right = 4 的時候,

mid1 = left + (right - left) // 2 = 3 + (4 - 3) // 2 = 3 + 0 = 3

mid2 = left + (right - left + 1) // 2 = 3 + (4 - 3 + 1) // 2 = 3 + 1 = 4

左中位數 mid1 是索引 left,右中位數 mid2 是索引 right

記憶方法

(right - left) 不加 1 選左中位數,加 1 選右中位數

那麼,何時使用左中位數,何時使用右中位數呢?選中位數的依據是爲了不死循環,得根據分支的邏輯來選擇中位數,而分支邏輯的編寫也有技巧,下面具體說。

(3)先寫邏輯上容易想到的分支邏輯,這個分支邏輯一般是排除中位數的邏輯;

在邏輯上,「多是也有可能不是」讓咱們感到舉棋不定,但**「必定不是」是咱們很是堅定的,一般考慮的因素特別單一,所以「好想」 **。在生活中,咱們常常聽到這樣的話:找對象時,「有車、有房,能夠考慮,但沒有必定不要」;找工做時,「事兒少、離家近能夠考慮,可是錢少必定不去」,就是這種思想的體現。

例:LeetCode 第 69 題:x 的平方根

實現 int sqrt(int x) 函數。

計算並返回 x 的平方根,其中 x 是非負整數。

因爲返回類型是整數,結果只保留整數的部分,小數部分將被捨去。

分析:由於題目中說「返回類型是整數,結果只保留整數的部分,小數部分將被捨去」。例如 5 的平方根約等於 2.236,在這道題應該返回 2。所以若是一個數的平方小於或者等於 x,那麼這個數有多是也有可能不是 x 的平方根,可是能很確定的是,若是一個數的平方大於 x ,這個數確定不是 x 的平方根。

注意:先寫「好想」的分支,排除了中位數以後,一般另外一個分支就不排除中位數,而沒必要具體考慮另外一個分支的邏輯的具體意義,且代碼幾乎是固定的。

(4)循環內只寫兩個分支,一個分支排除中位數,另外一個分支不排除中位數,循環中不單獨對中位數做判斷

既然是「夾逼」法,沒有必要在每一輪循環開始前單獨判斷當前中位數是不是目標元素,所以分支數少了一支,代碼執行效率更高。

如下是「排除中位數的邏輯」思考清楚之後,可能出現的兩個模板代碼。

二分查找法模板

能夠排除「中位數」的邏輯,一般比較好想,但並不絕對,這一點視狀況而定。

分支條數變成 2 條,比原來 3 個分支要考慮的狀況少,好處是:

不用在每次循環開始單獨考慮中位數是不是目標元素,節約了時間,咱們只要在退出循環的時候,即左右區間壓縮成一個數(索引)的時候,去判斷這個索引表示的數是不是目標元素,而沒必要在二分的邏輯中單獨作判斷。

這一點很重要,但願讀者結合具體練習仔細體會,每次循環開始的時候都單獨作一次判斷,在統計意義上看,二分時候的中位數剛好是目標元素的機率並不高,而且即便要這麼作,也不是普適性的,不能解決絕大部分的問題

還以 LeetCode 第 35 題爲例,經過以前的分析,咱們須要找到「大於或者等於目標值的第 1 個數的索引」。對於這道題而言:

(1)若是中位數小於目標值,它就應該被排除,左邊界 left 就至少是 mid + 1

(2)若是中位數大於等於目標值,還不可以確定它就是咱們要找的數,由於要找的是等於目標值的第 1 個數的索引中位數以及中位數的左邊都有多是符合題意的數,所以右邊界就不能把 mid 排除,所以右邊界 right 至可能是 mid,此時右邊界不向左邊收縮。

下一點就更關鍵了

(5)根據分支邏輯選擇中位數的類型,多是左中位數,也多是右位數,選擇的標準是避免死循環

形成死循環的代碼

死循環容易發生在區間只有 2 個元素時候,此時中位數的選擇尤其關鍵。選擇中位數的依據是:避免出現死循環。咱們須要確保:

(下面的這兩條規則提及來很繞,能夠暫時跳過)。

一、若是分支的邏輯,在選擇左邊界的時候,不能排除中位數,那麼中位數就選「右中位數」,只有這樣區間纔會收縮,不然進入死循環;

二、同理,若是分支的邏輯,在選擇右邊界的時候,不能排除中位數,那麼中位數就選「左中位數」,只有這樣區間纔會收縮,不然進入死循環。

理解上面的這個規則能夠經過具體的例子。針對以上規則的第 1 點:若是分支的邏輯,在選擇左邊界的時候不能排除中位數,例如:

Python 僞代碼:

while left < right:
      # 不妨先寫左中位數,看看你的分支會不會讓你代碼出現死循環,從而調整
    mid = left + (right - left) // 2
    # 業務邏輯代碼
    if (check(mid)):
        # 選擇右邊界的時候,能夠排除中位數
        right = mid - 1
    else:
        # 選擇左邊界的時候,不能排除中位數
        left = mid
複製代碼
  • 在區間中的元素只剩下 2 個時候,例如:left = 3right = 4。此時左中位數就是左邊界,若是你的邏輯執行到 left = mid 這個分支,且你選擇的中位數是左中位數,此時左邊界就不會獲得更新,區間就不會再收縮(理解這句話是關鍵),從而進入死循環
  • 爲了不出現死循環,你須要選擇中位數是右中位數,當邏輯執行到 left = mid 這個分支的時候,由於你選擇了右中位數,讓邏輯能夠轉而執行到 right = mid - 1 讓區間收縮,最終成爲 1 個數,退出 while 循環。

上面這段話不理解沒有關係,由於我尚未舉例子,你有個印象就好,相似地,理解選擇中位數的依據的第 2 點。

(6)退出循環的時候,可能須要對「夾逼」剩下的那個數單獨作一次判斷,這一步稱之爲「後處理」。

二分查找法之因此高效,是由於它利用了數組有序的特色,在每一次的搜索過程當中,均可以排除將近一半的數,使得搜索區間愈來愈小,直到區間成爲一個數。回到這一節最開始的疑問:「區間左右邊界相等(即收縮成 1 個數)時,這個數是否會漏掉」,解釋以下:

一、若是你的業務邏輯保證了你要找的數必定在左邊界和右邊界所表示的區間裏出現,那麼能夠放心地返回 left 或者 right,無需再作判斷;

二、若是你的業務邏輯不能保證你要找的數必定在左邊界和右邊界所表示的區間裏出現,那麼只要在退出循環之後,再針對 nums[left] 或者 nums[right] (此時 nums[left] == nums[right])單獨做一次判斷,看它是否是你要找的數便可,這一步操做經常叫作「後處理」。

  • 若是你能肯定候選區間裏目標元素必定存在,則沒必要作「後處理」。

例:LeetCode 第 69 題:x 的平方根

實現 int sqrt(int x) 函數。

計算並返回 x 的平方根,其中 x 是非負整數。

因爲返回類型是整數,結果只保留整數的部分,小數部分將被捨去。

分析:非負實數 x 的平方根在 [0, x] 內必定存在,故退出 while (left < right) 循環之後,沒必要單獨判斷 left 或者 right 是否符合題意。

  • 若是你不能肯定候選區間裏目標元素必定存在,須要單獨作一次判斷。

例:LeetCode 第 704 題:二分查找

給定一個 n 個元素有序的(升序)整型數組 nums 和一個目標值 target ,寫一個函數搜索 nums 中的 target,若是目標值存在返回下標,不然返回 -1。

分析:由於目標數有可能不在數組中,當候選區間夾逼成一個數的時候,要單獨判斷一下這個數是否是目標數,若是不是,返回 -1。

(7)取中位數的時候,要避免在計算上出現整型溢出;

int mid = (left + right) / 2; 的問題:在 left 和 right 很大的時候,left + right 會發生整型溢出,變成負數,這是一個 bug ,得改!

int mid = left + (right - left) / 2;right 很大、 left 是負數且很小的時候, right - left 也有可能超過 int 類型能表示的最大值,只不過通常狀況下 leftright 表示的是數組索引值,left 是非負數,所以 right - left 溢出的可能性很小。所以,它是正確的寫法。下面介紹推薦的寫法。

int mid = (left + right) >>> 1; 若是這樣寫, left + right 在發生整型溢出之後,會變成負數,此時若是除以 2mid 是一個負數,可是通過無符號右移,能夠獲得在不溢出的狀況下正確的結果

解釋「無符號右移」:在 Java 中,無符號右移運算符 >>> 和右移運算符 >> 的區別以下:

  • 右移運算符 >> 在右移時,丟棄右邊指定位數,左邊補上符號位;
  • 無符號右移運算符 >>> 在右移時,丟棄右邊指定位數,左邊補上 0,也就是說,對於正數來講,兩者同樣,而負數經過 >>> 後能變成正數。

下面解釋上面的模板中,取中位數的時候使用先用「+」,而後「無符號右移」。

一、int mid = (left + right) / 2int mid = left + (right - left) / 2 兩種寫法都有整型溢出的風險,沒有哪個是絕對安全的,注意:這裏咱們取平均值用的是除以 2,而且是整除:

  • int mid = (left + right) / 2leftright 都很大的時候會溢出;
  • int mid = left + (right - left) / 2right 很大,且 left 是負數且很小的時候會溢出;

二、寫算法題的話,通常是讓你在數組中作二分查找,所以 leftright 通常都表示數組的索引,所以 left 在絕大多數狀況下不會是負數而且很小,所以使用 int mid = left + (right - left) // 2 相對 int mid = (left + right) // 2 更安全一些,而且也能向別人展現咱們注意到了整型溢出這種狀況,但事實上,還有更好的方式;

三、建議使用 int mid = (left + right) >>> 1 這種寫法,實際上是大有含義的:

JDK8 中採用 int mid = (left + right) >>> 1 ,重點不在 + ,而在 >>>

咱們看極端的狀況,lefthigh 都是整型最大值的時候,注意,此時 32 位整型最大值它的二進制表示的最高位是 0,它們相加之後,最高位是 1 ,變成負數,可是再通過無符號右移 >>>重點是忽略了符號位,空位都以 0 補齊),就能保證使用 + 在整型溢出了之後結果仍是正確的。

Java 中 CollectionsArrays 提供的 binarySearch 方法,咱們點進去看 leftright 都表示索引,使用無符號右移又不怕整型溢出,那就用 int mid = (left + right) >>> 1 好啦。位運算原本就比使用除法快,這樣看來使用 +<<< 真的是又快又好了。

我想這一點多是 JDK8 的編寫者們更層次的考量。

看來之後寫算法題,就用 int mid = (left + right) >>> 1 吧,反正更多的時候 leftright 表示索引。

公衆號:五分鐘學算法

(8)編碼一旦出現死循環,輸出必要的變量值、分支邏輯是調試的重要方法。

當出現死循環的時候的調試方法:打印輸出左右邊界、中位數的值和目標值、分支邏輯等必要的信息。

按照個人經驗,一開始編碼的時候,稍不注意就很容易出現死循環,不過沒有關係,你能夠你的代碼中寫上一些輸出語句,就容易理解「在區間元素只有 2 個的時候容易出現死循環」。具體編碼調試的細節,能夠參考我在「力扣」第 69 題:x 的平方根的題解《二分查找 + 牛頓法(Python 代碼、Java 代碼)》

六、總結

總結一下,我愛用這個模板的緣由、技巧、優勢和注意事項:

(1)緣由:

無腦地寫 while left < right: ,這樣你就不用判斷,在退出循環的時候你應該返回 left 仍是 right,由於返回 left 或者 right 都對;

(2)技巧:

先寫分支邏輯,而且先寫排除中位數的邏輯分支(由於更多時候排除中位數的邏輯容易想,可是前面我也提到過,這並不絕對),另外一個分支的邏輯你就不用想了,寫出第 1 個分支的反面代碼便可(下面的說明中有介紹),再根據分支的狀況選擇使用左中位數仍是右中位數;

說明:這裏再多說一句。若是從代碼可讀性角度來講,只要是你認爲好想的邏輯分支,就把它寫在前面,而且加上你的註釋,這樣方便別人理解,而另外一個分支,你就沒必要考慮它的邏輯了。有的時候另外一個分支的邏輯並不太好想,容易把本身繞進去。若是你練習作得多了,會造成條件反射。

我簡單總結了一下,左右分支的規律就以下兩點:

  • 若是第 1 個分支的邏輯是「左邊界排除中位數」(left = mid + 1),那麼第 2 個分支的邏輯就必定是「右邊界不排除中位數」(right = mid),反過來也成立;
  • 若是第 2 個分支的邏輯是「右邊界排除中位數」(right = mid - 1),那麼第 2 個分支的邏輯就必定是「左邊界不排除中位數」(left = mid),反之也成立。

「反過來也成立」的意思是:若是在你的邏輯中,「邊界不能排除中位數」的邏輯好想,你就把它寫在第 1 個分支,另外一個分支是它的反面,你能夠不用管邏輯是什麼,按照上面的規律直接給出代碼就能夠了。能這麼作的理論依據就是「排除法」。

在「力扣」第 287 題:尋找重複數的題解《二分法(Python 代碼、Java 代碼)》和這篇題解的評論區中,有我和用戶 @fighterhit 給出的代碼,在一些狀況下,咱們先寫了不排除中位數的邏輯分支,更合適的標準就是「哪一個邏輯分支好想,就先寫哪個」,歡迎你們參與討論。

(3)優勢:

分支條數只有 2 條,代碼執行效率更高,不用在每一輪循環中單獨判斷中位數是否符合題目要求,寫分支的邏輯的目的是儘可能排除更多的候選元素,而判斷中位數是否符合題目要求咱們放在最後進行,這就是第 5 點;

說明:每一輪循環開始都單獨判斷中位數是否符合要求,這個操做不是頗有普適性,由於從統計意義上說,中位數直接就是你想找的數的機率並不大,有的時候還要看看左邊,還要看看右邊。不妨就把它放在最後來看,把候選區間「夾逼」到只剩 1 個元素的時候,視狀況單獨再作判斷便可。

(4)注意事項 1:

左中位數仍是右中位數選擇的標準根據分支的邏輯而來,標準是每一次循環都應該讓區間收縮,當候選區間只剩下 2 個元素的時候,爲了不死循環發生,選擇正確的中位數類型。若是你實在很暈,不防就使用有 2 個元素的測試用例,就能明白其中的緣由,另外在代碼出現死循環的時候,建議你能夠將左邊界、右邊界、你選擇的中位數的值,還有分支邏輯都打印輸出一下,出現死循環的緣由就一目瞭然了;

(5)注意事項 2:

若是能肯定要找的數就在候選區間裏,那麼退出循環的時候,區間最後收縮成爲 1 個數後,直接把這個數返回便可;若是你要找的數有可能不在候選區間裏,區間最後收縮成爲 1 個數後,還要單獨判斷一下這個數是否符合題意。

最後給出兩個模板,你們看的時候看註釋,沒必要也無需記憶它們。

公衆號:五分鐘學算法

二分查找模板-1.png

二分查找模板-2.png

說明:我寫的時候,通常是先默認將中位數寫成左中位數,再根據分支的狀況,看看是否有必要調整成右中位數,便是不是要在 (right - left) 這個括號裏面加 1

雖然說是兩個模板,區別在於選中位數,中位數根據分支邏輯來選,原則是區間要收縮,且不出現死循環,退出循環的時候,視狀況,有可能須要對最後剩下的數單獨作判斷

我想我應該是成功地把你繞暈了,若是您以爲囉嗦的地方,就當我是「重要的事情說了三遍」吧,確實是重點的地方我纔會重複說。固然,最好的理解這個模板的方法仍是應用它。在此建議您不妨多作幾道使用「二分查找法」解決的問題,用一下我說的這個模板,在發現問題的過程當中,體會這個模板好用的地方,相信你必定會和我同樣愛上這個模板的

在「力扣」的探索版塊中,給出了二分查找法的 3 個模板,我這篇文章着重介紹了第 2 個模板,可是我介紹的角度和這個版塊中給出的角度並不同,第 1 個模板被我「嫌棄」了,第 3 個模板我看過了,裏面給出的例題也能夠用第 2 個模板來完成,若是你們有什麼使用心得,歡迎與我交流。

公衆號:五分鐘學算法

七、應用提高

這裏給出一些練習題,這些練習題均可以使用這個「神奇的」二分查找法模板比較輕鬆地寫出來,而且獲得一個不錯的分數,你們加油!

LeetCode 第 704 題

說明:傳送門。這道題是二分查找的模板題,由於目標值有可能在數組中並不存在,因此退出 while 循環的時候,要單獨判斷一下。

LeetCode 第 69 題

說明:傳送門

(1)題解連接已經在上文中已經給出,這道題根據分支的邏輯應該選右中位數;

(2)這道題由於還有更高效的「牛頓法」,因此看起來排名並非特別理想。

LeetCode 第 300 題

說明:傳送門,第 300 題的一個子過程就是本題(第 35 題),我在這道題的題解《動態規劃 + 貪心算法(二分法)(Python 代碼、Java 代碼)》 中給了兩個 Python 的示例代碼,它們是對本文中給出的注意事項:

若是你肯定要搜索的數在區間裏,循環完成之後直接返回便可;若是你不肯定要搜索的數在區間裏,循環完成之後須要再作一次判斷。

的具體代碼實現。

LeetCode 第 153 題

說明:傳送門,二分查找法還能夠用於部分有序數組中元素的查找。

LeetCode 第 154 題

說明:傳送門

LeetCode 第 287 題

說明:傳送門,這道題是對「數」做二分,而不是對索引作二分,具體能夠參考我寫的題解《二分法(Python 代碼、Java 代碼)》

這裏要感謝一下「力扣」的用戶 @顧葉峯,他提醒了我「慎用 L 啊,跟 1 傻傻分不清楚了」,根據他的建議,我正在盡力修改之前我寫的題解(包括本文)。

LeetCode 第 1095 題

說明:傳送門。這道題頗有意思,作這一道題等於作了 3 道二分查找的問題,而且,你還會發現,這 3 個二分查找的問題寫出來的分支都是同樣的,所以它們選中位數的時候,都選擇了左中位數。

LeetCode 第 658 題

說明:傳送門。這道題是「力扣」的探索版塊裏給出了二分查找法的 3 個模板中第 3 個模板的練習題,實際上也能夠用我給出的這個模板(即「探索」裏面的第 2 個模板)來完成,這道題我也寫了題解《排除法(雙指針) + 二分法(Python 代碼、Java 代碼)》

LeetCode 第 4 題

說明:傳送門,這道題我也寫了題解《合併之後找 + 歸併過程當中找 + 找兩個數組的「邊界線」(Python 代碼、Java 代碼)》

相關文章
相關標籤/搜索