用带锚点的动态规划解决全局最优问题:删除可整除和后的最小数组和

128 阅读10分钟

问题描述

给你一个整数数组 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^5
  • 1 <= nums[i] <= 10^6
  • 1 <= k <= 10^5

解法

第一步:理解问题的核心

首先,我们来分析这个“删除操作”。我们可以删除任何一个和能被 k 整除的 连续子数组。删除后,剩下的部分会拼接起来。这个“拼接”操作是问题的难点所在,因为它会创造出新的、原来并不相邻的子数组。

例如,在 [2, 3, 4, 1]k=5 中:

  1. 我们看到子数组 [2, 3] 的和是 5,可以被 5 整除。删除它,数组剩下 [4, 1]
  2. 此时,41 拼接成了新的连续子数组 [4, 1]。它的和也是 5,可以被 5 整除。
  3. 删除 [4, 1],数组剩下 [],和为 0。

直接模拟这个过程会非常复杂,因为每次删除都会改变数组结构,我们需要不断地重新扫描。当数组长度达到 10^5 时,这种模拟是不可行的。因此,我们需要找到一个更高效的视角来理解这个问题。

第二步:转换问题视角 —— 从“删除”到“保留”

我们想要最小化最终数组的和。这等价于最大化所有被删除的元素的总和

所有被删除的元素构成了一个或多个不相交的子数组,每个子数组的和都是 k 的倍数。因此,所有被删除元素的总和也必然是 k 的倍数。

设原数组的总和为 TotalSum,最终保留的数组的和为 RemainingSum,被删除的所有元素的总和为 DeletedSum。 我们有: RemainingSum = TotalSum - DeletedSum

为了最小化 RemainingSum,我们需要最大化 DeletedSum。同时,DeletedSum 必须满足 DeletedSum % k == 0

这似乎将问题转化为了:从原数组中找到一个子序列(不一定连续),使得这个子序列的和 Sk 的倍数,并且 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]) 的位置上。有两种可能:

  1. 不删除以 nums[i-1] 结尾的任何子数组: 这种情况下,我们将 nums[i-1] 这个元素加到 dp[i-1] 所代表的最小和之上。 所以,这部分的和为 dp[i-1] + nums[i-1]

  2. 删除一个以 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[i]=min(dp[i1]+nums[i1],minj<iP[j]%k=P[i]%k{dp[j]})dp[i] = \min(dp[i-1] + \text{nums}[i-1], \min_{\substack{j<i \\ P[j] \% k = P[i] \% k}} \{dp[j]\})

优化 DP

这个转移方程需要遍历所有满足条件的 j,导致 O(N^2) 的时间复杂度,对于 N=10^5 会超时。

我们需要优化 minj<iP[j]%k=P[i]%k{dp[j]}\min_{\substack{j<i \\ P[j] \% k = P[i] \% k}} \{dp[j]\} 这一项的计算。 注意到,对于一个固定的 i,所有备选的 j 都具有相同的 P[j] % k 值。我们可以用一个辅助数据结构来记录对于每一个余数 r[0,k1]r \in [0, k-1],我们目前见到的 dp[j] 的最小值是多少。

我们用一个哈希表(或数组)min_dp_for_rem 来实现这个功能: min_dp_for_rem[r] 存储 min{dp[j]},其中 j 是所有满足 P[j] % k == r 并且已经被计算过的位置。

第五步:最终算法流程

  1. 初始化一个大小为 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。这个初始状态是必须的。
  2. 初始化 current_dp = 0 (代表 dp[0]) 和 prefix_sum = 0

  3. 遍历 nums 数组中的每一个元素 num(从 i = 1N):

    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,为下一次循环做准备。

  4. 循环结束后,current_dp 的值就是 dp[N],即最终答案。

示例 walkthrough:nums = [3,1,4,1,5], k = 3

  • 初始化:

    • min_dp_for_rem = [0, inf, inf]
    • prefix_sum = 0
    • current_dp = 0
  • 处理 num = 3 (i=1):

    • prefix_sum = 3
    • remainder = 3 % 3 = 0
    • option1 (保留) = current_dp + 3 = 0 + 3 = 3
    • option2 (删除) = 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 = 4
    • remainder = 4 % 3 = 1
    • option1 (保留) = current_dp + 1 = 0 + 1 = 1
    • option2 (删除) = 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 = 8
    • remainder = 8 % 3 = 2
    • option1 (保留) = current_dp + 4 = 1 + 4 = 5
    • option2 (删除) = min_dp_for_rem[2] = inf
    • new_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 = 9
    • remainder = 9 % 3 = 0
    • option1 (保留) = current_dp + 1 = 5 + 1 = 6
    • option2 (删除) = 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 = 14
    • remainder = 14 % 3 = 2
    • option1 (保留) = current_dp + 5 = 0 + 5 = 5
    • option2 (删除) = 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
    }
}

复杂度分析

  • 时间复杂度: O(N)O(N)。我们只对输入数组 nums 进行了一次遍历。在循环中,所有操作(加法、取模、数组访问)都是常数时间。
  • 空间复杂度: O(k)O(k)。我们使用了一个大小为 k 的数组 min_dp_for_rem 来存储中间状态。