4.1 自定义函数和结构体
我们已经用过了许多数学函数,如cos、sqrt等。能不能自己写一个呢?没问题。下面就编写一个计算两点欧几里德距离的函数:
double dist(double x1, double y1, double x2, double y2)
{
return sqrt((x1-x2)*(x1-x2)+(y1-y2)*(y1-y2));
}
提示4-1:C语言中的数学函数可以定义成“返回类型 函数名(参数列表) { 函数体 }”,其中函数体的最后一条语句应该是“return 表达式;”。
这里,参数和返回值的类型一般是前面介绍过的“一等公民”,如int或者double,也可以是char。可不可以是数组呢?也不是不可以,但是比较麻烦,稍后再考虑。有时,函数并不需要返回任何值,例如,它只是用printf向屏幕输出一些内容。这时只需定义函数返回类型为void,并且无须使用return(除非希望在函数运行中退出函数)。
提示4-2:函数的参数和返回值最好是“一等公民”,如int、char或者double等。其他“非一等公民”作为参数和返回值要复杂一些。如果函数不需要返回值,则返回类型应写成void。
注意这里的return是一个动作,而不是描述。
提示4-3:如果在执行函数的过程中碰到了return语句,将直接退出这个函数,不去执行后面的语句。相反,如果在执行过程中始终没有return语句,则会返回一个不确定的值。幸好,-Wall可以捕捉到这一可疑情况并产生警告。
顺便说一句,main函数也是有返回值的!到目前为止,我们总是让它返回0,这个0是什么意思呢?尽管没有专门说明,读者应该已经发现了,main函数是整个程序的入口。换句话说,有一个“其他的程序”来调用这个main函数——如操作系统、IDE、调试器,甚至自动评测系统。这个0代表“正常结束”,即返回给调用者。在算法竞赛中,除了有特殊规定之外,请总是让其返回0,以免评测系统错误地认为程序异常退出了。
提示4-4:在算法竞赛中,请总是让main函数返回0。
函数不一定要一步得出结果。下面是上述函数的另一种写法:
double dist(double x1, double y1, double x2, double y2)
{
double dx = x1-x2;
double dy = y1-y2;
return hypot(dx, dy);
}
这里用到了一个新的数学函数——hypot,相信读者能猜到它的意思(1)。这个例子也说明,一个函数也可以调用其他函数——在自定义函数中写代码和在main函数中写代码并没有什么区别,以前讲过的知识都适用。
下面来思考一个问题:这个函数是否好用?通常,x1和y1在语义上属于一个整体(x1,y1),而x2和y2属于另一个整体(x2,y2),代表两个点的坐标。那么能否设计一个函数,其参数是明显的两个点,而不是4个double型的坐标值呢?
struct Point{ double x, y; };
double dist(struct Point a, struct Point b)
{
return hypot(a.x-b.x, a.y-b.y);
}
这里出现了一个新内容。上述代码中定义了一个称为Point的结构体,包含两个域:double型的x和y。
提示4-5:在C语言中,定义结构体的方法为“struct 结构体名称{ 域定义 };”,注意花括号的后面还有一个分号。
这样用起来有些不合习惯:所有用到Point的地方都得写一个struct。有一个方法可以避开这些struct,让结构体用起来和int、double这样的“原生”类型更接近:
typedef struct{ double x, y; }Point;
double dist(Point a, Point b)
{
return hypot(a.x-b.x, a.y-b.y);
}
代码中虽然没少几个字符,但是看上去清爽多了!
提示4-6:为了使用方便,往往用“typedef struct { 域定义; }类型名;”的方式定义一个新类型名。这样,就可以像原生数据类型一样使用这个自定义类型。
计算组合数。编写函数,参数是两个非负整数n和m,返回组合数
,其中m≤n≤25。例如,n=25,m=12时答案为5200300。
【分析】
既然题目中的公式多次出现n!,将其作为一个函数编写是比较合理的:
程序4-1 组合数(有问题)
long long factorial(int n){
long long m = 1;
for(int i = 1; i <= n; i++)
m *= i;
return m;
}
long long C(int n, int m)
{
return factorial(n)/(factorial(m)*factorial(n-m)));
}
由此可见,编写函数并不困难。写完之后的函数可以像cos、sqrt等库函数一样被调用。
“别忘了测试!”如果你这样说,请为自己鼓掌。还记得第2章那个“阶乘”之和的第一个程序吗?那个程序溢出了。那这个程序呢?很不幸:n=21,m=1的返回值竟然是-1。手算不难得到:n=21,m=1的正确结果是21,显然结果不符。
提示4-7:即使最终答案在所选择的数据类型范围之内,计算的中间结果仍然可能溢出。
这个题目还说明:即使认为题目在“暗示”你使用某种语言特性,也应该深入分析,不能贸然行事。如何避免中间结果溢出?办法是进行“约分”。一个简单的方法是利用n!/m!=(m+1)(m+2)…(n-1)n。虽然不能完全避免中间结果溢出,但是对于题目给出的范围已经可以保证得到正确的结果了。代码如下:
程序4-2 组合数
long long C(int n, int m) {
if(m < n-m) m = n-m;
long long ans = 1;
for(int i = m+1; i <= n; i++) ans *= i;
for(int i = 1; i <= n-m; i++) ans /= i;
return ans;
}
上述代码还有一个小技巧:当m<n-m时把m变成n-m。请读者思考这样做的意图。另外,这个函数里笔者改变了参数m的值。这样做并不会影响到函数的调用者,具体原因会在4.2节详细讨论。
提示4-8:对复杂的表达式进行化简有时不仅能减少计算量,还能减少甚至避免中间结果溢出。
素数判定。编写函数,参数是一个正整数n,如果它是素数,返回1,否则返回0。
【分析】
根据定义,被1和它自身整除的、大于1的整数称为素数。这种“判断一个事物是否具有某一性质”的函数还有一个学术名称——谓词(predicate),下面程序中将写一个谓词。
程序4-3 素数判定(有问题)
//n=1或者n太大时请勿调用
int is_prime(int n)
{
for(int i = 2; i*i <= n; i++)
if(n % i == 0) return 0;
return 1;
}
注意这里用到了两个小技巧。一是只判断不超过sqrt(x)的整数i(想一想,为什么)。二是及时退出:一旦发现x有一个大于1的因子,立刻返回0(假),只有最后才返回1(真)。函数名的选取是有章可循的,“is_prime”取自英文“is it a prime?”(它是素数吗?)。
提示4-9:建议把谓词(用来判断某事物是否具有某种特性的函数)命名成“is_xxx”的形式,返回int值,非0表示真,0表示假。
注意程序4-2中is_prime函数上方的注释:不要用在n=1或者n太大时调用。这是为什么呢?n太小时不难解释:n=1会被错误地判断为素数(因为确实没有其他因子)。n太大时的理由则不明显:i*i可能会溢出!如果n是一个接近int的最大值的素数,则当循环到i=46340时,i*i=2147395600<n;但i=46341时,i*i=2147488281,超过了int的最大值,溢出变成负数,仍然满足i*i<n。若n不是太大,可能出现101128442溢出后等于2147483280,终止循环;但如果n= 2147483647,循环将一直进行下去。
提示4-10:编写函数时,应尽量保证该函数能对任何合法参数得到正确的结果。如若不然,应在显著位置标明函数的缺陷,以避免误用。
下面是改进之后的版本:
程序4-4 素数判定(2)
int is_prime(int n)
{
if(n <= 1) return 0;
int m = floor(sqrt(n) + 0.5);
for(int i = 2; i <= m; i++)
if(n % i == 0) return 0;
return 1;
}
除了特判n≤1的情况外,程序中还使用了变量m,一方面避免了每次重复计算sqrt(n),另一方面也通过四舍五入避免了浮点误差——正如前面所说,如果sqrt将某个本应是整数的值变成了xxx.99999,也将被修正,但若直接写m = sqrt(n),“.99999”会被直接截掉。
为什么is_prime的参数不是long long型呢?因为当n很大时,上述函数并不能很快计算出结果。对此,在竞赛篇会有更详细的讨论。