拆分数组的最小代价

标签: 数组 哈希表 动态规划 计数

难度: Hard

给你一个整数数组 nums 和一个整数 k

将数组拆分成一些非空子数组。拆分的 代价 是每个子数组中的 重要性 之和。

trimmed(subarray) 作为子数组的一个特征,其中所有仅出现一次的数字将会被移除。

  • 例如,trimmed([3,1,2,4,3,4]) = [3,4,3,4]

子数组的 重要性 定义为 k + trimmed(subarray).length

  • 例如,如果一个子数组是 [1,2,3,3,3,4,4]trimmed([1,2,3,3,3,4,4]) = [3,3,3,4,4] 。这个子数组的重要性就是 k + 5

找出并返回拆分 nums 的所有可行方案中的最小代价。

子数组 是数组的一个连续 非空 元素序列。

示例 1:

输入:nums = [1,2,1,2,1,3,3], k = 2
输出:8
解释:将 nums 拆分成两个子数组:[1,2], [1,2,1,3,3]
[1,2] 的重要性是 2 + (0) = 2 。
[1,2,1,3,3] 的重要性是 2 + (2 + 2) = 6 。
拆分的代价是 2 + 6 = 8 ,可以证明这是所有可行的拆分方案中的最小代价。

示例 2:

输入:nums = [1,2,1,2,1], k = 2
输出:6
解释:将 nums 拆分成两个子数组:[1,2], [1,2,1] 。
[1,2] 的重要性是 2 + (0) = 2 。
[1,2,1] 的重要性是 2 + (2) = 4 。
拆分的代价是 2 + 4 = 6 ,可以证明这是所有可行的拆分方案中的最小代价。

示例 3:

输入:nums = [1,2,1,2,1], k = 5
输出:10
解释:将 nums 拆分成一个子数组:[1,2,1,2,1].
[1,2,1,2,1] 的重要性是 5 + (3 + 2) = 10 。
拆分的代价是 10 ,可以证明这是所有可行的拆分方案中的最小代价。

提示:

  • 1 <= nums.length <= 1000
  • 0 <= nums[i] < nums.length
  • 1 <= k <= 109

Submission

运行时间: 332 ms

内存: 18.3 MB

class Solution:
    def minCost(self, nums: List[int], k: int) -> int:
        # 线段树优化
        # Lazy 线段树模板(区间加,查询区间最小)
        n = len(nums)
        mn = [0] * (4 * n)
        todo = [0] * (4 * n)

        def do(o: int, v: int) -> None:
            mn[o] += v
            todo[o] += v

        def spread(o: int) -> None:
            v = todo[o]
            if v:
                do(o * 2, v)
                do(o * 2 + 1, v)
                todo[o] = 0

        # 区间 [L,R] 内的数都加上 v   o,l,r=1,1,n
        def update(o: int, l: int, r: int, L: int, R: int, v: int) -> None:
            if L <= l and r <= R:
                do(o, v)
                return
            spread(o)
            m = (l + r) // 2
            if m >= L: update(o * 2, l, m, L, R, v)
            if m < R: update(o * 2 + 1, m + 1, r, L, R, v)
            mn[o] = min(mn[o * 2], mn[o * 2 + 1])

        def query(o: int, l: int, r: int, L: int, R: int) -> int:
            if L <= l and r <= R:
                return mn[o]
            spread(o)
            m = (l + r) // 2
            if m >= R: return query(o * 2, l, m, L, R)
            if m < L: return query(o * 2 + 1, m + 1, r, L, R)
            return min(query(o * 2, l, m, L, R),query(o * 2 + 1, m + 1, r, L, R))

        ans = 0
        last = [0] * n
        last2 = [0] * n

        for i, x in enumerate(nums, 1):
            update(1, 1, n, i, i, ans) # 相当于设置 f[i+1] 的值
            if last[x]: update(1, 1, n, last2[x] + 1, last[x], 2)
            if last2[x]: update(1, 1, n, 1, last2[x], 1)
            ans = k + query(1, 1, n, 1, i)
            last2[x] = last[x]
            last[x] = i
        return ans 

Explain

这个题解使用了线段树来优化动态规划的过程。具体思路如下: 1. 定义状态 f[i] 表示将数组 nums[:i] 拆分成若干个非空子数组的最小代价。 2. 状态转移时,对于每个 i,枚举上一个拆分点 j,计算 [j+1,i] 这一段的重要性,加上 f[j] 作为一种拆分方案,取所有方案的最小值作为 f[i] 的值。 3. 朴素的状态转移复杂度为 O(n^2),因此使用线段树优化,将每次查询 f[j] 的最小值的复杂度降至 O(log n)。 4. 线段树中维护区间加的懒标记,同时在更新答案时,需要对每个数字 x 维护其出现的上一个位置和上上个位置,方便线段树对区间进行修改。

