LeetCode-03-二分查找

第三讲 二分查找

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 一定是配对出现的
  • 使用二分查找的要点
    • 找到某些规则或规律使得指针可以一次移动一半的区间

3.2 平方根

69. x 的平方根(简单)

  • 思路

    • 同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. 在排序数组中查找元素的第一个和最后一个位置(中等)

  • 分析

    • 使用二分查找找一个特定的数字并不难实现自然而然的,可以想到,我们先找到这个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;
    }
    
  • 注意:

    • 两次二分查找时对于左右边界上的处理,如果处理不当,会导致死循环的错误

3.4 旋转数组查找数字

81. 搜索旋转排序数组 II(中等)

  • 分析

    • 仔细观察旋转后的数组会发现,旋转过的数组分为两部分,这两部分都是有序的,既然是有序的,那么就可以使用二分查找进行搜索
    • 记数组下标为 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)
  • 代码

    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官方题解)

    • 要使用二分搜索,那么就要求被搜索的区间是有序的,而由题目给出的数组是局部有序的,那么我们能不能只在有序的部分进行搜索呢,忽略无序部分?
    • 这启示我们可以在常规二分搜索的时候查看当前 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)
  • 实现

    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(困难)

  • 分析

    • 旋转数组在旋转前,我们可以表示为 [小数区间][大数区间] ,那么旋转之后,就是[大数区间][小数区间] 。我们要找的这个最小值,就是大数区间变成小数区间的转折点
      • 哪怕数组中有重复元素也不例外,这个转折点必然是数组的最小值(想一想,为什么?)
    • 记这个元素下标为 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. 有序数组中的单一元素(中等)

  • 分析

    • 考虑单一元素出现前后,数组奇数和偶数下标对应的元素发生的变化
      • 在单一元素出现之前,第 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. 寻找两个正序数组的中位数(困难)

  • 分析

    • 如果对时间复杂度没有要求
      • 那么将两个数组合并之后再查找中位数即可,这样的话时间复杂度为 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;
            }
        }
    }
    
posted @ 2020-11-18 21:54  PrimaBruceXu  阅读(159)  评论(0编辑  收藏  举报