问题描述
给你一个整数数组 nums 和一个整数 k。
你可以 多次 选择 连续 子数组 nums,其元素和可以被 k 整除,并将其删除;每次删除后,剩余元素会填补空缺。
返回在执行任意次数此类删除操作后,nums 的最小可能 和。
示例 1:
- 输入:
nums = [1,1,1],k = 2 - 输出:
1
解释:
删除子数组 nums[0..1] = [1, 1],其和为 2(可以被 2 整除),剩余 [1]。
剩余数组的和为 1。
示例 2:
- 输入:
nums = [3,1,4,1,5],k = 3 - 输出:
5
解释:
首先删除子数组 nums[1..3] = [1, 4, 1],其和为 6(可以被 3 整除),剩余数组为 [3, 5]。
然后删除子数组 nums[0..0] = [3],其和为 3(可以被 3 整除),剩余数组为 [5]。
剩余数组的和为 5。
提示:
1 <= nums.length <= 10^51 <= nums[i] <= 10^61 <= k <= 10^5
解法
第一步:理解问题的核心
首先,我们来分析这个“删除操作”。我们可以删除任何一个和能被 k 整除的 连续子数组。删除后,剩下的部分会拼接起来。这个“拼接”操作是问题的难点所在,因为它会创造出新的、原来并不相邻的子数组。
例如,在 [2, 3, 4, 1] 和 k=5 中:
- 我们看到子数组
[2, 3]的和是 5,可以被 5 整除。删除它,数组剩下[4, 1]。 - 此时,
4和1拼接成了新的连续子数组[4, 1]。它的和也是 5,可以被 5 整除。 - 删除
[4, 1],数组剩下[],和为 0。
直接模拟这个过程会非常复杂,因为每次删除都会改变数组结构,我们需要不断地重新扫描。当数组长度达到 10^5 时,这种模拟是不可行的。因此,我们需要找到一个更高效的视角来理解这个问题。
第二步:转换问题视角 —— 从“删除”到“保留”
我们想要最小化最终数组的和。这等价于最大化所有被删除的元素的总和。
所有被删除的元素构成了一个或多个不相交的子数组,每个子数组的和都是 k 的倍数。因此,所有被删除元素的总和也必然是 k 的倍数。
设原数组的总和为 TotalSum,最终保留的数组的和为 RemainingSum,被删除的所有元素的总和为 DeletedSum。
我们有:
RemainingSum = TotalSum - DeletedSum
为了最小化 RemainingSum,我们需要最大化 DeletedSum。同时,DeletedSum 必须满足 DeletedSum % k == 0。
这似乎将问题转化为了:从原数组中找到一个子序列(不一定连续),使得这个子序列的和 S 是 k 的倍数,并且 S 尽可能大。剩下的元素的和 TotalSum - S 就是我们的答案。
然而,这个子序列必须是通过删除连续子数组得到的。这个约束我们还没有解决。
第三步:关键洞察 —— 前缀和与同余
“子数组的和”这个问题,通常会让我们联想到 前缀和 (Prefix Sum)。
- 定义
P[i]为数组nums从第 0 个元素到第i-1个元素的和。即P[i] = nums[0] + ... + nums[i-1]。为了方便,我们定义P[0] = 0。 - 那么,一个连续子数组
nums[j...i-1](包含nums[j]和nums[i-1]) 的和就是P[i] - P[j]。
我们删除子数组 nums[j...i-1] 的条件是 (P[i] - P[j]) % k == 0。
根据模运算的性质,这等价于 P[i] % k == P[j] % k。
这就是解决问题的钥匙!
如果两个不同位置的前缀和 P[i] 和 P[j] (假设 i > j) 除以 k 的余数相同,那么它们之间的那段子数组 nums[j...i-1] 的和就一定能被 k 整除,因此可以被“删除”。
这个“删除”操作,从前缀和的角度看,就像是建立了一条从状态 j 到状态 i 的“传送带”。我们计算到 j 之后,可以通过删除中间的元素,直接“跳跃”到 i 的状态,而我们付出的代价(保留的元素和)并不会增加 P[i] - P[j] 这一段。
第四步:构建动态规划 (DP) 解法
基于前缀和的洞察,我们可以设计一个动态规划的解法。
DP 状态定义
我们从左到右遍历数组,我们需要知道在处理到每个位置时,能得到的最小数组和是多少。
定义 dp[i] 为:考虑原数组的前 i 个元素 (nums[0...i-1]),经过任意次删除操作后,所能得到的最小的和。
我们的最终目标是 dp[nums.length]。
DP 转移方程
当我们计算 dp[i] 时,我们站在第 i 个元素 (nums[i-1]) 的位置上。有两种可能:
-
不删除以
nums[i-1]结尾的任何子数组: 这种情况下,我们将nums[i-1]这个元素加到dp[i-1]所代表的最小和之上。 所以,这部分的和为dp[i-1] + nums[i-1]。 -
删除一个以
nums[i-1]结尾的子数组nums[j...i-1]: 这个删除操作是合法的,当且仅当(P[i] - P[j]) % k == 0,即P[i] % k == P[j] % k。 如果执行了这个删除,那么前i个元素经过操作后,剩下的部分就等同于对前j个元素进行操作后的结果。我们希望这个结果尽可能小,所以我们应该取dp[j]。 我们可能有很多个这样的j(j < i) 满足P[j] % k == P[i] % k。为了让dp[i]最小,我们应该选择其中能提供最小dp[j]值的那个j。
综合这两种情况,dp[i] 应该取其中的最小值:
优化 DP
这个转移方程需要遍历所有满足条件的 j,导致 O(N^2) 的时间复杂度,对于 N=10^5 会超时。
我们需要优化 这一项的计算。
注意到,对于一个固定的 i,所有备选的 j 都具有相同的 P[j] % k 值。我们可以用一个辅助数据结构来记录对于每一个余数 ,我们目前见到的 dp[j] 的最小值是多少。
我们用一个哈希表(或数组)min_dp_for_rem 来实现这个功能:
min_dp_for_rem[r] 存储 min{dp[j]},其中 j 是所有满足 P[j] % k == r 并且已经被计算过的位置。
第五步:最终算法流程
-
初始化一个大小为
k的数组min_dp_for_rem,所有值都设为一个极大值(代表无穷大),min_dp_for_rem[0] = 0。- 为什么
min_dp_for_rem[0] = 0?因为在开始之前,我们有一个空的前缀(长度为 0),其和为 0,0 % k = 0。对应的dp[0]也是 0。这个初始状态是必须的。
- 为什么
-
初始化
current_dp = 0(代表dp[0]) 和prefix_sum = 0。 -
遍历
nums数组中的每一个元素num(从i = 1到N):a. 更新前缀和:
prefix_sum += num。b. 计算当前前缀和的余数:
remainder = prefix_sum % k。c. 计算
dp[i]:i. 情况1(不删除):
option1 = current_dp + num。ii. 情况2(删除):从
min_dp_for_rem中查找具有相同余数的历史最小dp值:option2 = min_dp_for_rem[remainder]。iii.
new_dp = min(option1, option2)。这就是dp[i]的值。d. 更新
min_dp_for_rem: 我们刚刚计算出了dp[i],它的前缀和余数是remainder。我们需要用这个新的dp值去更新对应余数的历史最小值:min_dp_for_rem[remainder] = min(min_dp_for_rem[remainder], new_dp)。e. 更新
current_dp = new_dp,为下一次循环做准备。 -
循环结束后,
current_dp的值就是dp[N],即最终答案。
示例 walkthrough:nums = [3,1,4,1,5], k = 3
-
初始化:
min_dp_for_rem = [0, inf, inf]prefix_sum = 0current_dp = 0
-
处理
num = 3(i=1):prefix_sum= 3remainder= 3 % 3 = 0option1(保留) =current_dp + 3= 0 + 3 = 3option2(删除) =min_dp_for_rem[0]= 0 (这对应于删除子数组[3]本身)new_dp=min(3, 0)= 0- 更新
min_dp_for_rem[0]=min(min_dp_for_rem[0], 0)= 0 current_dp变为 0
-
处理
num = 1(i=2):prefix_sum= 3 + 1 = 4remainder= 4 % 3 = 1option1(保留) =current_dp + 1= 0 + 1 = 1option2(删除) =min_dp_for_rem[1]=inf(之前没见过余数1)new_dp=min(1, inf)= 1- 更新
min_dp_for_rem[1]=min(inf, 1)= 1 current_dp变为 1
-
处理
num = 4(i=3):prefix_sum= 4 + 4 = 8remainder= 8 % 3 = 2option1(保留) =current_dp + 4= 1 + 4 = 5option2(删除) =min_dp_for_rem[2]=infnew_dp=min(5, inf)= 5- 更新
min_dp_for_rem[2]=min(inf, 5)= 5 current_dp变为 5
-
处理
num = 1(i=4):prefix_sum= 8 + 1 = 9remainder= 9 % 3 = 0option1(保留) =current_dp + 1= 5 + 1 = 6option2(删除) =min_dp_for_rem[0]= 0 (删除子数组[1,4,1],对应P[4]-P[1],回到dp[1]的状态,但min_dp_for_rem记录的是dp[0]的0更优)new_dp=min(6, 0)= 0- 更新
min_dp_for_rem[0]=min(0, 0)= 0 current_dp变为 0
-
处理
num = 5(i=5):prefix_sum= 9 + 5 = 14remainder= 14 % 3 = 2option1(保留) =current_dp + 5= 0 + 5 = 5option2(删除) =min_dp_for_rem[2]= 5 (删除子数组[1,5],对应P[5]-P[3], 回到dp[3]的状态)new_dp=min(5, 5)= 5- 更新
min_dp_for_rem[2]=min(5, 5)= 5 current_dp变为 5
-
循环结束。最终答案是
current_dp= 5。
代码实现
import java.util.Arrays;
public class Solution {
/**
* 计算删除和可被k整除的连续子数组后,数组的最小可能和。
*
* @param nums 整数数组
* @param k 一个整数
* @return 执行任意次数删除操作后,nums的最小可能和
*/
public long minArraySumAfterRemovals(int[] nums, int k) {
// minDpForRem[r] 存储到目前为止,
// 对于所有前缀和 P[j] % k == r 的位置 j,
// 对应的 dp[j] 的最小值。
// 使用 long 类型防止求和溢出。
long[] minDpForRem = new long[k];
// 初始化为一个非常大的值,代表正无穷。
// 我们不能用 Long.MAX_VALUE,因为它加上一个正数会溢出变成负数。
// 一个足够大的数即可,比如 10^5 * 10^6 + 1。
long infinity = (long) 1e12;
Arrays.fill(minDpForRem, infinity);
// 基础情况:空前缀(和为0)的最小和是0,其和模k的余数也是0。
minDpForRem[0] = 0;
long prefixSum = 0;
// currentDp 相当于 dp[i-1],我们在循环中计算 dp[i]
long currentDp = 0;
for (int num : nums) {
prefixSum += num;
int remainder = (int) (prefixSum % k);
// 为了处理负数余数的情况(在Java中 % 运算符可能返回负数)
if (remainder < 0) {
remainder += k;
}
// 选项1:保留当前元素 num,将其加到上一步的最小和上
long option1_keep = currentDp + num;
// 选项2:将 num 作为某个被删除子数组的结尾。
// 这意味着我们可以回退到之前某个前缀和余数也为 `remainder` 的状态。
// 我们选择这些状态中 dp 值最小的那个。
long option2_delete = minDpForRem[remainder];
// newDp 就是当前这一步的最小和,即 dp[i]
long newDp = Math.min(option1_keep, option2_delete);
// 用新计算出的 dp[i] 值更新其对应余数的历史最小值
minDpForRem[remainder] = Math.min(minDpForRem[remainder], newDp);
// 为下一次迭代准备
currentDp = newDp;
}
return currentDp;
}
public static void main(String[] args) {
Solution sol = new Solution();
// 示例 1
int[] nums1 = {1, 1, 1};
int k1 = 2;
System.out.println("示例 1: nums = [1,1,1], k = 2, 输出: " + sol.minArraySumAfterRemovals(nums1, k1)); // 预期: 1
// 示例 2
int[] nums2 = {3, 1, 4, 1, 5};
int k2 = 3;
System.out.println("示例 2: nums = [3,1,4,1,5], k = 3, 输出: " + sol.minArraySumAfterRemovals(nums2, k2)); // 预期: 5
// 额外示例
int[] nums3 = {2, 3, 4, 1};
int k3 = 5;
System.out.println("额外示例: nums = [2, 3, 4, 1], k = 5, 输出: " + sol.minArraySumAfterRemovals(nums3, k3)); // 预期: 0
}
}
复杂度分析
- 时间复杂度: 。我们只对输入数组
nums进行了一次遍历。在循环中,所有操作(加法、取模、数组访问)都是常数时间。 - 空间复杂度: 。我们使用了一个大小为
k的数组min_dp_for_rem来存储中间状态。