1. 从一道经典题看多项式加法:不只是A+B那么简单
“A+B for Polynomials”, 这行字对于任何一个刷过PAT(浙江大学计算机程序设计能力考试)甲级、乙级,或者准备过类似编程能力测试的人来说,都再熟悉不过了。它通常以“1002”这样的题号出现,是数据结构与算法入门的一道“门神”。表面上看,题目要求简单到令人发指:给你两个多项式,每个多项式由若干项组成,每项包含一个非零的系数和一个指数,要求你计算这两个多项式的和,并按指数降序输出结果中非零的项。
很多新手,甚至一些有经验的程序员,看到这里可能已经打开了IDE,准备用两个数组或者两个map,把指数映射到系数,然后遍历相加,最后排个序输出。搞定,提交。然后,他们可能会遇到一些意想不到的“坑”:比如,系数相加后恰好为0的项需要被“吞掉”,不输出;比如,输出的格式要求非常严格,指数和系数的小数位数都有规定;再比如,当两个多项式项数很多时,如何高效地合并。
这道题之所以经典,绝不仅仅是因为它考察了基础的输入输出和算术运算。它像一面镜子,清晰地照出了编程者对于数据表示、算法效率和边界处理这三个核心工程能力的理解深度。一个合格的实现,和一个优秀的实现,中间隔着对“合并”操作本质的思考。今天,我们就以这道题为引子,深入聊聊多项式表示与运算背后的那些事,以及如何写出一个既正确又优雅的解法。你会发现,这远不止是“A+B”那么简单。
2. 多项式加法的本质与核心挑战
在动手写代码之前,我们必须先想清楚:我们在操作的对象到底是什么?一个一元多项式,例如3.4x^5 + 2.1x^2 - 1.7,其核心信息是一系列(系数,指数)对。这里的“一系列”意味着顺序不是本质属性(虽然我们通常按指数排序以便阅读),本质是一个从指数到系数的映射关系。
2.1 数据结构的选型:数组、链表还是映射?
这是第一个分水岭。不同的选择直接决定了算法的效率和实现的复杂度。
- 数组(或向量):这是最直观的想法。我们可以声明一个足够大的数组
coef[1001],下标代表指数,值代表系数。对于指数范围明确(例如题目常限定指数为0到1000的非负整数)且范围不大的情况,这种方法极其高效。相加操作就是一次线性遍历:result_coef[i] = a_coef[i] + b_coef[i]。时间复杂度是O(N),其中N是指数范围。它的缺点是空间可能浪费(如果多项式很稀疏,只有少数几项),且无法直接处理指数范围很大或不确定的情况。 - 有序链表:每个节点存储一项(系数,指数,下一节点指针)。输入时即按指数降序插入,保证链表有序。合并两个有序链表,是数据结构课的经典算法,时间复杂度O(M+N),空间复杂度O(1)(如果复用节点)。它天然支持指数范围很大的情况,且空间利用紧凑。缺点是代码实现比数组稍复杂,需要小心处理指针和节点插入/删除。
- 有序映射(如 C++ 的
std::map或std::unordered_map+ 排序):map(红黑树实现)本身按键(指数)有序,自动处理了排序问题。插入和查找是对数时间复杂度。相加过程就是遍历一个map,将项加到另一个map中,如果系数加为零则删除该项。最后遍历输出即可。这种方法代码最简洁,几乎贴近于我们对“映射”这一数学概念的直译,在项数不是特别巨大时表现良好。unordered_map(哈希表)插入查找更快(平均O(1)),但最后需要将结果拷贝到向量中排序输出。
注意:在实际解题(如PAT)中,由于指数范围通常明确(如0~1000),且时间限制宽松,使用数组法往往是代码最短、运行最快、最不易出错的选择。它避免了动态内存管理和复杂数据结构的细节,让你能更专注于处理题目本身的边界条件。这就是“在正确的场景选择最简单的工具”。
2.2 算法核心:合并有序序列
无论采用上述哪种结构,多项式加法的核心算法都是合并两个有序序列。这与合并两个有序数组、两个有序链表的思路完全一致。我们维护两个指针(或迭代器),分别指向两个多项式当前待处理的项(指数最大的项)。
比较两个指针所指项的指数:
- 如果指数相等,则系数相加。若结果不为零,则在结果中新增一项;若结果为零,则两项抵消,两个指针都后移。
- 如果A的指数大于B的指数,则将A的当前项加入结果,A指针后移。
- 如果B的指数大于A的指数,则将B的当前项加入结果,B指针后移。
这个过程一直进行到两个多项式的所有项都处理完毕。这个算法是一次遍历,时间复杂度是线性的O(M+N),是最高效的方式。如果使用数组法,这个“合并”过程就隐含在了逐下标相加的过程中。
2.3 边界与精度:魔鬼在细节中
这是这道题主要的“坑点”,也是区分代码是否健壮的关键。
- 系数为零项的消除:这是题目明确要求的。在数组法中,这意味着在统计结果项数或输出时,要跳过系数为零的位置。在链表或映射法中,意味着在系数相加为零时,要删除该节点或条目。务必注意,两个多项式输入时保证系数非零,但相加后可能产生零,这是一个必须处理的边界条件。
- 输出格式:这是OJ(Online Judge)题目常见的严格之处。通常要求:
- 先输出结果中的非零项个数K。
- 随后输出K行(或在一行内以空格分隔),每行按“指数 系数”的格式。
- 指数必须按降序排列。
- 系数保留小数点后1位(例如用
printf(“%.1f”))。这里必须注意浮点数的精度问题,虽然本题数据通常不会涉及极端精度,但使用double类型并遵循格式化输出是良好习惯。
- 零多项式:这是一个极端但重要的边界情况。如果两个多项式完全抵消,结果为零多项式。此时,输出的项数K应为0。在数组法中,遍历完整个数组都找不到非零系数;在链表/映射法中,结果容器为空。之后通常不需要再输出任何系数指数对(或者有些题目要求输出一个空行)。务必仔细阅读题目输出说明。
3. 三种实现方案的代码级拆解与对比
理解了原理,我们来看代码。我将分别用数组、链表和映射(C++)实现,并分析各自的优劣。假设题目输入格式为:每个多项式第一行是一个整数K,表示该多项式的非零项数,随后K行每行给出一个指数和系数。
3.1 方案一:数组法(静态数组)
这是最推荐在限时编程中使用的方案,尤其是当指数范围已知时。
#include <cstdio> const int MAX_EXP = 1001; // 假设最大指数为1000 double poly[MAX_EXP] = {0}; // 数组初始化,下标为指数,值为系数 int main() { int k, exp; double coef; // 读取第一个多项式 scanf("%d", &k); for (int i = 0; i < k; i++) { scanf("%d %lf", &exp, &coef); poly[exp] += coef; // 直接加到对应位置 } // 读取第二个多项式 scanf("%d", &k); for (int i = 0; i < k; i++) { scanf("%d %lf", &exp, &coef); poly[exp] += coef; // 继续累加 } // 统计非零项个数 int count = 0; for (int i = 0; i < MAX_EXP; i++) { if (poly[i] != 0.0) { // 注意浮点数比较,通常与0比较是安全的 count++; } } // 输出 printf("%d", count); // 注意题目要求降序输出,所以从最高指数向低遍历 for (int i = MAX_EXP - 1; i >= 0; i--) { if (poly[i] != 0.0) { printf(" %d %.1f", i, poly[i]); // 格式:空格分隔,系数1位小数 } } // 如果count为0,这里就只输出了一个0,符合要求 return 0; }为什么这样设计?
- 空间换时间,简化逻辑:我们牺牲了最多1001个
double的空间(约8KB),换来了极致简单的逻辑。相加操作就是简单的数组累加,O(1)复杂度。 - 规避排序:因为数组下标天然有序,输出时从高到低遍历即可,完全不需要排序操作。
- 易于处理零项:统计和输出时,用一个
if判断跳过零系数即可。 - 输入顺序无关:无论输入的多项式项是否有序,都不影响结果正确性。
实测心得:
- 在PAT等OJ上,这种方法的代码行数最少,运行速度最快,几乎不会超时。
- 浮点数比较
poly[i] != 0.0在本题场景下是安全的,因为数据是精确的。但在更一般的数值计算中,判断浮点数是否为零应使用fabs(poly[i]) < 1e-8之类的精度容差。 - 一定要看清指数范围。如果题目说指数是0~1000,那么数组大小至少为1001。如果指数可能为负,则需要进行偏移(例如
poly[exp + 1000]),或者改用其他方法。
3.2 方案二:有序链表法
这种方法更贴近数据结构教学,能锻炼指针操作能力,适用于指数范围未知或很大的情况。
#include <cstdio> #include <algorithm> // 用于sort,如果输入无序则需先排序 struct Node { int exp; double coef; Node* next; Node(int e, double c) : exp(e), coef(c), next(nullptr) {} }; // 向有序(降序)链表中插入一项,如果指数已存在则合并,系数为零则删除 Node* insertOrAdd(Node* head, int exp, double coef) { if (coef == 0.0) return head; // 系数为零直接忽略 Node dummy(-1, 0.0); // 哑节点,简化头节点插入处理 dummy.next = head; Node* prev = &dummy; Node* curr = head; // 寻找插入位置:找到第一个指数小于等于当前指数的节点 while (curr != nullptr && curr->exp > exp) { prev = curr; curr = curr->next; } if (curr != nullptr && curr->exp == exp) { // 指数相同,合并系数 curr->coef += coef; if (curr->coef == 0.0) { // 系数抵消,删除该节点 prev->next = curr->next; delete curr; } } else { // 指数不同,创建新节点插入到prev和curr之间 Node* newNode = new Node(exp, coef); newNode->next = curr; prev->next = newNode; } return dummy.next; // 返回新的头节点 } int main() { Node* resultHead = nullptr; int k, exp; double coef; // 处理两个多项式 for (int polyNum = 0; polyNum < 2; polyNum++) { scanf("%d", &k); for (int i = 0; i < k; i++) { scanf("%d %lf", &exp, &coef); resultHead = insertOrAdd(resultHead, exp, coef); } } // 统计并输出 int count = 0; Node* p = resultHead; while (p != nullptr) { count++; p = p->next; } printf("%d", count); p = resultHead; while (p != nullptr) { printf(" %d %.1f", p->exp, p->coef); p = p->next; } // 释放内存(在实际OJ中可省略,但好习惯) while (resultHead != nullptr) { Node* temp = resultHead; resultHead = resultHead->next; delete temp; } return 0; }为什么这样设计?
- 动态空间:只为非零项分配空间,在多项式非常稀疏时比数组法更省内存。
- 在线处理:
insertOrAdd函数保证了链表始终有序,可以边读入边合并,无需等待所有输入完毕再排序。 - 通用性强:不依赖固定的指数范围。
踩坑点:
- 指针操作易错:特别是处理头节点插入、节点删除时,使用哑节点(dummy node)可以极大简化逻辑,避免对头节点的特殊判断。
- 内存管理:在OJ环境中,程序结束操作系统会回收内存,所以不
delete也可以。但在实际工程或养成好习惯的角度,应该释放。注意,如果题目时间极端苛刻,频繁的new/delete可能成为性能瓶颈。 - 输入有序假设:上面的
insertOrAdd假设每次插入都可能在任意位置。如果题目保证输入的多项式项是按指数降序给出的,那么合并算法可以进一步优化为类似合并有序链表的O(M+N)算法,而无需在插入时查找位置。但通常OJ不保证这一点,所以上述通用插入法更稳妥。
3.3 方案三:映射法(使用 std::map)
利用C++ STL的map,代码可以非常简洁。
#include <cstdio> #include <map> #include <algorithm> // 用于reverse_iterator using namespace std; int main() { map<int, double, greater<int>> polyMap; // greater<int>使map按key降序排列 int k, exp; double coef; for (int polyNum = 0; polyNum < 2; polyNum++) { scanf("%d", &k); for (int i = 0; i < k; i++) { scanf("%d %lf", &exp, &coef); polyMap[exp] += coef; // 如果相加后系数为零,需要删除该项 if (polyMap[exp] == 0.0) { polyMap.erase(exp); } } } // 输出 printf("%d", (int)polyMap.size()); for (auto it = polyMap.begin(); it != polyMap.end(); ++it) { printf(" %d %.1f", it->first, it->second); } return 0; }为什么这样设计?
- 代码极度简洁:
map自动处理了键的排序和唯一性,我们只需要关心系数的累加和归零删除。 - 表达直观:
polyMap[exp] += coef;这行代码几乎就是数学定义的直接翻译。 - 灵活:通过自定义比较器
greater<int>,轻松实现降序输出,无需反转。
性能考量:
- 每次
polyMap[exp]操作(如果exp不存在)会先插入一个默认构造的值(0.0),然后返回引用进行加法。这比数组的直接寻址慢,但代码更清晰。 - 对于项数N,插入和查找的时间复杂度是O(log N)。对于本题规模,完全足够。
- 需要注意,在系数累加为零后,必须手动
erase该项,否则它会作为一个系数为零的项留在map中,影响后续计数和输出。这是使用map时的一个关键细节。
4. 举一反三:多项式运算的扩展与应用
解决了加法,我们很自然地会想到其他运算:减法、乘法、求导、积分,甚至是求值。这些运算的核心,依然离不开我们之前讨论的数据表示和核心算法。
4.1 多项式乘法
乘法比加法复杂。多项式A乘以B,结果是A的每一项与B的每一项相乘(系数相乘,指数相加),然后将所有乘积项合并同类项。
- 数组法实现乘法:如果指数范围有限(如0~1000),两个多项式相乘结果的指数范围会扩大(0~2000)。我们可以用一个两倍大小的结果数组
result_coef[2001]。两层循环遍历两个多项式的非零项,进行乘积累加:result_coef[i + j] += a_coef[i] * b_coef[j]。最后遍历结果数组,输出非零项。时间复杂度O(M*N),对于指数范围K,则是O(K^2)。在K=1000时,百万级操作也是瞬间完成。 - 链表/映射法实现乘法:需要双重循环生成所有乘积项,存入一个临时容器(如
map<int, double>),在插入时合并同类项。最后输出该容器。代码比数组法稍复杂,但原理相同。
4.2 多项式求值与霍纳法则
给定多项式P(x) = a_n*x^n + a_{n-1}*x^{n-1} + ... + a_1*x + a_0和一个值x0,求P(x0)。 最笨的方法是计算每一项a_i * pow(x0, i)然后求和,需要多次计算幂,效率低。霍纳法则(Horner‘s Rule)提供了高效的方法:P(x0) = (...((a_n * x0 + a_{n-1}) * x0 + a_{n-2}) * x0 + ... + a_1) * x0 + a_0。 从最高次项开始,依次乘x0并加上低一次项的系数。只需要n次乘法和n次加法。
// 假设系数存储在数组coef中,coef[i]对应x^i的系数,且已知最高次项为n double horner(double coef[], int n, double x0) { double result = coef[n]; for (int i = n - 1; i >= 0; i--) { result = result * x0 + coef[i]; } return result; }如果多项式是用链表降序存储的,遍历链表执行同样的累加过程即可。
4.3 工程中的应用场景
你以为多项式运算只存在于教科书和编程题中吗?远非如此。
- 计算机图形学:贝塞尔曲线、B样条曲线的参数方程就是多项式(或有理多项式)。曲线的绘制、求交、分割等操作,底层都在进行多项式运算。
- 数值分析:多项式插值(拉格朗日插值、牛顿插值)、多项式拟合(最小二乘法)是逼近复杂函数、进行数据分析的基础工具。
- 密码学与编码理论:某些加密算法和纠错码(如Reed-Solomon码)的运算是在有限域上的多项式环中进行的。
- 符号计算系统:如Mathematica、Maple,其核心功能之一就是进行符号化的多项式运算(因式分解、展开、求最大公因式等)。
所以,熟练掌握多项式的表示和基本运算,是通向这些更高级领域的一块坚实的垫脚石。下次你再看到“A+B for Polynomials”,希望你能意识到,它不是一个简单的加法题,而是一个关于如何优雅、高效地表示和操作结构化数据的经典案例。从数组的暴力美学,到链表的精细操作,再到映射的抽象简洁,不同的实现反映了你对问题不同层面的理解和权衡。这才是编程真正有趣的地方。