第2章 循环结构程序设计
学习目标
- 掌握for循环的使用方法
- 掌握while和do-while循环的使用方法
- 学会使用计数器和累加器
- 学会用输出中间结果的方法调试
- 学会用计时函数测试程序效率
- 学会用重定向的方式读写文件
- 学会用fopen的方式读写文件
- 了解算法竞赛对文件读写方式和命名的严格性
- 记住变量在赋值之前的值是不确定的
- 学会使用条件编译指示构建本地运行环境
- 学会用编译选项-Wall获得更多的警告信息
第1章的程序虽然完善,但并没有发挥出计算机的优势。顺序结构程序自上到下只执行一遍,而分支结构中甚至有些语句可能一遍都执行不了。换句话说,为了让计算机执行大量操作,必须编写大量的语句。能不能只编写少量语句,就让计算机做大量的工作呢?这就是本章的主题。基本思路很简单:一条语句执行多次就可以了。但如何让这样的程序真正发挥作用,可不是一件容易的事。
2.1 for循环
提示2-1: for循环的格式为:for (初始化; 条件; 调整) 循环体;
提示2-2: 尽管for循环反复执行相同的语句,但这些语句每次的执行效果往往不同。
提示2-3: 编写程序时,要特别留意“当前行”的跳转和变量的改变。
提示2-4: 建议尽量缩短变量的定义范围。例如,在for循环的初始化部分定义循环变量。
例题2-1 aabb
输出所有形如aabb的4位完全平方数(即前两位数字相等,后两位数字也相等)。
【分析】
分支和循环结合在一起时功能强大:下面枚举所有可能的aabb,然后判断它们是否为完全平方数。注意,a的范围是1~9,但b可以是0。主程序如下:
for(int a = 1; a <= 9; a++)
for(int b = 0; b <= 9; b++)
if(aabb是完全平方数) printf("%d\n", aabb);
上面的程序并不完整——“aabb是完全平方数”是中文描述,而不是合法的C语言表达式,而aabb在C语言中也是另外一个变量,而不是把两个数字a和两个数字b拼在一起(C语言中的变量名可以由多个字母组成)。但这个“程序”很容易理解,甚至能让读者的思路更加清晰。
这里把这样“不是真正程序”的“代码”称为伪代码(pseudocode)。 虽然有一些正规的伪代码定义,但在实际应用中,并不需要太拘泥于伪代码的格式。主要目标是描述算法梗概,避开细节,启发思路。
提示2-5: 不拘一格地使用伪代码来思考和描述算法是一种值得推荐的做法。
提示2-6: 把伪代码改写成代码时,一般先选择较为容易的任务来完成。
程序2-2 7744问题(1)
#include<stdio.h>
#include<math.h>
int main() {
// 使用两层循环来枚举所有可能的a和b的值
for(int a = 1; a <= 9; a++) { // a的范围是1到9
for(int b = 0; b <= 9; b++) { // b的范围是0到9
// 构造形如aabb的数
int n = a*1100 + b*11; // a*1000 + a*100 + b*10 + b
// 计算n的平方根,并四舍五入到最接近的整数
int m = floor(sqrt(n) + 0.5);
// 检查四舍五入后的整数平方是否等于原数n
if(m*m == n) {
// 如果等于原数n,说明n是完全平方数,打印出来
printf("%d\n", n);
}
}
}
return 0;
}
读者可能会问:可不可以这样写?if (sqrt(n) == floor(sqrt(n))) printf("%d\n", n),即直接判断sqrt (n) 是否为整数。理论上当然没问题,但这样写不保险,因为浮点数的运算(和函数)有可能存在误差。
假设在经过大量计算后,由于误差的影响,整数1变成了0.9999999999,floor的结果会是0而不是1。为了减小误差的影响,一般改成四舍五入,即 floor(x+0.5)
(2) 这样做,小数部分为0.5的数也会受到浮点误差的影响,因此任何一道严密的算法竞赛题目中都需要想办法解决这个问题。后面还会讨论这个问题。
。如果难以理解,可以想象成在数轴上把一个单位区间往左移动0.5个单位的距离。 floor(x) 等于1的区间为[1,2),而 floor(x+0.5) 等于1的区间为 [0.5,1.5) 。
提示2-7: 浮点运算可能存在误差。在进行浮点数比较时,应考虑到浮点误差。
// 这行代码是用来计算`n`的平方根,并将其四舍五入到最接近的整数。
int m = floor(sqrt(n) + 0.5);
-
sqrt(n): 这个函数调用计算n的平方根。sqrt是标准数学库函数,返回的是一个double类型的值,表示n的平方根。 -
sqrt(n) + 0.5: 将计算出的平方根与0.5相加。这是为了实现四舍五入的效果。当你向一个数加上0.5后再取其下限,就相当于对原数进行了四舍五入。 举个例子:- 如果
sqrt(n)的值是10.3,加上0.5后变成10.8,再取下限,结果是10。 - 如果
sqrt(n)的值是10.6,加上0.5后变成11.1,再取下限,结果是11。
- 如果
-
floor(...):floor函数返回小于或等于给定参数的最大整数。在这里,它的作用是取sqrt(n) + 0.5的下限,即向下取整。 -
int m = ...: 将floor函数的结果赋值给整型变量m。由于floor函数返回的是double类型,这里会发生隐式类型转换,将double类型转换为int类型。
综上所述,int m = floor(sqrt(n) + 0.5); 这行代码的目的是为了找到最接近n的平方根的整数。这个整数m在后面的代码中用于验证n是否是完全平方数,即m * m是否等于n。如果相等,则说明n是一个完全平方数,否则不是。
程序2-3 7744问题(2)
#include<stdio.h>
int main()
{
for(int x = 1; ; x++)
{
int n = x * x;
if(n < 1000) continue;
if(n > 9999) break;
int hi = n / 100;
int lo = n % 100;
if(hi/10 == hi%10 && lo/10 == lo%10) printf("%d\n", n);
}
return 0;
}
此程序中的新知识是continue和break语句。
- continue是指跳回for循环的开始,执行调整语句并判断循环条件(即“直接进行下一次循环”),
- 而break是指直接跳出循环。
另外,注意到这里的for语句是“残缺”的:没有指定循环条件。事实上,3部分都是可以省略的。没错,for(; ; )就是一个死循环,如果不采取措施(如break),就永远不会结束。
这道题目要求我们使用C语言找出所有4位数中形如aabb的完全平方数。所谓形如aabb的数,就是指这个数的千位和百位数字相同,十位和个位数字也相同。例如,1122、3344都是符合条件的数。
题目的解决方案是通过枚举所有可能的4位数,然后检查它们是否为完全平方数,且是否符合aabb的形式。
分析给出的代码,我们可以看到以下几点:
- 主循环使用了一个变量
x,循环的目的是为了枚举所有可能的整数x,并计算x的平方n。 - 为了确保
n是一个4位数,代码使用了两个if语句来进行过滤:if(n < 1000) continue;这一行确保了n至少是一个4位数。如果n小于1000,那么它不是一个4位数,因此使用continue语句跳过后续的代码,直接进入下一次循环。if(n > 9999) break;这一行确保了n不会超过4位数的范围。如果n大于9999,循环终止,因为再往后的数都不可能是4位数了。
- 接下来,代码使用了两个变量
hi和lo来分别存储n的高两位和低两位:int hi = n / 100;这一行计算出n的前两位数。int lo = n % 100;这一行计算出n的后两位数。
- 最后,代码使用了一个
if语句来检查hi和lo是否符合aabb的形式:if(hi/10 == hi%10 && lo/10 == lo%10)这一行检查hi的十位和个位是否相同,以及lo的十位和个位是否相同。如果这两个条件都满足,那么n就是我们要找的数,使用printf函数将其打印出来。
综上所述,这段代码通过枚举所有可能的整数x,计算它们的平方,然后检查这些平方数是否为4位数且符合aabb的形式,最终打印出所有符合条件的完全平方数。
2.2 while循环和do-while循环
例题2-2 3n+1问题
猜想
:对于任意大于1的自然数n,若n为奇数,则将n变为3n+1,否则变为n的一半。经过若干次这样的变换,一定会使n变为1。例如,3→10→5→16→8→4→2→1。
输入n,输出变换的次数。n≤10⁹。
样例输入:
3
样例输出:
7
【分析】
不难发现,程序完成的工作依然是重复性的:要么乘3加1,要么除以2,但和2.1节的程序又不太一样:循环的次数是不确定的,而且n也不是“递增”式的循环。这样的情况很适合用while循环来实现。
程序2-4 3n+1问题(有bug)
#include<stdio.h>
int main()
{
int n, count = 0;
scanf("%d", &n);
while(n > 1)
{
if(n % 2 == 1) n = n*3+1;
else n /= 2;
count++;
}
printf("%d\n", count);
return 0;
}
提示2-8: while循环的格式为while(条件) 循环体;。
此格式看上去比for循环更简单,可以用while改写for。for (初始化; 条件; 调整) 循环体; 等价于:
初始化;
while(条件)
{
循环体;
调整;
}
count++ 的作用是计数器。由于最终输出的是变换的次数,需要一个变量来完成计数。
提示2-9: 当需要统计某种事物的个数时,可以用一个变量来充当计数器。
这个程序是否正确?先来测试一下:输入“987654321”,看看结果是什么。很不幸,答案等于1——这明显是错误的。题目中给出的范围是n≤10⁹,这个987654321是合法的输入数据。
提示2-10: 不要忘记测试。一个看上去正确的程序可能隐含错误。
问题出在哪里呢?若反复阅读程序仍然无法找到答案,就动手实验吧!一种方法是利用IDE和gdb跟踪调试,但这并不是本书所推荐的调试方法。一个更通用的方法是:输出中间结果。
提示2-11: 在观察无法找出错误时,可以用“输出中间结果”的方法查错。
在给n做变换的语句后加一条输出语句printf("%d\n", n);,将很快找到问题的所在:第一次输出为 -1332004332,它不大于1,所以循环终止。如果认真完成了前面的所有探索实验,读者将立刻明白这其中的缘由:乘法溢出了。
#include<stdio.h>
int main()
{
int n, count = 0;
scanf("%d", &n);
while(n > 1)
{
if(n % 2 == 1) n = n*3+1;
else n /= 2;
printf("%d\n", n);
count++;
}
printf("%d\n", count);
return 0;
}
提示2-12: C99并没有规定int类型的确切大小,但在当前流行的竞赛平台中,int都是32位整数,范围是-2147483648~2147483647。
回到本题。本题中n的上限 10⁹ 只比 int 的上界稍微小一点,因此溢出了也并不奇怪。只要使用 C99 中新增的 long long 即可解决问题,其范围是 -2⁶³~2⁶³-1,唯一的区别就是要把输入时的 %d 改成 %lld 。但这也是不保险的——在MinGW的gcc中,要把 %lld 改成 %I64d,但奇怪的是VC2008里又得改回 %lld。是不是很容易搞错?所以如果涉及long long的输入输出,常用C++ 的输入输出流或者自定义的输入输出方法,本书将在后面的章节对其进行深入讨论。
提示2-13: long long在Linux下的输入输出格式符为 %lld,但Windows平台中有时为 %I64d。为保险起见,可以用后面介绍的 C++ 流,或者编写自定义输入输出函数。
最后给出 long long 版本的代码,它避开了对 long long 的输入输出,并且成功算出 n=987654321 时的答案为 180。
程序2-5 3n+1问题
#include<stdio.h>
int main()
{
int n2, count = 0;
scanf("%d", &n2);
long long n = n2;
while(n > 1)
{
if(n % 2 == 1) n = n*3+1;
else n /= 2;
count++;
}
printf("%d\n", count);
return 0;
}
例题2-3 近似计算
计算 ,直到最后一项小于10⁻⁶。
【分析】
本题和例题2-2一样,也是重复计算,因此可以用循环实现。但不同的是,只有算完一项之后才知道它是否小于10⁻⁶。也就是说,循环终止判断是在计算之后,而不是计算之前。这样的情况很适合使用do-while循环。
程序2-6 近似计算
#include<stdio.h>
int main()
{
double sum = 0;
for(int i = 0; ; i++)
{
double term = 1.0 / (i*2+1);
if(i % 2 == 0) sum += term;
else sum -= term;
if(term < 1e-6) break;
}
printf("%.6f\n", sum);
return 0;
}
提示2-14: do-while循环的格式为do{循环体} while(条件);,其中循环体至少执行一次,每次执行完循环体后判断条件,当条件满足时继续循环。
您的代码实现了题目要求的功能,但是它使用了 for 循环而不是 do-while 循环。在代码中,您使用了一个无限循环 for(;;) 并在循环体内部判断最后一项是否小于 1e-6 来决定是否跳出循环。这样的实现是正确的,但如果要按照题目的要求使用 do-while 循环,代码应该稍作调整。
以下是使用 do-while 循环的代码示例:
#include<stdio.h>
int main() {
double sum = 0.0;
int i = 0;
double term;
do {
term = 1.0 / (i * 2 + 1); // 计算当前项
if (i % 2 == 0) {
sum += term; // 偶数项,加上当前项
} else {
sum -= term; // 奇数项,减去当前项
}
i++; // 更新项的索引
} while (term >= 1e-6); // 判断最后一项是否小于10⁻⁶
printf("%.6f\n", sum * 4); // 输出π的近似值,注意乘以4
return 0;
}
注意几个要点:
- 使用
do-while循环保证至少执行一次循环体内的代码,然后再检查条件。 - 循环条件检查的是变量
term是否小于1e-6。 - 最后输出的时候,因为我们计算的是
π/4的近似值,所以要将sum乘以4才是π的近似值。
请注意,您的原始代码没有乘以4来输出 π 的值,这是一个需要修正的地方。在上述 do-while 循环的代码中,这个问题已经被纠正。
2.3 循环的代价
例题2-4 阶乘之和
输入n,计算 的末6位(不含前导0)。, 表示前 n 个正整数之积。
样例输入:
10
样例输出:
37913
【分析】
这个任务并不难,引入累加变量S之后,核心算法只有“
for (int i=1; i<=n; i++) S += i!
”。**不过,C语言并没有阶乘运算符,所以这句话只是伪代码,而不是真正的代码。**事实上,还需要一次循环来计算i!,即“
for (int j=1; j<=i; j++) factorial *= j;
”。代码如下:
程序2-7 阶乘之和(1)
#include<stdio.h>
int main()
{
int n, S = 0;
scanf("%d", &n);
for(int i = 1; i <= n; i++)
{
int factorial = 1;
for(int j = 1; j <= i; j++)
factorial *= j;
S += factorial;
}
printf("%d\n", S % 1000000);
return 0;
}
注意累乘器factorial(英文“阶乘”的意思)定义在循环里面。换句话说,每执行一次循环体,都要重新声明一次factorial,并初始化为1(想一想,为什么不是0)。因为只要末6位,所以输出时对 10⁶ 取模。
提示2-15: 在循环体开始处定义的变量,每次执行循环体时会重新声明并初始化。
有了刚才的经验,下面来测试一下这个程序:n=100 时,输出 -961703。直觉告诉我们:乘法又溢出了。这个直觉很容易通过“输出中间变量”法得到验证,但若要解决这个问题,还需要一点数学知识。
提示2-16: 要计算只包含加法、减法和乘法的整数表达式除以正整数n的余数,可以在每步计算之后对n取余,结果不变。
在修正这个错误之前,还可以进行更多测试:当 n=10⁶ 时输出什么?更会溢出不是吗?但是重点不在这里。事实上,它的速度太慢!下面把程序改成“每步取模”的形式,然后加一个“计时器”,看看究竟有多慢。
程序2-8 阶乘之和(2)
#include<stdio.h>
#include<time.h>
int main()
{
const int MOD = 1000000;
int n, S = 0;
scanf("%d", &n);
for(int i = 1; i <= n; i++)
{
int factorial = 1;
for(int j = 1; j <= i; j++)
factorial = (factorial * j % MOD);
S = (S + factorial) % MOD;
}
printf("%d\n", S);
printf("Time used = %.2f\n", (double)clock() / CLOCKS_PER_SEC);
return 0;
}
提示2-17: 可以使用 time.h 和 clock() 函数获得程序运行时间。常数 CLOCKS_PER_SEC 和操作系统相关,请不要直接使用 clock() 的返回值,而应总是除以 CLOCKS_PER_SEC。
输入“20”,按Enter键后,系统瞬间输出了答案820313。但是,输出的 Time used 居然不是0!其原因在于,键盘输入的时间也被计算在内——这的确是程序启动之后才进行的。为了避免输入数据的时间影响测试结果,可使用一种称为“管道”的小技巧: 在Windows命令行下执行echo 20|abc,操作系统会自动把20输入,其中abc是程序名
(8) Linux下需要输入“echo|./abc”,因为在默认情况下,当前目录不在可执行文件的搜索路径中。
。如果不知道如何操作命令行,请参考附录A。笔者建议每个读者都熟悉命令行操作,包括Windows和Linux。
在尝试了多个n之后,得到了一张表,如表2-1所示。
表2-1 程序2-8的输出结果与运行时间表

