数位DP-基础讲解
Part 1: [ZJOI2010] 数字计数
欢迎来到我们的数位 DP 之旅!第一站,我们来解决一道非常经典的问题:P2602 [ZJOI2010] 数字计数。这道题是数位 DP 的绝佳入门范例,可以帮助我们理解其核心思想。
题目大意
简单来说,题目会给我们两个正整数 和 ,我们需要统计出在 这个闭区间内,所有的整数中,数字 分别出现了多少次。
例如,在 区间内,数字 在 等数字中出现,我们需要计算它出现的总次数。
思路分析
当看到数据范围 时,逐个遍历数字并统计的方法显然会超时。这种与“数字位数”强相关且数据范围极大的计数问题,正是数位 DP 的用武之地。
解决这类区间 的问题,我们通常会使用一个经典的 前缀和 思想:
求 范围内的答案,可以转化为
(1 到 b 的答案) - (1 到 a-1 的答案)。
这样,问题就简化为如何实现一个函数 count(n, d),用来计算在 范围内,目标数字 d 出现的总次数。
为了实现 count(n, d),我们将采用 从高位到低位 依次填数的方式来构造 范围内的所有数字。我们使用一个递归函数(通常命名为 dfs)来完成这个过程,并通过记忆化来优化。在递归的每一步,我们需要携带一些关键信息(状态),以确保我们构造的数字是合法的,并且能够正确地进行计数。
状态设计 (DP Definition)
在我们的 dfs 函数中,需要定义以下几个参数来构成我们的 DP 状态:
dfs(int pos, int sum, bool lead, bool limit)
int pos: 表示我们当前正在处理的是数字的第几位(从高位向低位)。例如,对于数字987,pos=3就代表最高位的百位。int sum: 这是一个累加器,记录到目前为止,我们正在统计的目标数字已经出现了多少次。bool lead: 一个布尔标记,用于判断当前位及之前是否是前导零。例如,构造三位数时,007中的前两个0就是前导零,不应计入数字0的统计。一旦我们填入一个非零数字,lead标志就会变为false。bool limit: 一个布尔标记,用于判断当前位的选择是否受到上限的限制。例如,要统计 内的数,当我们处理百位时,若填了1,则十位最多只能填9;但如果百位填了0(即构造一个两位数),那么十位就可以从0到9任意选择,limit就会变为false。
代码讲解
我们来详细分析一下这份现代 C++ 风格的代码。
1. 主体框架
i64 a, b;cin >> a >> b;for (int i = 0; i < 10; i++) { now = i; // now 是一个外部变量,记录当前要统计的数字 cout << f(b) - f(a - 1) << " \n"[i == 9];}这里清晰地展示了 f(b) - f(a - 1) 的思想。通过一个循环,依次将 设为目标数字 now,并计算其出现次数。
2. f(x) 函数
auto f = [&](i64 x) -> i64 { int len = 0; num = to_string(x); dp.assign(N, vector<i64>(N, -1)); // 重置 dp 数组 return dfs(dfs, 0, 0, true, true); // 开始递归};这是数位 DP 的入口。它首先将数字 x 拆解成数位(注意这里 num[1] 是个位,num[len] 是最高位)。然后调用 dfs 开始递归,并传入初始状态:从最高位 len 开始,初始 sum 为 0,lead 和 limit 均为 true。
3. 核心 dfs 函数
auto dfs = [&](auto &self, int pos, int sum, bool lead, bool limit) -> i64 { i64 ans = 0; // 递归边界:所有位都填完了,返回累加的结果 if (pos == num.lenght()) return sum; // 记忆化:只有在不受 lead 和 limit 影响的通用状态下才能使用 if (!lead && !limit && dp[pos][sum] != -1) return dp[pos][sum];
int up = limit ? num[pos] : 9; // 确定当前位能填的数的上限
for (int i = 0; i <= up; ++ i) { // 分情况讨论并进行递归 if (i == 0 && lead) { // 情况1: 当前是前导零 ans += self(self, pos + 1, sum, true, limit && i == up); } else if (i == now) { // 情况2: 当前数字是我们要统计的目标 now ans += self(self, pos + 1, sum + 1, false, limit && i == up); } else { // i != now // 情况3: 普通数字 ans += self(self, pos + 1, sum, false, limit && i == up); } }
if (!lead && !limit) { dp[pos][sum] = ans; // 记录通用状态的结果 } return ans;};- 递归边界:
pos == 0,表示一个完整的数构造完毕。此时我们不能返回1,而应该返回sum。因为dfs的返回值是 当前状态下能构造出的所有合法数字中,目标数字now出现的总次数。 - 记忆化:
if (!lead && !limit && dp[pos][sum] != -1)是记忆化的关键。只有当状态是“泛化”的(即后续位数可以从0-9自由选择,也不再是前导零),其计算结果才可以被复用。 - 循环与转移:
up确定了当前位pos的数字i的上限。- 循环内部的
if-else分情况讨论:i == 0 && lead: 这是前导零,不计入0的个数,所以sum不变,lead标志继续为true。i == now: 填入的数字是目标数字now,所以下一层的sum要加1,同时lead变为false。i != now: 普通情况,sum不变,lead变为false。
limit && i == up: 这个表达式用于决定下一层的limit状态。只有当这一位本身受限(limit为true),并且我们填入的数字达到了上限(i == up),下一位才会继续受限。
总结
这道题是数位 DP 的一个完美模板。通过它,我们学习到了:
f(b) - f(a-1)的经典区间转化技巧。- 数位 DP 的核心状态设计:
pos,state(此题为sum),lead,limit。 - 记忆化搜索是数位 DP 的常见实现方式,逻辑清晰,代码简洁。
- 处理 前导零 是一个需要特别注意的细节,尤其是在统计数字
0时。
Part 2: [SCOI2009] windy 数
接下来,我们看一道对状态设计提出新要求的题目:P2657 [SCOI2009] windy 数。这道题将帮助我们理解如何根据题目独特的约束来调整 DP 状态。
题目大意解析
本题的核心是理解 “windy 数” 的定义:一个不含前导零的正整数,其相邻两个数字的差的绝对值至少为 。我们需要计算在给定区间 内有多少个这样的 windy 数。
例如, 是 windy 数,因为 。 则不是,因为 。
思路分析
题目依然是区间计数问题,数据范围也暗示了需要使用数位 DP。我们还是沿用 solve(b) - solve(a-1) 的经典思路,将问题核心聚焦于如何实现 solve(x) 函数,即统计 中 windy 数的数量。
与上一题不同的是,这里出现了一个新的约束条件:“相邻两个数字之差至少为 2”。这个 “相邻” 的概念是关键。当我们在 dfs 中为当前位 pos 选择一个数字 i 时,我们必须知道它的前一位(即 pos+1 位)的数字是什么,才能判断 i 是否合法。
因此,我们的 DP 状态必须能够记录前一位数字的信息。
状态设计 (DP Definition)
我们在上一题的基础上,为 dfs 函数增加一个参数来记录前一位的数字。
dfs(int pos, int pre, bool lead, bool limit)
int pos: 与之前相同,表示当前处理的位数。int pre: 新增状态。记录前一位(更高位)填的数字。这是本题的核心。为了处理首位数字(它没有前一位),我们可以用一个特殊值(如10或-1)来表示。bool lead:前导零标记。当lead为true时,说明前面都是0,此时对当前位没有 “相邻差” 的约束。bool limit:上限限制标记,含义与之前完全相同。
代码讲解
下面是针对本题的参考实现。
#include <bits/stdc++.h>using namespace std;using i64 = long long; // 使用 i64 作为 long long 的别名,方便书写
int main() { // 优化 C++ 输入输出流的性能 ios::sync_with_stdio(false); cin.tie(nullptr);
i64 a, b; // 定义两个长整型变量 a 和 b,用于存储输入的区间 cin >> a >> b; // 读取区间的左右端点
// 定义一个 lambda 函数 f,用于计算 [0, x] 中满足条件的数的个数 auto f = [&](i64 x) -> i64 { string s = to_string(x); // 将数字 x 转换为字符串 int len = s.size(); // 获取字符串的长度,即数字的位数 // 定义一个二维向量 dp 用于记忆化搜索 // dp[pos][pre] 表示当前处理到第 pos 位,前一位是 pre 的情况下,后面可以构造出的满足条件的数的个数 vector dp(len, vector<i64>(10, -1));
// 定义一个递归的 lambda 函数 dfs,用于进行数位 DP // 参数: // self: 用于递归调用自身 // pos: 当前处理的位数(从左到右,0-indexed) // pre: 前一位的数字 // limit: bool 类型,表示当前位是否受到 x 的限制(例如 x=123,处理到百位时最大只能是1) // lead: bool 类型,表示当前位是否是前导零 auto dfs = [&](auto &&self, int pos, int pre, bool limit, bool lead) -> i64 { // 如果所有位都处理完了,说明找到一个满足条件的数,返回 1 if (pos == len) { return 1; }
// 记忆化:如果当前状态没有限制,不是前导零,并且之前已经计算过,则直接返回结果 if (pre >= 0 && !limit && !lead && dp[pos][pre] != -1) { return dp[pos][pre]; }
i64 res = 0; // 用于累加当前状态下满足条件的数的个数 // 确定当前位的上限 up // 如果受到限制,则 up 为 s[pos] 对应的数字;否则为 9 int up = (limit ? s[pos] - '0' : 9);
// 遍历当前位所有可能的数字 i for (int i = 0; i <= up; i++) { // 处理前导零的情况 if (i == 0 && lead) { // 如果当前是前导零,那么下一位仍然可以看作是前导零,pre 设为 -1 表示没有前一位 res += self(self, pos + 1, -1, limit && i == up, true); } else { // 非前导零的情况 // 判断当前位 i 和前一位 pre 的差的绝对值是否大于等于 2 if (abs(i - pre) >= 2) { // 递归到下一位 // limit && i == up: 只有在当前位受限且 i 取到上限时,下一位才受限 // lead: false,因为已经填了非零数字 res += self(self, pos + 1, i, limit && i == up, false); } } }
// 如果没有限制且不是前导零,将结果存入 dp 表中 if (pre >= 0 && !limit && !lead) { dp[pos][pre] = res; }
return res; // 返回当前状态下满足条件的数的总数 };
// 从第 0 位开始搜索,初始时没有前一位(pre=-1),受限制(limit=true),是前导零(lead=true) return dfs(dfs, 0, -1, true, true); };
// 区间 [a, b] 中满足条件的数的个数等于 f(b) - f(a - 1) cout << f(b) - f(a - 1) << "\n";
return 0;
}-
f(x)函数:- 与之前类似,它负责拆分数字并启动
dfs。 dfs(dfs, len, -1, true, true): 初始调用dfs。pre参数传入-1,这是一个“虚拟”的前置数字,因为它不影响任何一位的决策(加了判断)。
- 与之前类似,它负责拆分数字并启动
-
dfs函数:- Base Case:
if (pos == 0) return 1;如果成功填完所有位,说明找到一个合法的 windy 数,返回1。 - Memoization:
if (pre >= 0 && !limit && !lead && dp[pos][pre] != -1)。记忆化的状态现在是(pos, pre)。 - Transitions:
if (lead): 如果处于前导零状态。- 若填
0,则下一位依然是lead状态,pre依然是虚拟值-1。 - 若填
1-9,则下一位lead变为false,且pre更新为当前数字i。
- 若填
else: 如果不处于前导零状态。- 必须检查
abs(i - pre) >= 2。只有满足该条件的数字i才能被选择,并进入下一层递归。
- 必须检查
- Base Case:
总结
通过 “windy 数” 这个问题,我们学到了数位 DP 一个非常重要的扩展思路:当题目约束与相邻数位有关时,需要将前一位(或后一位)的信息加入到 DP 状态中。这个思想可以推广到更复杂的问题,比如要求数位满足某种特定模式或性质的场景。
Part 3: Special Numbers (Combinatorial Digit DP)
这次我们来挑战一个结合了数论性质和组合数学的数位 DP 问题。题目来源于 Codeforces,其核心在于理解一个数字的变换规则,并统计满足特定变换次数的数字个数。
题目大意解析
题目定义了一种变换操作:将一个正整数 x 替换为其二进制表示中 1 的个数(即 popcount(x))。例如,13 的二进制是 1101,有 3 个 1,所以 13 经过一次操作后会变成 3。
如果一个数 x 经过 恰好 k 次 这样的操作后,第一次变为 1,那么 x 就被称为“特殊数”。
我们需要求解在 的范围内有多少个这样的特殊数。注意,n 是以二进制字符串的形式给出的,其长度可能高达 1000。
思路分析
1. 建模变换过程
首先,我们需要把“经过 k 次操作变为 1”这个条件数学化。
让我们定义一个函数 表示将数字 通过上述操作变为 1 所需的最小操作次数。
根据定义,我们有:
- (因为它已经是 1,需要 0 次操作)
- 对于 , 会先变为
popcount(i),这消耗了 1 次操作,之后还需要 次操作。所以,。
现在,我们来分析一个数 x 变为 1 的过程。
- 第一次操作:
- 后续操作: 再经过 次操作变为
1。
所以,将 x 变为 1 的总操作次数就是 。
题目要求的条件是总操作次数为 k,因此,一个数 x > 1 是特殊数的充要条件是:
对于特殊情况 x = 1,它需要 0 次操作,所以它在 k=0 时是一个特殊数。
2. 转化为数位 DP
问题转化成了:统计在 范围内,有多少个数 x,其二进制中 1 的个数 c = popcount(x) 满足 。
由于 n 的范围非常大(二进制长度达 1000),我们自然想到使用数位 DP。
- 预计算:
n的长度不超过 1000,所以任何小于n的数的popcount也不会超过 1000。我们可以预先计算出g[1]到g[1000]的所有值。 - 数位 DP: 我们将从高位到低位(从左到右)构建一个二进制数,确保它小于等于
n,同时统计其1的个数,并检查这个个数是否满足条件。
3. 组合数学优化
在标准的数位 DP 过程中,当我们某一位的选择不受 n 的限制时(即 limit 标志变为 false),后续的所有位都可以自由地填 0 或 1。在这种情况下,逐位递归就显得效率低下了。
我们可以使用组合数学进行优化。假设我们当前处理到第 pos 位,已经统计了 cnt 个 1,并且 limit 为 false。剩下的还有 len - pos 位需要填写。如果我们计划在剩下的位数中再填 j 个 1,那么总的 1 的个数就是 cnt + j。我们检查 cnt + j 是否满足特殊数的条件。如果满足,那么有多少种方法可以在 len - pos 个位置中选择 j 个位置填 1 呢?答案是组合数 。
我们只需遍历所有可能的 j (从 0 到 len - pos),将满足条件的组合数累加起来,就可以一次性计算出所有从此状态出发的合法数字数量。
代码讲解
#include <bits/stdc++.h>using namespace std;using i64 = long long;
constexpr int M = 1e9 + 7;
const int N = 1e3 + 5;i64 fact[N], ifact[N];// ... 组合数相关的预计算函数 power, init, C ...
int main() { // ... string s; int k; cin >> s >> k;
if (k == 0) { // k=0 只有 x=1 这一个解 cout << 1 << "\n"; return 0; }
init(); // 预计算组合数
vector<int> g(1010); g[1] = 0; for (int i = 2; i <= 1000; i++) { g[i] = g[__builtin_popcount(i)] + 1; // 预计算 g(i) }
int len = s.size(); i64 ans = 0; // C++23风格的递归lambda auto dfs = [&](this auto &&self, int pos, bool limit, int cnt) -> void { if (pos == len) { // 到达末尾 if (cnt > 0 && g[cnt] + 1 == k) { ans = (ans + 1) % M; } return; }
if (limit) { // 受限情况,只能递归 for (int i = 0; i <= s[pos] - '0'; i++) { self(pos + 1, limit && (i == s[pos] - '0'), cnt + (i == 1)); } } else { // 不受限情况,组合数学优化 for (int j = 0; j <= len - pos; j++) { if (cnt + j > 0 && g[cnt + j] + 1 == k) { ans = (ans + C(len - pos, j)) % M; } } } };
dfs(0, true, 0);
// 最终答案需要修正 if (k == 1) { ans = (ans - 1 + M) % M; }
cout << ans << "\n"; return 0;}- 预计算
g数组:g[i] = g[__builtin_popcount(i)] + 1;这行代码完美地实现了我们之前推导的递推关系。 k=0特判:k=0的情况只对应x=1,直接输出1并退出。后续的dfs逻辑处理的是k \ge 1的情况。dfs函数:- 状态为
(pos, limit, cnt),分别代表当前位数、是否受限、已有的1的个数。 limit分支: 当limit为true时,当前位i最大只能取到s[pos] - '0'。我们继续递归,并根据i是否等于上限来更新下一位的limit标志。!limit分支: 这是优化的核心。我们不再递归,而是直接计算。len-pos是剩余位数,j是我们打算在这些位中放的1的个数。如果总的1的个数cnt+j满足条件g[cnt+j]+1 == k,我们就将方案数C(len - pos, j)加入答案。- Base Case: 在
pos == len时,我们已经构建了一个完整的数。此时cnt就是总的 popcount。我们检查cnt是否满足条件g[cnt]+1 == k。注意要排除cnt=0的情况,因为它对应x=0,而题目要求正整数。
- 状态为
- 最终答案修正:
dfs统计的是 范围内的数。- 当
k=1时,需要的条件是g(popcount(x)) = 0,这意味着popcount(x) = 1。 dfs会统计所有popcount(x)=1的数,包括x=1。- 但是,
x=1是一个k=0的特殊数,不应该被k=1的情况统计。因此,当k=1时,我们需要从答案中减去1。
总结
本题是一个非常精彩的数位 DP 题目,它教会我们:
- 如何将一个看似复杂的操作计数问题,通过函数建模,转化为对
popcount的简单约束。 - 在数位 DP 中,当
limit约束解除后,可以使用组合数学进行剪枝和优化,极大地提高了计算效率。 - 注意处理问题的边界条件,如
k=0、x=1等,它们往往需要特判或在最终答案中进行修正。
Part 4: [CEOI 2015] 卡尔文球锦標赛 (Digit DP for Ranks)
这次,我们将探讨一个更深层次的数位 DP 应用:计算一个序列在所有合法序列中的字典序排名。这道题 P4798 [CEOI 2015] 卡尔文球锦标赛 是一个绝佳的例子,它将数位 DP 的思想从简单的“计数”提升到了“计算排名”的层面。
题目大意解析
这道题的核心是理解一种特殊的“球队分配”方案。有 名选手,要被分成若干个非空队伍。队伍的编号规则很特别:
- 队伍按其队内编号最小的选手的编号进行排序。
- 队伍编号是从 1 开始的连续整数。
例如,选手 {1}, {2, 3, 5}, {4, 6} 分成了三队。因为 min{1}=1, min{2,3,5}=2, min{4,6}=4,所以 {1} 是 1 号队,{2,3,5} 是 2 号队,{4,6} 是 3 号队。
这样,每个选手 i 都有一个对应的队伍编号 。上面的例子对应的序列是 1 2 2 3 2 3。
这个规则等价于一个简单的约束:第 i 名选手所属的队伍编号 不能超过 max(a_1, a_2, ..., a_{i-1}) + 1。
题目将所有可能的合法序列按字典序排序,并从 1 开始编号(天数)。我们的任务是,给定一个序列,求出它的排名(是第几天的方案)。
思路分析:从朴素 DFS 到 MLE
这是一个经典的求字典序排名的问题,天然地指向了数位 DP 的思路。我们可以从高位到低位(即从第 1 个选手到第 个)来确定排名。
一个序列 的排名,等于 1 + (所有字典序小于 A 的合法序列 B 的数量)。
我们可以尝试用一个递归函数 dfs(pos, max_teams, is_limit) 来解决这个问题,其状态定义如下:
pos: 当前正在考虑第pos个选手。max_teams: 在前pos - 1个选手中,已经出现过的最大队伍编号。is_limit: 一个布尔值,表示当前位的选择是否受到给定序列A的值的限制。
这个 dfs 函数可以用来计算在当前状态下,总共有多少种合法的方案。通过在主循环中枚举每一位,并计算当该位选择一个更小的值时后续有多少种方案,我们就能累加出总排名。
下面是一个基于此想法的 DFS 实现:
// 一个会导致 MLE (Memory Limit Exceeded) 的版本vector dp(n + 1, vector<int>(n + 1, -1));
auto dfs = [&](auto &&self, int pos, bool limit, int maxn) -> int { if (pos == n) { return 1; } if (!limit && dp[pos][maxn] != -1) { return dp[pos][maxn]; } int res = 0; // 当前选手可选的队伍编号上限 int up = limit ? num[pos] : maxn + 1; for (int i = 1; i <= up; i++) { res = (res + self(self, pos + 1, limit && i == num[pos], max(i, maxn))) % M; } if (!limit) { dp[pos][maxn] = res; } return res;};局限性分析:
这个思路是正确的,但它的状态空间是 pos * maxn。pos 的范围是 ,而 maxn 的范围也可以达到 。因此,DP 数组 的大小是 。当 时, 的状态空间会轻易地超出内存限制(MLE)。
这启发我们,必须寻找一种更高效的 DP 状态设计和计算方法。
高效的排名 DP
与其在外部循环计算排名,不如让 DP 本身直接计算排名。我们可以设计一个 DP 状态,它不仅包含方案数,还包含目标序列的排名。
状态定义 我们采用自底向上(从第 个选手反向到第 1 个)的方式进行 DP,并使用滚动数组优化空间。
dp[m][0]: 表示对于某个后缀的选手,在他们之前已经有m个队伍的情况下,总共有多少种合法的分配方案。dp[m][1]: 表示对于目标序列 A 的某个后缀,在他们之前已经有m个队伍的情况下,这个后缀序列在所有合法后缀中所处的排名。
状态转移
我们从第 i = n - 1 个选手开始,倒着计算到第 i = 0 个选手。在计算第 i 层的 DP 值时,我们会用到第 i + 1 层的结果。
-
dp[m][0](方案数计算) 对于第i个选手,如果前面已经有m个队伍,他可以选择加入这m个队伍中的任意一个,或者新开一个m + 1号队伍。- 加入
m个旧队伍:有m种选择。后续n - i - 1个选手面临的局面是,仍然有m个队伍可选。方案数为m * dp_next[m][0]。 - 新开
m + 1号队伍:有1种选择。后续n - i - 1个选手面临的局面是,有m + 1个队伍可选。方案数为1 * dp_next[m + 1][0]。 所以,转移方程为:dp[m][0] = m * dp_next[m][0] + dp_next[m + 1][0]
- 加入
-
dp[m][1](排名计算) 第i个选手的队伍是a[i]。他的排名由两部分构成:- 贡献1: 第
i位选择比a[i]小的队伍编号k(1 <= k < a[i]),所能产生的所有后缀方案数。 - 贡献2: 第
i位选择a[i],然后看后缀a[i+1...]的排名。
我们分两种情况讨论(基于
a[i]是否是新队伍):- Case 1:
a[i] <= m(加入旧队伍)- 贡献1: 我们可以选择
k = 1, ..., a[i] - 1。这些选择都不会改变最大队伍数m。因此,对于每一种选择,后续都有dp_next[m][0]种方案。总贡献为(a[i] - 1) * dp_next[m][0]。 - 贡献2: 我们选择了
a[i],最大队伍数仍为m。后续排名为dp_next[m][1]。 - 总排名:
(a[i] - 1) * dp_next[m][0] + dp_next[m][1]。
- 贡献1: 我们可以选择
- Case 2:
a[i] > m(新开队伍)- 根据合法性,此时必有
a[i] = m+1。 - 贡献1: 我们可以选择加入旧的
m个队伍。每种选择后续都有dp_next[m][0]种方案。总贡献为m * dp_next[m][0]。 - 贡献2: 我们选择了
a[i] = m+1,最大队伍数变为m + 1。后续排名为dp_next[m + 1][1]。 - 总排名:
m * dp_next[m][0] + dp_next[m + 1][1]。
- 根据合法性,此时必有
- 贡献1: 第
代码实现
这份代码完美地实现了上述思路,并通过滚动数组将空间复杂度优化到了 。
#include <bits/stdc++.h>using namespace std;using i64 = long long;constexpr int M = 1e6 + 7;
void solve() { int n; cin >> n; vector<int> a(n); for (int i = 0; i < n; i++) { cin >> a[i]; }
// dp[j][0] -> 方案数, dp[j][1] -> 排名 // 状态 j 代表 max_teams + 1 vector<array<int, 2>> dp(n + 2, {0, 0});
// Base Case: 对于 0 个选手 (i=n), 方案数为1, 排名为1 for (int j = 1; j <= n + 1; j++) { dp[j][1] = 1; dp[j][0] = 1; }
// 从第 n-1 个选手倒推到第 0 个 for (int i = n - 1; i >= 0; i--) { auto ndp = dp; // ndp 是 i 层的结果, dp 是 i+1 层的结果 for (int j = 1; j <= n; j++) { // j 是 max_teams + 1 int m = j - 1; // m 是 max_teams
// 计算 ndp[j][0] (方案数) ndp[j][0] = (1LL * m * dp[j][0] % M + dp[j + 1][0]) % M;
// 计算 ndp[j][1] (排名) if (a[i] <= m) { // Case 1: 加入旧队伍 ndp[j][1] = (1LL * (a[i] - 1) * dp[j][0] % M + dp[j][1]) % M; } else { // Case 2: 新开队伍 (a[i] must be m+1) ndp[j][1] = (1LL * m * dp[j][0] % M + dp[j + 1][1]) % M; } } dp = std::move(ndp); }
// 初始状态:0个选手之前,有0个队伍 (m=0), 对应 j=1 cout << dp[1][1] << "\n";}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); solve(); return 0;}总结
本题展示了数位 DP 的强大扩展性。当面对因为状态空间过大而无法直接实现的朴素 DP 时,我们可以尝试:
- 优化 DP 目标: 从“计数”转为直接计算“排名”,将排名信息融入状态中。
- 优化 DP 维度: 采用自底向上的递推,并结合滚动数组,将空间复杂度从 降至 ,从而通过大规模数据。 这个从“计数”到“求排名”的思维转变,以及对 DP 状态和计算顺序的精妙设计,是解决复杂计数问题的关键。