前言
计算 1 到 n 的和,最直接的做法是一个循环。但这个问题在面试和习题里经常被加上各种限制:「不许用循环」「不许用乘除法」「不许用 if」,于是出现了许多「另辟蹊径」的写法:公式、递归、模板元编程、构造函数副作用、虚函数分派。了解这些写法,与其说是为了炫技,不如说是借它把 C++ 的几套机制串起来看一遍。
常见的误解有三个。第一,认为公式n(n+1)/2永远安全——当 n 很大且用int计算时,乘法会溢出,而有符号整数溢出是未定义行为(undefined behavior, UB)。第二,认为递归比循环「更优雅」——递归深度等于 n 时,栈溢出是实实在在的风险。第三,把构造函数累加这类谜题写法当成工程手段——它依赖静态状态,不可重入,也不适合多线程。
本文按「公式 → 循环与递归 → 编译期计算 → 构造函数技巧」展开,每个方案都说明它的限制,最后给出一个可以实际使用的版本。代码以 C++17 为准。
一、公式法
高斯求和公式:
1 + 2 + ... + n = n(n + 1) / 2// 适用于 C++17
#include <cstdint>
std::uint64_t sumFormula(std::uint32_t n) {
const std::uint64_t m = n;
return m * (m + 1) / 2;
}这段代码有两个细节值得说:
- 先转成 64 位再乘。如果写成
n * (n + 1) / 2,且n是std::uint32_t,乘法会在 32 位无符号数上进行,结果按模 2³² 回绕——虽然无符号回绕是良定义的,但答案已经错了。先转成std::uint64_t,中间结果最大约 2⁶³,放得下。 - 必须是先乘后除。
m / 2 * (m + 1)在 m 为奇数时会丢掉 0.5,结果偏小。由于m与m+1中必有一个偶数,先乘再除以 2 不会丢精度。
如果 n 用int且可能接近INT_MAX,那么n * (n + 1)会在int上溢出,这是 UB,不是「得到一个负数」这么简单。公式法本身没问题,问题在于类型选得太窄。
二、循环与递归
循环版本最直白:
// 适用于 C++17
#include <cstdint>
std::uint64_t sumLoop(std::uint32_t n) {
std::uint64_t total = 0;
for (std::uint32_t i = 1; i <= n; ++i) {
total += i;
}
return total;
}注意当n == UINT32_MAX时,i <= n永远成立,++i回绕到 0 后继续循环,形成死循环。把i改成std::uint64_t,或换成for (std::uint32_t i = 1; i != n + 1; ++i)并单独处理边界,都可以规避。这类边界在真实数据里很少出现,但写库函数时值得留意。
递归版本:
// 适用于 C++17
std::uint64_t sumRecursive(std::uint32_t n) {
if (n == 0) return 0;
return n + sumRecursive(n - 1);
}时间 O(n)、空间 O(n)(调用栈)。n 稍大就可能栈溢出,而且编译器不一定能把它优化成循环(尾递归优化不是标准保证)。递归在这里没有任何优势,只是用来演示「同一个问题的另一种表达」。
如果题目要求「不能用 if」,可以用逻辑与的短路特性代替判断:
// 适用于 C++17
std::uint64_t sumShortCircuit(std::uint32_t n) {
std::uint64_t total = 0;
n > 0 && (total = n + sumShortCircuit(n - 1));
return total;
}&&从左到右求值,左边为假时右边不会执行,起到了if的作用。可读性很差,递归深度问题也依然存在,只适合做智力题。
三、编译期计算
如果 n 是编译期常量,可以让编译器直接算出结果,运行期零成本。
constexpr 函数(C++14 起支持循环):
// 适用于 C++17
constexpr std::uint64_t sumConstexpr(std::uint64_t n) {
std::uint64_t total = 0;
for (std::uint64_t i = 1; i <= n; ++i) {
total += i;
}
return total;
}
static_assert(sumConstexpr(100) == 5050, "100 的和应为 5050");C++11 的constexpr函数体只能有一条 return 语句,写循环需要递归;C++14 放宽后可以直接写循环和局部变量。static_assert在编译期检查结果,不满足就编译失败。
模板递归:
// 适用于 C++17
template <unsigned N>
struct Sum {
static constexpr unsigned long long value = N + Sum<N - 1>::value;
};
template <>
struct Sum<0> {
static constexpr unsigned long long value = 0;
};
static_assert(Sum<100>::value == 5050, "编译期求和");模板特化提供了递归的终止条件。C++17 起静态数据成员默认是内联的,不需要在类外再写定义。这种写法在 C++11 时代很常见,现在有constexpr函数后已经不再必要——模板递归的编译错误信息通常很难读,实例化层次也有上限。
四、构造函数的副作用
经典谜题:利用「创建 n 个对象」这件事本身完成累加。每个对象构造时把静态计数器加一并累加到总和。
// 适用于 C++17
#include <cstdint>
#include <vector>
class Summer {
public:
Summer() {
++current_;
total_ += current_;
}
static std::uint64_t total() { return total_; }
static void reset() {
current_ = 0;
total_ = 0;
}
private:
static std::uint64_t current_;
static std::uint64_t total_;
};
std::uint64_t Summer::current_ = 0;
std::uint64_t Summer::total_ = 0;
std::uint64_t sumByCtor(std::uint32_t n) {
Summer::reset();
std::vector<Summer> objects(n); // 构造 n 个对象,逐个累加
return Summer::total();
}std::vector<Summer> objects(n)会值初始化 n 个元素,每个元素调用默认构造函数。第 k 个对象构造时current_变成 k,total_累加 k,最终得到 1 + 2 + … + n。
这个写法的问题很明显:
- 依赖可变的静态状态,不可重入;
- 多线程同时调用会数据竞争,属于 UB;
- 为了求和分配了 n 个对象的存储,时间与空间都不划算;
- 如果编译器把对象优化掉(理论上不允许,因为构造函数有副作用,但依赖这一点本身就很脆弱)。
它适合作为「展示构造函数调用时机」的例子,不适合放进生产代码。
五、完整示例
// 适用于 C++17
#include <cstdint>
#include <iostream>
std::uint64_t sumFormula(std::uint32_t n) {
const std::uint64_t m = n;
return m * (m + 1) / 2;
}
constexpr std::uint64_t sumConstexpr(std::uint64_t n) {
std::uint64_t total = 0;
for (std::uint64_t i = 1; i <= n; ++i) {
total += i;
}
return total;
}
static_assert(sumConstexpr(10) == 55, "1 到 10 的和应为 55");
int main() {
for (std::uint32_t n : {0u, 1u, 10u, 100u, 1000u}) {
std::cout << n << " -> " << sumFormula(n) << '\n';
}
// 与循环结果对照(小范围)
std::uint64_t check = 0;
for (std::uint32_t i = 1; i <= 1000; ++i) check += i;
std::cout << "check = " << check << '\n'; // 500500
std::cout << "compile-time = " << sumConstexpr(1000) << '\n'; // 500500
return 0;
}按标准语义,输出依次是 0、1、55、5050、500500,随后check = 500500,最后一行同样是 500500。三个结果互相印证,比单独看一个数字更可靠。
sumFormula对n = 0返回 0,无需特判;这是因为0 * 1 / 2 = 0,公式在边界上自然成立。
常见坑点
1. 先用 32 位乘再转 64 位
❌return n * (n + 1) / 2;,n 较大时结果错误。 ✅std::uint64_t m = n; return m * (m + 1) / 2;。
2. 先除后乘
❌return m / 2 * (m + 1);,m 为奇数时少算一半。 ✅ 先乘后除;m与m+1必有一个偶数,不会丢精度。
3. 用有符号 int 计算并溢出
❌int total = n * (n + 1) / 2;,溢出是 UB。 ✅ 用std::uint64_t,或先做范围检查。
4. 递归深度过大
❌sumRecursive(1000000),栈溢出。 ✅ 用循环或公式;递归只用于教学演示。
5. 循环变量回绕
❌for (std::uint32_t i = 1; i <= n; ++i)且n == UINT32_MAX,死循环。 ✅ 用更宽的循环变量,或把上界单独处理。
6. 认为尾递归一定会被优化
❌ 依赖编译器把递归改成循环。 ✅ 标准不保证尾递归优化;需要稳定行为就手写循环。
7. 在构造函数技巧里使用静态变量却忽略线程安全
❌ 多线程同时调用sumByCtor,静态计数器被并发修改。 ✅ 这个技巧只用于单线程演示;生产代码用无状态的公式或循环。
8. 模板递归层数过深
❌Sum<100000>::value,实例化层次过深,编译失败或极慢。 ✅ 编译期求和优先用constexpr函数。
总结
| 方案 | 时间 | 空间 | 适用场景 | 主要风险 |
|---|
| 公式 | O(1) | O(1) | 生产代码首选 | 类型宽度不足导致溢出 |
| 循环 | O(n) | O(1) | 教学、需要逐项处理 | 边界回绕 |
| 递归 | O(n) | O(n) | 教学 | 栈溢出 |
constexpr | 编译期 | 无运行期开销 | n 为常量 | 需要 C++14 及以上 |
| 模板递归 | 编译期 | 无运行期开销 | 老代码、模板练习 | 编译错误难读、层次上限 |
| 构造函数 | O(n) | O(n) | 谜题/演示 | 静态状态、非线程安全 |
「另辟蹊径」的写法大多是为了绕开题目限制,而不是为了解决实际问题。真正写代码时,n(n+1)/2配一个足够宽的无符号类型就够了;需要编译期结果就用constexpr。其余方案了解其原理即可,不必在工程里使用。