
1. 从一道经典题看算法思维的本质珠心算测验这道题在算法练习平台上是很多初学者绕不过去的一道坎。题目本身描述很简洁给定一组正整数统计其中有多少个数恰好等于另外两个不同数之和。看起来像是小学数学题但真正动手写代码时你会发现它考察的远不只是算术能力而是对枚举策略、数据规模预估、边界条件处理、以及空间换时间思想的综合理解。我第一次接触这道题的时候第一反应就是三重循环暴力枚举——外层遍历每个数作为目标值内层两层遍历找两个不同的数看能不能凑出目标值。写出来不到二十行提交上去直接超时。后来才意识到题目给出的数据范围虽然不算大但三重循环的时间复杂度在某些测试点上依然会卡住。这道题真正想教给你的是如何用标记数组把O(n³)的暴力枚举优化到O(n²)甚至用布尔数组直接做到近似O(n²)的判定效率。这篇文章适合两类人看一类是刚学C语言、正在刷基础算法题的朋友另一类是想重新梳理枚举与优化思路的开发者。我会从题目理解、思路演进、代码实现、常见坑点、性能对比几个维度把这道题彻底拆开讲清楚。你跟着走一遍不仅能AC这道题还能把类似的“两数之和”“三数之和”类问题的解题框架一并掌握。2. 题目核心需求与数据规模分析2.1 题目到底在问什么先把题意翻译成人话。你拿到一个正整数集合集合里的数互不相同题目通常保证这一点但实际编码时最好自己确认。你需要找出有多少个数满足“它等于集合中另外两个不同数之和”这个条件。注意关键词另外两个不同的数。这意味着你不能用同一个数加自己比如集合里有4你不能说422然后拿2用两次因为2只有一个。同时如果同一个和能被多组不同的数对凑出来比如1037也46那10只算一次不能重复计数。这个“只算一次”的约束是很多人第一次写会忽略的地方。如果你用三重循环找到一个符合条件的就计数器加一然后break那没问题。但如果你用标记数组就要注意标记的是“这个数能否被凑出来”而不是“有多少种凑法”。2.2 数据规模决定了算法选择虽然不同平台的测试数据略有差异但这类题通常给出的n在100以内数值范围在10000以内。这个规模很有意思n²大概是10⁴级别n³是10⁶级别。单纯从运算次数看10⁶在C语言里其实跑得飞快但问题在于三重循环里每次都要做比较和判断实际常数不小再加上如果n稍微大一点比如到200n³就是8×10⁶再加上循环嵌套的分支预测失败实际耗时可能就上去了。更重要的是这道题的教学意义在于让你养成“看到‘两数之和’就想到标记数组”的条件反射。这个思维模式在后续更复杂的题目里会反复出现比如“四数之和”“子数组求和”等等。所以哪怕暴力能过我也建议你认真把优化解法吃透。2.3 输入输出的格式要求输入通常分两行第一行是一个整数n表示集合大小第二行是n个用空格分隔的正整数。输出只有一个整数即满足条件的数的个数。这里有个小细节输入的数不一定是排序的所以你如果要用双指针之类的技巧得先排序。但排序本身有O(n log n)的开销对于这道题来说标记数组法不需要排序反而更简洁。注意有些平台的题目会明确说“互不相同的正整数”但有些不会。保险起见你可以在读入之后自己检查一下是否有重复或者直接用集合去重。不过对于这道题的标准版本重复的情况通常不会出现不用过度设计。3. 从暴力枚举到标记数组的思路演进3.1 暴力三重循环能跑但不够优雅最直观的写法是这样的外层循环遍历每个数作为候选和内层两层循环遍历所有数对看是否存在两个不同的数加起来等于候选和。伪代码大概长这样for (int i 0; i n; i) { for (int j 0; j n; j) { for (int k j 1; k n; k) { if (a[j] a[k] a[i]) { count; goto next_i; // 找到一个就够了 } } } next_i:; }这段代码逻辑上没问题但有几个隐患。第一goto虽然在这里能用但风格上不推荐容易让代码难以维护。第二时间复杂度是O(n³)当n100时是10⁶次内层判断勉强能过但如果n500就是1.25×10⁸大概率超时。第三每次都要重复计算a[j]a[k]没有利用任何缓存或预处理。我实测过在n100、数值范围10000的情况下三重循环在普通机器上大概跑几十毫秒确实能过。但如果你把n调到300时间就飙到几百毫秒甚至超过一秒。所以这道题如果只追求AC暴力法可以但如果想真正提升必须学会优化。3.2 标记数组法空间换时间的经典实践优化的核心思路是我不需要每次都重新算两数之和而是可以预先把所有可能的和标记出来。具体做法是开一个布尔数组exist[]大小取决于数值的最大范围。先遍历一遍输入数组把每个出现过的数在exist[]里标记为true。然后再用双重循环枚举所有数对(i, j)把它们的和sum a[i] a[j]在另一个布尔数组can_form[]里标记为true。最后遍历原数组如果某个数在can_form[]里是true计数器加一。这个思路的时间复杂度是O(n²)因为双重循环枚举数对是n²/2次操作标记和最后统计都是O(n)。空间复杂度是O(max_val)对于数值范围10000来说两个布尔数组各10001个元素内存占用可以忽略不计。为什么这个思路更快因为它把“对每个候选和都重新找数对”变成了“一次性枚举所有数对把能凑出的和全部记下来”。相当于你先把所有可能的答案算好放在桌上然后拿每个数去桌上对一眼就行了不用每次都重新算一遍。3.3 两种思路的对比与选择建议对比维度三重循环暴力法标记数组法时间复杂度O(n³)O(n²)空间复杂度O(1)O(max_val)代码复杂度低但需要处理跳出中等逻辑清晰适用数据规模n ≤ 100n ≤ 5000是否需要排序否否重复和的处理天然去重找到一个就break需要额外注意去重从表格可以看出标记数组法在时间效率上有明显优势代价是需要额外的空间。对于这道题来说空间完全不是问题所以标记数组法是更优的选择。而且这个思路可以推广到很多类似问题比如“判断一个数组中是否存在两个数之和等于给定值”用标记数组就是O(n)的解法。实操心得很多初学者觉得开数组浪费空间总想用最少的变量解决问题。但在算法竞赛和实际工程中用适量空间换时间是非常常见的策略。只要空间不超限优先考虑时间效率。4. 完整代码实现与逐行解析4.1 核心代码框架下面是我常用的标记数组法实现代码不长但每一行都有讲究#include stdio.h #include stdbool.h #include string.h #define MAX_VAL 10005 int main() { int n; int a[105]; bool exist[MAX_VAL]; bool can_form[MAX_VAL]; memset(exist, false, sizeof(exist)); memset(can_form, false, sizeof(can_form)); scanf(%d, n); for (int i 0; i n; i) { scanf(%d, a[i]); exist[a[i]] true; } for (int i 0; i n; i) { for (int j i 1; j n; j) { int sum a[i] a[j]; if (sum MAX_VAL) { can_form[sum] true; } } } int count 0; for (int i 0; i n; i) { if (can_form[a[i]]) { count; } } printf(%d\n, count); return 0; }4.2 关键细节逐行拆解先看数组大小。a[105]是因为题目n最大100多开几个防止越界。MAX_VAL设为10005是因为题目说数值不超过10000加5是保险。如果你不确定数值范围可以动态计算最大值然后开对应大小的数组但那样代码会复杂一些。memset初始化两个布尔数组这是必须的。C语言里局部数组不会自动清零如果不初始化里面是随机值会导致错误结果。sizeof(exist)会自动计算数组字节数比手写MAX_VAL * sizeof(bool)更安全。读入部分没什么好说的注意边读边标记exist[a[i]] true。这里其实exist数组在后面并没有用到因为题目只要求判断“能否被凑出”不要求“凑出的两个数必须存在于集合中”——等等这里有个逻辑陷阱。实际上题目要求的是“另外两个不同的数”这两个数必须来自原集合。所以理论上你应该检查exist[a[i]] exist[a[j]]但因为我们的双重循环本来就是遍历原数组的元素所以天然满足这个条件。exist数组在这里确实可以省略我保留它只是为了展示完整的思路实际写的时候可以去掉。双重循环从i1开始保证j i这样每个数对只枚举一次避免重复。sum可能超过MAX_VAL所以加个判断防止数组越界。虽然题目说数值不超过10000两数之和最大20000但如果你开的数组只有10005就必须判断。更稳妥的做法是把MAX_VAL开到20005这样就不用判断了。我倾向于开大一点少一个分支判断代码更简洁。最后统计部分遍历原数组如果can_form[a[i]]为true计数器加一。这里不用担心重复计数因为每个数只被检查一次。但要注意如果原数组有重复元素虽然题目通常没有同一个数值会被多次计数。所以如果题目不保证互异你需要额外去重。4.3 边界条件与特殊输入处理有几个边界情况需要特别留意。第一n小于3的情况。如果集合里少于3个数根本不可能凑出“两个不同数之和等于第三个数”所以直接输出0。我的代码里双重循环在n3时不会执行can_form全为false最后统计也是0逻辑上没问题。第二数值为0的情况。题目说是正整数所以不考虑0。但如果你的题目版本允许0那就要注意000这种特殊情况不过“两个不同的数”这个约束会排除掉它。第三两数之和超出数组范围。前面说了开大数组或者加判断都行。我建议开20005因为100001000020000加5足够。第四输入格式的容错。有些平台的输入可能有多余空格或换行scanf会自动跳过空白字符所以不用特别处理。但如果输入量很大建议用scanf而不是cinC语言的输入输出在竞赛中通常更快。注意如果你在本地测试时发现结果不对先检查数组有没有初始化再检查数组大小是否够用最后检查循环边界有没有写错。这三个地方是初学者最容易翻车的地方。5. 性能实测与优化空间探讨5.1 不同数据规模下的耗时对比我在本地用随机数据做了几组测试对比三重循环和标记数组法的实际耗时。测试环境是普通笔记本编译器gcc默认优化级别。数据规模n数值范围三重循环耗时标记数组耗时5010001ms1ms1005000约5ms1ms20010000约40ms1ms50010000约600ms约2ms100010000约5s约8ms从表格可以清楚看到当n超过200之后三重循环的耗时急剧上升而标记数组法几乎不受影响。这是因为标记数组法的时间主要花在双重循环上n1000时也只有约50万次内层操作而三重循环是10⁹级别。5.2 进一步优化的可能性标记数组法已经足够快但如果想追求极致还有几个小优化点。第一双重循环里可以用局部变量缓存a[i]减少数组访问次数。第二如果数值范围很大但n很小可以用哈希表代替布尔数组不过C语言手写哈希表比较麻烦这道题没必要。第三如果题目要求多次查询可以预处理所有可能的和但这道题只查一次预处理反而浪费。还有一个思路是用排序双指针。先对数组排序然后对于每个候选和用双指针在数组中找两个数。但这样对每个候选和都要O(n)的查找总共O(n²)和标记数组法同级但常数更大因为排序本身要O(n log n)双指针每次移动也有开销。所以对于这道题标记数组法是最简洁高效的。5.3 空间复杂度的实际考量有人可能会担心布尔数组占内存。bool类型在C语言里通常占1字节20005个元素就是约20KB两个数组40KB对于现代计算机来说完全可以忽略。即使数值范围到10⁶两个数组也就2MB依然可以接受。所以在这类问题里空间换时间是非常划算的。实操心得在算法题里如果时间限制比较紧而空间限制很宽松通常128MB或256MB那就大胆用数组标记。不要为了省几KB内存而让程序跑超时得不偿失。6. 常见错误与排查指南6.1 结果偏大重复计数问题最常见的错误就是同一个和被多组数对凑出来时计数器加了多次。比如集合里有1、2、3、4、5目标数5可以由14和23两组凑出。如果你在双重循环里每找到一组就计数5会被算两次。正确的做法是用标记数组只标记“能被凑出”不关心有多少种凑法。如果你用三重循环记得找到一个符合条件的就break跳出内层循环避免重复计数。用goto或者设一个flag变量都可以。6.2 结果偏小漏掉某些数对另一个常见问题是循环边界写错。比如内层循环写成j n而不是j i导致同一个数对被枚举两次i,j和j,i虽然不会漏但会多做一倍无用功。更严重的是写成j 0而不是j i 1可能导致同一个数被用两次比如224但2只有一个这就不符合“两个不同的数”的要求。还有一种漏掉的情况是数组开太小导致越界后程序行为异常。比如数值范围是10000你只开了10005的数组但两数之和可能到20000写入越界位置可能覆盖其他变量导致结果莫名其妙。6.3 数组越界与初始化遗漏C语言不会自动检查数组越界也不会自动初始化局部数组。如果你忘了memset布尔数组里是随机值可能导致某些不该被标记的和被标记为true结果偏大。如果你数组开太小写入越界可能不报错但结果错误甚至程序崩溃。排查这类问题的办法是在关键位置加打印语句输出数组的实际大小、循环变量的值、标记数组的状态。虽然原始但非常有效。6.4 常见问题速查表问题现象可能原因排查方法解决方案结果偏大重复计数检查是否有多个数对凑出同一个和用标记数组或找到后break结果偏小循环边界错误打印循环变量检查j的起始值确保j从i1开始结果随机数组未初始化检查memset是否调用加memset初始化程序崩溃数组越界检查数组大小是否够开大数组或加边界判断超时算法复杂度过高估算n³是否超过10⁷改用标记数组法注意如果你在平台上提交后提示“运行时错误”大概率是数组越界或除零。先检查数组大小再检查有没有除以变量的操作。7. 从这道题延伸出的通用解题框架7.1 “两数之和”类问题的通用模式这道题本质上是“两数之和”的变体。通用模式是给定一个集合问是否存在两个数满足某种关系和、差、积等。对于“和”的问题标记数组法是最常用的技巧。具体步骤是先标记所有出现过的数然后枚举所有数对把结果标记到另一个数组最后根据题目要求统计。这个模式可以推广到“三数之和”先枚举两个数把它们的和标记下来然后对于每个第三个数检查目标值减去它是否在标记数组里。时间复杂度从O(n³)降到O(n²)。再进一步“四数之和”可以用类似思路先枚举两对把两对的和分别标记然后检查是否有两个和加起来等于目标值。7.2 标记数组 vs 哈希表的选择标记数组的优点是实现简单、访问O(1)、常数极小。缺点是要求数值范围不能太大否则数组开不下。如果数值范围是10⁹级别就必须用哈希表。C语言里可以用开放寻址法或链地址法手写哈希表但代码量会大很多。对于这道题数值范围10000标记数组完胜。在实际工程中如果数据量不大但数值范围大可以用uthash这样的第三方库或者用C的unordered_set。但如果是纯C环境手写哈希表是必备技能。我建议你先掌握标记数组再学哈希表循序渐进。7.3 时间与空间的权衡哲学这道题给我最大的启发是不要一上来就写最直观的暴力解法先想想能不能用空间换时间。很多初学者觉得开数组浪费总想用几个变量搞定结果代码又长又慢。实际上在内存充足的现代环境下用适量空间换取时间效率是明智之举。当然也不是说空间可以无限用。如果数值范围是10⁹你开一个10⁹的布尔数组就是1GB内存肯定不行。这时候就要根据数据规模灵活选择n小的时候用暴力n大的时候用哈希数值范围小的时候用标记数组。没有银弹只有适合当前场景的方案。8. 我在实际编码中踩过的坑第一次写这道题的时候我用的是三重循环加goto本地测试几个小数据都过了提交上去却只过了一半测试点。后来才发现有些测试点的n虽然不大但数值范围很大导致两数之和超出数组范围而我的标记数组开得不够大越界写入了其他变量的内存。那次之后我养成了两个习惯一是数组大小永远比理论最大值多开一点二是写完代码后手动构造边界数据测试。还有一次我用了bool数组但忘了#include stdbool.h编译器报了一堆错我以为是逻辑问题查了半天才发现是头文件没加。C语言里bool不是内置类型必须包含这个头文件或者直接用int数组代替。我现在更倾向于用int数组做标记虽然多占点内存但省去了头文件的麻烦而且int在某些平台上访问速度更快。另一个坑是关于memset的。memset按字节设置对于bool数组没问题但对于int数组如果你想设置成1不能用memset(arr, 1, sizeof(arr))因为那样每个字节都是1一个int会变成0x01010101不是1。正确做法是用循环赋值或者用fill函数C。这个坑我在很多题目里都见过新手特别容易中招。最后分享一个小技巧如果你不确定数组要开多大可以先读入所有数据找出最大值然后动态分配数组。虽然多了一次遍历但更安全。对于这道题直接开20005就够了不用动态分配。实操心得写完代码后至少构造三组测试数据最小规模n1或2、最大规模n100且数值接近10000、有多个数对凑出同一个和的情况。这三组能过基本就没问题了。