《从问题到程序》第四章读书笔记
这章主要从6大部分进行讲解的:循环程序设计、循环程序设计中的问题、循环与递归、基本的输入输出、控制结构与控制语句、程序的测试与排错。每一部分都根据具体实例进行讲解。
一、循环程序设计
(1)基本循环方式:采用for循环。例如:假设我们需要求从 0 到 100 每隔 0.2 的各数的平方和:
int n;
double sum, x;
for (sum = 0.0, n = 1; n <= 500; ++n) {
x = 0.2 * n;
sum += x * x;
}
(2)求出一系列完全平方数
1.问题: 写一个程序, 打印出 1 到 200 之间的所有完全平方数,
有两种方法:第一种:
#include <stdio.h>
int main () {
int m, n;
for (n = 1; n <= 200; ++n)
for (m = 1; m * m <= n; ++m)
if (m * m == n)
printf("%d ", n);
printf("\n"); /* 最后换一行 */
}
第二种:采用递推:平方数序列具有如下递推关系:
#include<stdio.h>
#include<stdlib.h>
int main(){
int n=1,i;
for(i=1;n<=200;i++){
printf("%d ",n);
n=2*i+n+1;
}
system("PAUSE");
return 0;
}
(3)判断素数
2.问题:写一个函数,判断一个整数(参数)是否为素数。
int isprime (int n) { /* 判断一个数是否素数 */
int m = 2;
for ( ; m * m <= n; ++m)
if (n % m == 0)
return 0; /* 发现因子,不是素数 */
return 1; /* 可能性均考虑过,没有因子,是素数 */
}
书中问:稍加分析不难看出,只要试到 m*m>n 就够了,继续试下去已经没有意义(这一论断的合理性请读者考虑)。
答:对于每个数n,其实并不需要从2判断到n-1,我们知道,一个数若可以进行因数分解,那么分解时得到的两个数一定是一个小于等于sqrt(n),一个大于等于sqrt(n),据此,上述代码中并不需要遍历到n-1,遍历到sqrt(n)即可,因为若sqrt(n)左侧找不到约数,那么右侧也一定找不到约数。另外一种解法如下:
#include<stdio.h>
#include<math.h>
int main()
{
int m,i,k;
printf("请输入一个整数:");
scanf("%d",&m);
k=(int)sqrt(m);
for(i=2;i<=k;i++)
if(m%i==0)
break;
if(i>k)
printf("%d 是素数。\n",m);
else
printf("%d 不是素数。\n",m);
}
(3)浮点误差:
结论:通过这些试验,我们可以看到多次浮点数运算确实可能带来明显的误差。在解决实际问题时, 运算的次数可能远远多于这个小试验, 累计误差的情况更复杂。 由此可以看到选择适当浮点数的需要。 人们建议,如果没有特殊原因,就应选用 double 类型。
二、循环程序的问题
(1)从循环中退出:采用break语句
break 语句在形式上就是:break;它只能用在循环语句以及在后面将要介绍的 switch 语句里,其作用是使当前的(最内层的,因为循环等可能出现嵌套)循环语句(或 switch 语句)立刻终止,使程序从被终止的循环(或 switch) 语句之后继续执行下去。 break 语句实际上就是为了解决前面提出的一个问题,使人能方便地描述从循环执行中退出的动作。 通常应把 break 语句放在条件语句控制之下,以便在某些条件成立时立即结束循环。
下面是借助 break 语句重写的求立方根函数:
double cbrt (double x) {
double x1, x2 = x;
if (x == 0.0) return 0.0;
while (1) {
x1 = x2;
x2 = (2.0 * x1 + x / (x1 * x1)) / 3.0;
if (fabs((x2 - x1) / x1) < 1E-6) break;
}
return x2;
}
(2)循环中的几种变量
循环中有几种常见的变量,它们在循环中的使用方式非常典型, 了解这些情况有助于对重复计算和循环的分析和思考。这种分类和分析也是人们写循环程序的经验总结。应当注意,这里提出的种类不是绝对的,不同种类之间常常也没有截然的界限。
1. 循环控制变量(简称循环变量)。下面三个例子中的 n 就是典型的循环控制变量:
for (n = 0; n < 10; ++n)
... ...
for (n = 30; n >= 0; --n)
... ...
for (n = 2; n < 52; n += 4)
... ...
2. 累积变量。这类变量在每次循环执行中被更新,其更新常用诸如 += 或 *= 一类运算符, 而循环之前变量的初值常用相应运算符的单位元素(例如, 采用加法更新的变量用0 作为初值;采用乘法更新的变量用 1 作为初值;等等)。循环结束时,累积变量中将积累下来一个最终值,这种最终值常被作为循环计算的最终结果。
3. 递推变量。 例如,在三个递推变量 x1、 x2、 x3 的循环体里,常可以看见下面形式的语句序列:
x1 = x2;
x2 = x3;
x3 = ... x1 ... x2 ...;
我们可以形象地把 x3 看成是“走在前面”的一个,而 x2 和 x1 依次紧随其后,三个变量亦步亦趋地更新。
三、循环与递归
(1)阶乘和乘幂(循环,递归)
采用递归定义方式,定义阶乘函数变成了一件极简单的事情:
long fact (long n) {
return n == 0 ? 1 : n * fact(n-1);
}
再例:写出函数 double dexp(int n),它求出 e(自然对数的底)的 n 次幂*。
递归形式的函数定义:
double dexp1 (int n) {
return n == 0 ? 1 : 2.71828 * dexp1(n-1);
}
double dexp (int n) {
return n >= 0 ? depx1(n) : 1 / dexp1(-n);
}
这个函数同样可以用循环的方式写出。下面是一种定义:
double dexp1 (int n) {
double x = 2.71828, d = 1;
int i;
if (n < 0) {
n = -n;
x = 1/x;
}
for (i = 0; i < n, ++i) d *= x;
return d;
}
当 n 的值小于 0 时,循环中乘起 |n| 个 1/e,仍能得到正确结果。
(2)斐波那契序列
很容易用递归方式给出求Fn的函数,可直接写出下面定义:
long fib (int n) {
return n < 2 ? 1 : fib(n-1) + fib(n-2);
}
可以看出这个定义却有一个本质性的缺陷,启动计算所用的参数值越大,重复计算就出现得越多。
经测试,迭代算法,时间花费更少:
long fib1 (int n) {
long f1 = 1, f2 = 1, f3, i;
if (n <= 1) return 1;
for (f3 = f1 + f2, i = 2; i < n; ++i) {
f1 = f2;
f2 = f3;
f3 = f1 + f2;
}
return f3;
}
总结:要写好一个循环,最重要就是弄清楚“循环中应当维持的什么东西不变”。应当想想在循环中需要维持变量间的什么关系, 才能保证当循环结束时, 各有关变量都能处在所需的状态。 写完循环后还要仔细检查, 看提出的要求是否满足了。寻找完成计算的好算法一直是计算机科学及其应用中一个非常重要的研究领域。
(3)最大公约数
1.递归方式:
long gcd1 (long m, long n) {
return m % n == 0 ? n : gcd1(n, m % n);
}
2.循环方式:
long gcd2 (long m, long n) {
long r;
if (n == 0) return m;
for (r = m % n; r != 0; r = m % n) {
m = n;
n = r;
}
return n;
}
(3)汉诺塔问题
1.递归解法
void moveone (char from, char to) {
printf("%c -> %c\n", from, to);
}
void henoi (int n, char from, char to, char by) {
if (n == 1) moveone(from, to);
else {
henoi(n-1, from, by, to);
moveone(from, to);
henoi(n-1, by, to, from);
}
}
2.书中提问:采用循环结构实现这个程序, 有很多可能方法, 具体情况这里就不介绍了,读者可以设法自己做一做,或者去查找有关资料。 答:
#include <iostream>
using namespace std;
//圆盘的个数最多为64
const int MAX = 64;
//用来表示每根柱子的信息
struct st{
int s[MAX]; //柱子上的圆盘存储情况
int top; //栈顶,用来最上面的圆盘
char name; //柱子的名字,可以是A,B,C中的一个
int Top()//取栈顶元素
{
return s[top];
}
int Pop()//出栈
{
return s[top--];
}
void Push(int x)//入栈
{
s[++top] = x;
}
} ;
long Pow(int x, int y); //计算x^y
void Creat(st ta[], int n); //给结构数组设置初值
void Hannuota(st ta[], long max); //移动汉诺塔的主要函数
int main(void)
{
int n;
cin >> n; //输入圆盘的个数
st ta[3]; //三根柱子的信息用结构数组存储
Creat(ta, n); //给结构数组设置初值
long max = Pow(2, n) - 1;//动的次数应等于2^n - 1
Hannuota(ta, max);//移动汉诺塔的主要函数
system("pause");
return 0;
}
void Creat(st ta[], int n)
{
ta[0].name = 'A';
ta[0].top = n-1;
//把所有的圆盘按从大到小的顺序放在柱子A上
for (int i=0; i<n; i++)
ta[0].s[i] = n - i;
//柱子B,C上开始没有没有圆盘
ta[1].top = ta[2].top = 0;
for (int i=0; i<n; i++)
ta[1].s[i] = ta[2].s[i] = 0;
//若n为偶数,按顺时针方向依次摆放 A B C
if (n%2 == 0)
{
ta[1].name = 'B';
ta[2].name = 'C';
}
else //若n为奇数,按顺时针方向依次摆放 A C B
{
ta[1].name = 'C';
ta[2].name = 'B';
}
}
long Pow(int x, int y)
{
long sum = 1;
for (int i=0; i<y; i++)
sum *= x;
return sum;
}
void Hannuota(st ta[], long max)
{
int k = 0; //累计移动的次数
int i = 0;
int ch;
while (k < max)
{
//按顺时针方向把圆盘1从现在的柱子移动到下一根柱子
ch = ta[i%3].Pop();
ta[(i+1)%3].Push(ch);
cout << ++k << ": " <<"Move disk " << ch << " from " << ta[i%3].name <<" to " << ta[(i+1)%3].name << endl;
i++;
//把另外两根柱子上可以移动的圆盘移动到新的柱子上
if (k < max)
{ //把非空柱子上的圆盘移动到空柱子上,当两根柱子都为空时,移动较小的圆盘
if (ta[(i+1)%3].Top() == 0 ||ta[(i-1)%3].Top() > 0 &&ta[(i+1)%3].Top() > ta[(i-1)%3].Top())
{
ch = ta[(i-1)%3].Pop();
ta[(i+1)%3].Push(ch);
cout << ++k << ": " << "Move disk "<< ch << " from " << ta[(i-1)%3].name<< " to " << ta[(i+1)%3].name << endl;
}
else
{
ch = ta[(i+1)%3].Pop();
ta[(i-1)%3].Push(ch);
cout << ++k << ": " << "Move disk "<< ch << " from " << ta[(i+1)%3].name<< " to " << ta[(i-1)%3].name << endl;
}
}
}
}
四、基本的输入输出
(1)getchar():两个特点:一是它没有参数;二是,当它输入一系列字符时,总要选定一个作为结束判断的字符,标准库定义了一个符号常量EOF。
(2)scanf():
1.例:写程序读入一系列数值,把每个数据作为圆盘半径,分别算出圆盘面积并输出。
#include <stdio.h>
void pc_area (double r) {
if (r < 0)
printf("Input error: %f\n", r);
else
printf ("r = %f, S = %f\n", r, 3.14159265 * r * r);
}
int main () {
double x;
while (scanf("%lf", &x) == 1) pc_area(x);
return 0;
}
注:这里用 scanf 返回值为 1 作为继续循环的条件。 由上面介绍可知, scanf 返回 1 表示它正确地读入了一个数据项, 这里就是一个整数。
2.输入循环
1).通过计数器控制循环
假设在编程时已经知道输入数据的项数, 我们就可以写一个计数循环,用计数器的值达到或者超过限定值控制循环的结束。这是最基本的控制循环的方式。 实际上, 这种循环在编程时已知重复执行的次数, 因此完全可以不用循环, 而是采用重复写出相应的语句的方式实现。 采用循环结构的优势在于用一段较短的描述代替了很长一段繁琐重复的描述
2).用结束标志控制的循环
因此无法使用计数循环。 可以考虑用一种特殊“结束标志” 通知程序所有数据都已输入完。
五、控制结构和控制语句
(1)do-while循环语句
三种循环语句的比较:相对而言,while 和 do-while 循环结构比较简单,while循环用得很多。 另外,C语言的 for 功能更强大,实际覆盖了 while 的功能。 for循环的结构比较复杂,成分较多,但它的设计确实反映了循环的典型特征,在 C 程序里使用非常多。在许多情况下,用 for 结构实现的循环往往比用 while 结构实现的循环更简洁清晰,特别是因为 for 结构把与循环控制有关的部分都集中在语句的头部,有利于人的阅读和理解。
(2)流程控制语句
C语言提供了三个流程控制语句: break 语句, continue 语句和 goto 语句。
1.continue语句。下图是break和continue控制转移的地方:
2.goto语句:使用 goto 的大部分情况是在构造条件执行或者循环。典型的情况例如:
但其实,C程序里真正“需要” 使用 goto 语句的地方很少。这方面唯一合理的例子是需要从多重循环内部直接退出,一个可能的方法是用 goto 语句直接转移的循环语句之后,其大致形式是:
for (......)
for (......) {
....
if (....) goto label;
....
}
label: ....
....
虽然也可以通过加入控制变量处理这种情况(并从而消除 goto 的出现),但那样做产生的程序并不更清晰易懂。 因此,在这里使用 goto 是可取的。要注意,这里只是把 goto 语句作为退出循环的手段,转移的目标就是循环结束位置,随便转移仍是不可取的。
3.开关语句
开关语句(switch 语句)是 C 语言的另一种分支结构,这是一种多分支结构,根据一个整型表达式的值从多个分支中选择执行。开关语句的形式较复杂,其一般形式是:
switch (整型表达式) {
case 整型常量表达式: 语句序列
case 整型常量表达式: 语句序列
.... ....
default: 语句序列
}
开关语句的执行:先求出整型表达式的值,然后用该值与各个 case 表达式的值比较。如果遇到相等的值,程序就从那里执行下去; 如果找不到,而这一开关语句有 default 部分,那么就从“default:”之后执行; 如果没有 default 部分,那么整个开关语句的执行结束。开关语句的一个基本要求是各 case 表达式的值互不相同。
六、程序的测试与排错
所谓测试(testing),就是要在完成一个程序或者程序中的一部分之后, 通过一些试验性的运行,通过仔细检查运行效果,设法确认这个程序确实完成了我们所期望的工作。也可以反过来说: 测试就是设法用一些特别选出的数据去挖掘出程序里的错误,直至无法发现进步的错误为止。 排错(debugging)则是在发现程序有错的情况下,设法确认产生有关错误的根源,而后通过修改程序,排除这些错误的工作过程。
测试中考虑的基本问题就是怎样运行程序,提供它什么样的数据,才可能最大限度地将程序中的缺陷和错误挖掘出来。 通过思考,我们可以看到两类基本的确定测试数据的方式:
- 根据程序本身的结构确定测试数据。 这一做法相当于将程序打开, 看着它的内部考虑如
何去检查它,以便发现其中的问题。这种方式通常称为白箱测试。
- 根据程序所解决的问题本身去确定测试过程和数据数据, 并不考虑程序内部如何解决问
题。这一做法相当于将程序看作一个解决问题的“黑箱”,因此称为黑箱测试。