從LeetCode No.34談二分查找

從LeetCode No.34說起

題目描述:

給定一個按照升序排列的整數數組 nums,和一個目標值 target。找出給定目標值在數組中的開始位置和結束位置。

你的算法時間複雜度必須是 O(log n) 級別。
如果數組中不存在目標值,返回 [-1, -1]。


示例 1:
輸入: nums = [5,7,7,8,8,10], target = 8
輸出: [3,4]


示例 2:
輸入: nums = [5,7,7,8,8,10], target = 6
輸出: [-1,-1]

首先貼一下我的LeetCode運行結果:

在這裏插入圖片描述

分析

一般我們看到時間複雜度是O(log n)級別的話,第一反應就是使用二分查找法。實際上二分查找是一種思想很簡單,但是細節很困難的算法。

困難的地方就在於要明確分區點。我們先說說一般的二分查找

常規二分查找

Java代碼:

    public int search(int[] nums, int target) {

        if (nums == null || nums.length == 0) {
            return -1;
        }

        int start = 0;
        int end = nums.length - 1;

        while (start <= end) {
          int mid = (end + start) / 2;
            if (nums[mid] == target) {
                return mid;
            } else if (target < nums[mid]) {
                end = mid - 1;
            } else if (target > nums[mid]){
                start = mid + 1;
            }
        }
        return -1;
    }

分析:

首先確定我們的查找區間是[left, right],也就是[0 , nums.length - 1], 這個時候末尾區間元素是數組nums的最後一個數字,因此爲了覆蓋到最後一個元素,我們的循環條件應該是 '<= ’ 而不是 ‘<’

然後我們來看看分區點的判斷條件(注意查找的數組是有序的)

  • 如果mid正好是要找數字的下標,那麼直接返回,這個應該很容易理解
  • 如果mid比target要小,那麼說明target在右半區間,因此要把start變大,而mid已經判斷過了,因此是start = mid + 1
  • 如果mid比target要打,那麼說明target在左半區間,因此要把end變小,而mid已經判斷過了,因此是end = mid - 1

最後我們來分析一下結束循環的幾種情況

  • 目標處於數組中,通過不斷修改mid,最終一定是可以找到對應下標的,正常結束循環
  • 目標比數組中所有數字都要小,例如:[1,2,3,4,5], target = 0, 這個時候不斷縮小end,從左半區間中查找,直到最後mid = 0,end = mid - 1 = -1, 不滿足 start <= end 結束循環。
  • 目標比數組中所有數字都要打,例如:[1,2,3,4,5], target= 6, 這個時候不斷增大start,從右半區間中查找,直到最後mid = 4(因爲我們下標區間就是[0, 4]),start = mid + 1 = 5, 不滿足條件,結束循環

可以看到,在常規的二分查找中,分區點是很明確的,因爲每個元素都只會判斷一次,不符合就不用再去管它了。

這個時候我們再來思考一個問題:如果我要找的元素不是唯一的,而我想要找到第一個出現這個元素的位置,該怎麼辦呢?

舉個例子:target = 2,nums = [1,2,2,3,3]

我們想要得到的結果是1,因爲第一個2出現的下標就是1,但是如果用上面的算法,在第一次循環中就會直接返回2了,我們該怎麼修改一下,使得他能夠返回我們要的答案呢?

二分查找變形 - 第一個元素位置

我們先直接貼代碼,然後再來分析爲什麼要這麼寫

Java代碼:

   private int leftBound(int[] nums, int target) {
        if (nums == null || nums.length == 0 ) return -1;

        int left = 0;
        int right = nums.length - 1;  // 確定右區間

        // 確定邊界條件
        while (left <= right) {
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                right = mid - 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid - 1;
            }
        }

        if (left == nums.length || nums [left] != target) return -1;

        return left;
    }

分析:

首先要注意的是,我們這裏的右區間是nums.length - 1,也就是數組的最後一個元素,通過上面常規算法的分析,我們知道了結束循環的條件是 <= ,因爲如果是 < 的話,萬一target正好是最後一個,那就永遠無法找到了

