最大乘积子数组(进阶版:允许一次翻转某个子数组)
题目描述
给定一个整数数组 nums,可以最多翻转数组的任意一个连续子数组一次(即反转该子数组的顺序)。翻转后,返回可能得到的最大子数组乘积。子数组是原始数组的一个连续子序列。数组中的整数可以是正数、负数或零。
示例:
输入:nums = [2,3,-2,4,-1]
解释:不翻转时,最大子数组乘积是 23=6。但我们可以翻转子数组[-2,4,-1]得到[-1,4,-2],此时数组变为[2,3,-1,4,-2],最大子数组乘积是3 -1 * 4 * -2 = 24。
输出:24
解题过程循序渐进讲解
这个问题是在经典“最大乘积子数组”基础上的一个增强变体。核心挑战在于如何将“一次翻转”的操作融入动态规划的状态设计中。我们逐步分析:
步骤1:回顾基础问题(最大乘积子数组)
经典问题(LeetCode 152)中,我们定义两个DP数组:
max_dp[i]:以nums[i]结尾的子数组的最大乘积。min_dp[i]:以nums[i]结尾的子数组的最小乘积(用于处理负数相乘得正的情况)。
转移方程:
\[\begin{aligned} \text{max\_dp}[i] &= \max(\text{nums}[i],\ \text{max\_dp}[i-1] \times \text{nums}[i],\ \text{min\_dp}[i-1] \times \text{nums}[i]) \\ \text{min\_dp}[i] &= \min(\text{nums}[i],\ \text{max\_dp}[i-1] \times \text{nums}[i],\ \text{min\_dp}[i-1] \times \text{nums}[i]) \end{aligned} \]
最终答案即所有 max_dp[i] 中的最大值。
时间复杂度 O(n),空间可优化到 O(1)。
步骤2:理解“翻转一个子数组”的影响
翻转一个子数组 nums[l..r] 会改变元素顺序,相当于反转了这一段中连续乘积的“方向”。
例如原数组 [A, B, C, D],翻转 B,C 得到 [A, C, B, D],那么原来以 C 结尾的乘积在翻转后可能变成以 B 结尾(方向反了)。
关键观察:
任意翻转一段子数组,相当于从原数组中选取三个连续段:前缀 + 反转段 + 后缀,其中反转段内部顺序倒置。
我们要找的就是一种分割方式,使某个子数组乘积最大,这个子数组可能:
- 完全不经过翻转段(即经典问题的答案)。
- 完全在翻转段内部(但顺序反了,乘积和原来一样,因为乘法满足交换律,所以这种情况等价于经典问题,无需特殊处理)。
- 跨越翻转段的边界:这是翻转带来新可能性的地方。例如子数组一部分在反转段前,一部分在反转段后,中间是反转段的一部分但顺序颠倒。
步骤3:将问题转化为“拼接乘积”
考虑翻转子数组 nums[l..r]。
设我们要计算的子数组起始在 i,结束在 j,且它们跨越翻转段的边界。实际上,翻转后,原来顺序 nums[i..j] 可能被拆成两段乘积的拼接。
更直观的方法:
将原数组分成三部分:Left, Mid, Right。翻转 Mid 后,新数组是 Left + reversed(Mid) + Right。
在新数组中,一个子数组可能是:
- 在 Left 内部
- 在 reversed(Mid) 内部
- 在 Right 内部
- 跨越 Left 和 reversed(Mid) 的连接处
- 跨越 reversed(Mid) 和 Right 的连接处
- 跨越 Left, reversed(Mid), Right 三部分
由于 reversed(Mid) 内部乘积和原 Mid 乘积一样(乘法交换律),唯一不同是连接处的乘积顺序变化。
所以,我们只需要考虑连接处。
步骤4:定义状态以处理翻转
一个巧妙的方法:
定义四个数组,分别表示从某个方向计算的连续乘积:
prefix_max[i]:从数组开头到 i 的某个连续子数组(以 i 为结尾)的最大乘积(即经典问题从左到右的max_dp)。prefix_min[i]:从开头到 i 的连续子数组(以 i 为结尾)的最小乘积。suffix_max[i]:从数组末尾到 i 的某个连续子数组(以 i 为起点)的最大乘积(即从右到左计算的类似 DP)。suffix_min[i]:从末尾到 i 的连续子数组(以 i 为起点)的最小乘积。
如何计算 suffix_* 数组?
我们从右往左扫描,定义:
suffix_max[i]表示从 i 开始往右的连续子数组(以 i 为起点)的最大乘积。- 类似地,
suffix_min[i]是最小乘积。
递推(从右到左,j 从 n-1 到 0):
\[\begin{aligned} \text{if } j = n-1: & \quad \text{suffix\_max}[j] = \text{suffix\_min}[j] = nums[j] \\ \text{else}: & \quad \text{suffix\_max}[j] = \max(nums[j],\ nums[j] \times \text{suffix\_max}[j+1],\ nums[j] \times \text{suffix\_min}[j+1]) \\ & \quad \text{suffix\_min}[j] = \min(nums[j],\ nums[j] \times \text{suffix\_max}[j+1],\ nums[j] \times \text{suffix\_min}[j+1]) \end{aligned} \]
步骤5:利用状态计算翻转带来的新乘积
翻转子数组 nums[l..r] 后,数组变为:
nums[0..l-1] + reverse(nums[l..r]) + nums[r+1..n-1]。
考虑一个跨越 l-1 和 l 边界的子数组(即包含 l-1 和翻转段的开头部分):
翻转前,这个子数组是 nums[k..l-1] 和 nums[l..m] 的乘积,但翻转后,nums[l..m] 在 reversed 段中变成了 nums[r - (m-l) .. r] 的顺序,不直观。
但我们可以这样想:
翻转后,原来的 nums[l..r] 顺序反了,原来位于 l 的元素现在在 r 位置。
所以,翻转后跨越边界的子数组,相当于在原数组中选取两段不连续的部分,它们的乘积相乘,但这两段在原数组中是“背靠背”方向相反的。
具体来说,翻转后,新数组在位置 l-1 之后的元素是原 nums[r]。
所以,跨越边界的一个子数组(在原数组中是 i..j 且 i <= l-1 < r <= j)的乘积,可以看作:
- 在原数组中以 l-1 结尾的某段最大/最小乘积(来自 prefix_*[l-1])
- 乘以
- 在原数组中以 r 开始的某段最大/最小乘积(来自 suffix_*[r])
因为翻转后,两段在边界处是原数组的 l-1 位置和 r 位置直接相连。
因此,对于每个可能的边界 (l-1, r),其中 l <= r,翻转子数组 l..r 后,新数组在边界处能得到的最大乘积候选为:
\[\max( \text{prefix\_max}[l-1] \times \text{suffix\_max}[r],\ \text{prefix\_min}[l-1] \times \text{suffix\_min}[r] ) \]
但注意,l-1 可能不存在(l=0),则前半段为空,乘积为 1(但子数组不能为空,所以我们只考虑 l>=1 的情况,或者前半段为空时,只取后半段)。
同理,我们也要考虑边界 (r, r+1) 的跨越情况,但对称,可以统一处理。
步骤6:统一公式
我们可以枚举所有可能的“分界点”p,将数组看作 A = nums[0..p] 和 B = nums[p+1..n-1],然后考虑翻转 A 的某个后缀和 B 的某个前缀组成的一段,但这样复杂。
更直接枚举法:
枚举所有可能的翻转区间 [l, r],然后计算翻转后整个数组的最大子数组乘积。但这样 O(n³) 太慢。
优化:
我们只需要考虑翻转后,那些跨越原数组某个位置 p 和 p+1 的子数组,其中 p 是翻转区间的左边界-1 或右边界。
实际上,可以证明:翻转后能获得的最大子数组乘积,一定是以某个位置 p 为“缝合点”,将原数组的两段乘积(一段以 p 结尾,一段以 p+1 开头)相乘得到。
这是因为翻转操作相当于将两段不连续的乘积“粘合”在一起,粘合点就是 p 和 p+1 在原数组的位置。
所以,我们枚举每个可能的 p(0 <= p < n-1),然后考虑翻转子数组 [p+1, r] 或 [l, p] 等等,但最终等价于计算:
\[\text{candidate} = \max\{ \text{prefix\_max}[p] \times \text{suffix\_max}[p+1],\ \text{prefix\_min}[p] \times \text{suffix\_min}[p+1] \} \]
然后,我们还需要考虑这两段各自的符号,有可能负负得正,所以上面取了最大和最小两种可能。
步骤7:最终算法步骤
- 计算
prefix_max[i],prefix_min[i]从左到右。 - 计算
suffix_max[i],suffix_min[i]从右到左。 - 初始化答案 ans 为经典最大子数组乘积(即不翻转的情况),就是 max(prefix_max) 和 max(suffix_max) 的较大者。
- 对于每个位置 p = 0 到 n-2:
- 计算 candidate1 = prefix_max[p] * suffix_max[p+1]
- 计算 candidate2 = prefix_min[p] * suffix_min[p+1]
- 更新 ans = max(ans, candidate1, candidate2)
- 返回 ans。
注意:如果某一段为空(比如 p 是 -1 或 n-1),那么乘积等于另一段的值,但我们的枚举已经包含在不翻转的情况中。
步骤8:边界和细节
- 当数组长度<=1,翻转无意义,答案就是唯一元素。
- 乘法涉及整数,注意乘积可能溢出(题目一般用 32 位整数,但乘积可能超过 32 位,可用 64 位存储)。
- 当 p 不存在时(n=1)跳过枚举。
步骤9:举例验证
nums = [2,3,-2,4,-1]
计算 prefix_max = [2,6,-2,4,-4](实际是每个位置结尾的最大乘积)
经典最大乘积是 6。
suffix_max 从右到左:
- 初始化:i=4: -1
- i=3: max(4, 4×-1= -4) → 4
- i=2: max(-2, -2×4=-8, -2×-1=2) → 2
- i=1: max(3, 3×2=6, 3×-4=-12) → 6
- i=0: max(2, 2×6=12, 2×-8=-16) → 12
所以 suffix_max = [12,6,2,4,-1]
枚举 p:
- p=0: max(26=12, 26=12) → 12
- p=1: max(62=12, -12 -4=48) → 48
- p=2: max(-24=-8, -24=-8) → -8
- p=3: max(4*-1=-4, 4*-1=-4) → -4
最大 candidate 是 48,但注意 48 来自 prefix_min[1]=-12 和 suffix_min[2]=-4 乘积 48。
检查是否可能:
p=1 对应原数组分割 [2,3] 和 [-2,4,-1],翻转区间是 [p+1, r] 即 [2,4],翻转后数组 [2,3,-1,4,-2],此时最大子数组乘积是 3*-14-2=24,不是 48。
说明我们的 candidate 48 是无效的,因为 prefix_min[1]=-12 对应的子数组是 [2,3,-2]?等等,我们算一下:
prefix_min[1] 是到下标 1 结尾的最小子数组乘积:
prefix_min[1] = min(3, 23=6, 23=6) 不对,经典递推:
i=0: (2,2)
i=1: max(3, 23=6, 23=6)=6, min(3, 23=6, 23=6)=3? 错!
重算:
i=0: max=2, min=2
i=1: 候选(3, 23=6, 23=6) → max=6, min=3(因为3更小) 对。
所以 prefix_min[1]=3 不是 -12。
我们发现之前的 suffix_min 算错了。我们来正确计算。
正确计算 suffix_min:
i=4: suffix_max[4]=-1, suffix_min[4]=-1
i=3: 候选(4, 4×-1=-4, 4×-1=-4) → max=4, min=-4
i=2: 候选(-2, -2×4=-8, -2×-4=8) → max=8, min=-8
i=1: 候选(3, 3×8=24, 3×-8=-24) → max=24, min=-24
i=0: 候选(2, 2×24=48, 2×-24=-48) → max=48, min=-48
所以 suffix_max = [48,24,8,4,-1], suffix_min = [-48,-24,-8,-4,-1]
prefix_max = [2,6,-2,4,-4], prefix_min=[2,3,-12,-48,48](这里我快速递推:
i=0: (2,2)
i=1: max(3,6,6)=6, min(3,6,6)=3
i=2: max(-2,6*-2=-12,3*-2=-6) = -2, min(-2,-12,-6)=-12
i=3: max(4,-24=-8,-124=-48)=4, min(4,-8,-48)=-48
i=4: max(-1,4*-1=-4,-48*-1=48)=48, min(-1,-4,48)=-4
所以 prefix_min=[2,3,-12,-48,-4] 注意最后是 -4,不是 48 我写错了。修正:
i=4: 候选(-1, 4*-1=-4, -48*-1=48) → max=48, min=-4 对,所以 prefix_min[4] = -4。
现在枚举 p:
p=0: max(224=48, 2-24=-48) → 48
p=1: max(68=48, 3-8=-24) → 48
p=2: max(-24=-8, -12-4=48) → 48
p=3: max(4*-1=-4, -48*-1=48) → 48
所以多个 p 得到 48。但 48 可能吗?
p=2: prefix_min[2] = -12 对应子数组 [2,3,-2] 乘积 -12,suffix_min[3] = -4 对应子数组 [4,-1] 乘积 -4,相乘 (-12)(-4)=48。
这在翻转后对应什么?p=2 对应原数组分割 [0..2] 和 [3..4],翻转区间是 [p+1, r] 即 [3,4],翻转后数组 [2,3,-2, -1,4]。最大子数组乘积是 3-2*-1*4=24,不是 48。
所以候选 48 并不对应实际的一个子数组,因为 prefix_min[2] 和 suffix_min[3] 对应的子数组在原数组中不相邻,翻转后也不能连成一段(因为中间隔了未翻转部分)。
这说明我们的枚举方法有缺陷。实际上,我们枚举 p 时,必须确保两段在原数组中相邻,翻转其中一段后它们才能连起来。我们的公式错误地允许了任意两段相乘。
步骤10:修正方法
正确方法:枚举翻转区间 [l,r],然后计算新数组的最大子数组乘积,但这样 O(n³)。优化:
我们可以用 O(n²) 的时间枚举所有区间,对每个区间翻转,用类似最大子数组乘积的方法 O(n) 计算,总体 O(n³) 仍大。
但注意到,翻转区间 [l,r] 后,最大子数组要么完全在翻转区间内(同原数组),要么跨越边界。跨越边界时,一定是原数组中以 l-1 结尾的某段和以 r+1 开头的某段,加上整个翻转区间,因为翻转区间是整体反转,它的最大/最小乘积可以预处理。
定义:
max_prod_from[i]= 从 i 开始的最大连续乘积(类似 suffix_max 但必须从 i 开始)min_prod_from[i]max_prod_to[i]= 以 i 结尾的最大连续乘积(prefix_max[i])
然后翻转区间 [l,r] 后,新数组的最大子数组乘积候选为:
- 完全在左边:max_prod_to[l-1]
- 完全在右边:max_prod_from[r+1]
- 跨越左边和翻转区间:max{ max_prod_to[l-1] * max_prod_rev, min_prod_to[l-1] * min_prod_rev } 等,其中 max_prod_rev 是翻转区间的最大连续乘积(等于原区间从 r 到 l 的方向计算,可以用类似 suffix/prefix 的方法得到)。
- 跨越翻转区间和右边:类似。
这样,如果我们预处理出每个区间的正反方向最大最小乘积,就可以 O(1) 计算每个翻转区间的结果。但预处理所有区间是 O(n²),加上枚举区间 O(n²),总体 O(n²) 可行。
由于时间关系,我们给出最终 O(n²) 的算法步骤(适用于 n 不太大时):
- 预处理原数组的 prefix_max, prefix_min, suffix_max, suffix_min。
- 预处理 rev_max[l][r] 和 rev_min[l][r] 表示原数组区间 [l,r] 反转后的最大/最小连续乘积(从右到左计算,等价于从 r 到 l 的正向计算,可以用类似 suffix 的方法在 O(n²) 内算出)。
- 初始答案 = 经典最大子数组乘积。
- 枚举所有可能的翻转区间 [l,r](0 <= l <= r < n):
- 不跨越边界的情况已在经典答案中。
- 跨越左边和翻转区间:如果 l>0,计算候选 = 最优组合(prefix_max[l-1], prefix_min[l-1], rev_max[l][r], rev_min[l][r]),即四种乘法的最大值。
- 跨越翻转区间和右边:如果 r<n-1,计算候选 = 最优组合(rev_max[l][r], rev_min[l][r], suffix_max[r+1], suffix_min[r+1])。
- 跨越左、翻转、右三部分:如果 l>0 且 r<n-1,候选 = 最优组合(prefix_max[l-1], prefix_min[l-1], rev_max[l][r], rev_min[l][r], suffix_max[r+1], suffix_min[r+1]),即八种乘法的最大值。
- 更新全局最大值。
这个算法是 O(n²),在 n <= 200 时可行。对于更大 n,可能有 O(n) 的解法,但更复杂,需进一步推导。
总结
本题核心在于理解翻转操作如何改变连续乘积的拼接方式,并通过预处理各个方向的最大最小乘积,枚举翻转区间来求得可能的最大乘积。由于完整 O(n) 解法较复杂,这里给出了 O(n²) 的可行思路。你可以先实现 O(n²) 版本,再尝试优化。