4.4 竞赛题目选讲

从技术上讲,不用函数和递归也可以写出所有程序(7)。但是从实用的角度来讲,函数和递归能帮我们大忙。人毕竟不是机器,代码的可读性和可维护性是相当重要的。很多初学者渴望学习到更好的调试技巧,但在此之前,笔者却总是建议他们先学习如何更好地写程序。如果方法得当,不仅能更快地写出更短的程序,而且调试起来也更轻松,隐含的错误也会更少。本节的题目并不涉及新的知识点,但在程序组织和调试技巧上会给读者一些新的启示。

例题4-2 刽子手游戏(Hangman Judge, UVa 489)

   

图4-1 刽子手游戏

刽子手游戏其实是一款猜单词游戏,如图4-1所示。游戏规则是这样的:计算机想一个单词让你猜,你每次可以猜一个字母。如果单词里有那个字母,所有该字母会显示出来;如果没有那个字母,则计算机会在一幅“刽子手”画上填一笔。这幅画一共需要7笔就能完成,因此你最多只能错6次。注意,猜一个已经猜过的字母也算错。

在本题中,你的任务是编写一个“裁判”程序,输入单词和玩家的猜测,判断玩家赢了(You win.)、输了(You lose.)还是放弃了(You chickened out.)。每组数据包含3行,第1行是游戏编号(-1为输入结束标记),第2行是计算机想的单词,第3行是玩家的猜测。后两行保证只含小写字母。

样例输入:

1

cheese

chese

2

cheese

abcdefg

3

cheese

abcdefgij

-1

样例输出:

Round 1

You win.

Round 2

You chickened out.

Round 3

You lose.

【分析】

一般而言,程序不是直接从第一行开始写到最后一行结束,而是遵循两种常见的顺序之一:自顶向下和自底向上。什么叫自顶向下呢?简单地说,就是先写框架,再写细节。实际上,之前已经用过这个方法了,就是先写“伪代码”,然后转化成实际的代码。有了“函数”这个工具之后,可以更好地贯彻这个方法:先写主程序,包括对函数的调用,再实现函数本身。自底向上和这个顺序相反,是先写函数,再写主程序。对于编写复杂软件来说,自底向下的构建方式有它独特的优势(8)。但在算法竞赛中,这样做的选手并不多见(9)

程序4-11 刽子手游戏——程序框架


#include<stdio.h>
#include<string.h>
#define maxn 100
int left, chance;           //还需要猜left个位置,错chance次之后就会输
char s[maxn], s2[maxn];     //答案是字符串s,玩家猜的字母序列是s2
int win, lose;              //win=1表示已经赢了;lose=1表示已经输了

void guess(char ch) { … }

int main() {
  int rnd;
  while(scanf("%d%s%s", &rnd, s, s2) == 3 && rnd != -1) {
    printf("Round %d\n", rnd);
    win = lose = 0;                     //求解一组新数据之前要初始化
    left = strlen(s);
    chance = 7;
    for(int i = 0; i < strlen(s2); i++) {
      guess(s2[i]);                     //猜一个字母
      if(win || lose) break;            //检查状态
    }
    //根据结果进行输出
    if(win) printf("You win.\n");
    else if(lose) printf("You lose.\n");
    else printf("You chickened out.\n");
  }
  return 0;
}

有一些细节需要说明。

一是变量名的选取。那个rnd本应叫round,但是有一个库函数也叫round,所以改名叫rnd了。当然,改成Round也可以,因为C语言的标识符是区分大小写的。这里改成rnd只是个人习惯。毕竟这个代码很短,而且rnd这个变量的作用域很小,很容易搞清楚它的含义。在第5章学习完STL之后,这种“被用过的常用名字”还会增加,例如count、min、max等都是STL已经使用的名字,程序中最好避开它们。

