3.3 竞赛题目选讲
例题3-1 TeX中的引号(Tex Quotes, UVa 272)(1)
在TeX中,左双引号是“``”,右双引号是“''”。输入一篇包含双引号的文章,你的任务是把它转换成TeX的格式。
样例输入:
"To be or not to be," quoth the Bard, "that
is the question".
样例输出:
``To be or not to be,'' quoth the Bard, ``that
is the question''.
【分析】
本题的关键是,如何判断一个双引号是左双引号还是右双引号。方法很简单:使用一个标志变量即可。可是在此之前,需要解决另外一个问题:输入字符串。
之前学习了使用“scanf("%s")”输入字符串,但却不能在本题中使用它,因为它碰到空格或者TAB就会停下来。虽然下次调用时会输入下一个字符串,可是不知道两次输入的字符串中间有多少个空格、TAB甚至换行符。可以用下述两种方法解决这个问题:
第一种方法是使用“fgetc(fin)”,它读取一个打开的文件fin,读取一个字符,然后返回一个int值。为什么返回的是int而不是char呢?因为如果文件结束,fgetc将返回一个特殊标记EOF,它并不是一个char。如果把fgetc(fin)的返回值强制转换为char,将无法把特殊的EOF和普通字符区分开。如果要从标准输入读取一个字符,可以用getchar,它等价于fgetc(stdin)。
提示3-14:使用fgetc(fin)可以从打开的文件fin中读取一个字符。一般情况下应当在检查它不是EOF后再将其转换成char值。从标准输入读取一个字符可以用getchar,它等价于fgetc(stdin)。
fgetc和getchar将读取“下一个字符”,因此需要知道在各种情况下,“下一个字符”是哪个。如果用“scanf("%d", &n)”读取整数n,则要是在输入123后多加了一个空格,用getchar读取的将是这个空格;如果在“123”之后紧跟着换行,则读取到的将是回车符“\n”。
这里有个潜在的陷阱:不同操作系统的回车换行符是不一致的。Windows是“\r”和“\n”两个字符,Linux是“\n”,而MacOS是“\r”。如果在Windows下读取Windows文件,fgetc和getchar会把“\r""吃掉”,只剩下“\n”;但如果要在Linux下读取同样一个文件,它们会忠实地先读取“\r”,然后才是“\n”。如果编程时不注意,所写程序可能会在某个操作系统上是完美的,但在另一个操作系统上就错得一塌糊涂。当然,比赛的组织方应该避免在Linux下使用Windows格式的文件,但正如前面所强调过的:选手也应该把自己的程序写得更鲁棒,即容错性更好。
提示3-15:在使用fgetc和getchar时,应该避免写出和操作系统相关的程序。
第二种方法是使用“fgets(buf, maxn, fin)”读取完整的一行,其中buf的声明为char buf[maxn]。这个函数读取不超过maxn-1个字符,然后在末尾添上结束符“\0”,因此不会出现越界的情况。之所以说可以用这个函数读取完整的一行,是因为一旦读到回车符“\n”,读取工作将会停止,而这个“\n”也会是buf字符串中最后一个有效字符(再往后就是字符串结束符“\0”了)。只有在一种情况下,buf不会以“\n”结尾:读到文件结束符,并且文件的最后一个不是以“\n”结尾。尽管比赛的组织方应避免这样的情况(和输出文件一样,保证输入文件的每行均以回车符结尾),但正如刚才所说,选手应该把自己的程序写得更鲁棒。
提示3-16:"fgets(buf, maxn, fin)"将读取完整的一行放在字符数组buf中。应当保证buf足够存放下文件的一行内容。除了在文件结束前没有遇到“\n”这种特殊情况外,buf总是以“\n”结尾。当一个字符都没有读到时,fgets返回NULL。
和fgetc一样,fgets也有一个"标准输入版"gets。遗憾的是,gets和它的"兄弟"fgets差别比较大:其用法是gets(s),没有指明读取的最大字符数。这里就出现了一个潜在的问题:gets将不停地往s中存储内容,而不管是否存储得下!难道gets函数不去管s的可用空间有多少吗?确实如此。
提示3-17:C语言并不禁止程序读写"非法内存"。例如,声明的是char s[100],完全可以赋值s[10000] = 'a'(甚至-Wall也不会警告),但后果自负。
正是因为如此,gets已经被废除了,但为了向后兼容,仍然可以使用它。从长远考虑,读者最好不要使用此函数。事实上,在C11标准里,gets函数已被正式删除。
提示3-18:C语言中的gets(s)存在缓冲区溢出漏洞,不推荐使用。在C11标准里,该函数已被正式删除。
本题的特点是:可以边读边处理,而不需要把输入字符串完整地存下来,因此getchar是一个不错的选择。下面的代码里还有一个有趣的运算符"?:",是if语句的"表达式版"。表达式"a?b:c"的含义是:当a为真时值为b,否则为c。另一个细节是直接用到了赋值语句"c = getchar()"的返回值,把它和EOF进行比较。这样的写法并不多见,但有时能让代码更简洁。
程序3-5 TeX中的引号
#include<stdio.h>
int main() {
int c, q = 1;
while((c = getchar()) != EOF) {
if(c == '"') { printf("%s", q ? "``" : "''"); q = !q; }
else printf("%c", c);
}
return 0;
}
例题3-2 WERTYU(WERTYU, UVa10082)
图3-2 键盘
把手放在键盘上时,稍不注意就会往右错一位。这样,输入Q会变成输入W,输入J会变成输入K等。键盘如图3-2所示。
输入一个错位后敲出的字符串(所有字母均大写),输出打字员本来想打出的句子。输入保证合法,即一定是错位之后的字符串。例如输入中不会出现大写字母A。
样例输入:
O S, GOMR YPFSU/
样例输出:
I AM FINE TODAY.
【分析】
和例题3-1一样,每输入一个字符,都可以直接输出一个字符,因此getchar是输入的理想方法。问题在于:如何进行这样输入输出变换呢?一种方法是使用if语句或者switch语句,如"if(c == 'W') putchar('Q')"。但很明显,这样做太麻烦。一个较好的方法是使用常量数组,下面是完整程序:
程序3-6 WERTYU
#include<stdio.h>
char s[] = "`1234567890-=QWERTYUIOP[]\\ASDFGHJKL;'ZXCVBNM,./";
int main() {
int i, c;
while((c = getchar()) != EOF) {
for (i=1; s[i] && s[i]!=c; i++); //找错位之后的字符在常量表中的位置
if (s[i]) putchar(s[i-1]); //如果找到,则输出它的前一个字符
else putchar(c);
}
return 0;
}
还有其他使用常量数组的方法。例如,构造一个数组s,使得对于任意字符c, s[c]的值为c"左边"的字符。这个方法也是可行的,但是在程序里输入这样一个s数组有些麻烦,还是本题的策略更容易实现。常量数组并不需要指明大小,编译器可以完成计算。
提示3-19:善用常量数组往往能简化代码。定义常量数组时无须指明大小,编译器会计算。
例题3-3 回文词(Palindromes, UVa401)
输入一个字符串,判断它是否为回文串以及镜像串。输入字符串保证不含数字0。所谓回文串,就是反转以后和原串相同,如abba和madam。所有镜像串,就是左右镜像之后和原串相同,如2S和3AIAE。注意,并不是每个字符在镜像之后都能得到一个合法字符。在本题中,每个字符的镜像如图3-3所示(空白项表示该字符镜像后不能得到一个合法字符)。
图3-3 镜像字符
输入的每行包含一个字符串(保证只有上述字符。不含空白字符),判断它是否为回文串和镜像串(共4种组合)。每组数据之后输出一个空行。
样例输入:
NOTAPALINDROME
ISAPALINILAPASI
2A3MEAS
ATOYOTA
样例输出:
NOTAPALINDROME -- is not a palindrome.
ISAPALINILAPASI -- is a regular palindrome.
2A3MEAS -- is a mirrored string.
ATOYOTA -- is a mirrored palindrome.
【分析】
既然不包含空白字符,可以安全地使用scanf进行输入。回文串和镜像串的判断都不复杂,并且可以一起完成,详见下面的代码。使用常量数组,只用少量代码即可解决这个看上去有些复杂的题目(2)。
程序3-7 回文词
#include<stdio.h>
#include<string.h>
#include<ctype.h>
const char* rev = "A 3 HIL JM O 2TUVWXY51SE Z 8 ";
const char* msg[] = {"not a palindrome", "a regular palindrome", "a mirrored string", "a mirrored palindrome"};
char r(char ch) {
if(isalpha(ch)) return rev[ch - 'A'];
return rev[ch - '0' + 25];
}
int main() {
char s[30];
while(scanf("%s", s) == 1) {
int len = strlen(s);
int p = 1, m = 1;
for(int i = 0; i < (len+1)/2; i++) {
if(s[i] != s[len-1-i]) p = 0; //不是回文串
if(r(s[i]) != s[len-1-i]) m = 0; //不是镜像串
}
printf("%s -- is %s.\n\n", s, msg[m*2+p]);
}
return 0;
}
本题使用了一个自定义函数char r(char ch),参数ch是一个字符,返回值是ch的镜像字符。这是因为该常量数组中前26项是各个大写字母的镜像,而后10个是数字1~9的镜像(数字0不会出现),所以需要判断ch是字母还是数字。函数在第4章中会详细讨论,如果现在理解有困难,可以等看完第4章后回顾此题。
本题用isalpha来判断字符是否为字母,类似的还有idigit、isprint等,在ctype.h中定义。由于ASCII码表中大写字母、小写字母和数字都是连续的,如果ch是大写字母,则ch-'A'就是它在字母表中的序号(A的序号是0,B的序号是1,依此类推);类似地,如果ch是数字,则ch-'0'就是这个数字的数值本身(例如'5'-'0'=5)。
另一个有趣的常量数组是msg(事实上,这是一个字符串数组,即二维字符数组),请读者自行理解它的作用。
提示3-20:头文件ctype.h中定义的isalpha、isdigit、isprint等工具可以用来判断字符的属性,而toupper、tolower等工具可以用来转换大小写。如果ch是大写字母,则ch-'A'就是它在字母表中的序号(A的序号是0,B的序号是1,依此类推);类似地,如果ch是数字,则ch-'0'就是这个数字的数值本身。
例题3-4 猜数字游戏的提示(Master-Mind Hints, UVa 340)
实现一个经典"猜数字"游戏。给定答案序列和用户猜的序列,统计有多少数字位置正确(A),有多少数字在两个序列都出现过但位置不对(B)。
输入包含多组数据。每组输入第一行为序列长度n,第二行是答案序列,接下来是若干猜测序列。猜测序列全0时该组数据结束。n=0时输入结束。
样例输入:
4
1 3 5 5
1 1 2 3
4 3 3 5
6 5 5 1
6 1 3 5
1 3 5 5
0 0 0 0
10
1 2 2 2 4 5 6 6 6 9
1 2 3 4 5 6 7 8 9 1
1 1 2 2 3 3 4 4 5 5
1 2 1 3 1 5 1 6 1 9
1 2 2 5 5 5 6 6 6 7
0 0 0 0 0 0 0 0 0 0
0
样例输出:
Game 1:
(1,1)
(2,0)
(1,2)
(1,2)
(4,0)
Game 2:
(2,4)
(3,2)
(5,0)
(7,0)
【分析】
直接统计可得A,为了求B,对于每个数字(1~9),统计二者出现的次数c1和c2,则min(c1,c2)就是该数字对B的贡献。最后要减去A的部分。代码如下:
#include<stdio.h>
#define maxn 1010
int main() {
int n, a[maxn], b[maxn];
int kase = 0;
while(scanf("%d", &n) == 1 && n) { //n=0时输入结束
printf("Game %d:\n", ++kase);
for(int i = 0; i < n; i++) scanf("%d", &a[i]);
for(;;) {
int A = 0, B = 0;
for(int i = 0; i < n; i++) {
scanf("%d", &b[i]);
if(a[i] == b[i]) A++;
}
if(b[0] == 0) break; //正常的猜测序列不会有0,所以只判断第一个数是否为0即可
for(int d = 1; d <= 9; d++) {
int c1 = 0, c2 = 0; //统计数字d在答案序列和猜测序列中各出现多少次
for(int i = 0; i < n; i++) {
if(a[i] == d) c1++;
if(b[i] == d) c2++;
}
if(c1 < c2) B += c1; else B += c2;
}
printf(" (%d,%d)\n", A, B-A);
}
}
return 0;
}
例题3-5 生成元(Digit Generator, ACM/ICPC Seoul 2005, UVa1583)
如果x加上x的各个数字之和得到y,就说x是y的生成元。给出n(1≤n≤100000),求最小生成元。无解输出0。例如,n=216,121,2005时的解分别为198,0,1979。
【分析】
本题看起来是个数学题,实则不然。假设所求生成元为m。不难发现m<n。换句话说,只需枚举所有的m<n,看看有没有哪个数是n的生成元。
可惜这样做的效率并不高,因为每次计算一个n的生成元都需要枚举n-1个数。有没有更快的方法?聪明的读者也许已经想到了:只需一次性枚举100000内的所有正整数m,标记“m加上m的各个数字之和得到的数有一个生成元是m”,最后查表即可。
#include<stdio.h>
#include<string.h>
#define maxn 100005
int ans[maxn];
int main() {
int T, n;
memset(ans, 0, sizeof(ans));
for(int m = 1; m < maxn; m++) {
int x = m, y = m;
while(x > 0) { y += x % 10; x /= 10; }
if(ans[y] == 0 || m < ans[y]) ans[y] = m;
}
scanf("%d", &T);
while(T--) {
scanf("%d", &n);
printf("%d\n", ans[n]);
}
return 0;
}
例题3-6 环状序列(Circular Sequence, ACM/ICPC Seoul 2004, UVa1584)
图3-4 环状串
长度为n的环状串有n种表示法,分别为从某个位置开始顺时针得到。例如,图3-4的环状串有10种表示:CGAGTCAGCT,GAGTCAGCTC,AGTCAGCTCG等。在这些表示法中,字典序最小的称为"最小表示"。
输入一个长度为n(n≤100)的环状DNA串(只包含A、C、G、T这4种字符)的一种表示法,你的任务是输出该环状串的最小表示。例如,CTCC的最小表示是CCCT,CGAGTCAGCT的最小表示为AGCTCGAGTC。
【分析】
本题出现了一个新概念:字典序。所谓字典序,就是字符串在字典中的顺序。一般地,对于两个字符串,从第一个字符开始比较,当某一个位置的字符不同时,该位置字符较小的串,字典序较小(例如,abc比bcd小);如果其中一个字符串已经没有更多字符,但另一个字符串还没结束,则较短的字符串的字典序较小(例如,hi比history小)。字典序的概念可以推广到任意序列,例如,序列1, 2, 4, 7比1, 2, 5小。
学会了字典序的概念之后,本题就不难解决了:就像"求n个元素中的最小值"一样,用变量ans表示目前为止,字典序最小串在输入串中的起始位置,然后不断更新ans。
#include<stdio.h>
#include<string.h>
#define maxn 105
//环状串s的表示法p是否比表示法q的字典序小
int less(const char* s, int p, int q) {
int n = strlen(s);
for(int i = 0; i < n; i++)
if(s[(p+i)%n] != s[(q+i)%n])
return s[(p+i)%n] < s[(q+i)%n];
return 0; //相等
}
int main() {
int T;
char s[maxn];
scanf("%d", &T);
while(T--) {
scanf("%s", s);
int ans = 0;
int n = strlen(s);
for(int i = 1; i < n; i++)
if(less(s, i, ans)) ans = i;
for(int i = 0; i < n; i++)
putchar(s[(i+ans)%n]);
putchar('\n');
}
return 0;
}