[R16E] 数组问题

  • 难度 普及+/提高
  • 时限 1s
  • 空限 512m
  • 动态规划分拆数预处理

数据规模:1T1051 \le T \le 10^51nm80001 \le n \le m \le 8000。答案对 998244353998244353 取模。

思路

合法数组 a1a2ana_1 \le a_2 \le \cdots \le a_nai=m\sum a_i = ma11a_1 \ge 1。这恰好是 mmnn 部分拆数,即把 mm 拆成 nn 个正整数之和(不计顺序)的方案数。

dp[i][j]dp[i][j] 表示把 jj 拆成 ii 个正整数之和的方案数。按划分中最小值是否为 11 分类:

  1. 划分里含至少一个 11:去掉一个 11,剩下的就是把 j1j - 1 拆成 i1i - 1 个正整数,方案数 dp[i1][j1]dp[i-1][j-1]
  2. 划分里所有数都大于 11:把 ii 个数各减 11,等价于把 jij - i 拆成 ii 个正整数,方案数 dp[i][ji]dp[i][j-i]

于是转移方程为

dp[i][j]=dp[i1][j1]+dp[i][ji],dp[i][j] = dp[i-1][j-1] + dp[i][j-i],

边界 dp[0][0]=1dp[0][0] = 1,且只有 jij \ge i 时状态有意义。注意第二种转移要求 jiij - i \ge i(即 k=jiik = j - i \ge ikk 为行内下标),否则第二项为 00

由于 TT 高达 10510^5,必须离线预处理整张表,查询时 O(1)O(1) 查表。预处理时间 O(nm)O(nm),查询时间 O(1)O(1),总复杂度 O(nm+T)O(nm + T)

仓颉实现

import std.console.*
import std.convert.*

main(): Int64 {
    let MOD: UInt32 = 998244353
    let MAX: Int64 = 8000
    // dp[i][j] = number of ways to partition j into i positive parts.
    // Only j >= i is meaningful. Use a triangular layout to save memory:
    // for row i, store values for j in [i, MAX] in length (MAX - i + 1),
    // i.e. index k corresponds to j = i + k.
    // MOD < 2^31, so MOD+MOD < 2^32 fits in UInt32 without overflow.
    var dp = Array<Array<UInt32>>(MAX + 1, { _ => Array<UInt32>(0, { _ => UInt32(0) }) })
    // dp[0][0] = 1 (empty partition of 0); all other dp[0][k>0] = 0.
    let row0 = Array<UInt32>(MAX + 1, { _ => UInt32(0) })
    row0[0] = UInt32(1)
    dp[0] = row0
    var i: Int64 = 1
    while (i <= MAX) {
        let len: Int64 = MAX - i + 1
        let row = Array<UInt32>(len, { _ => UInt32(0) })
        var k: Int64 = 0
        while (k < len) {
            // dp[i][j] = dp[i-1][j-1] + dp[i][j-i], where j = i + k.
            // part 1: dp[i-1][j-1] -> previous row index k (since (j-1)-(i-1)=k).
            var v = dp[i - 1][k]
            // part 2: dp[i][j-i] -> current row index k-i, valid only if k-i >= 0.
            if (k >= i) {
                v = v + row[k - i]
                if (v >= MOD) {
                    v = v - MOD
                }
            }
            row[k] = v
            k = k + 1
        }
        dp[i] = row
        i = i + 1
    }
    let reader = Console.stdIn
    let t = Int64(reader.readln().getOrThrow().split(" ", removeEmpty: true).map({ p => Int64.parse(p) })[0])
    var out = StringBuilder()
    var count: Int64 = 0
    while (count < t) {
        count = count + 1
        let parts = reader.readln().getOrThrow().split(" ", removeEmpty: true).map({ p => Int64.parse(p) })
        let n = parts[0]
        let m = parts[1]
        // dp[n][m]: row n index for value m is m - n.
        let ans = Int64(dp[n][m - n])
        out.append(ans)
        out.append('\n')
    }
    print(out.toString())
    return 0
}

要点:

  • 分拆数递推:把合法序列重述为「mm 拆成 nn 个正整数」,立刻得到经典分拆数递推 dp[i][j]=dp[i1][j1]+dp[i][ji]dp[i][j] = dp[i-1][j-1] + dp[i][j-i],两个加法项分别对应「划分里有一个 11」「所有数都减 11」两种情形。
  • 三角化存储省内存:只有 jij \ge i 时状态非零,第 ii 行只存下标 k=ji[0,MAXi]k = j - i \in [0, MAX-i],下标换算 j=i+kj = i + k。完整 8001×80018001 \times 8001Int64 表约 512MB,三角化后约 3200 万项。
  • UInt32 存值:模数 998244353<231998244353 < 2^{31},两项之和 <232< 2^{32} 不会溢出,用 v + row[k-i] 后比较减模即可。约 3200 万项的 UInt32 表约 128MB,实测峰值 160MB,远低于 512MB 上限。逐行只引用「上一行」与「本行左侧」,递推方向天然无依赖冲突。
  • 预处理后查询 O(1)O(1)TT10510^5 时不能每次重算;整张表离线建好后,每问只查 dp[n][m-n] 一次。输出用 StringBuilder 汇总再一次性打印,避免逐行 I/O 的开销。