YAOTU INSIGHTS

嵌套循环与动态数组构建:多语言实战与避坑指南

嵌套循环与动态数组构建:多语言实战与避坑指南
这些日子连着帮几个朋友捋代码发现一个很有意思的现象不管是做前端表格处理还是写Excel宏甚至是在刷算法题大家绕来绕去都躲不开同一件事——数组的嵌套循环 新数组动态构建。听起来是个基础得不能再基础的话题但真正上手的时候翻车的人不在少数。有人在嵌套循环里把索引写错有人在动态构建数组时被引用共享坑了一把还有人为了“给新数组加一个元素”写出了O(n^2)的代码而不自知。所以我想把这块内容单独拎出来好好聊聊。这不仅是面试题里爱考的基础更是日常开发里真正高频使用的能力你要把二维数据拍平、要把两列匹配数据抽出来重新组装、要在一堆数字里找出加和等于固定值的组合本质都是同一套“遍历旧结构、构建新结构”的思路。这篇内容不挑语言我会同时用JavaScript、Python、C还有VBA里的真实场景来讲不管你是写业务脚本还是搞算法题都能直接抄走用。1. 嵌套循环 新数组动态构建到底在解决什么问题1.1 一个场景秒懂假设你在做订单管理有一张二维表每一行是一个订单明细包含用户ID、商品名、金额。现在你要做一件事把所有属于同一个用户的订单金额汇总生成一个新数组每个元素是“用户ID 总金额”。这就是最典型的“嵌套循环 新数组动态构建”const orders [ [U001, 键盘, 299], [U002, 鼠标, 129], [U001, 显示器, 1499], [U003, 耳机, 399], [U002, 摄像头, 599] ]; const result []; // 外层循环遍历每一行 for (let i 0; i orders.length; i) { const currentUser orders[i][0]; // 先看看新数组 result 里有没有这个用户 let found -1; for (let j 0; j result.length; j) { if (result[j][0] currentUser) { found j; break; } } if (found -1) { // 没有就新建一条这是“动态构建” result.push([currentUser, orders[i][2]]); } else { // 有就把金额累加 result[found][1] orders[i][2]; } } console.log(result); // [[U001, 1798], [U002, 728], [U003, 399]]这里有两个动作叠加在一起嵌套循环负责在现有数据和新数组之间做“查找—比对”动态构建则负责在找不到匹配项时扩展新数组。很多初学者把这两个动作分开看其实它们经常是紧密耦合的新数组的结构往往是由旧数据的排列方式决定的而不是一开始就定义好的。这也是“动态构建”和“初始化一个固定大小数组”的本质区别固定大小数组是在数据进来之前就圈好了地动态构建则是边遍历边决定要创建什么样的新结构。1.2 不同语言的“动态构建”能力差异这一点我必须先说清楚因为很多人踩坑就踩在这里。同样是“给数组加一个元素”不同语言的底层行为完全不一样JavaScript 的push()不用预先指定大小数组长度自动增长你可以一路push到底。Python 的append()行为类似但底层是“动态数组”当容量不够时会自动扩容且扩容策略会预留一部分空间。C 的std::vector也是动态扩容但你用传统C数组int a[10]就不行大小编译期就必须定死想要“动态”要么用vector要么自己写realloc逻辑。VBA 的数组更特殊Dim arr()声明动态数组后必须先用ReDim指定大小才能用而且大小时刻要你手动管理这点非常容易让从JavaScript转过来的人抓狂。所以后面所有讨论里我会尽量把语言差异指出来。同样的思路在JavaScript里可能是两三行代码到了VBA里可能就需要提前估算数组大小。这不是语言优劣问题是你要理解每种语言的“动态构建”机制才不会写出“看着对但跑起来错”的代码。2. 先看最通用的实现JavaScript 双层循环 新数组构建2.1 从零手写嵌套循环别急着用优雅API我见过很多人一上来就用map、filter、reduce把嵌套循环包装得花里胡哨但等到真正需要调试的时候反而不知道数据是怎么流转的。我建议你先把最原始的for嵌套写明白再去考虑高级写法——就像你先学会手算乘法再用计算器才不会算错。下面这段代码做了这么几件事把一组产品分类数据按“类型”拆分最后生成一个一维新数组里面每个元素是“类型名称-产品名称”的字符串。const categories [ [数码, [手机, 平板]], [家电, [冰箱, 空调]], [图书, [技术书, 小说]] ]; const flatList []; for (let i 0; i categories.length; i) { const type categories[i][0]; const items categories[i][1]; // 这是个内层数组 for (let j 0; j items.length; j) { flatList.push(type - items[j]); } } console.log(flatList); // [数码-手机, 数码-平板, 家电-冰箱, 家电-空调, 图书-技术书, 图书-小说]这里的关键点在于内层循环的次数不是固定的它取决于外层数组当前元素的第二个子数组的长度。这就是“动态”二字的含义——你的新数组长度在代码写出来的那一刻是不知道的必须等循环跑起来才能确定。有个很常见的错误是预先用一个固定值去初始化结果数组比如let flatList new Array(6)然后拿索引手动赋值。表面上看没毛病但实际上数组里会有空位有些API在处理空位时的行为和预期不一样更重要的是一旦数据源的行数变了你的“固定大小”就失效了。正确姿势是让新数组从空数组开始完全靠 push 生长这样长度永远和实际需求一致。2.2 原生方法的演进以及替代时的注意事项当嵌套循环用多了以后你会发现有些模式可以替代。比如上面的拍平逻辑用flatMap一行就能搞定const flatList2 categories.flatMap(([type, items]) items.map(item type - item) );还有按用户累加金额的那个场景用reduce也能写const grouped orders.reduce((acc, [userId, , amount]) { const existing acc.find(item item[0] userId); if (existing) { existing[1] amount; } else { acc.push([userId, amount]); } return acc; }, []);但必须提醒一下find本身就是一次线性查找它内部依然是循环。所以用这类API只是“代码层面看着简洁”时间复杂度并没有本质变化还是要O(n^2)。数据量小的时候无所谓数据量一旦到几千几万条性能差距就开始显现了。要真正优化得引入哈希结构JavaScript里就是普通对象或Map把查找复杂度降成O(1)后面我会专门讲。还有一个很容易被忽略的坑当你把“嵌套循环”替换成forEach嵌套forEach时break和continue就不生效了。想在循环里提前跳出某个分支for写法可以轻松做到forEach里只能靠return那只是跳过当前回调不是跳出循环。所以如果你的逻辑里有“找到就停止搜索”这种需求最稳妥的还是写for。3. 换个语言换种思路Python、C 的嵌套与数组构建3.1 Python列表推导式与 numpy 的向量化Python里做嵌套循环最舒服的一点是语法特别贴近自然语言但它也有自己的坑。比如下面这段生成一个3行4列的二维数组新手最容易写成# 错误示范这样生成的是3个指向同一个列表的引用 matrix [[0] * 4] * 3 matrix[0][0] 1 print(matrix) # [[1, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0]]这个坑我见人踩过无数回。原因在于[[0] * 4] * 3里的* 3复制的是外层列表的引用而不是重新创建三个独立列表。正确写法是matrix [[0] * 4 for _ in range(3)]用列表推导式创建新数组是Python里“动态构建”最典型的姿势。如果你是从JavaScript转过来的这个写法和Array.from({length: 3}, () new Array(4).fill(0))本质是一回事都是通过“每次循环创建一个新对象”来避免共享引用。再说说嵌套循环的写法。假设有一个二维数组你想把所有偶数挑出来放到新一维数组data [[1, 2, 3], [4, 5, 6], [7, 8, 9]] evens [num for row in data for num in row if num % 2 0] print(evens) # [2, 4, 6, 8]这个列表推导式看起来可能有点绕拆开就是evens [] for row in data: for num in row: if num % 2 0: evens.append(num)注意推导式里的for row in data和for num in row的书写顺序它是“外层循环写在前面内层循环写在后面”和普通嵌套顺序一致只是把最内层的“生成结果”提到了最前面。这种语法虽然简洁但嵌套超过两层以后可读性会明显下降我建议三层以上老老实实写普通循环别为了炫技牺牲可维护性。如果数据是数值型的还有一个重要的优化方向——用 numpy 做向量化运算。比如你想把两个三维数组按元素相乘对应热词里的“numpy三维数组相乘”原本可能要写三层循环import numpy as np a np.arange(24).reshape(2, 3, 4) b np.ones((2, 3, 4)) result a * b # 广播机制底层是C循环比你自己写三层Python循环快得多只有当数据规模大、且操作是“逐元素”的数学运算时向量化才有明显优势。如果逻辑里有复杂的条件判断、可变长度的子数组那还是老老实实用Python循环和列表。毕竟 numpy 要求数据形状规整遇到“每一行的列数不一样”这种动态结构它反而使不上劲。3.2 C指针、多维数组与动态数组扩容C 里谈数组绕不开“数组本质是一段连续内存”这件事。这跟 JavaScript 很不一样JS的数组更像一个“对象集合”元素类型可以混着来而 C 传统数组一旦定义类型和大小都定死了。你可能看到过热词里“指针数组”、“c 多维数组 指针”、“c字符串数组初始化”这些概念其实都围绕着同一个核心数组名在很多场景下会退化成指针。#include iostream #include vector int main() { // 传统二维数组必须是编译期常量大小 int matrix[2][3] {{1, 2, 3}, {4, 5, 6}}; // 用指针遍历matrix[i] 是指向第 i 行首元素的指针 for (int i 0; i 2; i) { for (int j 0; j 3; j) { std::cout *(*(matrix i) j) ; } } std::cout std::endl; // 动态构建用 std::vector 才是 C 里的“动态数组” std::vectorstd::vectorint dynamicMat; for (int i 0; i 3; i) { std::vectorint row; for (int j 0; j i; j) { // 每行长度动态变化 row.push_back(i * 10 j); } dynamicMat.push_back(row); // 动态扩展外层 } // 打印动态构建的结果 for (const auto row : dynamicMat) { for (int val : row) std::cout val ; std::cout std::endl; } return 0; }这里有个值得展开的细节std::vector的push_back并不是“每次加一个元素就分配一次内存”而是采用容量翻倍策略——当容量不够时一般会申请一个比当前容量大两倍的新内存块把旧元素搬过去再释放旧内存。正因为预留了额外空间所以连续push_back多次的均摊复杂度是O(1)而不是O(n)。这个机制对“动态构建新数组”至关重要你不必每次都担心性能崩塌。C 字符串数组初始化也有很多讲究。你看热词里有“c字符串数组初始化”、“cstring转char数组 函数”这里简单提示一句char* arr[] {hello, world};是“指针数组”每个元素是指向字符串字面量的指针char arr[][10] {hello, world};是二维字符数组字符串会拷贝进数组里。两者的内存布局和使用限制完全不同嵌套循环遍历时也要分清楚——指针数组遍历到的是指针二维数组遍历到的是连续内存块。3.3 三种语言构建新数组的方式对比为了让你对整套逻辑有个更清晰的把握我把刚才讲的三种语言的差异整理成一张表维度JavaScriptPythonC (std::vector)动态添加元素push()append()push_back()预分配容量不需要自动管理不需要自动管理reserve(n)可主动预留空数组初始化[]或new Array()[]或list()std::vectorint v;二维动态构建数组里嵌数组列表里嵌列表vector里嵌vector易错点引用共享少见[[0]*4]*3共享引用传统C数组大小固定指针退化和越界风险这张表其实就是这篇文章的“地图”。后面讲到具体实战场景时我会不断回到这张表里的细节——比如提到“数组初始化规则”时你别只觉得是语法层面的小事它直接决定了嵌套循环里你会不会把数据改串。4. 实战场景一Excel/VBA 提取两列匹配数据生成新数组4.1 需求背景这个场景真不是编的热词里有一句“excel 提取前两列匹配的数据成一个数组”这正好是我在给财务同事帮忙时遇到的一个活生生的需求。场景是这样的有一张Excel表A列是订单号B列是商品名C列是金额另一张表里只有部分订单号。现在要把“第一张表里、且出现在第二个订单号列表中的那些行”提取出来组成一个新的数组/区域。这种工作如果用Excel自带的VLOOKUP也能做但数据量一上万行公式跑起来就开始卡而且你往往不是要“查一个值”而是要“筛一整批行”这种场景用VBA数组处理反而是最稳的。4.2 用 VBA 实现动态构建数组的完整过程VBA数组最反直觉的地方是你不能直接arr arr something或者靠push来生长。你必须先ReDim但好在VBA允许用ReDim Preserve在保留旧数据的前提下扩展末尾一维。下面的代码演示了核心思路Sub ExtractMatchedRows() Dim src As Range, targetCol As Range Dim srcArr As Variant, targetArr As Variant, resultArr() As Variant Dim i As Long, j As Long, matchCount As Long Dim srcLast As Long, tgtLast As Long Dim found As Boolean 原始数据区域A1:C100假设有100行 srcLast Sheet1.Cells(Sheet1.Rows.Count, A).End(xlUp).Row tgtLast Sheet2.Cells(Sheet2.Rows.Count, A).End(xlUp).Row srcArr Sheet1.Range(A1:C srcLast).Value targetArr Sheet2.Range(A1:A tgtLast).Value 动态结果数组先按最坏情况估算大小 ReDim Preserve resultArr(1 To srcLast, 1 To 3) matchCount 0 For i 1 To UBound(srcArr, 1) found False 内层循环去匹配目标订单号列表 For j 1 To UBound(targetArr, 1) If srcArr(i, 1) targetArr(j, 1) Then found True Exit For End If Next j If found Then matchCount matchCount 1 resultArr(matchCount, 1) srcArr(i, 1) resultArr(matchCount, 2) srcArr(i, 2) resultArr(matchCount, 3) srcArr(i, 3) End If Next i 写回结果区域 If matchCount 0 Then Sheet3.Range(A1).Resize(matchCount, 3).Value resultArr End If End Sub几点实操层面的说明我特意用了ReDim Preserve resultArr(1 To srcLast, 1 To 3)把二维数组的第一维按最坏情况全部匹配预设好然后用一个计数器matchCount来标记实际写入行号。为什么这么做因为ReDim Preserve在二维数组里只能红第一维末尾维而且每次调用都有内存拷贝成本如果在循环里反复ReDim Preserve性能会差得离谱。这个“先按最大上限初始化最后只取前 matchCount 行”的做法是在VBA里处理动态结果数组的经典方案。内层Exit For很重要。它表示“找到第一个匹配订单号就停止继续找”。如果没有它每一行都要把整个目标列表扫到底白白浪费时间。srcArr Range.Value拿到的是二维数组(1 to rowCount, 1 to colCount)注意它是从1开始下标不是0这在VBA里非常容易出现“索引超出范围”的错误刚开始写的人要注意。4.3 为什么说“VBA数组对比最快”其实是空间换时间热词里还有一句“vba数组对比最快”我当时看到就想说这句话单独理解是有歧义的。数组对比本身不“快”快的是你对比的方式。上面这段代码用的是双层循环如果目标列表有5000行原表有10000行那最坏情况下要比较5000万次VBA这种解释执行的慢语言真的可能会跑几十秒。真正快的做法是把“内层查找”换成字典。VBA里可以用CreateObject(Scripting.Dictionary)Set dict CreateObject(Scripting.Dictionary) For j 1 To UBound(targetArr, 1) dict(targetArr(j, 1)) True Next j For i 1 To UBound(srcArr, 1) If dict.Exists(srcArr(i, 1)) Then 匹配成功写入结果 End If Next i这样就把原本O(n*m)的双层循环降成了O(nm)。外层每来一行只需要查一次字典哈希查找的平均复杂度是O(1)。我在实际处理两万行数据时双层循环跑了大约40秒换字典后秒出。这个经验放在任何语言里都成立——嵌套循环里如果内层是“查找”语义就要优先考虑用哈希结构替代。这也是为什么热词里会同时出现“数组去重”、“js数组合并去重”这类问题它们的底层逻辑都是同一个用空间换时间。5. 实战场景二一列数里找哪些和等于固定值5.1 问题本质嵌套循环的经典变体热搜词里有一组我很眼熟的话“一列数已知固定数值如何确定数组中的哪些数据和等于固定值”。这看起来像Excel里的一个实际问题放到算法领域就是“子集和问题”Subset Sum它和背包问题有很深的血缘关系。举个例子你有一组预算金额 [12, 35, 8, 20, 15, 7]现在想找出来哪些金额相加等于 43。可能有不同的组合35843121587? 不对等等12158742差112208? 其实各种组合都有。关键是要找到“所有”能组成43的组合。这就不再是简单的双层循环能解决的问题了因为组合数量可能是任意多个数字相加嵌套层数不固定。5.2 用回溯法动态构建结果数组这种问题需要用递归来模拟“嵌套循环”循环的层数等于组合里数字的个数。我写一个JavaScript版本的递归实现它最终的输出就是一个动态构建的新数组里面每一项都是一个组合数组function findCombinations(nums, target) { const result []; const path []; // 先排序方便剪枝 nums.sort((a, b) a - b); function backtrack(start, remaining) { if (remaining 0) { result.push([...path]); // 注意要拷贝不能推 path 本身 return; } for (let i start; i nums.length; i) { if (nums[i] remaining) break; // 剪枝如果当前数字已经大于剩余值后面更大直接停 path.push(nums[i]); backtrack(i 1, remaining - nums[i]); // 每个数字只能用一次所以从 i1 开始 path.pop(); // 回溯把最后一个元素弹出 } } backtrack(0, target); return result; } console.log(findCombinations([12, 35, 8, 20, 15, 7], 43));这段代码其实是“嵌套循环”的抽象形态每一层递归相当于一层循环path数组则是“动态构建”的载体。关键操作是result.push([...path])这里必须用展开运算符拷贝一份否则result里存的全是同一个path数组的引用回溯时path.pop()会把你已保存的结果一并改掉。这个坑等同于前面Python里[[0] * 4] * 3的引用共享问题只是换了个马甲。另外排序剪枝很重要。nums[i] remaining直接break因为数组已经排序后面的数只会更大没必要继续试。这个看似微小的优化在数据量稍大的时候能把递归分支砍掉一大半。5.3 扩展三个数组最大乘积同类的“嵌套循环组合”还有一个高频考法三个数组里各取一个数求最大乘积。如果无脑三层循环代码很好写function maxProduct(a, b, c) { let max -Infinity; for (let i 0; i a.length; i) { for (let j 0; j b.length; j) { for (let k 0; k c.length; k) { max Math.max(max, a[i] * b[j] * c[k]); } } } return max; }数组长度都是100时就是100万次乘法还能接受一旦每个数组有1万个元素就是一万亿次任何语言都扛不住。优化思路还是老几样——排序后取最大或最小值。乘积最大不一定取三个最大因为负数成对相乘可能得正数比如 [-100, -99] 和 [1, 2] 的最大乘积是 (-100)*(-99)*2 19800而不是 -99 * 1 * 2。所以正确做法是找每个数组的最大两个和最小两个然后枚举这有限几个候选组合。这也是嵌套循环的优化方向之一不是所有循环层数都得完整跑通过数学分析缩小候选集比硬算更有效。6. 高频翻车点与排查技巧6.1 嵌套循环的性能永远先问“内层在干什么”每当我看到有人把两层循环写满都会先问一句内层是“查找”还是“遍历”这点我在VBA实战里已经强调过但值得再单独拿出来说。如果内层是查找比如判断某个值是否在另一个数组里那就应该优先想到用哈希/Map/字典替代如果内层是“对每个元素做一次操作”那循环本身是必要的但你可以考虑能不能用语言自带的批量操作map、列表推导式、numpy向量化来提速。实际开发里我见过最离谱的写法是在一个循环里不断用arr.indexOf(item)来查重结果整个函数复杂度变成O(n^2)处理5000条数据就卡得要死。替换成new Set(arr)之后同样的逻辑毫秒级跑完。很多性能问题不是靠优化循环体而是靠换数据结构。6.2 动态构建时“引用共享”是最隐蔽的Bomb前面Python部分提到过[[0] * 4] * 3JavaScript里也有等价版本const matrix new Array(3).fill(new Array(4).fill(0)); matrix[0][0] 1; console.log(matrix); // [[1,0,0,0],[1,0,0,0],[1,0,0,0]]fill(new Array(4))会把同一个数组引用填到每个位置。要构建独立的多维数组应该写成const matrix Array.from({ length: 3 }, () new Array(4).fill(0));排查这种问题时有个经验如果修改一个元素其他行也跟着变基本就是引用共享。遇到这种可疑代码可以用console.log(matrix[0] matrix[1])快速验证。6.3 js数组删除元素时的“索引塌陷”热词里有“js数组删除指定元素”它跟嵌套循环搭配时会产生一个经典bug。在循环里用splice删除元素后数组长度变短后面的元素会往前移动当前索引指向的元素实际上越过了下一个待处理元素const arr [1, 2, 3, 4, 5, 6]; for (let i 0; i arr.length; i) { if (arr[i] % 2 0) { arr.splice(i, 1); // 删除了 arr[1]2此时 arr[1] 变成原来的 arr[2]3 } } console.log(arr); // [1, 3, 5]不对实际是 [1, 3, 5] 再算一下实际跑完得到 [1, 3, 5]看起来好像是“对的”但这只是巧合。如果数据是 [1, 2, 4, 5]你想删所有偶数按上面的写法i1时删除2数组变成[1,4,5]i2时指向54就被跳过了最终结果是 [1,4,5]偶数4没删掉。解决办法有两个要么倒序遍历for (let i arr.length - 1; i 0; i--) { if (arr[i] % 2 0) arr.splice(i, 1); }要么用filter生成新数组。这里我想引出一个跟主题直接相关的观点如果你本来就要构建一个新数组那filter才是真正符合“动态构建”思路的解法原地splice反而容易出错。嵌套循环 动态构建的组合拳是用循环判断条件用push构建新数组最后旧数组原封不动新数组就是你要的干净结果。6.4 VBA 二维数组写入与传参的坑最后补一个VBA专项问题。VBA里数组可以被看作一个整体做参数传递但默认是按ByRef也就是说函数内部改了数组外面的原数组也会变。如果你想保护原数组不被内部操作污染应该写成ByVal这一点和C里传指针还是传引用很像。另外把VBA二维数组写回单元格时有个常见报错你ReDim Preserve之后数组下标是从0还是从1开始必须跟读取时的Range.Value保持一致。如果你用Dim resultArr(1 To n, 1 To 3)写入时Sheet3.Range(A1).Resize(n, 3).Value resultArr这样是安全的。如果下标定义不匹配VBA会弹出“类型不匹配”或“下标越界”排查起来很浪费时间。我的经验是凡是涉及VBA数组和单元格交互统一把数组下标定义为1 To 行数, 1 To 列数能少踩很多坑。6.5 关于“数组求区间最大值”这类算法模板热词里还有“数组求区间最大值的算法题”、“树状数组模板”这两个其实也是嵌套循环 动态构建思路的延展。最简单的区间最大值查询就是外层固定左端点、内层扫右端点边扫边更新最大值生成一个二维的结果表const arr [3, 1, 4, 1, 5, 9, 2, 6]; const n arr.length; const maxMatrix Array.from({ length: n }, () new Array(n).fill(0)); for (let i 0; i n; i) { let curMax -Infinity; for (let j i; j n; j) { curMax Math.max(curMax, arr[j]); maxMatrix[i][j] curMax; } }每个区间 [i, j] 的最大值都被预计算出来存到新数组中查询时直接O(1)读取。如果数据量再大可以用树状数组、线段树这类高级结构优化更新和查询但底层思想依然是“用新数组保存中间结果避免重复遍历”。所以不要觉得“嵌套循环”是个初级话题它其实是很多高级结构的雏形。最后说点我自己的体会。写这篇文章时我反复想起以前处理一张几万行的数据表当时也是用双层循环去匹配两个列表等了快一分钟才出结果。后来只是把内层查找换成字典整个过程缩短到一两秒。那次之后我养成了个习惯每次写嵌套循环之前都会先问自己内层这层真的必须“扫一遍”吗能不能用哈希能不能剪枝能不能用语言自带的批量API数组嵌套循环 动态构建这件事看着基础但恰恰是这些基础操作里藏着的细节决定了你的代码在大数据量面前是“秒开”还是“卡死”。把这几个关键套路吃透你处理任何“一堆数据要整理成另一堆数据”的需求时思路都会清晰很多。要是再遇到具体场景卡住就回到这篇文的几个核心点来排查索引对不对、引用有没有共享、内层查找能不能换哈希、要不要拆成新数组而不是原地改。记住这四条基本不会翻大车。