4.5 注解与习题
到目前为止,本书要介绍的C语言知识已经全部讲完了(第5章将介绍C++)。本章涉及了整个C语言中最难理解的两项内容:指针和递归。
4.5.1 头文件、副作用及其他
还记得第1章中给出的程序框架吗?是时候搞清楚所有细节了。读者现在已经知道main函数也是一个普通的函数(甚至可以递归调用),其返回值将告之操作系统,在算法竞赛中应当总是等于0,唯一的谜团就是#include<stdio.h>了。
这是一个头文件。什么是头文件呢?实践者的理解方式就是——不加这一行时会出现什么错误,反过来就说明了这一行的作用。不加这一行的编译警告是:
warning: incompatible implicit declaration of built-in function 'printf' [enabled by default]
也就是说,printf函数的“隐式定义”出了问题,这个头文件和printf有关。还记得第一次介绍math.h是怎么讲的吗?如果要使用数学相关的函数,需要包含这个头文件。换句话说,头文件的作用就是:包含了一些函数,供主程序使用(11)。表4-1中列出了一些常用函数和对应的头文件。
表4-1 常用函数及头文件

在编写实用软件时,往往需要编写自己的头文件,但在大部分算法竞赛中,只是编写单个程序文件。在本书中,所有题目都由单个程序文件求解。
下面来看一个有意思的问题:是否可以编写一个函数f(),使得依次执行int a = f()和 int b = f()以后a和b的值不同?使用全局变量,这个问题不难解决:
#include<stdio.h>
int g = 0;
int f() { g++; return g; } //修改全局变量的函数
int main() {
int a = f();
int b = f();
printf("%d %d\n", a, b);
return 0;
}
不难写出一个更有意思的程序:写3个函数f()、g()和h(),使得“int a = (f()+g())+h()”和“int b=f()+(g()+h())”后,a和b的值不同。
加法明明满足结合律,居然有可能“(f()+g())+h()”不等于“f()+(g()+h())”!这个例子说明:C语言的函数并不都像数学函数那样“规矩”。或者说得学术一点:C语言的函数可以有副作用,而不像数学函数那样“纯”。本书无意深入介绍函数式编程,但时刻警惕并最小化“副作用”是一个良好的编程习惯。正因为如此,前面曾多次强调:全局变量要少用。
再来看一个小问题:函数可以返回指针吗?例如这样:
int* get_pointer() {
int a = 3;
return &a;
}
这个程序可以编译通过,不过有一个警告:
warning: function returns address of local variable [enabled by default]
意思是函数返回了一个局部变量的地址。为什么不能返回局部变量的地址呢?前面说过,局部变量是在栈中,函数执行完毕后,局部变量就失效了。严格地讲,指针里保存的地址仍然存在,但不再属于那个局部变量了。这时如果修改那个指针指向的内容,程序有可能会崩溃,也可能悄悄地修改了另外一个变量的值,使程序输出一个莫名其妙的结果。
那推荐的写法是怎样的?这取决于你想做什么。如果只是想得到一个指向内容为3的指针,可以把这个指针作为参数,然后在函数里修改它;如果坚持返回一个“新”的指针,可以使用malloc函数进行动态内存分配。笔者并不准备在这里叙述详细做法,因为在接下来的章节中会对动态内存分配进行深入讨论。在学习到那些知识之前,请尽量不要编写返回指针的函数。
最后一个话题是关于浮点误差的。例如:
#include<stdio.h>
int main() {
double f;
for(f = 2; f > 1; f -= 1e-6);
printf("%.7f\n", f);
printf("%.7f\n", f / 4);
printf("%.1f\n", f / 4);
return 0;
}
在笔者的机器上,输出如下:
0.9999990
0.2499998
0.2
换句话说,在不断减1e-6的过程中出现了误差,使得循环终止时f并不等于1,而是比1小一点。在除以4保留1位小数时成了0.2。如果不出现误差,正确答案应该是0.25四舍五入保留一位小数,即0.3。一道好的竞赛题目应避免这种情况出现(12),但作为竞赛选手来说,有一种方法可以缓解这种情况:加上一个EPS以后再输出。这里的EPS通常取一个比最低精度还要小几个数量级的小实数。例如,要求保留3位小数时取EPS为1e-6。这只是个权宜之计,甚至有可能起到“反作用”(如正确答案真的是0.499999),但在实践中很好用(毕竟正确答案是0.499999的情况比0.5要少很多)。
4.5.2 例题一览和习题
本章共有6道例题,如表4-2所示。除了最后两道题目比较复杂之外,读者应熟练掌握前4道题目的程序写法。当然,为了巩固基础,让后面的学习更加轻松,笔者强烈建议大家独立实现所有6道题目。
表4-2 例题一览
| 类别 | 题号 | 题目名称(英文) | 备注 |
| 例题4-1 | UVa1339 | Ancient Cipher | 排序 |
| 例题4-2 | UVa489 | Hangman Judge | 自顶向下逐步求精法 |
| 例题4-3 | UVa133 | The Dole Queue | 子过程(函数)设计 |
| 例题4-4 | UVa213 | Message Decoding | 二进制;输入技巧;调试技巧 |
| 例题4-5 | UVa512 | Spreadsheet Tracking | 模拟;一题多解 |
| 例题4-6 | UVa12412 | A Typical Homework (a.k.a Shi Xiong Bang Bang Mang) | 综合练习 |
下面是一些习题。这些题目的综合性较强,部分题目还涉及一些专门知识(如中国象棋、莫尔斯电码、RAID),理解起来也需要一定时间。另外一些题目需要一些思考,否则无从入手编写程序。由于这些题目的挑战性,在继续阅读之前只需完成其中的3道题目。如果想达到更好的效果,最好是完成3道或更多的题目。
习题4-1 象棋(Xiangqi, ACM/ICPC Fuzhou 2011, UVa1589)
考虑一个象棋残局,其中红方有n(2≤n≤7)个棋子,黑方只有一个将。红方除了有一个帅(G)之外还有3种可能的棋子:车(R),马(H),炮(C),并且需要考虑“蹩马腿”(如图4-4所示)与将和帅不能照面(将、帅如果同在一条直线上,中间又不隔着任何棋子的情况下,走子的一方获胜)的规则。
输入所有棋子的位置,保证局面合法并且红方已经将军。你的任务是判断红方是否已经把黑方将死。关于中国象棋的相关规则请参见原题。
习题4-2 正方形(Squares, ACM/ICPC World Finals 1990, UVa201)
有n行n列(2≤n≤9)的小黑点,还有m条线段连接其中的一些黑点。统计这些线段连成了多少个正方形(每种边长分别统计)。
行从上到下编号为1~n,列从左到右编号为1~n。边用H i j和V i j表示,分别代表边(i,j)-(i,j+1)和(i,j)-(i+1,j)。如图4-5所示最左边的线段用V 1 1表示。图中包含两个边长为1的正方形和一个边长为2的正方形。
| ![]() |
| 图4-4 “蹩马腿”情况 | 图4-5 正方形 |
习题4-3 黑白棋(Othello, ACM/ICPC World Finals 1992, UVa220)
你的任务是模拟黑白棋游戏的进程。黑白棋的规则为:黑白双方轮流放棋子,每次必须让新放的棋子“夹住”至少一枚对方棋子,然后把所有被新放棋子“夹住”的对方棋子替换成己方棋子。一段连续(横、竖或者斜向)的同色棋子被“夹住”的条件是两端都是对方棋子(不能是空位)。如图4-6(a)所示,白棋有6个合法操作,分别为(2,3),(3,3),(3,5), (6,2),(7,3),(7,4)。选择在(7,3)放白棋后变成如图4-6(b)所示效果(注意有竖向和斜向的共两枚黑棋变白)。注意(4,6)的黑色棋子虽然被夹住,但不是被新放的棋子夹住,因此不变白。
| ![]() |
| (a) | (b) |
图4-6 黑白棋
输入一个8*8的棋盘以及当前下一次操作的游戏者,处理3种指令:
习题4-4 骰子涂色(Cube painting, UVa 253)
输入两个骰子,判断二者是否等价。每个骰子用6个字母表示,如图4-7所示。
图4-7 骰子涂色
例如rbgggr和rggbgr分别表示如图4-8所示的两个骰子。二者是等价的,因为图4-8(a)所示的骰子沿着竖直轴旋转90°之后就可以得到图4-8(b)所示的骰子。
| ![]() |
| (a) | (b) |
图4-8 旋转前后的两个骰子
习题4-5 IP网络(IP Networks, ACM/ICPC NEERC 2005, UVa1590)
可以用一个网络地址和一个子网掩码描述一个子网(即连续的IP地址范围)。其中子网掩码包含32个二进制位,前32-n位为1,后n位为0,网络地址的前32-n位任意,后n位为0。所有前32-n位和网络地址相同的IP都属于此网络。
例如,网络地址为194.85.160.176(二进制为11000010|01010101|10100000|10110000),子网掩码为255.255.255.248(二进制为11111111|11111111|11111111|11111000),则该子网的IP地址范围是194.85.160.176~194.85.160.183。输入一些IP地址,求最小的网络(即包含IP地址最少的网络),包含所有这些输入地址。
例如,若输入3个IP地址:194.85.160.177、194.85.160.183和194.85.160.178,包含上述3个地址的最小网络的网络地址为194.85.160.176,子网掩码为255.255.255.248。
习题4-6 莫尔斯电码(Morse Mismatches, ACM/ICPC World Finals 1997, UVa508)
输入每个字母的Morse编码,一个词典以及若干个编码。对于每个编码,判断它可能是哪个单词。如果有多个单词精确匹配,任选一个输出并且后面加上“!”;如果无法精确匹配,可以在编码尾部增加或删除一些字符以后匹配某个单词(增加或删除的字符应尽量少)。如果有多个单词可以这样匹配上,任选一个输出并且在后面加上“?”。
莫尔斯电码的细节参见原题。
习题4-7 RAID技术(RAID!, ACM/ICPC World Finals 1997, UVa509)
RAID技术用多个磁盘保存数据。每份数据在不止一个磁盘上保存,因此在某个磁盘损坏时能通过其他磁盘恢复数据。本题讨论其中一种RAID技术。数据被划分成大小为s(1≤s≤64)比特的数据块保存在d(2≤d≤6)个磁盘上,如图4-9所示,每d-1个数据块都有一个校验块,使得每d个数据块的异或结果为全0(偶校验)或者全1(奇校验)。
图4-9 数据保存情况
例如,d=5,s=2,偶校验,数据6C7A79EDFC(二进制01101100 01111010 01111001 11101101 11111100)的保存方式如图4-10所示。
图4-10 数据6C7A79EDPC的保存方式
其中加粗块是校验块。输入d、s、b、校验的种类(E表示偶校验,O表示奇校验)以及b(1≤b≤100)个数据块(其中“?”表示损坏的数据),你的任务是恢复并输出完整的数据。如果校验错或者由于损坏数据过多无法恢复,应报告磁盘非法。
提示:本题是位运算的不错练习,但如果没有RAID的知识背景,上述简要翻译可能较难理解,细节建议参考原题。
习题4-8 特别困的学生(Extraordinarily Tired Students, ACM/ICPC Xi'an 2006, UVa12108)
课堂上有n个学生(n≤10)。每个学生都有一个“睡眠-清醒”周期,其中第i个学生醒Ai分钟后睡Bi分钟,然后重复(1≤Ai,Bi≤5),初始时第i个学生处在他的周期的第Ci分钟。每个学生在临睡前会察看全班睡觉人数是否严格大于清醒人数,只有这个条件满足时才睡觉,否则就坚持听课Ai分钟后再次检查这个条件。问经过多长时间后全班都清醒。如果用(A,B,C)描述一些学生,则图4-11中描述了3个学生(2,4,1)、(1,5,2)和(1,4,3)在每个时刻的行为。
图4-11 3个学生每个时刻的行为
注意:有可能并不存在“全部都清醒”的时刻,此时应输出-1。
习题4-9 数据挖掘(Data Mining, ACM/ICPC NEERC 2003, UVa1591)
有两个n元素数组P和Q。P数组每个元素占SP个字节,Q数组每个元素占SQ个字节。有时需直接根据P数组中某个元素P(i)的偏移量Pofs(i)算出对应的Q(i)的偏移量Qofs(i)。当两个数组的元素均为连续存储时
,但因为除法慢,可以把式子改写成速度较快的
。为了让这个式子成立,在P数组仍然连续存储的前提下,Q数组可以不连续存储(但不同数组元素的存储空间不能重叠)。这样做虽然会浪费一些空间,但是提升了速度,是一种用空间换时间的方法。
输入n、SP和SQ(N≤220,1≤SP,SQ≤210),你的任务是找到最优的A和B,使得占的空间K尽量小。输出K、A、B的值。多解时让A尽量小,如果仍多解则让B尽量小。
提示:本题有一定实际意义,不过描述比较抽象。如果对本题兴趣不大,可以先跳过。
习题4-10 洪水!(Flooded! ACM/ICPC World Finals 1999, UVa815)
有一个n*m(1≤m,n<30)的网格,每个格子是边长10米的正方形,网格四周是无限大的墙壁。输入每个格子的海拔高度,以及网格内雨水的总体积,输出水位的海拔高度以及有多少百分比的区域有水(即高度严格小于水平面)。
本题有多种方法,能锻炼思维,建议读者一试。
4.5.3 小结
指针还有很多相关内容本书没有介绍,例如,指向void型的指针、指向函数的指针、指向常量的指针以及指针和数组之间的关系(注意,尽管在很多地方可以混用,但指针和数组不是一回事!《C语言程序设计奥秘》用一章的篇幅来叙述二者的区别)。正如书中所说,本书将尽量回避指针,但尽管如此,调试并理解前面几个swap函数的工作方式对于理解计算机的工作原理大有好处。
递归需要从概念和语言两个方面理解。从概念上,递归就是“自己使用自己”的意思。递归调用就是自己调用自己,递归定义就是自己定义自己……当然,这里的“使用自己”可以是直接的,也可以是间接的。很多初学者在学习递归时专注于表象,从而未能透彻理解其“计算机”本质。由于我们的重点是设计算法和编写程序,理解递归函数的执行过程是非常重要的。因此,本章大量使用了gdb作为工具讲解内部机理,即使读者在平时编程时不用gdb调试,在学习初期用它帮助理解也是大有裨益的。关于gdb的更多介绍参见附录A。
————————————————————
(1) 注意:这个函数不是ANSI C的。
(2) gdb是一个功能强大的源码级调试器,虽然是基于命令的文本界面,但运用熟练后非常方便。关于gdb更多的介绍请参见附录A。
(3) 这是一个指向函数的指针,该函数返回一个指针,该指针指向一个只读的指针,此指针指向一个字符变量。
(4) 更严密的说法是:正整数集是满足(1)、(2)的最小集。这里牺牲一点严密性,换来的是更通俗易懂的表达方式。
(5) Linux和Windows下的MinGW中都有这个程序。
(6) 实际上,栈大小是由连接程序ld指定的。gcc编译参数-Wl的作用正是把其后的参数(--stack=<size>)传递给ld。
(7) 这里没有“几乎”二字。函数和递归均可以用其他内容替代。
(8) 有兴趣的读者可以翻阅Paul Graham的经典著作《On Lisp》。
(9) 注意:这里讨论的是编写代码的顺序。在测试时,先测试工具函数的方式非常常用。
(10) 当然,这是笔者的主观看法。有些人觉得充满指针的代码很优美。
(11) 和本章开头的自定义函数不同,头文件里并没有printf的源代码,而只有它的声明。printf属于libc的一部分,有兴趣的读者请自行查阅相关资料。
(12) 方法有两种:一是删除答案恰好处于“舍入交界口”的数据,二是允许选手输出和标准答案有少许出入。