理解了這個前置條件之後,我們再來看看分區點是如何被改變的,上面說到,如果按照常規的算法,那麼在第一次找到target的時候就會返回結果,爲了解決這個問題,我們在第一次找到的時候不返回,而是找到最後確定左邊沒有target元素了,再返回,這樣就可以了。

這個算法的查找過程:

  • 如果mid = target,繼續從左半區間查找target,看看有沒有相同的值,把right修改爲 mid - 1
  • 如果不等,target比mid大就從右半邊找,target比mid就從左半邊找
  • 最終返回的值區間爲[-1, nums.length - 1]

這裏重點分析一下幾種特殊情況:

nums = [2,2,2,2,2] target = 2,不斷修改mid,最終mid = 0,right = -1,結束循環,left = 0

nums = [2,2,2,2,3] target = 3,不斷修改mid,最終mid = 4,right = 3,結束循環,left = 4

nums = [1,2,3,4,5] target = 0,注意:這種情況下雖然left是0,但是實際上nums[0]並不是我們要找的target,因此要在函數的最後加上一個判斷:nums[left] == target

nums = [1,2,3,4,5] target = 6, 注意:這種情況下left = 5,因此加上判斷 left = nums.length,否則會出現數組越界的錯誤。

二分查找變形 - 最後一個元素位置

這種情況和上面的情況類似,我們就直接貼代碼吧

Java代碼:

private int rightBound(int[] nums, int target) {
        if (nums == null || nums.length == 0 ) return -1;

        int left = 0;
        int right = nums.length - 1;

        // 確定邊界條件
        while (left <= right) {
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                left = mid + 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid - 1;
            }
        }

        if (right == -1 || nums[right] != target) return -1;

        return right;
    }

分析:

這裏的情況和上面的變形類似,不過有個地方需要注意,就是在最後加的判斷條件

如果:

nums = [1,2,3,4,5] target = 0, 這個時候right = -1 結束循環,因此我們判斷要寫 - 1,而不是 0

nums = [1,2,3,4,5] target = 6, 這個時候 right = 4 循環結束,但是nums[4] 不是我們要找的。因此要加上判斷

LeetCode No.34解答

結合上面所說的兩種情況,這題的思路就比較清晰了,我們可以通過兩次二分查找,分別找到第一個和最後一個元素的位置。

Java代碼:

public int[] searchRange(int[] nums, int target) {
        int[] result = new int[2];
        result[0] = leftBound(nums, target);
        result[1] = rightBound(nums, target);

        return result;
    }

     private int leftBound(int[] nums, int target) {
        if (nums == null || nums.length == 0 ) return -1;

        int left = 0;
        int right = nums.length - 1;

        // 確定邊界條件
        while (left <= right) {
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                right = mid - 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid - 1;
            }
        }

        if (left == nums.length || nums [left] != target) return -1;

        return left;
    }

    // 尋找最後一個出現的下標(最大右邊界)
    private int rightBound(int[] nums, int target) {
        if (nums == null || nums.length == 0 ) return -1;

        int left = 0;
        int right = nums.length - 1;

        // 確定邊界條件
        while (left <= right) {
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                left = mid + 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid - 1;
            }
        }

        if (right == -1 || nums[right] != target) return -1;

        return right;
    }

總結

我們通過LeetCode第34題來分析了一下二分查找和二分查找的變形。除了LeetCode的34題,還有33題也是屬於二分的變形題。大家有興趣可以去看看。

二分查找的主要難點就是在於如何劃分分區點,避免數組越界和查找不到的問題。想要熟練掌握二分查找,只能通過多做題多總結,在做題的過程中可以自己試一下這些特殊的數組,看看自己設定的查找分區和分區點條件是否有問題,然後再進行調整。

感謝大家的閱讀,也希望大家多溝通交流,寫的不夠好的地方歡迎批評指正~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章