ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

二维数组练手项目:矩阵转置、旋转与四大陷阱全解析

二维数组练手项目:矩阵转置、旋转与四大陷阱全解析

第一次看到《数组2》这个项目名,我愣了一下,心想谁会把教程目录当项目名。但转念一想,这种命名反而很诚实——它明明白白告诉你:数组学习进入第二回合了。这篇文章我准备拿“数组2”当一个小型练手项目来拆,从一个最普通的矩阵开始,把二维数组的创建、遍历、转置、旋转、求和全部亲手写一遍,再把二维数组特有的初始化坑、拷贝坑、越界坑一次性说透。内容面向刚学完一维数组、想继续往下走的新手,也适合准备面试手写题的朋友当复习提纲。标题虽然只有三个字,但值得展开的东西比你想象中多。

1. 项目拆解:数组2到底在做什么

“数组2”这个标题起得相当粗粝,但粗粝有粗粝的好处:它把讨论范围圈得死死的,既不牵扯链表,也不勾连树,就是数组的第二阶段。搞清楚这一点,整篇文章的路线就不会跑偏。

1.1 标题里的“2”,不是版本号而是阶段标记

一维数组是数组的第一课,二维数组是第二课,所以叫“数组2”。如果你已经能轻松写出用 for 循环求一维数组平均值的代码,说明你有资格打开这个项目。但注意,“数组2”不是“把两个一维数组拼在一起”那么简单。二维数组真正难的地方在于:索引是嵌套的,初始化是有陷阱的,复制是浅拷贝还是深拷贝是分不清的,遍历顺序甚至会直接影响程序快慢。这些问题在一维数组里都不明显,一上升到二维,全冒出来了。

我见过不少人学完一维数组之后,觉得数组不过如此,然后直接去追链表、追二叉树。结果遇到图像处理、矩阵运算、动态规划表格这类场景时,二维数组写起来磕磕绊绊,一调试就懵。根本原因不是智商不够,而是跳步了——数组的二维形态值得单独花一个项目去练。这也是我觉得“数组2”这个练手项目真正有价值的点:它让你在进入更复杂数据结构之前,先把“二维”这个台阶踩实。

1.2 前置知识清单:需要会什么

按我的想法,开始“数组2”之前,最好先满足这四条:

  • 会变量和基本运算,知道 int、float、bool 这些类型;
  • 会写 for 循环和 while 循环,能理解嵌套循环的执行顺序;
  • 会定义函数,明白参数传递的基本概念;
  • 有过一维数组的使用经验,至少写过“求最大值”“累加和”这类题目。

如果这些还没把握,我的建议是先补一补基础,再回来看本项目。这不是门槛歧视,而是“数组2”的核心操作全是双层循环加下标变换,基础不牢的话,代码报错都看不懂,更别说自己排查。

语言选用上,我采用双轨讲法:逻辑演示用 Python,因为写起来短、看着明白;底层原理用 C 语言讲,因为内存布局、数组越界这些概念,C 暴露得最直白。两边对照着看,你会比只用单一语言的人理解得深。

1.3 这次练手项目想解决什么问题

我给它定了一个非常具体的目标:写一个 3×4 矩阵处理工具包,至少包含五个函数——打印矩阵、矩阵转置、顺时针旋转 90 度、行求和、列求和。为什么选矩阵?因为矩阵就是最典型的二维数组应用,游戏地图、图像像素、评分表、表格数据,本质都是二维数据。把矩阵操作练熟,等于给这些场景都打了底子。

这五个函数看起来简单,但覆盖了二维数组最核心的知识点:创建、下标访问、双层遍历、行列互换、结果收集。做完这个小工具包,你不仅能应付常见的笔试手写题,而且对“二维数组到底怎么存、怎么访问、怎么复制”会有一个完整的认知。后面再遇到任何二维数据场景,都只是换一层皮而已。

2. 核心概念:先把二维数组揉碎了讲

这一节不急着写完整项目,而是把二维数组相关的关键概念逐个过一遍。概念不清楚,代码写得再花哨也是空中楼阁。

2.1 一维数组是“一排锁柜”,下标就是编号

想象一排储物柜,从左到右编号 0、1、2、3、4,一共 5 个柜子。如果每个柜子里存了一个整数,这就是一个长度为 5 的一维数组。取值的时候你要说“第 3 个柜子”,在代码里就是arr[3]。

