算法-03-二分查找

3 二分查找

3.1 介紹

  • 二分查找也叫折半查找,顧名思義,就是查找的時候只查找其中的一半,這樣的話大大下降了查找的時間複雜度,爲 O(log n) ,其中n爲數組的長度
  • 二分查找也能夠看做雙指針的一種特殊狀況,但咱們通常會將兩者區分。
    • 雙指針類型的題,指針一般是一步一步移動的;
    • 二分查找時,指針每次移動半個區間長度。
  • 注意:
    • 對於端點上的處理(若是對端點的處理不夠細心,有可能會致使沒法得出正確答案或者沒法退出循環等問題)
      • 端點的處理方式:左閉右開(符合大多數語言的習慣),左閉右閉(方便處理邊界條件)
      • 首先,養成固定使用一個方式處理的習慣
      • 其次,思考當區間中有一個或兩個或沒有元素時,是否能正確執行並退出循環
    • 二分查找中關於mid的選取
      • mid良好的賦值方式應爲 mid = low + ((high - low) / 2) ,而不是 mid = (low + high) / 2,這是由於當數組較大時,可能會出現溢出的狀況
      • 對mid向上仍是向下取整要根據實際狀況來判斷
    • while(left <= right) 和 while(left < right) 的區別
      • while(left <= right) 表示在區間中還剩下一個元素的時候,咱們還要在進行一次循環
        • 一般和 right = mid -1left = mid + 1 配合使用
      • while(left < right) 表示在區間中還剩下一個元素的時候,中止循環
        • 一般和 right = midleft = mid + 1right = mid - 1left = mid 配合使用
  • 來自 LeetCode @liweiwei1419 對於二分搜索編碼的建議
    • 循環終止條件寫成:while (left < right) ,表示退出循環的時候只剩下一個元素;
    • 在循環體內考慮如何縮減待搜索區間,也能夠認爲是在待搜索區間裏排除必定不存在目標元素的區間;
    • 根據中間數被分到左邊和右邊區間,來調整取中間數的行爲;
    • 如何縮小待搜索區間
      • 從 nums[mid] 知足什麼條件的時候必定不是目標元素去考慮,進而考慮 mid 的左邊元素和右邊元素哪一邊可能存在目標元素。
      • 一個結論是:當看到 left = mid 的時候,取中間數須要上取整,這一點是爲了不死循環;
    • 退出循環的時候,根據題意看是否須要單獨判斷最後剩下的那個數是否是目標元素。
    • 邊界設置的兩種寫法:
      • right = midleft = mid + 1mid = left + (right - left) / 2 必定是配對出現的
      • right = mid - 1left = midmid = left + (right - left + 1) / 2 必定是配對出現的
  • 使用二分查找的要點
    • 找到某些規則或規律使得指針能夠一次移動一半的區間

69. x 的平方根(Easy)

  • 思路java

    • 同2.6練習中的367
  • 代碼數組

    public int mySqrt(int x) {
        if (x <= 1) {
            return x;
        }
        if (x < 4) {
            return  1;
        }
        int left = 2;
        int right = x / 2;
        int mid = 0;
        while (left <= right) {
            mid = left + (right - left) / 2;
            long temp = (long)mid * (long)mid;
            if (temp < x) {
                left = mid + 1;
            } else if (temp > x) {
                right = mid - 1;
            } else {
                return mid;
            }
        }
        return (long)mid * (long)mid > x ? mid - 1 : mid;
    }
  • 注意:優化

    • intint 會致使直接向下取整,這裏致使了精度上的損失。因此,最後要多判斷一下 mid - 1 的平方
    • intint 會先得出int類型的結果,而後將這個結果轉換成long,這回致使溢出,因此在乘以前要先轉成long類型或者統一使用 long 最後在返回的時候轉成 int

3.3 查找區間