二是变量的使用。全局变量本应该尽量少用,但是对于本题来说,需要维护的内容比较多,例如,是否赢了,是否输了,以及剩余的机会数等。如果不用全局变量,则它们都需要传递给函数guess。更麻烦的是,其中有些参数还需要被guess修改,只能传指针,但这会让代码变“丑(10)”。所以笔者最终选择了使用全局变量。读者完全可以对此持不同看法,刚才的文字只是想说明:变量和函数调用方式的设计是一个需要思考的问题。如果设计出的方案还未写出便觉得别扭,恐怕写出来的程序会既不优美,也不好调试,甚至容易隐藏bug。

下一步是实现guess函数。在编写这个函数时,可能会注意到一个问题:题目中说了猜过的字母再猜一次算错,可是似乎并没有保存哪些字母已经猜过。一个解决方案是在程序框架中增加一个字符数组int guessed[256],让guessed[ch]标识字母ch是否已经猜过。但其实还有一个更简单的方法,就是将猜对的字符改成空格,像这样:

程序4-12 刽子手游戏——guess函数


void guess(char ch) {
  int bad = 1;
  for(int i = 0; i < strlen(s); i++)
    if(s[i] == ch) { left--; s[i] = ' '; bad = 0; }
  if(bad) --chance;
  if(!chance) lose = 1;
  if(!left) win = 1;
}

这样,程序就完整了。如何调试呢?每猜完一个字母之后打印出s、left、chance等重要变量的值,很容易就能发现程序出错的位置,读者不妨一试。另一方面,如果刚才加上了guessed数组,每次打印的调试信息就会多出这样一个庞大的数组,不仅数据多,而且不直观,会给调试带来麻烦。一般来说,减少变量的个数对于编程和调试都会有帮助。

例题4-3 救济金发放(The Dole Queue, UVa 133)

n(n<20)个人站成一圈,逆时针编号为1~n。有两个官员,A从1开始逆时针数,B从n开始顺时针数。在每一轮中,官员A数k个就停下来,官员B数m个就停下来(注意有可能两个官员停在同一个人上)。接下来被官员选中的人(1个或者2个)离开队伍。

输入n,k,m输出每轮里被选中的人的编号(如果有两个人,先输出被A选中的)。例如,n=10,k=4,m=3,输出为4 8, 9 5, 3 1, 2 6, 10, 7。注意:输出的每个数应当恰好占3列。

【分析】

仍然采用自顶向下的方法编写程序。用一个大小为0的数组表示人站成的圈。为了避免人走之后移动数组元素,用0表示离开队伍的人,数数时跳过即可。主程序如下:


#include<stdio.h>
#define maxn 25
int n, k, m, a[maxn];

//逆时针走t步,步长是d(-1表示顺时针走),返回新位置
int go(int p, int d, int t) { … }

int main() {
  while(scanf("%d%d%d", &n, &k, &m) == 3 && n) {
    for(int i = 1; i <= n; i++) a[i] = i;
    int left = n; //还剩下的人数
    int p1 = n, p2 = 1;
    while(left) {
      p1 = go(p1, 1, k);
      p2 = go(p2, -1, m);
      printf("%3d", p1); left--;
      if(p2 != p1) { printf("%3d", p2); left--; }
      a[p1] = a[p2] = 0;
      if(left) printf(",");
    }
    printf("\n");
  } 
  return 0;
}

注意go这个函数。当然也可以写两个函数:逆时针go和顺时针go,但是仔细思考后发现这两个函数可以合并:逆时针和顺时针数数的唯一区别只是下标是加1还是减1。把这个+1/-1抽象为“步长”参数,就可以把两个go统一了。代码如下:


int go(int p, int d, int t) {
  while(t--) {
    do { p = (p+d+n-1) % n + 1; } while(a[p] == 0); //走到下一个非0数字
  }
  return p;
}

例题4-4 信息解码(Message Decoding, ACM/ICPC World Finals 1991, UVa 213)

考虑下面的01串序列:

0, 00, 01, 10, 000, 001, 010, 011, 100, 101, 110, 0000, 0001, …, 1101, 1110, 00000, …

首先是长度为1的串,然后是长度为2的串,依此类推。如果看成二进制,相同长度的后一个串等于前一个串加1。注意上述序列中不存在全为1的串。

