模拟
竞赛中有一类问题,被称为“模拟题”,设计程序完整地按照题目叙述的方式运行得到答案,这一类问题通常对思维与算法设计的要求不高,但要求扎实的编程基本功。
例题:P2670 [NOIP2015 普及组] 扫雷游戏
给出一个 \(n \times m\) 的网格,有些格子埋有地雷。求问这个棋盘上每个没有地雷的格子周围(上、下、左、右和斜对角)的地雷数。
输入第一行的两个整数 \(n\) 和 \(m\),表示网格的行数和列数,接下来的 \(n\) 行,每行 \(m\) 个字符,描述了雷区中的地雷分布情况。字符 * 表示相应格子是地雷格,字符 ? 表示相应格子是非地雷格。\(m\) 和 \(n\) 不超过 \(100\)。
输出包含 \(n\) 行,每行 \(m\) 个字符,描述整个雷区。用 * 表示地雷格,用周围的地雷个数表示非地雷格。
分析:根据题意,对于每个非地雷格的格子,只需要统计其八个边上方向的格子中的地雷数量。如果需要统计的格子的坐标是 \((x,y)\),那么它左上角的坐标是 \((x-1,y-1)\)。
一般情况下,竞赛程序中涉及的坐标系表示方法(b)相对于日常接触的直角坐标系(a)略有不同,比如在二维数组里,一般原点对应的是左上角,这样就能很方便地将行数和 \(x\) 对应起来,列数与 \(y\) 对应起来,比如第一行第二列可以表示成 \((1,2)\),便于存储和查询。
没有必要列举出 8 个 if 语句逐一判定这个格子的 8 个方向是否有雷,只要预先定义好的这 8 个方向的格子相对于这个格子的偏移量,然后对这个偏移量数组循环即可。
还有一点需要注意的是边界问题。在枚举某些边界上的格子时,它的某些方向会超出这个二维阵列,如果不经过特殊处理很容易造成数组越界。有两种方法可以解决这个问题,一是对需要查询的坐标进行范围判断;二是可以在这个二维阵列外围加一圈“空白的虚拟非雷区”参与统计。
#include <cstdio>
const int N = 105;
char mine[N][N]; // 全局变量区定义的变量默认初始化为0,注意这个0是指'\0',而不是字符'0'
int ans[N][N];
// 某点的8个方向相邻位置的偏移量
int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
scanf("%s", mine[i] + 1);
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
for (int k = 0; k < 8; k++) {
int x = i + dx[k], y = j + dy[k];
// 因为地雷数组占用的范围是[1~n][1~m],相当于外面有一圈“虚拟边框”
if (mine[x][y] == '*') ans[i][j]++;
}
}
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (mine[i][j] == '*') printf("*");
else printf("%d", ans[i][j]);
}
printf("\n");
}
return 0;
}
例题:P1042 [NOIP2003 普及组] 乒乓球
华华和朋友打乒乓球,得到了每一球的输赢记录:W 表示华华得一分,L 表示对手得一分,E 表示比赛信息结束。一局比赛刚开始时比分为 0:0。每一局中达到 11 分或者 21 分(赛制不同),且领先对方 2 分或以上的选手可以赢得这一局。现在要求输出在 11 分制和 21 分制两种情况下的每一局得分。输入数据每行至多有 25 个字母,最多有 2500行。
分析:根据题意,只需要对读入的内容进行统计即可。使用数组 rec
记录下从开始到结束的得分情况,如果是华华赢了就记为 1,反之记为 0,读到 E 的时候就直接退出读入过程,读到换行符时要忽略,同时记录他们一共打了多少球 len
。
然后分别对两种赛制进行计算。首先记分板上双方(c1
和 c2
)都是 0,如果华华赢了,c1
就增加 1,否则 c2
就增加 1,如果发现记分板上得分高的一方达到了赛制要求的球数,而且分差也足够,就将记分板的得分输出,同时记分板清零开始下一局。到最后还要输出正在进行中的比赛的得分。
#include <cstdio>
#include <cmath>
#include <algorithm>
using std::max;
using std::abs;
const int N = 1e5 + 5;
int rec[N];
int win[2] = {11, 21}; // 两种赛制的获胜得分
int main()
{
int len = 0;
while (true) {
char ch; scanf("%c", &ch); // 不断读入比赛记录
if (ch == 'E') break;
// 注意ch可能会读到换行符,所以必须只记录W和L
if (ch == 'W') rec[++len] = 1; // 华华赢
else if (ch == 'L') rec[++len] = 0; // 华华输
}
for (int i = 0; i < 2; i++) { // 两种赛制循环
int c1 = 0, c2 = 0;
for (int j = 1; j <= len; j++) {
if (rec[j] == 1) c1++;
else c2++;
// 获胜者达到对应分数并且超出对手两分
if (max(c1, c2) >= win[i] && abs(c1 - c2) >= 2) {
printf("%d:%d\n", c1, c2);
c1 = c2 = 0;
}
}
printf("%d:%d\n", c1, c2); // 还要输出最终的即时比分
if (i == 0) printf("\n");
}
return 0;
}
本题思路很简单,直接根据题意和生活常识模拟即可,但还是有一些需要注意的地方:
- 记录比赛记录的数组要开够,看清楚题目描述中所说的最多输入行数以及每行最多的字母数量。
- 读到 E 就可以停止读入了,后面的都忽略掉,同时遇到换行符之类的与比赛结果无关的字符也要忽略。
- 注意要分差 2 分及以上才能结算一局比赛的结果。
- 最后还要输出最后一局正在进行中比赛比分,就算是刚刚完成一局也要输出 0:0,以及第一种赛制和第二种赛制间有一个空行。
题目中的描述可能包含关键信息,所以一定要认真审题。
例题:P1563 [NOIP2016 提高组] 玩具谜题
\(n \ (n<100000)\) 个玩具小人依次逆时针围成一个圈,有些小人面向圈内,有些面向圈外。一开始第一个小人手里有眼镜。从这只小人开始进行 \(m \ (m<100000)\) 次传递。拿着眼镜的小人将眼镜传递给左手 / 右手边的第 \(s \ (s<n)\) 个人。已知每个玩具小人的职业(长度不超过 \(10\) 的字符串)和朝向(\(0\) 表示朝圈内,\(1\) 表示超圈外),以及每次传递的方向(\(0\) 表示往这只小人的左边,\(1\) 表示往右边)和传过的玩具人数,求最后眼镜在谁手上,输出职业。
分析:如果模拟现实中击鼓传花的过程,一个传一个,模拟眼镜在谁的手上,考虑到数据范围很大,两重循环的做法会超时。
把手里拥有眼镜的第一个玩具小人称为 \(0\) 号小人(这里从 \(0\) 开始比较方便,最后一个小人是 \(n-1\) 号)。当他面朝内时,往右边传递 \(3\) 只小人,就会变成 \(0+3=3\) 号小人拥有礼物。如果 \(0\) 号小人往左边传递 \(3\) 只小人,就会变成 \(0-3=-3\) 号小人拿着眼镜,显然没有 \(-3\) 号小人。假设总共有 \(7\) 只小人,考虑 \(0\) 号小人和 \(7\) 号小人等价(因为 \(6\) 号小人的下一个就是 \(0\) 号小人),\(1\) 号小人和 \(8\) 号小人等价,……,可以推出:\(-3\) 号小人和 \(-3+7=4\) 号小人等价。面朝内的小人往右边数 \(s\) 个就是编号增加 \(s\),往左边数就是编号减少 \(s\)。如果得到的编号不在 \(0 \sim n-1\) 的范围内,则需要通过增减 \(n\) 调整到这个范围内。为了达到这个目的,可以使用取余数的方式。
如果小人面朝外,那么方向和加减的关系就和上面一种情况反过来,但是依然需要调整到 \(0 \sim n-1\) 的范围内。
#include <cstdio>
const int N = 1e5 + 5;
const int LEN = 15;
struct Toy { // 使用结构体存储玩具的朝向和职业
int face;
char job[LEN];
};
Toy t[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%d%s", &t[i].face, t[i].job);
}
int now = 0;
for (int i = 1; i <= m; i++) {
int a, s; scanf("%d%d", &a, &s);
if (t[now].face == 0 && a == 1) { // 朝向内,向右,编号+s
now = (now + s) % n;
} else if (t[now].face == 0 && a == 0) { // 朝向内,向左,编号-s
now = (now + n - s) % n;
} else if (t[now].face == 1 && a == 1) { // 朝向外,向右,编号-s
now = (now + n - s) % n;
} else if (t[now].face == 1 && a == 0) { // 朝向外,向左,编号+s
now = (now + s) % n;
}
}
printf("%s\n", t[now].job);
return 0;
}
这段代码还能进行一些精简,利用异或运算将四种情况合并,参考代码如下:
参考代码
#include <cstdio>
const int N = 1e5 + 5;
const int LEN = 15;
struct Toy { // 使用结构体存储玩具的朝向和职业
int face;
char job[LEN];
};
Toy t[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%d%s", &t[i].face, t[i].job);
}
int now = 0;
for (int i = 1; i <= m; i++) {
int a, s; scanf("%d%d", &a, &s);
int flag = (t[now].face ^ a) * 2 - 1;
now = (now + n + flag * s) % n;
}
printf("%s\n", t[now].job);
return 0;
}
习题:P4924 [1007] 魔法少女小Scarlet
首先把 \(1\) 到 \(n^2\) 的正整数按照从左往右,从上至下的顺序填入 \(n \times n \ (n \le 500)\) 的二维数组中,并进行 \(m \ (m \le 500)\) 次操作,使二维数组上一个奇数阶方阵按照顺时针或者逆时针旋转 \(90°\)。每次操作给出 \(4\) 个整数 \(x,y,r,z\),表示把以第 \(x\) 行第 \(y\) 列为中心的 \(2r+1\) 阶矩阵按照某种时针方向旋转,其中 \(z=0\) 表示顺时针,\(z=1\) 表示逆时针。要求输出最终所得的矩阵。
参考代码
#include <cstdio>
const int N = 505;
int a[N][N], tmp[N][N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
int num = 0;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
num++; a[i][j] = num;
}
}
for (int i = 1; i <= m; i++) {
int x, y, r, z; scanf("%d%d%d%d", &x, &y, &r, &z);
// 涉及到的子矩阵范围是 (x-r,y-r)~(x+r,y+r)
for (int j = x - r; j <= x + r; j++) {
for (int k = y - r; k <= y + r; k++) {
// 不能在原地旋转,需要先将旋转后的结果放到另一个临时数组
if (z == 0) { // 顺时针
// 在子矩阵中,原来的第一行旋转到结果的最后一列,第二行转到倒数第二列,……
// 原来的第一列旋转到结果的第一行,第二列转到第二行,……
tmp[x - r + k - (y - r)][y + r - (j - (x - r))] = a[j][k];
} else { // 逆时针
// 在子矩阵中,原来的第一行旋转到结果的第一列,第二行转到第二列,……
// 原来的第一列旋转到倒数第一行,第二列转到倒数第二行,……
tmp[x + r - (k - (y - r))][y - r + j - (x - r)] = a[j][k];
}
}
}
// 最后再将旋转后的结果拿回来
for (int j = x - r; j <= x + r; j++) {
for (int k = y - r; k <= y + r; k++) {
a[j][k] = tmp[j][k];
}
}
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
printf("%d ", a[i][j]);
}
printf("\n");
}
return 0;
}
习题:P1328 [NOIP2014 提高组] 生活大爆炸版石头剪刀布
参考代码
#include <cstdio>
int res[5][5] = {
{0, -1, 1, 1, -1},
{1, 0, -1, 1, -1},
{-1, 1, 0, -1, 1},
{-1, -1, 1, 0, 1},
{1, 1, -1, -1, 0}}; // 制作结果表,1表示小A赢,-1表示小B赢
int a[205], b[205];
int main()
{
int n, na, nb;
scanf("%d%d%d", &n, &na, &nb);
// 利用取余的周期性,从0位置开始存储
for (int i = 0; i < na; ++i) scanf("%d", &a[i]);
for (int i = 0; i < nb; ++i) scanf("%d", &b[i]);
int sa = 0, sb = 0;
for (int i = 0; i < n; ++i) {
// 利用取余的周期性
int r = res[a[i % na]][b[i % nb]];
if (r == 1) ++sa;
else if (r == -1) ++sb;
}
printf("%d %d\n", sa, sb);
return 0;
}
习题:P1067 [NOIP2009 普及组] 多项式输出
参考代码
#include <cstdio>
#include <cmath>
using std::abs;
int main(){
int n; scanf("%d",&n);
for (int i = n; i >= 0; i--) {
int x; scanf("%d",&x);
if (x != 0) {
// 将每一项拆成几个部分
// 非最高项系数为正时要有加号
if (i != n && x > 0) printf("+");
// 系数为负数时必然有负号
if (x < 0) printf("-");
// 系数为1时不显示(常数项除外)
if (abs(x) > 1 || i == 0) printf("%d", abs(x));
// 除了常数项以外需要有x
if (i > 0) printf("x");
// 除了常数项和一次项以外都有指数
if (i > 1) printf("^%d", i);
}
}
}
习题:P1098 [NOIP2007 提高组] 字符串的展开
参考代码
#include <cstdio>
#include <iostream>
#include <string>
using std::string;
using std::cin;
using std::cout;
bool islower(char ch) {
return ch >= 'a' && ch <= 'z';
}
bool isdigit(char ch) {
return ch >= '0' && ch <= '9';
}
int main() {
int p1,p2,p3;
string s;
cin>>p1>>p2>>p3>>s;
int len = s.size();
string ans = "";
for (int i = 0; i < len; i++) {
if (s[i] != '-') {
ans += s[i];
} else {
// s[i]是减号
// 检查是否能够展开?题目条件:
// 减号两侧同为小写字母或同为数字,
// 且按照 ASCII 码的顺序,减号右边的字符严格大于左边的字符。
string tmp="-"; // 如果展开失败,就加减号
char l=s[i-1], r=s[i+1];
if (i-1>=0 && i+1<len) { // 得有两侧
if (islower(l)&&islower(r) || isdigit(l)&&isdigit(r)) {
if (l < r) {
// 符合展开条件,进行展开过程
tmp = "";
// 展开的字符实际上是s[i-1]+1~s[i+1]-1
if (p3==2) { // 逆序
for (char ch=r-1;ch>=l+1;ch--) {
// 错误写法:直接让ch表达要加的字符
// 这样会破坏循环过程
// 正确方式:单独用一个变量表示加什么字符
char add=ch;
if (p1==3) add='*';
if (p1==2 && islower(add)) add-=32;
for (int j=1; j<=p2; j++) tmp+=add;
}
} else {
for (char ch=l+1;ch<=r-1;ch++) {
char add=ch;
if (p1==3) add='*';
if (p1==2 && islower(add)) add-=32;
for (int j=1; j<=p2; j++) tmp+=add;
}
}
}
}
}
ans += tmp;
}
}
cout<<ans<<"\n";
return 0;
}
习题:P1518 [USACO2.4] 两只塔姆沃斯牛 The Tamworth Two
参考代码
#include <cstdio>
const int N = 15;
// 上,右,下,左 顺时针方向
int dx[4] = {-1, 0, 1, 0};
int dy[4] = {0, 1, 0, -1};
char a[N][N];
bool vis[N][N][4][N][N][4]; // 牛处于某位置某方向与John处于某位置某方向的状态是否出现过
bool valid(int x, int y) { // 验证(x,y)位置是否能走
// 在地图范围内 + 不是障碍物
// 注意不能用空地来判断,因为一开始牛和John的位置也能走
return x >= 1 && x <= 10 && y >= 1 && y <= 10 && a[x][y] != '*';
}
int main()
{
for (int i = 1; i <= 10; i++) {
scanf("%s", a[i] + 1);
}
int x_cow, y_cow, d_cow = 0;
int x_farmer, y_farmer, d_farmer = 0;
for (int i = 1; i <= 10; i++) {
for (int j = 1; j <= 10; j++) {
// 寻找牛和John的初始位置
if (a[i][j] == 'C') {
x_cow = i; y_cow = j;
}
if (a[i][j] == 'F') {
x_farmer = i; y_farmer = j;
}
}
}
vis[x_cow][y_cow][0][x_farmer][y_farmer][0] = true;
int ans = 0; // 时长
while (true) {
// 模拟一分钟的变化
ans++;
int x = x_cow + dx[d_cow];
int y = y_cow + dy[d_cow];
if (valid(x, y)) {
x_cow = x; y_cow = y;
} else {
d_cow = (d_cow + 1) % 4; // 转向
}
x = x_farmer + dx[d_farmer];
y = y_farmer + dy[d_farmer];
if (valid(x, y)) {
x_farmer = x; y_farmer = y;
} else {
d_farmer = (d_farmer + 1) % 4;
}
if (x_farmer == x_cow && y_farmer == y_cow) { // 相遇了
break;
}
if (vis[x_cow][y_cow][d_cow][x_farmer][y_farmer][d_farmer]) {
// 说明这个局面之前已经出现过了,再继续走下去也只是重复
// 不可能再有机会相遇了
ans = 0; break;
}
// 还未相遇,标记此局面
vis[x_cow][y_cow][d_cow][x_farmer][y_farmer][d_farmer] = true;
}
printf("%d\n", ans);
return 0;
}
习题:P1065 [NOIP2006 提高组] 作业调度方案
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int INF = 1e9;
int seq[405]; // 输入的安排顺序
int machine_id[25][25]; // machine_id[x][y]: 工件x的第y道工序在哪台机器上加工
int time_cost[25][25]; // time_cost[x][y]: 工件x的第y道工序的加工时间
int finish[25]; // finish[x]: 工件x已完成多少道工序
int end_time[25]; // end_time[x]: 工件x上一道工序的完成时刻
int machine[25][25][2]; // machine[x][y][0/1]: 机器x的第y个空闲时间段
// [0/1]分别为空闲开始时间和结束时间
// 初始时统一为 0 ~ 无穷
int sz[25]; // sz[x]代表机器x上有多少个空闲时间段
int main() {
int m, n;
scanf("%d%d", &m, &n);
for (int i = 1; i <= m; i++) {
machine[i][1][1] = INF;
sz[i] = 1;
}
for (int i = 0; i < m * n; i++) scanf("%d", &seq[i]);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) scanf("%d", &machine_id[i][j]);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) scanf("%d", &time_cost[i][j]);
int ans = 0;
for (int i = 0; i < m * n; i++) {
int cur = seq[i]; // 当前对哪个工件进行加工
int order = finish[cur] + 1; // 该工件即将进行第几道工序
int start_time = end_time[cur]; // 该工件在机器上至少要从哪个时刻开始可以加工
int mch_id = machine_id[cur][order]; // 该工件当前这道工序放在哪台机器上加工
int cost = time_cost[cur][order]; // 该工件当前这道工序所需的加工时间
// 寻找机器上符合要求的第一个可以用来加工的空闲时间段
for (int j = 1; j <= sz[mch_id]; j++) {
int bg = machine[mch_id][j][0], ed = machine[mch_id][j][1];
// 该空闲时间段为[bg,ed]
if (ed >= start_time) { // 至少要在start_time时刻之后
int available_time = ed - max(start_time, bg);
if (available_time >= cost) { // 该空闲时间段可用来加工
int occupy_bg = max(start_time, bg);
int occupy_ed = occupy_bg + cost;
// 这次加工将占用[occupy_bg,occupy_ed]这个时间段
// 分类讨论:
// 1.[bg,ed]全用完;
// 2.[bg,ed]前面/后面被占用;
// 3.[bg,ed]被拆成两段空闲时间
if (ed - bg == occupy_ed - occupy_bg) { // [bg,ed]被完全占用
// 此时这个空闲时间段将被完全占用,将其从空闲时间段里删除
for (int k = j; k < sz[mch_id]; k++) {
machine[mch_id][k][0] = machine[mch_id][k + 1][0];
machine[mch_id][k][1] = machine[mch_id][k + 1][1];
}
sz[mch_id]--;
} else if (occupy_bg == bg) { // [bg,ed]的前半段被占用
// 此时直接更新这个空闲时间段的开始时间即可
machine[mch_id][j][0] = occupy_ed;
} else if (occupy_ed == ed) { // [bg,ed]的后半段被占用
// 此时直接更新这个空闲时间段的终止时间即可
machine[mch_id][j][1] = occupy_bg;
} else {
// 此时[bg,ed]被拆成[bg,occupy_bg]和[occupy_ed,ed]
machine[mch_id][j][1] = occupy_bg;
// 将[occupy_ed+1,ed]插入到数组中
for (int k = sz[mch_id]; k > j; k--) {
machine[mch_id][k + 1][0] = machine[mch_id][k][0];
machine[mch_id][k + 1][1] = machine[mch_id][k][1];
}
machine[mch_id][j + 1][0] = occupy_ed;
machine[mch_id][j + 1][1] = ed;
sz[mch_id]++;
}
finish[cur]++;
end_time[cur] = occupy_ed;
if (occupy_ed > ans) ans = occupy_ed;
break;
}
}
}
}
printf("%d\n", ans);
return 0;
}