每日一题 - LeetCode 907.子数组的最小值之和

107 阅读9分钟

题目描述 给定一个整数数组 arr,找到 min(b) 的总和,其中 b 的范围为 arr 的每个(连续)子数组。

由于答案可能很大,因此 返回答案模 10^9 + 7 。

提示:

  • 1 <= arr.length <= 3 * 10^4
  • 1 <= arr[i] <= 3 * 10^4

示例

输入:arr = [3,1,2,4]
输出:17
解释:
子数组为 [3][1][2][4][3,1][1,2][2,4][3,1,2][1,2,4][3,1,2,4]。 
最小值为 3,1,2,4,1,1,2,1,1,1,和为 17。

思路

这是今天的每日一题,题意描述非常简单。就是给一个数组,找出这个数组所有的子数组,对每个子数组求个min,再把结果相加。

想法一:暴力+单调栈

// C++
class Solution {
public:
    int sumSubarrayMins(vector<int>& arr) {
        int ans = 0, n = arr.size(), MOD = 1e9 + 7;
        // 单调递增的单调栈
        vector<int> v;
        // 枚举所有以i为右端点的子数组
        for (int i = 0; i < n; i++) {
            while (!v.empty() && arr[v.back()] >= arr[i]) v.pop_back();
            v.push_back(i);
            int k = 0;
            for (int j = 0; j <= i; j++) {
                while (v[k] < j) k++;
                ans += arr[v[k]];
                ans %= MOD;
            }
        }
        return ans;
    }
};

提交结果:TLE(Time Limit Exceed)。

反思:这样用单调栈其实没什么卵用,效率甚至比直接暴力更差。我上面这个的想法是,枚举子数组的右端点i,然后再枚举左端点j,然后v是用单调栈存了[0, i]中的最小值。然后每次尝试找[j, i]内的最小值,都是从单调栈的第一个元素开始找。这样搞,本来枚举子数组的时间复杂度就已经是 O(n2)O(n^2) 了,每次找某个子数组的最小值,花的时间还不是固定的 O(1)O(1)。脑子真是秀逗了。比纯暴力还拉跨。下面写个纯暴力的,时间复杂度标准的 O(n2)O(n^2)

typedef long long LL;
class Solution {
public:
    int sumSubarrayMins(vector<int>& arr) {
        LL ans = 0, MOD = 1e9 + 7;
        // 枚举子数组右端点
        for (int i = 0; i < arr.size(); i++) {
            // 枚举左端点j, 从右往左枚举, 并维护最小值
            int t = arr[i];
            for (int j = i; j >= 0; j--) {
                t = min(t, arr[j]);
                ans += t;
                ans %= MOD;
            }
        }
        return (int) ans;
    }
};

但是这道题的数据范围是10^4O(n2)O(n^2) 就已经达到了10^8,一定会超时。

我们看看可以如何优化,先随便构造一组数据进行观察

5 3 2 6 8 1 9
[5]
[5 3]
[5 3 2]
[5 3 2 6]
[5 3 2 6 8]
[5 3 2 6 8 1]
[5 3 2 6 8 1 9]
  [3]
  [3 2]
  [3 2 6]
  [3 2 6 8]
  [3 2 6 8 1]
  [3 2 6 8 1 9]
    [2]
	[2 6]
	[2 6 8]
	[2 6 8 1]
	[2 6 8 1 9]
	  [6]
	  [6 8]
	  [6 8 1]
	  [6 8 1 9]
	    [8]
		[8 1]
		[8 1 9]
		  [1]
		  [1 9]
		    [9]

我们观察一下,可以看到,有些数字其实是作为了多个子数组的最小值的。比如3,作为了[5 3][3]的最小值,2作为了[5 3 2][5 3 2 6][5 3 2 6 8],...等子数组的最小值。

我们计算所有子数组的最小值的和,肯定是不能通过子数组来算了,因为只要通过枚举子数组来算,子数组的个数就一定是O(n2)O(n^2)。 上面我们看到,某个值x,可能是会作为多个子数组的最小值的。那么我们可以换个角度,不从子数组来求最小值,而从最小值来求子数组。

