likes
comments
collection
share

【基础算法】一文详解二分及其重要性质

作者站长头像
站长
· 阅读数 40

二分

今天通过几道 LC 上最热门的「二分」题目,带大家学习何为二分,以及二分对应的几类模板有和区别。

在掌握模板的基础上,带大家进一步的了解二分的本质,算法新手往往对觉得使用二分的充要条件是给定数据具有单调性,实则不然。


153. 寻找旋转排序数组中的最小值

已知一个长度为 n 的数组,预先按照升序排列,经由 1 到 n 次 旋转 后,得到输入数组。

例如,原数组 nums = [0,1,2,4,5,6,7] 在变化后可能得到:

  • 若旋转 4 次,则可以得到 [4,5,6,7,0,1,2]
  • 若旋转 7 次,则可以得到 [0,1,2,4,5,6,7]

注意,数组 [a[0], a[1], a[2], ..., a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]] 。

给你一个元素值 互不相同 的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。

示例 1:

输入:nums = [3,4,5,1,2]

输出:1

解释:原数组为 [1,2,3,4,5] ,旋转 3 次得到输入数组。

提示:

  • n == nums.length
  • 1 <= n <= 5000
  • -5000 <= nums[i] <= 5000
  • nums 中的所有整数 互不相同
  • nums 原来是一个升序排序的数组,并进行了 1 至 n 次旋转
二分查找

今天这道题和昨天的 81. 搜索旋转排序数组 II 相比,有了限制条件「所有整数互不相同」。

因此我们不需要进行「恢复二段性」的预处理,是可以做到严格 O(logn)O(log{n})O(logn) 的复杂度。

我们仍然从「二分」的本质「二段性」进行出发分析:

【基础算法】一文详解二分及其重要性质

经过旋转的数组,显然前半段满足 >= nums[0],而后半段不满足 >= nums[0]。我们可以以此作为依据,通过「二分」找到旋转点。然后通过旋转点找到全局最小值即可。

代码:

class Solution {
    public int findMin(int[] nums) {
        int n = nums.length;
        int l = 0, r = n - 1;
        while (l < r) {
            int mid = l + r + 1 >> 1;
            if (nums[mid] >= nums[0]) {
                l = mid;
            } else {
                r = mid - 1;
            }
        }
        return r + 1 < n ? nums[r + 1] : nums[0];
    }
}
  • 时间复杂度:O(log⁡n)O(\log{n})O(logn)
  • 空间复杂度:O(1)O(1)O(1)

33. 搜索旋转排序数组

整数数组 nums 按升序排列,数组中的值 互不相同 。

在传递给函数之前,nums 在预先未知的某个下标 k (0 <= k < nums.length)上进行了旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。

例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2]

给你旋转后的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 。

示例 1:

输入:nums = [4,5,6,7,0,1,2], target = 0

输出:4

示例 2:

输入:nums = [4,5,6,7,0,1,2], target = 3

输出:-1

示例 3:

输入:nums = [1], target = 0

输出:-1

提示:

  • 1<=nums.length<=50001 <= nums.length <= 50001<=nums.length<=5000
  • −104<=nums[i]<=104-10^4 <= nums[i] <= 10^4104<=nums[i]<=104
  • nums 中的每个值都 独一无二
  • 题目数据保证 nums 在预先未知的某个下标上进行了旋转
  • −104<=target<=104-10^4 <= target <= 10^4104<=target<=104

进阶:你可以设计一个时间复杂度为 O(log⁡n)O(\log{n})O(logn) 的解决方案吗?

朴素解法

但凡是从有序序列中找某个数,我们第一反应应该是「二分」。

这道题是一个原本有序的数组在某个点上进行了旋转,其实就是将原本一段升序的数组分为了两段。

我们可以先找到旋转点 idx,然后对 idx 前后进行「二分」。

代码:

class Solution {
    public int search(int[] nums, int target) {
        int n = nums.length;
        int idx = 0;
        for (int i = 0; i < n - 1; i++) {
            if (nums[i] > nums[i + 1]) {
                idx = i;
                break;
            }
        }
        int ans = find(nums, 0, idx, target);
        if (ans != -1) return ans;
        if (idx + 1 < n) ans = find(nums, idx + 1, n - 1, target);
        return ans;
    }
    int find(int[] nums, int l, int r, int target) {
        while (l < r) {
            int mid = l + r >> 1;
            if (nums[mid] >= target) {
                r = mid;
            } else {
                l = mid + 1;
            }
        }
        return nums[l] == target ? l : -1;
    }
}
  • 时间复杂度:先对数组进行一次遍历,找到 idx,复杂度为 O(n)O(n)O(n),对 idx 前后进行二分查找,复杂度为 O(log⁡n)O(\log{n})O(logn)。整体为 O(n)O(n)O(n)
  • 空间复杂度:O(1)O(1)O(1)
二分解法

不难发现,虽然在朴素解法中我们应用了「二分」查找。

但理论复杂度为 O(n)O(n)O(n),实际复杂度也远达不到 O(log⁡n)O(\log{n})O(logn),执行效率取决于旋转点 idx 所在数组的下标位置。

那么我们如何实现 O(log⁡n)O(\log{n})O(logn) 的解法呢?

这道题其实是要我们明确「二分」的本质是什么。

「二分」不是单纯指从有序数组中快速找某个数,这只是「二分」的一个应用。