很多人会问“为什么下标从 0 开始,而不是从 1 开始”?因为数组在内存里是连续排列的,编译器要找第 i 个元素,实际上是靠地址偏移:起始地址 + i × 每个元素占用的字节数。下标从 0 开始,第一个元素偏移量就是 0,计算最简单。下标从 1 开始的话,每次都要多做一个减一操作,既浪费又不直观。所以看到arr[5]的时候,一定要清楚:它表示第 6 个元素,而不是第 5 个。长度为 n 的数组,合法下标是0到n-1,最后一个元素永远是arr[n-1]。

Python 里还有一个特殊写法:arr[-1]表示最后一个元素。这个特性很方便,但它改变了“下标必须非负”的习惯,新手用的时候要留意,别在循环里写arr[-1]却以为是倒数第二个,其实那已经是最后一个了。

2.2 二维数组就是“一张课表”,行和列缺一不可

把一排锁柜扩展成一层楼,里面有好多排锁柜,这就构成了二维结构。二维数组的经典表达是一张课表:行代表星期几,列代表第几节课,单元格里存课程名。你要定位一个数据,必须同时给出“哪一行、哪一列”,这就是matrix[行][列]的由来。

C 语言里声明一个 3 行 4 列的二维数组是这样写的:

int matrix[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} };

它表示有 3 个一维数组,每个一维数组里有 4 个 int。注意顺序:先行后列。matrix[2][1]是第 3 行第 2 列的元素,值是 10,不是第 2 行第 3 列。这个“第几个”和“下标”之间的错位感,是新手写代码最容易卡壳的地方。

Python 里没有专门的二维数组类型,但用“列表的列表”可以实现同样效果:

matrix = [ [1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12] ]

外层列表长度是 3,表示行数;每个内层列表长度是 4,表示列数。len(matrix)得到行数,len(matrix[0])得到列数。这里的每行列表还可以各自长度不同,那就是后面要说的不规则数组,先记住“外层管行、内层管列”这个基本结构。

2.3 内存里到底怎么排:行主序与性能

C 语言里,二维数组虽然逻辑上是“行和列”,但内存中还是一段连续空间,排列方式是“行主序”:先把第 0 行的所有元素排完,再排第 1 行,再排第 2 行。所以内存地址顺序是:

matrix[0][0] -> matrix[0][1] -> matrix[0][2] -> matrix[0][3] -> matrix[1][0] -> ...

这个排列方式会直接影响程序性能。CPU 访问内存时,并不是一个字节一个字节地读,而是按“缓存行”为单位成块加载。如果你按行顺序遍历,读matrix[0][0]的时候,相邻数据已经被一起加载进缓存了,后面几个连续访问都能命中缓存,速度快。如果你按列遍历,比如固定列 j,然后一行一行跳着走,每读一个数都要从内存重新加载,缓存命中率大幅下降,程序就会明显变慢。

Python 的列表稍微特殊一些,它每个元素存的是指向对象的引用,数据本身不一定连续,但逻辑上仍是“先第 0 行、再第 1 行”的访问顺序。按行遍历依然比按列遍历快,只是一般规模下差距没那么吓人。这个知识点在“数组2”阶段必须建立起来,因为你以后做图像处理、神经网络数据预处理时,遍历顺序选错了,性能差异是数量级的。

2.4 长度、下标和越界:一半的崩溃都在这

“越界”是我最想强调的坑。C 语言里有检查吗?没有。你写matrix[3][4],理论上第 3 行根本不存在,但 C 编译器不会报错,它会拿着算出来的内存地址去读写。读还好,最多拿到垃圾数据;写就危险了,可能把别的变量、甚至函数返回地址写坏了,等到程序崩溃时,错误表现往往是“某个无关变量莫名其妙变了”。这种 bug 极难排查,因为问题发生点和崩溃点可能隔得很远。

Python 相对温和,越界直接抛IndexError: list index out of range,至少你立刻知道出事了。但新手经常在双重循环里把行列数搞反,比如外层循环用列数、内层用行数,然后访问matrix[i][j],当 i 超过行数时就会报错。

我的建议是,凡是用到二维数组,先做两件事:第一,打印len(matrix)和len(matrix[0]),确认行列数;第二,在访问下标前加一句边界判断,尤其是写通用函数时:

if 0 <= i < rows and 0 <= j < cols: value = matrix[i][j]

看起来多写两行,但能帮你省下大量调试时间。

3. 实操篇:手写一个“数组2”练手 Demo

概念讲完,下面进入正题。我用一个 3 行 4 列的矩阵当原始数据,逐步实现五个函数。你可以新建一个 Python 文件,跟着敲一遍,跑出结果后再对照我的说明理解每一步。

3.1 定义任务:为什么拿矩阵转置当主线