什么意思呢?

对于某个位置i的数arr[i],我们看一下,最小值为arr[i]的子数组,共有多少个。要想使得子数组的最小值为arr[i],首先子数组要包含i这个位置,其次,arr[i]要小于等于子数组中其他所有的数。

于是,我们可以看到,只需要求解一下,以arr[i]为最小值,往左往右最远能扩展到什么位置,即可。假设往左最远能到的位置是L,往右最远能到的位置是R。那么区间[L, R]内的所有数都是大于等于arr[i]的。而L - 1R + 1,要么已经越界,要么其数字比arr[i]更小。

我们可以将[L, R]称作是arr[i]辐射范围。由于子数组需要包含i这个位置,那么子数组的左端点的有效取值范围是[L, i],子数组右端点的有效取值范围是[i, R]。根据乘法原理,子数组的个数就是左端点的个数 × 右端点的个数,即(i - L + 1) × (R - i + 1),令其为num。那么共有num个子数组的最小值为arr[i],我们可以通过一次运算,将这num个子数组的最小值的和算出来,即num × arr[i]

于是,只要遍历整个数组,对每个i \in [0, n - 1],我们都计算一下,看以arr[i]作为最小值的子数组共有多少个,然后将结果进行累加,就能得到最终答案。这样的计算方式,只需要一次遍历,时间复杂度是 O(n)O(n)

能这样计算的前提是,对于每个位置i,我们都求得了其辐射范围 [L, R]。所以现在的问题变成了,如何求得某个i辐射范围

根据定义,arr[i]是整个[L, R]区间内的最小值。很明显,我们只要从i开始往左,往右,找到第一个比arr[i]更小的数,就可以了。假设i左侧第一个小于arr[i]的位置是 L - 1i右侧第一个小于arr[i]的位置是R + 1。那么区间[L, R]内全部元素,没有比arr[i]更小的了,这样就找到了i辐射范围[L, R]

那么现在,问题就变成了,如何找到i左(右)侧第一个小于arr[i]的数。

一看到这种描述,什么找左(右)侧第一个比其小(大)的数,就应当立刻想到单调栈。单调栈就是干这个的!

left[i]表示i左侧第一个小于arr[i]的位置,right[i]表示i右侧第一个小于arr[i]的位置。那么我们只需要两次遍历,就能求出所有的left[i]right[i]。于是我们写出如下代码

typedef long long LL;
class Solution {
public:
    int sumSubarrayMins(vector<int>& arr) {
        int n = arr.size(), MOD = 1e9 + 7;
        vector<int> left(n, -1), right(n, n);
        stack<int> stk;
        // 求左侧第一个比其小, 栈中应当单调递增
        for (int i = 0; i < n; i++) {
            // 栈非空且栈顶 >= arr[i], 出栈
            while (!stk.empty() && arr[stk.top()] >= arr[i]) stk.pop();
            // 找到左侧第一个比其小的位置
            // 栈如果为空, 则为-1, 前面已经初始化过了
            if (!stk.empty()) left[i] = stk.top();
            stk.push(i);
        }
        // 清空栈, 注: C++中的stack没有clear方法
        while (!stk.empty()) stk.pop();

        // 求右侧第一个比其小
        for (int i = n - 1; i >= 0; i--) {
            // 栈非空且栈顶 >= arr[i], 出栈
            while (!stk.empty() && arr[stk.top()] >= arr[i]) stk.pop();
            // 栈如果为空, 则为n, 前面已经初始化过了
            if (!stk.empty()) right[i] = stk.top();
            stk.push(i);
        }

        LL ans = 0;
        for (int i = 0; i < n; i++) {
            // 辐射范围的左端点 L = left[i] + 1, 右端点 R = right[i] - 1
            // 这也是为什么 left[i]初始化为-1, right[i]初始化为n
            // 左端点的有效区间为 [L, i], 总个数为 i - L + 1 = i - left[i]
            // 右端点的有效区间为 [i, R], 总个数为 R - i + 1 = right[i] - i
            LL t = arr[i] * (i - left[i]) * (right[i] - i);
            ans = (ans + t) % MOD;
        }
        return ans;
    }
};

