[算法竞赛入门]第五章_基础题目选解
第2部分 算 法 篇
第5章 基础题目选解
【学习内容相关章节】
5.1字符串 5.2高精度运算 5.3排序与检索
5.4数学基础 5.5训练参考
【学习目标】
(1)学会用常量表简化代码;
(2)学会用状态变量辅助字符串输入;
(3)学会用结构体定义高精度整数,并设计构造函数、复制构造函数和输入输出方法;
(4)学会为结构体定义“小于”运算符,并用它定义其他比较运算符;
(5)熟练掌握冒泡排序和顺序搜索;
(6)熟练掌握用qsort库函数给整数和字符串排序的方法;
(7)熟练掌握小规模素数表的构造方法;
(8)熟练掌握素因子分解的方法;
(9)熟练掌握三角形有向面积的意义和计算方法;
(10)完成一定数量的编程练习。
【学习要求】
掌握字符串的操作,用状态变量辅助字符串输入;掌握高精度整数的计算;掌握有关排序与检索的算法;掌握素数的计算和分解素因子方法;掌握有向三角形的面积的计算方法。
【学习内容提要】
在算法竞赛中,编程能力是非常重要的。算法设计得再好,如果程序写不出来就是零分;即使程序写出来了,也可能会因为细小的错误导致丢失大量的得分。本章通过一定数量和类型的例题和习题熟悉常见的编程技巧,为接下来的算法学习打下坚实的基础。在本章中,程序的正确性是第一位的。
【学习重点、难点】
学习重点:
(1)掌握字符串的操作,用状态变量辅助字符串输入;
(2)掌握高精度整数的计算;
(3)掌握有关排序与检索的算法;
(4)掌握素数的计算和分解素因子方法;
(5)掌握有向三角形的面积的计算方法。
学习难点:
(1)掌握高精度整数的计算;
(2)掌握有关排序与检索的算法;
(3)掌握素数的计算和分解素因子方法。
【课时安排(共5学时)】
5.1字符串 5.2高精度运算 5.3排序与检索
5.4数学基础 5.5训练参考(0.5学时)
字符串
5.1.1 WERTYU
把手放在键盘上时,稍不注意就会往右错一位。 这样的话,Q会变成W,J会变成K等。
输入一个错位敲出的字符串,输出打字员本来想打出的句子。
样例输入:O S,GOMR YPFSU/
样例输出:I AMFINE TODAY.
【分析】
每输入一个字符,都可以直接输出一个字符。但是对输入的字符转换成输出的字符的一种较好的方法是使用常量数组。
完整的程序如下:
#include <stdio.h>
char *s = "`1234567890-=QWERTYUIOP[]\\ASDFGHJKL;'ZXCVBNM,./";
int main() {
int i, c;
while ((c = getchar()) != EOF) {
for (i = 1; s[i] && s[i] != c; i++); /* 空语句 */
if (s[i]) putchar(s[i - 1]);
else putchar(c);
}
return 0;
}
说明:现将getchar函数与EOF总结如下:
(1)对于getchar的两点总结
①getchar是以行为单位进行存取的。
当用getchar进行输入时,如果输入的第一个字符为有效字符(即输入是文件结束符EOF,Windows下为组合键Ctrl+Z,Unix/Linux下为组合键Ctrl+D),那么只有当最后一个输入字符为换行符'\n'(也可以是文件结束符EOF)时,getchar才会停止执行,整个程序将会往下执行。譬如下面程序段:
while((c = getchar()) != EOF){
putchar(c);
}
执行程序,输入:abc,然后回车。则程序就会去执行puchar(c),然后输出abc,这个地方不要忘了,系统输出的还有一个回车。然后可以继续输入,再次遇到换行符的时候,程序又会把那一行的输入的字符输出在终端上。
对于getchar必须读到一个换行符或者文件结束符EOF才进行一次输出。对这个问题的一个解释是,在大师编写C的时候,当时并没有所谓终端输入的概念,所有的输入实际上都是按照文件进行读取的,文件中一般都是以行为单位的。因此,只有遇到换行符,那么程序会认为输入结束,然后采取执行程序的其他部分。同时,输入是按照文件的方式存取的,那么要结束一个文件的输入就需用到EOF (Enf Of File),这也就是为什么getchar结束输入退出时要用EOF的原因。
②getchar()的返回值一般情况下是字符,但也可能是负值,即返回EOF。
getchar函数的返回值类型不是char类型,而是int类型。其原型如下:
int getchar(void);
需要强调的是getchar函数通常返回终端所输入的字符,这些字符系统中对应的ASCII值都是非负的。因此,很多时候,我们会写这样的两行代码:
char c;
c = getchar();
这样就很有可能出现问题。因为getchar函数除了返回终端输入的字符外,在遇到Ctrl+D(Linux下)即文件结束符EOF时,getchar()的返回EOF,这个EOF在函数库里一般定义为-1。因此,在这种情况下,getchar函数返回一个负值,把一个负值赋给一个char型的变量是不正确的。导致这种错误的责任并不是用户,是函数getchar函数误导了使用者。为了能够让所定义的变量能够包含getchar函数返回的所有可能的值,正确的定义方法为:
int c;
c = getchar();
(2)EOF的两点总结(主要指普通终端中的EOF)
①EOF作为文件结束符时的情况
EOF虽然是文件结束符,但并不是在任何情况下输入Ctrl+D(Windows下Ctrl+Z)都能够实现文件结束的功能,只有在下列的条件下,才作为文件结束符。
(a)遇到getcahr函数执行时,要输入第一个字符时就直接输入Ctrl+D,就可以跳出getchar(),去执行程序的其他部分;
(b)在前面输入的字符为换行符时,接着输入Ctrl+D;
(c)在前面有字符输入且不为换行符时,要连着输入两次Ctrl+D,这时第二次输入的Ctrl+D起到文件结束符的功能,至于第一次的Ctrl+D的作用将在下面介绍。
其实,这三种情况都可以总结为只有在getchar()提示新的一次输入时,直接输入Ctrl+D才相当于文件结束符。
②EOF作为行结束符时的情况,这时候输入Ctrl+D并不能结束getchar(),而只能引发getchar()提示下一轮的输入。
这种情况主要是在进行getchar()新的一行输入时,当输入了若干字符(不能包含换行符)之后,直接输入Ctrl+D,此时的Ctrl+D并不是文件结束符,而只是相当于换行符的功能,即结束当前的输入。以上面的代码段为例,如果执行时输入abc,然后Ctrl+D,程序输出结果为:
abcabc
注意:第一组abc为从终端输入的,然后输入Ctrl+D,就输出第二组abc,同时光标停在第二组字符的c后面,然后可以进行新一次的输入。这时如果再次输入Ctrl+D,则起到了文件结束符的作用,结束getchar()。
如果输入abc之后,然后回车,输入换行符的话,则终端显示为:
abc //第一行,带回车
abc //第二行
//第三行
其中第一行为终端输入,第二行为终端输出,光标停在了第三行处,等待新一次的终端输入。
从这里也可以看出Ctrl+D和换行符分别作为行结束符时,输出的不同结果。
EOF的作用也可以总结为:当终端有字符输入时,Ctrl+D产生的EOF相当于结束本行的输入,将引起getchar()新一轮的输入;当终端没有字符输入或者可以说当getchar()读取新的一次输入时,输入Ctrl+D,此时产生的EOF相当于文件结束符,程序将结束getchar()的执行。
5.1.2 TeX括号
在TeX中,左双引号,右双引号"。输入一篇篇包含双引号的文章,你的任务是把它转换成TeX的格式。 样例输入:"To be or not to be,"quoth the Bard, "that is the question". 样例输出:
To be or not to be,"quoth the Bard, ``that is the question''.
【分析】
本题的关键是,如何判断一个双引号是“左”双引号还是“右”双引号。方法很简单,使用一个标志变量即可。
完整的程序如下:
#include <stdio.h>
int main() {
int c, q = 1;
while((c= getchar()) != EOF) {
if(c == '"') { printf("%s", q ? "``" : "''"); q = !q; }
else printf("%c", c);
}
return 0;
}
5.1.3 周期串
如果一个字符串可以由某个长度为k的字符串重复多次得到,我们说该串以为周期。例如,abcabcabcabc以3为周期(注意,它也以6和12为周期)。输入一个长度不超过80的串,输出它的最小周期。
样例输入:HoHoHo
样例输出:2
【分析】
字符串可能会有多个周期。但因为只需求出最小的一个,可以从小到在枚举各个周期,一旦符合条件就立即输出。
下面的程序用到了一个新的语法:临时定义变量,例如,变量i和j只定义在循环体内,因此在循环体后无法访问到它们。
完整的程序如下:
#include <stdio.h>
#include <string.h>
int main() {
char word[100];
scanf("%s", word);
int len = strlen(word);
for (int i = 1; i <= len; i++)
if (len % i == 0) {
int ok = 1;
for (int j = i; j < len; j++)
if (word[j] != word[j % i]) { ok = 0; break; }
if (ok) { printf(“%d\n”, i); break; }
}
return 0;
}
5.2 高精度运算
在介绍C语言时,已经看到了很多整数溢出的情形。如果运算结果真的很大,需要用所谓的高精度算法,用数组来储存整数,模拟手算的方法进行四则运算。
5.2.1 小学生算术
在学习加法是时,发现“进位”特别容易出错。你的任务是计算两个整数在相加时需要多少次进位。你编制的程序应当可以连续处理多组数据,直到读到两个0(这是输入结束标记)。假设输入的整数都不超过9个数字。
样例输入:123 456
555
123 594
0 0
样例输出:0
3
1
【分析】
注意int的上限约是2000000000,可以保存所有9位整数,因此可以用整数来保存输入每次把a和b分别模10就能获取它们的个位数。
完整的程序如下:
#include <stdio.h>
int main() {
int a, b;
while (scanf("%d%d",&a,&b) == 2){
if (!a && !b) return; /* 输入0和0结束 */
int c = 0, ans = 0; /* c储存进位的标志,ans储存进位的次数 */
for (int i = 9; i >= 0; i--) {
c = (a%10 + b%10 + c) > 9 ? 1 : 0;
ans += c;
a /= 10; b /= 10;
}
printf(“%d\n”, ans);
}
return 0;
}
5.2.2 阶乘的精确值
输入不超过1000的正整数n,输出n!=1×2×3×…×n的精确结果。
样例输入:30
样例输出:265252859812191058636308480000000
【分析】
为了保存结果,先分析1000!大约等于4×102567,因此可以用一个3000个元素的数组f保存。让f[0]保存结果的个位,f[1]是十位,f[2]是百位,…,则每次只需要模似手算即可完成n!。在输出时需要忽略前导0。注意,如果结果本身就是0,那么忽略所有前导0后将什么都不输出。所幸n!肯定不等于0,因本题可以忽略这个细节。
完整的程序如下:
#include <stdio.h>
#include <string.h>
const int maxn = 3000;
int f[maxn];
int main() {
int i, j, n;
scanf("%d", &n);
memset(f, 0, sizeof(f));
f[0] = 1;
for(i = 2; i <= n; i++) { /* 乘以i */
int c = 0;
for(j = 0; j < maxn; j++) {
int s = f[j] * i + c;
f[j] = s % 10;
c = s / 10;
}
}
for(j = maxn-1; j >= 0; j--) if(f[j]) break; /* 忽略前导0 */
for(i = j; i >= 0; i--) printf("%d", f[i]);
printf("\n");
return 0;
}
5.2.3 高精度运算类bign
虽然前面的代码能实现高精度运算,但是代码不能重用。如果写一个“高精度函数库”,实现“代码模板”,这样现成、好用的代码在测试中更加方便。
所以,设计一个结构体bign来储存高精度非负整数:
const int max = 1000;
struct bign
{
int len,s[maxn];
bign() { memset(s, 0, sizeof(s)); len = 1; }
};
其中,len表示位数,而s数组就是具体的各个数字。
上面的结构体中有一个函数,称为构造函数(Constructor)。构造函数是C++中特有的,作用是进行初始化。
说明:(1)C++语言对C语言的struct进行了改造,使其也可以像class那样支持成员函数的定义,从而使struct变成真正的抽象数据类型(ADT,Abstract Data Type)。
(2)在C++语言中,如果不特别指明,struct的成员的默认访问说明符为public,而class的成员的默认访问说明符为private。实际上就C++语言来讲,struct和class除了“默认的成员访问说明符”这一点外,没有任何区别。
(3)C++的struct和class差别很小,其实class就是从struct发展出来的。struct定义的结构体在C++中也是一个类,结构体可以有class的任何东西。
现在来重新定义赋值运算(注意,下面的函数要写在bign结构体定义的内部,千万不要写在外面):
bign operator = (const char* num) {
len = strlen(num);
for(int i = 0; i < len; i++)
s[i] = num[len-i-1] - '0';
return *this;
}
说明:每个类实例化一个对象后,就会有一个this指针,指向当前实例本身。this 是由编译器自动产生的,在类的成员函数中有效。this 是一个常量,不允许对其赋值。
可以用x= "1234567898765432123456789"给x赋值,它会也这个字符串转化为“逆序数组+长度”的内部表示法。为了支持x=1234的赋值方式,再定义另外一种赋值运算(定义在结构体内部):
bign operator = (int num) {
char s[maxn];
sprintf(s, "%d", num);
*this = s;
return *this;
}
可以用“bign x; x=100;”来声明一个x并给它赋值,却不能写成“bign x=100;”。原因在于,bign x=100是初始化,而非普通的赋值操作。为了让代码支持“初始化”操作,需要增加两个函数(定义在结构体内部):
bign(int num) { *this = num; }
bign(const char* num) { *this = num; }
下面需要提供一个函数把它转化为字符串:
string str() const {
string res = "";
for(int i = 0; i < len; i++)
res = (char)(s[i] + '0') + res;
if(res == "") res = "0";
return res;
}
说明:任何不会修改数据成员(即函数中的变量)的函数都应该声明为const 类型。如果在编写const 成员函数时,不慎修改了数据成员,或者调用了其它非const 成员函数,编译器将指出错误,这无疑会提高程序的健壮性。
接下来,重新定义<<和>>运算符,让输入输入出流直接支持bign结构体(这两个孙函数要定义在结构体bign的外边,不在写在里面):
istream& operator >> (istream &in, bign& x) {
string s;
in >> s;
x = s.c_str();
return in;
}
ostream& operator << (ostream &out, const bign& x) {
out << x.str();
return out;
}
5.2.4 重载bign的常用运算符
加法写成函数(定义在结构体内部):
bign operator + (const bign& b) const{
bign c;
c.len = 0;
for(int i = 0, g = 0; g || i < max(len, b.len); i++) {
int x = g;
if(i < len) x += s[i];
if(i < b.len) x += b.s[i];
c.s[c.len++] = x % 10;
g = x / 10;
}
return c;
}
两个数中只要任何一个数还有数字,加法就要继续,即使两个数都加完了,也不要忘记处理进位。注意,上面的算法并没有假设s数组中“当前没有用到的数字都是0”。如果事先已经清零,就可以把循环体的前3行简化为int x=s[i]+b.s[i]=g。甚至可以直接把循环次数设置max(len,b.len)+1,然后检查最终结果是否有前导零。如果有,则把len减1。为了让使用简单,可以重新定义+=运算符(定义在结构体内部):
bign operator += (const bign& b) {
*this = *this + b;
return *this;
}
接下来,实现“比较”操作(定义在结构体内部):
bool operator < (const bign& b) const{
if(len != b.len) return len < b.len;
for(int i = len-1; i >= 0; i--)
if(s[i] != b.s[i]) return s[i] < b.s[i];
return false;
}
一开始就比较两个bign的位数,如果不相等则直接返回,否则比较两个数组的逆序的字典序。注意,这样做的前提是两个数都没有前导零,如果不注意的话,很可能出现“运算结果都没有问题,但一比较就错”的情况。
用“小于(<)”符号,就可用它来定义其他所有比较运算符:
bool operator > (const bign& b) const{
return b < *this;
}
bool operator <= (const bign& b) {
return !(b > *this);
}
bool operator >= (const bign& b) const{
return !(*this< b );
}
bool operator != (const bign& b) {
return b < *this) || *this < b;
}
bool operator == (const bign& b) {
return !(b < *this) && !(*this < b);
}
后面如果要用到高精度运算的题目中,将直接使用bign类中的所有运算。
5.3 排序与检索
数据处理是计算机的强项,包括排序、检索和统计等。下面举一些例子,展示排序和检索引的技巧。
5.3.1 6174问题
假设你有一个各位数字互不相同的四位数,把所有数字从到小排序后得到a,从小到大排序后得到b,然后a-b替换原来的数,并且继续操作。例如,从1234出发,依次可以得到4321-1234=3087、8730-378=8352、8532-2358=6174。有趣的是,7641-1467=6174,回到了它自己。
输入一个n位数,输出操作序列,直到出现循环(即新得到的数曾经得到过)。输入保证在循环之前最多只会产生1000个整数。
样例输入:1234
样例输出:1234->3087->8352->6174->6174
【分析】
要解决本问题需要解决下面两个问题:
(1)需要把各个数字排序,因此首先需要把各个数字提取出来。下面的函数使用“冒泡排序”的方法,可以方便地把一个数组按照从大到小的或者从小到大的顺序排序:
int get_next(int x) {
int a, b, n;
char s[10];
//转换成字符串
sprintf(s, "%d", x);
n = strlen(s);
//冒泡排序
for(int i = 0; i < n; i++)
for(int j = i+1; j < n; j++)
if(s[i] > s[j]) {
char t = s[i]; s[i] = s[j]; s[j] = t;
}
sscanf(s, "%d", &b);
//字符串反转
for(int i = 0; i < n/2; i++) {
char t = s[i]; s[i] = s[n-1-i]; s[n-1-i] = t;
}
sscanf(s, "%d", &a);
return a - b;
}
(2)逐个生成各个数,并判断是否曾经生成过。常用的方法是用数组:
int num[2000], count;
int main() {
scanf("%d", &num[0]);
printf("%d", num[0]);
count = 1;
for(;;) {
//生成并输出下一个数
num[count] = get_next(num[count-1]);
printf(" -> %d", num[count]);
//在数组num中寻找新生成的数
int found = 0;
for(int i = 0; i < count; i++)
if(num[i] == num[count]) { found = 1; break; }
//如果找到,则退出循环
if(found) break;
count++;
}
printf("\n");
return 0;
}
5.3.2 字母重排
输入一个字典(用*******结尾),然后再输入若于单词。每输入一个单词w,你需要在字典中找出所有可以用w的字母重排后得到的单词,并按照字典序从小到大的顺序在一行中输出(如果不存在,输出:()。输入单词之间用空格或空行隔开,且所有输入单词都不超过6个小写字母组成。注意,字典中的单词不一定按字典序排列。
样例输入:
tarp given score refused only trap work earn course pepper part
resco nfudre aptr sett oresuc
样例输出:
score
refund
part tarp trap
:(course
【分析】
首先需要把字典读入并保存下来。可用如下方法:
(1)每读入一个单词,就和字典中的所有单词比较,看看是否可以通过重排得到。
(2)把可以重排得到的单词放在一个数组中。
(3)把这个数组排序后输出。
下面简化上面的3个步骤如下:
(1)判断两个单词可以通过重排得到的方法是:把各个字母排序,然后直接比较即可。所以可以在读入时就把每个单词按照字母排好序,就不必每次重排。
(2)不必把重排的单词保存下来再排序,只要在读入字典之后把所有单词排序,就可以每遇到一个满足条件的单词就立刻输出。
完整程序如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int n;
char word[2000][10], sorted[2000][10];
//字符比较函数
int cmp_char(const void* _a, const void* _b) {
char* a = (char*)_a;
char* b = (char*)_b;
return *a - *b;
}
//字符串比较函数
int cmp_string(const void* _a, const void* _b) {
char* a = (char*)_a;
char* b = (char*)_b;
return strcmp(a, b);
}
int main() {
n = 0;
for(;;) {
scanf("%s", word[n]);
if(word[n][0] == '*') break; //遇到结束标志就终止循环
n++;
}
qsort(word, n, sizeof(word[0]), cmp_string); //给所有单词排序
for(int i = 0; i < n; i++) {
strcpy(sorted[i], word[i]);
//给每个单词排序
qsort(sorted[i], strlen(sorted[i]), sizeof(char), cmp_char);
}
char s[10];
while(scanf("%s", s) == 1) { //持续读取到文件结尾
qsort(s, strlen(s), sizeof(char), cmp_char); //给输入单词排序
int found = 0;
for(int i = 0; i < n; i++)
if(strcmp(sorted[i], s) == 0) {
found = 1;
printf("%s ", word[i]); //输出原始单词,而不排序后的
}
if(!found) printf(":(");
printf("\n");
}
return 0;
}
说明:(1)不管把字符串中的各个字符排序还是把所有字符串排序,上面的代码都用到了stdlib.h中的排序函数qsort。使用库函数排序的代码量并不比用冒泡排序法小,但速度却快很多。
(2)下面来说明一下qsort函数的使用。
在实际应用中更多的采用qsort函数进行排序。qsort函数是C/C++语言的库函数(包含在头文件stdlib.h中),采用快速排序算法实现,其效率比插入排序、冒泡排序和简单选择排序的效率都高得多。
qsort函数是在<stdlib.h>头文件中声明的,因此声明qsort函数必须包含这个头文件。qsort函数的原型为:
void qsort(void base,int num,in width,int (compare)(const void *elem1,const void *elem2))
它有四个参数,其含义为:
base:参与排序的元素存储空间的首地址,它是空类型指针。
num:参与排序的元素个数。
width:参与排序的每个元素所占字节数(宽度)。
第四个参数为一个函数指针,这个函数需要用户自己定义,用来实现排序时对元素之间的大小关系进行比较。compare函数的两个能数都是空类型指针,在实现时必须强制转换成参与排序的元素类型的指针。
如果是按从小到大的顺序(即升序)排序,compare函数的返回值的含义为:
第1个参数所指向的元素小于第2个参数所指向的元素,则返回值<0。
第1个参数所指向的元素等于第2个参数所指向的元素,则返回值=0。
第1个参数所指向的元素大于第2个参数所指向的元素,则返回值>0。
如果需要按从大到小的顺序(降序)排序,compare函数的返回值具有相反的含义:当第1个元素大于第2个元素,则返回值<0;当第1个元素小于第2个元素,则返回值<0。
以下分别介绍对于不同数据类型、不同排序要求时qsort函数的使用方法。
①对基本数据类型的数组排序
如果数组元素是int型,且按从小到大的顺序(升序)排序,compare函数可以编写成:
int compare(const void *elem1,const void *elem2)
{
return *(int *)elem1-*(int *)elem2;
}
这样如果在qsort函数实现排序的过程中调用compare函数比较67和89这两个元素,compare函数的返回值为-22,即<0。
如果需要按从大到小的顺序(降序)排序,只需把compare函数中的语句改写成:
return *(int *)elem2-*(int *)elem1;
即可。这样调用compare函数比较67和89这两个元素,compare函数的返回值为22,即>0。
另外,compare函数也可以编写成(按从小到大顺序排序):
int compare(const void *elem1,const void *elem2)
{
return (*(int *)elem1<*(int *)elem2)?-1:(*(int *)elem1>*(int *)elem2)?1:0;
}
compare函数定义好后,就可以用下面的代码段实现一个整型数组的排序:
int num[100];
… //输入100个数组元素的值
qsort(num,100,sizeof(num[0]),compare); //调用qsort函数进行排序
对char、double等其他基本数据类型数组的排序,只需把上述compare函数代码中的int型指针(int *)改成其他类型指针即可。
②对结构体的一级排序
所谓结构体,就是把不同类型的数据组合成一个整体,比如一个学生的数据包括:姓名、年龄、分数。则可按如下方式声明一个student结构体:
struct student
{
char name[20]; //姓名
int age; //年龄
double score; //分数
};
声明好student结构体以后,就可以像用int、char等基本数据类型一样去定义变量、数组了,如下面的例子:
struct student s1; //定义结构体变量
struct student s[10]; //定义结构体数组
struct student ps=&s1; //定义结构体指针,指向s1
其中s1为student类型的变量,它包含了3个成员:name、age和score;s为student类型的数组,它有10个元素,每个元素都包含了3个成员;ps为student类型的指针变量,指向s1。
要引用结构体变量中的成员,需要使用成员运算符“.”,如下面的例子。
s1.age=20; //给s1的age成员变量赋值为20
strcpy(s1.name,"WangLin"); //将字符串"WangLin"拷贝到s1的name成员
如果通过结构体指针变量引用它所指向的结构体变量的成员,需要使用指向运算符“->”,如下面的例子:
ps->age=20; //相当于s1=20;
所谓对结构体一级排序,是指对结构体中的某一个成员的大小关系排序。例如对上述的student数组s中的元素以其age成员的大小关系按从大到小的顺序(升序)排序。Compare函数可定义成:
int compare(const void *elem1,const void *elem2)
{
return ((student *)elem1)->age-((student *)elem2)->ag;
}
qsost函数调用形式为:
qsort(s,10,sizeof(s[0]),compare);
③对结构体二级排序
所谓对构体二级排序,含义是先按某个成员的大小关系排序,如果成员大小相等,再按另一个成员的大小关系进行排序。比如上面的student数组s,可以先按age成员从小到大的顺序排序,如果age成员大小相等,再按score成员从小到大的顺序排序。
int compare(const void *elem1,const void *elem2)
{
student *p1=(student *)elem1;
student *p2=(student *)elem2;
if(p1->age!=p2->age) return p1->age-p2->age;
else return p1->score-p2->score;
}
也就是说,如是两个元素s1和s2的age成员不等,compare返回的是它们age成员的大小关系;如果它们的age成员大小相等,返回的是它们的score成员的大小关系。
qsort函数调用形式为:
qsort(s,10,sizeof(s[0]),compare);
5.4 数 学 基 础
数学是算法的基石,在入门时要重视数学,逐步积累各种技巧。
5.4.1 Cantor的数表
如下列数,第一项是1/1,第二项是1/2,第三项是2/1,第四项是3/1,第五项是2/2,…。输入n,输出第n项。
1/1 1/2 1/3 1/4 1/5
2/1 2/2 2/3 2/4
3/1 3/2 3/3
4/1 4/2
5/1
样例输入:
3
14
7
12345
样例输出:
2/1
2/4
1/4
59/994
【分析】
数表提示需按照斜线分类。第1条斜线有1个数,第2条斜线有2个数,第3条斜线有3个数,…,第i条斜线有i个数。所以,前i条斜线一共有S(k)=1+2+3+…+k=k(k+1)/2个数。
第n项在哪条斜线上呢?只要找到一个最小的正整数k,使得n≤S(k),那么n就是第k条斜线上的倒数第S(k)-n+1个元素(最后一个元素是倒数第1个元素,而不是倒数第0个元素)。所以,第k条斜线的倒数第i个元素是i/(k+1-i)。
完整程序如下:
#include <stdio.h>
int main()
{
int n;
while(scanf("%d", &n) == 1)
{
int k = 1, s = 0;
for(;;)
{
s += k;
if(s >= n) { //所求项是第k条对角线的倒数第s-n+1个元素
printf("%d/%d\n", s-n+1, k-s+n);
break;
}
k++;
}
}
return 0;
}
下面利用代数知识,还可以简化:
注意到总是正数,因此,换句话说,可以直接求出。为了避免浮点数,下面的程序用到了一点小技巧:
#include <stdio.h>
#include <math.h>
int main() {
int n;
while(scanf("%d", &n) == 1) {
int k = (int)floor((sqrt(8.0*n+1)-1)/2-1e-9)+1;
int s = k*(k+1)/2;
printf("%d/%d\n", s-n+1, k-s+n);
}
return 0;
}
5.4.2 因子和阶乘
输入正整数n(2≤n≤100),把阶乘n!=1×2×3×…×n分解成素因子相乘的形式,从小到大输出各个素数(2、3、5、…)的指数。例如825=3×52×11应表示成(0,1,2,0,1),表示分别有0、1、2、0、1个2、3、5、7、11。你的程序应忽略比最大素因子更大的素数(否则末尾会有无穷多个0)。
样例输入:
5
53
样例输出:
5!=3 1 1
53!=49 23 12 8 4 4 3 2 2 1 1 1 1 1 1 1
【分析】
因为am·an=am+n,只需把所有素因子对应的指数累加起来。注意到n≤100,这些素因子不会超过100,输出时忽略到最后的0即可。
完整程序如下:
#include <stdio.h>
#include <string.h>
//素数判定。注意:n不能太大
int is_prime(int n) {
for(int i = 2; i*i <= n; i++)
if(n % i == 0) return 0;
return 1;
}
//素数表
int prime[100], count = 0;
int main() { //n和各个素数的指数
int n, p[100];
//构造素数表
for(int i = 2; i <= 100; i++)
if(is_prime(i)) prime[count++] = i;
while(scanf("%d", &n) == 1) {
printf("%d! =", n);
memset(p, 0, sizeof(p));
int maxp = 0;
for(int i = 1; i <= n; i++) {
//必须把i复制到变量m中,而不要在做除法时直接修改它
int m = i;
for(int j = 0; j < count; j++)
while(m % prime[j] == 0) { //反复除以prime[j],并累加p[j]
m /= prime[j];
p[j]++;
if(j > maxp) maxp = j; //更新最大素因子下标
}
}
//只循环到最大下标
for(int i = 0; i <= maxp; i++)
printf(" %d", p[i]);
printf("\n");
}
return 0;
}
5.4.3 果园里的树
果园里的树排列成矩阵,它们的x和y坐标均是1~99的整数。输入若干个三角形,依次统计每一个三角形内部和边界上共有多少棵树,如图5-1所示。
图5-1 果园里的树
样例输入:
1.5 1.5 1.5 6.8 6.8 1.5
10.7 6.9 8.5 1.5 14.5 1.5
样例输出:
15
17
【分析】
最容易的方法是逐一判断每个点(x,y),是否在三角形内。下面给出一个函数:
double area2(double x0, double y0, double x1, double y1, double x2, double y2){
return x0*y1+x2*y0+x1*y2-x2*y1-x0*y2-x1*y0;
}
它给出了三角形(x0,y0)-(x1,y1)-(x2,y2)的有向面积(signed area)的两倍。什么叫有向面积呢?如图5-3所示。
图5-3 三角形的有向面积
如果△P0P1P2的3个顶点呈逆时针排列,那么有向面积为正;如果是顺时针排列,则有向面积为负;3点共线时,有向面积为零。可以利用下面的行列式来帮助记忆:
下面给出3×3行列式的运算规律:
| a b c |
| d e f |= aei +bfg + cdh – ceg – afh – bdi
| g h i |
如果把矩阵多复制两列,规律就很明显了:
!"#$%&
下面判断是:假设输入三角形为ABC,待判断的点为O,则O在三角形ABC的内部或边界上当且仅当S△ABC=S△OAB+S△OBC+S△OCA。但需注意是:在判断两个浮点数a和b是否相等时,请尽量判断fabs(a-b)是否小于一个事先给定的eps,如1e-9。
说明:虽然海伦公式'(其中半周长p=(a+b+c)/2),也可以计算三角形的面积,但在使用时需要小心浮点误差(在area2函数是只有加减法和乘法,而海伦公式中却有除法和开平方)。所以,尽量避免使用海伦公式。
下面给出完整的程序(效率低):
#include<stdio.h>
#include<math.h>
double area(double x1, double y1, double x2, double y2, double x3, double y3){
return fabs(x1*y3+x2*y1+x3*y2-x2*y3-x3*y1-x1*y2);
}
int main() {
double x1, y1, x2, y2, x3, y3;
while(scanf("%lf%lf%lf%lf%lf%lf", &x1, &y1, &x2, &y2, &x3, &y3) == 6) {
int ans = 0;
for(int x = 1; x <= 99; x++)
for(int y = 1; y <= 99; y++)
if(fabs(area(x1,y1,x2,y2,x,y)+area(x2,y2,x3,y3,x,y)
+area(x3,y3,x1,y1,x,y)-area(x1,y1,x2,y2,x3,y3)) < 1e-8)
ans++;
printf("%d\n", ans);
}
return 0;
}
5.4.4 多少块土地
你有一块椭圆形的土地。你可以在边界上选n个点,并两两连接得到n(n-1)/2条线段。它们最多能把土地分成多少部分?
样例输入:4
样例输出:8
【分析】
通过分析,最优方案是不会让任何3条线段交于一点。下面首先知道欧拉公式:V-E+F=2。其中,V是顶点数(即所有线段的端点数加上交点数),E是边数(即n段椭圆弧加上这引此被线段切成的段数),F是面数(即土地块数加上椭圆外那个无穷大的面)。所以,所求的F=E-V+1。
不管是顶点还是边,计算时都要枚举一条从固定点出发(所以,最后要乘以n)的所有对角线。假设该对角线的左边有i个点,右边有n-2-i个点,则左右两边的点两两搭配后在这条对角线上形成了i(n-2-i)个交点,得到了i(n-2-i)+1条线段。注意,每个交点被重复计算了4次,而每条线段被重复计算了2次,因此在公式中需要有所体现。下面是完整的公式:
)
下面给出完整的程序:
#include<stdio.h>
int main() {
int n;
scanf("%d", &n);
int V = 0, E = 0;
for(int i = 0; i <= n-2; i++){
V += i*(n-2-i);
E += i*(n-2-i)+1;
}
V = V*n/4+n;
E = E*n/2+n;
printf("%d\n", E-V+1);
return 0;
}
5.5 训 练 参 考
编程不会看会的,也不是听会的,而练会的。以后的每章中都有一些的正式编程练习,所以可以根据自己水平去选择相应的习题练习,所以下面给出应该如何去练习。
5.5.1 黑盒测试
算法竞赛一般采取黑盒测试:事先准备好一些测试用例,然后用它们测试选手程序,根据运行结果得分。除了找不到程序(如程序名没有按照比赛规定取,或者放错位置)、编译错等连程序都没有能跑起来的错误之外,一些典型的错误类型如下:
(1)答案错(Wrong Answer,WA)
(2)输出格式错(Presentation Error,PE)。
(3)超时(Time Limit Exceeded,TLE)。
(4)运行错(Runtime Error,RE)。
在一些比较严格的比赛中,输出格式错被看成是答案错,而在另外一些比赛中,则会把二者区分开。在运行中,除了程序自身异常退出(如除0、栈溢出、非法访问内存、断言为假、main函数返回非0值)外,还可能是因为超过了评测系统的资源约束(如内存限制、最大输出限制)而被强制中止执行。有的评测系统会把这些情况和一般的运行错区分开,但在多数情况下会统一到“运行错”中。
需在注意的是,超时不见得是因为程序效率太低,也可能是其他原因造成的。例如,比赛规定程序应从文件读入数据,但程序却在等待键盘输入。其他原因包括:特殊数据导致程序进入死循环、程序实际上已经崩溃却没异常退出。
如果上述错误没有,那程序通过了测试了。在ACM/ICPC中,这意味着程序被裁判接受了(accepted,AC),而在分测试点的比赛中,这意味着拿到了该测试点的分数。
5.5.2 在线评测系统
在线评测系统(Online Judge,OJ)为平时练习和网上竞赛提供了一个很好的平台。下面介绍几个具有代表性的OJ。
(1)西班牙Valladolid大学的UVaOJ
历史最悠久、最著名的OJ是西班牙Valladolid大学的UVaOJ,它的网址是http://uva.
Onlinejudge.org/。除了收录了早期的ACM/ICPC区域比赛题目之外,还经常邀请世界顶尖的命题者共同组织网上比赛,吸引了大量来自世界各地的高手同场竞技。
目前,UVaOJ网站的题库已经包含了一特殊的分卷(Volume)——“AOAPCI”,把习题按照易于查找和提交的方式集中在一起,并将逐步提供题目的中文翻译和算法提示。网上题库可能还增加一些有价值的题目,并移除了一些不太合适的题目,所以在做题时直接参考UVaOJ的AOAPC分卷。
(2)浙江大学的ZOJ
浙江大学的ZOJ的网址是http://acm.zju.edu.cn/。它是国内第一个OJ,也是目前最具影响力的OJ之一。和UVa一样,它也有很多简单题。
(3)北京大学的POJ
北京大学的POJ的网址是http://acm.pku.edu.cn/。它也是目前最具有影响力的OJ之一。它和ZOJ都会收录很多最近的比赛题目,因此题库有一定数量的重叠。尽管如此,ZOJ与POJ和UVa一样,都举办过很多高质量的在线比赛,这些比赛中的很多题目都是在其他地方找不到的。
本 章 小 结
本章主要对ACM/ICPC竞赛中,基础题目进行了选讲。对基础题目进行了分类:字符串题、高精度运算题、排序与检索题、相关的数学题,并进行了详细的讲解,给出了分析过程和源程序,还给出在解决问题的一些小技巧,这对进行在线练习非常有用。
布 置 作 业
可以在UVaOJ上进行在线练习。可以练习9道字符串题、5道高精度运算题、12道与排序、检索有关的题目、17道与数学相关的杂题、9道和数论有关的题目、5道简单的几何计算题。