1. 程式人生 > >有了這套模板,女朋友再也不用擔心我刷不動 LeetCode 了

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

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

作者 | 李威

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

個人部落格 | https://www.cxyxiaowu.com

來源 | https://www.liwei.party/


下面的動畫以 「力扣」第 704 題:二分查詢 為例,展示了使用這個模板編寫二分查詢法的一般流程。

以下“簡報”展示了本文所要講解的主要內容,您可以只看這部分的內容,如果您還想看得更仔細一點,可以檢視“簡報”之後的原文。

《十分好用的二分查詢法模板》簡報













(上面的“簡報”是對以下文字的概括。)


1、導讀

本文介紹了我這半年以來,在刷題過程中使用“二分查詢法”刷題的一個模板,包括這個模板的優點、使用技巧、注意事項、除錯方法等。

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

這個模板應該能夠幫助你解決 LeetCode 帶“二分查詢”標籤的常見問題(簡單、中等難度)。

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

2、歷史上有關“二分查詢法”的故事

二分查詢法雖然簡單,但寫好它並沒有那麼容易。我們可以看看一些名人關於二分查詢法的論述。

  • 演算法和程式設計技術的先驅 Donald Ervin Knuth(中文名:高德納):

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 把二分查詢作為專業程式設計師課程中的一個問題時,他發現百分之九十的人在花了幾個小時的時間研究之後,沒有提供正確的解決方案,主要是因為錯誤的實現無法正確執行(筆者注:可能返回錯誤的結果,或者出現死迴圈),或者是不能很好地判斷邊界條件。

3、“傳統的”二分查詢法模板的問題

(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 個示例,不難分析出這個問題的等價表述如下:

1、如果目標值(嚴格)大於排序陣列的最後一個數,返回這個排序陣列的長度,否則進入第 2 點。

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;
    }
}

說明:

1、當把二分查詢法的迴圈可以進行的條件寫成 while (left <= right) 時,在寫最後一句 return 的時候,如果不假思索,把左邊界 left 返回回去,雖然寫對了,但可以思考一下為什麼不返回右邊界 right 呢?

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

由此,我認為“傳統二分查詢法模板”使用的痛點在於:

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

那麼,是不是可以迴避這個問題呢?答案是肯定的,答案就在下面我要介紹的“神奇的”二分查詢法模板裡。

4、“神奇的”二分查詢法模板的基本思想

(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);
    }
}

5、細節、注意事項、除錯方法

(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、如果分支的邏輯,在選擇左邊界的時候,不能排除中位數,那麼中位數就選“右中位數”,只有這樣區間才會收縮,否則進入死迴圈;

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 個數)時,這個數是否會漏掉”,解釋如下:

1、如果你的業務邏輯保證了你要找的數一定在左邊界和右邊界所表示的區間裡出現,那麼可以放心地返回 left 或者 right,無需再做判斷;

2、如果你的業務邏輯不能保證你要找的數一定在左邊界和右邊界所表示的區間裡出現,那麼只要在退出迴圈以後,再針對 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 在發生整型溢位以後,會變成負數,此時如果除以 $2$ ,mid 是一個負數,但是經過無符號右移,可以得到在不溢位的情況下正確的結果。

解釋“無符號右移”:在 Java 中,無符號右移運算子 >>> 和右移運算子 >> 的區別如下:

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

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

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

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

2、寫演算法題的話,一般是讓你在陣列中做二分查詢,因此 leftright 一般都表示陣列的索引,因此 left 在絕大多數情況下不會是負數並且很小,因此使用 int mid = left + (right - left) // 2 相對 int mid = (left + right) // 2 更安全一些,並且也能向別人展示我們注意到了整型溢位這種情況,但事實上,還有更好的方式;

3、建議使用 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 程式碼)》 。

6、總結

總結一下,我愛用這個模板的原因、技巧、優點和注意事項:

(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$ 個數後,還要單獨判斷一下這個數是否符合題意。

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

公眾號:五分鐘學演算法

說明:我寫的時候,一般是先預設將中位數寫成左中位數,再根據分支的情況,看看是否有必要調整成右中位數,即是不是要在 (right - left) 這個括號裡面加 $1$ 。

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

我想我應該是成功地把你繞暈了,如果您覺得囉嗦的地方,就當我是“重要的事情說了三遍”吧,確實是重點的地方我才會重複說。當然,最好的理解這個模板的方法還是應用它。在此建議您不妨多做幾道使用“二分查詢法”解決的問題,用一下我說的這個模板,在發現問題的過程中,體會這個模板好用的地方,相信你一定會和我一樣愛上這個模板的。

在「力扣」的探索版塊中,給出了二分查詢法的 3 個模板,我這篇文章著重介紹了第 2 個模板,但是我介紹的角度和這個版塊中給出的角度並不一樣,第 1 個模板被我“嫌棄”了,第 3 個模板我看過了,裡面給出的例題也可以用第 2 個模板來完成,如果大家有什麼使用心得,歡迎與我交流。

公眾號:五分鐘學演算法

7、應用提升

這裡給出一些練習題,這些練習題都可以使用這個“神奇的”二分查詢法模板比較輕鬆地寫出來,並且得到一個不錯的分數,大家加油!

說明:傳送門。這道題是二分查詢的模板題,因為目標值有可能在陣列中並不存在,所以退出 while 迴圈的時候,要單獨判斷一下。

說明:傳送門。

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

(2)這道題因為還有更高效的“牛頓法”,所以看起來排名並不是特別理想。

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

如果你確定要搜尋的數在區間裡,迴圈完成以後直接返回即可;如果你不確定要搜尋的數在區間裡,迴圈完成以後需要再做一次判斷。

的具體程式碼實現。

說明:傳送門,二分查詢法還可以用於部分有序陣列中元素的查詢。

說明:傳送門。

說明:傳送門,這道題是對“數”作二分,而不是對索引做二分,具體可以參考我寫的題解《二分法(Python 程式碼、Java 程式碼)》。

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

說明:傳送門。這道題很有意思,做這一道題等於做了 3 道二分查詢的問題,並且,你還會發現,這 3 個二分查詢的問題寫出來的分支都是一樣的,因此它們選中位數的時候,都選擇了左中位數。

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

說明:傳送門,這道題我也寫了題解《合併以後找 + 歸併過程中找 + 找兩個陣列的“邊界線”(Python 程式碼、Java 程式碼)》