这道题我在本科时也写过,后来帮同学调试过几次,每次都能发现一些新坑。一元稀疏多项式的计算几乎是数据结构线性表章节的“标配”上机题,不管哪个学校,只要讲到链表,十有八九会拿它当练习。它表面上只是“读两个多项式,加一下,输出”,但真正动手写的时候才知道,从存储结构选择到指针操作再到边界处理,每一步都能把人卡住。
这篇文章就围绕这道典型的“一元稀疏多项式的计算”上机题,把需求拆解、数据结构设计、核心算法实现、常见调试问题一次性说清楚。内容按照我实际做题和帮人调试的经验来写,适合正在准备数据结构上机考试的同学,也适合自学链表后想找一道综合题练手的开发者。
1. 题目核心与隐藏考点
很多同学拿到这道题的第一反应是:这不就是两个多项式相加吗?开两个数组,指数对号入座,系数加起来就完事了。理论上是这样,但一旦加上“稀疏”两个字,事情就没那么简单了。
1.1 表面需求与真实考点
表面需求非常直接:输入两个多项式,每个多项式由若干“系数 + 指数”的项组成,计算它们的和,按格式输出结果。
真实考点却藏在细节里:
- 空间和时间的权衡。稀疏意味着项数远小于最高次数,如果按最高次数开数组,大量空间被浪费。这道题真正考的是你能不能根据数据特征选择合适的数据结构。
- 指针操作的正确性。链表方案的插入、删除、合并,每一步都有指针修改的顺序问题,顺序错了要么丢节点,要么直接崩溃。
- 边界条件的处理。零多项式、负系数、指数无序输入、没有同类项、多次同类项合并,这些情况全都要覆盖。
- 内存管理的严谨性。上机题如果只判“运行结果正确”,那内存泄漏可能没人管,但如果人工检查代码,每新建一个节点、每次合并后释放旧节点,都是评分点。
所以这道题从来不只是一道“加法题”,它是链表操作、结构设计、边界处理的一次综合检验。
1.2 为什么“稀疏”两个字这么关键
先看看“稀疏”到底是什么意思。假设我们需要表示这个多项式:
[ 3x^{1000} + 5 ]
如果按稠密方式存储,比如用数组下标代表指数,那需要开辟至少1001个元素的空间,但真正有值的只有2个元素,空间利用率0.2%。当最高次数到一万、十万,而项数只有几十项时,数组方案在空间上基本不可接受。
这就是“稀疏”这个限定词存在的意义:它逼你放弃基于数组的稠密表示,转向只存储非零项的压缩表示。
打个比方,稠密数组存储多项式,相当于你把一整年的日历都打印出来,只为记录其中三天有安排;稀疏存储则像只写三张便利贴贴在电脑边,明显后者更合理。
1.3 顺序表与链表的取舍
既然是稀疏表示,那可以用两个数组分别存系数和指数,这也是顺序表方案。但顺序表方案有两个问题:
- 插入和删除需要移动大量元素。合并两个多项式时,结果多项式的长度不确定,频繁在中间位置插入节点,顺序表的时间开销很大。
- 最终结果不确定。你没法提前算出结果多项式有多少项,动态扩容虽然可以做,但需要额外实现一套逻辑。
链表方案的优点恰好对应这些痛点:插入和删除只需要修改指针,不需要移动数据;节点按需申请,天然支持不确定长度;结构上更贴合“每一项一个节点”的压缩表示。
当然,链表不是没有缺点:每个节点存储系数和指数之外还需要存指针,存储密度降低;遍历时只能顺序访问,随机访问能力差。但在多项式相加这个场景里,遍历本身就是算法的主要操作,链表这个缺点几乎不构成制约。
所以结论很明确:用带头结点的单链表,按指数有序存储,是最适合这道题的方案。
2. 数据结构设计与初始化细节
选定了链表方案之后,第一件事就是把结构体定义清楚。这个决定会影响后面所有函数的设计,值得认真对待。
2.1 节点结构体怎么定义最顺手
先看一个非常标准的定义:
typedef struct Term { float coef; // 系数 int expo; // 指数 struct Term *next; // 指向下一项 } Term, *Polynomial;系数用float没问题,但输出要求不统一的话可能出现“1.000000x^2”这种难看的格式,后面在输出函数里再处理。如果要精确匹配某些判题系统,系数有时也用double,看题目具体要求。
指数用int足够覆盖绝大多数测试数据,极少会有题目跑到超出 int 范围的指数。
这里有一个经常被忽略但很关键的点:指针域的定义。很多初学者会用Term *next,编译没问题,但在某些函数里需要“指向指针的指针”时,写起来会比较绕。用上面这种typedef struct Term {...} Term, *Polynomial的写法,之后声明一个链表头就可以写成Polynomial head;,函数的参数直接Polynomial p来传,可读性高不少。
2.2 带头结点还是不带头结点
这个问题几乎在每一道链表题里都要纠结一次。我的建议是:带头结点。
带头结点意味着链表的第一个实际数据节点是head->next,head本身不存储任何有效数据。这样做最大的好处是,对第一个数据节点的操作和其他节点完全统一,不需要写“如果是第一个节点就特殊处理”的逻辑。
以插入为例,如果要在某个节点prev后面插入新节点,代码永远是同一套:
newNode->next = prev->next; prev->next = newNode;不管prev是头结点还是中间节点,都一样。
如果不带头结点,那在头部插入时需要额外更新链表头指针,例如head = newNode;,函数参数就得用二级指针,否则修改在函数外不生效。这平白增加了不少心智负担。
2.3 创建多项式:先排序还是边读边插
初始化一个空链表很简单,确定一下采用升幂还是降幂排列,然后逐个插入节点。
这里的核心问题是:输入的项顺序不一定有序。有的题目输入会明确“按指数递增输入”,这种最省事,直接尾插法创建链表;但也有很多题目不保证输入顺序,甚至可能有多个相同指数的项。遇到这种情况,就产生了两种设计路线:
路线一:先读入所有数据,统一排序,再合并同类项,然后建链表。这种思路清晰,但需要额外写排序和合并逻辑,或者在创建链表时按指数有序插入。
路线二:读取每一项时,按有序插入的方式把它们逐个放进链表,插入过程中如果发现指数相同的项,直接合并系数。这样建链完成的同时,链表已经天然有序且没有重复指数。
第二种路线更符合“链表上机题”的训练目标,因为它把“按指数找到插入位置”这个基础操作练到了。实现如下:
void insertTerm(Polynomial poly, float coef, int expo) { if (coef == 0) return; // 系数为零的项没有存储意义 Term *prev = poly; Term *cur = poly->next; while (cur != NULL && cur->expo < expo) { prev = cur; cur = cur->next; } // 情况1:指数相同的项已经存在,合并系数 if (cur != NULL && cur->expo == expo) { cur->coef += coef; if (cur->coef == 0) { // 合并后系数为0,删除这个节点 prev->next = cur->next; free(cur); } return; } // 情况2:需要插入新节点 Term *newNode = (Term*)malloc(sizeof(Term)); newNode->coef = coef; newNode->expo = expo; newNode->next = cur; prev->next = newNode; }这个函数同时做了“按指数查找位置”“合并同类项”“删除零系数节点”三件事。循环结束的条件一定要想清楚:cur == NULL说明已经到链表末尾,cur->expo >= expo说明找到了应该插入的位置。很多同学写这个循环时忘记判断cur != NULL,结果在最后一个节点后插入时访问了空指针,程序直接崩溃。
3. 核心运算的实现与指针操作细节
链表建好了,接下来是重头戏:两个多项式相加、相乘,以及输出格式化。这三个函数各有各的坑,尤其是相加和输出,几乎每个同学第一次写完都会在某些测试用例上翻车。
3.1 多项式相加:经典三段式逻辑
两个按指数有序排列的链表相加,核心思路类似“归并排序”中的归并过程。用两个指针分别指向两个链表的当前节点,比较它们的指数:
- 指数相等:系数相加,如果结果不为零,作为新节点加入结果链表;同时两个指针都后移。
- 第一个多项式的当前指数较小:把该节点复制到结果链表,指针后移。
- 第二个多项式的当前指数较小:同理。
循环结束后,哪个链表还有剩余节点,就把剩余部分全部接到结果链表后面。
这里需要强调的是“复制节点”而不是“移动节点”。如果你直接把原链表的节点接到结果链表上,那么在后续需要释放原链表时,可能会发生重复释放或悬空指针的问题。最稳妥的做法是遍历原链表的每个节点,用insertTerm或者构造新节点的方式加入结果链表。
完整实现如下:
Polynomial addPolynomial(Polynomial a, Polynomial b) { Polynomial result = createPolynomial(); // 创建一个空结果链表 Term *pa = a->next; Term *pb = b->next; while (pa != NULL && pb != NULL) { if (pa->expo == pb->expo) { float sum = pa->coef + pb->coef; if (sum != 0) { insertTerm(result, sum, pa->expo); } pa = pa->next; pb = pb->next; } else if (pa->expo < pb->expo) { insertTerm(result, pa->coef, pa->expo); pa = pa->next; } else { insertTerm(result, pb->coef, pb->expo); pb = pb->next; } } while (pa != NULL) { insertTerm(result, pa->coef, pa->expo); pa = pa->next; } while (pb != NULL) { insertTerm(result, pb->coef, pb->expo); pb = pb->next; } return result; }看起来很简单,但这里有一个隐藏问题:如果两个多项式都非常长,insertTerm每次都从结果链表头开始扫描,那整体的时间复杂度就不是 O(m+n),而会退化到 O(m × n) 甚至更高。因为结果链表本身也是有序的,append 操作完全可以从尾指针直接处理,不需要从头查找。
所以更高效的做法是用一个尾指针tail记录结果链表的最后一个节点,插入时直接接在末尾。但用insertTerm的好处是代码更简洁,不会出现零系数节点。考虑到上机题的数据量通常不大,用insertTerm写清晰版本完全足够。如果追求极致效率,再改成尾插法。
3.2 多项式相乘:从暴力到可优化
多项式相乘在上机题中属于加分项或选做内容,但既然题目叫“一元稀疏多项式的计算”,最好把乘法也写出来。
核心思路也非常直白:两个多项式相乘,本质上就是“第一个多项式的每一项,乘以第二个多项式的所有项”,然后把所有结果合并同类项。
拿一个具体例子来说,如果:
[ A(x) = x^2 + 1 ]
[ B(x) = x + 2 ]
那 A 的每一项分别乘 B:
x^2乘 B:得到x^3 + 2x^21乘 B:得到x + 2
四项全部加起来,合并后结果是x^3 + 2x^2 + x + 2。
代码结构上,就是两层循环遍历 A 和 B 的每个节点,对每一对组合调用insertTerm插入结果链表:
Polynomial multiplyPolynomial(Polynomial a, Polynomial b) { Polynomial result = createPolynomial(); for (Term *pa = a->next; pa != NULL; pa = pa->next) { for (Term *pb = b->next; pb != NULL; pb = pb->next) { insertTerm(result, pa->coef * pb->coef, pa->expo + pb->expo); } } return result; }两行循环,核心逻辑就完成了。关键在于insertTerm承担了自动合并同类项的任务:如果结果链表里已经有某个指数,系数会累加;如果累加后系数变成零,节点会被删除。这些细节都不需要主调用方操心。
时间复杂度是 O(m × n),m 和 n 分别是两个多项式的项数。这个复杂度在稀疏表示下已经可以接受,因为稀疏多项式项数远小于次数。如果两个多项式都有一百项,一万次相乘和合并操作对计算机来说轻松得很。
3.3 输出格式:最容易丢分的部分
很多人把输出格式当成小事,结果恰恰是这里扣分最多。判题系统对输出格式有严格匹配,多一个空格、多一个符号、少一个负号,都是“答案错误”。
先总结一下输出规则,用几个例子感受:
- 结果多项式是
3x^2 + 2x + 1,输出应写成3x^2+2x+1,中间项的加号紧跟在上一项后面。 - 系数为 1 时,
1x^2应该输出x^2;但常数项不能省略,系数为 1 的输出1。 - 系数为 -1 时,
-1x^2输出-x^2,但常数项 -1 输出-1。 - 第一项如果是正数,不需要输出加号;从第二项开始,正数项前要加
+,负数项前自然带-。 - 指数为 0 时,只输出系数,不输出
x^0。 - 指数为 1 时,输出
x而不是x^1。
把这些规则串成一个输出函数如下:
void printPolynomial(Polynomial poly) { Term *p = poly->next; if (p == NULL) { printf("0\n"); return; } int first = 1; while (p != NULL) { if (p->coef == 0) { p = p->next; continue; } // 处理符号 if (p->coef < 0) { printf("-"); } else if (!first) { printf("+"); } // 取绝对值处理系数输出 float absCoef = p->coef > 0 ? p->coef : -p->coef; // 打印系数部分 if (p->expo == 0) { printf("%.0f", absCoef); } else if (absCoef != 1) { printf("%.0f", absCoef); } // 打印 x 和指数 if (p->expo != 0) { printf("x"); if (p->expo != 1) { printf("^%d", p->expo); } } first = 0; p = p->next; } // 如果所有项都被合并成零了 if (first) { printf("0"); } printf("\n"); }这里用%.0f输出整数系数,但注意:题目如果要求保留小数,比如系数是1.5,用%.0f会输出2,那就错了。更稳妥的做法是判断系数是否为整数,是则%.0f,否则%.1f或按题目要求输出。上机题多项式系数通常给整数,但加法可能出现小数,所以实际写的时候,我都会封装一个printNumber函数统一处理。
还有一个很容易出错的地方:中间项的正号连接。很多人会写成“每一项前面都打印符号”,导致结果变成+3x^2+2x+1,第一项多了加号,判题直接报错。正确思路是“除第一项外,正数项前输出加号;所有负数项前输出减号”。
4. 常见问题与调试经验
链表题目最磨人的不是算法本身,而是各种隐藏的边界情况和指针问题。把我在调试和帮同学看代码过程中遇到的高频问题整理成速查表,方便你对照排查。
4.1 常见问题速查表
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 程序运行时崩溃 | 空指针访问,通常在cur->expo判断前没检查cur != NULL | 检查所有循环中对链表节点指针的使用是否先判空 |
| 结果链表丢项 | 插入新节点时只修改了newNode->next,忘记修改prev->next | 确认插入操作是否完整连接了前后指针 |
| 死循环 | 合并同类项时,指针没有后移,或while循环条件写反 | 检查每个分支中指针的推进代码是否都被执行到 |
| 相同指数没有合并 | 创建多项式时没有比对指数就尾插 | 改用insertTerm方式,插入过程中判断指数是否相等 |
| 结果多出一个系数为 0 的节点 | 相加时没有处理“系数相加为 0”的分支 | 相加时要删除系数为 0 的节点,或直接不插入 |
| 输出格式错误 | 首次项前多了加号、x^1没简写、常数项缺失 | 对照 3.3 节输出规则逐条核对 |
| 内存泄漏 | malloc 的节点没有 free,或释放顺序错误 | 用工具检查,或人工检查每个分支是否释放了所有临时节点 |
4.2 指针画图法与一步一测法
排查链表问题时,自己拿笔画图比盯着代码看效率高得多。我在调试时习惯画三个方框代表前驱节点、当前节点和下一节点,用箭头模拟指针移动,再把代码里每一步改指针的操作和图画对照,基本上能在三分钟内找到逻辑错误。
另外一个小技巧是“一步一测法”:在insertTerm的循环里临时加打印语句,输出当前prev->expo和cur->expo,看看指针移动是不是按照预期执行。找到问题后把打印语句删掉即可。这种临时调试输出比直接上调试器更快,尤其适合链表这类逻辑直观但步骤繁琐的操作。
4.3 必测的边界测试用例
写完代码先别急着提交,把这几个用例跑一遍,能避开九成的坑:
| 用例 | 输入 | 期望输出 |
|---|---|---|
| 空多项式 | 第一个多项式为空,第二个为x | x |
| 全抵消 | x+1和-x-1 | 0 |
| 常数项合并 | 3和5 | 8 |
| 系数为 1 和 -1 | x^2+1 | 输出x^2+1而不是1x^2+1 |
| 输出没有多余空格 | 多项式中不应出现空格 | 例如x^2+2x+1 |
| 指数乱序输入 | 输入x^5+3x^2+2x^5 | 结果应合并为3x^5+3x^2 |
| 负指数(如有) | -2x^3+x | 输出-2x^3+x |
跑一遍这个表,基本可以把输出格式和边界条件都测到。
4.4 代码风格与模块化建议
上机题最怕遇到“能跑但是结构混乱”的代码。评卷时如果人工看代码,函数职责单一、命名清晰都会加分。我的建议是把每个功能独立成函数:创建链表、插入项、多项式相加、多项式相乘、输出多项式、释放链表。主函数只负责读入数据和调用这些函数。
释放链表这个函数也要写,有些人忽略它导致内存越用越多。思路是从头结点开始逐个释放:
void freePolynomial(Polynomial poly) { Term *cur = poly; while (cur != NULL) { Term *next = cur->next; free(cur); cur = next; } }这里注意释放顺序:先用临时变量保存next,再释放当前节点,否则释放完当前节点后没法拿到下一个节点的地址。
5. 扩展思考与个人经验
这道题做完之后,还有几个值得深入想一想的点,把它们想清楚,你的收获会比“做出题目”大很多。
第一个是“为什么这个题目适合上机考核”。它看起来只是链表操作,但任何一个环节的疏忽都会导致结果错误或程序崩溃。数据结构课程选这道题作为作业或考试题,就是为了同时检验你对结构设计、算法实现和代码规范的综合能力。从空间利用率的分析到有序链表的维护,再到指针的精细操作,覆盖面很完整。
第二个是可以延伸的方向。比如,如果题目进一步要求“计算多项式的导数”,只需要遍历链表对每项执行coef * expo和expo - 1的变换,然后删除系数为 0 的节点,逻辑比加法还简单。再比如“多项式求值”,可以从头到尾遍历一遍,利用秦九韶算法或直接逐项累加都可以,代码核心不超过十行。这些延伸题目本质上都在吃透链表操作的基础上做变化,根基就是本题。
第三个是使用其它语言实现时的思路对照。如果用 Python,很多人会直接用列表存(coef, expo)二元组,然后用字典按指数合并,代码短很多。但要注意,Python 这种“几行搞定”的方式隐藏了大量细节,比如排序、合并、去零等操作,如果你是为了面试而学数据结构,还是建议在 C 语言里把链表操作完整写一遍。C 语言里你亲手管理每个节点的生命周期,对这个数据结构的理解深度是完全不同的。
最后,分享一个小建议。如果你刚开始写这道题,不需要一上来就追求最优实现。我第一次写的时候,先用最简单的思路:把两个多项式做成两个数组,暴力合并,再转成链表输出。虽然绕了一点,但至少把“输入输出”这一关先过了。然后,再重写成真正的链表方案。这种“先跑通、再优化”的步骤,能帮你把每一层的问题拆开,不至于一次面对太多难点而崩溃。
我个人在实际操作中的体会是,链表题出错不可怕,可怕的是出错后没有系统性的排查思路。你只要掌握前面提到的画图法和边界用例法,绝大多数问题都能在十分钟内定位。把一元稀疏多项式这道题啃下来,后面遇到二叉树、图的邻接表等更复杂的链式结构,心态会稳很多。这个基础打牢了,不亏。