第二章:枚举与模拟

枚举遍历候选并检查条件,模拟按规则更新状态。设计时先确定对象、范围和操作顺序,再利用约束减少重复工作。

1. 确定枚举对象与范围

1.1 枚举整数与数位

例题:NOIP 2013 普及组·计数问题

简易题面:统计整数 11nn 的十进制表示中,数字 xx 总共出现了多少次。

数据范围:1n1061\le n\le 10^60x90\le x\le 9

题目统计出现次数,例如 1111 中的两个 11 都要计入答案。因此,直接枚举每个整数,再逐位检查。对 1010 取余得到个位,整除 1010 去掉个位;例如 101101 依次拆出 1,0,11,0,1,顺序不影响计数。

拆位会改变数值,需先把循环变量 ii 复制到 tt。以下 ansans 初始为 00

for (int i = 1; i <= n; ++i)
{
    int t = i;
    while (t > 0)
    {
        if (t % 10 == x)
        {
            ++ans;
        }
        t /= 10;
    }
}

每个整数的每一位恰好检查一次。设最大整数有 DD 位,时间复杂度为 O(nD)O(nD),额外空间为 O(1)O(1)。本题至多七百万次拆位,直接枚举足够。若枚举范围包含整数 00,需单独统计它的一位数字 00,因为 while (t>0)\texttt{while }(t>0) 不会执行。

1.2 用唯一表示避免重复枚举

候选对象表示方式范围或去重规则
一个整数数值 xx枚举给定闭区间
两个不同位置组成的无序数对下标 i,ji,j1i<jn1\le i<j\le n
一个非空连续区间左右端点 l,rl,r1lrn1\le l\le r\le n
网格中的定长线段起点、方向和长度检查边界,按题意区分正反方向

任意无序位置对都能把较小下标放在前面,得到唯一的 i<ji<j,因此枚举全部这样的下标对就能不重不漏。连续区间同理由唯一的左右端点确定。

位置与数值不能混淆。数组 2,2,32,2,3 中,两个 22 分别与 33 配对,是两个不同的位置对;只有题目要求按数值去重时才能合并。

2. 利用等式减少枚举变量

例题:三连击(升级版)

简易题面:将数字 1199 各使用一次,组成三个三位数 x,y,zx,y,z,使 x:y:z=A:B:Cx:y:z=A:B:C。按 xx 递增输出全部方案;无解时输出 No!!!\texttt{No!!!}

数据范围:0A<B<C9990\le A<B<C\le 999,三个结果均为三位正整数。

分别枚举三个三位数需要检查 9003900^3 组候选。比例关系可以减少枚举变量:当 A>0A>0 时,确定 xx 就唯一确定了

y=xBA,z=xCA.y=\frac{xB}{A},\qquad z=\frac{xC}{A}.

因此只枚举 xx,先检查 xB,xCxB,xC 能否被 AA 整除,再检查 y,zy,z 是否为三位数,以及九个数位是否恰好使用 1199 各一次。例如比例为 1:2:31:2:3 时,192,384,576192,384,576 合法,123,246,369123,246,369 因数位重复而不合法。若 A=0A=0,第一个数只能为 00,直接判定无解。

以下已声明 cnt[10]cnt[10]v[3]v[3]foundfound 初始为假;每个候选都重新清空数位计数。

if (A > 0)
{
    for (int x = 100; x <= 999; ++x)
    {
        if (x * B % A != 0 || x * C % A != 0)
        {
            continue;
        }
        int y = x * B / A, z = x * C / A;
        if (y < 100 || y > 999 || z < 100 || z > 999)
        {
            continue;
        }
        for (int d = 0; d <= 9; ++d)
        {
            cnt[d] = 0;
        }
        v[0] = x;
        v[1] = y;
        v[2] = z;
        for (int i = 0; i < 3; ++i)
        {
            int t = v[i];
            while (t > 0)
            {
                ++cnt[t % 10];
                t /= 10;
            }
        }
        bool ok = cnt[0] == 0;
        for (int d = 1; d <= 9; ++d)
        {
            if (cnt[d] != 1)
            {
                ok = false;
            }
        }
        if (ok)
        {
            printf("%d %d %d\n", x, y, z);
            found = true;
        }
    }
}

