祖传的万能二分查找模板,不好用包赔|力扣第1802. 有界数组中指定下标处的最大值

267 阅读5分钟

题目:1802. 有界数组中指定下标处的最大值

2022/01/04每日一题

初始思路:贪心 + 暴力搜索

思考过程

首先要搞清题意:所构造的数组 nums,每个元素都是正数,相邻的两个数相等或相差 1,所有元素的和不超过 maxSum

题目的求解目标是使 nums[index] 尽可能大,对于最大或最小问题,要像条件反射一样,考虑动态规划和贪心算法。

为了使 nums[index] 尽可能大,那自然要让其他元素尽可能小。考虑到相邻的两个数相等或相差 1,那可以得到到一个山峰数列index峰顶,其两侧的数逐个减小 1,直到最后遇到数组左右边界,或者减小到 1 后不再减小(因为元素必须是正数)。

山峰数列是我杜撰的词,不是专业词汇,仅仅是个比喻。

也就是说,index 的右侧,首先是一个公差为 -1 的递减的等差数列,递减到等于 1 后,后面还跟着 0 个或多个 1。所以右侧的第 i 个数,其大小等于 Math.max(sum[index] - i, 1)

index 的左侧类似。

只要求出所有可能的山峰数列的和,找出和不超过 maxSum前提下的最大峰顶,这道题就解出来了。

计算机最擅长的就是暴力求解,加上我比较懒,我最喜欢暴力搜索了。这样写起来比较省时省事 🤣,也不容易出错。缺点是可能会超时。先不管超时,无脑暴力一番,过不了再改。

代码

class Solution {
    public int maxValue(int n, int index, int maxSum) {
        for(int ret = maxSum; ret >= 1; ret--){ // 从大到小暴力搜索
            long sum = ret; // 注意用 long,防止整数溢出
            for(int i = index + 1; i < n; i++) sum += Math.max(ret - i + index, 1);
            for(int i = index - 1; i >= 0; i--) sum += Math.max(ret - index + i, 1);
            if(sum <= maxSum)return ret;
        }
        return -1;
    }
}

怎么样,这个代码是不是很简单?可惜,还是超时了,烦人:

居然超时了,毕竟n只有 10,m 只有 9 个 0,真·壕无人性!

复杂度

时间复杂度O(mn)O(mn),其中 m=maxSumm = maxSum。这道题中,峰顶 可能的取值范围是 1 到 maxSum,搜索空间的大小为 mm,对每一种可能都要遍历 nn 个数进行求和,所以总的时间复杂度是 O(mn)O(mn)

复杂度O(1)O(1)

思路二:贪心 + 公式求和,优化到 O(m)

思考过程

其实一开始,直觉就告诉我,可以直接对这个山脉数列求和,毕竟只是个等差数列后面跟着一串 1。只要能在 O(1)O(1) 内求和,那么复杂度就可以被优化到线性阶,大概率不会超时了。

山峰处的大小为 MM,先考山峰的右侧。假设右侧有 kk 个数,则右侧的第 ii 个数大小为 ri=max{Mi1}r_i = \max\{M - i,1\},第 k 个数为 rk=max{Mk1}r_k = \max\{M - k,1\}

只要 M>kM > k,则右侧一开始的等差数列后面,不会有多余的 1 了,于是右侧元素的和 rightSumrightSum 为:

rightSum=i=1k(Mi)=k(2M1k)2rightSum = \sum_{i = 1}^k (M - i) = \frac{k(2M - 1 - k)}{2}

如果 MkM \le k,则山峰右侧的前 M1M - 1 个数是一个等差数列,右侧第一个数为 M1M - 1,第 M1M - 1 个数为 11;这个等差数列后面,还跟着 kM+1k - M + 1 个 1。所以此时:

rightSum=(i=1M1(Mi))+(kM+1)=M(M1)2+(kM+1)=M(M3)2+k+1\begin{aligned} rightSum & = \left( \sum_{i = 1}^{M - 1} (M - i)\right) + (k - M + 1) \\ & = \dfrac{M(M - 1)}{2} + (k - M + 1) \\ & = \dfrac{M(M - 3)}{2} + k + 1 \end{aligned}

山峰左侧元素的和 leftSumleftSum ,求解公式是相同的。

代码

class Solution {
    public int maxValue(int n, int index, int maxSum) {
        int lCount = index, rCount = n - 1 - index; // 山峰 index 左右两侧的元素数量
        for(int ret = maxSum; ret >= 1; ret--){ // 从大到小搜索
            long sum = ret + helper(ret, lCount) + helper(ret, rCount);
            if(sum <= maxSum)return ret;
        }
        return -1;
    }

    /*求山峰一侧的元素的和,max 为山峰大小,k 为某一侧的元素数量*/
    private long helper(int max, int k){
        if(max > k)return (long)k * (2 * max - 1 - k) / 2;
        else return (long)max * (max - 3) / 2 + k + 1;
    }
}

非常不幸,虽然看起来很完美,但是依然会超时。我抑郁了 🤣。

复杂度

时间复杂度 O(m)O(m),其中 m=maxSumm = maxSum

空间复杂度 O(1)O(1)

思路三:贪心 + 二分查找

思考过程

