[NOIP2001-提高] 统计单词个数

GitHub跳转原题关系图返回列表

每份得分只依赖两个端点:预处理 cut[t][j] 计数表,用 f[j][m]=max(f[t][m-1]+cut[t][j]) 恰好分成 k 份求最大值。

OJ: roj

题目 ID: 3510

难度:普及+/提高-

标签:动态规划线性DP字符串python

创建: 2026-10-02 04:33

更新: 2026-10-10 11:30

形式化题目

给定小写字母串 SS(由 pp 行、每行 20 个字母合并而成,n=20p⩽200n = 20p \leqslant 200)与字典 WW(s⩽6s \leqslant 6 个单词)。把 SS 切成恰好 kk 份非空连续段 S=A1A2⋯AkS = A_1 A_2 \cdots A_k(1<k⩽401 < k \leqslant 40),每份 AiA_i 的得分按下面三条规则数单词:

  1. 单词可以在串中任意位置匹配,可以部分重叠;
  2. 选用一个单词后它的第一个字母不能再用——同一起点最多选一个单词,因此同一起点匹配到多个字典词也只计 1 次;
  3. 每份只数完整落在该份内的单词,跨过切点的单词不属于任何一份。

每份得分即该份内可数的单词个数,求所有划分方案的总得分最大值。

样例:k=3k = 3,字典 {is, a, ok, sab},串 thisisabookyouareaoh,输出 7。先标出串里每个能匹配单词的起点:

起点 pp 匹配单词 最短单词末尾
2 is 3
4 is 5
5 sab 7
6 a 6
9 ok 10
14 a 14
17 a 17

这张表展示样例全部 7 个可数起点(其余位置匹配不到任何单词)。题面给出的划分 this / isabookyoua / reaoh 恰好把它们分开数:第 1 份只有起点 2,第 2 份含起点 4、5、6、9、14,第 3 份只有起点 17,合计 1+5+1=71 + 5 + 1 = 7。注意起点 4 的 is 与起点 5 的 sab 虽然重叠,但首字母下标不同,可以同时计数。

正解

思路

计数模型:起点之间互不冲突

规则 2 只限制"同一起点选一个单词",而不同起点的首字母是不同下标,单词区间重叠也不互相占用(规则 1 明确允许部分重叠)。所以一份的得分就是可数起点的个数,不需要在一份里再做"选哪些词"的贪心或区间调度。

对起点 pp,记 end[p]\mathrm{end}[p] 为它能匹配的最短单词末尾下标。份 [t,j)[t, j)(jj 为右端点开区间)里 pp 可数当且仅当

t⩽p且end[p]⩽j−1,t \leqslant p \quad\text{且}\quad \mathrm{end}[p] \leqslant j-1,

即最短的匹配单词完整落在份内——最短的放得下,等价于"存在放得下的单词"。同一起点匹配多个词(字典还可能有重复单词)都被 end[p]\mathrm{end}[p] 收敛成一个下标比较,天然只计 1 次。

朴素做法与瓶颈

朴素做法是枚举全部 (n−1k−1)\binom{n-1}{k-1} 种切法再逐份扫描计数,n=200n = 200 时组合数天文数字;而且"起点在 [t,j)[t,j) 里可不可数"只由 (t,j)(t, j) 两个端点决定,与两端怎么切无关,却被不同切法反复重算。两个瓶颈各对应一步优化:切法枚举 → 分段 DP;重复计数 → 预处理计数表。

预处理计数表

cut[t][j]=∣{ p:t⩽p<j, end[p]⩽j−1 }∣\mathrm{cut}[t][j] = \bigl|\{\,p : t \leqslant p < j,\ \mathrm{end}[p] \leqslant j-1\,\}\bigr|

固定右端点 jj,左端点从 j−1j-1 倒序扫到 00,边扫边累加"end[t]⩽j−1\mathrm{end}[t] \leqslant j-1"即可,O(n2)O(n^2) 建完整张表,之后任意一份的得分 O(1)O(1) 查表。

状态与转移

设 f[j][m]f[j][m] = 前 jj 个字符恰好分成 mm 份非空段的最大单词数。枚举最后一份的左端点 tt:

