循环结构学习笔记——不少初学者可能还没意识到,这其实是编程里最容易被低估的基本功。我这些年带过不少从零开始的新人,观察他们写代码,发现差距往往不在语法背得熟不熟,而在能不能把一段需要反复执行的动作,用循环结构清晰地表达出来。这篇笔记想聊的,就是我自己整理循环结构时攒下的心得:从执行原理、语法细节,到实际调试过程中踩过的坑,全部摊开讲一讲。无论你是刚接触编程不久,还是已经写了一段代码想查漏补缺,应该都能从中找到一点有用的东西。
1. 循环结构的核心设计与思维模型
1.1 循环三要素:初始化、条件、迭代
不管哪种编程语言里的循环,骨子里都包含三个东西:起点、判断、步进。拿最常见的 for 循环来说:
for (let i = 0; i < 10; i++) { // 循环体 }这里的let i = 0是初始化,i < 10是条件,i++是迭代。很多初学者只盯着循环体里的逻辑,忽略了这三者之间的配合关系,结果要么进不去循环,要么陷在循环里出不来。
我经常打一个比方:循环就像跑步绕圈。你得先站在起跑线上(初始化),每跑一圈之前问自己“目标圈数到了没有”(条件),跑完一圈就记一下“已经第几圈了”(迭代)。任何一环没对上,整个跑步计划都会乱。
为什么要把三要素单独拎出来讲?因为我在排查循环问题的时候,十有八九都能追溯到三要素的某一个环节写错。比如条件反了导致一次都不执行,或者迭代步长设置不对导致死循环。把三要素当成一个整体去检查,问题往往一眼就能看出来。
还有一个细节值得记一下:初始化语句在整个循环里只会执行一次。这个看似理所当然的事情,在某些变体写法里容易踩坑。比如把初始化放在循环外,后来为了“简洁”又把它挪到循环内,就会导致每一轮循环都重复初始化,逻辑完全变味。
1.2 如何判断该用哪种循环
很多新手会纠结:for、while、do-while 到底有什么区别,什么时候选哪个?我的判断标准比较直白:
- 提前知道要循环多少次,优先用 for
- 不知道次数、只知道终止条件,用 while
- 无论如何都得先执行一次再判断的,用 do-while
这个标准背后是三种不同的思维方式。for 强调“次数可控的重复”,while 强调“条件满足就继续”,do-while 强调“先做事后看情况”。
举个例子:遍历一个数组,你清楚知道数组长度,属于次数可控的情况,用 for 最合适。读取用户输入,直到输入某个退出指令为止,你根本不知道会输入几次,这是 while 的菜。做一个菜单提示,至少要给用户展示一次菜单,再判断退不退出,do-while 就很贴合。
不过千万别人为地把这三种循环割裂开。实际项目中我经常混着用,比如用while(true)加内部break来处理复杂流程,读起来反而比强行套某个语法更顺畅。循环是工具,不是教条,目标是清楚表达重复逻辑,不是证明自己会把所有语法都用一遍。
2. 各语言循环语法对照与选择逻辑
2.1 经典 for 循环的执行顺序拆解
C 系语言的经典 for 循环长这样:
for (int i = 0; i < n; i++) { // do something }它的执行顺序,很多人其实没真正理解。不是“先设 i=0,然后执行循环体,然后判断 i<n”,而是:
- 初始化语句执行一次(i = 0)
- 判断条件是否成立(i < n)
- 条件为真,执行循环体
- 执行迭代语句(i++)
- 回到第 2 步继续判断
用文字描述就是:初始化 → 条件判断 → 循环体 → 迭代 → 条件判断 → 循环体 → 迭代……
这个顺序之所以值得写进笔记,是因为循环体里如果修改了迭代变量,或者迭代语句里有副作用,执行顺序会直接影响结果。比如在循环体里偷偷把 i 改成别的值,再执行i++,得到的索引和预期完全不同。这种问题很隐蔽,尤其是算法题里为了“优化”而在循环体内动索引变量的写法,最容易埋雷。
另一个容易忽略的点:for 的三个表达式都可以省略,但分号不能省。写成for(;;)就是无限循环,配合 break 使用。这个写法在底层代码里很常见,本质上就是把循环控制权完全交给循环体内逻辑。
2.2 while 与 do-while 的使用边界
while 的结构人人都会写:
while condition: # do something它的特点是先判断条件再执行循环体,条件一开始不满足,循环体一次都不会执行。
do-while 则不同,它保证循环体至少执行一次:
do { // do something } while (condition);有个实际情况值得拿出来说:很多现代语言并没有原生 do-while,比如 Python。这时候大家通常用一个变通方案:
while True: # do something if not condition: break这个写法模拟了“先执行一次再判断”的语义,我在处理“先读一次数据,再决定是否继续读取”的场景时经常用。比如读取配置文件,第一份数据无论如何都要读,读到结束标记才退出。用这种“现实版 do-while”比硬在 while 里凑条件清晰得多。
选 while 还是 do-while,本质上是问自己一个问题:这个循环体存不存在“一次都不该执行”的合法场景?如果存在,用 while;如果不存在,用 do-while 或它的等价变体。
2.3 for-each / for-in / for-of 的高阶用法
传统 for 循环用索引访问元素,写起来略显繁琐,索引变量也容易写错。现代语言基本都提供了更顺手的遍历方式:
const list = [10, 20, 30]; for (const item of list) { console.log(item); }for item in list: print(item)这种 for-each 风格的核心价值不是少打几个字,而是把“循环次数”和“索引管理”这些细枝末节从你脑子里清空,让你只关注元素本身。对初学者来说,这个抽象能减少大量边界错误。
不过有一个坑必须要说:for-each 里如果需要知道当前是第几个元素,或者需要在遍历中删除元素,就要格外小心。JavaScript 的 for-of 在遍历过程中修改数组长度,行为会变得非常诡异;Python 里直接在 for-in 中删除列表项,会跳过元素甚至运行时报错。
我的建议是:单纯遍历、不需要索引、不修改集合结构时,直接用 for-each;一旦涉及索引、删除、嵌套循环中对齐位置,老老实实退回传统 for 或显式迭代器。
3. 循环控制与嵌套循环的进阶技巧
3.1 break、continue 和标签语句
控制循环的节奏,光靠条件判断是不够的。break 负责“提前终止整个循环”,continue 负责“跳过当前这一轮,进入下一轮”。
在数组里找目标元素,找到了就没必要继续遍历:
for (const item of items) { if (item === target) { console.log("找到了"); break; } }continue 常见于“排除特殊情况”的过滤逻辑。比如只统计偶数的和:
for (int i = 1; i <= 100; i++) { if (i % 2 != 0) continue; sum += i; }这两个关键字本身很基础,但我想强调的是它们在嵌套循环里的行为差异。很多人以为 break 能退出所有循环,结果实际只退出了当前这一层,外层还在继续跑。
解决方案有两个:一是定义标志位,外层循环每轮都检查标志位;二是语言提供的标签机制:
outerLoop: for (let i = 0; i < n; i++) { for (let j = 0; j < m; j++) { if (someCondition) { break outerLoop; } } }标签 break 在 Java 和 JavaScript 里都支持,C 语言不支持这个语法,但用 goto 也能实现类似效果。我的个人经验是:标签机制能用,但别滥用。大部分场景定义一个布尔标志位,或者把双层循环拆成一个独立的函数、用 return 结束,可读性反而更好。
3.2 嵌套循环与复杂度分析
嵌套循环是算法性能的重灾区。两层嵌套基本就是 O(n²),三层就是 O(n³),数据量一大,时间消耗成倍上涨。
但是很多人忽略了一个事实:嵌套循环的复杂度不取决于“有没有嵌套”,而取决于内层循环体执行的次数。即使内层只做常数次操作,只要外层 n 次、内层 n 次,总操作数就是 n²。
举个非常典型的例子:求两个数组的交集,最容易想到的写法:
for (const a of listA) { for (const b of listB) { if (a === b) console.log(a); } }逻辑完全正确,但 listA 和 listB 各有 1 万个元素时,就是 1 亿次比较,明显不合适。优化思路是先把一个数组转成 Set,然后只遍历另一个数组去查询,时间复杂度直接降到 O(n) 级别:
const setB = new Set(listB); for (const a of listA) { if (setB.has(a)) console.log(a); }我每次讲嵌套循环都会拿出这个例子,因为它说明了一个重要道理:循环本身不是问题,循环体里做的事情才是决定复杂度的关键。
还有一个细节值得记录:嵌套循环里尽量把循环不变量提升到外层。例如内层循环每轮都要访问arr.length,如果这个属性值不会变化,就应该在外层先取出来,省得每一轮都重复访问。编译器有时候会帮你优化,但不要完全依赖它。
3.3 递归与循环的转化
递归从某种角度看也是一种循环,只是通过函数自己调用自己来实现“重复”。很多新手把递归和循环对立起来,其实它们是同一类思维。
比如计算阶乘,递归版本:
function factorial(n) { if (n <= 1) return 1; return n * factorial(n - 1); }改成循环版本:
let result = 1; for (let i = 2; i <= n; i++) { result *= i; }递归的优势是表达清晰,特别适合树形结构、分治算法;缺点是每次函数调用都有调用栈开销,递归深度太大会栈溢出。我的经验是:递归适合用来表达问题,而不是优先用来处理性能敏感场景。如果递归深度可能非常大,最好转成显式的循环加上手动栈。
我见过一些项目为了追求代码“优雅”,坚持用递归处理超大列表,最后不得不把调用栈容量调大,反而掩盖了设计上的隐患。用循环改写之后,问题简单很多,性能也更稳定。
4. 实操过程:从需求到循环实现
4.1 需求拆解:把业务问题翻译成循环
用一个我之前实际处理过的需求来说明循环的落地过程。需求本身不复杂:有一组订单数据,需要按省份分组统计订单数量。但数据量很大,而且存在脏数据,某些订单缺省份字段。
拿到这种需求,我不急着写代码,先拆解成循环要素:
- 初始化:一个空 Map,用来存省份和计数
- 遍历条件:订单列表不是空的
- 迭代步进:每次取出一个订单
- 循环体:判断省份字段,有效才计数
这个拆解过程,本质上是把“按省份分组统计”翻译成“遍历 + 条件 + 累加”三个动作的组合。用 for-of 遍历订单,在循环体里做判断和更新计数,代码大概是这样:
const stats = new Map(); for (const order of orders) { const province = order.province; if (!province) continue; stats.set(province, (stats.get(province) || 0) + 1); }continue 在这里的作用就是跳过脏数据,非常直观。整个过程完全不用关注循环次数,因为 for-of 已经帮我们处理了底层细节。
我特别想强调一件事:很多新手拿到需求就开始敲循环,结果漏了“跳过无效数据”这种分支,最终统计结果虚高。这就是需求拆解没做到位。循环体里的每一个分支,都应该对应需求里的一条明确规则,而不是写到哪想到哪。
4.2 边界条件推演:决定循环是否正确的关键
循环写完以后,最有价值的一步不是测试常规情况,而是推演边界。我给自己定了一个检查清单:
- 空集合:循环体是否会一次都不执行?
- 只有一个元素:行为是否符合预期?
- 最大长度:会不会越界访问?
- 循环变量会不会出现负数或超出范围?
拿一个常见的“反转数组”举例,双指针写法:
let left = 0, right = arr.length - 1; while (left < right) { [arr[left], arr[right]] = [arr[right], arr[left]]; left++; right--; }如果数组为空,arr.length - 1是 -1,left < right就是0 < -1,条件不成立,循环一次都不执行,正确。如果只有一个元素,left 和 right 都是 0,条件不成立,也正确。这个推演过程能让我确信代码没有致命的边界问题。
边界条件推演应该成为习惯,而不是偶尔想起来才做。我见过太多线上事故,都是“看起来没问题”的循环在空数据和极端数据下崩溃。尤其是涉及数组、索引、字符串切片的时候,这个习惯会帮你避开大量坑。
4.3 性能视角:循环展开与缓存友好
循环优化这块,我只提两个我实际用过的思路。
第一个是循环展开。当循环体非常短,比如只是把同一个值赋给数组的连续几个位置,循环本身的判断、递增开销占比会变高。手动展开几次可以减少这部分开销:
for (let i = 0; i < n; i += 4) { data[i] = value; data[i + 1] = value; data[i + 2] = value; data[i + 3] = value; }但你要注意 n 不能被 4 整除的情况,需要额外处理剩余元素。我的态度是:一上来不要做这种优化,先用分析定位,确认循环确实是瓶颈,再考虑展开。
第二个思路是缓存友好的遍历顺序。二维数组在内存里通常是按行优先存储的,所以按行遍历访问连续内存,比按列遍历快很多,因为按列访问会频繁跨越内存,导致缓存命中率变低。
我当年用工具处理大矩阵时,把两层循环从“外层列、内层行”改成“外层行、内层列”,性能差异大到肉眼可见。这个问题的本质,是循环顺序和数据存储布局的匹配程度。
不过我也要说句实在话:90% 的业务代码根本不需要做这种级别的优化。先把逻辑写对、写好懂,真遇到性能瓶颈再回来调整循环结构。过早优化是万恶之源,这句话放在循环场景同样成立。
5. 常见问题与排查技巧实录
5.1 死循环:条件永远为真
死循环是循环结构里最经典的问题。最常见的原因有几个:迭代变量没更新、条件写反、用浮点数做条件比较。
迭代变量没更新,基本就是忘了写i++或者i += 1。代码看起来在循环,实际上变量一直不变,条件永远成立,程序自然停不下来。排查方法很简单:在循环体开头打印迭代变量的值,看看它到底有没有变化。
浮点数做条件比较是个非常隐蔽的坑。浮点数有精度问题,用浮点累加可能永远达不到设定的阈值。比如:
for (float x = 0.0f; x != 1.0f; x += 0.1f) { // 这个循环几乎不可能按预期退出 }正确做法是用整数计数控制循环次数,或者用“差值的绝对值小于极小值”这种方式判断。
我的排查经验是:遇到死循环,先不要紧张,也别急着改代码。找张纸把三要素写出来,逐个核对。绝大多数死循环都是三要素的某一个环节写得不合常理,核对完基本就能定位。
5.2 边界差一(Off-By-One)问题
差一问题几乎每个写循环的人都遇到过:想循环 10 次,结果执行了 9 次或 11 次。典型原因是对区间开闭的理解不一致。
如果目标是遍历数组下标 0 到 length-1,用i < length就是正确的左闭右开区间;写成i <= length就越界,写成i < length - 1就少一个。这类错误高发的时候,往往是条件边界改动后,循环体内部依赖原边界的逻辑没有同步调整。
避免差一问题的办法有两个。一是统一边界风格:循环里默认使用左闭右开区间i = 0; i < n; i++,除非有明确理由,否则不改成其他方式。二是写完代码后,刻意代入两个值验证:第一个元素和最后一个元素的位置,条件是否正确。
5.3 在循环中修改集合结构
遍历列表的时候顺便删除元素,是我见过翻车率最高的操作之一。前面提过 Python 和 JavaScript 的坑,这里展开讲。
Python 里这样写:
lst = [1, 2, 3, 4, 5] for item in lst: if item % 2 == 0: lst.remove(item)表面上没问题,实际运行结果可能是[1, 3, 5],有时候甚至直接报运行时错误。原因是遍历过程中列表的长度和内部索引都在变,迭代器记录的位置已经错位。
正确做法是遍历副本,或者反向遍历,或者直接用列表推导式生成新列表:
lst = [item for item in lst if item % 2 != 0]我建议所有新人都直接养成的习惯是:不要在遍历循环里修改集合的结构。要么创建新集合,要么先标记、循环结束后再统一删除。否则,光是这个坑够你折腾半天。
5.4 闭包与异步中的循环陷阱
JavaScript 里有一个经典陷阱:在循环中创建闭包,异步回调执行的时候,循环早就结束了,闭包捕获的变量是最后一次的值。
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 1000); }输出结果不是 0、1、2,而是 3、3、3。原因在于var声明的 i 是函数级变量,所有闭包共享同一个 i,等 1 秒后回调执行时,i 早就变成了 3。
解决办法有两个思路。一个是把var换成let,这样每次循环都会创建一个新的词法绑定:
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 1000); }另一个是用立即执行函数把 i 作为参数传进去,形成独立作用域。这个坑让我意识到:循环变量并不总是一个普普通通的变量,在闭包和异步场景里,它变成了一个需要认真对待的作用域问题。
类似的坑在异步编程里还会变着花样出现,比如在循环里发请求、在循环里注册事件监听器。排查的时候,先想清楚每轮循环是否共享了同一个外部变量,再想怎么给每轮循环创建一个独立环境。
循环结构学习笔记的收尾记录
顺手记一下我自己的习惯:每次学一门新语言,我做的第一件事就是把它的循环语法完整过一遍,写两三个相同逻辑的循环片段做对比。这个习惯让我在切换语言时几乎没有阵痛期,因为循环结构是任何语言里都躲不开的基础设施。
最后再分享一个小技巧:调试循环的时候,别急着用断点一步一步走,那样效率太低。我通常在循环体里加一个计数器,或者打印循环变量的当前值和关键分支的命中次数,一次性掌握整段循环的运行轨迹。等确认逻辑没问题,再把这些临时输出删掉。循环结构这块内容,写笔记的人很多,但真正能把边界、性能、坑点串在一起讲明白的其实不多。希望这篇笔记能帮正在学循环的你少走几步弯路。