题目描述

✅ 920. 播放列表的数量

image-20260929105129836

image-20260929105129988

题意分析

用 n 首不同歌曲组成长度为 goal 的播放列表,每首至少出现一次。重复播放某首歌前,必须先播放至少 k 首其他歌曲。统计满足这些条件的列表数量,并对 1_000_000_007 取模。

要同时控制列表长度和是否用满全部歌曲,可以按“已排位置数”和“已经出现的不同歌曲数”计数;每个位置只需区分放入新歌还是旧歌。

解法:动态规划(滚动数组)

核心思路

[!blue]

定义 dp[i][j] 为长度恰好为 i、已经使用 j 首不同歌曲的合法列表数。若最后一个位置加入新歌,前面必然只用过 j-1 首,还能从未出现的 n-(j-1) 首中选择,因此贡献为 dp[i-1][j-1]*(n-j+1)。

若最后一个位置重播旧歌,前面就已经用过 j 首。合法前缀最近的 k 首必然互不相同,否则其中某首歌的两次播放间隔不足要求;这 k 首暂时不能重播,其他旧歌都可以,因此可选数是 j-k。当 j<=k 时没有可重播的旧歌,贡献为零。

由此得到转移:dp[i][j] = dp[i-1][j-1]*(n-j+1) + dp[i-1][j]*max(j-k,0)。不同前缀中受限制的具体歌曲可能不同,但可选数量相同,所以不必把最近歌曲的身份放进状态。新歌与旧歌两类互不重叠,删除最后一首又能唯一还原前缀,因此转移既不漏数也不重复计数。

初值 dp[0][0]=1 表示空列表这一种起点,其余状态为零。每层只依赖前一层,代码用 dp 保存旧长度的计数、newDp 保存新长度的计数;每次取模后再进入下一轮。最终返回 dp[n],确保全部 n 首都至少出现一次,而不是把未用满歌曲的状态也加进答案。

解题步骤

  1. 初始化空列表的一种方案。
  2. 逐个增加播放位置,创建全零的新数组,只枚举 1 到 min(i,n) 首已用歌曲。
  3. 分别累加选新歌与重播合法旧歌的贡献并取模。
  4. 滚动到下一轮,最终返回 dp[n]。

k=0 时所有已出现歌曲都可以重播,旧歌系数就是 j;goal=n 时为了用满歌曲,每个位置都必须加入新歌。前缀还不足 k 首时不能发生重复,对应的旧歌转移自然为零。

代码实现

class Solution {
    public int numMusicPlaylists(int n, int goal, int k) {
        long mod = 1_000_000_007L;

        long[] dp = new long[n + 1];

        // 空播放列表是一种方案,作为所有转移的起点。
        dp[0] = 1;

        for (int i = 1; i <= goal; i++) {
            long[] newDp = new long[n + 1];
            int upper = Math.min(i, n);

            for (int j = 1; j <= upper; j++) {
                // 加入新歌前已有 j-1 首,还能从其余 n-j+1 首中选择。
                newDp[j] = (newDp[j] + dp[j - 1] * (n - (j - 1))) % mod;

                // 最近 k 首不能重复,已用歌曲中只有 j-k 首可重新播放。
                if (j > k) {
                    newDp[j] = (newDp[j] + dp[j] * (j - k)) % mod;
                }
            }

            dp = newDp;
        }

        return (int) dp[n];
    }
}
func numMusicPlaylists(n int, goal int, k int) int {
    const mod int64 = 1_000_000_007

    dp := make([]int64, n+1)
    // 空播放列表是一种方案,作为所有转移的起点。
    dp[0] = 1

    for i := 1; i <= goal; i++ {
        newDp := make([]int64, n+1)
        upper := min920(i, n)
        for j := 1; j <= upper; j++ {
            // 加入新歌前已有 j-1 首,还能从其余 n-j+1 首中选择。
            newDp[j] = (newDp[j] + dp[j-1]*int64(n-(j-1))) % mod
            // 最近 k 首不能重复,已用歌曲中只有 j-k 首可重新播放。
            if j > k {
                newDp[j] = (newDp[j] + dp[j]*int64(j-k)) % mod
            }
        }
        dp = newDp
    }

    return int(dp[n])
}

func min920(a, b int) int {
    if a < b {
        return a
    }
    return b
}

复杂度分析

  • 时间复杂度:$O(n\cdot goal)$,包括每轮数组初始化和状态转移。
  • 空间复杂度:$O(n)$,保存相邻两层。

关键点总结

[!green]

  • 新歌增加不同歌曲数,旧歌保持该数量。
  • 旧歌必须排除最近 k 首,不能简单乘 j。
  • 最终只取全部歌曲都出现的状态,不对各 j 求和。

易错点总结

[!yellow]

  • 空状态没有初始化为一:所有计数都无法启动。
  • 新歌系数写成 n-j:少算尚未出现的歌曲。
  • 正序原地更新状态:本轮可能反复读取刚更新的值。
  • 乘法仍在窄整数中完成:取模前的乘积可能溢出。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2025/58820621
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!