多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

二维数组错题总结:越界、初始化、遍历、传参的五大坑

二维数组错题总结:越界、初始化、遍历、传参的五大坑 先说明一句这个题目叫“二维数组错题总结”那这篇文章的定位就很明确——不是讲二维数组怎么用而是把用二维数组时最容易踩的坑、最容易写错的代码、最容易搞混的概念全部拉出来过一遍。读者对齐的目标人群有两类一类是刚学完数组、正在刷题或者应付考试的学生另一类是工作里偶尔要和二维数据打交道、被初始化、越界、键值这些问题搞到头大的开发者。我尽量每种错题都给出错误写法、现象、根因和修正写法顺便把背后的存储逻辑讲透。1. 为什么二维数组的错题翻来覆去就那么几类学编程的人几乎都有一个共同体验一维数组的题目做起来挺顺手从遍历到查找、从逆序到排序逻辑都很直白。但只要换成二维数组各种奇奇怪怪的错误就冒出来了——要么越界要么结果不对要么内存崩了要么数据被莫名其妙改掉。我梳理了自己带过的学生和身边同事的代码发现二维数组的错题翻来覆去其实就那么几类背后的根源都可以归结为一句话二维数组不是“二维”的它本质上是内存里一段连续的一维空间只是用下标把它包装成了“行和列”。举个例子假设你声明了一个int a[3][4]在C语言里它占用的内存是连续的12个int位置第一行4个接着第二行4个再接着第三行4个。也就是说a[1][2]实际上访问的是内存里第1 * 4 2 6个元素。这个“行优先存储”的底层规则决定了二维数组几乎所有的经典错题越界、行列颠倒、遍历顺序错误、数组退化为指针时的混乱全部都和它有关。打个比方一维数组就像一栋只有一层楼的酒店房间号就是你的下标走错一扇门顶多进错房间二维数组则像一整栋大楼每一层有4个房间你要去的是第2层第3间。如果你用错了楼层号或者房间号轻则进错门拿错东西重则直接走到大楼外面去了——而内存访问越界正是这个效果但它不会像酒店保安一样拦住你而是让你“走进隔壁楼的房间”拿回来的数据看着还像模像样实际上早就错了。看完这篇文章我希望你把二维数组的错误全部归纳到五个篮子里边界越界、初始化类型和维度、遍历行列顺序、传参指针退化、键值键名和值的关系。每个篮子里的典型错题下面逐个拆。2. 错题复盘一越界的边界幻觉——为什么多走一步没事多走十步才爆炸2.1 越界读的幽灵数据先复现一个很多初学者都写过的代码场景是求一个3x3矩阵对角线元素之和#include stdio.h int main() { int matrix[3][3] { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} }; int sum 0; // 错误写法习惯性把循环条件写成 3 for (int i 0; i 3; i) { sum matrix[i][i]; } printf(sum %d\n, sum); return 0; }这段代码的输出是什么在大多数编译器上它不会崩溃而是输出一个莫名其妙的数字比如sum 30或者sum 25。为什么“幽灵数据”从哪里来matrix[3][3]本身就已经越界了。按照行优先存储的规则matrix[3]指向的位置是第0行第0个元素往后的第3 * 3 3 12个int位置——已经越过整个数组最后一个元素第2行第2个元素偏移量是2 * 3 2 8四个int的位置。那个内存地址里存的是什么谁也不知道。可能是别的变量的值可能是栈上的返回地址的一部分把它读进sum结果自然就是“随缘”。这是越界读的典型特征不报错、不崩溃只是结果错。越界读比越界写更难发现因为程序还能跑输出却完全不可信。我见过有人在笔试里为了求对角线把循环写成i 3还跑出了正确结果于是把这种错误写法带到了后续所有题目里直到某次它真的读出了垃圾数据才意识到问题。2.2 越界写悄悄改掉别人的数据越界写更阴险。看这个例子——把一个4x4矩阵的上三角元素置零#include stdio.h void zero_upper(int arr[][4], int n) { for (int i 0; i n; i) { for (int j i; j n; j) { // 错误j 应该从 i 开始但 n4 时 j 能到 3 arr[i][j] 0; } } } int main() { int a[4][4] {0}; int guard 12345; zero_upper(a, 4); printf(guard %d\n, guard); return 0; }问题出在哪当i 3时内层循环j从 3 到 3访问arr[3][3]还在范围内没问题。但如果i的循环条件是i n那当i 4时就会出现arr[4][4]的写入。arr[4]这个位置在内存里紧接着a[4][4]数组之后——正是guard变量所在的位置附近。结果就是你以为自己在操作矩阵实际上把guard的值写成了 0。关键是编译器完全不会提醒你因为在C语言里数组越界检查是不存在的。你写arr[4][4] 0它的机器码就是“计算地址然后向那个地址写入0”毫无防御。这就像你在酒店走廊尽头的墙上凿了一扇门门外面正好是隔壁房间的衣柜你往里塞了一件衣服隔壁客人打开衣柜时一脸懵。2.3 越界的根源别信直觉信偏移量计算所有越界的根源都可以用偏移量公式解释a[i][j]的实际内存偏移量是i * 列数 j。所以越界分三种情况行越界i超出[0, 行数-1]列越界j超出[0, 列数-1]整体越界i和j都在范围内但i * 列数 j超过了行数 * 列数 - 1——这种最隐蔽常见于把行列数搞混的遍历举个整体越界的例子int a[3][4]你写a[2][4]。i2没超出行数3j4超出了列数4。偏移量是2 * 4 4 12数组合法范围是偏移量 0 到 11。这个错误等价于a[3][0]正好是数组之后的下一个内存位置。排查这类错题我推荐一个笨但有效的办法凡是出现了奇怪的数据被篡改、输出时对时不对、程序换一个编译器或优化等级结果就变优先怀疑越界。尤其是在OJ系统上“本地运行正确提交后答案错误”的经典场景十有八九是越界读到了未定义行为本地恰好是某个值线上变成了另一个值。3. 错题复盘二C#里int[,]和int[][]不是一回事——二维数组与交错数组的混用3.1 经典错题想把交错数组当二维数组用这个错题在C#初学者里出现频率极高。先看两段代码// 写法A真正的二维数组矩形数组 int[,] grid new int[3, 4]; // 写法B交错数组数组的数组 int[][] jagged new int[3][]; jagged[0] new int[4]; jagged[1] new int[4]; jagged[2] new int[4];很多人在做完“写法A”的题目之后换成“写法B”的代码顺手就写jagged.Length想拿到行数结果发现它的值是3——对没问题。然后想拿列数写jagged[0].Length也对是4。看起来好像都能用于是产生了“它俩差不多”的错觉。但等你要给函数传参时差别瞬间就暴露了。// 编译错误 void Process(int[,] arr) { } Process(jagged); // 报错无法将 int[][] 转换为 int[,] // 正确做法必须单独写重载或者改成 jagged 类型的参数 void Process(int[][] arr) { }根因在于int[,]在CLR里是一个独立的类型底层是一整块连续内存像一个矩形int[][]则是三个独立的数组对象每个一维数组在堆上单独分配内存不一定连续。它们的内存布局完全不同所以int[][]的Length只代表“一维数组的个数”不代表总元素个数int[,]的Length则直接是所有元素总数。想拿到矩形数组的行数和列数必须用grid.GetLength(0)和grid.GetLength(1)而不是grid.Length。3.2 为什么会有int[][]这种设计int[][]之所以存在是因为它允许“每一行长度不同”。举例说你要表示一个三角形的数据第一行1个元素第二行2个第三行3个用int[,]做不到——矩形数组必须每行同样长否则就得浪费空间。交错数组就是为这种不规则结构准备的。但代价是什么两方面的访问性能int[,]的元素是连续内存遍历时CPU缓存命中率高int[][]每一行是独立的数组行与行之间地址不连续遍历时需要逐行跳转性能略差。代码复杂度int[][]需要先初始化外层数组再逐个初始化每一行。如果忘了初始化某一行访问时直接NullReferenceException。所以我的建议是除非确实需要“锯齿形”数据否则一律用int[,]。它更符合“二维数组”的直觉也更能避免初始化遗漏的坑。3.3 C#初始化二维数组的易错点C#里二维数组初始化还有一个容易被忽略的细节。看这两种写法int[,] a new int[,] { { 1, 2 }, { 3, 4 } }; // 正确 int[,] b new int[2, 2] { { 1, 2 }, { 3, 4 } }; // 正确 int[,] c new int[2,] { { 1, 2 }, { 3, 4 } }; // 错误必须同时指定行列数或同时省略最后一个写法的报错信息会很奇怪原因是C#的语法要求new int[,]要么完全省略维度、由右侧初始化器推断要么明确写出完整的[行,列]不允许只写一半。这个错误在从Java转C#的人身上尤其常见——Java里new int[2][]是合法的因为Java没有真正的二维数组它本来就是交错数组。跨语言写代码时这种“惯性语法”会带来不少隐藏问题。4. 错题复盘三PHP二维数组改变键值时foreach 引用的陷阱与 array_column 的正确姿势4.1 热词背后的高频需求网上搜“php二维数组改变键值”大概率是下面这类需求从数据库查出来一个二维数组每条记录的id字段是唯一标识想把数组的键名从默认的0、1、2改成记录的id值这样就能通过$arr[$id]直接取到某条记录不用每次都循环查找。这本身是合理需求但很多人第一次实现时会下意识用 foreach 引用$data [ [id 3, name 张三], [id 7, name 李四], [id 1, name 王五], ]; foreach ($data as $key $row) { $data[$row[id]] $row; unset($data[$key]); } print_r($data);这段代码在PHP 7和PHP 8里表现不一样而且很容易出错。原因在于foreach遍历的是$data的副本在PHP 7.0之前或者内部指针式的遍历PHP 7.0之后。在遍历过程中添加元素、删除元素会导致数组的内部哈希表结构变化可能出现元素丢失、重复、甚至内存错误。尤其是在 PHP 8 里遍历的同时修改数组结构结果不可预测。4.2 array_column 才是标准答案前面那种需求用array_column两行就能解决$data [ [id 3, name 张三], [id 7, name 李四], [id 1, name 王五], ]; $keyed array_column($data, null, id); // 结果是 // [ // 3 [id 3, name 张三], // 7 [id 7, name 李四], // 1 [id 1, name 王五], // ]array_column第三个参数就是“用哪个字段作为键名”。它不仅比 foreach 方案快还不会产生“遍历时修改结构”的问题。但要注意两个容易翻车的点如果id字段的值不是唯一的后面的记录会覆盖前面的记录丢失数据。如果id字段的值是数字字符串比如3PHP会自动把键名转为整数型3。这在你后续用isset($keyed[3])判断时没问题但在array_keys结合严格比较时可能踩坑。4.3 数组的键名自动转换规则PHP的数组键名有一个很多新手不知道的规则合法的十进制整数字符串会被自动转为int类型。所以下面这两行代码指向的是同一个元素$arr [3 hello]; echo $arr[3]; // hello echo $arr[3]; // hello如果你在二维数组中用array_column(..., null, id)得到键名3然后拿它去和另一个字符串类型的$id比较用就会失败因为一个是int(3)一个是string(3)。这个坑在从JSON解析数据时特别容易出现——JSON的键名一定是字符串而PHP数组的键名却可能自动转int。绕开的方式是取键名时不要依赖PHP的自动转换而是用strval($key)显式转成字符串再存。5. 错题复盘四遍历方向的陷阱——为什么转置之后矩阵没转置5.1 行列颠倒导致转置失败矩阵转置是二维数组的经典操作也是最容易写错行列顺序的题目。先看一个错误版本void transpose(int src[3][3], int dst[3][3]) { for (int i 0; i 3; i) { for (int j 0; j 3; j) { dst[i][j] src[j][i]; } } }这段代码本身如果src和dst是不同的数组逻辑是对的。错题通常在“原地转置”的场景里void transpose_inplace(int a[3][3]) { for (int i 0; i 3; i) { for (int j 0; j 3; j) { int tmp a[i][j]; a[i][j] a[j][i]; a[j][i] tmp; } } }问题在于当i0, j1时交换了a[0][1]和a[1][0]当i1, j0时又把同一个交换做了一遍——等于没交换。最终只有对角线元素没变其他元素转了一圈回到原位。正确做法是让j从i1开始循环只交换一次上三角。原地转置的错误版本等价于每天出门前把左脚的鞋换到右脚、再把右脚的鞋换到左脚来回折腾。5.2 按行遍历和按列遍历的性能差缓存命中的问题这个坑在刷算法题时不明显一旦处理大矩阵比如几千乘几千的二维数组性能差异就出来了。还是行优先存储的原因// 快版本按行遍历 for (int i 0; i n; i) { for (int j 0; j m; j) { sum a[i][j]; } } // 慢版本按列遍历 for (int j 0; j m; j) { for (int i 0; i n; i) { sum a[i][j]; } }在“快版本”里内存地址是连续增长的CPU从缓存里读取数据的命中率高。“慢版本”每次访问a[i][j]时都要跨m个元素跳到下一行缓存反复失效。当矩阵特别大时慢版本可能比快版本慢一个数量级以上。这是一个典型的“写对了结果、却写错了效率”的题。很多刷题网站不会因为这种性能差距判超时但面试官一定会问“你能优化一下遍历顺序吗”如果答不出“行优先存储导致按行遍历更高效”就说明对二维数组的底层理解还不够。5.3 动态规划表格里的方向错误二维数组在动态规划题目里特别常见比如最长公共子序列、编辑距离。这类题目的DP表填表顺序一旦搞错结果全错。最经典的错法是计算dp[i][j]时依赖的dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1]还没算出来结果读到了初始值0。以编辑距离为例dp[i][j]表示word1的前i个字符到word2的前j个字符的最小编辑距离。递推公式如果word1[i-1] word2[j-1]则dp[i][j] dp[i-1][j-1]否则取min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) 1填表时必须保证i-1行和j-1列已经填好所以外层循环i从1到n内层循环j从1到m正好是“从左到右、从上到下”。我见过有人把内外层反过来写结果左上角那些依赖项全是0最后答案自然错得离谱。这里的教训是**在做二维表格递推时先画一个3x3的小表手工填一遍确定填表顺序再写代码。**手工推演比直接写代码快得多也稳妥得多。6. 错题复盘五函数传参时二维数组的“退化”问题6.1 C/C里数组参数退化为指针C语言里二维数组传参不像一维数组那么随意一维数组传参时退化成指针很多人已经习惯了。但二维数组传参时很多人会犯两个错。第一个错写成void f(int a[3][3])然后把一个动态分配的int**传进去。这在语法上不匹配因为int a[3][3]在参数列表里被编译器解释为int (*a)[3]即“指向含有3个int的数组的指针”而int**是“指向int指针的指针”两者类型不同。强行传参会编译报错或者带上警告。第二个错忘记指定第二维大小。C语言要求void f(int a[][3])可以但void f(int a[][])不行。原因在于数组下标运算a[i][j]需要第二维的尺寸来算偏移量——i * 3 j中的3必须知道。编译器必须从类型里拿到这个信息如果你不写它无法计算a[i]的地址。6.2 Java与C#的引用传递误判Java和C#里二维数组准确说是数组的数组作为参数传递时传递的是引用。于是很多人会误以为“函数里修改数组元素”不会影响原数组。实际上void modify(int[][] arr) { arr[0][0] 99; }这个操作会直接改掉原数组的值因为arr指向的是同一个数组对象。但如果你写arr new int[2][2]那只会让局部变量arr指向新对象原数组不受影响。这两者的区别在于修改对象的内容 vs 让变量指向新的对象。二维数组传参时另一个常见问题是在函数里改变了二维数组的“行”结构比如arr[0] new int[3]这会让arr的第一行指向一个全新的一维数组而原数组的第一行如果还有其他引用它们仍然指向旧数组。结果就是你改了“行指针”但原来的行数据还在原地。6.3 避开传参坑的通用心法不管用哪种语言我建议先搞清楚一个核心问题你的“二维数组”到底是连续的矩形还是一组一维数组的集合这个问题的答案决定了传参的形式、访问的方式、以及底层的内存行为。C/C矩形连续数组用int (*)[N]或int[][N]传参动态分配的“数组指针数组”用int**访问和释放方式完全不同。Java只有交错数组函数传参一律是引用修改元素会作用到原数组。C#矩形数组int[,]传参时整个引用传递交错数组int[][]也是引用传递但行数组的替换操作语义要格外留意。Python二维数组通常用列表的列表传参同样是引用修改内层列表的元素直接影响原数据但如果写了arr [[0]*3]*3三个“行”其实是同一个列表对象改一行等于改三行。最后那种[[0]*3]*3的错题在Python里太过经典单独说一句它生成的不是3行独立列表而是3个指向同一个列表的引用。要生成真正的二维列表必须用列表推导式[[0]*3 for _ in range(3)]。这也是二维数组错题里的一种“初始化陷阱”只是因为Python不像C/C那样需要频繁管理内存很多人踩到这个坑之后还一脸茫然。7. 做题之外三条从实战里总结的防错习惯错题总结到最后光记住单个题目没太大用——下一次换个场景、换种语言坑的形状变了判断标准也容易失灵。我的个人建议是养成三条习惯能大幅度减少二维数组相关的低级错误。第一写任何二维数组代码前先算出偏移量。心里默念一遍a[i][j]等于base (i * 列数 j) * 元素大小。循环边界、传参类型、遍历顺序全部从这条公式倒推。所有越界、行列颠倒的问题在这一步就能拦截大半。第二在关键位置打印边界值。如果你不想用调试器就在遍历前后打印a[0][0]、a[行数-1][列数-1]确认数组的首尾元素符合预期。这两个点的内存地址分别对应数组的起点和终点只要它们在预期范围内越界概率就很低。打印首尾值最大的好处是定位问题特别快首尾对、中间乱说明是逻辑错误首尾错、中间更乱大概率是初始化或者传参的问题。第三做错题整理时按“错误根因”分类不要按“题目类型”分类。拿二维数组来说如果你把“计算矩阵对角线之和”“转置矩阵”“螺旋遍历”这些题目分别记错题你会发现它们之间没有联系错因也没法复用但如果你把所有错因归类为“越界”“初始化”“遍历顺序”“传参”这几类下次出新题目时你第一反应就是先检查这几处。我自己的笔记一般长这样错题场景错误根源修正要点对角线求和写成 列数边界条件错循环终值用行数-1C#里用Length取矩形数组总元素数API语义不清用GetLength(0)、GetLength(1)PHP foreach中改键值导致数据丢失遍历时修改数组结构用array_column($arr, null, key)原地转置没从i1开始重复交换内层循环从i1开始DP表格填充顺序错误依赖项未先计算手工画3x3小表推演填表顺序C语言漏写第二维大小编译器无法计算偏移量写成int a[][N]形式Python[[0]*3]*3初始化行引用共享用列表推导式逐行创建二维数组的错题还有不少比如字符串形式的二维字符数组、指针数组的用法、多维数组的初始化顺序、内存对齐等这篇文章写不下留给下篇再展开。如果你看完这篇能把自己过去踩过的坑归到上面任意一个篮子里那这篇总结就没白写。
返回列表