这道题没人性的点在于,线性复杂度,依然会超时。比线性阶还要低的,是对数阶。前两种解法,都是把题目转化成了搜索问题,二分查找也是一种搜索方式,且的复杂度是对数阶,很容易想到:能不能用二分查找来解?

通常情况下,适用二分查找的前提,是搜索空间具有某种单调性。这道题的搜索空间有没有单调性呢?有!

记数组的 index 处的元素大小为 ii 时,对应的山峰数列的和为 sumisum_i,那么必然有 :

sumi<sumi+1(1imaxSum)(式1)sum_i < sum_{i + 1} (1 \le i \le maxSum) \tag{式1}

其中 1imaxSum1 \le i \le maxSum。这是因为,山峰等于 ii 时,其左侧和右侧的第 jj 个数,大小为 max{ij,1}\max\{i - j, 1\},显然 max{ij,1}max{i+1j,1}\max\{i - j, 1\} \le \max\{i + 1 - j, 1\}, 故很容易得到式 1。

单调性得证,所以可用二分查找。

代码

/**
贪心 + 二分查找
执行用时:0 ms, 在所有 Java 提交中击败了100.00%
的用户内存消耗:39.1 MB, 在所有 Java 提交中击败了5.34%的用户
通过测试用例:370 / 370
 */
class Solution {
    public int maxValue(int n, int index, int maxSum) {
        int lCount = index, rCount = n - 1 - index; // 山峰左右两侧的元素数量
        int l = 1, r = maxSum; // 二分查找的左右边界
        while(l <= r){
            int m = (l + r)/2;
            long sum = m + helper(m, lCount) + helper(m, rCount);
            if(sum > maxSum)r = --m;
            else l = ++m;
        }
        return l - 1; // 此时,l 代表数组和恰好大于 maxSum 时的数组和。
    }

    /*求山峰一侧的元素的和,max 为山峰大小,k 为某一侧的元素数量*/
    private long helper(int max, int k){
        if(max > k)return (long)k * (2 * max - 1 - k) / 2;
        else return (long)(max - 3) * max / 2 + k + 1;
    }
}

复杂度

时间复杂度O(logm)O(\log m),其中 m=maxSumm = maxSum

空间复杂度 O(1)O(1)

总结和扩展

一个万能二分查找模板

这道题思路并不复杂,关键是要在超时的时候,及时想到二分查找,并仔细地考察其正确性。

二分查找的原理很容易理解,但是二分查找就像茴香豆,有很多种写法,每种都需要仔细地处理各种边界条件。这里介绍我比较喜欢的一个万能模板

给定一个单调不减数组 arr,和一个数 x,那么用下面的代码模板,可以查找出满足条件 arr[i] >= x 的最小 i

class Solution {

    /**
     * 二分查找万能模板。 arr 是单调不减数组。
     * 如果返回值等于 arr.length,代表 arr 中不存在 满足 arr[i] >= x 的 i,也就是所有元素都小于 x。
     * 否则返回满足 arr[i] >= x 的最小 i(最左 i)。也就是说,如果有多个连续的 x,会返回最靠左的那个的下标。
     * @param arr 单调不减数组
     * @param x 边界值
     * @return
     */
    public int binarySearch(int [] arr, int x) {
        int l = 0, r =  arr.length - 1; // 二分查找的左右初始边界
        while(l <= r){ // 注意这里不是 l < r
            int m = l + (r - l)/2;
            if(arr[m] >= x)r = --m;
            else l = ++m;
        }
        return l; // 此时,l 代表arr[i] >= x 的最小 i。
    }
}

之所以敢称这个模板是万能的,是因为它可以被灵活地调用,实现各种需求:

  1. 查找某个数 x 首次出现的位置,如果不存在,返回-1。如果 binarySearch(arr, x) == arr.length,代表所有元素都小于 x,不存在这样的位置;如果有多个元素等于 x,则binarySearch(arr, x)代表首个 x 的下标;如果arr[binarySearch(arr, x)] != x,则不存在某个数等于 x,binarySearch(arr, x)代表最靠左的大于 x 的数。
  2. 查找某个数 x 最后出现的位置,如果不存在,返回-1。转换为用 int ret = binarySearch(arr, x + 1) - 1 来解决。 如果ret < 0,返回 -1;否则,如果arr[ret] == xret 就是答案;否则,返回 -1。
  3. 查找某个数 x 首次出现的位置,如果不存在 x,则求出适合插入 x 的位置binarySearch(arr, x)就是。
  4. 查找小于x的最后一个数。转换为用binarySearch(arr, x) - 1来解决。
  5. 查找小于x的第一个数 。这是个伪问题。
  6. 查找大于x的第一个的数。转换为用binarySearch(arr, x + 1)来解决。
  7. 查找大于x的最后一个的数 。这是个伪问题。

如果把被查找的数组换成单调不增的,可以调整这个模板中的第五行中的 if 条件,把大于等于改为小于等于。

如果解二分查找的题比较耗时间,那么,从现在开始,把这个模板焊死在大脑里,并且找 10 道题练习一下。

如果在本文发现错漏,或者有更好的思路,或者有任何疑问,欢迎评论区交流。也可以点个赞支持一下作者 😊😊~~

本文首发于公众号「程序员老宋」,欢迎来逛逛~~