34. 在排序數組中查找元素的第一個和最後一個位置(Medium)

  • 分析編碼

    • 使用二分查找找一個特定的數字並不難實現天然而然的,能夠想到,咱們先找到這個target,而後從target的位置開始試探,經過試探能夠找出第一次出現的位置和最後一個出現的位置。
      • 理想狀況下,只須要幾回試探便可找到,這樣時間複雜度爲 O(log n)
      • 可是最壞的狀況,當一個數組中所有都是target時,那麼這樣時間複雜度就會升級成爲 O(n) ,不符合題目要求
    • 那麼能不能經過調整二分查找的一些策略,找到咱們須要的位置呢?
      • 以找第一次出現的位置舉例
      • 找到target以後,不着急中止循環,而是將right定位在這個位置。而後繼續向前查找,直到找不到位置。那麼最後一次right的位置就是它的起始位置了
  • 代碼指針

    public int[] searchRange(int[] nums, int target) {
        if (nums == null || nums.length == 0) {
            return new int[]{-1, -1};
        }
        if (nums.length <= 1) {
            return nums[0] == target ? new int[]{0, 0} : new int[] {-1, -1};
        }
    
        int[] res = new int[]{-1, -1};
        int left = 0;
        int right = nums.length - 1;
    
        // 找到第一次出現的位置
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid + 1;
            }
        }
        res[0] = nums[right] != target ? -1 : right;
        if (res[0] == -1) {
            return new int[] {-1, -1};
        }
    
        // 找最後一次出現的位置
        left = right;
        right = nums.length - 1;
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] > target) {
                right = mid;
            } else {
                left = mid + 1;
            }
        }
        res[1] = nums[left] == target ? left : left - 1;
    
        return res;
    }
  • 注意:code

    • 兩次二分查找時對於左右邊界上的處理,若是處理不當,會致使死循環的錯誤

3.4 旋轉數組查找數字

81. 搜索旋轉排序數組 II(Medium)

  • 分析排序

    • 仔細觀察旋轉後的數組會發現,旋轉過的數組分爲兩部分,這兩部分都是有序的,既然是有序的,那麼就可使用二分查找進行搜索
    • 記數組下標爲 0,1,...,i,i+1,...,n-1,其中 nums[i+1] < nums[i]
    • 那麼咱們只須要對 [0,i][i+1, n-1] 這兩塊分別進行二分搜索便可,那麼只須要找到 i 便可
      • 要找到 i 必然要對數組進行遍歷,而且由於數組中存在重複的元素,因此必需要遍歷整個數組,才能肯定出 i 。所以,這一部分的時間複雜度爲 O(n)
    • 找到 i 時間複雜度爲 O(n) ,二分查找時間複雜度爲 O(log n),總的時間複雜度爲 O(n)
  • 代碼element

    public boolean search(int[] nums, int target) {
        if (nums.length == 0) {
            return false;
        }
        if (nums.length == 1) {
            return nums[0] == target;
        }
        int flag = 0;
        for (int i = 0; i < nums.length - 1; i++) {
            if (nums[i] > nums[i + 1]) {
                flag = i;
            }
        }
        
        return binarySearch(0, flag, nums, target) || binarySearch(flag + 1, nums.length - 1, nums, target);
    }
    
    public boolean binarySearch(int left, int right, int[] nums,int target) {
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] > target) {
                right = mid - 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else {
                return true;
            }
        }
        return false;
    }
  • 改進(來自LeetCode官方題解)leetcode

    • 要使用二分搜索,那麼就要求被搜索的區間是有序的,而由題目給出的數組是局部有序的,那麼咱們能不能只在有序的部分進行搜索呢,忽略無序部分?
    • 這啓示咱們能夠在常規二分搜索的時候查看當前 mid 爲分割位置分割出來的兩個部分 [left, mid][mid + 1, right] 哪一個部分是有序的,並根據有序的那個部分肯定咱們該如何改變二分搜索的上下界,由於咱們可以根據有序的那部分判斷出 target 在不在這個部分:
      • 若是 [left, mid]是有序數組,且 target 的大小知足 [nums[left],nums[mid]),則咱們應該將搜索範圍縮小至 [left, mid-1],不然在 [mid + 1, right] 中尋找。
      • 若是 [mid, right] 是有序數組,且 target 的大小知足 (nums[mid+1],nums[right]],則咱們應該將搜索範圍縮小至 [mid + 1, right],不然在 [left, mid - 1] 中尋找。
    • 時間複雜度 O(log n)
  • 實現get

    public boolean search(int[] nums, int target) {
        if (nums.length == 0) {
            return false;
        }
        if (nums.length == 1) {
            return nums[0] == target;
        }
        int left = 0;
        int right = nums.length - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                return true;
            }
            if (nums[left] == nums[mid]) {
                left++;
            } else if (nums[mid] <= nums[right]) {
                // 右邊是有序的
                if (target > nums[mid] && target <= nums[right]) {
                    // target落在有序區間內
                    left = mid + 1;
                } else {
                    right = mid - 1;
                }
            } else {
                // 左邊是有序的
                if (target >= nums[left] && target < nums[mid]) {
                    // target落在有序區間內
                    right = mid - 1;
                } else {
                    left = mid + 1;
                }
            }
        }
    
        return false;
    }
  • 要點:如何判斷哪邊有序?

    • 由於數組存在重複數字,因此,當 nums[mid] == nums[left] 時,咱們沒法肯定哪邊是遞增的,這時須要將 left 左移一個位置