f[j][m]=max⁡m−1⩽t<j (f[t][m−1]+cut[t][j]),f[0][0]=0.f[j][m] = \max_{m-1 \leqslant t < j}\ \bigl(f[t][m-1] + \mathrm{cut}[t][j]\bigr),\qquad f[0][0] = 0.
  • t⩾m−1t \geqslant m-1:前 m−1m-1 份每份至少 1 个字符;t<jt < j:最后一份至少 1 个字符——枚举范围本身就保证了"恰好 mm 份、每份非空",j<mj < m 的状态不可达(代码里留哨兵 NEG)。
  • 答案是 f[n][k]f[n][k]。遍历顺序 j→m→tj \to m \to t 逐层递增,f[t][m−1]f[t][m-1](t<jt < j)总在使用前已算出。必须是“恰好”而非“至多”:多切一刀会把跨切点的起点切废,分得越细可能亏得越多(例如 200 个 a 配词 aaaaa,共 196 个起点,k=4k = 4 最优 193,k=40k = 40 只剩 157)。若按"至多 kk 份"算,两者都会得到 193,与真实数据不符。

样例 DP 表

下表是样例 f[j][m]f[j][m] 的关键行:行是前缀长度 jj,列是份数 mm,单元格是对应的 f[j][m]f[j][m](只列数值发生变化的 jj):

jj f[j][1]f[j][1] f[j][2]f[j][2] f[j][3]f[j][3]
0 — — —
4 1 1 1
6 2 2 2
8 4 4 4
11 5 5 5
15 6 6 6
18 7 7 7
20 7 7 7

最后一格来自 f[20][3]=f[15][2]+cut[15][20]=6+1=7f[20][3] = f[15][2] + \mathrm{cut}[15][20] = 6 + 1 = 7,其中 f[15][2]=f[4][1]+cut[4][15]=1+5=6f[15][2] = f[4][1] + \mathrm{cut}[4][15] = 1 + 5 = 6,正好对应题面划分 this / isabookyoua / reaoh 的逐份计数。表中"—"表示 j<mj < m 的不可达状态;样例里三列数值相同,是因为对每个前缀都存在不损失任何起点的分法,最优值恰好与份数无关——而真实数据(全 a 串配词 aaaaa)会因跨界损失拉开列间差距。

代码

正文的 end\mathrm{end}、cut\mathrm{cut}、ff 分别对应代码里的 match_end() / ends、build_cut() / cut、max_words() / f。

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-10 11:30
 * update_at: 2026-10-10 11:30
 */
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int NEG = -1000000000;

// end[p]:从 p 出发能匹配的最短单词的末尾下标;匹配不到为 -1
int end_[205];
int cut[205][205];   // cut[t][j]:把 [t, j) 单独作一份时能数到的单词起点数
int f[205][45];      // f[j][m]:前 j 个字符分成 m 份的最大单词数

// 多组输入,每组算一次
int max_words(const string& text, const vector<string>& words, int k) {
    int n = (int)text.size();
    for (int p = 0; p < n; p++) {
        int best = -1;
        for (size_t i = 0; i < words.size(); i++) {
            if (text.compare(p, words[i].size(), words[i]) == 0) {
                int e = p + (int)words[i].size() - 1;
                if (best == -1 || e < best) best = e;
            }
        }
        end_[p] = best;
    }
    // cut 打表
    for (int j = 1; j <= n; j++) {
        int acc = 0;
        for (int t = j - 1; t >= 0; t--) {
            if (end_[t] != -1 && end_[t] <= j - 1) acc++;
            cut[t][j] = acc;
        }
    }
    for (int j = 0; j <= n; j++)
        for (int m = 0; m <= k; m++)
            f[j][m] = NEG;
    f[0][0] = 0;
    for (int j = 1; j <= n; j++) {
        for (int m = 1; m <= min(j, k); m++) {
            int best = NEG;
            for (int t = m - 1; t < j; t++) {
                if (f[t][m - 1] > NEG) {
                    int v = f[t][m - 1] + cut[t][j];
                    if (v > best) best = v;
                }
            }
            f[j][m] = best;
        }
    }
    return f[n][k];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    string line;
    vector<string> lines;
    while (getline(cin, line)) {
        // 去首尾空白
        string t;
        int a = 0, b = (int)line.size();
        while (a < b && isspace((unsigned char)line[a])) a++;
        while (b > a && isspace((unsigned char)line[b - 1])) b--;
        t = line.substr(a, b - a);
        if (!t.empty()) lines.push_back(t);
    }
    size_t i = 0;
    while (i < lines.size()) {
        int p, k;
        stringstream ss(lines[i]);
        ss >> p >> k;
        i++;
        string text;
        for (int j = 0; j < p && i < lines.size(); j++, i++)
            text += lines[i];
        int s = 0;
        if (i < lines.size()) {
            stringstream ss2(lines[i]);
            ss2 >> s;
            i++;
        }
        vector<string> words;
        for (int j = 0; j < s && i < lines.size(); j++, i++)
            words.push_back(lines[i]);
        cout << max_words(text, words, k) << "\n";
    }
    return 0;
}
python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-02 04:37
# update_at: 2026-10-02 04:47

