很多人学C++,学到for循环就开始犯迷糊。很多讲循环的文章也就停在“怎么写、表达式仨部分是啥”这个层面,直接教语法,实际应用时还是不会用。这个系列我打算换个思路写,把从for循环到算法初步这条路拆开揉碎了讲清楚,不仅告诉你该写什么代码,还要告诉你为什么这么写,以及你写到一半踩进去的坑到底是怎么来的。
这一篇只聚焦一个核心区间:从for循环这个语句,走向排序、查找这些最基础的算法雏形。目标读者是那种刚把if和switch搞明白、正准备在循环这里大干一场的初学者,也包括一些代码能跑但说不清原理的“半吊子”开发者。看完这篇,你至少能把for循环用得地道,能自己写出冒泡和选择排序,也大概明白时间复杂度到底在衡量什么。
1. 先搞清楚for循环的本质:它不只是一个语法糖
1.1 for循环的三个组成部分在说什么
先看最基本的形态:
cpp复制for (int i = 0; i < 10; ++i) {
std::cout << i << std::endl;
}
括号里三个部分,依次是初始化、条件、迭代。很多教程让你直接背,但你要真理解,就得从它的执行顺序看起。for循环不是“每次判断完条件然后执行语句”,它的实际执行顺序是:
- 执行初始化语句,且整个过程只执行一次。
- 判断条件,如果为真,进入循环体;如果为假,直接跳过。
- 执行循环体内部代码。
- 执行迭代语句,一般是
++i或i++。 - 回到第2步,再次判断条件。
也就是说,条件判断发生在循环体执行之前。如果第一次判断就是假的,那么循环体一次都不执行。这一点很多人第一次没注意,直到后面写“先至少执行一次”的需求时,才发现自己需要 do...while 而不是 for。
我习惯把它类比成跑步:初始化是站在起跑线,条件是裁判手里的终点线,迭代是你每跑一圈后自己做的标记。没有迭代,你就会一直跑下去——也就是死循环。
1.2 初始化变量写在循环内,意义比你想的大
C++允许直接在for的初始化部分定义变量:
cpp复制for (int i = 0; i < 5; ++i) { ... }
这里的 i 生命周期被限制在这个循环之内。也就是说,循环结束之后,i 就不可见了。好处是避免了变量泄漏到外面造成命名污染。但这也带来一个常见错误:想在循环结束后拿 i 的值,比如“循环执行到哪个位置了”,你发现拿不到。
cpp复制for (int i = 0; i < n; ++i) {
// 最后一次修改 i 的值,循环结束后不可见
}
// 想在此处使用 i?编译错误
此时就需要把 i 定义在循环外面:
cpp复制int i = 0;
for (; i < n; ++i) { ... }
// 这里可以继续用 i
这个细节在面试题和实际工程中经常出现,比如在数组里查找某个元素,找到后要返回它的索引。索引变量不能只在循环内定义,得放在外面留得住。
1.3 循环体该用什么形式,别被“大括号可省略”带偏
for循环在循环体只有一条语句时可以省略大括号:
cpp复制for (int i = 0; i < 10; ++i)
std::cout << i << std::endl;
语法没错,但我不建议你这么做。工程上维护代码的人可能不是你本人,就算是你本人,一个月后再看也不敢保证自己不晕。大括号不仅仅是语法结构,它是对循环体范围的明确声明。少了它,一旦往循环体里新增语句,忘了加括号,新语句就不在循环内运行,这种bug非常隐蔽,不是一眼能看出来的。
我见过最多的实战翻车现场就是这种:在某个循环里加了一行 count++,编译器没提示任何问题,运行结果却跟预期不符。你排查了大半天,最后才发现是的,没加大括号。所以一句话,for循环的大括号永远不要省。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. for循环的进阶形态:嵌套、边界与中断
2.1 嵌套循环:读多重循环从“内层跑完一轮”开始
嵌套循环本身不难,难的是你在脑子里模拟它的执行过程时容易乱。核心规则只有一条:外层循环每执行一次,内层循环要完整执行一遍。
cpp复制for (int i = 1; i <= 3; ++i) {
for (int j = 1; j <= 3; ++j) {
std::cout << i << "," << j << std::endl;
}
}
输出顺序是 1,1、1,2、1,3,然后才轮到 i = 2 的 2,1、2,2、2,3。不是 i 和 j 同步增加的。理解这个顺序对你后面写二维数组的遍历至关重要。写双重循环时,推荐的习惯是外层管行、内层管列,因为二维数组在内存中是按行连续存储的,这样遍历顺序和物理存储顺序一致,缓存命中率友好,执行效率更高。这个细节在你处理数据量变大以后,能直观感受到性能差异。
2.2 break、continue和goto的使用边界
break 是跳出当前这一层循环,注意是当前层,不是所有层。如果你在嵌套循环的内部循环里写break,它只能终止内层循环,外层循环完全不受影响。
continue 是跳过本次循环剩余的语句,直接进入下一次迭代。注意,“进入下一次迭代”之前,仍然会先执行迭代语句。所以我一直提醒,continue之前如果忘了递增变量的逻辑,循环很可能退化成死循环。
举个例子:
cpp复制for (int i = 0; i < 10; ++i) {
if (i % 2 == 0) {
continue;
}
std::cout << i << " ";
}
这段代码能正常输出奇数。但如果你把 ++i 写在循环体末尾而不再是for的第三部分,然后配合continue,问题就来了:
cpp复制for (int i = 0; i < 10; ) {
if (i % 2 == 0) {
continue; // i 永远不会增加
}
std::cout << i << " ";
++i;
}
一旦 i 是偶数,continue直接跳过了 ++i,i保持不变,下次还是偶数,无限循环。这是非常经典的一类错误。
goto 在现代C++里名声不好,但在跳出多层嵌套循环这种场景,它确实是最直接的手段。例如两层循环里找到一个值后想彻底退出,break只能退一层,你需要一个标志变量,或者直接goto。我的建议是:遇到这种需求,优先想清楚能不能用函数返回替代,函数内 return 是最优雅的多层退出方式。
2.3 边界问题:for循环里最经典的隐雷
C++的数组索引从0开始,这决定了循环条件里 i < n 而不是 i <= n。很多从其他语言转过来的初学者(甚至一些老手)在边界上翻车。
cpp复制int arr[5] = {1, 2, 3, 4, 5};
for (int i = 0; i <= 5; ++i) {
std::cout << arr[i] << std::endl;
}
i <= 5 会让 i 取到5,而 arr[5] 已经越界。C++不强制检查数组越界,程序可能不崩溃,但读取到的值是未知的,可能直接影响你后续逻辑结果。这种错误比直接崩溃更可怕,因为它在表面上“能运行”。
所以,遍历数组的标准写法是 i < n,记住这一点就行。等以后你学了迭代器,会发现这种“半开区间”的思想在C++里无处不在,标准库的 [begin, end) 设计就是延续这个习惯。
3. 从for循环跨进算法:一切都还是循环
3.1 算法的骨架:循环加判断
提到“算法”,很多人下意识觉得是遥不可及的高等数学。实际上,当你踏上算法初步这一步,最先接触的排序和查找,底层骨架全都是循环和条件判断。你可以把算法看作是一个“聪明的循环”,循环负责遍历所有可能的情况,判断负责决定哪些情况满足条件。
以经典的“找最大值”为例:
cpp复制int maxVal = arr[0];
for (int i = 1; i < n; ++i) {
if (arr[i] > maxVal) {
maxVal = arr[i];
}
}
这就是一个算法雏形:遍历、比较、记录。它不复杂,但已经具备了算法的核心单元——用循环穷举,用判断筛选。学算法的时候时刻带着这个视角,你会觉得很多东西并不难,都是循环和判断的组合。
3.2 冒泡排序:用相邻交换解决顺序问题
冒泡排序的直观描述:从头开始,反复比较相邻的两个元素,如果顺序错了就交换,每一轮下来,最大的元素像气泡一样浮到末尾。实现它,正好就是你刚学到的嵌套循环。
cpp复制void bubbleSort(int arr[], int n) {
for (int i = 0; i < n - 1; ++i) {
for (int j = 0; j < n - 1 - i; ++j) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
注意内层循环的边界 n - 1 - i。为什么减去 i?因为外循环每跑完一轮,末尾就会多一个已经排好的最大值,下一轮完全不需要再碰它。如果你写 j < n - 1,也能排序,但会做很多无意义比较。这个边界优化,是冒泡排序里最值得学习的细节。
还有一个优化空间:如果某一轮里没有任何交换发生,说明数组已经有序,就可以提前终止。加一个标志位:
cpp复制bool swapped;
for (int i = 0; i < n - 1; ++i) {
swapped = false;
for (int j = 0; j < n - 1 - i; ++j) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = true;
}
}
if (!swapped) break;
}
这就是从“能跑”到“知道为什么能跑”,再到“知道怎么跑得更好”的完整过程。
3.3 选择排序和插入排序:两种不同的思维模式
选择排序的思路:每一轮从未排序部分找到最小值,把它放到已排序部分的末尾。它不交换相邻元素,而是记录最小值的下标,一轮结束再交换一次。
cpp复制void selectionSort(int arr[], int n) {
for (int i = 0; i < n - 1; ++i) {
int minIdx = i;
for (int j = i + 1; j < n; ++j) {
if (arr[j] < arr[minIdx]) {
minIdx = j;
}
}
if (minIdx != i) {
int temp = arr[i];
arr[i] = arr[minIdx];
arr[minIdx] = temp;
}
}
}
插入排序则完全不同,它像你打牌时整理手牌的过程:把新元素插入到已经有序的序列中,从后往前比较,遇到比它大的就往后挪。
cpp复制void insertionSort(int arr[], int n) {
for (int i = 1; i < n; ++i) {
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
--j;
}
arr[j + 1] = key;
}
}
三种排序对比着学最有收获。冒泡和选择是“选位置放元素”,插入是“选元素找位置”。插入排序的数据移动方式跟另外两个不一样,它用到了不断的“右移覆盖”而不是“交换”,理解它对后面学习移动类算法很有帮助。
3.4 查找算法:从线性查找到二分查找
排序之后顺便说说查找。最简单的线性查找:
cpp复制int linearSearch(int arr[], int n, int target) {
for (int i = 0; i < n; ++i) {
if (arr[i] == target) {
return i;
}
}
return -1;
}
这种查找不看数据顺序,从头找到尾,平均时间复杂度是线性级别的。如果数据有序,二分查找就能发挥威力。它的思路是每次把查找范围减半:看中间元素,比target大就把右边界左移,比target小就把左边界右移。
cpp复制int binarySearch(int arr[], int n, int target) {
int left = 0, right = n - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
注意这里计算中点时,我写的是 left + (right - left) / 2,而不是 (left + right) / 2。后者在 left 和 right 都很大时可能发生整数溢出,前者就避免了。这是一个非常经典但不写在教科书首页的工程细节。循环条件是 left <= right,如果写成 <,会漏掉区间里最后一个元素。很多面试题就从这种细节里挖坑。
4. 只理解还不够,亲手做一个对比实验
4.1 数循环次数:时间复杂度的直观认知
时间复杂度这个概念一开始很容易吓到人,但其实你完全可以用“数循环执行次数”来建立直觉。冒泡排序的外层跑 n-1 次,内层平均跑约 n/2 次,总的比较次数大约等于 (n-1) * n / 2。n变大时,起主要作用的是 n²,所以说是 O(n²) 级别的算法。
二分查找就不一样,每次排查掉一半数据,查找次数大约是对数级别。n=1024时,线性查找平均要约512次比较,二分查找最多只需要10次。差距就是这么大。理解了这个,你对时间复杂度就不会只觉得它是一个“考试概念”了,而是衡量算法优劣的真实标尺。
4.2 实验:同一批数据,三种排序的差距
你可以自己写一个测试:随机生成5000个整数,分别用冒泡、选择、插入排序去排,在代码里用一个计数器统计比较次数。你很快会发现冒泡和选择差不多“话痨”,大量时间花在无意义的比较上;而插入排序在数据“接近有序”的时候明显表现更好。原因是它能在内层循环前置条件不满足时提前停下来,而冒泡和选择不管数据多接近有序,都会老老实实把该比的全比完。这也是为什么很多高级排序(比如快排)在处理小规模有序数据时会转向插入排序。
我建议你亲手把这段实验跑一遍,这不复杂,但带来的直观感受比看十篇文章都强。
5. 常见问题与调试技巧实录
5.1 死循环:从怀疑人生到一秒定位
死循环是新手的第一大敌人。最常见的几个原因:
- 迭代语句写错,比如
i++写成了i--。 - 条件边界写反,比如
i < n写成了i > n。 - 循环体内修改了循环变量,且修改方向跟迭代方向相反。
continue跳过了迭代语句。
我的排查习惯是:先在循环开头加一句输出,打印当前循环变量值和几个关键变量的状态,然后观察输出规律。如果发现某个值在某次迭代后不再变化,问题大概率出在迭代或边界。
5.2 打印大法与断点调试
不建议一上来就用IDE的复杂调试器。学习阶段,最可靠的调试手段是打印。按个输出,看值变了没有、变的对不对,通常几分钟就能定位。打印输出结束后记得删掉,或者用宏统一控制。
等你熟练了,再上IDE的断点和单步执行。断点调试看循环最有效的方式是:在循环体的第一行打一个条件断点,比如 i == 5 时暂停,直接观察那一轮的所有变量状态,不用一次次按下一步。
5.3 新手错误排查速查表
| 症状 | 可能的根因 | 排查方向 |
|---|---|---|
| 编译报“未定义标识符” | 初始化变量作用域只在循环内 | 将变量定义提升到循环外 |
| 运行后无输出 | 条件初始判断为假,循环一次未执行 | 检查条件表达式和初始值 |
| 输出无穷无尽 | 迭代语句缺失、方向错误、continue跳过递增 | 增加关键变量打印 |
| 数组输出乱码或崩溃 | 边界条件多算了一位 | 统一使用 < n 边界 |
| 排序结果部分正确 | 内层循环边界没有随外层递进优化 | 检查边界是否写死 |
提示:碰到循环问题,先用纸笔手动模拟一次循环,不要急着改代码。手推两三轮就能发现意图和代码是否一致。这个习惯越早建立,后面写递归、写指针、写链表时越受益。
5.4 调试工具之外的习惯建议
写循环前先想清楚三件事:循环该遍历什么范围、循环体该做什么、循环结束后什么变量该保留。想清楚再动手写代码,比反复编译调试快得多。我见过一个某同学在for循环里直接修改数组长度,导致崩溃了一下午,原因就是没想清楚“数组长度是固定的,不该在循环体里被改变”这个基本事实。
6. 最后一个建议:学算法初期别追求“优雅”
很多人看别人写的算法题解,觉得人家代码特别简洁,恨不得一步到位模仿那种风格。但初级阶段的算法学习,不该以“优雅”为目标,而该以“清晰可读”为底线。变量名起得明白,循环边界一目了然,每一步动作都有注释或明显结构,比用几个看似高明的技巧堆出短代码对你更有益。
我在过了一遍这三种排序和两种查找之后,印象最深的不是哪个算法性能好,而是它们全都是你刚才学会的for循环包装出来的:嵌套循环、边界判断、提前退出、记录状态。你不是在学什么天外之物,你是在复用已经掌握的工具。
下一篇我会沿着这个方向继续,把for循环里引出的数组操作拓展到更实用的场景。你先自己动手把这篇里的排序和查找代码全部手敲一遍,不要复制粘贴。敲代码时多问自己一句——“这个循环为什么这么写边界”,比敲十遍不加思考有价值得多。