但是到这里还没有结束,上述代码提交后结果是WA (Wrong Answer)

来看一组错误数据: [1 5 3 7 4 3]

观察到这个数组里有2个3,第一个3的辐射范围是[1, 5],第二个3的辐射范围也是[1, 5]。我们发现,以第一个3为最小值的一个子数组是[5,3,7,4,3],以第二个3为最小值的一个子数组是[5,3,7,4,3],容易看到,这是同一个子数组。但我们计算时,这个子数组的最小值被加了2次。所以我们需要去重。

我自己的思路就卡在这里了,我想了很久都没想到要如何去重。简单记录下自己的想法:

假设数x的辐射范围内,有2个x,比如[?,?,x,?,?,?,x,?,?]。 则有哪些子数组会重复呢?答案是,左端点在第一个x位置的左侧(包含x的位置),右端点在第二个x的右侧(包含x的位置),这些子数组会被重复计算一次。那么我们需要额外维护x出现的位置才行。并且,假设辐射范围内有更多的x,则计算重复的子数组的个数将会变得更加复杂。于是便卡在这里走不下去了。

下面看看正确的做法是什么?只需要在求解辐射范围时,一侧找到第一个小于arr[i]的位置,另一侧找到第一个小于等于arr[i]的位置。 比如,左侧找到第一个小于等于arr[i]的位置,右侧找到第一个小于arr[i]的位置。我们来看下会有什么不同。

[1 5 3 7 4 3]

对于第一个3,左侧第一个小于等于3的位置是0,右侧第一个小于3的位置是6,那么其辐射范围为[1, 5]

对于第二个3,左侧第一个小于等于3的位置是2,右侧第一个小于3的位置是6,那么其辐射范围为[3, 5]

即,第一个3的有效区间是[5 3 7 4 3],第二个3的有效区间是[7 4 3],由于重复的子数组需要满足的条件是,左端点在第一个3左侧,右端点在第二个3右侧。但是我们第二个3的L并不能取到第一个3的位置以及更左侧的位置,所以,对于那些左端点在第一个3左侧,右端点在第二个3右侧的子数组,在第一个3的时候已经被算过,且在第二个3时不会再次被计算,对于后续再出现相同的3也一样。如此便能达到不重复计算的目的。

只需要把上述代码稍加修改,在求leftright时,保证其中一个数组求的是小于等于,另一个求的是小于,即可。

typedef long long LL;
class Solution {
public:
    int sumSubarrayMins(vector<int>& arr) {
        int n = arr.size(), MOD = 1e9 + 7;
        vector<int> left(n, -1), right(n, n);
        stack<int> stk;
        // 求左侧第一个比其小, 栈中应当单调递增
        for (int i = 0; i < n; i++) {
            // 栈非空且栈顶 >= arr[i], 出栈
            while (!stk.empty() && arr[stk.top()] >= arr[i]) stk.pop();
            // 找到左侧第一个比其小的位置
            // 栈如果为空, 则为-1, 前面已经初始化过了
            if (!stk.empty()) left[i] = stk.top();
            stk.push(i);
        }
        // 清空栈, 注: C++中的stack没有clear方法
        while (!stk.empty()) stk.pop();

        // 求右侧第一个比其小
        for (int i = n - 1; i >= 0; i--) {
            // 栈非空且栈顶 >= arr[i], 出栈
            while (!stk.empty() && arr[stk.top()] > arr[i]) stk.pop();
            // 栈如果为空, 则为n, 前面已经初始化过了
            if (!stk.empty()) right[i] = stk.top();
            stk.push(i);
        }

        LL ans = 0;
        for (int i = 0; i < n; i++) {
            // 辐射范围的左端点 L = left[i] + 1, 右端点 R = right[i] - 1
            // 这也是为什么 left[i]初始化为-1, right[i]初始化为n
            // 左端点的有效区间为 [L, i], 总个数为 i - L + 1 = i - left[i]
            // 右端点的有效区间为 [i, R], 总个数为 R - i + 1 = right[i] - i
            LL t = (LL) arr[i] * (i - left[i]) * (right[i] - i);
            ans = (ans + t) % MOD;
        }
        return ans;
    }
};