由表2-1可知:第一,程序的运行时间大致和 n 的平方成正比(因为 n 每扩大1倍,运行时间近似扩大4倍)。甚至可以估计 n=10⁶ 时,程序大致需要近5个小时才能执行完。
提示2-18: 很多程序的运行时间与规模n存在着近似的简单关系。可以通过计时函数来发现或验证这一关系。
第二,从40开始,答案始终不变。这是真理还是巧合?聪明的读者也许已经知道了: 末尾有6个0,所以从第5项开始,后面的所有项都不会影响和的末6位数字——只需要在程序的最前面加一条语句 if(n>25) n=25;,效率和溢出都将不存在问题。
本节展示了循环结构程序设计中最常见的两个问题:算术运算溢出和程序效率低下。 这两个问题都不是那么容易解决的,将在后面章节中继续讨论。另外,本节中介绍的两个工具——输出中间结果和计时函数,都是相当实用的。
2.4 算法竞赛中的输入输出框架
例题2-5 数据统计
输入一些整数,求出它们的最小值、最大值和平均值(保留3位小数)。输入保证这些数都是不超过1000的整数。
样例输入:
2 8 3 5 1 7 3 6
样例输出:
1 8 4.375
【分析】
如果是先输入整数n,然后输入n个整数,相信读者能够写出程序。关键在于:整数的个数是不确定的。下面直接给出程序:
程序2-9 数据统计(有bug)
#include<stdio.h>
int main()
{
int x, n = 0, min, max, s = 0;
while(scanf("%d", &x) == 1)
{
s += x;
if(x < min) min = x;
if(x > max) max = x;
n++;
}
printf("%d %d %.3f\n", min, max, (double)s/n);
return 0;
}
2 8 3 5 1 7 3 6
^Z
1 8 4.375
--------------------------------
Process exited after 3.993 seconds with return value 0
请按任意键继续. . .
按Enter键并不意味着输入的结束。那如何才能告诉程序输入结束了呢?
提示2-19: 在Windows下,输入完毕后先按Enter键,再按Ctrl+Z键,最后再按Enter键,即可结束输入。在Linux下,输入完毕后按Ctrl+D键即可结束输入。
输入终于结束了,但输出却是“1 2293624 4.375”。这个2293624是从何而来?当用-O2编译(读者可阅读附录A了解-O2)后答案变成了1 10 4.375,和刚才不一样!换句话说,这个程序的运行结果是不确定的。在读者自己的机器上,答案甚至可能和上述两个都不同。
根据“输出中间结果”的方法,读者不难验证下面的结论:变量max在一开始就等于2293624(或者10),自然无法更新为比它小的8。
提示2-20: 变量在未赋值之前的值是不确定的。特别地,它不一定等于0。
解决的方法就很清楚了:在使用之前赋初值。 由于min保存的是最小值,其初值应该是一个很大的数;反过来,max的初值应该是一个很小的数。一种方法是定义一个很大的常数,如 INF=1000000000,然后让max=-INF,而min=INF,另一种方法是先读取第一个整数x,然后令max=min=x。这样的好处是避免了人为的“假想无穷大”值,程序更加优美;而INF这样的常数有时还会引起其他问题,如“无限大不够大”,或者“运算溢出”,后面还会继续讨论这个问题。
上面的程序并不是很方便:每次测试都要手动输入许多数。尽管可以用前面讲的管道的方法,但数据只是保存在命令行中,仍然不够方便。
一个好的方法是用文件——把输入数据保存在文件中,输出数据也保存在文件中。 这样,只要事先把输入数据保存在文件中,就不必每次重新输入了;数据输出在文件中也避免了“输出太多,一卷屏前面的就看不见了”这样的尴尬,运行结束后,慢慢浏览输出文件即可。如果有标准答案文件,还可以进行文件比较
(9) 在Windows中可以使用fc命令,而在Linux中可以使用diff命令。
,而无须编程人员逐个检查输出是否正确。事实上,几乎所有算法竞赛的输入数据和标准答案都是保存在文件中的。
使用文件最简单的方法是使用输入输出重定向,只需在main函数的入口处加入以下两条语句:
freopen("input.txt", "r", stdin);
freopen("output.txt", "w", stdout);
上述语句将使得scanf从文件input.txt读入,printf写入文件output.txt。事实上,不只是scanf和printf,所有读键盘输入、写屏幕输出的函数都将改用文件。 尽管这样做很方便,并不是所有算法竞赛都允许用程序读写文件。甚至有的竞赛允许访问文件,但不允许用freopen这样的重定向方式读写文件。
提示2-21: 请在比赛之前了解文件读写的相关规定:是标准输入输出(也称标准I/O,即直接读键盘、写屏幕),还是文件输入输出?如果是文件输入输出,是否禁止用重定向方式访问文件?
提示2-22: 在算法竞赛中,选手应严格遵守比赛的文件名规定,包括程序文件名和输入输出文件名。不要弄错大小写,不要拼错文件名,不要使用绝对路径或相对路径。
有一种方法可以在本机测试时用文件重定向,但一旦提交到比赛,就自动“删除”重定向语句。 代码如下:
程序2-10 数据统计(重定向版)
#define LOCAL
#include<stdio.h>
#define INF 1000000000
int main()
{
#ifdef LOCAL
freopen("data.in", "r", stdin);
freopen("data.out", "w", stdout);
#endif
int x, n = 0, min = INF, max = -INF, s = 0;
while(scanf("%d", &x) == 1)
{
s += x;
if(x < min) min = x;
if(x > max) max = x;
/*
printf("x = %d, min = %d, max = %d\n", x, min, max);
*/
n++;
}
printf("%d %d %.3f\n", min, max, (double)s/n);
return 0;
}
- 重定向的部分被写在了
#ifdef和#endif中。其含义是:只有定义了符号LOCAL,才编译两条freopen语句。 - 输出中间结果的printf语句写在了注释中——它在最后版本的程序中不应该出现,但是又舍不得删除它(万一发现了新的bug,需要再次用它输出中间信息)。将其注释的好处是:一旦需要时,把注释符去掉即可。
上面的代码在程序首部就定义了符号LOCAL,因此在本机测试时使用重定向方式读写文件。如果比赛要求读写标准输入输出,只需在提交之前删除 #define LOCAL 即可。 一个更好的方法是在编译选项而不是程序里定义这个LOCAL符号(不知道如何在编译选项里定义符号的读者请参考附录A),这样,提交之前不需要修改程序,进一步降低了出错的可能。
提示2-23: 在算法竞赛中,有经验的选手往往会使用条件编译指令并且将重要的测试语句注释掉而非删除。
如果比赛要求用文件输入输出,但禁止用重定向的方式,又当如何呢? 程序如下:
程序2-11 数据统计(fopen版)
#include<stdio.h>
#define INF 1000000000
int main()
{
FILE *fin, *fout;
fin = fopen("data.in", "rb");
fout = fopen("data.out", "wb");
int x, n = 0, min = INF, max = -INF, s = 0;
while(fscanf(fin, "%d", &x) == 1)
{
s += x;
if(x < min) min = x;
if(x > max) max = x;
n++;
}
fprintf(fout, "%d %d %.3f\n", min, max, (double)s/n);
fclose(fin);
fclose(fout);
return 0;
}
虽然新内容不少,但也很直观:先声明变量 fin 和 fout(暂且不用考虑 FILE* ),把 scanf 改成 fscanf,第一个参数为 fin;把 printf 改成 fprintf,第一个参数为 fout,最后执行 fclose,关闭两个文件。
提示2-24: 在算法竞赛中,如果不允许使用重定向方式读写数据,应使用 fopen 和 fscanf/fprintf 进行输入输出。
重定向和fopen两种方法各有优劣。重定向的方法写起来简单、自然,但是不能同时读写文件和标准输入输出;fopen的写法稍显繁琐,但是灵活性比较大(例如,可以反复打开并读写文件)。顺便说一句,**如果想把 fopen 版的程序改成读写标准输入输出,只需赋值 fin = stdin; fout = stdout;即可,不要调用 fopen 和 fclose **
(10) 有读者可能试过用
fopen("con", "r")的方法打开标准输入输出,但这个方法并不是可移植的——它在Linux下是无效的。
。
对文件输入输出的讨论到此结束,本书剩余部分的所有题目均使用标准输入输出。
例题2-6 数据统计II
输入一些整数,求出它们的最小值、最大值和平均值(保留3位小数)。输入保证这些数都是不超过1000的整数。
输入包含多组数据,每组数据第一行是整数个数n,第二行是n个整数。n=0为输入结束标记,程序应当忽略这组数据。相邻两组数据之间应输出一个空行。
样例输入:
8
2 8 3 5 1 7 3 6
4
-4 6 10 0
0
样例输出:
Case 1: 1 8 4.375
Case 2: -4 10 3.000
【分析】
本题和例题2-5本质相同,但是输入输出方式有了一定的变化。由于这样的格式在算法竞赛中非常常见,这里直接给出代码:
程序2-12 数据统计II(有bug)
#include<stdio.h>
#define INF 1000000000
int main()
{
int x, n = 0, min = INF, max = -INF, s = 0, kase = 0;
while(scanf("%d", &n) == 1 && n)
{
int s = 0;
for(int i = 0; i < n; i++) {
scanf("%d", &x);
s += x;
if(x < min) min = x;
if(x > max) max = x;
}
if(kase) printf("\n");
printf("Case %d: %d %d %.3f\n", ++kase, min, max, (double)s/n);
}
return 0;
}
聪明的读者,你能看懂其中的逻辑吗?上面的程序有几个要点。首先是输入循环。题目说了n=0为输入标记,为什么还要判断scanf的返回值呢?答案是为了鲁棒性(robustness)。算法竞赛中题目的输入输出是人设计的,难免会出错。有时会出现题目指明以n=0为结束标记而真实数据忘记以n=0结尾的情形。虽然比赛中途往往会修改这一错误,但在ACM/ICPC等时间紧迫的比赛中,如果程序能自动处理好有瑕疵的数据,会节约大量不必要的时间浪费。
提示2-25: 在算法竞赛中,偶尔会出现输入输出错误的情况。如果程序鲁棒性强,有时能在数据有瑕疵的情况下仍然给出正确的结果。程序的鲁棒性在工程中也非常重要。
下一个要点是kase变量的使用。不难看出它是“当前数据编号”计数器。当输出第2组或以后的结果时,会在前面加一个空行,符合题目“相邻两组数据的输出以空行隔开”的规定。注意,最后一组数据的输出会以回车符结束,但之后不会有空行。不同的题目会有不同的规定,请读者仔细阅读题目。
像本题这样“多组数据”的题目数不胜数。例如,ACM/ICPC总决赛就只有一个输入文件,包含多组数据。即使是NOI/IOI这样多输入文件的比赛,有时也会出现一个文件多组数据的情况。例如,有的题目输出只有Yes和No两种,如果一个文件里只有一组数据,又是每个文件分别给分,一个随机输出Yes/No的程序平均情况下能得50分,而一个把Yes打成yes,No打成no的程序却只有0分
(11) 也不总是如此。有些比赛会善意地把这种只是格式不对的结果判成“正确”。可惜这样的比赛非常少。
。
接下来是找bug时间。上面的程序对于样例输入输出可以得到正确的结果,但它真的是正确的吗?在样例输入的最后增加第3组数据:
10
0 0 0 0 0 0 0 0 0 0
,会看到这样的输出:
Case 3:-4 10 0.000
相信读者已经意识到问题出在哪里了:min和max没有“重置”,仍然是上个数据结束后的值。
提示2-26: 在多数据的题目中,一个常见的错误是:在计算完一组数据后某些变量没有重置,影响到下组数据的求解。
解决方法很简单,把min和max定义在while循环中即可,这样每次执行循环体时,会新声明和初始化min和max。 细心的读者也许注意到了另外一个问题:为什么第3个数(累加和)是对的呢?原因在于:循环体内部也定义了一个s,把main函数里定义的s给“屏蔽”了。
提示2-27: 当嵌套的两个代码块中有同名变量时,内层的变量会屏蔽外层变量,有时会引起十分隐蔽的错误。
这是初学者在求解“多数据输入”的题目时常范的错误,请读者留意。这种问题通常很隐蔽,但也不是发现不了:对于这个例子来说,编译时加一个-Wall就会看到一条警告:warning:unused variable 's' [-Wunused-variable](警告:没有用过的变量's')。
提示2-28: 用编译选项-Wall编译程序时,会给出很多(但不是所有)警告信息,以帮助程序员查错。但这并不能解决所有的问题:有些“错误”程序是合法的,只是这些动作不是所期望的。
2.5 注解与习题
2.5.1 习题
习题2-1 水仙花数(daffodil)
输出 100~999 中的所有水仙花数。若 3 位数 ABC 满足 ,则称其为水仙花数。例如 ,所以 153 是水仙花数。
#include <iostream>
using namespace std;
int main()
{
for (int num = 100; num >= 100 && num <= 999; num++)
{
int A, B ,C; //个位十位百位
A = num % 10;
B = num / 10 % 10;
C = num / 100;
if (num == A*A*A + B*B*B + C*C*C)
cout << num << endl;
}
return 0;
}
习题2-2 韩信点兵(hanxin)
相传韩信才智过人,从不直接清点自己军队的人数,只要让士兵先后以三人一排、五人一排、七人一排地变换队形,而他每次只掠一眼队伍的排尾就知道总人数了。输入包含多组数据,每组数据包含 3 个非负整数 a,b,c,表示每种队形排尾的人数 ,输出总人数的最小值(或报告无解)。已知总人数不小于 10,不超过 100。输入到文件结束为止。
样例输入:
2 1 6
2 1 3
样例输出:
Case 1: 41
Case 2: No answer
题目讲解
这个问题可以用一个简单的故事来解释:
想象一下,你是韩信的助手,你的任务是通过韩信的点兵方法来找出军队的确切人数。韩信有一种特殊的点兵方法:他让士兵们按照三人一组,五人一组和七人一组排队,然后只看每种排法的最后一排有几个人,就能推算出整个军队的人数。
具体来说,如果最后一排三人一组时剩下a个人,五人一组时剩下b个人,七人一组时剩下c个人,那么整个军队的人数就是某个特定的数字,这个数字除以3余a,除以5余b,除以7余c。
为了找到这个数字,你可以这样做:
- 从10开始数(因为题目告诉我们军队人数至少是10人),一直数到100(因为题目说人数不超过100)。
- 对于每个数字,检查它除以3的余数是否等于a,除以5的余数是否等于b,除以7的余数是否等于c。
- 如果你找到了一个数字满足所有这些条件,那么这就是军队的人数。
- 如果你数到了100都没有找到这样的数字,那就意味着没有解决方案,也就是说没有办法通过这种点兵方法找到一个确切的数字。
这个问题的关键是理解“余数”的概念。举个例子,如果有23个士兵,当你尝试把他们三人一组排队时,最后一组会剩下2个人,因为23除以3得到7余2。同理,如果你尝试把他们五人一组排队,会剩下3个人,因为23除以5得到4余3。
在编写代码时,我们就是按照这个逻辑来检查每个数字,直到找到正确的答案或者确定没有答案为止。
【分析】
这个问题是一个典型的同余方程求解问题,也可以看作是中国剩余定理的一个应用。我们需要找到一个数 ,使得:
其中 ,,,且 。
下面分别用 C 和 C++ 语言实现这个算法。
C
#include <stdio.h>
int main() {
int a, b, c;
while (scanf("%d %d %d", &a, &b, &c) != EOF) { // 循环读取输入直到文件结束
int x;
int found = 0; // 标记是否找到解
for (x = 10; x <= 100; x++) { // 从10开始逐一尝试,直到100
if (x % 3 == a && x % 5 == b && x % 7 == c) { // 检查是否符合条件
printf("%d\n", x); // 输出结果
found = 1; // 标记找到解
break;
}
}
if (!found) { // 如果没有找到解
printf("No answer\n"); // 输出无解
}
}
return 0;
}
(scanf("%d %d %d", &a, &b, &c) != EOF) 这段代码是C语言中用来从输入中读取数据的一个常见模式。让我们一步一步解析它:
-
scanf是C语言中的一个函数,用于从标准输入(通常是键盘)读取并解析数据。它的名字代表 "scan formatted",意味着它可以按照指定的格式来读取输入的数据。 -
"%d %d %d"是scanf函数的格式字符串,指定了输入数据应该如何被解析。在这个例子中,%d是一个格式说明符,表示一个十进制整数。因此,"%d %d %d"期望输入中有三个十进制整数,它们可以由空格或者换行符分隔。 -
&a, &b, &c是scanf函数的参数,它们是三个变量的地址。scanf使用这些地址来存储从输入中读取的整数值。所以,第一个输入的整数将被存储在变量a中,第二个在b中,第三个在c中。 -
scanf函数的返回值是成功读取并赋值的数据项的数量。在这个例子中,如果成功读取了三个整数,scanf将返回3。 -
!= EOF是一个比较操作,它检查scanf的返回值是否不等于EOF。EOF是一个在stdio.h中定义的宏,代表 "End Of File",用于指示输入流的结束。如果scanf尝试读取输入但已经到达了输入流的末尾(例如,如果用户输入了文件结束符,通常是在键盘上按 Ctrl+D(在Unix/Linux系统)或 Ctrl+Z(在Windows系统)),scanf将返回EOF。
综上,这段代码的意思是:只要 scanf 函数从输入中成功读取了三个整数,并且没有到达输入流的末尾,就继续执行循环体中的代码。这是一个常用的模式,用于处理不定数量的输入数据组,直到没有更多的数据可以读取为止。
C++
#include <iostream>
using namespace std;
int main() {
int a, b, c;
while (cin >> a >> b >> c) { // 循环读取输入
bool found = false; // 标记是否找到解
for (int x = 10; x <= 100; ++x) { // 从10开始逐一尝试,直到100
if (x % 3 == a && x % 5 == b && x % 7 == c) { // 检查是否符合条件
cout << x << endl; // 输出结果
found = true; // 标记找到解
break;
}
}
if (!found) { // 如果没有找到解
cout << "No answer" << endl; // 输出无解
}
}
return 0;
}
代码解读
两段代码的逻辑是相同的,区别在于语言的使用上。C++ 使用了 iostream 库和命名空间 std,而 C 语言使用了 stdio.h 库。
- 两段代码都使用了一个循环来读取输入的三个数 、、。
- 通过一个
for循环,从 10 开始逐一尝试每个数 是否满足以上的三个同余条件。 - 如果找到一个数 满足所有条件,输出这个数,并标记找到了解。
- 如果循环结束后没有找到解,则输出 "No answer"。
注意,由于题目要求总人数不超过 100,因此循环的上界是 100。如果没有这个限制,我们可能需要考虑更高效的算法来解决这个问题。
习题2-3 倒三角形(triangle)
输入正整数 ,输出一个 n 层的倒三角形。例如, 时输出如下:
#########
#######
#####
###
#
#include <iostream>
using namespace std;
int main() {
int n;
cout << "请输入一个正整数n(n≤20):";
cin >> n;
if (n > 20) {
cout << "输入的数值超出范围!" << endl;
return 1;
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
cout << " ";
}
for (int k = 0; k < 2 * (n - i) - 1; k++) {
cout << "#";
}
//cout << endl;
cout << "\n";
//某些版本的 Dev C++ 调试 C++ 程序时,遇到 endl 会出现“卡死”的情况,无法继续调试程序。遇到这种情况,比如容易的解决方案是:将 endl 用 '\n' 替换,就可以解决这个问题。
}
return 0;
}
解析
题目要求编写一个程序,用于输出一个由 '#' 符号构成的倒三角形。用户必须输入一个正整数 (不大于20),程序将根据这个数打印出一个 层的倒三角形,每层的'#'的数量从上到下逐渐减少。顶端的一行有 个'#'符号,每往下一层就减少两个'#',同时每行开始的位置比上一行多一个空格。
针对提供的C++代码,它执行以下操作:
- 引入
<iostream>头文件,允许程序处理输入和输出操作。 - 使用命名空间
std,这可以避免在使用C++标准库时重复写std::。 main函数开始,这是每个C++程序的入口点。- 定义整数变量
n,用于存储用户输入的层数。 - 提示用户输入一个正整数并从标准输入中读取这个整数。
- 判断用户输入的整数是否大于20,如果是则输出错误信息并终止程序。
- 利用一个外层
for循环控制打印多少层,在循环中i从0变化至n-1。 - 第一个内层
for循环打印每一行前面的空格,空格的数量等于当前层数i。 - 第二个内层
for循环打印每行的 '#' 符号,其数量为2 * (n - i) - 1,这保证了每向下一层,'#' 的数目减两个。 - 使用
cout << "\n";代替cout << endl;来输出换行符,并避免某些特定开发环境下的潜在问题。 - 循环成功执行,打印完整的倒三角形后,程序返回0并正常退出。
注意代码中的注释解释了为什么选择使用 '\n' 替换 endl 来避免特定环境下的问题。
习题2-4 子序列的和(subsequence)
输入两个正整数 ,输出 ,保留 5 位小数。输入包含多组数据,结束标记为 。提示:本题有陷阱。
样例输入:
2 4
65536 655360
0 0
样例输出:
Case 1: 0.42361
Case 2: 0.00001
代码【落入陷阱】
#include <iostream>
#include <iomanip>
using namespace std;
// 计算子序列的和
double subsequenceSum(int n, int m) {
double sum = 0.0;
for (int i = n; i <= m; ++i) {
sum += 1.0 / (i * i);
}
return sum;
}
int main() {
int n, m;
int caseNum = 1;
// 循环读取每一组数据
while (true) {
cin >> n >> m;
if (n == 0 && m == 0) {
break; // 若输入为0 0,则结束输入
}
double result = subsequenceSum(n, m);
cout << fixed << setprecision(5); // 设置输出格式为固定的小数点,并精确到小数点后五位
cout << "Case " << caseNum << ": " << result << endl;
caseNum++;
}
return 0;
}
2 4
Case 1: 0.42361
65536 655360
Case 2: inf
0 0
解释代码的实现:
- 首先引入了iostream和iomanip头文件,用于输入输出操作以及控制输出格式。
- 使用using命名空间std,简化代码中的std前缀。
- 定义了subsequenceSum函数用于计算子序列的和,参数为整数n和m,返回值为double类型。此函数通过一个for循环迭代从n到m,将每个i值的平方的倒数累加到sum变量中。
- main函数是程序入口,定义了整型变量n,m和用于计数的caseNum。
- 进入无限循环,读取每一组n和m的值,使用标准输入流cin。
- 当读取到n和m均为0时,使用break语句跳出循环。
- 使用subsequenceSum函数计算当前数据组的子序列和,并将结果存入result变量中。
- 接下来利用iomanip库中的setprecision和fixed函数设置输出的格式,保留五位小数。
setprecision是设置浮点数输出时的精确度,而fixed是设置浮点数打印为定点格式(而不是科学计数法)。当这两者与<<操作符一起使用时,它们影响cout的行为,这是由于它们修改了流的状态。
- 输出Case和对应的编号,随后输出计算得到的子序列和的值。
- caseNum递增,为输出下一组数据做准备。
本题的陷阱有两个主要方面:
-
数值稳定性: 直接按照题目中的方法逐项相加,当n和m的值很大时,例如65536和655360,会造成计算中的数值误差。因为1/(i * i)当i很大时会非常小,当这些小数相加的时候,可能会在数值表示上丢失精度,尤其是在使用双精度浮点数时。这会导致结果和精确值有较大的偏差。
-
效率问题: 如果n和m的差距很大,直接使用for循环进行每项的逐个计算会导致计算量巨大,程序运行时间会变得非常长,效率极低。
为了避免这些陷阱,一种更好的方法是使用更为高效的数学公式或者算法来计算这个序列的和,比如利用积分法进行估计,或者找到递推关系减少计算量。一个简单的避免数值稳定性问题的方法是从大到小累加,即从m开始到n,这种情况下可以减少因为小数位的累加而导致的精度丢失。但即使这样,当n和m非常大时,依旧可能存在性能问题。
在实际编写代码时,应当根据具体情况选择合适的方法来避免这些陷阱,提高程序的精度和效率。
优化后的代码
为什么我的样例输出是这样的?
2 4
Case 1: 0.42361
65536 655360
Case 2: inf
0 0
infis infinity - a value that is greater than any other value.-infis therefore smaller than any other value.inf是无穷大 - 大于任何其他值的值。因此-inf小于任何其他值。
nanstands for Not A Number, and this is not equal to0.nan代表 Not A Number,它不等于0。Although positive and negative infinity can be said to be symmetric about
0, the same can be said for any valuen, meaning that the result of adding the two yieldsnan. This idea is discussed in this math.se question. 虽然正无穷大和负无穷大可以说是关于0对称的,但对于任何值n也可以说是对称的,这意味着将两个值相加的结果是nanBecause
nanis (literally) not a number, you can't do arithmetic with it, so the result of the second operation is also not a number (nan) 因为nan(字面上)不是数字,所以不能用它进行算术运算,因此第二个运算的结果也不是数字 (nan)excerpt from stackoverflow.com/questions/1…
样例输出中出现 "inf" 意味着计算过程中发生了数值溢出或者除零操作。在您的样例中,第二组数据是 65536 到 655360。在这个范围内,当 i 的值非常接近 m 的时候,1.0 / (i * i) 的结果会非常非常小,可能会达到计算机双精度浮点数(double)的精度极限。
此外,当 i 增长非常大的时候,i * i 可能会超出一个整型可以表示的最大范围,从而导致整型溢出,这将产生一个负值或者0,当它产生0时,1.0 / (i * i) 操作就会导致无限大的结果,因为不能除以零。
为了解决这个问题,代码需要小心地处理大数。一个可行的解决方案是在迭代过程中使用更大范围的数据类型,如 long long 来防止溢出。 不过在本题中,对于非常大的 i,1/(i*i) 会非常接近于0,对最后结果的影响非常小,甚至可以忽略不计。
这里提供一个修改后的代码示例:
#include <iostream>
#include <iomanip>
#include <cmath>
using namespace std;
// 计算子序列的和
double subsequenceSum(int n, int m) {
double sum = 0.0;
for (long long i = n; i <= m; ++i) {
sum += 1.0 / (i * i);
}
return sum;
}
int main() {
int n, m;
int caseNum = 1;
// 循环读取每一组数据
while (true) {
cin >> n >> m;
if (n == 0 && m == 0) {
break; // 若输入为0 0,则结束输入
}
double result = subsequenceSum(n, m);
if (!isfinite(result)) { // 检查结果是否是有限的
result = 0.0; // 对于非常大的值,它们对总和的贡献可以忽略不计,因此可以将其设置为0
}
cout << fixed << setprecision(5); // 设置输出格式为固定的小数点,并精确到小数点后五位
cout << "Case " << caseNum << ": " << result << endl;
caseNum++;
}
return 0;
}
在这个例子中,我们使用了 long long 类型进行迭代,并在调用 subsequenceSum 后如果结果不是有限的(正无穷或负无穷),将结果重置为0.0。这样可以提高程序的健壮性,确保大数范围内计算的正确性。
为什么加了cmath库
在修改后的代码中引入 cmath 库主要是为了使用 isfinite 函数,它用来检测结果是否为一个有限值。isfinite 函数是在 cmath 库中定义的,它能够确定一个数是正常的数值、无穷大还是NaN(不是一个数字)。
加入对 isfinite 的检查是为了防止输出非法的数值(比如 "inf" 或 "nan"),这在处理很大的数时是一个常见的问题。如果计算出的和为无限大(可能是由于溢出导致的结果),将其替换为0.0可以保证输出结果的合理性和正确性,因为任何超出double精度表示范围的小数部分我们都可以认为它对最终结果的影响是微乎其微的。
该问题在原始代码中的场景是计算一个很大范围内的倒数平方和,对于大的 n 和 m(如 65536 到 655360),会出现非常小的数值,这可能会造成计算上的困难。通过使用 isfinite,我们可以在不产生无限大或非数值结果的情况下,安全地处理所有的输入。
因此,添加 cmath 库并使用 isfinite 函数是为了增加代码对异常值情况的处理能力,确保无论输入多大,都能得到合理的输出结果。
习题2-5 分数化小数(decimal)
输入正整数 a,b,c,输出 的小数形式,精确到小数点后 c 位。。输入包含多组数据,结束标记为 。
样例输入:
1 6 4
0 0 0
样例输出:
Case 1: 0.1667
#include <iostream>
#include <iomanip>
using namespace std;
int main() {
int a, b, c;
int caseNum = 0;
while (true) {
cin >> a >> b >> c;
if (a == 0 && b == 0 && c == 0) break;
caseNum++;
cout << "Case " << caseNum << ": ";
cout << fixed << setprecision(c) << (double)a / b << endl;
}
return 0;
}
解释实现:
- 我们使用了
iostream和iomanip两个header文件。iostream用于输入输出流,iomanip用于格式化输出。 using namespace std;是为了方便使用标准库中的类和函数而不用每次都加std::前缀。- 定义
main()函数,程序开始执行的起点。 - 定义并初始化
a,b,c,caseNum四个变量。前三个变量分别用来读取输入的三个整数,caseNum用于记录和输出当前的案例编号。 - 使用
while(true)循环来持续接收输入直到接收到结束标记(a=b=c=0)。 - 使用
cin读取用户输入的三个整数a,b,c。 - 如果
a,b,c都为0,则使用break;退出循环。 - 案例编号
caseNum自增。 - 使用
cout输出案例编号。 - 使用
cout搭配fixed和setprecision(c)来格式化输出小数。fixed表示使用定点表示法输出浮点数,setprecision(c)控制输出小数点后c位。 - 计算a与b的商并转换为double类型输出,这样可以得到小数形式的结果。
endl用于换行并刷新输出缓冲区。- 当读到三个0时退出循环,程序结束。
习题2-6 排列(permutation)
用 1,2,3,…,9 组成 3 个三位数 abc,def 和 ghi,每个数字恰好使用一次,要求 。按照 abc def ghi 的格式输出所有解,每行一个解。提示:不必太动脑筋。
排序 permutation - 狼之殇 - 博客园 (cnblogs.com)
算法竞赛入门经典ch2_practice6排列abc def ghi 1 2 3 - ssh_alitheia - 博客园 (cnblogs.com)
用1,2,3,…,9组成3个三位数abc,def和ghi,每个数字恰好使用一次_用1,2,3,…,9组成3个三位数abc,def和ghi,每个数字恰好使用一次,要求abc:def:-CSDN博客
下面是一个用于解决这个问题的C语言程序示例:
#include <stdio.h>
int main() {
int a, b, c, d, e, f, g, h, i;
for (a = 1; a <= 3; a++) { // 由于abc:def:ghi=1:2:3,a只能从1到3开始
for (b = 1; b <= 9; b++) {
for (c = 1; c <= 9; c++) {
for (d = 1; d <= 9; d++) {
for (e = 1; e <= 9; e++) {
for (f = 1; f <= 9; f++) {
for (g = 1; g <= 9; g++) {
for (h = 1; h <= 9; h++) {
for (i = 1; i <= 9; i++) {
// 判断abc, def和ghi是否使用了每个数字恰好一次
int sum_digits = (1 << a) | (1 << b) | (1 << c) | (1 << d) |
(1 << e) | (1 << f) | (1 << g) | (1 << h) | (1 << i);
if (sum_digits == (1 << 10) - 2) { // 验证是否使用了1-9的所有数字
// 验证abc:def:ghi=1:2:3的比例关系
int abc = a * 100 + b * 10 + c;
int def = d * 100 + e * 10 + f;
int ghi = g * 100 + h * 10 + i;
if (abc * 2 == def && abc * 3 == ghi) {
printf("%d %d %d\n", abc, def, ghi);
}
}
}
}
}
}
}
}
}
}
}
return 0;
}
解释代码实现:
- 我们创建了9个变量
a,b,c,d,e,f,g,h,i分别代表三个三位数abc,def,ghi的每一位。 - 我们使用了9层嵌套的for循环来遍历数
a到i的所有可能取值(1到9)。 - 对于变量
a,我们只遍历1到3的值,因为abc是最小的三位数,而abc:def:ghi = 1:2:3,所以a不能大于3。 - 我们使用一个整数(
sum_digits)的位操作来检查每个数字(1到9)是否只使用了一次。对于变量a到i,我们将1左移相应的位数,然后进行OR操作,这样,如果所有的数字都被分别使用了一次,则最后的sum_digits将等于1 << 10(也即是1024)减去2(因为我们的循环是从1开始的,我们无需移动0位)。 - 如果
sum_digits的值等于1 << 10减去2,这说明我们使用了每个数字恰好一次,从而满足题目中的条件。 - 之后,我们根据我们的变量
a到i计算abc,def和ghi三个值,并检查它们是否满足abc:def:ghi = 1:2:3的比例关系,即abc * 2 == def和abc * 3 == ghi。 - 如果这些条件都满足,我们就打印出一个解决方案。
- 该程序会输出所有符合条件的解决方案,每行一个解。
请注意,该算法不是最优解,但它简单明了且能输出正确结果。如果想优化性能,可以考虑剪枝掉一些不必要的循环迭代和检查,例如在确定了a之后,可以减少d和g的循环范围。
下面是一些思考题。
题目1。
假设需要输出2,4,6,8,…,2n,每个一行,能不能通过对程序2-1进行小小的改动来实现呢?为了方便,现把程序复制如下:
#include<stdio.h>
int main()
{
int n;
scanf("%d", &n);
for(int i = 1; i <= n; i++)
printf("%d\n", i);
return 0;
}
任务1: 修改第7行,不修改第6行。
printf("%d\n", i*2);
任务2: 修改第6行,不修改第7行。
for(int i = 2; i <= 2*n; i += 2)
题目2。
下面的程序运行结果是什么?!= 运算符表示 “不相等”。提示:请上机实验,不要凭主观感觉回答。
#include<stdio.h>
int main()
{
double i;
for(i = 0; i != 10; i += 0.1)
printf("%.1f\n", i);
return 0;
}
这段程序的运行结果会导致无限循环。在C语言中,浮点数的精度问题可能会导致循环条件 i != 10 永远为真。这是因为浮点数的表示方式是近似的,而不是精确的。在每次循环中,i 的值会被增加0.1,但由于浮点数的精度限制,i 的实际值可能无法精确地等于10.因此,循环条件始终为真,导致无限循环。要解决这个问题,可以使用一个范围来判断 i 是否接近于10,而不是使用 != 运算符。例如,可以使用 fabs(i - 10) < 0.0001 来判断 i 是否接近于10。
下面是修改后的代码,使用范围判断 i 是否接近于10:
#include <stdio.h>
#include <math.h>
int main() {
double i;
for (i = 0; fabs(i - 10) > 0.0001; i += 0.1) {
printf("%.1f\n", i);
}
return 0;
}
在这个修改后的代码中,我们使用了 fabs() 函数来计算 i 与10的差的绝对值,并与一个很小的阈值(0.0001)进行比较。只要 i 的差值小于阈值,循环就会终止。这样可以避免由于浮点数精度问题导致的无限循环。
2.5.2 小结
循环的出现让程序逻辑复杂了许多。在很多情况下,仔细研究程序的执行流程能够很好地帮助理解算法,特别是“当前行”和变量的改变。有些变量是特别值得关注的,如计数器、累加器,以及“当前最小/最大值”这样的中间变量。很多时候,用printf输出一些关键的中间变量能有效地帮助读者了解程序执行过程、发现错误,就像本章中多次使用的一样。
别人的算法理解得再好,遇到问题时还是需要自己分析和设计。本章介绍了“伪代码”这一工具,并建议“不拘一格”地使用。伪代码是为了让思路更清晰,突出主要矛盾,而不是写“八股文”。
在程序慢慢复杂起来时,测试就显得相当重要了。本章后面的几个例题几乎个个都有陷阱:运算结果溢出、运算时间过长等。程序的运行时间并不是无法估计的,有时能用实验的方法猜测时间和规模之间的近似关系(其理论基础将在后面介绍),而海量数据的输入输出问题也可以通过文件得到缓解。尽管不同竞赛在读写方式上的规定不同,熟练掌握了重定向、fopen和条件编译后,各种情况都能轻松应付。
再次强调:编程不是看书看会的,也不是听课听会的,而是练会的。 本章后面的上机编程习题中包含了很多正文中没有提到的内容,对能力的提高很有好处。如有可能,请在上机实践时运用输出中间结果、设计伪代码、计时测试等方法。