矩阵转置就是行列互换:原来 3 行 4 列,转置后变成 4 行 3 列。原来的matrix[行][列]在转置矩阵里变成transposed[列][行]。这是二维数组最经典的“下标变换”练习,也是理解行列关系最好的题目之一。选它当主线,是因为它逼着你把“行、列、外层、内层、新矩阵的行数、新矩阵的列数”全部理清。转置学会了,后面的旋转、求和都是顺水推舟。

先定义原始矩阵:

matrix = [ [1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12] ]

这个矩阵有 3 行 4 列,故意不选方阵,因为非方阵转置后行列数都会变,更能看出“行和列不是一回事”。

3.2 第一版代码:创建矩阵并打印

先写一个最基础的打印函数。目的不是炫技,而是确认二维数组创建和访问没问题:

def print_matrix(mat): for row in mat: print(row)

运行print_matrix(matrix)会输出三行列表。这就是双层循环的最简形态:外层循环拿到每一行,内层循环(这里其实是print(row)打印整个列表)处理该行数据。严格一点的写法是逐个元素打印:

for i in range(len(mat)): for j in range(len(mat[i])): print(mat[i][j], end=" ") print()

这个写法虽然啰嗦,但能帮你建立“外层控制行、内层控制列”的肌肉记忆。很多“数组2”相关题目,本质就是在这两层循环里做文章。

3.3 矩阵转置:两种写法都要会

转置的核心是:新矩阵的行数等于原矩阵的列数,新矩阵的列数等于原矩阵的行数。实现的时候,外层循环遍历 0 到 3(原列数,也是新行数),内层循环遍历 0 到 2(原行数,也是新列数),然后执行new[新行][新列] = matrix[原行][原列]。看代码:

rows = len(matrix) cols = len(matrix[0]) transposed = [] for i in range(cols): new_row = [] for j in range(rows): new_row.append(matrix[j][i]) transposed.append(new_row) print(transposed)

跑完会得到[[1, 5, 9], [2, 6, 10], [3, 7, 11], [4, 8, 12]]。注意原矩阵只有 3 行,转置后变 4 行,每一行对应原矩阵的一列。如果你把循环条件写反,就会出现IndexError,这也是刚才强调先确认行列数很重要的原因。

Python 还可以用列表推导式一行完成:

transposed = [[matrix[j][i] for j in range(rows)] for i in range(cols)]

这个写法效率高,但可读性差一些。我的建议是:考试写推导式,工程代码写清晰版,两种都要能看懂,别只会其中一种。

还有一个小问题:方阵可以原地转置吗?可以。因为方阵的行列数相同,可以只交换上三角和下三角的元素。非方阵呢?不行,因为行数和列数不同,原矩阵没有足够的空间存放转置后的数据,必须新建数组。面试时遇到这种问题,先判断是不是方阵,再决定要不要原地操作。

3.4 从转置到旋转:一行代码背后的顺序

顺时针旋转 90 度和逆时针旋转 90 度,是转置的经典扩展。顺时针旋转的规律是:先转置,再把每一行反转。也就是说,转置后第 i 行会变成原矩阵第 i 列,反转后原来的第一列变最后一列,整体就完成了顺时针旋转。代码:

def rotate_clockwise(mat): rows = len(mat) cols = len(mat[0]) transposed = [[mat[j][i] for j in range(rows)] for i in range(cols)] return [row[::-1] for row in transposed]

如果你想逆时针旋转,套路反过来:先把每一行反转,再转置。你可以在小纸片上画一个 2×3 矩阵,手动推一遍这两个步骤,会比光看代码理解得快。

这个小函数里最值得品的是row[::-1]。Python 的切片反转很方便,但要记住它返回一个新列表,不会修改原来的行。如果你用row.reverse(),就会直接改动转置后的结果,可能会影响后续操作。旋转类题目考的不只是“能不能转”,还考“有没有搞乱原数据”。动手之前先想清楚哪一步会产生新对象、哪一步会修改原对象。

3.5 行列求和:顺带把双层循环练透

行求和最简单,每个内层列表本身就是一行,直接sum(row):

row_sums = [sum(row) for row in matrix] print(row_sums) # [10, 26, 42]

列求和稍微绕一点:要求“每一列的和”,也就是固定列号,把每行的同一列加起来。常规写法是三层嵌套——外层遍历列,中层遍历行,内层累加。用列表推导式可以写得更简短:

col_sums = [sum(matrix[i][j] for i in range(rows)) for j in range(cols)] print(col_sums) # [15, 18, 21, 24]