你的任务是编写一个解码程序。首先输入一个编码头(例如AB#TANCnrtXc),则上述序列的每个串依次对应编码头的每个字符。例如,0对应A,00对应B,01对应#,…,110对应X,0000对应c。接下来是编码文本(可能由多行组成,你应当把它们拼成一个长长的01串)。编码文本由多个小节组成,每个小节的前3个数字代表小节中每个编码的长度(用二进制表示,例如010代表长度为2),然后是各个字符的编码,以全1结束(例如,编码长度为2的小节以11结束)。编码文本以编码长度为000的小节结束。

例如,编码头为$#**\,编码文本为0100000101101100011100101000,应这样解码:010(编码长度为2)00(#)00(#)10(*)11(小节结束)011(编码长度为3)000(\)111(小节结束)001(编码长度为1)0($)1(小节结束)000(编码结束)。

【分析】

还记得二进制吗?如果不记得,请重新翻阅第3章的最后部分。有了二进制,就不必以字符串的形式保存这一大串编码了,只需把编码理解成二进制,用(len, value)这个二元组来表示一个编码,其中len是编码长度,value是编码对应的十进制值。如果用codes[len][value]保存这个编码所对应的字符,则主程序看上去应该是这个样子的。


#include<stdio.h>
#include<string.h> //使用memset
int readchar() { … }
int readint(int c) { … }

int code[8][1<<8];
int readcodes() { … }

int main() {
  while(readcodes()) { //无法读取更多编码头时退出
//printcodes();
    for(;;) {
      int len = readint(3);
      if(len == 0) break;
//printf("len=%d\n", len);
      for(;;) {
        int v = readint(len);
//printf("v=%d\n", v);
        if(v == (1 << len)-1) break;
        putchar(code[len][v]);
      }
    }
    putchar('\n');
  }
  return 0;
}

主程序里接连使用了两个还没有介绍的函数:readcodes和readint。前者用来读取编码,后者读取c位二进制字符(即0和1),并转化为十进制整数。

本题的调试方法也很有代表性。上面的代码中已经包含了几条注释掉的printf语句,用于打印出一些关键变量的值。如果程序的输出不是想要的结果,题目中的举例就派上用场了:只需把举例中的解释和程序输出的中间结果一一对照,就能知道问题出在哪里。

编写readint时会遇到同一个问题:如何处理“编码文本可以由多行组成”这个问题?方法有很多种,笔者的方案是再编写一个“跨行读字符”的函数readchar。


int readchar() {
  for(;;) {
    int ch = getchar();
    if(ch != '\n' && ch != '\r') return ch; //一直读到非换行符为止
  }
}

int readint(int c) {
  int v = 0;
  while(c--) v = v * 2 + readchar() - '0';
  return v;
}

下面是函数readcodes。首先使用memset清空数组(这是个好习惯。还记得之前讲过的多数据题目的常见错误吗?),编码头自身占一行,所以应该用readchar读取第一个字符,而用普通的getchar读取剩下的字符,直到\n。这样做,代码比较简单,但有些读者可能会觉得有些别扭。没关系,你完全可以使用另外一套自己觉得更清晰的方法。


int readcodes() {
  memset(code, 0, sizeof(code)); //清空数组
  code[1][0] = readchar(); //直接调到下一行开始读取。如果输入已经结束,会读到EOF
  for(int len = 2; len <= 7; len++) {
    for(int i = 0; i < (1<<len)-1; i++) {
      int ch = getchar();
      if(ch == EOF) return 0;
      if(ch == '\n' || ch == '\r') return 1;
      code[len][i] = ch;
    }
  }
  return 1;
}

最后是前面提到的printcodes函数。这个函数对于解题来说不是必需的,但对于调试却是有用的。


void printcodes() {
  for(int len = 1; len <= 7; len++)
    for(int i = 0; i < (1<<len)-1; i++) {
      if(code[len][i] == 0) return;
      printf("code[%d][%d] = %c\n", len, i, code[len][i]);
    }
}

由于每次读取编码头时把codes数组清空了,所以只要遇到字符为0的情况,就表示编码头已经结束。

例题4-5 踪电子表格中的单元格(Spreadsheet Tracking, ACM/ICPC World Finals 1997, UVa512)

有一个rc列(1≤rc≤50)的电子表格,行从上到下编号为1~r,列从左到右编号为1~c。如图4-2(a)所示,如果先删除第1、5行,然后删除第3, 6, 7, 9列,结果如图4-2(b)所示。

 

  (a)     (b)  

图4-2 删除行、列

接下来在第2、3、5行前各插入一个空行,然后在第3列前插入一个空列,会得到如图4-3所示结果。

图4-3 插入行、列

你的任务是模拟这样的n个操作。具体来说一共有5种操作:

 

在插入/删除指令后,各个x值不同,且顺序任意。接下来是q个查询,每个查询格式为“r c”,表示查询原始表格的单元格(r,c)。对于每个查询,输出操作执行完后该单元格的新位置。输入保证在任意时刻行列数均不超过50。

【分析】

最直接的思路就是首先模拟操作,算出最后的电子表格,然后在每次查询时直接在电子表格中找到所求的单元格。为了锻炼读者的代码阅读能力,此处不对代码进行任何解释:


#include<stdio.h>
#include<string.h>
#define maxd 100
#define BIG 10000
int r, c, n, d[maxd][maxd], d2[maxd][maxd], ans[maxd][maxd], cols[maxd];

void copy(char type, int p, int q) {
  if(type == 'R') {
    for(int i = 1; i <= c; i++)
      d[p][i] = d2[q][i];
  } else {
    for(int i = 1; i <= r; i++)
      d[i][p] = d2[i][q];
  }
}

void del(char type) {
  memcpy(d2, d, sizeof(d));
  int cnt = type == 'R' ? r : c, cnt2 = 0;
  for(int i = 1; i <= cnt; i++) {
    if(!cols[i]) copy(type, ++cnt2, i);
  }
  if(type == 'R') r = cnt2; else c = cnt2;
}

void ins(char type) {
  memcpy(d2, d, sizeof(d));
  int cnt = type == 'R' ? r : c, cnt2 = 0;
  for(int i = 1; i <= cnt; i++) {
    if(cols[i]) copy(type, ++cnt2, 0);
    copy(type, ++cnt2, i);
  }
  if(type == 'R') r = cnt2; else c = cnt2;
}

int main() {
  int r1, c1, r2, c2, q, kase = 0;
  char cmd[10];
  memset(d, 0, sizeof(d));
  while(scanf("%d%d%d", &r, &c, &n) == 3 && r) {
    int r0 = r, c0 = c;
    for(int i = 1; i <= r; i++)
      for(int j = 1; j <= c; j++)
        d[i][j] = i*BIG + j;
    while(n--) {
      scanf("%s", cmd);
      if(cmd[0] == 'E') {
        scanf("%d%d%d%d", &r1, &c1, &r2, &c2);
        int t = d[r1][c1]; d[r1][c1] = d[r2][c2]; d[r2][c2] = t;
      } else {
        int a, x;
        scanf("%d", &a);
        memset(cols, 0, sizeof(cols));
        for(int i = 0; i < a; i++) { scanf("%d", &x); cols[x] = 1; }
        if(cmd[0] == 'D') del(cmd[1]); else ins(cmd[1]);
      }
    }
    memset(ans, 0, sizeof(ans));
    for(int i = 1; i <= r; i++)
      for(int j = 1; j <= c; j++) {
        ans[d[i][j]/BIG][d[i][j]%BIG] = i*BIG+j;
      }
    if(kase > 0) printf("\n");
    printf("Spreadsheet #%d\n", ++kase);
    scanf("%d", &q);
    while(q--) {
      scanf("%d%d", &r1, &c1);
      printf("Cell data in (%d,%d) ", r1, c1);
      if(ans[r1][c1] == 0) printf("GONE\n");
      else printf("moved to (%d,%d)\n", ans[r1][c1]/BIG, ans[r1][c1]%BIG);
    }
  }
  return 0;
}

另一个思路是将所有操作保存,然后对于每个查询重新执行每个操作,但不需要计算整个电子表格的变化,而只需关注所查询的单元格的位置变化。对于题目给定的规模来说,这个方法不仅更好写,而且效率更高。代码如下:


     #include<stdio.h>
     #include<string.h>
     #define maxd 10000

     struct Command {
       char c[5];
       int r1, c1, r2, c2;
       int a, x[20];
     } cmd[maxd];
     int r, c, n;

     int simulate(int* r0, int* c0) {
       for(int i = 0; i < n; i++) {
         if(cmd[i].c[0] == 'E') {
            if(cmd[i].r1 == *r0 && cmd[i].c1 == *c0) { *r0 = cmd[i].r2; *c0 = cmd[i].c2; }
            else if(cmd[i].r2 == *r0 && cmd[i].c2 == *c0) { *r0 = cmd[i].r1; *c0 = cmd[i].c1; }
         } else {
           int dr = 0, dc = 0;
           for(int j = 0; j < cmd[i].a; j++) {
             int x = cmd[i].x[j];
             if(cmd[i].c[0] == 'I') {
               if(cmd[i].c[1] == 'R' && x <= *r0) dr++;
               if(cmd[i].c[1] == 'C' && x <= *c0) dc++;
             }
             else {
               if(cmd[i].c[1] == 'R' && x == *r0) return 0;
               if(cmd[i].c[1] == 'C' && x == *c0) return 0;
               if(cmd[i].c[1] == 'R' && x < *r0) dr--;
               if(cmd[i].c[1] == 'C' && x < *c0) dc--;
             }
           }
           *r0 += dr; *c0 += dc;     
         }
       }
       return 1;
     }

     int main() {
       int r0, c0, q, kase = 0;
       while(scanf("%d%d%d", &r, &c, &n) == 3 && r) {
         for(int i = 0; i < n; i++) {
           scanf("%s", cmd[i].c);
           if(cmd[i].c[0] == 'E') {
             scanf("%d%d%d%d", &cmd[i].r1, &cmd[i].c1, &cmd[i].r2, &cmd[i].c2);
           } else {
             scanf("%d", &cmd[i].a);
             for(int j = 0; j < cmd[i].a; j++) scanf("%d", &cmd[i].x[j]);
           }
         }
         if(kase > 0) printf("\n");
         printf("Spreadsheet #%d\n", ++kase);

         scanf("%d", &q);
         while(q--) {
           scanf("%d%d", &r0, &c0);
           printf("Cell data in (%d,%d) ", r0, c0);
           if(!simulate(&r0, &c0)) printf("GONE\n");
           else printf("moved to (%d,%d)\n", r0, c0);
         }
       }
       return 0;
     }

有没有觉得simulate函数不是特别自然?因为所有用到r0和c0的地方都要加上一个星号。幸运的是,C++语言中有另外一个语法,可以更自然地表达这种“需要被修改的参数”,详见第5章中的“引用”部分。

例题4-6 师兄帮帮忙(A Typical Homework (a.k.a Shi Xiong Bang Bang Mang), Rujia Liu's Present 5, UVa 12412)

(题目背景略,有兴趣的读者请自行阅读原题)

编写一个成绩管理系统(SPMS)。最多有100个学生,每个学生有如下属性。

 

进入SPMS后,应显示主菜单:


Welcome to Student Performance Management System (SPMS).
1 - Add
2 - Remove
3 - Query
4 - Show ranking
5 - Show Statistics
0 - Exit

选择1之后,会出现添加学生记录的提示信息:

Please enter the SID, CID, name and four scores. Enter 0 to finish.

然后等待输入。本题保证输入总是合法的(不会有非法的SID、CID,并且恰好有4个分数等),但可能会输入重复SID。在这种情况下,需要输出一行提示:

Duplicated SID.

不过名字是可以重复的。你的程序应当不停地打印前述提示信息,直到用户输入单个0。然后应当再次打印主菜单。

选择2之后,会出现如下提示信息:

Please enter SID or name. Enter 0 to finish.

然后等待输入,在数据库中删除能匹配上述SID或者名字的所有学生,并且打印如下信息(xx可以等于0):

xx student(s) removed.

你的程序应当不停地打印前述提示信息,直到用户输入单个0,然后再次打印主菜单。

选择3之后,会出现如下提示信息:

Please enter SID or name. Enter 0 to finish.

然后等待输入。如果数据库中没有能匹配上述SID或者名字的学生,什么都不要做;否则输出所有满足条件的学生,按照进入数据库的顺序排列。输出格式和添加的格式相同,但增加3列:年级排名(第一列)、总分和平均分(最后两列)。所有班级中总分最高的学生获得第1名,如果有两个学生并列第2名,则下一个学生的排名为4(而非3)。你的程序应当不停地打印前述提示信息,直到用户输入单个0。然后应当再次打印主菜单。

选择4之后,会出现如下提示信息:

Showing the ranklist hurts students' self-esteem. Don't do that.

然后自动返回主菜单。

选择5之后,会出现如下提示信息:


Chinese
Average Score: xx.xx
Number of passed students: xx
Number of failed students: xx

…(为了节约篇幅,此处省略了Mathematics、English和Programming的统计信息)

Overall:
Number of students who passed all subjects: xx
Number of students who passed 3 or more subjects: xx
Number of students who passed 2 or more subjects: xx
Number of students who passed 1 or more subjects: xx
Number of students who failed all subjects: xx

然后自动回到主菜单。

选择0之后,程序终止。注意,单科成绩和总分都应格式化为整数,但平均分应恰好保留两位小数。

提示:这个程序适合直接运行,用键盘与之交互,然后从屏幕中看到输出信息。但正因为如此,作为一道算法竞赛的题目,其输出看上去会比较乱。

【分析】

正如题目所说,这是一道很常见的“作业题”,在一些早期的大学编程教材中可以看到类似的问题(只是要求不一定有这么明确)。

因为要求比较多,可以沿用之前介绍过的“自顶向下,逐步求精”方法,先写出如下的框架:


     int main() {
       for(;;) {
         int choice;
         print_menu();
         scanf("%d", &choice);
         if(choice == 0) break;
         if(choice == 1) add();
         if(choice == 2) DQ(0);
         if(choice == 3) DQ(1);
         if(choice == 4) printf("Showing the ranklist hurts students' self-esteem. Don't do that.\n");
         if(choice == 5) stat();
       }
       return 0;
     }

接下来就是分别实现各个函数了。注意上面把操作2(删除)和操作3(查询)合并在了一起,因为二者非常相似,代码如下(isq=1表示查询,isq=0表示删除):


     void DQ(int isq) {
       char s[maxl];
       for(;;) {
         printf("Please enter SID or name. Enter 0 to finish.\n");
         scanf("%s", s);
         if(strcmp(s, "0") == 0) break;
         int r = 0;
         for(int i = 0; i < n; i++) if(!removed[i]) {
           if(strcmp(sid[i], s) == 0 || strcmp(name[i], s) == 0) {
             if(isq) printf("%d %s %d %s %d %d %d %d %d %.2f\n", rank(i), sid[i], cid[i], name[i], score[i][0], score[i][1], score[i][2], score[i][3], score[i][4],score[i][4]/4.0+EPS);
             else { removed[i] = 1; r++; }
           }
         }
         if(!isq) printf("%d student(s) removed.\n", r);
       }
     }

在编写上述函数的过程中,用到了尚未编写的rank函数,并且直接使用了还没有声明的数组removed、sid、cid、name和score。换句话说,根据函数编写的需要定义了数据结构,而不是一开始就设计好数据结构。程序的其他部分略为麻烦,但没有难点,建议初学者自主完成整个程序,作为C语言部分的结束。

顺便说一句,虽然在前面学习了排序,但rank函数的实现并不一定要对数据排序。另外,上述代码在输出实数时加了一个EPS,原因将在本章最后讨论。