import sys

NEG = -10**9  # 不可达状态的哨兵:份数超过字符数时凑不出合法划分


def match_end(text: str, words: list[str]) -> list[int | None]:
    """end[p]:从 p 出发能匹配的最短单词的末尾下标;该处匹配不到任何单词则为 None。

    同一起点匹配多个单词时只留最短的:整段落在一份里的前提是末尾不越界,
    最短的能放下,其余更长的也一定能放下。
    """
    ends: list[int | None] = []
    for p in range(len(text)):
        lens = [len(w) for w in words if text.startswith(w, p)]
        ends.append(p + min(lens) - 1 if lens else None)
    return ends


def build_cut(ends: list[int | None]) -> list[list[int]]:
    """cut[t][j]:把 [t, j) 单独作一份时能数到的单词起点数。

    起点 p 只有在“单词完整落在份内”(末尾 ≤ j-1)时才计 1 次;
    多个单词共享同一起点只算一个(首字母只能用一次)。
    """
    n = len(ends)
    cut = [[0] * (n + 1) for _ in range(n + 1)]
    for j in range(1, n + 1):
        acc = 0
        for t in range(j - 1, -1, -1):  # 右端点固定,左端点从右往左累计
            acc += ends[t] is not None and ends[t] <= j - 1
            cut[t][j] = acc
    return cut


def max_words(text: str, words: list[str], k: int) -> int:
    """把 text 切成恰好 k 份非空连续段,最大化各份内可数的单词起点总数。"""
    n = len(text)
    cut = build_cut(match_end(text, words))

    # f[j][m]:前 j 个字符恰好分成 m 份的最大单词数;t 是最后一份的左端点
    f = [[NEG] * (k + 1) for _ in range(n + 1)]
    f[0][0] = 0
    for j in range(1, n + 1):
        for m in range(1, min(j, k) + 1):  # 每份至少 1 个字符,m > j 不可达
            f[j][m] = max(f[t][m - 1] + cut[t][j] for t in range(m - 1, j))
    return f[n][k]


def solve() -> None:
    lines = [ln.strip() for ln in sys.stdin.read().splitlines() if ln.strip()]
    out: list[str] = []
    i = 0
    while i < len(lines):  # 输入可能包含多组测试数据
        p, k = map(int, lines[i].split())  # p 行字母串,切成 k 份
        i += 1
        text = "".join(lines[i:i + p])  # 每行恰好 20 个字母,合并成一个串
        i += p
        s = int(lines[i])  # 字典单词个数
        i += 1
        words = lines[i:i + s]
        i += s
        out.append(str(max_words(text, words, k)))

    print("\n".join(out))


if __name__ == "__main__":
    solve()

复杂度

  • 时间:预处理 end\mathrm{end} 为 O(n⋅s⋅L)O(n \cdot s \cdot L),建 cut\mathrm{cut} 为 O(n2)O(n^2),DP 三重循环 ∑j∑m(j−m+1)=O(n2k)\sum_{j}\sum_{m}(j-m+1) = O(n^2 k);n⩽200, k⩽40n \leqslant 200,\ k \leqslant 40 时约 6.6×1056.6 \times 10^5 次候选转移,与标准 C++ 解法同阶。
  • 空间:cut\mathrm{cut} 为 (n+1)2(n+1)^2、ff 为 (n+1)(k+1)(n+1)(k+1),合计 O(n2+nk)O(n^2 + nk),约 0.5 MB。

总结

  • 计数规则决定模型:同一起点只计 1 次、不同起点互不冲突,所以一份的得分是区间内可数起点的计数,不需要选词贪心。
  • 只保留每个起点的最短匹配末尾 end[p]\mathrm{end}[p],把"单词是否完整落在份内"压成一次下标比较。
  • 分段 DP 枚举最后一份左端点,cut[t][j]\mathrm{cut}[t][j] 把区间得分封装成 O(1)O(1) 查表;“恰好 kk 份非空"由枚举范围 m−1⩽t<jm-1 \leqslant t < j 保证,不能放松成"至多”。
  • 三步实现(合并串 → 预处理 ends/cut → DP)与推导一一对应,8 组真实数据全量 PASS(0.015–0.042 s、峰值约 15.2 MB,限制 1000 ms / 32 MB),Python 无 TLE/MLE 风险。