3.5 基礎練習

154. 尋找旋轉排序數組中的最小值 II(Hard)

  • 分析

    • 旋轉數組在旋轉前,咱們能夠表示爲 [小數區間][大數區間] ,那麼旋轉以後,就是[大數區間][小數區間] 。咱們要找的這個最小值,就是大數區間變成小數區間的轉折點
      • 哪怕數組中有重複元素也不例外,這個轉折點必然是數組的最小值(想想,爲何?)
    • 記這個元素下標爲 i
      • [0, i-1] 中的全部元素所有大於等於該元素
      • i 小於等於 [i+1, ... ,n-1] 中的全部元素
    • 參考3.4,制定策略
      • 若是 nums[mid] < nums[right] ,那麼轉折點在 [left, mid-1]範圍內
      • 若是 nums[mid] == nums[right] ,沒法肯定轉折點所在的區間,right左移一位
      • 若是 nums[mid] > nums[right] ,那麼轉折點在 [mid + 1, right]範圍內
    • 爲何要使用 right 進行判斷而不是使用 left
      • 若是整個數組都是有序的,使用 left 沒法正確得出結果。(通過屢次嘗試後發現,百思不得其解,參考了一下官方解法才發現關鍵在這裏)
  • 代碼

    public int findMin(int[] nums) {
        if (nums.length <= 2) {
            return nums.length == 1 ? 0 : Math.min(nums[0], nums[1]);
        }
        int left = 0;
        int right = nums.length - 1;
        int mid = 0;
        while (left < right) {
            mid = left + (right - left) / 2;
            if (nums[mid] < nums[right]) {
                right = mid;
            } else if (nums[right] == nums[mid]) {
                right--;
            } else {
                left = mid + 1;
            }
        }
        return nums[left];
    }
  • 注意

    • 根據分析,當二分查找結束後,剩下的那個元素就是咱們要找的中間點,因而咱們採用 left < right 做爲循環結束的條件

540. 有序數組中的單一元素(Medium)

  • 分析

    • 考慮單一元素出現先後,數組奇數和偶數下標對應的元素髮生的變化
      • 在單一元素出現以前,第 i 對元素的下標是 2i-22i-1
      • 在單一元素出現以後,第 i 對元素的下標是 2i-12i
    • 也就是說,
      • 在單一元素出現以前,對於奇數下標的元素,和它同樣的元素在它的前面
      • 在單一元素出現以後,對於奇數下標的元素,和它同樣的元素在它的後面
    • 根據這樣的規律,便可推斷出單一元素是在 mid 的 左邊仍是右邊
      • 若是 nums{mid] 和先後元素都不相同,則 nums{mid] 就是單一元素
      • 若是 mid 是奇數,且 nums{mid] 和後面元素相同,單一元素在 [left, mid-1] 範圍內,否則就在 [mid+1, right] 範圍內
      • 若是 mid 是偶數,且 nums{mid] 和後面元素相同,單一元素在 [mid+1, right]範圍內,否則就在 [left, mid-1] 範圍內
  • 代碼

    public int singleNonDuplicate(int[] nums) {
        if (nums.length == 0) {
            return 0;
        }
        if (nums.length == 1 || nums[0] != nums[1]) {
            return nums[0];
        }
        if (nums[nums.length - 1] != nums[nums.length -2]) {
            return nums[nums.length - 1];
        }
        int left = 0;
        int right = nums.length - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] != nums[mid-1] && nums[mid] != nums[mid+1]) {
                return nums[mid];
            }
            if (mid % 2 != 0) {
                // mid是奇數
                if (nums[mid] == nums[mid + 1]) {
                    right = mid - 1;
                } else {
                    left = mid + 1;
                }
            } else {
                // mid是偶數
                if (nums[mid] == nums[mid + 1]) {
                    left = mid + 1;
                } else {
                    right = mid - 1;
                }
            }
        }
        return 0;
    }
  • 注意

    • 由於涉及到了取 mid-1mid+1 的操做,因此要對首尾的元素進行特殊操做,避免數組越界

3.6 進階練習