这样提交就AC啦~

但是遍历了3次,我们看下能不能减少一下遍历的次数呢。答案是可以的。我们观察到,在求解某一侧的时候,就已经能同时求解出另一侧(比如在求解left时,已经能同时求出right)。

观察这一行:

while (!stk.empty() && arr[stk.top()] >= arr[i]) stk.pop();

我们是从左往右遍历的,单调栈中存的下标,一定是小于i的,而单调栈中要保持递增,当满足这个while条件时,对于栈顶元素,就是遇到了其右侧第一个小于等于它的位置。而我们恰好要求一侧是小于,一侧是小于等于。我们将代码修改如下

while (!stk.empty() && arr[stk.top()] >= arr[i]) {
     right[stk.top()] = i;
     stk.pop();
}

此时我们求出的left[i]表示的是左侧第一个小于,right[i]表示的是右侧第一个小于等于,恰好满足我们去重的需求。

所以我们可以用一次遍历就求出全部的left[i]right[i]

所以我们可以将3次遍历减少为2次遍历。

typedef long long LL;
class Solution {
public:
    int sumSubarrayMins(vector<int>& arr) {
        int n = arr.size(), MOD = 1e9 + 7;
        vector<int> left(n, -1), right(n, n);
        stack<int> stk;
        // 求左侧第一个比其小, 栈中应当单调递增
        for (int i = 0; i < n; i++) {
            // 栈非空且栈顶 >= arr[i], 出栈
            while (!stk.empty() && arr[stk.top()] >= arr[i]) {
                right[stk.top()] = i;
                stk.pop();
            }
            // 找到左侧第一个比其小的位置
            // 栈如果为空, 则为-1, 前面已经初始化过了
            if (!stk.empty()) left[i] = stk.top();
            stk.push(i);
        }
        // 清空栈, 注: C++中的stack没有clear方法
        while (!stk.empty()) stk.pop();

        LL ans = 0;
        for (int i = 0; i < n; i++) {
            // 辐射范围的左端点 L = left[i] + 1, 右端点 R = right[i] - 1
            // 这也是为什么 left[i]初始化为-1, right[i]初始化为n
            // 左端点的有效区间为 [L, i], 总个数为 i - L + 1 = i - left[i]
            // 右端点的有效区间为 [i, R], 总个数为 R - i + 1 = right[i] - i
            LL t = (LL) arr[i] * (i - left[i]) * (right[i] - i);
            ans = (ans + t) % MOD;
        }
        return ans;
    }
};

其实,还可以一次遍历。这里留作思考,不再做展开,直接给出代码。

typedef long long LL;
class Solution {
public:
    int sumSubarrayMins(vector<int>& arr) {
        stack<int> stk;
        arr.push_back(-1);
        stk.push(-1);
        int n = arr.size(), MOD = 1e9 + 7;
        LL ans = 0;
        // 求左侧第一个比其小, 栈中应当单调递增
        for (int i = 0; i < n; i++) {
            // 栈非空且栈顶 >= arr[i], 出栈
            while (stk.size() > 1 && arr[stk.top()] >= arr[i]) {
                int j = stk.top();
                stk.pop();
                LL t = (LL) arr[j] * (j - stk.top()) * (i - j);
                ans = (ans + t) % MOD;
            }
            stk.push(i);
        }
        return ans;
    }
};

今天的每日一题,难度medium,但是我花了整个上午加上下午的一两个小时,不过还是有所收获,只差最后一个去重的点没有想通。