Lesson 05: 从 1 加到 100 CNB
练习任务
编写一个 C 程序,计算从 1 加到 100 的和,并打印结果。然后扩展:只计算偶数的和。
期望输出:
sum = 5050sum = 2550提示:使用
for循环从 0 遍历到 100,用一个累加变量sum在每次循环中加上当前数字。累加器需要先初始化为 0,然后用+=运算符累加。扩展任务中,用continue跳过奇数。思考:如果用数学公式n*(n+1)/2一步算出结果,和循环的方法有什么本质区别?
核心知识点
for循环进阶 — 三段式的灵活性、省略各部分、逗号运算符管理多变量- 复合赋值运算符
+=、-=、*=、/=、%=— 完整列表与求值语义(左操作数只求值一次) - 累加器模式 — sum(求和)、product(累乘)、count(计数)、min/max(极值)
- 数学单位元 — 加法单位元 0、乘法单位元 1、极值的初始化策略
- 高斯求和公式 — n*(n+1)/2 的数学推导与证明
- O(n) 循环 vs O(1) 公式 — 算法复杂度的本质差异
- 整数溢出 — int 值范围、INT_MAX 边界、long long 扩展
- 循环变量生命周期 — 退出后 i 的终值、C89 vs C99 声明差异
continue与break— for/while 中的行为差异、避免死循环- 嵌套循环 — sum of sums、迭代次数计算
- 循环展开 — 减少分支指令、以空间换时间的优化思想
- 时间复杂度入门 — O(1)、O(n)、O(n²) 的直观理解
- 代码计时 — clock() 测量循环性能
- 有符号与无符号整数 — 溢出行为差异与陷阱
- 编译器优化 — 常量折叠、gcc -O2 识别高斯公式
- 循环调试 — 打印中间值观察累加过程
代码框架
#include <stdio.h>
int main(void)
{
int sum = /* 初始值 */;
for (int i = /* 起始值 */; i /* 条件 */ 100; i++)
{
// 在这里将 i 累加到 sum
}
printf("sum = %d\n", sum);
return 0;
}填充关键思考:sum 的初始值应该是多少(0 还是 1)?i 从 0 还是 1 开始?条件用 < 还是 <=?如何用 += 累加?
TIP
先不要往下翻看参考解答。完成基本任务后,尝试扩展:能否改为只计算偶数的和?能否不修改循环体而通过修改 for 头部实现?如果求和范围变成 1 到 100000,int 还够用吗?
深度讲解
1. for 循环进阶:三段式的灵活性与高级用法
1.1 基本形式回顾
回顾 Lesson 03 中学过的 for 循环:
for (初始化表达式; 条件表达式; 更新表达式) {
// 循环体
}for 完全等价于以下 while 循环:
初始化表达式;
while (条件表达式) {
// 循环体
更新表达式;
}关键细节:更新表达式在循环体之后执行——这意味着 continue 跳过循环体后,更新表达式仍然会执行(这与 while 中的行为不同,后文详述)。
1.2 三段式的灵活性
for 的三个表达式都是可选的:
// 1. 省略初始化(变量已在外定义)
int i = 0;
for (; i < 10; i++) { ... }
// 2. 省略条件(无限循环,需在循环体内 break)
for (int i = 0; ; i++) {
if (i >= 10) break;
...
}
// 3. 省略更新(在循环体内手动更新)
for (int i = 0; i < 10; ) {
...
i++;
}
// 4. 全部省略(经典无限循环)
for (;;) {
// 等价于 while(1) { ... }
}NOTE
for(;;) 是 C 语言中表达无限循环的惯用写法,比 while(1) 更「地道」——因为 for 天然适合表达「无终止条件」的语义。某些编译器会对 while(1) 发出「条件恒为真」的警告,而 for(;;) 不会。
1.3 逗号运算符:在一个 for 中管理多个变量
C 语言的逗号运算符 , 允许你在 for 的初始化和更新表达式中同时操作多个变量:
#include <stdio.h>
int main(void)
{
// 同时管理 i 和 j:i 递增,j 递减
for (int i = 0, j = 100; i <= 100; i++, j--)
{
printf("i = %3d, j = %3d, i + j = %d\n", i, j, i + j);
}
return 0;
}输出片段:
i = 0, j = 100, i + j = 100
i = 1, j = 99, i + j = 100
...
i = 100, j = 0, i + j = 100逗号运算符的语义:
- 从左到右依次求值每个子表达式
- 整个逗号表达式的结果是最右边子表达式的值
i++, j--先执行i++,再执行j--,整体值是j--的结果
int x = (1, 2, 3); // x = 3,只有最后一个值生效
int y = (i++, i * 2); // 先 i++,再计算 i * 2 作为 y 的值WARNING
不要混淆逗号运算符和函数参数中的逗号分隔符。f(a, b) 中的逗号是分隔符,求值顺序未定义;(a, b) 中的逗号是运算符,保证从左到右求值。
1.4 多变量累加:同时追踪 sum 和 count
#include <stdio.h>
int main(void)
{
int sum = 0;
int count = 0;
// i 用于遍历,count 用于计数(实际迭代次数)
for (int i = 0; i <= 100; i++, count++)
{
sum += i;
}
printf("sum = %d, count = %d\n", sum, count);
// 输出: sum = 5050, count = 101
return 0;
}注意 count = 101——因为 i 从 0 到 100 共 101 次迭代。循环变量和计数值并不总是相同。
1.5 C99 自动变量与作用域
for (int i = 0; i <= 100; i++) { // C99:在 for 头部声明 i
sum += i;
}
// 这里 i 不再可见——作用域仅限 for 循环在 for 头部声明的变量称为自动变量(automatic variable),其作用域仅限于 for 循环体(包括条件表达式和更新表达式)。循环结束后,i 不再可访问。
C89/C90 的限制:旧标准不允许在 for 头部声明变量——所有变量必须在代码块开头声明。现代 C 编程通常使用 C99 或更新的标准(gcc -std=c99 或 -std=c11)。
// C89 写法:变量在块开头声明
int i;
for (i = 0; i <= 100; i++) {
sum += i;
}
// i 在此处仍然可见!IMPORTANT
C89 中 i 的作用域延伸到 for 所在的整个代码块,而 C99 中 i 的作用域仅限于 for 循环。这是两种标准的重要差异。优先使用 C99 的 for (int i = ...) 写法——限制作用域可以避免变量名冲突。只在确实需要终值时才在 for 外声明。
2. 复合赋值运算符:从 += 到完整体系
2.1 完整列表
复合赋值运算符是「运算 + 赋值」的缩写形式:
| 运算符 | 等价形式 | 示例 | 含义 |
|---|---|---|---|
+= | a = a + b | sum += i | 累加 |
-= | a = a - b | count -= 1 | 递减 |
*= | a = a * b | product *= i | 累乘 |
/= | a = a / b | half /= 2 | 除后赋值 |
%= | a = a % b | rem %= 10 | 取模后赋值 |
&= | a = a & b | flags &= mask | 位与后赋值 |
|= | a = a | b | flags |= bit | 位或后赋值 |
^= | a = a ^ b | code ^= key | 位异或后赋值 |
<<= | a = a << b | val <<= 2 | 左移后赋值 |
>>= | a = a >> b | val >>= 1 | 右移后赋值 |
2.2 求值语义:左操作数只求值一次
复合赋值不仅更简洁,还有一个重要的语义差异——左操作数只求值一次:
array[get_index()] += 1; // get_index() 只调用一次
array[get_index()] = array[get_index()] + 1; // get_index() 调用两次!如果 get_index() 每次返回不同值,展开写法会写入错误的位置。复合赋值避免了这种隐患。
WARNING
这不是性能优化,而是语义保障。当左操作数含有副作用(如函数调用、自增运算)时,复合赋值与展开写法的行为可能不同。
2.3 运算符优先级与结合性
复合赋值运算符的优先级与普通赋值 = 相同——非常低:
a *= b + c; // 等价于 a = a * (b + c),而不是 a = (a * b) + c
a += b << 2; // 等价于 a = a + (b << 2),因为 << 优先级低于 +?不,<< 高于 +=优先级从高到低:算术运算 > 移位运算 > 关系运算 > 位运算 > 逻辑运算 > 赋值运算(含复合赋值)。
结合性:所有赋值运算符都是右结合的:
a += b += c; // 等价于 b += c; a += b;TIP
尽管复合赋值运算符很简洁,嵌套使用 a += b += c 会严重降低可读性。每个表达式只用一个复合赋值是最佳实践。
3. 累加器模式:从数学单位元到编程范式
3.1 基本模式
累加器是一种经典编程模式——在循环中逐步累积计算结果:
累加器 = 初始值; // ← 必须是操作的单位元
for (每个元素) {
累加器 = 累加器 ⊕ 当前元素; // ⊕ 可以是 +、*、& 等
}
// 累加器现在包含最终结果3.2 为什么初始值是 0——单位元的数学原理
单位元(identity element):对运算 ⊕,满足 x ⊕ e = x 的值 e。
- 加法的单位元是
0:x + 0 = x - 乘法的单位元是
1:x × 1 = x - 字符串拼接的单位元是空串
"":str + "" = str - 位或的单位元是
0:x | 0 = x - 位与的单位元是全 1:
x & 0xFF... = x
因此:
- 累加(求和)→ 初始值
0 - 累乘(阶乘)→ 初始值
1
// 累加 → 初始值 0(加法单位元)
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
// 累乘 → 初始值 1(乘法单位元)
int product = 1;
for (int i = 1; i <= n; i++) product *= i;如果初始值错误:
int sum = 1; // 不是加法单位元
for (int i = 1; i <= 100; i++) sum += i;
// 结果:5050 + 1 = 5051(错误!多加了初始值)3.3 本例的累加过程
| 迭代 | i | sum(更新前) | sum += i | sum(更新后) |
|---|---|---|---|---|
| 1 | 0 | 0 | 0 + 0 | 0 |
| 2 | 1 | 0 | 0 + 1 | 1 |
| 3 | 2 | 1 | 1 + 2 | 3 |
| 4 | 3 | 3 | 3 + 3 | 6 |
| ... | ... | ... | ... | ... |
| 101 | 100 | 4950 | 4950 + 100 | 5050 |
3.4 其他累加器变体:count、min、max
累加器模式不仅限于数值累加,它是一个通用框架:
#include <stdio.h>
#include <limits.h>
int main(void)
{
int arr[] = {42, 17, 89, 5, 73, 28, 91, 10};
int n = sizeof(arr) / sizeof(arr[0]);
// 1. 求和 (sum):初始值为 0(加法单位元)
int sum = 0;
for (int i = 0; i < n; i++) sum += arr[i];
// 2. 累乘 (product):初始值为 1(乘法单位元)
long long product = 1;
for (int i = 0; i < n; i++) product *= arr[i];
// 3. 计数 (count):初始值为 0(加法单位元)
int count = 0;
for (int i = 0; i < n; i++)
if (arr[i] > 50) count++;
// 4. 最大值 (max):初始值为 INT_MIN 或第一个元素
int max = INT_MIN;
for (int i = 0; i < n; i++)
if (arr[i] > max) max = arr[i];
// 5. 最小值 (min):初始值为 INT_MAX 或第一个元素
int min = INT_MAX;
for (int i = 0; i < n; i++)
if (arr[i] < min) min = arr[i];
printf("sum = %d\n", sum); // 355
printf("count (>50) = %d\n", count); // 3
printf("max = %d\n", max); // 91
printf("min = %d\n", min); // 5
return 0;
}TIP
累加器初始值的选择原则:选择操作的单位元。如果找不到单位元(如找最大值),就用第一个元素作为初始值,或者用 INT_MIN / INT_MAX(需 #include <limits.h>)。
3.5 min/max 初始化的两种策略对比
// 策略 A:用 INT_MIN(需要 <limits.h>)
int max = INT_MIN;
for (int i = 0; i < n; i++)
if (arr[i] > max) max = arr[i];
// 优点:空数组时返回 INT_MIN(有定义)
// 缺点:需要额外的头文件,INT_MIN 不是真正的"单位元"
// 策略 B:用第一个元素
int max = arr[0];
for (int i = 1; i < n; i++)
if (arr[i] > max) max = arr[i];
// 优点:无需额外头文件,结果永远来自数组
// 缺点:空数组时 arr[0] 越界(未定义行为)| 策略 | 适用场景 | 风险 |
|---|---|---|
INT_MIN | 可能为空的数组 | 需 <limits.h>,语义略抽象 |
arr[0] | 保证非空的数组 | 空数组会越界 |
4. 高斯求和公式:数学之美与算法思维
4.1 故事与数学推导
传说高斯(Carl Friedrich Gauss)小学时,老师让全班计算 1 + 2 + 3 + ... + 100。年幼的高斯很快写出答案 5050,他的思路是:
1 + 2 + 3 + ... + 98 + 99 + 100
+ 100 + 99 + 98 + ... + 3 + 2 + 1
-----------------------------------------
101 + 101 + 101 + ... + 101 + 101 + 101将原序列和反向序列相加,得到 100 对 (n+1),即 100 × 101 = 10100。因为加了两次,除以 2 得到 5050。
一般化推导:
sum(1..n) = 1 + 2 + 3 + ... + (n-2) + (n-1) + n
将第一项和最后一项配对: 1 + n = n + 1
将第二项和倒数第二项配对: 2 + (n-1) = n + 1
...
共有 n/2 对,每对和为 n+1
当 n 为偶数时:sum = (n/2) × (n+1) = n × (n+1) / 2
当 n 为奇数时:中间项为 (n+1)/2,但仍适用 n(n+1)/2
验证 n=5: 5×6/2 = 15 = 1+2+3+4+5 ✓
验证 n=100: 100×101/2 = 5050 ✓NOTE
高斯公式对任意非负整数 n 都成立——无论 n 是奇数还是偶数。这是因为 n(n+1) 必然是偶数(两个连续整数必有一个是偶数),所以整数除法不会产生余数。
4.2 数学归纳法证明
对于 sum(1..n) = n(n+1)/2:
基础步骤 (n=1):1 = 1 × 2 / 2 = 1 ✓
归纳步骤:假设公式对 k 成立,即 sum(1..k) = k(k+1)/2。 对于 k+1:
sum(1..k+1) = sum(1..k) + (k+1)
= k(k+1)/2 + (k+1)
= (k(k+1) + 2(k+1)) / 2
= (k+1)(k+2) / 2
= (k+1)((k+1)+1) / 2恰好是公式在 n=k+1 的形式。由数学归纳法,公式对所有正整数 n 成立。
4.3 O(n) 循环 vs O(1) 公式
对比两种实现方式:
#include <stdio.h>
int main(void)
{
int n = 100;
// 方法 1:循环累加 — O(n) 时间复杂度
int sum_loop = 0;
for (int i = 1; i <= n; i++)
sum_loop += i;
// 方法 2:数学公式 — O(1) 时间复杂度
int sum_formula = n * (n + 1) / 2;
printf("Loop: sum = %d\n", sum_loop); // 5050
printf("Formula: sum = %d\n", sum_formula); // 5050
return 0;
}| 维度 | 循环累加 O(n) | 数学公式 O(1) |
|---|---|---|
| 时间复杂度 | O(n) — 需要迭代 n 次 | O(1) — 一次计算 |
| 空间复杂度 | O(1) | O(1) |
| 可读性 | 直观:逐步累加 | 需知公式 |
| 适用性 | 可累加任意序列 | 只适用于连续整数 |
| n=100 时 | 101 次加法 | 1 次乘法 + 1 次除法 |
| n=10^9 时 | 10 亿次加法(秒级) | 1 次计算(纳秒级) |
IMPORTANT
这个对比揭示了计算机科学的核心原则:选择合适的算法比微观优化代码更重要。O(n) 和 O(1) 的差距在小数据量时不明显,但数据量增长时差距呈线性扩大。对入门者而言,循环累加的价值在于演示 for 和累加器的基本用法——这才是本课的重点。
4.4 公式的局限性
高斯公式虽然快,但只适用于连续整数。考虑以下情况:
#include <stdio.h>
int main(void)
{
// 场景 1:不是从 1 开始
// sum(m..n) = sum(1..n) - sum(1..m-1)
// = n(n+1)/2 - (m-1)m/2
int m = 50, n = 100;
int sum_range = n * (n + 1) / 2 - (m - 1) * m / 2;
printf("Sum from %d to %d = %d\n", m, n, sum_range);
// 场景 2:只加偶数
// sum_even(1..n) = 2 + 4 + ... + n (n 为偶数)
// = 2 × (1 + 2 + ... + n/2)
// = 2 × (n/2)(n/2+1)/2 = (n/2)(n/2+1)
n = 100;
int sum_even = (n / 2) * (n / 2 + 1);
printf("Sum of evens to %d = %d\n", n, sum_even); // 2550
// 场景 3:任意序列 [42, 17, 89, 5, ...] — 没有公式,只能循环
return 0;
}5. 整数溢出:当累加超出 int 的边界
5.1 int 的值范围
1 + 2 + ... + 100 = 5050,在 int 范围内安全。但如果求和范围更大呢?
int 在 32 位系统上的值范围(<limits.h> 中定义):
INT_MIN = -2,147,483,648 (即 -2^31)
INT_MAX = 2,147,483,647 (即 2^31 - 1)溢出临界点:
sum(1..n) = n(n+1)/2
当 n = 65535: sum = 65535 × 65536 / 2 = 2,147,450,880 → 安全(< INT_MAX)
当 n = 65536: sum = 65536 × 65537 / 2 = 2,147,516,416 → 安全(恰好 < INT_MAX)
当 n = 65537: sum = 65537 × 65538 / 2 = 2,147,839,936 → 溢出!(> INT_MAX)WARNING
整数溢出是 C 语言中最隐蔽的错误之一——程序不会崩溃,只是结果错误。永远考虑你的数据范围是否在 int 的安全边界内。有符号整数溢出在 C 标准中是未定义行为(Undefined Behavior)。
5.2 有符号整数溢出 vs 无符号整数溢出
有符号和无符号整数的溢出行为有本质区别:
#include <stdio.h>
#include <limits.h>
int main(void)
{
// 有符号整数溢出 → 未定义行为
int a = INT_MAX;
// int b = a + 1; // 未定义行为!编译器可以优化掉或产生任意结果
// 无符号整数溢出 → 定义良好的模运算(wraparound)
unsigned int u = UINT_MAX;
unsigned int v = u + 1;
printf("UINT_MAX = %u\n", u); // 4294967295
printf("UINT_MAX + 1 = %u\n", v); // 0(回绕到 0)
// C 标准规定:无符号整数运算结果 = 数学结果 mod (2^N)
return 0;
}| 类型 | 溢出行为 | C 标准 |
|---|---|---|
int(有符号) | 未定义行为 | 编译器可以假设不会发生,做激进优化 |
unsigned int(无符号) | 模 2^N 回绕(wraparound) | 定义良好,结果可预测 |
CAUTION
有符号整数溢出是未定义行为——编译器可以基于「溢出不会发生」的假设做优化,导致程序行为完全不可预测。使用 -ftrapv(gcc/clang)可以让程序在溢出时崩溃,帮助调试。
5.3 使用 long long 处理更大范围
当 int 不够用时,使用 long long(C99 引入的 64 位整数):
#include <stdio.h>
#include <limits.h>
int main(void)
{
// int 只能安全计算到 n ≈ 65535
// long long 可以安全计算到 n ≈ 3,037,000,499(sqrt(2 × LLONG_MAX))
long long n = 1000000LL; // 一百万
long long sum = n * (n + 1) / 2;
printf("Sum of 1 to %lld = %lld\n", n, sum);
// 输出: Sum of 1 to 1000000 = 500000500000
// 比较各类型的范围
printf("INT_MAX = %d\n", INT_MAX); // ~2.1 × 10^9
printf("LLONG_MAX = %lld\n", LLONG_MAX); // ~9.2 × 10^18
return 0;
}| 类型 | 位宽 | 大致范围 | 安全求和的 n 上限 |
|---|---|---|---|
int | 32 | ±2.1 × 10^9 | ~65,535 |
long long | 64 | ±9.2 × 10^18 | ~3,037,000,499 |
unsigned long long | 64 | 0 ~ 1.8 × 10^19 | ~4,294,967,295 |
TIP
在不确定数据范围时,优先使用 long long 或 int64_t(<stdint.h>)。多出的几个字节内存换取数值安全是值得的。但也要注意:long long 不是无限大——足够大的 n 仍然会溢出。
5.4 溢出检测技术
在累加循环中检测溢出:
#include <stdio.h>
#include <limits.h>
int main(void)
{
int sum = 0;
int n = 70000; // 故意使用一个会导致溢出的 n
for (int i = 1; i <= n; i++)
{
// 加法溢出检测:如果 sum > INT_MAX - i,再加 i 就会溢出
if (sum > INT_MAX - i)
{
printf("Overflow detected at i = %d!\n", i);
printf("Current sum = %d, INT_MAX = %d\n", sum, INT_MAX);
return 1; // 非正常退出
}
sum += i;
}
printf("sum = %d\n", sum);
return 0;
}原理:a + b > INT_MAX 等价于 a > INT_MAX - b。后者避免了加法溢出本身的问题。
6. 循环变量的生命周期与终值
6.1 循环退出后 i 的终值
int i;
for (i = 0; i <= 100; i++) {
sum += i;
}
printf("After loop: i = %d\n", i); // 输出 101执行流程:
| 步骤 | i 的值 | 操作 |
|---|---|---|
| 初始化 | 0 | i = 0 |
| 迭代 1 | 0 | 判断 0 <= 100 ✓ → 执行循环体 → i++ → i = 1 |
| ... | ... | ... |
| 最后一次迭代 | 100 | 判断 100 <= 100 ✓ → 执行循环体 → i++ → i = 101 |
| 退出检查 | 101 | 判断 101 <= 100 ✗ → 不执行循环体,退出 |
关键:循环退出时 i = 101。条件不满足的那次迭代,循环体不执行,但 i++ 已经在上一次迭代中执行了。
6.2 C89 vs C99:for-init 变量的作用域差异
这是两种标准的重要实际差异:
// C99 写法:i 作用域仅限于 for 循环
for (int i = 0; i <= 100; i++) { // i 在此声明
sum += i;
}
// printf("%d\n", i); // 编译错误:i 未声明
// C89 写法:i 在 for 外部可见
int i; // 在 for 外声明
for (i = 0; i <= 100; i++) {
sum += i;
}
printf("After loop: i = %d\n", i); // OK: i = 101| 标准 | for-init 变量作用域 | 循环外访问 | 默认编译器 |
|---|---|---|---|
| C89/C90 | 所在代码块 | ✓ 可以 | gcc 默认 (-std=gnu89) |
| C99+ | 仅 for 循环 | ✗ 不可以 | gcc -std=c99 或 -std=c11 |
TIP
优先使用 C99 的 for (int i = ...) 写法——限制作用域可以避免变量名冲突。只在确实需要终值时才在 for 外声明。
6.3 不同循环条件的终值规律
#include <stdio.h>
int main(void)
{
int i;
// 条件用 <=
for (i = 0; i <= 5; i++) { }
printf("i <= 5 → final i = %d\n", i); // 6
// 条件用 <
for (i = 0; i < 5; i++) { }
printf("i < 5 → final i = %d\n", i); // 5
// 条件用 >=
for (i = 5; i >= 1; i--) { }
printf("i >= 1 → final i = %d\n", i); // 0
// 条件用 >
for (i = 5; i > 1; i--) { }
printf("i > 1 → final i = %d\n", i); // 1
return 0;
}规律:终值 = 第一个使条件为假的值。对于 i <= N 模式,终值为 N+1;对于 i < N 模式,终值为 N。
7. continue 与 break:循环控制流的深入对比
7.1 continue 的工作机制
continue 跳过当前迭代的剩余部分,进入下一次迭代:
for (int i = 0; i <= 100; i++) {
if (i % 2 != 0) // 如果是奇数
continue; // 跳过累加,进入下一次迭代
sum += i; // 只有偶数才累加
}执行 continue 后:
- 跳过循环体中
continue之后的所有语句 - 在
for循环中:先执行更新表达式(i++),再判断条件 - 在
while循环中:直接跳到条件判断(不执行更新!)
7.2 continue 在 for 和 while 中的关键区别
#include <stdio.h>
int main(void)
{
// for 循环:continue 后 i++ 仍然执行
printf("for: ");
for (int i = 0; i < 10; i++) {
if (i == 5) continue; // 跳到 i++,然后判断 i < 10
printf("%d ", i);
}
printf("\n");
// 输出:0 1 2 3 4 6 7 8 9(跳过了 5)
// while 循环:continue 后 i++ 被跳过!可能死循环!
printf("while: ");
int i = 0;
while (i < 10) {
if (i == 5) { i++; continue; } // 必须在 continue 前手动更新!
printf("%d ", i);
i++;
}
printf("\n");
// 正确输出:0 1 2 3 4 6 7 8 9
return 0;
}| 语句 | for 中 | while 中 |
|---|---|---|
continue | 先执行更新表达式,再判断条件 | 直接跳到条件判断(跳过更新!) |
break | 立即退出循环 | 立即退出循环 |
CAUTION
在 while 循环中使用 continue 时,必须确保更新语句在 continue 之前执行,否则可能死循环。这也是 for 循环更适合过滤迭代的原因——更新表达式不会被跳过。
7.3 break 与 continue 的对比
for (int i = 0; i < 10; i++) {
if (i == 3) continue; // 跳过 i=3,继续 i=4
if (i == 7) break; // 在 i=7 时完全退出循环
printf("%d ", i);
}
// 输出: 0 1 2 4 5 6| 语句 | 效果 | 适用场景 |
|---|---|---|
continue | 跳过本次迭代剩余部分 | 过滤不需要处理的元素 |
break | 终止整个循环 | 提前退出(找到目标、遇到错误) |
7.4 扩展示例:用 continue 跳过多个条件
#include <stdio.h>
int main(void)
{
int sum = 0;
for (int i = 1; i <= 100; i++)
{
// 跳过 3 的倍数
if (i % 3 == 0) continue;
// 跳过以 7 结尾的数字
if (i % 10 == 7) continue;
sum += i;
}
// 加了所有不是 3 的倍数且不以 7 结尾的数
printf("Filtered sum = %d\n", sum);
return 0;
}使用 continue 的多层过滤比嵌套 if 更清晰——每个过滤条件独立一行,易于理解和修改。
8. 嵌套循环:sum of sums 与迭代次数
8.1 嵌套循环基本结构
当一个循环体内包含另一个循环时,就构成了嵌套循环。外层循环每执行一次,内层循环完整执行一轮:
#include <stdio.h>
int main(void)
{
int total = 0;
// 外层循环:i 从 1 到 3
for (int i = 1; i <= 3; i++)
{
int row_sum = 0;
// 内层循环:j 从 1 到 i
for (int j = 1; j <= i; j++)
{
row_sum += j;
}
printf("sum(1..%d) = %d\n", i, row_sum);
total += row_sum;
}
printf("Total sum of sums = %d\n", total);
// 输出:
// sum(1..1) = 1
// sum(1..2) = 3
// sum(1..3) = 6
// Total sum of sums = 10
return 0;
}8.2 迭代次数分析
for (int i = 0; i < M; i++) // 外层:M 次
for (int j = 0; j < N; j++) // 内层:每次 N 次
do_something(i, j);
// 总迭代次数 = M × N如果内层循环的迭代次数依赖于外层变量:
#include <stdio.h>
int main(void)
{
int count = 0;
int n = 5;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
count++;
// 总迭代次数 = 1 + 2 + 3 + 4 + 5 = n(n+1)/2
printf("Total iterations = %d\n", count); // 15
printf("Formula: %d*(%d+1)/2 = %d\n", n, n, n*(n+1)/2);
return 0;
}这就是时间复杂度分析的雏形:内层依赖外层的嵌套循环,总迭代次数为 O(n²)。
8.3 三重循环示例:计算所有 (i,j,k) 组合的和
#include <stdio.h>
int main(void)
{
int sum = 0;
int n = 3;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
for (int k = 1; k <= n; k++)
sum += i + j + k;
printf("sum = %d\n", sum);
// 迭代次数 = 3 × 3 × 3 = 27 (n³)
// 每次加 i+j+k,总结果取决于排列组合
return 0;
}三重嵌套循环的迭代次数为 O(n³)——这是许多朴素算法的复杂度(如矩阵乘法的朴素实现)。
9. 时间复杂度入门:O(1)、O(n)、O(n²)
9.1 什么是时间复杂度
时间复杂度描述算法的运行时间如何随输入规模增长。它不关心具体的秒数,而是关心增长趋势(增长率)。
常见时间复杂度从快到慢:
O(1) → 常数时间:不管输入多大,运行时间不变
O(log n) → 对数时间:输入翻倍,运行时间增加一个常数
O(n) → 线性时间:输入翻倍,运行时间也翻倍
O(n²) → 平方时间:输入翻倍,运行时间变 4 倍
O(2ⁿ) → 指数时间:输入增加 1,运行时间翻倍(极其可怕)9.2 直观对比
| n | O(1) | O(log n) | O(n) | O(n²) | O(2ⁿ) |
|---|---|---|---|---|---|
| 1 | 1 | 0 | 1 | 1 | 2 |
| 10 | 1 | ~3 | 10 | 100 | 1024 |
| 100 | 1 | ~7 | 100 | 10,000 | ~1.3×10³⁰ |
| 1000 | 1 | ~10 | 1000 | 1,000,000 | 天文数字 |
9.3 从代码识别时间复杂度
// O(1):只做一次计算
int sum = n * (n + 1) / 2;
// O(n):单层循环
for (int i = 0; i < n; i++)
sum += i;
// O(n²):嵌套循环(内层依赖 n)
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
sum += i * j;
// O(n²/2) 仍写作 O(n²):常数因子在复杂度分析中忽略
for (int i = 0; i < n; i++)
for (int j = i; j < n; j++) // 平均迭代 n/2 次
sum += i * j;
// 总迭代 ≈ n²/2,仍是 O(n²)
// 两层独立循环 → O(n) 而非 O(n²)
for (int i = 0; i < n; i++) sum += i; // O(n)
for (int j = 0; j < n; j++) prod *= j; // O(n)
// 总计 O(n + n) = O(2n) = O(n)IMPORTANT
时间复杂度分析的核心原则:
- 忽略常数因子:O(2n) = O(n)
- 忽略低阶项:O(n² + n) = O(n²)
- 关注最内层循环的迭代次数
- 独立循环相加,嵌套循环相乘
10. 代码性能测量:如何计时你的循环
10.1 使用 clock() 测量
#include <stdio.h>
#include <time.h>
int main(void)
{
clock_t start, end;
double cpu_time;
// 测量循环累加 O(n)
start = clock();
long long sum_loop = 0;
for (int i = 1; i <= 100000000; i++) // 1 亿次
sum_loop += i;
end = clock();
cpu_time = ((double)(end - start)) / CLOCKS_PER_SEC;
printf("Loop O(n): sum = %lld, time = %.4f seconds\n",
sum_loop, cpu_time);
// 测量公式 O(1)
start = clock();
long long n = 100000000LL;
long long sum_formula = n * (n + 1) / 2;
end = clock();
cpu_time = ((double)(end - start)) / CLOCKS_PER_SEC;
printf("Formula O(1): sum = %lld, time = %.8f seconds\n",
sum_formula, cpu_time);
return 0;
}典型输出(具体数值因机器而异):
Loop O(n): sum = 5000000050000000, time = 0.1234 seconds
Formula O(1): sum = 5000000050000000, time = 0.00000012 secondsO(n) 和 O(1) 的差距在 n = 1 亿时已经非常明显——约百万倍的差异。
10.2 clock() 的精度与局限
clock()返回程序使用的 CPU 时间(不是墙上时钟时间)- 精度为
CLOCKS_PER_SEC(通常 1,000,000,即微秒级) - 太短的代码段(< 微秒)测量不准——需要重复执行取平均
- 多核/多线程环境下,CPU 时间可能超过实际耗时
TIP
更精确的性能测量可以使用 clock_gettime(CLOCK_MONOTONIC, &ts)(POSIX)或 QueryPerformanceCounter(Windows)。但对于学习目的,clock() 已经足够。
11. while 循环版本对比
11.1 for 与 while 的等价转换
#include <stdio.h>
int main(void)
{
int sum;
// 版本 1:for 循环
sum = 0;
for (int i = 0; i <= 100; i++)
sum += i;
printf("for: sum = %d\n", sum);
// 版本 2:while 循环(C99 风格——i 在外部声明)
sum = 0;
int i = 0;
while (i <= 100)
{
sum += i;
i++;
}
printf("while: sum = %d\n", sum);
// 版本 3:while 循环(C89 兼容——i 在块开头)
sum = 0;
i = 0;
while (i <= 100)
{
sum += i;
i++;
}
printf("while: sum = %d\n", sum);
return 0;
}11.2 选择 for 还是 while
| 场景 | 推荐 | 原因 |
|---|---|---|
| 迭代次数已知 | for | 三要素集中,一目了然 |
| 迭代次数未知 | while | 条件判断在顶部,灵活 |
| 至少执行一次 | do-while | 后测循环,天然保证一次 |
| 过滤迭代 | for | continue 不会跳过更新 |
| 无限循环 | for(;;) | 惯用写法,无警告 |
NOTE
for 循环在本课的场景下优于 while:迭代次数明确(0 到 100,共 101 次),三要素(初始化 int i=0、条件 i<=100、更新 i++)集中在一行,代码更紧凑。如果在循环体内使用 continue,for 也更安全——更新表达式 i++ 一定执行。
12. 边界情况:当 start > end 或涉及负数
12.1 空范围:start > end
#include <stdio.h>
int main(void)
{
int sum = 0;
// 当 start > end 时,循环体不执行
for (int i = 100; i <= 1; i++)
sum += i;
printf("Empty range sum = %d\n", sum); // 0
// sum 保持初始值 0,这是累加器模式的正确行为
return 0;
}如果使用公式计算,也需要处理这种情况:
int sum_range(int start, int end)
{
if (start > end) return 0; // 空范围返回 0
// sum(start..end) = sum(1..end) - sum(1..start-1)
return end * (end + 1) / 2 - (start - 1) * start / 2;
}12.2 包含负数的求和
#include <stdio.h>
int main(void)
{
int sum = 0;
// 从 -5 加到 5
for (int i = -5; i <= 5; i++)
sum += i;
printf("Sum from -5 to 5 = %d\n", sum); // 0
// 因为 -5 + (-4) + ... + 4 + 5 = 0(对称抵消)
// 从 -3 加到 7
sum = 0;
for (int i = -3; i <= 7; i++)
sum += i;
printf("Sum from -3 to 7 = %d\n", sum); // 22
// 验证: (-3)+(-2)+(-1)+0+1+2+3+4+5+6+7 = 22
return 0;
}对于包含负数的范围,高斯公式仍然适用:
sum(m..n) = sum(1..n) - sum(1..m-1)
= n(n+1)/2 - (m-1)m/2当 m 为负数时,sum(1..m-1) 会产生一个负的结果,减负等于加正——公式自动处理了符号。
13. 循环展开:以空间换时间的优化思想
13.1 什么是循环展开
循环展开(Loop Unrolling)是一种优化技术:将循环体复制多份,减少循环控制开销(比较、跳转、更新)。
#include <stdio.h>
#include <time.h>
int main(void)
{
const int n = 100000000;
long long sum;
clock_t start, end;
// 普通循环
start = clock();
sum = 0;
for (int i = 1; i <= n; i++)
sum += i;
end = clock();
printf("Normal: sum = %lld, time = %.4f s\n",
sum, ((double)(end - start)) / CLOCKS_PER_SEC);
// 循环展开(因子 4)
start = clock();
sum = 0;
int i;
for (i = 1; i <= n - 3; i += 4)
{
sum += i; // i
sum += i + 1; // i+1
sum += i + 2; // i+2
sum += i + 3; // i+3
}
// 处理剩余项(当 n 不能被 4 整除时)
for (; i <= n; i++)
sum += i;
end = clock();
printf("Unrolled: sum = %lld, time = %.4f s\n",
sum, ((double)(end - start)) / CLOCKS_PER_SEC);
return 0;
}13.2 循环展开的利弊
| 优点 | 缺点 |
|---|---|
| 减少分支指令(条件跳转) | 代码体积增大(更多指令) |
| 减少循环变量更新次数 | 指令缓存压力增大 |
| 增加指令级并行机会 | 可读性下降 |
| 某些场景显著提升性能 | 现代编译器通常会自动做 |
NOTE
在现代编译器中,-O2 或 -O3 优化级别会自动进行循环展开。手动展开通常不必要——编译器比你更了解目标 CPU 的流水线深度和缓存特性。这里展示是为了理解编译优化原理,而非建议手写展开。
13.3 编译器比你更聪明:高斯公式的常量折叠
更有趣的是,编译器在 -O2 优化下甚至能识别高斯求和模式:
// 如果你写:
int n = 100;
int sum = 0;
for (int i = 1; i <= n; i++) sum += i;
// gcc -O2 可能会把它优化为:
int sum = 5050; // 直接在编译期算出了结果!当 n 是编译期常量时,编译器会做常量折叠(Constant Folding)——在编译时计算 n*(n+1)/2,直接嵌入结果。这意味着你的 O(n) 循环在优化后变成了 O(1)。
如果 n 是运行时变量,编译器不会直接算出结果,但可能用 SIMD 指令(如 AVX2)进行向量化累加。
14. 有符号与无符号整数的深入对比
14.1 表示范围
#include <stdio.h>
#include <limits.h>
int main(void)
{
printf("=== signed int ===\n");
printf("INT_MIN = %d\n", INT_MIN); // -2147483648
printf("INT_MAX = %d\n", INT_MAX); // 2147483647
printf("Range: [-2^31, 2^31-1]\n");
printf("\n=== unsigned int ===\n");
printf("0 = %u\n", 0);
printf("UINT_MAX = %u\n", UINT_MAX); // 4294967295
printf("Range: [0, 2^32-1]\n");
printf("\n=== size comparison ===\n");
printf("sizeof(int) = %zu bytes\n", sizeof(int));
printf("sizeof(unsigned int) = %zu bytes\n", sizeof(unsigned int));
// 两者大小完全相同(通常 4 字节)
return 0;
}14.2 混合运算的类型提升陷阱
#include <stdio.h>
int main(void)
{
int a = -1;
unsigned int b = 1;
// 混合比较:-1 被提升为 unsigned,变成 UINT_MAX
if (a < b)
printf("As expected: -1 < 1\n");
else
printf("Surprise: -1 >= 1 (because of unsigned promotion!)\n");
// 实际输出:Surprise: -1 >= 1
// 原因:当 signed 和 unsigned 混合时,signed 被隐式转换为 unsigned
// -1 的二进制表示为全 1 (0xFFFFFFFF),作为 unsigned 就是 4294967295
// 安全做法:显式比较
if (a < (int)b)
printf("Fixed: -1 < 1\n");
return 0;
}CAUTION
有符号和无符号整数的混合运算是 C 语言中著名的陷阱。当 signed 和 unsigned 出现在同一个表达式中时,signed 被隐式转换为 unsigned——负数会变成巨大的正数。循环条件中尤其要注意:for (unsigned i = 10; i >= 0; i--) 是无限循环!因为 i 永远 >= 0。
14.3 unsigned 递减的死循环陷阱
// 无限循环!unsigned 永远不会 < 0
for (unsigned int i = 10; i >= 0; i--)
printf("%u\n", i);
// 当 i = 0 时,i-- 使 i 变为 UINT_MAX,仍 >= 0
// 正确写法 1:用 signed int
for (int i = 10; i >= 0; i--)
printf("%d\n", i);
// 正确写法 2:改条件
for (unsigned int i = 10; i != UINT_MAX; i--)
printf("%u\n", i);WARNING
使用 unsigned 做递减循环时,条件 >= 0 永远为真——这是无限循环的经典来源。对递减循环,使用 signed int。
15. 循环调试技巧:打印中间值观察累加过程
15.1 基本调试打印
在开发累加逻辑时,打印中间值是理解程序行为最直接的方式:
#include <stdio.h>
int main(void)
{
int sum = 0;
printf("=== Loop Trace ===\n");
printf("%-6s %-8s %s\n", "i", "sum_before", "sum_after");
for (int i = 0; i <= 10; i++) // 先小范围调试
{
int sum_before = sum;
sum += i;
printf("%-6d %-8d %d\n", i, sum_before, sum);
}
printf("\nFinal sum = %d\n", sum);
return 0;
}输出:
=== Loop Trace ===
i sum_before sum_after
0 0 0
1 0 1
2 1 3
3 3 6
...
10 45 55
Final sum = 5515.2 条件编译调试宏:PRINTD
回顾 Lesson 02 中接触过的条件编译技巧,在循环累加中特别有用:
#include <stdio.h>
// 调试宏:编译时加 -DDEBUG 启用,否则展开为空
#ifdef DEBUG
#define PRINTD(fmt, ...) printf("[DEBUG] " fmt, ##__VA_ARGS__)
#else
#define PRINTD(fmt, ...) ((void)0)
#endif
int main(void)
{
int sum = 0;
for (int i = 0; i <= 100; i++)
{
sum += i;
PRINTD("i = %3d, sum = %d\n", i, sum);
}
printf("sum = %d\n", sum);
return 0;
}编译运行:
gcc -DDEBUG debug_macro.c -o debug_macro # 包含调试输出(打印 101 行)
gcc debug_macro.c -o debug_macro # 不包含调试输出(只打印最终结果)15.3 更精细的调试:打印每第 N 次迭代
对于大规模循环(如 1 到 10000),每次迭代都打印不现实:
#include <stdio.h>
int main(void)
{
int sum = 0;
int n = 10000;
for (int i = 1; i <= n; i++)
{
sum += i;
// 每 1000 次打印一次进度
if (i % 1000 == 0)
printf("Progress: i = %5d, partial sum = %d\n", i, sum);
}
printf("Final sum = %d\n", sum);
return 0;
}输出:
Progress: i = 1000, partial sum = 500500
Progress: i = 2000, partial sum = 2001000
...
Progress: i = 10000, partial sum = 50005000
Final sum = 50005000TIP
调试大型循环时的采样策略:
- 每 N 次迭代打印一次(
i % N == 0) - 只打印开头和结尾的几次迭代
- 打印关键状态变化点(如 sum 的位数变化时)
- 使用
#ifdef DEBUG包裹调试代码,发布时一键关闭
参考解答
1 到 100 求和
#include <stdio.h>
int main(void)
{
int sum = 0;
for (int i = 0; i <= 100; i++)
{
sum += i;
}
printf("sum = %d\n", sum);
return 0;
}核心逻辑:sum 初始为 0(加法单位元),i 从 0 遍历到 100,每次 sum += i 累加。i = 0 时 sum += 0 不改变结果,所以从 0 或 1 开始都可以——但 0 + 1 + ... + 100 = 5050 与 1 + ... + 100 = 5050 结果相同。
偶数和(使用 continue)
#include <stdio.h>
int main(void)
{
int sum = 0;
for (int i = 0; i <= 100; i++)
{
if (i % 2 != 0) // 如果是奇数
continue; // 跳过累加
sum += i; // 只有偶数才累加
}
printf("sum = %d\n", sum);
return 0;
}输出 sum = 2550。continue 跳过奇数后,for 的更新表达式 i++ 仍然执行(这是 for 与 while 的关键区别——回顾深度讲解第 7 节)。
数学公式版(O(1))
#include <stdio.h>
int main(void)
{
int n = 100;
int sum = n * (n + 1) / 2;
printf("sum = %d\n", sum);
return 0;
}高斯公式:sum = n × (n+1) / 2。一次计算,无需循环。时间复杂度 O(1),比循环累加的 O(n) 快 n 倍。当 n 为编译期常量时,编译器甚至会在编译时直接算出结果(常量折叠)。
while 循环版本
#include <stdio.h>
int main(void)
{
int sum = 0;
int i = 0;
while (i <= 100)
{
sum += i;
i++;
}
printf("sum = %d\n", sum);
return 0;
}while 版本与 for 版本逻辑完全等价,但三要素分散在三处(i = 0 在外部、i <= 100 在条件中、i++ 在循环体末尾)。对于迭代次数已知的场景,for 更清晰——三要素集中在一行,读者一眼就能看出循环的完整逻辑。
对照检查:
sum初始值是否为 0?for条件用的是<=还是<?用i <= 100时结果是 5050,用i < 100时结果是 4950(差 100)。如果求和范围扩大到 1 到 100000,int 还安全吗?试试改用long long。
课堂讨论
- 在
for循环内部修改循环变量i的值,可以吗?会有什么后果? - 循环退出时,
i++是否还会执行?最终i的值是多少? continue在while和for中的行为有什么关键区别?- 为什么累加器
sum的初始值是 0?如果初始值是 1 会怎样? - 把
i <= 100写成i < 100,结果会差多少? - 如果求和范围是 1 到 100000,使用
int会有什么风险?如何安全处理?
讨论答案
Q1: 在 for 循环内部修改循环变量 i 的值可以吗?
语法上可以,但强烈不推荐。 修改循环变量破坏循环的可预测性。
for (int i = 0; i < 10; i++) {
printf("%d\n", i);
i += 2; // 修改循环变量!实际每次迭代 i 增加 3
}
// 输出:0, 3, 6, 9后果:读者看到 for (i = 0; i < 10; i++) 会预期 10 次迭代,但内部修改破坏了这一预期。更危险的是,修改方向错误可能导致死循环。
更深层的问题:如果在循环体内修改了循环变量,任何代码审查者都需要重新推导整个循环的执行轨迹——这违背了 for 循环「三要素在一行」的设计初衷。for 的设计理念是让你一眼看懂循环的执行模式,内部修改打破了这个契约。
规则:循环变量由 for 头部的更新表达式管理,不要在循环体内修改。需要跳过某些迭代时,用 continue。如果确实需要复杂的迭代模式,改用 while 并在循环体内清晰表达每一步的更新逻辑。
Q2: 循环退出时 i++ 是否还会执行?最终 i 的值是多少?
i++ 会执行到条件不满足为止。 最后一次进入循环体时 i = 100,循环体执行后 i++ → i = 101,判断 101 <= 100 为假,退出。最终 i = 101。
int i;
for (i = 0; i <= 100; i++) { sum += i; }
printf("After loop: i = %d\n", i); // 输出 101注意:条件不满足的那次,循环体不执行,但上一次迭代中的 i++ 已经把 i 推到了 101。
通用规律:循环退出后,循环变量的值 = 第一个使条件为假的值。
for (i = 0; i <= N; i++)→ 终值N+1for (i = 0; i < N; i++)→ 终值Nfor (i = N; i >= 1; i--)→ 终值0
这是理解循环行为的关键——如果你在循环后还需要使用循环变量,必须清楚地知道它的终值。
Q3: continue 在 while 和 for 中的关键区别
核心区别:continue 在 for 中会执行更新表达式,在 while 中不会。
// for: continue 后 i++ 仍然执行 → 安全
for (int i = 0; i < 10; i++) {
if (i == 5) continue; // 跳到 i++,然后判断
printf("%d ", i);
}
// 输出:0 1 2 3 4 6 7 8 9
// while: continue 后 i++ 被跳过 → 可能死循环
int i = 0;
while (i < 10) {
if (i == 5) continue; // 跳到条件判断,跳过 i++!
printf("%d ", i);
i++;
}
// 输出:0 1 2 3 4(然后死循环!i 永远 = 5)为什么会这样设计? 因为 for 的更新表达式是循环结构的一部分——它不是循环体的一部分。continue 的含义是「跳过循环体剩余部分,进入下一次迭代」,在 for 中「进入下一次迭代」包含执行更新表达式。而在 while 中,更新语句在循环体内,continue 自然跳过了它。
最佳实践:
- 在
while中使用continue时,确保更新语句在continue之前 - 或者改用
for结构——这是for更适合过滤迭代的根本原因
Q4: 为什么累加器 sum 的初始值是 0?
初始值必须是加法的单位元——0。 因为 x + 0 = x,加 0 不改变结果。
如果初始值是 1:
int sum = 1; // 不是加法单位元
for (int i = 1; i <= 100; i++) sum += i;
// 结果:5050 + 1 = 5051(错误!)单位元的通用规则:
| 运算 | 单位元 | 初始值 | 示例 |
|---|---|---|---|
| 加法 (+) | 0 | sum = 0 | 0 + x = x |
| 乘法 (×) | 1 | product = 1 | 1 × x = x |
| 位或 (|) | 0 | flags = 0 | 0 | x = x |
| 位与 (&) | 全 1 | mask = ~0 | ~0 & x = x |
| 找最大值 | 无 | INT_MIN 或 arr[0] | — |
| 找最小值 | 无 | INT_MAX 或 arr[0] | — |
更深层的理解:累加器模式本质上是将「二元运算」通过「单位元」转化为「可迭代的累积计算」。初始值 = 单位元这个规则使得「空集合」的处理天然正确——如果循环体一次都不执行(如 start > end),累加器保持单位元,结果是数学上正确的「空和 = 0」。
Q5: 把 i <= 100 写成 i < 100 结果差多少?
差 100——恰好是被跳过的最后一个值。
// i <= 100: sum = 0+1+2+...+100 = 5050
// i < 100: sum = 0+1+2+...+99 = 4950(少加了 100)差值 = 5050 - 4950 = 100。这就是经典的 off-by-one(差一)错误——编程中最常见的逻辑错误之一。
为什么 off-by-one 如此常见?
人类计数从 1 开始,计算机从 0 开始。i <= 100 包含 101 个值(0 到 100),i < 100 包含 100 个值(0 到 99)。看似差 1,实则差了一整个边界值。
避免方法:
- 用小值手动验证:求
1 + 2 + 3,用i <= 3得 6 ✓,用i < 3得 4 ✗ - 遵循「从 0 开始,用
<」的约定:for (int i = 0; i < N; i++)→ N 次迭代,一目了然 - 用
<=时额外小心——它天然多包含一个边界值
Q6: 如果求和范围是 1 到 100000,使用 int 会有什么风险?如何安全处理?
风险分析:
1 到 100000 的和 = 100000 × 100001 / 2 = 5,000,050,000
这个值约为 50 亿,已经超过 int 的最大值约 21 亿(INT_MAX = 2,147,483,647)。使用 int 计算会导致有符号整数溢出——未定义行为。
int n = 100000;
int sum_loop = 0; // int 不够用!
for (int i = 1; i <= n; i++)
sum_loop += i;
// sum_loop 在某个时刻溢出,结果是未定义的
int sum_formula = n * (n + 1) / 2;
// n * (n+1) 首先溢出 int,然后再除以 2,结果错误安全处理方案:
方案 1:使用 long long(推荐)
long long n = 100000LL;
long long sum = n * (n + 1) / 2;
// sum = 5000050000,安全方案 2:使用 unsigned int 避免未定义行为(但结果仍可能回绕)
unsigned int n = 100000;
unsigned int sum = n * (n + 1) / 2;
// 注意:n*(n+1) 作为 unsigned int 计算时可能回绕
// 最好还是用 long long方案 3:公式运算时先提升类型
int n = 100000;
long long sum = (long long)n * (n + 1) / 2;
// 将 n 强制转换为 long long,后续运算在 64 位空间进行教训:在选择数据类型时,要估计最终结果的范围,而非只看单个元素的大小。累加会放大数值——n 个元素的累加结果可能是 O(n²) 级别。
课后练习
计算平均数:用户输入 5 个数,计算并打印它们的平均数(保留两位小数)。
知识点提示:累加器
sum+ 类型转换(double)sum / 5找最大值与最小值:用户输入 5 个数,找出并打印最大和最小的数。
知识点提示:维护「当前最优值」的累加器变体,初始值用
INT_MAX和INT_MIN打印月历:已知 11 月 1 日是星期四,打印 11 月份的月历。
知识点提示:
%2d格式对齐、(start + day) % 7 == 0判断换行挑战:奇偶分别求和:只用一层循环,分别计算 1 到 100 中所有奇数的和与偶数的和。
知识点提示:在循环体内用
if/else分别累加两个累加器挑战:性能对比:用
clock()分别测量循环累加和高斯公式计算 1 到 1 亿的和,比较耗时。知识点提示:
clock()和CLOCKS_PER_SEC、long long避免溢出
练习1: 计算平均数
#include <stdio.h>
int main(void)
{
int num, sum = 0;
printf("Enter 5 numbers:\n");
for (int i = 0; i < 5; i++)
{
printf("Number %d: ", i + 1);
scanf("%d", &num);
sum += num;
}
double average = (double)sum / 5;
printf("Average = %.2f\n", average);
return 0;
}关键:(double)sum / 5 将 sum 强制转为 double,使除法产生浮点结果。直接用 sum / 5 会做整数除法(截断小数部分)。%.2f 保留两位小数。
练习2: 找最大值与最小值
#include <stdio.h>
#include <limits.h>
int main(void)
{
int num, max = INT_MIN, min = INT_MAX;
printf("Enter 5 numbers:\n");
for (int i = 0; i < 5; i++)
{
printf("Number %d: ", i + 1);
scanf("%d", &num);
if (num > max) max = num;
if (num < min) min = num;
}
printf("Maximum = %d\n", max);
printf("Minimum = %d\n", min);
return 0;
}算法:维护「当前最优值」——每次新数据到来时比较更新。这是 O(n) 时间、O(1) 空间的在线算法(不需要存储所有数据)。初始值 INT_MIN 保证任何有效输入都能更新 max,INT_MAX 保证任何有效输入都能更新 min。
练习3: 打印月历
#include <stdio.h>
int main(void)
{
int days = 30; // 11 月有 30 天
int start = 4; // 1 号是星期四(0=周日, 1=周一, ..., 4=周四)
printf(" November\n");
printf("Su Mo Tu We Th Fr Sa\n");
// 打印 1 号之前的空白
for (int i = 0; i < start; i++)
printf(" ");
// 打印日期
for (int day = 1; day <= days; day++)
{
printf("%2d ", day);
if ((start + day) % 7 == 0)
printf("\n");
}
printf("\n");
return 0;
}逻辑:start = 4 表示前面有 4 个空位(日、一、二、三);(start + day) % 7 == 0 判断周六换行;%2d 使日期右对齐。
挑战1: 奇偶分别求和
#include <stdio.h>
int main(void)
{
int sum_even = 0;
int sum_odd = 0;
for (int i = 1; i <= 100; i++)
{
if (i % 2 == 0)
sum_even += i;
else
sum_odd += i;
}
printf("Sum of even: %d\n", sum_even); // 2550
printf("Sum of odd: %d\n", sum_odd); // 2500
printf("Total: %d\n", sum_even + sum_odd); // 5050
return 0;
}一次遍历,两个累加器——时间 O(n),空间 O(1)。数学验证:2550 + 2500 = 5050 = 100×101/2 ✓。
挑战2: 性能对比 — 循环 vs 公式
#include <stdio.h>
#include <time.h>
int main(void)
{
long long n = 100000000LL; // 1 亿
long long sum;
clock_t start, end;
double time_loop, time_formula;
// 循环累加 O(n)
start = clock();
sum = 0;
for (long long i = 1; i <= n; i++)
sum += i;
end = clock();
time_loop = ((double)(end - start)) / CLOCKS_PER_SEC;
printf("Loop O(n): sum = %lld, time = %.6f s\n", sum, time_loop);
// 公式 O(1)
start = clock();
sum = n * (n + 1) / 2;
end = clock();
time_formula = ((double)(end - start)) / CLOCKS_PER_SEC;
printf("Formula O(1): sum = %lld, time = %.9f s\n", sum, time_formula);
// 对比
if (time_formula > 0)
printf("Speedup: %.0f x faster\n", time_loop / time_formula);
return 0;
}典型输出(具体数值因机器而异):
Loop O(n): sum = 5000000050000000, time = 0.123456 s
Formula O(1): sum = 5000000050000000, time = 0.000000123 s
Speedup: 1003707 x faster分析:公式比循环快约一百万倍!这直观展示了 O(1) 和 O(n) 的本质差异。注意使用 long long 避免溢出——1 到 1 亿的和约为 5 × 10^15,远超 int 范围。
参考资料
- ISO C99 Standard, Section 6.8.5.3 —
for循环的正式定义与语义 - Gauss Summation — 高斯求和的历史与数学证明
- GNU C Library: Formatted Output —
printf格式符详解(%2d、%.2f等) - C Operator Precedence — 运算符优先级与复合赋值
- C Data Types and Sizes — 整数类型、范围与溢出行为
- GCC Optimize Options —
-O2优化与常量折叠
"If debugging is the process of removing bugs, then programming must be the process of putting them in." — Edsger W. Dijkstra