片段结束后,若 foundfound 仍为假,输出 No!!!\texttt{No!!!}。每个合法方案都有唯一的首数,枚举会按递增顺序访问它,因此输出不重不漏。共检查 900900 个首数,每次处理九个数位;乘积不超过 9992999^2,使用 int\texttt{int} 足够。

同理,方程 ax+by=nax+by=na,b>0a,b>0x,y0x,y\ge0 时,可枚举 0xn/a0\le x\le\lfloor n/a\rfloor,再通过整除检查确定 yy。减少变量的依据是等式约束。

3. 调整枚举顺序

例题:NOIP 2011 提高组·铺地毯

简易题面:按编号依次铺放 nn 张矩形地毯,后铺的盖住先铺的。求覆盖查询点的最上面一张地毯的编号,无覆盖时输出 1-1,边界也算覆盖。

数据范围:0n1040\le n\le10^4,左下角坐标和边长均在 0010510^5 之间。

设第 ii 张地毯左下角为 (ai,bi)(a_i,b_i),横向、纵向边长分别为 gi,kig_i,k_i。题目只查询一个点 (x,y)(x,y),无需逐格铺设,只需检查

aixai+gi,biybi+ki.a_i\le x\le a_i+g_i,\qquad b_i\le y\le b_i+k_i.

从前往后检查时,每遇到覆盖就更新答案,最终留下最后一张。既然答案是覆盖该点的最大编号,改成从后往前检查,遇到第一张覆盖就能停止。

int ans = -1;
for (int i = n; i >= 1; --i)
{
    if (a[i] <= x && x <= a[i] + g[i] && b[i] <= y && y <= b[i] + k[i])
    {
        ans = i;
        break;
    }
}

例如覆盖编号为 2,5,72,5,7,逆序首先找到 77;没有覆盖则保留 1-1。两种顺序的最坏时间均为 O(n)O(n),逆序只是允许提前结束。

4. 按规则维护过程状态

例题:NOIP 2003 普及组·乒乓球

简易题面:W\texttt{W}L\texttt{L} 分别表示双方得分,E\texttt{E} 表示记录结束,其后内容忽略。分别按 1111 分制和 2121 分制输出各局比分;一方达到规定分数且领先至少两分才结束一局。最后还要输出当前局比分,两种分制之间空一行。

数据范围:每行至多 2525 个字符,原题至多 25002500 行;洛谷保留的一组数据有 25012501 行。

每个字符只改变一方得分,按顺序处理即可。当前局用 a,ba,b 记录比分:先得分,再判断是否满足

max(a,b)Lab2.\max(a,b)\ge L\quad\text{且}\quad |a-b|\ge2.

其中 LL 为规定分数。例如 1111 分制的 11:1011:10 尚未结束,12:1012:10 才结束;结束时先输出,再清零。两种分制的分局位置不同,应分别从 0:00:0 开始处理同一份记录。

以下字符数组 ss 已去除换行,长度为 nn,容量至少为 6252662526,含字符串结束位置。

void play(int lim)
{
    int a = 0, b = 0;
    for (int i = 0; i < n && s[i] != 'E'; ++i)
    {
        if (s[i] == 'W')
        {
            ++a;
        }
        else if (s[i] == 'L')
        {
            ++b;
        }
        if (max(a, b) >= lim && abs(a - b) >= 2)
        {
            printf("%d:%d\n", a, b);
            a = b = 0;
        }
    }
    printf("%d:%d\n", a, b);
}

分别调用 play(11)play(11)play(21)play(21),中间输出空行。循环结束后仍需输出当前比分:即使开头就是 E\texttt{E},或恰好在一局结束后终止,也要输出 0:00:0。设记录长度为 TT,两次扫描总时间为 O(T)O(T),保存记录需要 O(T)O(T) 空间。

5. 网格枚举与状态更新

5.1 按坐标枚举邻居

例题:NOIP 2015 普及组·扫雷游戏

简易题面:给出 nnmm 列的网格,*\texttt{*} 表示地雷,?\texttt{?} 表示空格。保留地雷,将每个空格改为周围八个相邻位置中的地雷数量。

