题目描述

✅ 907. 子数组的最小值之和

image-20260928223609222

题意分析

求所有非空连续子数组的最小值之和,最后对 $10^9 + 7$ 取模。子数组有 $O(n^2)$ 个,逐个枚举并维护最小值仍需要平方级时间,难以处理长度为 $3 \times 10^4$ 的数组。

可以把求和顺序反过来:不再逐个子数组找最小值,而是统计每个元素作为最小值时负责多少个子数组,再累加它的贡献。一个子数组可能有多个相同的最小值,必须先约定只归其中一个位置负责。

解法:单调栈统计元素贡献

核心思路

[!blue]

约定每个子数组归它最右侧的最小值负责。对位置 i,左端点可以越过与 arr[i] 相等的元素,但不能越过更小的元素;右端点既不能越过更小的元素,也不能包含与 arr[i] 相等的元素,否则负责该子数组的就应是右边那个位置。

因此令 L = prevLess[i] 为左侧最近的严格更小元素下标,R = nextLessEqual[i] 为右侧最近的小于等于元素下标。不存在时分别取 -1、n。位置 i 负责的子数组恰好满足 L < left <= i <= right < R:左端点有 i - L 种选择,右端点有 R - i 种选择,两者可以独立组合,贡献为 arr[i] * (i - L) * (R - i)。

这些范围保证子数组中没有比 arr[i] 更小的值,并且它右侧没有相等值,所以 i 确实是最右侧最小值。反过来,任意子数组的最右侧最小值也一定满足这两个边界限制。因此所有子数组都会被统计一次,不会重复或遗漏。

用单调栈求左边界时,从左向右扫描,栈存下标,所对应的值从栈底到栈顶严格递增。遇到 arr[i],弹出所有大于等于它的栈顶:这些位置不可能是当前的严格更小边界;对将来的位置而言,i 比它们更近、值又不大于它们,它们也不可能再成为最近的严格更小候选。弹出后留下的栈顶,就是当前左侧最近的严格更小位置。

清栈后从右向左同样处理右边界,但只弹出严格大于 arr[i] 的值,保留相等值作为阻挡右端点的边界。两个方向的弹栈条件一严一松,正好实现前面规定的归属规则,而不是为了维护栈随意选择的条件。

最后遍历每个位置累加贡献。左右距离与元素值相乘可能超出 32 位整数,应在乘法之前使用 long 或 int64;逐次取模不会改变最终答案的余数。

解题步骤

  1. 从左向右扫描。弹出所有值大于等于 arr[i] 的下标,栈顶就是左侧第一个严格更小的位置。
  2. 清空栈,从右向左扫描。弹出所有值严格大于 arr[i] 的下标,栈顶就是右侧第一个小于等于它的位置。
  3. 对每个 i 计算左右端点的选择数,相乘后再乘 arr[i],累加时使用 64 位整数并取模。

-1 和 n 是数组外的边界,不需要真的入栈;它们让两端元素仍能直接使用同一个距离公式。只有一个元素时,左右选择数都为 1;大量元素相等时,不对称边界仍会把每段子数组唯一分配给其最右侧位置。

代码实现

class Solution {
    private static final int MOD = 1_000_000_007;

    public int sumSubarrayMins(int[] arr) {
        int n = arr.length;
        int[] prevLess = new int[n];
        int[] nextLessEqual = new int[n];
        Deque<Integer> stack = new ArrayDeque<>();

        for (int i = 0; i < n; i++) {
            while (!stack.isEmpty() && arr[stack.peek()] >= arr[i]) {
                stack.pop();
            }

            // 左界严格更小,允许左侧相等值纳入当前元素负责的区间。
            prevLess[i] = stack.isEmpty() ? -1 : stack.peek();
            stack.push(i);
        }

        stack.clear();

        for (int i = n - 1; i >= 0; i--) {
            while (!stack.isEmpty() && arr[stack.peek()] > arr[i]) {
                stack.pop();
            }

            // 右界保留相等值,一严一松把重复最小值的区间交给最右者。
            nextLessEqual[i] = stack.isEmpty() ? n : stack.peek();
            stack.push(i);
        }

        long ans = 0;

        for (int i = 0; i < n; i++) {
            // 左右端点独立选择,贡献次数是两侧可选数量的乘积。
            long leftChoices = i - prevLess[i];
            long rightChoices = nextLessEqual[i] - i;

            ans = (ans + arr[i] * leftChoices % MOD * rightChoices) % MOD;
        }

        return (int) ans;
    }
}
func sumSubarrayMins(arr []int) int {
    const mod int64 = 1_000_000_007
    n := len(arr)
    prevLess := make([]int, n)
    nextLessEqual := make([]int, n)
    stack := make([]int, 0, n)

    for i := 0; i < n; i++ {
        // 左扫弹掉相等值,左界只保留严格更小者。
        for len(stack) > 0 && arr[stack[len(stack)-1]] >= arr[i] {
            stack = stack[:len(stack)-1]
        }
        if len(stack) == 0 {
            prevLess[i] = -1
        } else {
            prevLess[i] = stack[len(stack)-1]
        }
        stack = append(stack, i)
    }

    stack = stack[:0]
    for i := n - 1; i >= 0; i-- {
        // 右扫保留相等值,一严一松把重复最小值的区间交给最右者。
        for len(stack) > 0 && arr[stack[len(stack)-1]] > arr[i] {
            stack = stack[:len(stack)-1]
        }
        if len(stack) == 0 {
            nextLessEqual[i] = n
        } else {
            nextLessEqual[i] = stack[len(stack)-1]
        }
        stack = append(stack, i)
    }

    var ans int64
    for i, value := range arr {
        // 左右端点独立选择,贡献次数是两侧可选数量的乘积。
        leftChoices := int64(i - prevLess[i])
        rightChoices := int64(nextLessEqual[i] - i)
        ans = (ans + int64(value)*leftChoices%mod*rightChoices) % mod
    }
    return int(ans)
}

复杂度分析

  • 时间复杂度:$O(n)$。每个下标在两轮扫描中都至多入栈、出栈一次。
  • 空间复杂度:$O(n)$。两个边界数组和单调栈最多各保存 $n$ 个元素。

关键点总结

[!green]

  • 将“枚举子数组”改成“统计每个元素的贡献”,才能把复杂度降到线性。
  • 左右端点独立选择,所以贡献次数是两侧选择数的乘积。
  • 重复值必须采用一严一松的边界规则;严格方向互换也可以,但必须保持不对称。
  • 栈存下标而不是值,因为贡献需要边界距离。

易错点总结

[!yellow]

  • 两侧都用严格小于:重复最小值会让同一子数组被重复统计。
  • 两侧都用小于等于:重复最小值之间的子数组会被漏掉。
  • 第二轮前没有清空栈,会把第一轮残留的下标当成右边界。
  • 使用 int 计算乘积会溢出;应先提升为 long / int64,再取模。

相似题目

题目 难度 关联与区别
828. 统计子串中的唯一字符 困难 同样按每个位置对多少子数组负责来计贡献,本题边界由更小元素决定,原题由相同字符位置决定。
2104. 子数组范围和 中等 区间最大值减最小值之和可拆成两次贡献计数,本题是其中的最小值部分。
84. 柱状图中最大的矩形 困难 用单调栈确定元素能影响的连续区间边界;本题统计每个值作为区间最小值的贡献,该题以每根柱高计算最大矩形面积。
85. 最大矩形 困难 用单调栈确定元素能影响的连续区间边界;本题统计每个值作为区间最小值的贡献,该题逐行累计柱高并复用直方图算法。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2024/77742227
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!