时间复杂度: O(n log n)

空间复杂度: O(n)

class Solution:
    def minCost(self, nums: List[int], k: int) -> int:
        # 数组长度
        n = len(nums)
        
        # 线段树的叶子节点数量,取为数组长度的 4 倍,保证是完全二叉树
        mn = [0] * (4 * n)  # 存储区间最小值
        todo = [0] * (4 * n)  # 存储区间加的懒标记

        # 区间加操作,将线段树中节点 o 对应的区间都加上值 v
        def do(o: int, v: int) -> None:
            mn[o] += v
            todo[o] += v

        # 下放懒标记
        def spread(o: int) -> None:
            v = todo[o]
            if v:
                do(o * 2, v)
                do(o * 2 + 1, v)
                todo[o] = 0

        # 更新线段树,将区间 [L,R] 内的值都加上 v
        def update(o: int, l: int, r: int, L: int, R: int, v: int) -> None:
            if L <= l and r <= R:
                do(o, v)
                return
            spread(o)
            m = (l + r) // 2
            if m >= L: update(o * 2, l, m, L, R, v)
            if m < R: update(o * 2 + 1, m + 1, r, L, R, v)
            mn[o] = min(mn[o * 2], mn[o * 2 + 1])

        # 查询线段树,返回区间 [L,R] 内的最小值
        def query(o: int, l: int, r: int, L: int, R: int) -> int:
            if L <= l and r <= R:
                return mn[o]
            spread(o)
            m = (l + r) // 2
            if m >= R: return query(o * 2, l, m, L, R)
            if m < L: return query(o * 2 + 1, m + 1, r, L, R)
            return min(query(o * 2, l, m, L, R),query(o * 2 + 1, m + 1, r, L, R))

        ans = 0
        last = [0] * n  # last[x] 表示数字 x 上一次出现的位置
        last2 = [0] * n  # last2[x] 表示数字 x 上上次出现的位置

        for i, x in enumerate(nums, 1):
            # 更新 f[i] 的值
            update(1, 1, n, i, i, ans)
            
            # 根据 x 的出现情况,对线段树中的区间进行修改
            if last[x]: update(1, 1, n, last2[x] + 1, last[x], 2)
            if last2[x]: update(1, 1, n, 1, last2[x], 1)
            
            # 查询 f[1..i] 的最小值,并加上 k 作为当前的答案
            ans = k + query(1, 1, n, 1, i)
            
            # 更新 x 的上上次出现位置和上次出现位置
            last2[x] = last[x]
            last[x] = i
            
        return ans

Explore

在这个解法中,线段树用于优化动态规划的查询和更新操作,主要目的是降低时间复杂度。每个节点在线段树中代表一个区间的最小值,以及一个懒标记用于记录该区间所有元素需要增加的值。通过线段树,我们可以在O(log n)时间内完成以下操作:1. 更新操作:当数字x的上一个位置和上上个位置发生变化时,我们需要更新线段树中对应区间的值,这通过递归地分割问题区间,直到达到完全覆盖的叶节点,然后应用懒标记进行快速更新。2. 查询操作:查询操作用于获取当前动态规划状态f[i]的最小值,这同样通过递归地访问涉及的区间,结合懒标记快速得到最小值。这种方法能够有效地减少重复的计算,从而提高整体的执行效率。

在更新线段树时,维护数字的上一个位置和上上个位置是因为这些信息直接关联到动态规划中状态转移的计算。特别是在本题中,数组中的每个数字的出现位置影响计算其对应的代价。具体来说,当一个数字的新位置被发现时,它的上一个位置和上上个位置的信息被用来调整线段树中的区间值,如重要性的调整(代价的改变)。这些位置信息帮助算法在保持正确性的同时,通过线段树快速更新和查询相关的动态规划状态,实现效率的提升。

在实现线段树时,选择使用懒惰传播(lazy propagation)主要是为了优化区间更新操作的效率,避免对每个单独的元素进行更新,从而减少不必要的计算量。懒惰传播允许我们将一个更新操作延迟到实际需要这个数据时再进行计算。在本题中,这种方法具有特别的优势,因为它使得复杂度高的区间更新操作变得高效,特别是在频繁调整线段树中多个区间值的场景下。通过懒惰传播,我们可以在O(log n)的时间内完成这些区间更新,相比朴素的更新方法大大提高了执行效率。