数据范围:1n,m1001\le n,m\le100

每个空格只需检查八个邻居。它们的行、列增量均在 1,0,1-1,0,1 中,排除同时为 00 即可。边缘邻居可能越界,必须先判断坐标是否合法,再读取数组。

以下原图存于 gg,结果存于 bb,行号为 11nn,列号为 11mm

for (int i = 1; i <= n; ++i)
{
    for (int j = 1; j <= m; ++j)
    {
        if (g[i][j] == '*')
        {
            b[i][j] = '*';
            continue;
        }
        int cnt = 0;
        for (int dx = -1; dx <= 1; ++dx)
        {
            for (int dy = -1; dy <= 1; ++dy)
            {
                if (dx == 0 && dy == 0)
                {
                    continue;
                }
                int x = i + dx, y = j + dy;
                if (x >= 1 && x <= n && y >= 1 && y <= m && g[x][y] == '*')
                {
                    ++cnt;
                }
            }
        }
        b[i][j] = char('0' + cnt);
    }
}

只有左上角为雷的 2×22\times2 网格,其余三格都应显示 11,因为斜角也相邻。每格至多检查八次,时间、存图空间均为 O(nm)O(nm)

5.2 枚举起点与固定方向

例题:单词方阵

简易题面:在字母方阵中找出所有沿同一直线连续排列的 yizhong\texttt{yizhong},方向可以是上、下、左、右或四个斜向,单词可以交叉。保留属于这些单词的字母,其余位置输出 *\texttt{*}

数据范围:方阵大小为 n×nn\times n7n1007\le n\le100,目标单词长度为 77

先把每个格子作为单词开头,检查能否连续读出七个字母。题目要求整个单词方向不变,因此确定起点 (x,y)(x,y) 和方向增量 (dx,dy)(d_x,d_y) 后,剩余位置就全部确定:

(x+tdx,y+tdy),0t<7.(x+td_x,y+td_y),\qquad 0\le t<7.

枚举每个起点和八个方向,沿该方向逐字比较;越界或字母不符就放弃,全部匹配后再标记七个位置。必须先检查完整个单词,避免留下失败候选的部分标记;标记也不能改动原图,否则会影响交叉单词。

以下 g[105][105]g[105][105] 存原图,下标从 11 开始;布尔数组 vis[105][105]vis[105][105] 初始为假。

char s[] = "yizhong";
int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
for (int i = 1; i <= n; ++i)
{
    for (int j = 1; j <= n; ++j)
    {
        for (int d = 0; d < 8; ++d)
        {
            bool ok = true;
            for (int t = 0; t < 7; ++t)
            {
                int x = i + t * dx[d], y = j + t * dy[d];
                if (x < 1 || x > n || y < 1 || y > n || g[x][y] != s[t])
                {
                    ok = false;
                    break;
                }
            }
            if (ok)
            {
                for (int t = 0; t < 7; ++t)
                {
                    vis[i + t * dx[d]][j + t * dy[d]] = true;
                }
            }
        }
    }
}

最后按 visvis 输出原字母或 *\texttt{*}。每个单词的起点、方向都在枚举范围内,因此不会遗漏;交叉位置重复标记不影响结果。时间为 O(8n27)=O(n2)O(8n^2\cdot7)=O(n^2),空间为 O(n2)O(n^2)。若改为统计无向线段且正反算同一条,则只枚举一半方向,避免重复计数。

5.3 区分旧状态与新状态

扫雷只读取地雷标记,将 ?\texttt{?} 原地改成数字不会影响后续计数;单词方阵则需要保留全部原字母。能否原地更新,取决于是否会覆盖后续仍需读取的旧值。

例如旧数组为 1,2,31,2,3,首项不变,其余项同时变成“旧的左邻项与自身之和”,应得到 1,3,51,3,5。从左往右原地更新会让第三项误用新的第二项,得到 66

双数组是通用做法:从旧数组 aa 计算 bb,全部完成后再替换;未重新计算的位置需保留旧值。本例也可从右往左原地更新,使所需的左邻项仍是旧值。关键是保存必要的旧状态,而非必须使用两个数组。

6. 日期合法性与候选构造

