LeetCode 1420. 生成数组
题目描述
题意分析
题目给了一段求最大值的伪代码:从左到右扫描数组,遇到比当前记录的最大值更大的元素就更新,并把
search_cost加一。现在要问:长度为n、每个元素取值在[1, m]的数组中,有多少个数组跑完这段伪代码后search_cost恰好等于k?答案对 $10^9 + 7$ 取模。先把
search_cost的含义翻译清楚:它统计的是扫描过程中「当前最大值被刷新」的次数,也就是数组的严格前缀最大值的个数。第一个元素必然刷新一次(初始最大值是 0,任何[1,m]的值都比它大),所以search_cost至少是 1。由此得到两个立即可用的推论。第一,
k = 0时答案必然是 0(长度至少为 1,第一次必刷新)。第二,search_cost最多是n(每个位置都刷新,此时数组严格递增),也不可能超过m(值域只有m个不同的值,严格递增最多m项)。关键的观察是:
search_cost只和「哪些位置刷新了最大值」有关,与非刷新位置的具体取值无关——非刷新位置只要不超过当前最大值就行。这提示状态里需要携带「当前最大值」和「已刷新次数」两个信息,而不需要记住整个数组。约束里
n、m最大 50,k最大min(n, m)。三者相乘只有 $1.25 \times 10^5$ 量级,说明 $O(nmk)$ 的三层状态是被允许的;但如果转移里再套一层枚举变成 $O(nm^2k)$,也才 $6 \times 10^6$,其实同样能过。不过前缀和优化是这类题的标准手法,值得按最优写法掌握。边界要留意四点:
k = 0返回 0;k > m或k > n时答案为 0(代码里会自然算出 0);方案数是指数级的,全程必须取模;n = 1时答案是m(任何单元素数组的search_cost都是 1)。
解法:DP + 前缀和优化
核心思路
枚举全部 $m^n$ 个数组不可行。扫描一个前缀后,未来转移只关心两个量:当前最大值,以及最大值被刷新了多少次;前缀的具体排列不再影响后续选择。
对当前长度
len,定义dp[maximum][cost]:最大值恰好为maximum、搜索代价恰好为cost的数组数量。单元素数组[maximum]会第一次刷新最大值,因此初始状态是dp[maximum][1] = 1。在末尾追加一个数并得到新最大值
maximum时只有两类互斥情况:
- 新数不超过
maximum:有maximum种取值,最大值和代价不变,贡献maximum * dp[maximum][cost]。- 新数就是
maximum且刷新了最大值:旧最大值p必须满足p < maximum,旧代价为cost - 1,贡献所有这类状态之和。因而转移为
\[next[maximum][cost] = maximum \cdot dp[maximum][cost] + \sum_{p=1}^{maximum-1} dp[p][cost-1]\]第二项若逐状态求和会多一层
m。固定cost后按maximum从小到大扫描,用smaller维护当前之前的前缀和。计算next[maximum][cost]时,smaller恰好等于上式的求和;计算完成后再把dp[maximum][cost-1]加入,供下一个最大值使用。正确性可按长度归纳:初始层准确;任一更长数组删除末位后唯一落入“刷新”或“不刷新”之一,两个分支互斥且穷尽,前缀和只合并求和、不改变计数。因此每层
dp都精确表示状态定义。最终对所有maximum的dp[maximum][k]求和。
解题步骤
- 若
k == 0、k > n或k > m,直接返回 0:非空数组至少刷新一次,刷新次数也不可能超过长度或值域大小。- 初始化长度 1:对
maximum = 1..m,令dp[maximum][1] = 1。- 对长度
2..n新建next,避免原地更新污染上一层。- 对每个
cost,令smaller = 0,再按最大值升序计算“不刷新贡献 + 刷新贡献”并取模。- 每算完一个最大值,把
dp[maximum][cost-1]加入smaller;这个先算后加的顺序保证只包含严格更小的旧最大值。- 滚动
dp = next,最后汇总dp[maximum][k]。例如
n = 3, m = 2, k = 2。合法数组为[1,1,2]、[1,2,1]、[1,2,2],答案 3。其中[1,2,2]说明“不刷新”分支必须有maximum种选择,等于当前最大值也不会增加代价。边界上,
n = 1时答案为m当且仅当k = 1;k > min(n,m)时为 0。所有加法和乘法都要取模,Java 乘法前必须转为long。
代码实现
class Solution {
private static final int MOD = 1_000_000_007;
public int numOfArrays(int n, int m, int k) {
if (k == 0 || k > n || k > m) {
return 0;
}
int[][] dp = new int[m + 1][k + 1];
for (int maximum = 1; maximum <= m; maximum++) {
dp[maximum][1] = 1;
}
for (int length = 2; length <= n; length++) {
int[][] next = new int[m + 1][k + 1];
for (int cost = 1; cost <= Math.min(k, length); cost++) {
int smaller = 0;
for (int maximum = 1; maximum <= m; maximum++) {
long keepMaximum = (long) dp[maximum][cost] * maximum;
next[maximum][cost] = (int) ((keepMaximum + smaller) % MOD);
smaller += dp[maximum][cost - 1];
if (smaller >= MOD) {
smaller -= MOD;
}
}
}
dp = next;
}
int answer = 0;
for (int maximum = 1; maximum <= m; maximum++) {
answer += dp[maximum][k];
if (answer >= MOD) {
answer -= MOD;
}
}
return answer;
}
}
func numOfArrays(n int, m int, k int) int {
const mod = 1_000_000_007
if k == 0 || k > n || k > m {
return 0
}
dp := make([][]int, m+1)
for maximum := 0; maximum <= m; maximum++ {
dp[maximum] = make([]int, k+1)
}
for maximum := 1; maximum <= m; maximum++ {
dp[maximum][1] = 1
}
for length := 2; length <= n; length++ {
next := make([][]int, m+1)
for maximum := 0; maximum <= m; maximum++ {
next[maximum] = make([]int, k+1)
}
for cost := 1; cost <= k && cost <= length; cost++ {
smaller := 0
for maximum := 1; maximum <= m; maximum++ {
keepMaximum := int64(dp[maximum][cost]) * int64(maximum)
next[maximum][cost] = int((keepMaximum + int64(smaller)) % int64(mod))
smaller += dp[maximum][cost-1]
if smaller >= mod {
smaller -= mod
}
}
}
dp = next
}
answer := 0
for maximum := 1; maximum <= m; maximum++ {
answer += dp[maximum][k]
if answer >= mod {
answer -= mod
}
}
return answer
}
复杂度分析
- 时间复杂度:$O(nmk)$。每个长度、代价和当前最大值组合只做常数次运算;运行前缀和消除了对旧最大值的额外枚举。
- 空间复杂度:$O(mk)$。只保留上一长度的
dp和当前长度的next;运行前缀和只需一个变量。
关键点总结
dp[maximum][cost]中两个量都必须是“恰好”,否则追加元素的选择数无法确定。- 不刷新时,新元素可取
1..maximum,贡献系数是maximum。- 刷新到新最大值
maximum时,新元素已经固定,贡献是所有更小旧最大值的方案和,不再乘选择数。smaller在当前状态计算后再更新,才能表示严格小于maximum的前缀和。- 长度维只依赖上一层,应使用滚动数组;计数全程取模,乘法使用 64 位中间值。
易错点总结
- 不刷新分支乘
maximum - 1:新元素等于当前最大值也不会刷新;n=3,m=2,k=2会漏掉[1,2,2]。- 刷新分支包含旧最大值等于新最大值的状态:追加相等值不是刷新;必须使用严格更小的前缀和。
- 计算当前状态前先更新
smaller:会把dp[maximum][cost-1]错计进刷新分支,等价于把相等最大值当成刷新。- 原地覆盖
dp:当前长度会读到本层刚写出的值,长度状态混在一起;必须写入next。- 初始化为
dp[maximum][0] = 1:非空数组第一次元素必定刷新,n=1,m=5,k=1会被算成 0。- 忽略非法
k或乘法溢出:k=0、k>n、k>m应返回 0;Java 的乘积必须先转为long。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 629. K 个逆序对数组 | 困难 | 同为「恰好 k 个某种事件」的计数 DP,转移是连续区间求和,必须用前缀和优化 |
| 920. 播放列表的数量 | 困难 | 状态是「已用歌曲数 + 已排长度」,转移要区分新歌与重播,同属排列计数 |
| 1155. 掷骰子等于目标和的方法数 | 中等 | 按「最后一个骰子的点数」分类转移,是本题「按末位决策分类」的入门版 |
| 96. 不同的二叉搜索树 | 中等 | 按根的位置分类的计数 DP,训练「枚举最后一步」的思维 |
| 115. 不同的子序列 | 困难 | 二维计数 DP,转移按「当前字符用不用」分成两支 |
| 940. 不同的子序列 II | 困难 | 需要用「上次出现位置」去重,展示计数 DP 中避免重复的技巧 |
| 518. 零钱兑换 II | 中等 | 组合计数,循环顺序决定算组合还是排列,是计数 DP 必踩的坑 |
| 494. 目标和 | 中等 | 转化为子集和计数,训练把问题变形到标准背包 |
| 91. 解码方法 | 中等 | 一维计数 DP,按「最后一位单独解还是两位一起解」分类 |
| 300. 最长递增子序列 | 中等 | 转移里含 $\max_{p<j}$ 形式,可用树状数组把 $O(n)$ 压成 $O(\log n)$ |
| 368. 最大整除子集 | 中等 | 同为「从更小状态求和或取最值」的转移,还要回溯构造具体方案 |
| 887. 鸡蛋掉落 | 困难 | 状态设计需要反转视角(用操作次数反推可测层数),是状态定义训练的高阶题 |
| 312. 戳气球 | 困难 | 区间 DP,按「最后戳破哪个」分类,与本题「按最后一步分类」同源 |
| 87. 扰乱字符串 | 困难 | 多维状态的记忆化搜索,训练把复杂过程压缩成有限状态 |