
面试官把一个3×3矩阵拍在你面前要求把矩阵外圈的8个数字按环形顺序重新排成递增序列。很多人第一反应是“这不就是排序吗直接排不就完了”可真动手写代码十个里有六七个会在坐标映射上绕晕。今天就拿这个“3×3矩阵外环数字环形排序”的题目把从拆题到落地实现的完整思路、代码和我踩过的坑一次讲透。这篇文章适合正在刷算法题的人、带编程竞赛的教练以及任何一个想把二维数组操作搞清楚的学习者。先看一个具体例子。假设原始矩阵长这样[5, 2, 8] [4, 1, 7] [9, 6, 3]要求把最外圈的8个数字也就是除了中心数字1以外的那一圈按照从左上角出发、顺时针方向、从小到大重新排列。处理之后矩阵应该变成[2, 3, 4] [9, 1, 5] [8, 7, 6]从左上角顺时针读一圈2、3、4、5、6、7、8、9完全递增。中心的1原地不动。这个题目麻雀虽小五脏俱全它其实同时考察了“二维坐标遍历”“环形路径展开”“线性排序后回填”三件事。接下来我按自己的解题顺序一层层拆开讲。1. 题面拆解环形排序到底在排什么1.1 一个最直观的例子上面那个例子值得多看几眼。外环的原始数字如果按顺时针从左上角提取顺序是5, 2, 8, 7, 3, 6, 9, 4排序之后变成2, 3, 4, 5, 6, 7, 8, 9再按同一个顺时针路径放回去就得到了目标矩阵。注意这里有一个很关键的现象排序后的8个数字在环上是“从左上角开始一路递增”的但当你走完一圈回到左上角时最后一位9和第一位2之间并不是递增的它有一个“断点”。这就牵扯出一个必须讲清楚的概念环形排序并不是要求环上任意相邻两个数都递增因为在一个闭合的环上如果沿某个方向严格递增最后一个元素会和第一个元素形成矛盾——它既要大于前一个元素又必须小于第一个元素。所以实际说的“环形递增排序”通常默认有一个起点和一个方向从起点出发沿指定方向看过去数字是单调递增或递减的。这样语义才算自洽。1.2 环形排序的严格含义我见过不少初学朋友纠结这一点环形上怎么做到严格递增答案就是上面说的允许收尾处有一个跳跃。用生活化的比喻来说开会时8个人围成一圈要求按年龄顺时针从门口那位开始依次增大坐那么最后一个人和门口那个人之间年龄当然可以“断崖式”变化这不影响“沿顺时针看每个人的年龄都比前一个大”。所以无论题目怎么措辞我们做的时候一定要先定好三个约定起点在哪、方向是顺时针还是逆时针、排序后是递增还是递减。多数在线判题系统的默认写法是“从左上角开始顺时针升序”这也是我在这篇文章里采用的约定。1.3 外环的边界定义3×3矩阵一共9个位置用坐标(i, j)表示第i行第j列。外环就是满足下面任意一个条件的位置i等于0最上面一行i等于2最下面一行j等于0最左边一列j等于2最右边一列换句话说只要这个点在矩阵边界上就属于外环。在3×3的情况里外环正好8个位置剩下的只有中心坐标(1,1)。对于更一般的n×n矩阵外环的大小是4n-4个元素。这个公式后面推广时会用到先放在这里。理解外环边界条件很重要因为很多人写代码时会把“左上角”这种具体坐标写死一换矩阵尺寸就崩。正确的做法是始终用“是否在边界上”来判断而不是把0、1、2这些数字焊死在代码里。2. 思路核心提取、排序、回填三步走2.1 为什么不能直接在矩阵内两两交换排数字的第一反应可能是类似冒泡排序那样直接在矩阵里比较交换。但环形排序有一个天然的麻烦环上的“相邻关系”往往不存在于同一行或同一列。比如外环里右上角(0,2)和右中(1,2)是相邻的它们在同一个方向但不在同一行左中(1,0)和左上(0,0)也是相邻的它们一个在边上一个在角上更别提最后一步从(1,0)回到(0,0)这种“跨行跨列”的邻接关系。如果你直接在二维数组里写交换逻辑很快就会陷入“谁和谁换、换了会不会把还没排好的位置覆盖掉”的泥潭。我自己在实践中形成的标准解法非常简单粗暴但极其有效先把环展开成一条线性数组排序再按原来的路径填回去。这本质上是一种“降维打击”——把二维问题变成一维问题一维排序有现成的排序算法不需要自己在二维空间里折腾。2.2 坐标序列一切操作的基准整套算法的灵魂是预先准备好一条“坐标序列”。这条序列描述了从起点开始沿着指定方向走完整个外环时每一个位置依次是什么。以顺时针、左上角为起点为例3×3矩阵的坐标序列是(0,0) - (0,1) - (0,2) - (1,2) - (2,2) - (2,1) - (2,0) - (1,0)为什么要单独把它拎出来说因为后面的提取和回填都用这一条序列。提取时按这个顺序读数字回填时按这个顺序写数字前后不一致就会得出一个莫名其妙的结果。我见过很多代码bug本质上都是提取用的是一种走法回填时又换了一种走法。生成这条序列的方法是把外环拆成四段上边从左往右取完一整行右边从上往下从右上角的下一格开始一直取到右下角下边从右往左从右下角的前一格开始一直取到左下角左边从下往上从左下角的上一格开始一直取到左上角的下一格。这个写法保证了四个角只被取一次不会重复。最稳妥的验证办法就是把每一步的坐标打印出来对照着纸上的矩阵逐个打勾。2.3 排序与循环移位的可选处理拿到展开后的环数组直接调用排序函数得到一条有序序列。这里有一个进阶玩法如果题目要求“最小数字不在左上角而是从某个指定入口开始递增”只需要把排序后的序列做一次循环移位再回填。因为环本身是闭合的线性数组循环移位等价于把起点换到环上的另一个位置。举个例子排序后序列是[2,3,4,5,6,7,8,9]如果希望数值5出现在左上角那就把序列循环左移三位得到[5,6,7,8,9,2,3,4]再按坐标序列回填。环上的相对顺序没有变只是入口位置变了。这个技巧在题目要求“指定起点的环形排序”时特别有用。3. 用 Python 从零手写完整实现3.1 面向3×3的写死版本先给一个针对3×3矩阵、逻辑最直白的版本。代码不多但每一步都有明确的目的def sort_outer_ring_3x3(mat): # 1. 按顺时针路径收集外环数字同时记录坐标 values [] pos [] # 上边从左到右 for j in range(3): pos.append((0, j)) values.append(mat[0][j]) # 右边从上到下从右上角下一格开始到右下角结束 for i in range(1, 3): pos.append((i, 2)) values.append(mat[i][2]) # 下边从右到左从右下角前一格开始到左下角结束 for j in range(1, -1, -1): pos.append((2, j)) values.append(mat[2][j]) # 左边从下到上从左下角上一格开始到左上角下一格结束 for i in range(1, 0, -1): pos.append((i, 0)) values.append(mat[i][0]) # 2. 排序 values.sort() # 3. 按原坐标序列回填注意拷贝一份矩阵避免影响原数据 new_mat [row[:] for row in mat] for (i, j), v in zip(pos, values): new_mat[i][j] v return new_mat这段代码没有用任何奇技淫巧就是按四段循环把外环“摸”一遍。三个循环里的边界范围一定要仔细看它们是四角不重复的关键。3.2 逐段拆解每段在做什么第一步收集环节四个循环分别对应外环的四条边。上边用range(3)把(0,0)、(0,1)、(0,2)三个位置全拿走右边用range(1,3)取(1,2)和(2,2)这样右上角(0,2)已经在上边取过了不会重复而右下角(2,2)被右边拿走下边用range(1,-1,-1)取(2,1)和(2,0)不碰右下角左边用range(1,0,-1)只取(1,0)不碰左下角(2,0)和左上角(0,0)。这样8个位置正好不重不漏。排序环节直接调用Python内置的values.sort()默认升序。这里不要自己写排序算法内置的Timsort又稳又快对于长度为8的数组来说完全够用。回填环节里new_mat [row[:] for row in mat] 是为了做一次浅拷贝这样原矩阵不会被修改。很多题目的判题系统要求不改变原输入只在返回值里给结果这个习惯能避免很多麻烦。3.3 测试验证跑一遍示例数据用文章开头的矩阵来做验证mat [ [5, 2, 8], [4, 1, 7], [9, 6, 3] ] result sort_outer_ring_3x3(mat) for row in result: print(row)输出结果是[2, 3, 4] [9, 1, 5] [8, 7, 6]和预期完全一致。为了稳妥我习惯在写完这类函数后加几个断言把“提取数字的数量”“中心元素不变”“外环元素集合不变”都检查一遍assert len(pos) 8 assert result[1][1] 1 assert sorted(values) [2, 3, 4, 5, 6, 7, 8, 9]其中最后一个断言能确认外环的8个数字一个没丢、一个没换只是顺序变了。3.4 逆时针与降序只改一个地方就行如果题目要求逆时针排序只需要把坐标序列重新生成一遍。逆时针从左上角出发的路径是(0,0) - (1,0) - (2,0) - (2,1) - (2,2) - (1,2) - (0,2) - (0,1)写代码时改四个循环的遍历方向即可左边从下到上、下边从左到右、右边从下到上、上边从右到左。如果要求降序把values.sort()改成values.sort(reverseTrue)。核心的三个步骤“提取、排序、回填”完全不变。这里顺便说一句我审视过不少项目里实现这个功能的代码最优雅的做法是把“走环方向”作为一个参数传进去顺时针和逆时针共用一套逻辑只是坐标生成顺序不同。后续我会在第5章给出一个面向n×n矩阵的通用版本。4. 实战中的坑与边界处理4.1 四个角重复采集最常见的崩溃来源写这个功能时八成以上的bug都出在坐标序列上其中“四个角被重复采集”又是重灾区。典型的错误写法是四段循环都取完整的一边# 错误示例右边从0开始取导致右上角被重复计算 for i in range(3): pos.append((i, 2))这时坐标序列里会出现两次(0,2)总共收集9个数字而不是8个。排序后回填时同一个位置被写了两次最后一个覆盖前面的最终结果完全错乱。正确的四段划分原则是上边全取右边去掉第一个下边去掉第一个和最后一个左边只取中间。如果你的代码写的是通用版本可以用一个笨办法验证收集完坐标后打印len(pos)3×3矩阵必须等于8。如果等于9基本就是角上重复了。4.2 中心元素被误伤外环排序很容易和“整个矩阵排序”混淆。如果把矩阵整体转成一维数组排序后再填回3×3那就不叫外环排序了中心元素也会被挪走。之前我就见过一个需求方描述得模模糊糊开发直接做成了全矩阵排序结果验收时发现中心值变了整个逻辑推倒重来。这个问题在实现层面很好避雷回填时只要严格按照外环坐标序列走就不会碰到(1,1)。但如果你是先把整个矩阵flatten排序再按某种规则回填就一定要在回填时显式跳过中心位置。更稳妥的做法始终是“只提取外环、只对外环排序、只回填到外环坐标”。4.3 起点和方向约定不一致导致“看起来没排对”另一个隐蔽的坑是提取和回填用的不是同一条坐标序列。比如提取时按顺时针但回填时图省事用了另一个函数生成逆时针序列。这样产生的结果很搞笑数字确实有序但位置整体错位或者呈现出“顺时针对一个、逆时针对一段”的混沌状态。排查这类问题有个立竿见影的办法在开发阶段打印pos序列提取前打印一次回填前再打印一次肉眼对比应完全一致。任何“我明明排序了但结果不对”的情况十有八九是这两条序列对不上。4.4 2×2、1×1 以及非正方形矩阵怎么办这个题目虽然叫3×3但实际应用中难免遇到其他尺寸。2×2矩阵的外环就是整个矩阵4个位置构成一个环算法照常处理坐标序列为(0,0)、(0,1)、(1,1)、(1,0)。1×1矩阵只有一个元素外环就是它自己排序等于没排直接返回原矩阵即可。非正方形矩阵比如3×4外环的定义仍然是“所有处于边界上的位置”但四个边并不等长用我上面给出的四段循环也能处理只是下边和左边的取法要根据行数列数调整。如果项目里确实需要支持非正方形我建议把矩阵的行数和列数作为两个参数传入而不是默认n×n。这个点放在第5章的通用代码里一起说。5. 延伸思考从3×3到n×n的通用套路5.1 把代码推广到任意n×n外环3×3的写死版本优点是直观缺点是换一个尺寸就要改边界。真正项目里我更愿意写一个通用函数接受任意n×n矩阵同样做外环排序def sort_outer_ring(matrix): n len(matrix) if n 1: return [row[:] for row in matrix] coords [] # 上边从左到右取整行 for j in range(n): coords.append((0, j)) # 右边从第二行到最后一行取到右下角 for i in range(1, n): coords.append((i, n - 1)) # 下边从倒数第二列到第0列取到左下角 for j in range(n - 2, -1, -1): coords.append((n - 1, j)) # 左边从倒数第二行到第1行 for i in range(n - 2, 0, -1): coords.append((i, 0)) values [matrix[i][j] for i, j in coords] values.sort() new_mat [row[:] for row in matrix] for (i, j), v in zip(coords, values): new_mat[i][j] v return new_mat这段代码的核心逻辑和3×3版本完全一致只是把数字3换成了n循环边界用n来表示。坐标总数等于4n-4这就是外环元素个数。验证时可以用len(coords) 4 * n - 4这个条件来检查。复杂度分析遍历外环和回填都是O(n)排序是O(m log m)其中m4n-4。整体时间主要花在排序上但对于典型的小矩阵来说完全可以忽略。5.2 多层“洋葱”式排序怎么办如果把外环排序做完之后再要求“下一层环也排序”就变成了逐层剥洋葱。n×n矩阵从外到内会有若干层每一层都是一个更小的方环。处理方式很直接外层排完把矩阵向内收缩一圈对剩下的(n-2)×(n-2)子矩阵继续执行同样的外环排序。收缩的关键是记录偏移量。当前层的左上角坐标为(offset, offset)当前矩阵边长为size那么这一层的外环就落在从(offset, offset)到(offsetsize-1, offsetsize-1)这个范围内。内层递归处理即可。逐层排序的时间复杂度大约是O(n² log n)因为每一层排序的元素个数不同但求和后还是O(n² log n)量级。这个思路不仅适用于排序也适用于一切“按层处理”的矩阵操作比如螺旋遍历、螺旋填充、蛇形旋转本质上都是同一类坐标变换问题。5.3 环展开这种思路的通用价值把二维环形操作降维成一维线性操作这个思路本身非常值钱。不管是外环排序、外环反转、外环筛选还是外环按奇偶分离都可以套用“提取坐标序列 - 线性处理 - 回填”的三步框架。面试时遇到类似题目先把坐标序列画出来再谈排序基本就能立于不败之地。我个人在实际项目里的习惯是接到这种题目第一件事不是在键盘上敲代码而是拿纸笔画一张网格把起点、方向、坐标序列标清楚。只要这张图是对的代码怎么写都不会偏相反如果脑子里一团浆糊就开写百分之百会在边界条件上翻车。尤其是“提取坐标序列”这种细节画一张图十分钟就能避免两小时的排查。最后分享一个小技巧写通用版本时不妨在函数入口临时加一行print(coords)来调试确认坐标序列符合预期后再删掉。我见过太多人在“明明是坐标错了”的问题上反复调试排序逻辑浪费时间。先验证坐标序列再怀疑排序这是处理环形矩阵题目的第一原则。