「二分」的本质是两段性,并非单调性。只要一段满足某个性质,另外一段不满足某个性质,就可以用「二分」。

经过旋转的数组,显然前半段满足 >= nums[0],而后半段不满足 >= nums[0]。我们可以以此作为依据,通过「二分」找到旋转点。

【基础算法】一文详解二分及其重要性质

找到旋转点之后,再通过比较 targetnums[0] 的大小,确定 target 落在旋转点的左边还是右边。

代码:

class Solution {
    public int search(int[] nums, int target) {
        int n = nums.length;
        if (n == 0) return -1;
        if (n == 1) return nums[0] == target ? 0 : -1;

        // 第一次「二分」:从中间开始找,找到满足 >=nums[0] 的分割点(旋转点)
        int l = 0, r = n - 1;
        while (l < r) {
            int mid = l + r + 1 >> 1;
            if (nums[mid] >= nums[0]) {
                l = mid;
            } else {
                r = mid - 1;
            }
        }
        // 第二次「二分」:通过和 nums[0] 进行比较,得知 target 是在旋转点的左边还是右边
        if (target >= nums[0]) {
            l = 0;
        } else {
            l = l + 1;
            r = n - 1;
        }
        while (l < r) {
            int mid = l + r >> 1;
            if (nums[mid] >= target) {
                r = mid;
            } else {
                l = mid + 1;
            }
        }

        return nums[r] == target ? r : -1;
    }
}
  • 时间复杂度:O(log⁡n)O(\log{n})O(logn)
  • 空间复杂度:O(1)O(1)O(1)

34. 在排序数组中查找元素的第一个和最后一个位置

给定一个按照升序排列的整数数组 nums,和一个目标值 target

找出给定目标值在数组中的开始位置和结束位置。

如果数组中不存在目标值 target,返回 [−1,−1][-1, -1][1,1]

进阶:

  • 你可以设计并实现时间复杂度为 O(log⁡n)O(\log{n})O(logn) 的算法解决此问题吗?

示例 1:

输入:nums = [5,7,7,8,8,10], target = 8

输出:[3,4]

提示:

  • 0<=nums.length<=1050 <= nums.length <= 10^50<=nums.length<=105
  • −109 <=nums[i] <=109-10^9 <= nums[i] <= 10^9109 <=nums[i] <=109
  • nums 是一个非递减数组
  • −109 <=target <=109-10^9 <= target <= 10^9109 <=target <=109
二分解法

这是一道「二分查找」的裸题。

「二分」有一个比较容易混淆的点是:当需要找目标值第一次出现的下标时,条件应该写成 nums[mid] >= target 还是 nums[mid] <= target

其实有一个很好理解的方法:

由于二分是从中间开始找起的,所以找的必然是条件区间中靠近中心的的边界值。

文字不好理解,我们结合图片来看:

【基础算法】一文详解二分及其重要性质

代码:

class Solution {
    public int[] searchRange(int[] nums, int t) {
        int[] ans = new int[]{-1, -1};
        int n = nums.length;
        if (n == 0) return ans;
        int l = 0, r = n - 1;
        while (l < r) {
            int mid = l + r >> 1;
            if (nums[mid] >= t) r = mid;
            else l = mid + 1;   
        }
        if (nums[r] != t) return ans;
        ans[0] = r;
        l = 0; r = n - 1;
        while (l < r) {
            int mid = l + r + 1 >> 1;
            if (nums[mid] <= t) l = mid;
            else r = mid - 1;
        }
        ans[1] = r;
        return ans;
    }
}
  • 时间复杂度:O(log⁡n)O(\log{n})O(logn)
  • 空间复杂度:O(1)O(1)O(1)

35. 搜索插入位置

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。

如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

你可以假设数组中无重复元素。

示例 1:

输入: [1,3,5,6], 5

输出: 2
朴素做法

一个朴素的做法,从前往后进行处理,直到遇到符合条件的位置。

代码:

class Solution {
    public int searchInsert(int[] nums, int t) {
        for (int i = 0; i < nums.length; i++) {
            if (nums[i] == t || nums[i] > t) return i;
        }
        return nums.length;
    }
}
  • 时间复杂度:O(n)O(n)O(n)
  • 空间复杂度:O(1)O(1)O(1)
二分解法

利用数组本身有序,我们可以通过二分找插入位置。

具体的,通过二分找到符合 nums[mid] >= t 的分割点。注意二分完后需要再次检查是否位置是否符合条件,如果不符合,代表插入元素应该被添加到数组结尾。

代码:

class Solution {
    public int searchInsert(int[] nums, int t) {
        int n = nums.length;
        int l = 0, r = n - 1;
        while (l < r) {
            int mid = l + r >> 1;
            if (nums[mid] >= t) {
                r = mid;
            } else {
                l = mid + 1;
            }
        }
        return nums[r] >= t ? r : r + 1;
    }
}
  • 时间复杂度:O(log⁡n)O(\log{n})O(logn)
  • 空间复杂度:O(1)O(1)O(1)

总结

综上,「二分」的本质是两段性,并非单调性。只要一段满足某个性质,另外一段不满足某个性质,就可以用「二分」。

同时,在考虑使用何种二分模板时,切忌不要硬套模板,多从「二分的是什么内容」的角度进行出发思考。

转载自:https://juejin.cn/post/7241951082792845369
评论
请登录