题目描述

:::fold-green 相关原题

✅ 1856. 子数组最小乘积的最大值

LeetCode 原题输入为正整数,答案对 1000000007 取模;本文允许元素为 0,返回不取模的最大得分。

:::

给定一个非负整数数组 nums。对于其中任意一个非空连续子数组,将该子数组的最小值乘以它的元素总和,得到这个子数组的得分。

请返回所有非空连续子数组中的最大得分,无需返回对应区间,也不需要对结果取模。

示例 1:

输入:nums = [6,2,1]
输出:36
解释:选择子数组 [6],得分为 6 × 6 = 36。

提示:

  • nums 非空,且 nums[i] >= 0。
  • 最小值与元素总和必须取自同一个连续子数组。
  • 前缀和与区间得分使用 64 位整数表示,计算结果需在该类型的可表示范围内。

题意分析

从数组中选一个非空连续区间,计算“区间内的最小值 × 区间内所有元素之和”,求能得到的最大乘积。这里区间和与最小值必须来自同一个区间。

原题数组允许零,以下按非负整数数组处理,并约定真实前缀和与乘积能由 64 位整数保存。非负性是扩张区间的依据:保持最小值不变时,加入更多元素不会减小区间和,乘积也不会减小。本题直接返回最大乘积,不要求取模。

解法:单调栈枚举最小值贡献 + 前缀和

核心思路

[!blue]

直接枚举左右端点会重复处理大量区间。换成枚举哪个元素充当最小值:固定 nums[j] 后,只要向两侧扩张时不遇到更小的值,最小值就不会改变。由于数组元素均非负,扩张不会减小乘积,因此可以把允许区间扩到最大,而不是再枚举其中的短区间。

先建立 prefix[i],表示前 i 个元素之和。若可选区间是 [left + 1, i - 1],它的和便是 prefix[i] - prefix[left + 1],无需重新遍历。

用栈保存下标,并让对应数值从栈底到栈顶严格递增。扫描到 i,只要当前值不大于栈顶,就弹出栈顶 j。弹出后新的栈顶 left 是左侧最近的严格更小元素;当前 i 是右侧第一个小于或等于 nums[j] 的位置。两处边界都不纳入,计算中间区间和乘以 nums[j]。

右侧相等也弹出,会暂时让较早的相等元素算不到完整区间,但不会漏解。较右的相等元素随后入栈,它继承前面的更小左边界,能够覆盖之前同值元素经过的范围。于是一个区间中最小值若出现多次,最右的那次最终负责完整范围,前面的结算只是较短候选。

对任意最优区间,取其中最右的最小值位置。它按上述规则得到的区间至少覆盖原区间,且没有纳入更小值,因此乘积不会更差。这说明枚举这些栈结算区间足以覆盖最优答案。

扫描到数组末尾时仍可能有元素没有右边界,使用 i == n 统一弹出,表示它们可以延伸到最后一项。虚拟下标只用于结算,不读取 nums[n],也不加入栈。

解题步骤

  1. 使用 64 位整数建立长度为 n + 1 的前缀和。
  2. 从左到右扫描,并维护数值严格递增的下标栈。
  3. 当前值不大于栈顶时弹出 j,取弹出后栈顶作为排除在区间外的左边界;空栈时左界为 -1。
  4. 计算 prefix[i] - prefix[left + 1],乘以 nums[j] 更新最大值。
  5. 当前真实下标入栈;到 i == n 时只清栈,完成剩余区间结算。

代码实现

class Solution {
    public long maxMinProduct(int[] nums) {
        int n = nums.length;
        long[] prefix = new long[n + 1];

        for (int i = 0; i < n; i++) {
            prefix[i + 1] = prefix[i] + nums[i];
        }

        long ans = 0;
        Deque<Integer> stack = new ArrayDeque<>();

        for (int i = 0; i <= n; i++) {
            // 当前不更大项确定右边界,相等项的完整归属交给更右位置
            while (!stack.isEmpty() && (i == n || nums[stack.peek()] >= nums[i])) {
                int j = stack.pop();
                int left = stack.isEmpty() ? -1 : stack.peek();
                // 弹出后的栈顶不属于区间,前缀差从它后一位开始
                long sum = prefix[i] - prefix[left + 1];

                ans = Math.max(ans, sum * nums[j]);
            }

            // 末尾仅负责清栈,不将虚拟位置加入候选
            if (i < n) {
                stack.push(i);
            }
        }

        return ans;
    }
}
func maxMinProduct(nums []int) int64 {
    prefix := make([]int64, len(nums)+1)
    for i, num := range nums {
        prefix[i+1] = prefix[i] + int64(num)
    }

    var ans int64
    stack := make([]int, 0, len(nums))
    for i := 0; i <= len(nums); i++ {
        // 当前不更大项确定右边界,相等项的完整归属交给更右位置
        for len(stack) > 0 &&
            (i == len(nums) || nums[stack[len(stack)-1]] >= nums[i]) {
            j := stack[len(stack)-1]
            stack = stack[:len(stack)-1]
            left := -1
            if len(stack) > 0 {
                left = stack[len(stack)-1]
            }
            // 弹出后的栈顶不属于区间,前缀差从它后一位开始
            product := (prefix[i] - prefix[left+1]) * int64(nums[j])
            if product > ans {
                ans = product
            }
        }
        // 末尾仅负责清栈,不将虚拟位置加入候选
        if i < len(nums) {
            stack = append(stack, i)
        }
    }
    return ans
}

复杂度分析

  • 时间复杂度:$O(n)$。前缀和线性计算,每个真实下标至多入栈、出栈各一次,嵌套弹栈总次数仍为线性。
  • 空间复杂度:$O(n)$,用于前缀和与下标栈。

关键点总结

[!green]

  • 从枚举区间改为枚举最小值,非负条件使允许的最大区间成为最佳候选。
  • 单调栈确定边界,前缀和计算边界之间的总和,两者职责不同。
  • 相等元素由较右位置承担完整区间,既保持严格递增栈,也不会漏掉最优值。
  • 末尾统一清栈,补齐没有真实右边界的候选。

易错点总结

[!yellow]

  • 前缀差应减去 prefix[left + 1],左边界本身是更小值,不能计入区间。
  • 右边界 i 也不属于被弹项的本次区间,前缀右端使用 prefix[i]。
  • 末尾不清栈会遗漏延伸到数组结尾的区间。
  • 判断必须先处理 i == n,再访问当前元素,避免读取虚拟下标。
  • 该扩张理由依赖非负条件;含负数时区间变大不一定让乘积变好,不能直接照搬。
  • 比较前取模会改变大小顺序;本实现按真实 64 位乘积直接比较和返回。

相似题目

题目 难度 关联与区别
1856. 子数组最小乘积的最大值 中等 核心最小值乘区间和相同,原题最终取模;必须先比较真实乘积,不能用模值选最大。
84. 柱状图中最大的矩形 困难 同样固定最矮元素并找可扩展边界,本题权重是区间和,柱状图题权重是宽度。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2023/37425078
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!