LeetCode 920. 播放列表的数量
题目描述
题意分析
有
n首互不相同的歌,要排出一个长度恰好为goal的播放列表,需要同时满足两条:每首歌至少播放一次;同一首歌两次播放之间,中间至少隔着k首其他的歌。问这样的播放列表有多少种,答案对 $10^9 + 7$ 取模。「有多少种」加上「取模」,几乎锁死了这是计数题,不是构造题也不是搜索题——目标是数出方案数,不需要真的列举任何一个列表。
两条约束的性质完全不同。「每首歌至少一次」是全局约束,只有排完整个列表才能验证;「间隔至少
k首」是局部约束,只跟最近k个位置有关。计数题里,把全局约束塞进状态维度、把局部约束塞进转移系数,是标准的拆解方式。顺着这条思路看:列表是从左往右一个位置一个位置排的,排到某个位置时,真正影响后续的信息只有两件——已经排了多少个位置,以及已经用过多少首不同的歌。至于具体用了哪几首、它们怎么排列,由于所有歌是等价的(互不相同但地位对称),只需要知道个数,剩下的用乘法计数即可。
约束里
n、goal最大 100,k < n <= goal。$O(n \cdot goal) = 10^4$ 的二维 DP 绰绰有余;同时k < n保证「隔k首」这个要求不至于自相矛盾。边界:
k = 0时相当于没有间隔限制,答案是「用满n首歌的长度为goal的序列数」;goal = n时每首歌恰好一次,答案是n!除去被间隔约束排除的部分(k = 0时正好是 $n!$)。
解法:动态规划(滚动数组)
核心思路
先想暴力:枚举每个位置放哪首歌,用回溯保证间隔约束,最后检查是否每首都出现过。分支因子接近
n、深度goal,是 $100^{100}$ 量级,完全不可能。瓶颈在于回溯记住了「具体放了哪些歌、按什么顺序」,而这些细节里绝大部分对未来是无关的。真正影响后续选择的只有两个数:已排位置数、已用不同歌曲数。这就是把指数级压成多项式的关键——状态是对历史的有损压缩,只保留对未来有影响的部分。
状态定义:$dp[i][j]$ 表示「播放列表已排好前
i个位置,其中恰好用到了j首互不相同的歌」的方案数。答案是 $dp[goal][n]$——长度排满goal,且n首歌全部用上(这正是「每首至少一次」的全局约束落地成的那个维度值)。初始条件:$dp[0][0] = 1$,空列表用了 0 首歌,算一种方案。其余 $dp[0][j] = 0$。
转移:看第
i个位置放什么歌,只有两种互斥的来源。第一种,放一首从未出现过的新歌。那么前
i-1个位置用了j-1首不同的歌,方案数是 $dp[i-1][j-1]$;而这首新歌可以从剩下的n - (j-1)首里任选一首。所以贡献是 $dp[i-1][j-1] \times (n - j + 1)$。注意新歌一定合法——它此前从未播放,不受间隔约束。第二种,重播一首已经出现过的旧歌。那么前
i-1个位置已经用了j首不同的歌(这个位置不引入新歌),方案数是 $dp[i-1][j]$;可选的旧歌有多少首?已用过j首,但最近播放的k首歌不能再放(间隔不足),所以可选的是 $j - k$ 首。当 $j \le k$ 时没有任何旧歌可选,这一项为 0。所以贡献是 $dp[i-1][j] \times \max(j - k, 0)$。「最近
k首歌恰好是k首互不相同的歌」这一点需要确认:正因为间隔约束成立,最后k个位置上的歌必然两两不同,所以被禁用的恰好是k首,可选的恰好是j - k首。这是本题最巧妙的一步——局部约束被压缩成了一个只依赖j和k的系数,不需要在状态里记录「最近播了哪几首」。合起来:
\[dp[i][j] = dp[i-1][j-1] \times (n - j + 1) + dp[i-1][j] \times \max(j - k,\, 0)\]不变量:外层第
i轮结束后,数组dp恰好等于「长度为i的合法前缀,按所用不同歌曲数分类的方案数」,且所有值都已对 $10^9+7$ 取模。由于 $dp[i][\cdot]$ 只依赖 $dp[i-1][\cdot]$,可以用滚动数组把二维压成一维。这里采用「新建
newDp再整体替换」的写法,而不是在原数组上倒序更新——转移同时用到了dp[j-1]和dp[j]两项,原地更新极易读到本轮已被改写的值,新建数组虽然多一次分配,但语义清晰、不会出错。
解题步骤
- 初始化
dp[0] = 1,其余为 0:对应 $dp[0][0] = 1$。这个 1 是所有计数的种子,漏掉它整张表全是 0。- 外层
i从 1 到goal:一个位置一个位置地排,每轮把长度推进 1。- 每轮新建
newDp(全 0):$dp[i][0]$ 恒为 0(i >= 1时至少用了一首歌),全 0 初始化正好覆盖这一点,不必单独写。- 内层
j从 1 到min(i, n):上界取min(i, n)是两个显然事实的合取——排了i个位置最多用i首不同的歌,同时总共也只有n首歌。超出这个范围的状态恒为 0,跳过它们能省掉大约一半的无效计算。- 新歌转移
newDp[j] += dp[j-1] * (n - (j-1)):dp[j-1]是上一轮的值(长度i-1、用了j-1首),n - (j-1)是可选新歌的首数。- 旧歌转移,仅当
j > k:newDp[j] += dp[j] * (j - k)。用if (j > k)而不是写max(j-k, 0),是因为j <= k时整项为 0,直接跳过既省一次乘法又避免负数参与运算。- 每步取模:两次累加后都
% mod。dp用long存储,dp[j-1]最大不超过 $10^9$,乘以最多 100 是 $10^{11}$,在long范围内安全;若用int则第一次乘法就溢出。- 轮末
dp = newDp:滚动到下一层。- 返回
(int) dp[n]:取n这个下标,正是「n首歌全部用上」的那一列。返回dp[goal]或dp[min(goal,n)]都是审题错误。以
n = 3、goal = 3、k = 1走一遍,正确答案是 6。初始:
dp = [1, 0, 0, 0](下标 0 到 3)。
i = 1(排第 1 个位置),upper = min(1,3) = 1。j = 1:新歌转移newDp[1] = dp[0] * (3 - 0) = 1 * 3 = 3;j = 1不大于k = 1,旧歌项跳过。滚动后dp = [0, 3, 0, 0]。含义:长度 1 的列表必然用了 1 首歌,3 种选法。
i = 2,upper = 2。j = 1:newDp[1] = dp[0] * 3 = 0 * 3 = 0(上一轮dp[0]已变成 0,因为长度 1 不可能用 0 首歌);旧歌项因j = 1 <= k跳过。j = 2:新歌转移newDp[2] = dp[1] * (3 - 1) = 3 * 2 = 6;j = 2 > k = 1,旧歌转移+= dp[2] * (2 - 1) = 0 * 1 = 0。滚动后dp = [0, 0, 6, 0]。含义:长度 2 必须用两首不同的歌(因为k = 1禁止相邻重复),$3 \times 2 = 6$ 种。
i = 3,upper = 3。j = 1:dp[0] * 3 = 0。j = 2:新歌dp[1] * 2 = 0,旧歌dp[2] * (2-1) = 6 * 1 = 6,得newDp[2] = 6——这 6 种是「用两首歌排三个位置」,第三个位置重播了一首非最近的旧歌。j = 3:新歌dp[2] * (3 - 2) = 6 * 1 = 6;旧歌dp[3] * (3-1) = 0。得newDp[3] = 6。滚动后dp = [0, 0, 6, 6]。返回
dp[3] = 6。手工验证:三首歌各播一次,就是 3 的全排列共 6 种,且k = 1只禁止相邻相同,全排列里不存在相同元素相邻,所以 6 种全部合法,与结果一致。注意dp[2] = 6那一列被正确地排除在答案之外——它们只用了 2 首歌,违反「每首至少一次」。再看旧歌系数为什么是
j - k而不是j - 1:把k改成 2 重算i = 3, j = 3那一步,新歌项仍是 6,而j = 3 > k = 2,旧歌项是dp[3] * (3-2),此时dp[3]为 0;若误写成j - 1,在更长的列表上会把「距离不足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++) {
newDp[j] = (newDp[j] + dp[j - 1] * (n - (j - 1))) % mod;
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++ {
newDp[j] = (newDp[j] + dp[j-1]*int64(n-(j-1))) % mod
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)$。外层
goal轮、内层最多n个状态,每个状态只做两次乘加与取模。代入上界是 $100 \times 100 = 10^4$,几乎瞬时。- 空间复杂度:$O(n)$。滚动数组只保留相邻两层,各占
n + 1个long;不做滚动则是 $O(n \cdot goal)$,本题规模下也能接受,但滚动写法更能体现对依赖关系的把握。
关键点总结
- 计数题的第一步是设计状态:把「对未来有影响的信息」全部留下、其余全部丢掉。本题丢掉了「具体用了哪几首歌、怎么排」,只留下「长度」和「不同歌曲数」两个维度。
- 全局约束(每首至少一次)适合做成状态维度并在最终答案处取特定值;局部约束(间隔至少
k)适合做成转移系数。这个分工是组合计数 DP 的通用套路。- 「最近
k首必然互不相同」是本题最关键的一步推理——正因为约束成立,被禁用的恰好是k首,可选旧歌恰好j - k首,局部信息才不必进状态。- 新歌系数
n - (j-1)来自「从未用过的歌还剩几首」,旧歌系数j - k来自「用过的歌里有几首解禁」,两个系数的语义必须能一句话说清,否则一定会写错某一个。- 全程用 64 位整数并每步取模:$10^9 \times 100$ 已超
int,先乘后模的中间值必须放得下。- 面试视角:这题的得分点不在代码而在推导。能主动说出「歌曲之间是对称的,所以只需记个数」和「最近 k 首必然互不相同」两句话,基本就通过了;写不出闭式组合公式没关系,DP 是面试官期待的答案。
易错点总结
- 返回
dp[goal]而不是dp[n]:n = 3, goal = 3, k = 1时两者恰好相同看不出问题,但n = 2, goal = 3, k = 0中dp数组长度只有 3,访问dp[3]直接越界。- 漏掉
dp[0] = 1的初始化:整张表全是 0,任何输入都返回 0。- 新歌系数写成
n - j:n = 3, goal = 3, k = 1的i=1, j=1一步会算成1 * 2 = 2而不是 3,最终答案变成 2 而正确答案是 6。- 旧歌系数写成
j - 1或j:等于把间隔约束当成「只禁止相邻重复」或「完全不禁止」,n = 2, goal = 3, k = 1会算出大于正确值的方案数。- 旧歌转移不判
j > k:j = k时系数为 0 尚且无害,j < k时系数为负,会把方案数越减越小甚至出现负值。- 内层上界只写
n不取min(i, n):虽然多出的状态本身是 0 不影响正确性,但i < j的位置若被误当作有效状态参与后续转移(例如原地更新时),会引入根本不存在的方案。- 在原数组上正序原地更新:
dp[j] += dp[j-1] * ...中的dp[j-1]已是本轮新值,等于允许「同一轮里连续加入两首新歌」,方案数严重偏大。必须新建数组或倒序更新。- 用
int存dp或不取模:dp[j-1]接近 $10^9$ 时乘以n直接溢出成负数,n = 100, goal = 100, k = 0会返回负数。- 只在最后取一次模:中间值早已远超
long上限,取模必须逐步进行。- 把「间隔 k 首」理解成「间隔 k 个位置」:题目说的是中间必须夹着
k首其他的歌,也就是两次播放的下标差至少是k + 1;理解偏差会让k的所有系数整体错位一格。- 忘记「每首歌至少播放一次」而对所有
j求和:n = 3, goal = 3, k = 1会把dp[2] = 6也加进去返回 12,正确答案是 6。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 629. K 个逆序对数组 | 困难 | 同为「按位置逐个放入 + 计数」的排列 DP,转移需前缀和优化 |
| 96. 不同的二叉搜索树 | 中等 | 计数 DP 的入门形态,按根节点划分左右规模,答案是卡塔兰数 |
| 377. 组合总和 Ⅳ | 中等 | 求排列数而非组合数,循环顺序决定「顺序是否算不同方案」 |
| 518. 零钱兑换 II | 中等 | 与 377 对照:外层枚举物品才是组合计数,是理解循环顺序语义的最佳例子 |
| 62. 不同路径 | 中等 | 最基础的方案数 DP,转移只有两项且无系数,适合作为状态设计的起点 |