4. 尋找兩個正序數組的中位數(Hard)

  • 分析

    • 若是對時間複雜度沒有要求
      • 那麼將兩個數組合並以後再查找中位數便可,這樣的話時間複雜度爲 O(m+n)
      • 那麼可不能夠不合並數組來進行查找呢?
        • 雙指針,誰小移誰,一共移動 (m+n)/2 次後便可獲得中位數,不過這樣並無優化時間複雜度,只是把空間複雜度從 O(m+n) 下降到了 O(1)
    • 若是對時間複雜度的要求有 log,一般都須要用到二分查找,這裏我是沒想出來有什麼好方法,後來看了官方題解後恍然大悟
      • 根據中位數的定義,當 m+n 是奇數時,中位數是兩個有序數組中的第 (m+n)/2 個元素,當 m+n 是偶數時,中位數是兩個有序數組中的第 (m+n)/2 個元素和第 (m+n)/2+1 個元素的平均值。所以,這道題能夠轉化成尋找兩個有序數組中的第 k 小的數,其中 k 爲 (m+n)/2(m+n)/2+1
      • 假設兩個有序數組分別是 A 和 B。要找到第 k 個元素,咱們能夠比較 A[k/2−1]B[k/2−1]。因爲 A[k/2−1]B[k/2−1] 的前面分別有 A[0..k/2−2]B[0..k/2−2],即 k/2-1 個元素,對於 A[k/2−1]B[k/2−1] 中的較小值,最多隻會有 (k/2−1)+(k/2−1)≤k−2 個元素比它小,那麼它就不能是第 k 小的數了。
      • 所以咱們能夠概括出三種狀況:
        • 若是 A[k/2−1]<B[k/2−1] ,則比 A[k/2−1] 小的數最多隻有 A 的前 k/2-1 個數和 B 的前 k/2−1 個數,即比 A[k/2−1] 小的數最多隻有 k-2 個,所以 A[k/2−1] 不多是第 k 個數,A[0]A[k/2−1] 也都不多是第 k 個數,能夠所有排除。
        • 若是 A[k/2−1]>B[k/2−1] ,則能夠排除 B[0]B[k/2−1]
        • 若是 A[k/2−1]=B[k/2−1] ,則能夠納入第一種狀況處理。
      • 對於某些狀況,須要特殊處理
        • 若是 A[k/2−1] 或者 B[k/2−1] 越界,那麼咱們能夠選取對應數組中的最後一個元素。在這種狀況下,咱們必須根據排除數的個數減小 k 的值,而不能直接將 k 減去 k/2
        • 若是一個數組爲空,說明該數組中的全部元素都被排除,咱們能夠直接返回另外一個數組中第 k 小的元素。
        • 若是 k=1,咱們只要返回兩個數組首元素的最小值便可。
    • 總結
      • 核心思想:找到第k個小的數
      • 在兩個數組中依次比較第 k / 2 個數字,誰小,就說明該數組的前 k / 2 個數字都不是第 k 個小的數,而後縮減該數組,直到 k = 1,而後取兩個中最小的那一個
      • 每次縮減數組以後,k要減去數組減小的元素個數
  • 代碼

    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
        int len = nums1.length + nums2.length;
        int k = len / 2;
        if (len % 2 == 0) {
            int r1 = findKth(nums1, nums2, k);
            int r2 = findKth(nums1, nums2, k + 1);
            return (r1 + r2) / 2.0;
        } else {
            return findKth(nums1, nums2, k+1);
        }
    }
    
    private int findKth(int[] nums1, int[] nums2, int k) {
        int length1 = nums1.length;
        int length2 = nums2.length;
    
        // index表示的是排除元素以後,「新數組」 的開始位置
        int index1 = 0;
        int index2 = 0;
    
        while (true) {
            // 若是其中一個數組爲空,則返回另一個數組的中位數
            if (index1 == length1) {
                return nums2[index2 + k - 1];
            }
            if (index2 == length2) {
                return nums1[index1 + k - 1];
            }
            // 當 k=1 時退出循環
            if (k == 1) {
                return Math.min(nums1[index1], nums2[index2]);
            }
    
            int half = k / 2;
            // 這步操做保證了newIndex不會超出數組長度
            int newIndex1 = Math.min(index1 + half, length1) - 1;
            int newIndex2 = Math.min(index2 + half, length2) - 1;
            int pivot1 = nums1[newIndex1], pivot2 = nums2[newIndex2];
    
            // k要減去每次排除的元素個數
            if (pivot1 <= pivot2) {
                k -= (newIndex1 - index1 + 1);
                index1 = newIndex1 + 1;
            } else {
                k -= (newIndex2 - index2 + 1);
                index2 = newIndex2 + 1;
            }
        }
    }
相關文章
相關標籤/搜索