文章目录
- E: 实数相加——大数加法的拓展
- 原题
- 题目描述
- 输入
- 输出
- 样例输入 Copy
- 样例输出 Copy
- 题目思路
- 实现步骤
- 代码和注释
- X: 谍影迷踪——链表的思想
- 原题
- 题目描述
- 输入
- 输出
- 样例输入 Copy
- 样例输出 Copy
- 题目思路
E: 实数相加——大数加法的拓展
原题
题目描述
C语言就要期末考试了,经过一学期的学习,小南学会了分数相加、分数相减、大整数相加,可就是没有尝试过将两个最大达400位的实数相加。在调试过程中,小南遇到了很多问题,一直解决不了。你能帮他编写一个程序实现两个实数相加吗?
输入
多组样例。每行输入一个样例包括两个用空格分开的实数_x_和_y_,其中_x_和_y_是长度不大于400位的非负实数。注意,非负实数中也可能包括非负整数,正整数会省略小数点和后面的0,只包括整数部分。
输出
对于每一行输入,输出两个实数_x_和_y_相加的结果。输出结果中整数部分的前面和小数部分的末尾不输出多余的0;如果是整数,不输出小数点和小数点后面的0。每个输出占一行。
样例输入 Copy
1.1 2.9
1.1111111111 2.3444323343
1 1.1
1.1111 1.1889
样例输出 Copy
4
3.4555434454
2.1
2.3
题目思路
给出的数据明显超过任何一个数据类型,属于大数处理的类型,具体可以b站搜索大数加法、大数阶乘先进行学习,基础的大数加法需要熟练,这样遇到这样的类似的进阶题,思路会更清晰。考过了大数加法,大数阶乘会不会考呢?是有可能的,大数阶乘也是大数加法的拓展。
这题是“实数”的大数处理,也就是有可能有小数点了。我一开始想的是先把小数点去掉,然后按大整数的加法来,原来大整数是右对齐进行计算,现在改为“居中对齐”计算,也就是小数点的位置对齐,然后从右往左计算,想法有,但是很难实现,单是这个“对齐”,已经非常麻烦了。
换个思路,毛主席教导我们:“事物都是一分为二的。”,这一串字符串也不是铁板一块,必然也能一分为二,分成“整数”和“小数”两部分,对这两部分分别使用大数加法。其中,整数部分就是最常见的右对齐(或者是倒序,上次用的是右对齐,这次用一下倒序)加法。小数部分就是左对齐的大数加法。
下面我们就看一下实现的步骤,从中能看到很多需要注意的地方,和我犯错误的一些经验教训。
实现步骤
读入的是一整串数据,首先需要分成整数和小数部分,分界点就是小数点。这里用for循环是可以解决的。
这两部分数据使用char数组存储。在存储整数的char数组内,我们要使用字符的数字进行存储,而非把原始数据减去'0'
,但是小数部分却可以把原始数据减去'0'
进行存储。为什么呢?这就需要先了解大数是怎么存储的。
大数是怎么存储的呢?大数加法一般用的是char数组存储每一个数字。整个数组的长度是大于输入的数字的长度的,也就是说,输入的数字,或者是计算后的数字,一般是填不满数组的,那么,剩下的空位置怎么办,怎么样能不影响计算和数组长度的判断,就需要思考。惯例是使用空字符填充,并且,读入的原始数据,是'0'
、'1'
,这样的字符,一般为了方便计算和初始化,会在存储的时候减去'0'
。这样,字符串首先是计算方便了许多——直接用数组成员相加就行了,不用担心忘记减'0'
,其次,这样不影响计算——0加0还是零。
要回答最开始的问题——整数和小数为何要区别对待,这样说还是不够的。我们还需要了解整数和小数相加方式的不同
整数的相加是常见的题型,具体可以去看b站的视频。由于整数的加法是从个位开始的,也就是右对齐的(想象列竖式的场景),所以需要一种办法,对齐个位数。这里有两条路,一个是右对齐,然后从右加到左,我在期中考试的时候就是这样写的,那时候没有看其他的文章视频,按最直观的办法想到的。其实大数处理最好的办法是另一个办法,就是倒序存储后从左往右做加法,然后从右往左读取结果。这里重点说这个倒序的办法。
而小数的相加,明显是左对齐的,但也是从右往左相加。此时为何区别对待小数和整数的原因已经可以窥见一二,整数由于其右对齐的特性,需要倒序,小数因为是左对齐加法的,而数组存储本来就是左对齐的,就不需要倒序。问题就出在这个倒序上。
我们来看看,如果把整数部分用数字,也就是0, 1, 2 ,3这样的数字(不是'0'
、'1'
、'2'
、'3'
这样的字符,这四个字符在ASCII中代表48,49,50,51四个数字)然后进行反向,进行相加,会怎么样:我们会发现无法分别出整数末尾的0和本来数组中用来占位的0!这并不奇怪,因为这两个0是一样的,在ASCII码中都代表空字符。我就在这里错了一次。
那么,我们还能把整数的字符转成数字吗,答案是肯定的,但是需要在反转的时候同步进行转换。
void reverse(char buffer[], char a[])
{for (int tmp = 0, i = strlen(buffer) - 1; i >= 0; i--) {a[tmp++] = buffer[i] - '0'; // 边倒序,边变化形式}
}
这样做为什么就没关系了呢,其实,关键在反转时候的strlen上,之前那样的做法,不仅是混淆了两种0的,更是无法确定数组的长度——长度是靠第一个出现的空字符确定的,只要在反转之前,我们可以区分这两个0,也就是使用'0'
、'1'
、'2'
、'3'
这样的字符进行存储,我们就能正确读出字符串的长度,此时进行反转,就会在正确的字符串长度的范围内反转。
很抽象,来看看图,假设要计算1300+1400,如果全部用数字存储,就会变成这样:
反转数组的时候,程序使用的strlen也会把长度算成2而不是4:
使用正确的办法:
反转后:
这里把0放在了前面,虽然strlen依然不准,但没有人让我们用strlen了!之后我们只需要从最右边往左相加,然后结果中,从右往左遍历,找到第一个不为0的数开始输出即可。最后的代码中可以看到这个过程。
其实这已经把整个题的思路说完了。剩下的细枝末节,其实只要把核心思路掌握,就是一些对核心思路的运用,比如小数相加中,结果中结尾有很多0,怎么去除?整数的思路是从右往左,从第一个不为0的数开始输出,明显小数不能这样——小数没有倒序呢。但是可以运用相同的思路,从右往左,找到第一个不为0的数,记下它的数组下标,然后输出的时候,只要从头输出到这个位置即可。
还要注意题目中的许多细节,比如也有整数加整数、整数加小数的情况出现,此时要判断是否有小数点。还要判断小数部分全为0的时候,此时不输出小数点和小数部分,诸如此类就不再赘述,可以参看代码和注释。
代码和注释
代码中包含详细的注释,命名也尽量明确意思。
题外话,在此提几个倡议:
- 稍复杂的程序中尽量用一些单词和下划线命名,用一些拼音也行,尽量不要abcd ijk mnopq到底,比较容易混淆
- 多用空格!等号、加号、减号两边、逗号右边、中括号之前等地方加上空格,会让代码舒服非常非常多
当然是细枝末节的问题,代码风格取决于自己的想法。
#include <stdio.h>
#include <string.h>#define MAX_LEN 410void reverse(const char buffer[], char a[]) // 把前一个数组反向后存储到后一个数组中,因为对前一个数组只读,加一个const
{for (int tmp = 0, i = strlen(buffer) - 1; i >= 0; i--) {a[tmp++] = buffer[i] - '0';}
}int main()
{char a[MAX_LEN] = {0}, b[MAX_LEN] = {0};while (~scanf("%s %s", a, b)) {int alen = strlen(a), blen = strlen(b), adot = alen, bdot = blen, up = 0; // 给adot赋值的值是,如果没有小数点,adot的默认值,在这个值下,两个存储小数的循环不会执行,这样小数的数组就全是0,不会影响计算char a_float[MAX_LEN] = {0}, b_float[MAX_LEN] = {0}, a_int[MAX_LEN] = {0}, b_int[MAX_LEN] = {0}, // 这是用来分别存储整数小数部分的,都是正序float_result[MAX_LEN] = {0}, int_result_reverse[MAX_LEN] = {0}, // 这是用来存储算术结果的,小数是左对齐计算,整数是右对齐计算,因此一个是正序,一个是倒序(右对齐计算采取了倒序再相加的策略,因此结果也是倒序)a_int_r[MAX_LEN] = {0}, b_int_r[MAX_LEN] = {0}; // 这是整数部分的倒序,用于上述的倒序计算int is_adot = 0, is_bdot = 0; // 是否具有小数点,用于最后判断要不要输出小数点和小数部分(两者都没有小数部分,即不输出小数点和小数部分)// 两个for循环,分别记录小数点的位置,以一分为二for (int i = 0; i < alen; i++) {if (a[i] == '.') {adot = i;is_adot = 1;break;}}for (int i = 0; i < blen; i++) {if (b[i] == '.') {bdot = i;is_bdot = 1;break;}}// 四个for循环,分别存储a、b数的整数、小数部分for (int i = adot + 1; i < alen; i++) {a_float[i - adot - 1] = a[i] - '0'; // 小数可以减去'0'存储}for (int i = bdot + 1; i < blen; i++) {b_float[i - bdot - 1] = b[i] - '0';}for (int i = 0; i < adot; i++) {a_int[i] = a[i]; // 整数在反转的时候在减去'0'存储}for (int i = 0; i < bdot; i++) {b_int[i] = b[i];}// 因为小数是左对齐相加,因此直接从右往左做大数加法计算即可for (int i = MAX_LEN - 1, tmp; i >= 0; i--) {tmp = a_float[i] + b_float[i] + up;up = tmp / 10; // 进位,用这种形式可以不用if语句判断tmp是否大于等于10,更加简洁明了float_result[i] = tmp % 10;}// 因为整数是右对齐相加,因此需要将两个加数倒序后,从左往右对倒序后的数列做大数加法计算reverse(a_int, a_int_r);reverse(b_int, b_int_r);for (int i = 0, up1 = 0, tmp; i < MAX_LEN; i++) {tmp = a_int_r[i] + b_int_r[i] + up + up1;up = 0;up1 = tmp / 10;int_result_reverse[i] = tmp % 10;}// 倒序标记小数,标出从右到左第一个不为0的值,后续从左往右输出到这个位置即可int float_not_zero_index = 0, is_empty = 1; // is_empty判断小数部分是否为空,以决定要不要输出小数点和小数部分for (int i = MAX_LEN - 1; i >= 0; i--) {if (float_result[i] != 0) {float_not_zero_index = i;is_empty = 0; // 但凡执行到这里,就说明小数部分不为空,不执行到这里,is_empty默认是1,也就是为空break;}}// 倒序输出整数结果,从右往左,从第一个不为0的数开始输出int is_int_empty = 1; // 判断整数部分是否为空,如果为空需要手动输出一个0,原理和上面那个控制变量一样for (int i = MAX_LEN - 1; i >= 0; i--) {if (int_result_reverse[i] != 0) {for (i; i >= 0; i--) {printf("%d", int_result_reverse[i]);is_int_empty = 0;}}}if (is_int_empty) { // 如果整数部分为空,手动输出一个0printf("0");}// 任意一个数有小数点,并且小数部分不为空,输出就要有小数点和小数部分if ((is_adot || is_bdot) && !is_empty) {printf(".");for (int i = 0; i <= float_not_zero_index; i++) {printf("%d", float_result[i]);}}// 统一输出换行符printf("\n");}
}
X: 谍影迷踪——链表的思想
原题
题目描述
2020年10月,国家安全机关组织实施“迅雷-2020”专项行动,破获数百起间谍窃密事件,有效维护了国家安全和利益。在行动中,我方安全机关破获了一个情报组织,他们是单线联系的。我方给情报组织中的每个人员一个唯一的代号。同时,情报人员为了隐秘,可能会有不同的姓名编号(设定最多两个姓名编号)。我方顺藤摸瓜,分别截获并返回组织信息。请你帮忙汇总并恢复这条组织链条。
输入
多组样例。每组样例包括多行输入,第一行包含一个正整数_n_(0<n_≤1000),表示截获到的情报数。接下来的_n_行输入形式为A->B,表示A单向联系B,B是A的下线。A和B表示两名组织人员的信息,包括代号_x_和姓名编号_y,用逗号“,”分开。代号_x_和姓名编号_y_为整数,满足1≤_x_,_y_≤999999。除了第一行,A或B的代号信息在之前的行中出现过。
输出
对于每一组样例,按照顺序在一行中输出该组织的所有成员的信息,用“->”间隔。每个输出占一行。如果有一个代号有两个姓名编号,则认为是一个成员,输出代号和对应的两个姓名编号,用“#”分开。如有两个信息1,2345和1,6666,输出时对应一个成员信息1,2345#6666,两个姓名编号先输入的在前面。
样例输入 Copy
3
1,1234->5,2236
5,2236->3,7177
7,3234->1,1234
4
1,258->10,111111
5,4353->1,36900
7,22->5,4353
10,159->20,220102
2
1,111->2,222
1,101->2,202
样例输出 Copy
7,3234->1,1234->5,2236->3,7177
7,22->5,4353->1,258#36900->10,111111#159->20,220102
1,111#101->2,222#202
题目思路
看到这个题目名字第一时间想到电影谍影重重和碟中谍,还是很喜欢这类片子的。
题目明显是考察链表的运用,这部分知识需要先掌握,可以看各种书,完成一些书后的习题,上b站搜一些视频学习一下,差不多就可以了。
起初没有看到题目中的“除了第一行,A或B的代号信息在之前的行中出现过”,思来想去了很久AB两者都是第一次出现的情况,还好没想出来具体的实现方法,不然写代码的时间恐怕要翻倍了。
我们还是按实现步骤梳理一下。
既然每一次的代号信息都在之前出现过,那么每一行输入就是对链表的增添。那么自然最开始的时候要有一个初始的链表,要有链表,就先要有个结构体,于是在代码开头,我们写一个结构体。
要包含什么数据呢?题目很明确:名字,代号,可能存在的第二代号。仔细一想,我怎么知道有没有第二代号呢?于是加一个新的标识符is_code2,等于1就是有第二代号,等于0就是没有。
顺便说一下,用is开头命名控制变量有个好处,在判断的时候很明确,比如 if (is_code2) ,即可,英文意思很清楚,“如果是有第二代号”嘛。
结构体就是这样,next作为指针指向下一个结构体,构成链表:
struct spy {int name, code1, code2, is_code2;struct person *next;
};
结构体有了,说好了要建一个链表的。具体来说就是用第一行的信息组建一个链表,我们用代码来解释:
struct spy *head;head = malloc(sizeof(struct spy));head->next = malloc(sizeof(struct spy));scanf("%d,%d->%d,%d", &head->name, &head->code1, &head->next->name, &head->next->code1); // 第一次扫,先建立一个初始的链表head->is_code2 = head->next->is_code2 = 0; // 都还没有第二代号,标识符为假head->next->next = NULL;
开始需要malloc一些内存,因为第一行代码只是建立一个指针,指向哪里还不知道,分配完内存后,本质是一种质变,本来无意义的内存变成了“结构体”性质的内存,所以分配完内存后,就可以把head看作指向一个结构体的指针了。
什么时候不需要malloc呢?知道了原理其实不言而喻了,假设已经有一个指针p指向了某个结构体,此时写struct spy *head = p;
,自然是把head也指向那个结构体,显然就不需要再用malloc了。提一句,这个声明变量要看作两部分struct spy *
和head
,表示声明一个变量head,这个变量的类型是“指向某个spy类结构体(具体哪个还不知道,这里的指向只是它的形式,而非内容)的指针”,不要看成struct spy
和*head
,后者容易引起疑惑。
由于输入的第一行给出两个人的信息,所以初始链表也要包含两个人,其形式就是下面这样的:
接下来的任务就是找和插入了——找到后续输入的a(或b),在a的后面(或b的前面)插入一个新的链表即可 。我采用的是单个指针,使用p和p->next两步判断——p判断是不是a,p->next判断是不是b,最终的结果,都是在p的后面增加指针。这引出了许许多多的问题,修修补补了很多地方才正确,肯定是有大佬有优雅得多的办法的。我就浅谈一下我的思路。可以对照后文的代码看。
首先scanf一些数据,我们把第一行之后的每行数据存入临时的变量a_name a_code b_name b_code中,即我们称前一个人为a,后一个为b,接着我们开始遍历链表。
p从左往右移动,每次做两个判断:p指向的人的name(即p->name)等于输入的a_name吗?p->next的name(即p->next->name)等于输入的b_name吗?如果其中一个判断为真,就创建一个新的spy结构体,插入其中(插入的具体过程可以参看b站视频和后文代码,如果p是a,就在p后插入b;如果p->next是b,就在p后插入a),一旦插入成功,就break退出遍历,防止重复修改链表。遍历的过程大概如图所示:
而题目还要求一个第二代号,这就让问题更加复杂,我的想法是在每次对name的判断为真后,接着判断这个位置是否具有第二代号,如果有就写入代号信息。(具体参看后文代码)
这看似很好,其实还是有很多纰漏,即需要考虑到一些特例:
- 如果第一次p的位置是b怎么办?此时p判断的是a,察觉不到它和b相等,而即使p往右移动,也不会有判断head这个位置是不是b_name的过程。
- 如果遇到了两个判断同时为真怎么办?此时虽然会正确地插入,但是如果此时出现了第二代号的问题,就得不到正确结果(也就是第四个输入样例的情况,说实话,要是没有这个输入样例,我用我的这种办法自己可能很难想到这种情况)
- 如果p指向的是最后一个结构体,那么p->next就指向的是NULL空指针,此时访问p->next->name就会出错,怎么避免?
第一个问题,这就需要加一个判断,来判断head指针指向的结构体是否是b,如果是的话,就在链表头部插入a。
第二个问题比较抽象了,就要在判断p为a后,在判断是否有第二代号的时候加入一个判断——即加入p->next->name和b是否相等,且code是否不一样,如果不一样就要写入b的第二代号。
第三个问题比较明确,需要在if语句中使用p->next != NULL && p->next->name == b_name
,由于有短路机制的存在,前者判断为假(即指向空指针),就不会判断后者,自然就不会访问p->next->name
了
核心思想就是这么多。具体还是来看代码和注释吧:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>struct spy {int name, code1, code2, is_code2;struct spy *next;
};int main()
{int n;while (~scanf("%d", &n)) {struct spy *head; // 头指针,它将要指向第一个结构体head = malloc(sizeof(struct spy)); // 分配结构体空间,此时head成为**具体的**指向这个结构体的指针了head->next = malloc(sizeof(struct spy)); // 分配下一个结构体空间(也就是输入中的第二个人)scanf("%d,%d->%d,%d", &head->name, &head->code1, &head->next->name, &head->next->code1); // 第一次读入,先往初始链表中的结构体中写入信息head->is_code2 = head->next->is_code2 = 0; // 因为它们都还没有第二代号,标识符为0(假)head->next->next = NULL; // 第二个人指向NULL(空指针)int a_name, a_code, b_name, b_code; // 声明四个变量,用于存储每次读入的数据for (int i = 1; i < n; i++) { // 由于第一次读过了,只要再读n-1次即可scanf("%d,%d->%d,%d", &a_name, &a_code, &b_name, &b_code); // 存入临时变量for (struct spy *p = head; p != NULL; p = p->next) { // 遍历(扫描、搜索)链表,目的就是插入目标的结构体和其所附带的信息if (head->name == b_name) { // 这是判断head是否指向b,因为按这个思路,只用p判断的话判断不到这种情况,此作为一种特例的补充if (head->code1 != b_code) { // 判断有没有第二个代号head->is_code2 = 1; // 有第二代号,标识符为真head->code2 = b_code; // 写入第二代号}struct spy *to_insert; // 创建一个待插入的结构体的指针to_insert = malloc(sizeof(struct spy)); // 分配内存,和之前一个道理to_insert->name = a_name; // 给结构体写入name信息to_insert->code1 = a_code; // 给结构体写入代号信息// 下面两句是插入的操作to_insert->next = head; // 新插入对象指向之前的第一项head = to_insert; // 现在的head指向新插入对象break; // 及时退出循环,防止修改到其他数据}if (p->name == a_name) { // 这是判断是否要前插(即p是a,即在p->next前插入b)if (p->code1 != a_code) { // 判断并写入第二个代号p->is_code2 = 1;p->code2 = a_code;}if (p->next != NULL && p->next->name == b_name && p->next->code1 != b_code) { // 判断b有没有第二个代号(针对第四个输入样例那样的判断)p->next->is_code2 = 1; // 标识符为真p->next->code2 = b_code;}if (p->next == NULL || (p->next != NULL && p->next->name != b_name)) { // 字面意思,其中前一个 p->next == NULL 是防止它不读最后一个了struct spy *to_insert;to_insert = malloc(sizeof(struct spy));to_insert->name = b_name;to_insert->code1 = b_code;// 插入的操作to_insert->next = p->next; // 新插入对象的next,赋值为原来p的nextp->next = to_insert; // 现在p的next是新插入对象}break;}if (p->next != NULL && p->next->name == b_name) {if (p->next->code1 != b_code) { // 判断有没有第二个代号p->next->is_code2 = 1; // 标识符为真p->next->code2 = b_code;}struct spy *to_insert;to_insert = malloc(sizeof(struct spy));to_insert->name = b_name;to_insert->code1 = b_code;// 插入的操作to_insert->next = p->next; // 新插入对象的next,赋值为原来p的nextp->next = to_insert; // 现在p的next是新插入对象break;}}}int is_first_one = 1; // 是不是输出第一项呢?结合下面的判断,如果是输出第一项,就不打最开始的->,后续都不是第一项,就都打出->for (struct spy *p = head; p != NULL; p = p->next) {if (!is_first_one)printf("->");is_first_one = 0; // 只有第一次循环才不执行上面那句代码,后续因为有了这句,所以都会执行printf("%d,%d", p->name, p->code1);if (p->is_code2) {printf("#%d", p->code2); // 这里令我非常无语,把一个井号看成两个,导致一直0%}}printf("\n");}
}