前后缀分解
差分+前缀和重在差分,前缀和不过用来还原而已
前后缀分解重在区间查询优化
1. 前缀和
1.1. LC 2256 最小平均差
前缀和记得用long维护,不然溢出了
class Solution {
public int minimumAverageDifference(int[] nums) {
int n = nums.length;
long[] prefix = new long[n];
prefix[0] = nums[0];
for(int i=1;i<n;i++){
prefix[i] = prefix[i-1]+nums[i];
}
long min = Long.MAX_VALUE;
int ans = -1;
for(int i = 0;i<n;i++){
long cur = Math.abs(prefix[i] / (i + 1) - (i == (n - 1) ? 0 : ( (prefix[n - 1]-prefix[i]) / (n - i - 1))));
if(cur<min){
min = cur;
ans = i;
}
}
return ans;
}
}
1.2. LC 2909 元素和最小的山形三元组Ⅱ
很明显可以枚举山峰位置,向两边查找最小值。这个过程是O(n)的。假设查找是O(f(n))的话,那么总体复杂度就是O(nf(n))的。
从前向后维护前缀,代表从0到i的最小元素;从后向前维护后缀,代表从i到n-1的最小元素。这两个是O(n)的,查找就是O(1)的。
枚举山峰位置时,判断山峰高度是否大于两边最小值,是则更新最小元素和即可。总体O(n*1)=O(n)。
class Solution {
public int minimumSum(int[] nums) {
int n = nums.length;
int[] prefix = new int[n];
int[] suffix = new int[n];
prefix[0] = nums[0];
for(int i=1;i<n;i++){
prefix[i] = Math.min(nums[i],prefix[i-1]);
}
suffix[n-1] = nums[n-1];
for(int i=n-2;i>=0;i--){
suffix[i] = Math.min(nums[i],suffix[i+1]);
}
int ans = Integer.MAX_VALUE;
for(int i=1;i<=n-2;i++){
if(prefix[i-1]<nums[i] && suffix[i+1]<nums[i]){
ans = Math.min(ans,prefix[i-1]+nums[i]+suffix[i+1]);
}
}
return ans==Integer.MAX_VALUE?-1:ans;
}
}
1.3. LC 2483 商店的最小代价
令oC[i]表示在i小时开始时关门的情况下产生的开门代价
令cC[i]表示在i小时开始时关门的情况下产生的关门代价
则cost[i] = oC[i] + cC[i]
而oC显然是前缀数组,cC显然是后缀数组。oC代表0到i小时有几次没开门的;cC代表i到n小时有几次开门的。
注意第i小时关门时,第i小时的顾客涌入情况是不算入oC[i]的,而是cC[i]。因为第i小时已经关门了,就不用统计开门代价。
class Solution {
public int bestClosingTime(String customers) {
int n = customers.length();
char[] ch = customers.toCharArray();
int[] oC = new int[n+1];
int[] cC = new int[n+1];
oC[0] = 0;
for (int i = 1; i < oC.length; i++) {
oC[i] = oC[i-1];
if(ch[i-1]=='N'){
oC[i] += 1;
}
}
cC[n] = 0;
for(int i=n-1;i>=0;i--){
cC[i] = cC[i+1];
if(ch[i]=='Y'){
cC[i]+=1;
}
}
int min = Integer.MAX_VALUE;
int ans = -1;
int cur;
for(int i=0;i<=n;i++){
cur = cC[i]+oC[i];
if(cur<min){
min = cur;
ans = i;
}
}
return ans;
}
}
1.4. LC 2874 有序三元组中的最大值Ⅱ
对于任意j,显然若找到nums[i]的最大值nums[i0],以及nums[k]中的最大值nums[k0],则(i0,j,k0)的三元组值最大,无论j是正还是负(乘积为负用0卡掉就可以)
枚举j找最大值即可。
前缀维护[0,j]的最大值,后缀维护[j+1,n-1]的最大值,则
Max( val(i,j,k) ) = (pref[j-1] - nums[j]) * suf[j+1]
class Solution {
public long maximumTripletValue(int[] nums) {
// 前缀 nums[0,i]中的最大值
// 后缀 nums[i+1,n-1]中的最大值
int n = nums.length;
int[] suf = new int[n];
int[] pref = new int[n];
pref[0] = nums[0];
for(int i=1;i<n;i++){
pref[i] = Math.max(nums[i],pref[i-1]);
}
suf[n-1] = nums[n-1];
for(int i=n-2;i>=0;i--){
suf[i] = Math.max(nums[i],suf[i+1]);
}
long ans = 0L;
// 枚举 j 找两边最大值
for(int j=1;j<n-1;j++){
ans = Math.max(ans, (long) (pref[j - 1] - nums[j]) *suf[j+1]);
}
return ans;
}
}
优化:不要枚举j,枚举k。滚动数组维护最大差分,然后对于每个k乘以当前的最大差分,更新最大值即可。
不必担心造成最大差分的两个因子可能会出现在k后,因为是从前向后遍历的,因此最大差分的两个因子一定出现在k之前。
这样时间O(n),空间O(1)。而且常数方面比初版写法好。
class Solution {
public long maximumTripletValue(int[] nums) {
// 优化: 枚举k而不是j
long ans = 0L;
// 最大差分
long maxDiff = nums[0]-nums[1];
long maxNumK = Math.max(nums[0],nums[1]);
for(int k = 2;k<nums.length;k++){
ans = Math.max(ans,maxDiff*nums[k]);
maxDiff = Math.max(maxDiff,maxNumK-nums[k]);
maxNumK = Math.max(maxNumK,nums[k]);
}
return ans;
}
}
1.5. LC 2420 找到所有好下标
这题一开始理解错题意了,以为是左右两边存在长度至少为K的子序列。因为子序列最快也是nlgn,所以我一看1e6的数据范围感觉完全过不去,结果没想到是子数组不是子序列……闹麻了。
那么就是从左向右一个O(n)的DP维护单调数组长度,从右向左同样。最终O(n-2k)的遍历统计可能满足题意的下标。
这题的优化写法是在右向左的时候,一旦计算完转移后的状态,直接尝试统计当前下标。卡常可能快一点。
import java.util.ArrayList;
import java.util.List;
class Solution {
public List<Integer> goodIndices(int[] nums, int k) {
int n = nums.length;
int[] decDP = new int[n];
int[] incDP = new int[n];
decDP[0] = 1;
incDP[n-1]=1;
for(int i = 1;i<n;i++){
decDP[i] = 1;
if(nums[i]<=nums[i-1]){
decDP[i] = decDP[i-1]+1;
}
}
for(int i=n-2;i>=0;i--){
incDP[i]=1;
if(nums[i]<=nums[i+1]){
incDP[i] = incDP[i+1]+1;
}
}
ArrayList<Integer> ans = new ArrayList<>();
for(int i=k;i<n-k;i++){
if(decDP[i-1]>=k && incDP[i+1]>=k){
ans.add(i);
}
}
return ans;
}
}
1.6. LC 2439 最小化数组中的最大值
二分是O(nlgn)的比较慢。这里来一个前缀和做法。
-
对于nums[0],显然最小的最大值是他自己
-
对于nums[1],如果nums[1]>nums[0],就可以把nums[1]的压力转移到nums[0]上,即Math.ceil((nums[1]+nums[0])/2)
-
对于nums[2],因为我们只能向前转移压力,而不能向后转移压力。因此若nums[2]比之前的最小的最大值要大的话,可以向前转移压力,否则无法把之前的最小的最大值的压力转移到nums[2]上来,因此我们可以令m是之前最小的最大值,那么nums[0..2]的最小化最大值在nums[2]>m时为Math.ceil((nums[2]+m)/2)。
但需要注意能否将压力转移到前方还需要看前方的最小的最大值是否大于了当前的这个平均,如果大于的话其实还是无法转移到前方的,因为前方的最优情况还是满足不了当前的平均。
-
这个过程可以类推直到最后一个元素。
因此总体为O(n)的。
import java.util.Arrays;
class Solution {
public int minimizeArrayValue(int[] nums) {
long max = nums[0];
long sum = nums[0];
for(int i=1;i<nums.length;i++){
sum += nums[i]; // 求平均维护前缀和
if(max<nums[i]){
max = Math.max(max,(sum+i)/(i+1)) ; // 向上取整避免用浮点数的卡常trick
}
}
return (int) max;
}
}
1.7. LC 238 除自身以外数组的乘积
这题很明显板子了。维护前缀后缀,乘一下就行了。而且保证了不会爆int。
就是可以卡常,前缀算一遍,算后缀的时候直接乘就可以(我老是不会这样,虽然大家都是O(n)但常数时间差很多),同时可以原地更新,O(1)额外空间。
先放一个平民版:
class Solution {
public int[] productExceptSelf(int[] nums) {
int n = nums.length;
int[] suffix = new int[n];
int[] prefix = new int[n];
for (int i = 0; i < nums.length; i++) {
prefix[i] = i>=1?prefix[i-1]*nums[i]:nums[i];
}
for(int i=n-1;i>=0;i--){
suffix[i] = i<n-1?suffix[i+1]*nums[i]:nums[i];
}
int[] ans = new int[n];
int l,r;
for (int i = 0; i < n; i++) {
l = i>=1?prefix[i-1]:1;
r = i<n-1?suffix[i+1]:1;
ans[i] = l*r;
}
return ans;
}
}
卡常版:
class Solution {
public int[] productExceptSelf(int[] nums) {
int n = nums.length;
int[] ans = new int[n];
ans[0] = 1;
for (int i = 1; i < n; i++) {
ans[i] = ans[i-1]*nums[i-1];
}
int suffix = 1;
for(int i=n-1;i>=0;i--){
ans[i] *= suffix;
suffix*=nums[i];
}
return ans;
}
}
前缀算的时候,因为答案不带自己的乘积,所以ans[i]表示前方[0,i-1]的乘积,而ans[i-1]表示[0,i-2]的乘积,因此ans[i] = ans[i-1] * nums[i-1]。
后缀原地更新时,注意ans[i] = [0,i-1] * [i+1,n-1],因此后缀从1开始算,因为[n,n-1]不存在。最后一个数的后缀是1。
1.8. LC 2171 拿出最少数目的魔法豆
这道题可以枚举分界线e,<e的全部清零,>e的减少到e。注意这个枚举是不可以用二分的,因为并不存在单调性(例如分界线越高拿的豆子越少这种),很容易可以举出反例来。
这个分界线不一定要从整个数组的最小值逐渐+1枚举到最大值,可以直接用数组中的元素作为分界线。虽然这个不好证明,但还是可以接受的,举个例子:
[ 1 , 4 , 8 , 5 ]
完全没有必要枚举6或者7这样的分界线,因为这会把4,5全部清空的同时,还要让8拿走1/2个。所以只枚举数组中的元素即可。
但枚举分界线不代表写个暴力。我们先对数组排序,对于任意一个索引i,[0,i-1]都是要清空的,这很显然就是个前缀和。而[i+1,n-1]是后缀和。这个后缀和算起来并不是简单的累加,比如上面的例子排序之后为:
[ 1 , 4 , 5 , 8 ]
如果我们以4为分界线,8需要移除的就是 ( 8-5 ) + ( 5-1 ) + ( 5-1 )。意思就是先把8移除到5,然后再把两个5移除到4。所以显然我们要倒着遍历,移除的时候记得乘以移除次数即可。
import java.util.Arrays;
class Solution {
public long minimumRemoval(int[] beans) {
Arrays.sort(beans);
int n = beans.length;
long[] prefix = new long[n];
long[] suffix = new long[n];
for (int i = 1; i < prefix.length; i++) {
prefix[i] = prefix[i-1] + beans[i-1];
}
for(int i=suffix.length-2;i>=0;i--){
suffix[i] = suffix[i+1] + (long) (n - i - 1) *(beans[i+1]-beans[i]);
}
long ans = Long.MAX_VALUE;
for (int i = 0; i < n; i++) {
ans = Math.min(ans,prefix[i]+suffix[i]);
}
return ans;
}
}
1.9. LC 1124 表现良好的最长时间段
这题除了前缀和+单调栈,还有一个前缀和+散列表的做法(散列只是为了O(1)的查询和插入)
维护散列表,存放<前缀和,索引>的键值对。每次计算了一个前缀和sum,查询sum-1是否在散列表中,如果在,那么计算两个索引之间的距离。
另外我们计算的最长距离,所以碰到相同的前缀和,我们要维护的是索引靠前的。例如索引为3,6的位置的前缀和都是1,那么我们要的是3的那个1,而不是6的。假想一下,现在索引是10,sum=2,那么查询的时候,希望查到3还是6?为了更长我们当然希望要查到3。(10-3)>(10-6)
可能会有疑问:为什么是sum-1?按理说根据前缀和的性质,只要>就可以计算距离?也就是sum-1,sum-2,…,sum-(+∞)都应该考虑?
这是因为,前缀和维护的是从[0,i]的连续的子数组的和。那么sum-1出现的索引一定是比sum-2或者sum-其他的索引要小的。举个例子:
hours = [ 6,6,9,0,9,9,6 ]
prefix = [ -1,-2,-1,-2,-1,0,-1 ]
对于prefix[5] = 0而言,-1的第一次出现在于索引0处,而-2在索引1处。这是因为如果想要达到-2,必然要经过-1。也就是想要第一次达到-2,也必然已经第一次达到-1过了。所以查询sum-1很明显就是最远的位置。
import java.util.HashMap;
import java.util.Stack;
class Solution {
public int longestWPI(int[] hours) {
HashMap<Integer, Integer> m = new HashMap<>();
m.put(0,-1);
int ans = 0;
int sum = 0;
for (int i = 0; i < hours.length; i++) {
sum += (hours[i]>8?1:-1);
if(sum>0){
// 从头到现在都能>0,一定是目前最长的
ans = i+1;
}else{
ans = Math.max(ans,i-m.getOrDefault(sum-1,i));
}
if(!m.containsKey(sum)){
m.put(sum,i);
}
}
return ans;
}
}
1.10. LC 560 和为K的子数组
一开始昏头了写了个DP,O(nk)的。其实这题就是前缀和哈希表。遍历维护前缀和,哈希表维护某一前缀和出现的次数:
from typing import List
from collections import defaultdict
class Solution:
def subarraySum(self, nums: List[int], k: int) -> int:
m = defaultdict(int)
m[0] = 1
pre = 0
res = 0
for i in range(len(nums)):
pre += nums[i]
res += m[pre-k]
m[pre] += 1
return res