这个表达式里,外层for j in range(cols)固定了列,内层生成器for i in range(rows)遍历所有行,每次取matrix[i][j]累加。如果你能一眼看懂这段代码,说明你对二维数组的访问方式已经有了手感。看懂之后,强烈建议你用手动双层循环再写一遍,因为很多实际场景里你需要在求和的同时干点别的事,表达式一行写完就没办法继续扩展了。

到这为止,一个基本的“数组2”工具包已经成型。打印、转置、旋转、行和、列和,五个函数全部搞定。接下来聊聊真正让新手崩溃的坑。

4. 常见坑与排查技巧实录

二维数组的坑,很多不是“不会写”,而是“写出了 bug 看不懂”。这一节我按实战经验总结四个高频问题,每一个都值得贴在你的编辑器上方。

4.1 越界报错:行列数搞反是头号事故

症状很典型:双重循环跑着跑着,突然IndexError: list index out of range。原因大半都是行列下标写倒。

比如你想读matrix[i][j],但外层循环范围用了len(matrix[0]),内层用了len(matrix),当 i 超过行数时立刻越界。排查这类问题,我有一套固定动作:先把rows = len(matrix)和cols = len(matrix[0])打印出来,确认原始数据规模;然后检查循环边界用的是rows还是cols;最后确认访问时是matrix[行][列],顺序别颠倒。这套动作做下来,大部分越界问题 30 秒内就能定位。

还有一种相对隐蔽的越界,发生在“读但没越界,写越界”的 C 语言场景。C 里越界读可能不报错,但越界写会污染内存。这种问题靠调试器很难直击,最好的办法是从源头规范化:所有访问先判边界,循环一律用i < rows、j < cols,不写<=。后者很容易让你偏移一格,访问到第rows行,也就是越界。

4.2 初始化陷阱:[[0] * n] * m 为什么错

这是我眼中“数组2”第一名送命题。想创建一个 4 行 3 列、元素全为 0 的二维数组,Python 新手常写成:

wrong = [[0] * 3] * 4 wrong[0][0] = 99 print(wrong)

你猜会发生什么?结果是四行全部被改成[99, 0, 0]。原因在于[0] * 3先创建了一个长度为 3 的列表,然后* 4并不是复制这个列表的内容四次,而是把“同一个列表的引用”重复了四次。也就是说,四个外层元素指向的是同一个内层列表,改其中一行,四行一起变。这一步极其反直觉,我见过不少人在这上面排查了半小时。

正确写法是用列表推导式,每一轮迭代都创建一个新列表:

right = [[0] * 3 for _ in range(4)] right[0][0] = 99

这次只有第一行被修改,其他行保持全 0。记住这个结论:创建二维数组,永远用推导式,不要用乘法。乘法创建的是一排指向同一个列表的引用,在二维场景下几乎是必然出错的。

4.3 拷贝陷阱:b = a 不是复制

很多人以为写了b = a就是复制了一份二维数组,然后高高兴兴去修改b,回头发现原始数组a也被改了。这同样是“数组2”阶段绕不过的坑。

先明确:b = a只是给同一个对象起了个别名,b和a指向同一块内存,改b必然改a。想要真正的复制,一是用标准库的copy.deepcopy,二是用列表推导式逐行创建一个新列表:

# 浅拷贝,只复制外层,内层列表仍然是共享的 b = a.copy() # 深拷贝,内层也创建新列表 import copy c = copy.deepcopy(a) # 列表推导式,等价于逐行复制 d = [row[:] for row in a]

其中row[:]是切片复制一行的写法,生成一个新列表,不会和原行共享元素。这四种方式在什么场景用哪个,一句话总结:读操作随便;要修改原数组结构、且不想影响原来的数据,必须深拷贝。如果不确定,多花一行copy.deepcopy最稳妥。

这个坑在实际项目里最典型的后果是:你写了一个转置函数,内部不小心对参数进行了修改,结果调用方发现原矩阵已经面目全非。所以在函数开头先明确“我要不要保留原数据”,需要保留就先拷一份。

4.4 性能与遍历顺序:缓存友好不是玄学

最后聊一个偏性能的坑。前面提过行主序内存布局,遍历顺序直接影响缓存命中率。C 语言里写一个双重循环,按行遍历一个 5000×5000 的 int 二维数组,可能只要几十毫秒;按列遍历,时间可能翻 5 到 10 倍。不是因为编译器偏心,而是缓存机制就是这么运作的。你跳着读数据,内存就得反复把整块数据搬到缓存里,CPU 大部分时间都在等内存。

