前两天我整理基础算法题单时,又碰到了这道老熟人:求范围奇数和。题面短得可怜,就是“给定整数 a 和 b,求闭区间 [a, b] 内所有奇数的和”。不少朋友第一反应是写个 for 循环,三行搞定,可一旦把负数、逆序、大范围这些条件摆上台面,简单的题也能翻出不少浪花。这篇就把我的完整思考过程、写代码时踩过的坑、以及最后沉淀出来的通用模板都摊开来讲,顺便聊聊为什么我建议你在任何“求和”题目里,都先停下来想想数学公式,而不是急着开循环。如果你能把我后面列出的测试用例全部跑过,这道题才算真正吃透了。
1. 题目理解与边界条件分析
1.1 范围定义要先说死
“范围”这个词看着简单,实际至少有三种常见定义:闭区间 [a, b]、开区间 (a, b)、左开右闭 (a, b]。我遇到的实际场景里,绝大多数都默认闭区间,也就是两端都要包含。所以我的函数注释第一行就会写清楚:“参数 a、b 视为闭区间端点,且允许 a > b,函数内部自动交换。”别小看这句话,它能省掉后续无数的疑问。
如果题目没给清楚,优先按闭区间实现,再用测试用例去确认。比如你接到一个明确约定为“不包含右端点”的需求,那只需要把最后一处 b 改成 b-1,其他逻辑不用动。真正容易出问题的不是区间本身,而是“奇数的定义”。很多人默认奇数就是自然数里的 1、3、5,却忘了在整数范围内,-1、-3、-5 同样是奇数。这个认知直接决定负数区间的测试结果,也是整个题最容易翻车的地方。
1.2 四类边界必须手算一遍
边界往往是这类题最容易被扣分的地方。我总结了四类必测的边界,下面这张表可以直接抄进你的测试用例里。
| 输入区间 | 区间内奇数 | 期望和 |
|---|---|---|
| [7,7] | 7 | 7 |
| [8,8] | 无 | 0 |
| [5,1] | 1,3,5 | 9 |
| [-5,-1] | -5,-3,-1 | -9 |
| [-4,4] | -3,-1,1,3 | 0 |
第一类是端点相等:如果区间里只有一个整数,奇数就返回它本身,偶数就返回 0。第二类是输入逆序:比如 [5,1],合理做法是交换成 [1,5],而不是直接返回 0 或者抛异常。第三类是全负区间:比如 [-5,-1],里面包含 -5、-3、-1,和是 -9,很多人会想当然算成正数。第四类是跨零区间:比如 [-4,4],奇数恰好是 -3、-1、1、3,正负抵消后结果是 0,这个特性可以用来做对称性校验。
这些边界看起来简单,但恰恰是很多暴力循环写法容易踩雷的地方。比如在 C 语言里用i % 2 == 1来判断奇数,遇到 -3 时会失败,因为 C 语言的取余结果是 -1 而不是 1,这是后面第 5 章要展开的跨语言坑。所以我的做法是先把这些边界结果写进测试用例,再写主体逻辑,代码写完后用测试一跑,就知道边界有没有漏。
1.3 从边界反推代码结构
从上面这张表可以反推出三个必备步骤:第一步,判断是否需要交换端点;第二步,把左右端点分别调整到最近的奇数;第三步,处理“首项大于末项”的空集情况。这三个步骤做好了,后面无论换成什么语言,都只是语法层面的差别。
我见过不少人一开始就写 for 循环,等测到 [8,8] 发现循环也能算出 0,好像没问题,但等到 [-5,-1] 就露馅了,因为循环里的判断条件写错了。这也是我坚持先列用例再写实现的原因:用例先定好,实现的正确性是被约束出来的,而不是靠感觉。
2. 从暴力循环到等差数列公式:性能差在哪里
2.1 暴力循环的写法与复杂度
先看最直接的写法:
def sum_odd_range_naive(a: int, b: int) -> int: if a > b: a, b = b, a total = 0 for i in range(a, b + 1): if i & 1: total += i return total这段代码逻辑没错,时间复杂度 O(n),空间复杂度 O(1)。如果区间只有几百、几千,它完全够用。但一旦碰到 10^9 量级的输入,循环次数会超过十亿次,结果就是肉眼可见的卡顿。我在本机跑过一个大约一亿次的版本,体感已经在秒级徘徊,所以把复杂度从 O(n) 降到 O(1),不是炫技,是实际需求逼出来的。
更重要的是,这类“求和”题目往往不是一个单独的运算,它可能被包在更大的循环里反复调用。如果内部每次都暴力跑一个长区间,外层再循环十万次,总耗时就会从秒级变成分钟级。别问我是怎么知道的,问就是我曾经写过一个定时统计任务,就是因为内部用了暴力求和,跑一次要十几分钟,后来改成公式解法,耗时直接降到毫秒级。
2.2 奇数和公式:前 k 个奇数和等于 k²
这里有一个小学数学就学过的规律:1+3=4=2²,1+3+5=9=3²。也就是说,前 k 个奇数和等于 k²。如果定义函数 f(n) 表示 1 到 n 的所有奇数和,那么 n 以内奇数个数是 (n+1)//2,把这些奇数看成一个新的等差数列,它们的和正好是个数平方。于是:
f(n) = ((n+1)//2)²
要求 [a,b] 的奇数和,就做差:f(b) - f(a-1)。举个例子,[3,9] 时,f(9)=25,f(2)=1,差是 24,而 3+5+7+9=24,完全对得上。这个思路在纯正整数区间下非常优雅,代码只有短短几行,而且不需要循环,是很多人最喜欢写进题解里的方法。
但这里有个致命的前提:这个公式是建立在“从 1 开始的连续奇数序列”上的。一旦 a 和 b 出现负数,f(-1) 在大多数简单实现里会直接返回 0,导致整个区间结果变成 0。我最初的版本就是这样,测 [1,10] 全对,一旦换成 [-5,-1] 就立刻翻车。所以后来我在正经代码里不再用它,而是改用下面这个首尾项公式。
2.3 首尾项公式更稳妥
在闭区间里取所有奇数,本质上是取一个等差数列。假设我们已经保证 a <= b。那么第一个奇数 first:如果 a 是奇数就用 a,否则用 a+1;最后一个奇数 last:如果 b 是奇数就用 b,否则用 b-1。则:
项数 count = (last - first) // 2 + 1
和 = (first + last) * count // 2
这个公式厉害在什么地方?它不需要假设非负。因为等差数列求和公式对负数同样成立,例如 [-5,-1] 里,first = -5,last = -1,count = (-1 + 5)//2 + 1 = 3,和 = (-6)3//2 = -9,和手算完全一致。它也能处理跨零区间,[-4,4] 的 first = -3,last = 3,count = (3+3)//2 + 1 = 4,和 = 04//2 = 0。如果区间里没有奇数,first 会大于 last,直接返回 0 即可。
| 方案 | 时间复杂度 | 空间复杂度 | 适合场景 |
|---|---|---|---|
| 暴力循环 | O(n) | O(1) | 区间小、写 Demo |
| 平方差公式 | O(1) | O(1) | 仅非负区间 |
| 首尾项公式 | O(1) | O(1) | 通用,推荐 |
3. 工程实现:Python、JavaScript、C++ 三种落地
3.1 Python 实现与逐行说明
先看 Python 的最终版本:
def sum_odd_range(a: int, b: int) -> int: if a > b: a, b = b, a first = a if a & 1 else a + 1 last = b if b & 1 else b - 1 if first > last: return 0 count = (last - first) // 2 + 1 return (first + last) * count // 2我用位运算a & 1判断奇偶,它在 Python 里对负数也返回 1,看起来很自然。不过如果团队习惯用取余,写成a % 2 == 1在 Python 里也成立,但我建议写成a % 2 != 0,因为 C 语言里不是这样。这里使用//做整除,Python 的 int 没有上限,所以不需要担心溢出,哪怕传入 10^18 量级的数,这段代码依然能算出精确结果。
如果你喜欢紧凑一点的写法,也可以把中间步骤合并成一行,但我不太建议。工程代码最重要的是让三个月后的自己一眼能看懂,多写两个变量名不丢人。
3.2 JavaScript 实现与 BigInt 扩展
JavaScript 版本在语法上微微调整:
function sumOddRange(a, b) { if (a > b) { [a, b] = [b, a]; } const first = a % 2 === 0 ? a + 1 : a; const last = b % 2 === 0 ? b - 1 : b; if (first > last) return 0; const count = Math.floor((last - first) / 2) + 1; return (first + last) * count / 2; }注意(first + last) * count / 2在 JavaScript 里可能会出现浮点数运算,但因为 first 和 last 都是奇数,两个奇数相加必然是偶数,除以 2 的结果还是整数,只要数值在Number.MAX_SAFE_INTEGER以内,就不会有精度问题。如果区间端点超过 2^53,就别用 Number 了,直接换 BigInt:
function sumOddRangeBig(a, b) { a = BigInt(a); b = BigInt(b); if (a > b) [a, b] = [b, a]; const first = a % 2n === 0n ? a + 1n : a; const last = b % 2n === 0n ? b - 1n : b; if (first > last) return 0n; const count = (last - first) / 2n + 1n; return (first + last) * count / 2n; }BigInt 版本的count计算不会产生浮点误差,因为 BigInt 的除法会向下取整,而这里(last - first)本身是偶数,除以 2n 精确无误。实际开发中如果只是应对普通数值范围,用 Number 版本就够了。
3.3 C++ 实现与溢出规避
C++ 版本和 Python、JavaScript 最大的不同是整型溢出,所以我通常会加一层防御:
long long sumOddRange(long long a, long long b) { if (a > b) { std::swap(a, b); } long long first = (a % 2 == 0) ? a + 1 : a; long long last = (b % 2 == 0) ? b - 1 : b; if (first > last) { return 0; } long long count = (last - first) / 2 + 1; return (first + last) / 2 * count; }关键点在最后一行:我先做(first + last) / 2,再做乘法。两个奇数相加一定是偶数,所以先除以 2 是精确的,不会有精度损失。这样中间结果的最大值大约是“平均值 × 项数”,比“首末项之和 × 项数”小了一半,能给 long long 多留出一倍安全余量。
如果担心参数本身溢出,函数签名直接接收 long long,调用侧先把 int 提升为 long long 即可。不要在这个函数内部把 int 强转成 long long,等到计算时已经晚了,因为在传入那一刻可能就已经溢出了。
3.4 三种语言的共同骨架
把三个版本放在一起看,你会发现核心逻辑就是四步:交换、找首末奇数、判空、套公式。语言差异只体现在整除语法和奇偶判断的符号上。我建议你把这四步当作注释写进自己的工具函数,下次遇到“求任意等差数列”也可以直接改。
实际操作中,我还习惯给函数加一个明确的注释:
// 计算闭区间 [a, b] 内所有奇数的和。 // 允许 a > b,内部会自动交换。 // 如果区间内没有奇数,返回 0。这个注释能帮调用方省去很多猜测。很多线上问题都是因为调用方没搞清楚区间语义,写了错误的 case,最后 debug 半天才发现是理解偏差。
4. 测试用例设计:正常样本和负样本都要有
4.1 常规输入用例
常规用例主要验证公式在正整数区间上的正确性。比如 [1,10],奇数是 1、3、5、7、9,和是 25;[2,10],奇数是 3、5、7、9,和是 24。这些用例看起来简单,但能快速发现公式里的符号错误。比如如果你在项数公式里少加一个 1,[1,10] 的结果会从 25 变成 20,测一次就露馅。
我习惯把这类用例放在测试文件的开头,它们是最低限度的保障。如果连常规用例都过不了,后面的边界用例更不用谈。
4.2 边界输入用例
边界用例比常规用例重要得多。除了上面表格里的四类,我还会补两个:全零区间 [0,0] 期望结果是 0,因为 0 不是奇数;[1,1] 期望结果是 1,因为单个奇数本身就是结果。把下面这张表直接抄进测试文件:
(1, 10) -> 25 (2, 10) -> 24 (7, 7) -> 7 (8, 8) -> 0 (-5, -1) -> -9 (-4, 4) -> 0 (5, 1) -> 9 (0, 0) -> 0 (1, 1) -> 1用这张表去测任何实现版本,基本能覆盖 80% 的边界问题。尤其是(-4, 4) -> 0这个用例,它直接检验跨零区间的对称性,如果实现里在负数部分漏了符号,结果就会变成正数。
4.3 把测试写成断言
手工跑测试不够可靠,最好写成自动化断言。Python 里的写法很简单:
def run_tests(): cases = [ ((1, 10), 25), ((2, 10), 24), ((7, 7), 7), ((8, 8), 0), ((-5, -1), -9), ((-4, 4), 0), ((5, 1), 9), ((0, 0), 0), ((1, 1), 1), ] for (a, b), expected in cases: actual = sum_odd_range(a, b) assert actual == expected, f"a={a}, b={b}, expected={expected}, actual={actual}" print("all passed") run_tests()JavaScript 可以用console.assert,C++ 可以写一个宏CHECK_EQ或者直接用标准库的测试工具。重点是测试用例应当和实现代码一起提交,而不是临时复制粘贴。以后任何人改动这个函数,只要跑一遍测试就知道有没有破坏行为。
4.4 测试中的两个直觉陷阱
第一个陷阱:看到“奇数和”就默认结果为正数。负数区间的和当然是负的,这个不是 bug,而是数学定义。第二个陷阱:没有测试逆序输入。很多在线判题为了保证题意简洁,会承诺 a <= b,但真实业务场景里用户输入可不一定有序。多写一行交换成本很低,建议默认支持逆序。这不算过度设计,而是防御性编程。
我见过最离谱的情况是某个实现把逆序输入直接返回 0,而调用方又刚好传了 [5,1],结果默默丢了正确结果。这种 bug 在线上很难查,因为报错不明显,最后靠加日志才定位到参数顺序。如果你在函数内部就做交换,这类问题根本不会出现。
5. 常见错误与排查实录
5.1 奇偶判断:换一种语言,坑就来了
C 语言里-3 % 2结果是 -1,Python 里-3 % 2结果是 1,JavaScript 里-3 % 2结果也是 -1。逻辑完全相同的判断条件,换到 C 后如果写成i % 2 == 1,负数奇数全部会被漏掉。这个问题在跨语言协作时非常隐蔽。
最稳妥的办法是统一用位运算i & 1或者“不等于 0”的判断,例如i % 2 != 0。如果团队里同时存在 C++ 和 Python,建议封装一个isOdd函数,一处定义,避免每个调用点各自猜语义。不要把判断i % 2 == 1当作标准写法,它只在 Python 里成立。
5.2 先乘后除导致整型溢出
这是 C++ 或 Java 代码里最常见的坑。公式(first + last) * count // 2在 Python 里可以随便写,但 C++ 里(first + last) * count可能先溢出,再除以 2 也救不回来。比如 first 接近 9e18、last 也接近 9e18 时,两者相加直接超出 long long 上限。改成(first + last) / 2 * count之后,第一步的结果是两个奇数的平均值,最多也就是 9e18,然后再乘以 count,只要最终结果在范围内都不会爆。
如果你是写 Java,记得用long,并且同样先除后乘。不要用Math.multiplyExact硬扛,因为这种基础函数会被高频调用,频繁抛异常不是好设计。
5.3 平方公式对负数失效
第 2 章提过,平方公式只适用于自然数区间。如果你看到某个题解用了f(b) - f(a-1),请先确认它的 f 函数有没有处理负数。如果题目要求支持负数,直接套用会得出错误结果。我曾经在代码里混用平方公式和首尾项公式,因为用例没覆盖负数,上线后用户传入负区间才发现问题。后来我统一只用首尾项公式,变体只是作为优化思路放进注释,而不是放进实现。
如果你确实很喜欢平方公式的简洁,也可以先判断区间是否全为正、全为负或跨零,拆成三段再分别处理。但这样代码会多出好几个分支,测试成本也更高,收益只是省掉几行变量赋值,实际不划算。
5.4 一个可复用的排查检查单
当你的函数计算结果和预期不一致时,按这个顺序排查效率最高:
- 输入逆序有没有交换?
- 负数奇数的判断是否跨语言正确?
first > last是否提前处理?- 乘法运算是否可能溢出?
- 是否测过单元素、空集、全负、跨零这些边界?
- 打印中间值
first、last、count,和手算结果对一下。
其中打印中间值是定位问题最快的方法。几乎所有公式类 bug 都能在first、last、count这三个变量中暴露出来。比如count算错,通常就是项数公式里的+1丢了;first算错,基本是端点调整时漏了+1或-1。
6. 从这道题延伸出去的通用价值
6.1 通用等差数列求和模板
这道题再往前走一步,就是任意等差数列求和。比如要求区间内所有 3 的倍数之和、所有除以 4 余 1 的数之和,只需要把“找首末奇数”的逻辑换成“找首末满足条件的数”,其他完全一样。项数公式从(last-first)//2+1变成(last-first)//step+1即可。
我在项目里维护过一个类似的小工具函数,接收区间端点、步长、余数三个参数,专门用来统计带周期性的数据。它的核心思想和求奇数和完全一致:先定位区间内的第一个和最后一个目标值,再用等差数列求和。很多时候业务里报上来的“求总和”需求,都能抽象成这个模板。
6.2 适用场景与常见变形
这道题常见变形有三种。第一种是求偶数和的变体,只需要把first和last的调整规则改成取偶数,逻辑几乎不变。第二种是求区间内所有质数和,这就不能套公式了,需要筛法预处理,因为质数不是等差数列。第三种是求满足某个条件的整数个数,比如“能被 3 整除且不能被 5 整除的数的个数”,这种本质上需要容斥原理,和奇数和思路也有关系,但更复杂。
我个人的建议是:遇到新题目先判断它背后的序列是否具备“等差”特征。如果是,那就该想到用常数时间公式,而不是直接循环。这个思考过程远比这道题本身值钱。
6.3 我的真实建议
如果只是为了刷题,这道题花十分钟做完就好。但如果想提升代码质量,我建议你把边界测试、跨语言坑、溢出规避都记录到自己的笔记里。我后来写工具函数时,也会先写测试用例再写实现,这个习惯就是被这类简单题训练出来的。
最后再说个小技巧:把函数命名成sumOddRange,不要叫f或fun。等三个月后回来看代码,你会感谢自己留了清晰的名字。如果你在维护一个公共基础库,记得把这一段函数和测试一起交出去,别人改代码时也会少踩很多坑。