例题:NOIP 2016 普及组·回文日期

简易题面:日期写成八位整数,依次为四位年、两位月、两位日。统计给定闭区间内有多少个合法日期,其八位表示是回文。

数据范围:起止日期合法且起始不晚于结束,年份为 1000100099999999

直接逐日模拟并检查回文,需要遍历所有日期、处理月末和年末进位。回文的后四位由前四位唯一确定,而前四位正是年份,因此每年只需构造一个候选。例如 20102010 生成 2010010220100102,但 20232023 生成的 2023320220233202 月份非法。

构造后先检查月份,再检查当月天数和区间。闰年二月有 2929 天,闰年条件为

ymod400=0(ymod4=0 且 ymod1000).y\bmod400=0\quad\text{或}\quad(y\bmod4=0\ \text{且}\ y\bmod100\ne0).

例如 20002000 年是闰年,19001900 年不是。以下 l,rl,r 为起止日期,ansans 初始为 00,平年月长存于 daysdays

int days[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
for (int y = l / 10000; y <= r / 10000; ++y)
{
    int x = y, t = y;
    for (int i = 0; i < 4; ++i)
    {
        x = x * 10 + t % 10;
        t /= 10;
    }
    int m = x / 100 % 100, d = x % 100;
    if (m < 1 || m > 12)
    {
        continue;
    }
    bool leap = y % 400 == 0 || (y % 4 == 0 && y % 100 != 0);
    int lim = days[m] + (m == 2 && leap);
    if (d >= 1 && d <= lim && x >= l && x <= r)
    {
        ++ans;
    }
}

每个合法回文日期都由其年份唯一构造,因此不重不漏。设区间含 YY 个年份,时间为 O(Y)O(Y),额外空间为 O(1)O(1),本题至多检查 90009000 个候选。

需要逐日模拟时,应按当前年月确定天数,再处理进位;跨年后重算闰年。时刻计算可先统一单位,例如将 hhmm 分转为 60h+m60h+m 分钟,运算后用整除、取余还原。

7. 按字符串规则划分处理对象

例题:NOIP 2011 普及组·统计单词数

简易题面:给出目标单词和一行只含英文字母、空格的文章。忽略大小写,统计完整单词的匹配次数及首次出现的下标;下标从 00 开始,空格也占位置,无匹配时输出 1-1

数据范围:单词长度为 111010,文章长度为 1110610^6

逐字符尝试匹配,还需检查左右边界:To\texttt{To} 能匹配 to\texttt{to}today\texttt{today} 的前两个字母却不算。既然空格已划分单词,可先取出完整单词再比较,省去额外的边界判断。

扫描时跳过空格,记录起点 ll,走到空格或文章末尾,得到单词区间 [l,i)[l,i)。长度相同才逐字比较,用 tolowertolower 忽略大小写。以下目标 tt 长度为 mm,文章 ss 长度为 nn,均从下标 00 开始;文章整行读入,保留全部空格。

int ans = 0, first = -1, i = 0;
while (i < n)
{
    while (i < n && s[i] == ' ')
    {
        ++i;
    }
    if (i == n)
    {
        break;
    }
    int l = i;
    while (i < n && s[i] != ' ')
    {
        ++i;
    }
    if (i - l != m)
    {
        continue;
    }
    bool ok = true;
    for (int j = 0; j < m; ++j)
    {
        if (tolower(s[l + j]) != tolower(t[j]))
        {
            ok = false;
            break;
        }
    }
    if (ok)
    {
        if (ans == 0)
        {
            first = l;
        }
        ++ans;
    }
}

无匹配时输出 1-1,否则输出 ansansfirstfirst。例如  To today TO\texttt{\ To\ today\ TO} 匹配 to\texttt{to} 两次,首次下标为 22。扫描和比较的字符总量为 O(n+m)O(n+m);最后一个单词在到达文章末尾时同样结算,不能只在遇到空格时处理。

8. 环形位置与周期序列

8.1 用取余代替逐个移动

例题:NOIP 2016 提高组·玩具谜题

简易题面:玩具按逆时针顺序围成一圈,各有名字和朝向。从第一个开始,执行“向左或右数 ss 个”的指令,左右以当前玩具朝向为准,输出最终玩具的名字。

数据范围:1n,m1051\le n,m\le10^51s<n1\le s<n,名字长度不超过 1010。朝内、朝外分别记为 0,10,1,向左、向右分别记为 0,10,1

逐个移动最坏需要 O(nm)O(nm) 时间。一条指令的方向由起点玩具确定,途中不会改变,因此可直接计算终点。按输入顺序编号为 00n1n-1,下标增加表示逆时针移动:

当前朝向指令方向下标变化
朝内 00向左 00减少
朝内 00向右 11增加
朝外 11向左 00增加
朝外 11向右 11减少

两个编码相同时减,不同时加,再对 nn 取余处理绕圈。以下 pospos 初始为 00dir[pos]dir[pos] 为当前朝向,a,sa,s 为当前指令。

if (dir[pos] == a)
{
    pos = (pos - s + n) % n;
}
else
{
    pos = (pos + s) % n;
}

例如 n=5n=5,从下标 11 减少 33,到达 (13+5)mod5=3(1-3+5)\bmod5=3。本题 s<ns<n,加一个 nn 即可保证非负;若允许更大步数,应先对 nn 取余。下一条指令读取新位置的朝向。处理指令的时间为 O(m)O(m),保存玩具需要 O(n)O(n) 空间。

8.2 维护多个周期的当前位置

例题:NOIP 2014 提高组·生活大爆炸版石头剪刀布

简易题面:双方循环使用各自的出拳序列,比赛 NN 轮。每轮胜者得一分,败者和平局不得分,求双方总分。手势关系如下。

编号与手势能战胜的手势
00:剪刀布、蜥蜴人
11:石头剪刀、蜥蜴人
22:布石头、斯波克
33:蜥蜴人布、斯波克
44:斯波克剪刀、石头

数据范围:1N,p,q2001\le N,p,q\le200p,qp,q 为双方周期长度,手势编号为 0044

逐轮模拟时,无需复制出拳序列:从第 00 轮开始,第 ii 轮的下标分别为 imodpi\bmod pimodqi\bmod q。胜负组合只有 2525 种,用二维表 win[x][y]win[x][y] 保存前者得分,反查 win[y][x]win[y][x] 得到后者得分。以下序列 a,ba,b 从下标 00 开始。

int win[5][5] = {
    {0, 0, 1, 1, 0}, {1, 0, 0, 1, 0}, {0, 1, 0, 0, 1}, {0, 0, 1, 0, 1}, {1, 1, 0, 0, 0}};
int sa = 0, sb = 0;
for (int i = 0; i < N; ++i)
{
    int x = a[i % p], y = b[i % q];
    sa += win[x][y];
    sb += win[y][x];
}

p=2,q=3p=2,q=3,双方下标组合依次为

(0,0),(1,1),(0,2),(1,0),(0,1),(1,2),(0,0).(0,0),(1,1),(0,2),(1,0),(0,1),(1,2),(0,0).

只有双方下标都复原,后续过程才整体重复。原题逐轮处理的时间为 O(N)O(N),空间为 O(p+q)O(p+q)。若轮数很大,可模拟至组合首次回到 (0,0)(0,0),求出周期长度 TT 和得分,再计算 N/T\lfloor N/T\rfloor 个整周期及 NmodTN\bmod T 轮余数的贡献。

9. 用有限状态判断过程是否终止

例题:USACO 2.4·两只塔姆沃斯牛

简易题面:农夫和牛群均朝北,每分钟同时行动:前方可走则前进一步,否则原地顺时针转 9090 度。求首次在某分钟结束时位于同一格的时间,永不相遇输出 00,途中交错不算相遇。

数据范围:地图固定为 10×1010\times10,含空地、障碍和双方各一个起点,起点不同。

直接按分钟模拟,可能因永不相遇而无法结束。固定地图上,下一步由位置和朝向决定,因此需记录双方各自的“行、列、朝向”。只记录位置会混淆不同朝向,只记录一方也无法确定另一方的动作。

完整状态再次出现时,之后的变化必然重复;此前未相遇,以后也不会相遇。每方至多有 10×10×4=40010\times10\times4=400 种状态,可编码为

id(x,y,d)=(10x+y)×4+d.\operatorname{id}(x,y,d)=(10x+y)\times4+d.

行、列从 00 开始,上、右、下、左编号为 0,1,2,30,1,2,3。以下 x[0],y[0]x[0],y[0]x[1],y[1]x[1],y[1] 为双方位置,d[0],d[1]d[0],d[1] 初始为 00vis[400][400]vis[400][400] 初始为假,地图 gg*\texttt{*} 表示障碍。

int dx[4] = {-1, 0, 1, 0};
int dy[4] = {0, 1, 0, -1};
int ans = 0;
while (x[0] != x[1] || y[0] != y[1])
{
    int u = (x[0] * 10 + y[0]) * 4 + d[0];
    int v = (x[1] * 10 + y[1]) * 4 + d[1];
    if (vis[u][v])
    {
        ans = 0;
        break;
    }
    vis[u][v] = true;
    for (int k = 0; k < 2; ++k)
    {
        int nx = x[k] + dx[d[k]], ny = y[k] + dy[d[k]];
        if (nx < 0 || nx >= 10 || ny < 0 || ny >= 10 || g[nx][ny] == '*')
        {
            d[k] = (d[k] + 1) % 4;
        }
        else
        {
            x[k] = nx;
            y[k] = ny;
        }
    }
    ++ans;
}

双方只读取自身状态和固定地图,因此依次计算也符合同时行动;相遇在双方都更新后判断。转向已经消耗一分钟,不能立即再走一步。设完整状态数为 S160000S\le160000,每个状态最多处理一次,时间、标记空间均为 O(S)O(S)

有限状态过程可能先经过一段前缀再进入循环,如 A,B,C,D,C,D,A,B,C,D,C,D,\ldots。计算远期状态时,应先扣除前缀,再对循环长度取余,不能从起点直接套用周期。

10. 枚举规则,再执行模拟

例题:Codeforces 908B·New Year and Buggy Bot

简易题面:地图含起点 S\texttt{S}、终点 E\texttt{E}、障碍 #\texttt{\#}。指令由 0033 组成,四个数字与四个方向一一对应,但关系未知。统计能到达终点的完整对应关系数;撞墙或越界立即失败,到达终点立即成功,均停止后续指令。

数据范围:2n,m502\le n,m\le50,指令长度为 11100100,起点和终点各一个。

对应关系确定后,就能逐条模拟指令;未知的关系则放在外层枚举。四个数字依次有 4,3,2,14,3,2,1 种选择,共 2424 种。题目统计对应关系,即使产生相同路线也不能合并。

p[c]p[c] 为数字 cc 对应的方向,方向数组沿用上一节的上、右、下、左定义。以下地图 gg 从下标 00 开始,指令 ss 长度为 lenlen,每次检查都从起点 sx,sysx,sy 恢复位置。

bool check()
{
    int x = sx, y = sy;
    for (int i = 0; i < len; ++i)
    {
        int d = p[s[i] - '0'];
        x += dx[d];
        y += dy[d];
        if (x < 0 || x >= n || y < 0 || y >= m || g[x][y] == '#')
        {
            return false;
        }
        if (g[x][y] == 'E')
        {
            return true;
        }
    }
    return false;
}

成功、失败均立即返回,避免后续指令改变已确定的结果。外层枚举前三个不同方向,第四个取剩余编号:四个编号之和为 66,减去前三个即可。以下 ansans 初始为 00

for (p[0] = 0; p[0] < 4; ++p[0])
{
    for (p[1] = 0; p[1] < 4; ++p[1])
    {
        if (p[1] == p[0])
        {
            continue;
        }
        for (p[2] = 0; p[2] < 4; ++p[2])
        {
            if (p[2] == p[0] || p[2] == p[1])
            {
                continue;
            }
            p[3] = 6 - p[0] - p[1] - p[2];
            ans += check();
        }
    }
}

每种对应关系由前三个编号唯一确定,枚举不重不漏。设指令长度为 LL,总时间为 O(nm+24L)O(nm+24L),空间为 O(nm+L)O(nm+L)

11. 作业

巩固练习

提高训练