Python 里虽然没有这么夸张,但纯 Python 双层循环本身就很慢,如果你的二维数组规模很大,我的建议是:

  • 尽量用内置的sum()、max()、min()代替手写内层循环;
  • 能用列表推导式就用推导式,它在底层做了大量优化;
  • 需要真正的数值计算,直接用现成的数值库,底层是 C 实现的连续数组,性能和内存占用完全不同。

这条经验来自我实际测试:同样一个 1000×1000 矩阵求列和,用纯 Python 双层循环大概要跑几秒,用数值库的向量化运算,时间可以缩短到肉眼不可见。不是说你必须立刻上库,但要知道“哪一层代码才能命中二维数组的线性内存布局”。

5. 进阶玩法:数组2还能扩展成什么

工具包做完,坑也排完,接下来把视野放大一点。二维数组不是终点,它后面还有三个方向值得延伸,每一个都能让你的“数组2”项目变得更完整。

5.1 不规则数组:每行长度不一样

我前面说“外层管行、内层管列”,其实内层的长度并不是必须相同的。Python 列表完全允许每行长度不同,比如:

jagged = [ [1], [2, 3], [4, 5, 6] ]

这种不规则数组在“杨辉三角”里特别常见,每一行的元素个数都不同。遍历时要特别注意:不能再用统一的len(matrix[0])当列数,必须逐行取长度:

for i in range(len(jagged)): for j in range(len(jagged[i])): print(jagged[i][j], end=" ") print()

很多人从均匀矩阵切到不规则数组,会惯性写len(matrix[0]),结果要么漏数据,要么越界。学会“每一行单独看长度”,是数组进阶的一个分水岭。杨辉三角生成代码我贴在这里,建议自己敲一遍:

def yanghui(n): tri = [] for i in range(n): row = [1] * (i + 1) for j in range(1, i): row[j] = tri[i - 1][j - 1] + tri[i - 1][j] tri.append(row) return tri

5.2 稀疏矩阵的压缩存储

当二维数组绝大多数元素都是 0 时,继续用完整二维数组存储就非常浪费。比如一张 1000×1000 的棋盘,可能只有几百个位置有值。这时可以考虑“稀疏矩阵”思路:只存储非零元素的位置和值。

Python 里我用字典实现,键是(行, 列)元组,值是元素内容:

sparse = {} for i in range(rows): for j in range(cols): if matrix[i][j] != 0: sparse[(i, j)] = matrix[i][j]

存储量从原来的 rows × cols 个元素,降为“非零元素个数”。如果稀疏率达到 90% 以上,压缩效果极其明显。这个技巧在很多场景里都会用到:游戏地图、推荐系统的用户评分矩阵、社交网络邻接矩阵。“数组2”阶段理解这个思路就够了,真要做工程,直接用专门的稀疏矩阵库,不用自己造轮子。

5.3 从数组到对象:什么时候别用二维数组

二维数组适合数据形态规整、列含义统一、且以数值计算为主的场景。但如果你的每一“行”代表一个实体,每一“列”有明确名字,比如一个人有姓名、年龄、城市、手机号,再用person[i][0]、person[i][1]这种下标去取,代码会迅速变成天书。你根本分不清0是姓名还是年龄。

这时候应该考虑把“行”升级为对象。Python 里可以用namedtuple或dataclass,把字段名写清楚,访问时直接person.name、person.city。代码语义清晰,还能减少“下标写错一位”导致的隐藏 bug。记住一个原则:行列均匀、纯数值、依赖下标运算的用二维数组;带语义、字段多、字段名重要的用对象。两者不是对立的,工程里经常混用——先从文件读出二维数据,再转换成对象列表,最后做业务处理。

做图像像素处理时,一个像素点往往是(x, y, r, g, b),这种固定结构用数组没问题;但如果你要表示“用户最后登录时间”“用户级别”这些带业务含义的数据,再用二维数组就属于自找麻烦。数组负责“底层存储”,对象负责“上层语义”,分清楚边界,“数组2”就算毕业了。

我个人在实际操作中的体会是:二维数组的很多 bug,其实都出在“我以为”上——我以为转置后行列数不变,我以为*复制了列表,我以为b = a是复制。把这三个“我以为”逐个击破,你对数组的理解会扎实很多。最后再分享一个实用小技巧:拿到任何二维数组操作题,先别急着写代码,拿纸画一个 2×3 的小矩阵,手动把转置、旋转、求和推一遍,把每一步的结果写下来,再对着实现代码检查。超过一半的 bug,在你落笔之前其实就已经被消灭了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进