数值算法在竞赛中的综合应用
极难2让数值算法变成你的竞赛利器:积累、内积、前缀和、iota 和 bitset 的组合运用
这是干什么的?
在编程竞赛中,你常常需要快速处理大量数字或集合问题。比如:老师想知道全班同学每周零花钱的总和是多少?或者,你和同桌一起做选择题,要统计你们有多少题答案一样?又或者,学校要记录每个同学参加了哪些社团,想知道两个社团有哪些共同成员?这些问题看起来不同,但背后都可以用 STL 里几个“数值算法工具箱”里的工具来解决。
我们之前学过 accumulate、inner_product、partial_sum、iota 和 bitset,它们就像乐高积木,每一块都很小,但组合起来就能搭出强大的程序。今天,我们就通过三个真实的竞赛例题,看看怎么把这些工具用活,让你在考场上又快又准地写出代码。
1. 前缀和与区间查询:像统计零花钱一样简单
生活小故事:小明每个星期都会把零花钱存进小猪存钱罐里。他记录下每天存了多少元(比如周一存 5 元,周二存 3 元,周三存 8 元……)。有一天妈妈问:“从周一到周三你一共存了多少钱?”小明很快就能用“周一 + 周二 + 周三”算出来。但如果妈妈问:“从周二到周五一共存了多少?”他只需要把前五天总数减去前一天的数就行。这个思路就是 前缀和:先算出到每一天为止的总和,然后任意区间的和 = 两个前缀和之差。
竞赛问题:给你一个长度为 N 的整数数组 a,有 Q 次询问,每次问从第 l 个到第 r 个元素的和(下标从 1 开始)。N 和 Q 都可能很大(比如 10 万),每次查询必须 O(1) 回答。
解法思路:用 partial_sum 快速算出前缀和数组 pre,其中 pre[i] 表示前 i 个数的和(pre[0]=0)。那么区间 [l, r] 的和 = pre[r] - pre[l-1]。
C++ 实现(带详细注释)
#include <iostream>
#include <vector>
#include <numeric> // partial_sum
int main() {
int N, Q; // N: 数组长度, Q: 询问次数
std::cin >> N >> Q;
std::vector<long long> a(N); // 用 long long 防止求和溢出
for (int i = 0; i < N; ++i) std::cin >> a[i];
// 创建前缀和数组,大小 N+1,pre[0]=0
std::vector<long long> pre(N + 1, 0);
// 用 partial_sum 把 a 的部分和存入 pre[1] 开始的位置
std::partial_sum(a.begin(), a.end(), pre.begin() + 1);
// 现在 pre[0]=0, pre[1]=a[0], pre[2]=a[0]+a[1], ...
while (Q--) {
int l, r; // 询问的区间(1-based)
std::cin >> l >> r;
long long sum = pre[r] - pre[l - 1]; // 关键公式
std::cout << sum << std::endl;
}
return 0;
}
说明:partial_sum 默认做加法,它会逐个元素累加并输出到目标位置。我们利用 pre.begin()+1 跳过了 pre[0],这样 pre[0] 自然就是 0。如果不习惯,也可以自己写循环,但 partial_sum 更简洁。
Python 实现(带注释)
import itertools
N, Q = map(int, input().split())
a = list(map(int, input().split()))
# 利用 itertools.accumulate 生成前缀和,前面加一个 0
pre = [0] + list(itertools.accumulate(a)) # pre[0]=0, pre[1]=a[0], ...
for _ in range(Q):
l, r = map(int, input().split())
print(pre[r] - pre[l - 1])
Python 的 accumulate 和 C++ 的 partial_sum 功能一样,都返回累加结果。
新手容易犯的错误
- 下标从 0 还是 1? 题目通常用 1-based 下标,但 C++ 数组默认从 0 开始。所以
pre[1]对应a[0],pre[r] - pre[l-1]中的l-1和r都是基于 1 的,容易混淆。建议写成pre[r] - pre[l-1],并确保pre足够大。 - 忘记用 long long:如果数组元素很大(比如 10^9 级别),前缀和可能超过 int 范围,必须用
long long。 partial_sum目标位置写错:std::partial_sum(a.begin(), a.end(), pre.begin()+1),第三个参数是目标迭代器的起始位置,要确保pre的大小至少是a.size()+1。
2. inner_product:快速比较两个序列——像核对答案一样
生活小故事:你和同桌做了一套选择题,每道题有 A、B、C、D 四个选项。你们想快速知道有多少道题答案一样。最直接的办法就是一道一道题看,如果一样就计 1 分。这个“逐位置比较并计数”的过程,就是 内积 的典型应用:对应位置相乘(这里相等为 1,不等为 0),然后求和。
竞赛问题:给定两个长度相同的字符串 s 和 t,只包含大小写字母。问有多少个位置 i 使得 s[i] 和 t[i] 相同(区分大小写)。例如 s="abc",t="adc",输出 2(位置 0 和 2 相同)。
C++ 实现(用 inner_product 自定义操作)
#include <iostream>
#include <string>
#include <numeric> // inner_product
int main() {
std::string s, t;
std::cin >> s >> t;
// inner_product(s.begin(), s.end(), t.begin(), 初始值, 累加操作, 对应位置操作)
int same = std::inner_product(s.begin(), s.end(), t.begin(), 0,
std::plus<int>(), // 累加:把结果加起来
[](char a, char b) { // 对应位置:相等返回1,不等返回0
return a == b ? 1 : 0;
});
std::cout << same << std::endl;
return 0;
}
说明:inner_product 默认是“对应位置相乘再求和”,但我们可以通过后两个参数定制:第一个自定义操作替换“乘法”,第二个自定义操作替换“加法”。这里我们把“乘法”改成“比较并返回 0/1”,把“加法”保留为 std::plus<int>()。Lambda 表达式 [](char a, char b) { return a == b ? 1 : 0; } 就是自定义的对应位置操作。
Python 实现
s = input().strip()
t = input().strip()
# 用 zip 配对,生成器表达式求和
same = sum(1 for a, b in zip(s, t) if a == b)
print(same)
Python 没有内积函数,但 zip 加列表推导式非常直观,性能也不差。
新手容易犯的错误
- 忘记字符串长度可能不同:
inner_product和zip都会以短的那一个为准,但题目一般保证相等,否则要先检查长度。 - 自定义操作返回类型错误:C++ 中
inner_product的第四参数(初始值)类型决定了累加结果的类型,这里初始值0是int,所以std::plus<int>()也是int。如果字符串长度很大可能超过 int,要改成0LL(long long)。 - Lambda 不熟悉:可以换成普通函数或函数对象,但 Lambda 更简洁。记得捕获
[]为空,因为不需要外部变量。
3. bitset:用位来管理集合——就像班级签到表
生活小故事:学校有 1000 个学生,编号从 1 到 1000。每个班级要统计哪些学生参加了数学竞赛。如果给每个班级准备一张纸,上面列出所有学号,打勾表示参加,那这张纸就是一张“位图”。如果两个班级要找出共同参加了竞赛的学生,只需要把两张纸上下对齐,看哪些位置两个勾都打了。这个“对齐比较”操作,计算机里就是 按位与。用 bitset 来做,又快又省内存:每个学生只占 1 位(bit),1000 个学生只需要 125 字节!
竞赛问题:有 N 个班级(N ≤ 1000),每个班级有一些学生参加了数学竞赛,学生编号从 1 到 M(M ≤ 10^5)。现在老师想知道:哪些学生至少参加了一个班级的竞赛?另外,给出两个班级的编号 x 和 y,求这两个班级的参赛学生交集。要求高效处理。
思路:每个班级用一个 bitset 表示,第 i 位为 1 表示学号 i 参加了。那么“至少参加一个班级”就是所有 bitset 的按位或(OR),“两个班级的交集”就是按位与(AND)。C++ 的 std::bitset 在编译期固定大小,这里假设 M=10000 便于演示;如果 M 是变量,可以用 std::vector<bool> 或动态 bitset 库(如 boost::dynamic_bitset),但竞赛中常用固定大小。
C++ 实现(带注释)
#include <iostream>
#include <bitset>
#include <vector>
const int MAXM = 10000; // 假设最大学生编号为 10000
int main() {
int N; // 班级数量
std::cin >> N;
std::vector<std::bitset<MAXM>> classes(N); // 每个班级一个 bitset
for (int i = 0; i < N; ++i) {
int k; // 该班级人数
std::cin >> k;
for (int j = 0; j < k; ++j) {
int stu; // 学生编号
std::cin >> stu;
classes[i].set(stu - 1); // 学生编号从1开始,位索引0对应学生1
}
}
// 所有班级的并集:至少参加一个班级
std::bitset<MAXM> all; // 初始全0
for (const auto& b : classes) {
all |= b; // 按位或
}
std::cout << "至少参加一个班级的学生人数: " << all.count() << std::endl;
// 查询两个班级的交集
int x, y; // 班级编号(1-based)
std::cin >> x >> y;
auto inter = classes[x - 1] & classes[y - 1]; // 按位与
std::cout << "两班交集人数: " << inter.count() << std::endl;
// 还可以枚举交集的学生
std::cout << "交集学生学号: ";
for (size_t i = 0; i < MAXM; ++i) {
if (inter.test(i)) {
std::cout << i + 1 << " ";
}
}
std::cout << std::endl;
return 0;
}
注意:std::bitset 的大小必须是编译期常量。如果 M 是运行时输入的,可以用 std::vector<bool>,但它的位操作(按位与、或)不像 bitset 那么直观,需要手动遍历。另一个常用技巧是:用 unsigned long long 数组模拟大 bitset,但较复杂。
Python 实现(用集合替代,但仅作概念演示)
N = int(input())
classes = []
for _ in range(N):
data = list(map(int, input().split()))
k = data[0]
students = set(data[1:]) # 用 set 存储该班级的学生学号
classes.append(students)
# 所有班级的并集
all_set = set()
for s in classes:
all_set |= s
print("至少参加一个班级的学生人数:", len(all_set))
x, y = map(int, input().split())
inter = classes[x-1] & classes[y-1]
print("两班交集人数:", len(inter))
print("交集学生:", sorted(inter))
Python 的 set 虽然直观,但每个学生占用内存远大于 1 位(约 72 字节),当 M=10^5 时,一个班级的 set 可能占用几 MB。而 C++ 的 bitset<100000> 只占 12.5 KB。这就是 bitset 的威力。
新手容易犯的错误
- bitset 索引从 0 开始:学生编号若从 1 开始,要记得
set(stu-1)。 - 忘记 clear():如果需要重复使用同一个 bitset,要用
.reset()清空,否则上次的数据会残留。 - 位操作优先级:
a & b | c实际上等于(a & b) | c,因为&优先级高于|。建议用括号明确,如(a & b) | c。 - M 太大导致 bitset 声明失败:比如
bitset<1000000>(一百万个位)是允许的,但若 M=10^7,bitset可能超过栈内存(约 1.25MB 栈,可能不够),这时需要动态分配(如vector<bool>)。
4. 综合应用:用 iota 生成编号,用 accumulate 求和,用 bitset 做统计
为了让你看看这些工具如何协同工作,我们设计一个综合小问题:
题目:小明在期末考试中,语、数、英三科的成绩分别存储在三个数组 chinese[100]、math[100]、english[100](每个学生一科成绩)。现在要统计所有学生中总分大于等于 270 分的学生人数,并且输出这些学生的编号(从 1 到 100)。
提示:可以用 iota 生成学生编号数组,用 transform 与 inner_product 或手算总分,再用 accumulate 计数?实际更好的做法是循环,但为了展示组合,我们可以这样:
#include <iostream>
#include <numeric> // iota, accumulate
#include <vector>
#include <bitset>
int main() {
const int N = 100;
std::vector<int> chinese(N), math(N), english(N);
// 假设已输入成绩,这里省略
// 用 iota 生成编号 1..100
std::vector<int> ids(N);
std::iota(ids.begin(), ids.end(), 1); // ids = [1,2,...,100]
// 计算每个学生的总分,存入 total 数组
std::vector<int> total(N);
for (int i = 0; i < N; ++i) {
total[i] = chinese[i] + math[i] + english[i];
}
// 用 bitset 标记总分 >=270 的学生(位索引对应学生编号-1)
std::bitset<100> excellent;
for (int i = 0; i < N; ++i) {
if (total[i] >= 270) {
excellent.set(i); // 注意:位索引 i 对应学生 id = i+1
}
}
std::cout << "优秀学生人数: " << excellent.count() << std::endl;
std::cout << "优秀学生编号: ";
for (size_t i = 0; i < N; ++i) {
if (excellent.test(i)) {
std::cout << i + 1 << " ";
}
}
std::cout << std::endl;
// 还可以用 accumulate 求总分总和(所有学生)
int sum_all = std::accumulate(total.begin(), total.end(), 0);
std::cout << "所有学生总分总和: " << sum_all << std::endl;
return 0;
}
这个例子展示了 iota 生成序列、bitset 做标记、accumulate 求总和。当然,实际竞赛中你可能会用更直接的方法,但理解这些工具的灵活组合会让你思路更开阔。
总结要点和常见错误
| 工具 | 典型用途 | 常见错误 |
|---|---|---|
partial_sum / accumulate | 求前缀和、区间和;求总和 | 忘记前缀和数组大小 N+1;没考虑 int 溢出 |
inner_product | 对应位置比较、计算内积 | 自定义操作返回类型不对;初始值类型不匹配 |
iota | 生成连续的序列(如编号) | 忘记指定起始值;误用于非整数类型 |
bitset | 集合运算、状态压缩 | 索引从 0 开始;大小编译期常量;位操作优先级 |
竞赛实战小贴士
- 用
long long做数值累加:尤其在求和、前缀和中,用long long避免溢出。 - bitset 的大小要足够:如果 M 超过 10^6,考虑用
vector<bool>或手动位运算。 - 优先使用标准算法:
partial_sum、inner_product等比自己写的循环更少 bug,且代码更短。 - 不要滥用函数式风格:如果逻辑复杂(如 inner_product 的 lambda 很长),不如写循环可读性更好。
- C++ 与 Python 对比:C++ 性能高,适合大数据;Python 开发快,适合小数据或原型。在大型竞赛中,C++ 是主流。
相关知识点继续学习
- STL 算法库:除了数值算法,还有
sort、find、copy等,它们与数值算法组合使用能解决很多问题。 vector<bool>特化:它也是一个类似 bitset 的位容器,但接口略有不同。- 大整数与位运算:Python 的整数可以无限位,适合模拟大 bitset;C++ 可以用
unsigned long long数组实现 64 位的 bitset 拼接。
掌握了这些数值算法,你就拥有了一个强大的数学工具箱。下一次面对区间查询、集合运算、序列比较等问题时,不要急着手写循环,先想想工具箱里的工具能不能直接拿来用。快打开你的编译器,动手试试吧!
例题精讲
在C++中,以下关于数值算法与bitset的叙述中,正确的是?
`std::partial_sum`可以用于计算一个整型数组的前缀和,但不能用于计算前缀积,因为其默认操作是加法。
给定一个长度为N的整数数组a,请使用`std::partial_sum`函数计算其前缀和并存储在数组prefix中,要求prefix[0]=a[0],prefix[1]=a[0]+a[1],依此类推。补全代码:`std::partial_sum(a, a+N, ___);`考虑使用`std::bitset`和`std::iota`解决以下问题:需要生成一个长度为16的bitset,其中第i位(从0开始)的值为1当且仅当i是3的倍数。下列哪个代码片段能正确实现?
使用`std::inner_product`计算两个长度为n的整数数组a和b的点积(内积),结果存入变量dot。补全代码:`int dot = std::inner_product(a, a+n, b, ___);`