数组
大家好,我是老马。
今天我们一起来学习一下乘积最大子数组
LC152. 乘积最大子数组 maximum-product-subarray
给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续 子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32-位 整数。
示例 1:
输入: nums = [2,3,-2,4] 输出: 6 解释: 子数组 [2,3] 有最大乘积 6。 示例 2:
输入: nums = [-2,0,-1] 输出: 0 解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
提示:
1 <= nums.length <= 2 * 104 -10 <= nums[i] <= 10 nums 的任何子数组的乘积都 保证 是一个 32-位 整数
v0-DP
思路
数组中有负数和零,我们必须要考虑负数
这是最常见的解法,先放在开始。
- 
    
负数的影响
- 乘积遇到负数会翻转符号
 - 所以 最大乘积可能来自前面的最小乘积 × 当前负数
 - 同理,最小乘积可能来自前面的最大乘积 × 当前负数
 
 - 
    
动态规划(DP) 我们维护两个状态:
maxProd[i]:以nums[i]结尾的子数组最大乘积minProd[i]:以nums[i]结尾的子数组最小乘积
 - 
    
状态转移方程
 
maxProd[i] = max(nums[i], nums[i] * maxProd[i-1], nums[i] * minProd[i-1])
minProd[i] = min(nums[i], nums[i] * maxProd[i-1], nums[i] * minProd[i-1])
- 如果 
nums[i]为负数,maxProd[i]可能由minProd[i-1] * nums[i]得到 - 如果 
nums[i]为正数,maxProd[i]可能由maxProd[i-1] * nums[i]得到 - 如果 
nums[i]为零,最大/最小都重置为nums[i] = 0 
- 
    
最终答案
- 遍历数组,每一步记录 
maxProd[i]的全局最大值 
 - 遍历数组,每一步记录 
 
个人理解
其实方程3可以写的简单一些,就是我们不考虑 nums 的各种场景。
直接用两个方程去找就行。
可以自定义一个 min max 方法
这个实现还是很好理解的
java
    public int maxProduct(int[] nums) {
        int n = nums.length;
        long[] minProds = new long[n];
        long[] maxProds = new long[n];
        minProds[0] = nums[0];
        maxProds[0] = nums[0];
        long max = nums[0];
        for(int i = 1; i < n; i++) {
            int cur = nums[i];
            // cur * 最大  || cur * 最小
            long preMax = cur * maxProds[i-1];
            long preMin = cur * minProds[i-1];
            minProds[i] = min(cur, preMax, preMin);
            maxProds[i] = max(cur, preMax, preMin);
            max = Math.max(max, maxProds[i]);
        }
        return (int)max;
    }   
    private long max(long num1, long num2, long num3) {
        long res = num1;
        if(num2 > res) {
            res = num2;
        }
        if(num3 > res) {
            res = num3;
        }
        return res;
    }
    private long min(long num1, long num2, long num3) {
        long res = num1;
        if(num2 < res) {
            res = num2;
        }
        if(num3 < res) {
            res = num3;
        }
        return res;
    }
效果
2ms 击败 72.91%
v1-暴力
思路
穷举所有可能的连续乘积,找到最大的那一个。
我们尝试不用 dp 来解决。
实现
    public int maxProduct(int[] nums) {
        // 任意两个位置的乘积        
        long max = nums[0];
        int n = nums.length;
        for(int i = 0; i < n; i++) {
            for(int j = i; j < n; j++) {
                long temp = calc(nums, i, j);
                max = Math.max(max, temp);
            }
        }
        return (int)max;
    }   
    private long calc(int[] nums, int start, int end) {
        long res = 1;
        for(int i = start; i <= end; i++) {
            if(nums[i] == 0) {
                return 0;    
            }
            
            res *= nums[i];
        }
        return res;
    }
结果
超出时间限制 186 / 190 个通过的测试用例
复杂度
TC: O(n²) 遍历,calc 也是 O(n),整体是 O(n^3)
反思
calc 可以进一步提升吗?
v2-前缀乘积
思路
如果我们能把 calc 提升到 O(1) 的话?
使用前缀乘积的话。
实现
一个支持 zero 的实现方式。
    class PrefixProductWithZero {
        private long[] prefix;
        private int[] nums;
        private List<Integer> zeroPos;
        public PrefixProductWithZero(int[] nums) {
            this.nums = nums;
            prefix = new long[nums.length];
            zeroPos = new ArrayList<>();
            long prod = 1;
            for (int i = 0; i < nums.length; i++) {
                if (nums[i] == 0) {
                    zeroPos.add(i);
                    prod = 1;
                    prefix[i] = 1; // 对于0,用1占位
                } else {
                    prod *= nums[i];
                    prefix[i] = prod;
                }
            }
        }
        public long query(int i, int j) {
            // 用二分查找 zeroPos 是否在 [i..j]
            int l = 0, r = zeroPos.size() - 1;
            while (l <= r) {
                int m = (l + r) / 2;
                if (zeroPos.get(m) < i) l = m + 1;
                else if (zeroPos.get(m) > j) r = m - 1;
                else return 0; // 区间内有0
            }
            if (i == 0) return prefix[j];
            return prefix[j] / prefix[i - 1];
        }
    }
实现
calc 改为用这个实现。
效果
超出时间限制 187 / 190 个通过的测试用例
v3-前缀乘积 + 双向扫描
思路
乘积最大子数组的问题之所以复杂,是因为:
1)负数会翻转乘积符号
偶数个负数 → 乘积为正 → 有可能是最大值
奇数个负数 → 乘积为负 → 可能错过最大值
2)零会把乘积断开
零把数组拆成多个段,最大乘积一定在某一段内
所以如果只做 单向扫描,可能错过某些 奇数个负数的情况。
那如果我们前后夹击呢?
反向扫描,就可以解决这个问题。
实现
    public int maxProduct(int[] nums) {
        int n = nums.length;
        int max = nums[0];
        int product = 1;
        // 正
        for(int i = 0; i < n; i++) {
            product *= nums[i];
            max = Math.max(product, max);
            if(nums[i] == 0) {
                product = 1;
            } 
        }
        // 逆序
        product = 1;
        for(int i = n-1; i > 0; i--) {
            product *= nums[i];
            max = Math.max(product, max);
            if(nums[i] == 0) {
                product = 1;
            }
        }
        return max;
    }   
效果
0ms 100%
反思
巧妙!
开源项目
为方便大家学习,所有相关文档和代码均已开源。
小结
希望本文对你有帮助,如果有其他想法的话,也可以评论区和大家分享哦。
各位极客的点赞收藏转发,是老马持续写作的最大动力!
下一节我们将讲解力扣经典,感兴趣的小伙伴可以关注一波,精彩内容,不容错过。
