
上机笔试碰到“取出指定条件二维数组的子数组”这种题我的第一反应不是高兴它简单而是警铃大作。原因很简单这类题看起来三五句话就能写完实际挂分点比很多中等难度的算法题都多。它正好卡在“你懂不懂二维数组本质”和“你会不会把模糊需求翻译成严谨代码”之间是笔试里最典型的“看似送分、实则筛人”的题目。这篇文章就把这道题彻底拆开揉碎从条件类型、接口设计、C#与C语言两套实现到边界条件和变体题一次性讲透。无论你是正在准备校招机考的应届生还是工作中需要处理二维数组筛选的开发者这篇文章都能让你少踩几个坑。1. 这道题背后真正想考察的三种能力先说结论面试官出这道题不是为了考你会不会用for循环而是想通过一个很小的切口观察你面对“二维数据 条件筛选”时的抽象能力、边界意识和代码组织能力。1.1 考察点一索引坐标系和维度抽象二维数组的核心是坐标系。array[i, j]里i是行索引、j是列索引这个大家都会写但一放到“子数组”这个概念里就开始乱了。什么叫“子数组”很多人的第一反应是“取矩阵的一部分”也就是从第rowStart行到第rowEnd行、第colStart列到第colEnd列的子矩阵。也有人理解成“按条件挑出若干行”还有人理解成“按条件挑出若干元素组成新数组”。这三种理解对应完全不同的代码结构。笔试里最常考的是前两种按条件筛选行或者按范围截取子矩阵。但题目原文往往只给一句“取出指定条件二维数组的子数组”这时候你必须在脑子里立刻建立一个二维坐标模型明确“行是外层、列是内层”后续所有代码都建立在这个坐标系上。我见过不少同学代码写到一半行列索引搞反或者把行索引当列索引用最后输出全错。问题根源就是没有在动手前把坐标系画清楚。1.2 考察点二把“条件”参数化“指定条件”这四个字是整道题的题眼。条件是什么是值大于某个阈值是行内元素全部满足某个规则是行内某一列的值落在某个区间还是行内所有元素的和超过某个数如果你在函数里写死if (row[2] 10) { ... }那这道题你只能得基础分。稍微有点区分度的评分标准都会看你能不能把“条件”从函数里抽出来作为一个参数传进去。这就是策略模式在算法题里的降维应用。能写出FilterSubArrays(int[,] matrix, Predicateint[] condition)这种签名的人和写死条件的人在面试官眼里是两个档次的候选人。前者展示的是可扩展性思维后者展示的是“我能跑通这一个用例”。后面我会给出具体代码你一看就明白。1.3 这种题为什么比 LeetCode 难题更容易翻车LeetCode 难题你一看就知道自己不会反而会认真读题、仔细分析。这种“简单题”才是最危险的因为它给你的安全感是虚假的。我见过真实的笔试统计像“二维数组的子数组”这类题目通过率往往不到 50%。挂掉的原因五花八门有的人没处理空数组直接越界有的人返回了引用导致后续修改污染原始数据有的人没有考虑一行数据恰好满足/不满足条件的边界还有的人压根没读懂“子数组”到底按行还是按列取。所以别小看这道题。把它当作一次完整的编码流程来对待拆解需求 → 设计接口 → 实现 → 自测边界 → 复盘扩展。本文就是按这个流程组织的。2. 动手前先拆题四种常见的“指定条件”题面只给了“指定条件”四个字具体条件要靠你从上下文里猜。我总结这些年笔试里见到的高频条件基本逃不出下面四类。2.1 条件类型一按行数据本身筛选这是最直观的写法二维数组的每一行是一个单位如果这一行里“存在某个元素大于阈值”或者“所有元素都大于阈值”就把这一行作为子数组加入结果。对应到代码上的关键点是你需要先决定“条件作用在行上”然后设计遍历顺序。外层循环走行内层循环走列遇到满足条件的行就复制出来。谁在外层、谁在内层看似是小细节其实决定了你的代码是否清晰。另一个容易忽略的问题是“复制”。如果直接把原数组的某一行返回出去在 C# 里int[,]转行数组本来就要复制但在 C 语言里返回int*就要小心。后面我会专门讲。2.2 条件类型二按位置范围截取子矩阵这种变体更接近“切片”语义给定rowStart, rowEnd, colStart, colEnd取出矩阵中的一个矩形区域。它在上机题里出现频率很高因为实现思路清晰外层从rowStart循环到rowEnd内层从colStart循环到colEnd把值逐格复制到新矩阵。坑在哪里坑在于题目给的坐标是“闭区间”还是“半开区间”。有的题写[rowStart, rowEnd]有的写[rowStart, rowEnd)很多人的代码在第二个用例直接翻车就是因为没确认区间语义。如果题目没说清楚建议在注释里写明你的理解然后按半开区间实现因为 C# 的Array.Copy、Python 的切片都是左闭右开这个约定更符合通用习惯。2.3 条件类型三按行聚合结果筛选比如“取出所有行和大于 100 的行”“取出所有行平均数大于 50 的行”。这类条件要求你先对行内元素做一次聚合计算再用聚合结果做判断。实现上有一个优化点如果题目要求反复筛选可以先把每行的聚合值算出来存到一个一维数组里避免每次筛选都重新遍历整行。笔试里虽然数据量通常不大但这体现的是你“有没有考虑重复计算”的意识是加分项。2.4 条件类型四组合条件与数组越界的边界真正拉开差距的是组合条件。比如“取第三列大于 0 且行和大于 100 的行”或者“取某个元素值落在一个动态变化区间内的连续区域”。这种题不仅考你的条件抽象能力还考你对数组越界的敏感度。比如你判断row[j] threshold时有没有先确认j确实小于列数如果传入的数组本身是“不规则二维数组”比如交错数组int[][]每一行的列数可能不同你是不是还假设了所有行等长我见过一个真实案例候选人写的代码在规则二维数组上全部通过但测试用例里有一行的列数比其它行小结果直接 IndexOutOfRangeException前功尽弃。所以动手拆题时一定要确认这个二维数组是规则的int[,]还是不规则的int[][]。3. C#版本接口设计、完整代码与复杂度分析C# 写这类题有天然优势PredicateT委托、ListT动态扩容能让你把注意力放在逻辑上。但优势用不好也会变成劣势——很多人只会写死条件不会抽象。3.1 C#实现用 Predicateint[] 筛选行先给出按行筛选的完整实现。核心思想是把“条件”定义为一个接收int[]返回bool的委托函数只负责遍历和收集。using System; using System.Collections.Generic; public static class MatrixHelper { /// summary /// 按条件筛选二维数组中的行。 /// /summary /// param namematrix源二维数组/param /// param namecondition行条件接收一行数据返回是否保留/param /// returns满足条件的行组成的 List/returns public static Listint[] FilterRows(int[,] matrix, Predicateint[] condition) { var result new Listint[](); if (matrix null || condition null) return result; int rowCount matrix.GetLength(0); int colCount matrix.GetLength(1); for (int i 0; i rowCount; i) { int[] row new int[colCount]; for (int j 0; j colCount; j) { row[j] matrix[i, j]; } if (condition(row)) { result.Add(row); } } return result; } }调用方式也很直观int[,] data new int[,] { { 1, 5, 9 }, { 12, 3, 4 }, { 7, 8, 15 }, { 20, 1, 2 } }; // 取出“至少有一个元素大于10”的行 var rows MatrixHelper.FilterRows(data, row { foreach (int val in row) { if (val 10) return true; } return false; }); foreach (var row in rows) { Console.WriteLine(string.Join(, , row)); } // 输出 // 12, 3, 4 // 7, 8, 15 // 20, 1, 2有没有发现把这个设计里的Listint[]换成ListListint再把int[,]换成int[][]代码几乎不用改这就是抽象的好处条件变了、数据结构微调了核心逻辑纹丝不动。3.2 C#实现截取矩形子矩阵如果是按坐标截取矩形区域上面的按行筛选就不够用了。你需要写一个接收行范围、列范围的函数。public static int[,] GetSubMatrix(int[,] matrix, int rowStart, int rowEnd, int colStart, int colEnd) { if (matrix null) throw new ArgumentNullException(nameof(matrix)); int rowCount matrix.GetLength(0); int colCount matrix.GetLength(1); // 边界检查防止索引越界 if (rowStart 0 || rowStart rowCount || rowEnd rowStart || rowEnd rowCount || colStart 0 || colStart colCount || colEnd colStart || colEnd colCount) { throw new ArgumentOutOfRangeException(取值范围超出矩阵边界); } int subRowCount rowEnd - rowStart 1; int subColCount colEnd - colStart 1; int[,] result new int[subRowCount, subColCount]; for (int i 0; i subRowCount; i) { for (int j 0; j subColCount; j) { result[i, j] matrix[rowStart i, colStart j]; } } return result; }这里我特意写了显式边界检查。笔试时这道检查语句可能就是 5 分和满分的区别。因为测试用例里大概率会出现rowStart 0, rowEnd 0单行矩阵或者colStart colEnd单列矩阵的极端情况没有边界检查你的代码会在最后一组用例上挂掉。3.3 调用示例与复杂度分析两种实现的时间复杂度都很容易分析操作时间复杂度额外空间按条件筛选行O(rowCount * colCount)O(k * colCount)k 为满足条件的行数截取矩形子矩阵O((rowEnd - rowStart 1) * (colEnd - colStart 1))O(子矩阵元素个数)如果笔试数据量在10^3 * 10^3量级这个复杂度完全够用。如果列数特别大还可以在条件判断前先把矩阵“扁平化”为交错数组减少索引计算的开销。但笔试阶段不建议为了这种优化牺牲可读性先保证逻辑正确再考虑优化。4. C语言版本二维数组传参的正确姿势与动态返回C 语言版本才是这道题的“重头戏”。因为 C 语言里二维数组本身就有很多坑尤其是函数传参一个不留神就编译不过或者内存泄漏。下面我先把最核心的坑讲清楚再给一份能直接跑通的完整实现。4.1 二维数组传参为什么必须给列数先看一个很多人踩过的坑。你写void process(int arr[][], int rows, int cols);编译直接报错。原因其实不复杂二维数组在内存里是连续存放的arr[i][j]等价于*(arr i * cols j)。编译器必须知道cols才能计算出每一行的起始地址否则它根本无法定位arr[i][j]的位置。所以数组形参里第一维可以省略第二维必须写死或者用 C99 的可变长数组// 方式一固定列数 void process(int arr[][4], int rows); // 方式二C99 可变长数组调用时 cols 必须在前面 void process(int rows, int cols, int arr[rows][cols]);方式二写起来灵活但需要注意参数顺序rows和cols必须出现在数组参数之前。很多老旧的笔试环境用的是 C89 标准上机时要先确认编译器是否支持 VLA如果不支持就老老实实用宏定义固定列数或者像我下面那样用int**手动构建“指针数组”来模拟二维数组。4.2 函数指针实现条件回调C语言没有委托实现“把条件参数化”要用函数指针。#include stdio.h #include stdlib.h #include stdbool.h // 条件函数类型接收一行数据返回是否保留 typedef bool (*RowCondition)(int *row, int cols); // 示例条件行内存在元素 threshold typedef struct { int threshold; } ThresholdConditionContext; bool AnyGreaterThan(int *row, int cols, void *context) { ThresholdConditionContext *ctx (ThresholdConditionContext *)context; for (int j 0; j cols; j) { if (row[j] ctx-threshold) return true; } return false; }这里我把context指针也传进去了这样就解决了“函数指针无法携带额外参数”的问题。C语言写条件筛选这一招是标配否则每次要改阈值就得重新写一个函数代码会爆炸。4.3 输出结构设计与内存管理C语言里函数不能直接返回动态二维数组最常用的方案是返回一个二级指针同时通过一个出参返回筛选出的行数。// 筛选结果用结构体封装省得参数太多容易记混 typedef struct { int **rows; int rowCount; int colCount; } MatrixResult; MatrixResult FilterRows(int rows, int cols, int matrix[rows][cols], bool (*condition)(int *, int, void *), void *context) { MatrixResult result { NULL, 0, cols }; // 第一遍遍历先数出有多少行满足条件 int count 0; for (int i 0; i rows; i) { if (condition(matrix[i], cols, context)) { count; } } result.rowCount count; if (count 0) return result; // 分配二维结果空间 result.rows (int **)malloc(sizeof(int *) * count); int idx 0; for (int i 0; i rows; i) { if (condition(matrix[i], cols, context)) { result.rows[idx] (int *)malloc(sizeof(int) * cols); for (int j 0; j cols; j) { result.rows[idx][j] matrix[i][j]; } idx; } } return result; }调用方记得释放内存void FreeMatrixResult(MatrixResult *result) { if (result-rows NULL) return; for (int i 0; i result-rowCount; i) { free(result-rows[i]); } free(result-rows); result-rows NULL; result-rowCount 0; }为什么要先数一遍再分配因为malloc是按精确数量分配如果你边筛边分配就需要realloc或者维护一个变长列表逻辑复杂还容易出错。两遍遍历虽然在时间上多了一遍 O(rows * cols)但换来的内存利用率是 100%对这种笔试场景非常划算。5. 一测就崩的隐藏坑边界条件清单代码能跑通示例用例不算完上机笔试的判题系统会用大量边界用例来考验你的代码。以下这些坑我在真实考前辅导里反复强调。5.1 空数组与空条件第一个隐藏测试用例永远是空数组。C# 里int[,]可以用GetLength(0)拿到 0然后你的循环压根不进去返回空列表这没问题。怕的是你忘了判空直接访问matrix[0, 0]抛异常。C语言里更危险如果你处理的是int**类型传进来的可能是NULL。我在第 4 节的实现里没有显示判空实际笔试时必须加上MatrixResult result { NULL, 0, cols }; if (matrix NULL || condition NULL || rows 0 || cols 0) return result;这些防御性代码看起来多但它是区分“能用”和“能上生产”的关键。笔试时判空不会扣分但缺失判空会直接导致运行时错误一票否决。5.2 行内索引越界与条件边界条件里经常会有“取第k列做判断”这种逻辑。你写row[k] 0之前先问自己k一定小于cols吗如果题目里的k是从外部传入的参数而矩阵列数恰好小于k那你的代码会直接越界。还有一个常见的边界逻辑错误是“大于”和“大于等于”的混用。条件写的是“大于 0”还是“不小于 0”“行内所有元素大于 0”和“行内存在元素大于 0”是两套完全不同的逻辑。前者初始化为true遇到0就标false后者初始化为false遇到0就标true。千万别搞反。5.3 笔试环境里的输入输出陷阱很多在线笔试平台用的是“标准输入 标准输出”的判题方式。这时候单纯的函数实现是不够的你还需要写出正确的Main函数去读取输入、构造二维数组、调用函数、打印结果。C# 的输入解析常用这一套// 输入示例 // 3 3 // 1 2 3 // 4 5 6 // 7 8 9 // 10 // 表示 3行3列按条件取阈值 10 string[] firstLine Console.ReadLine().Split(); int rows int.Parse(firstLine[0]); int cols int.Parse(firstLine[1]); int[,] matrix new int[rows, cols]; for (int i 0; i rows; i) { string[] line Console.ReadLine().Split(); for (int j 0; j cols; j) { matrix[i, j] int.Parse(line[j]); } } int threshold int.Parse(Console.ReadLine()); var result MatrixHelper.FilterRows(matrix, row row[0] threshold); foreach (var row in result) { Console.WriteLine(string.Join( , row)); }这里最容易被忽略的坑是Console.ReadLine()可能返回null尤其在通过管道输入时。严谨地处理需要在解析前判空但笔试环境通常不会卡这么严。不过养成习惯总是好的。6. 从这道题延伸出去像素矩阵、最大子数组和与排序变体“取出指定条件二维数组的子数组”绝不是一道孤立的题。把它吃透了很多看似不相关的题目都能找到同一套底层抽象。6.1 C#二维像素数组转图片子数组的实际业务场景我实际做图像处理的时候经常遇到“从一张大图里抠出一块区域”的需求。位图在内存里本质上就是二维像素数组Bitmap.GetPixel和SetPixel性能太差专业的做法是先把整张图读成byte[,]像素数组用数组切片的方式提取子区域再转成新图片。这就是我们第 3 节“截取矩形子矩阵”的工业版public static Bitmap CropBitmap(Bitmap source, Rectangle region) { int[,] pixels new int[source.Height, source.Width]; // 用 LockBits 把整张图读进二维数组 // ... int[,] subPixels MatrixHelper.GetSubMatrix(pixels, region.Top, region.Bottom - 1, region.Left, region.Right - 1); // 再把 subPixels 转成新的 Bitmap // ... }所以说笔试题目不是空中楼阁。你在考场上写的GetSubMatrix稍微封装一下就能用在实际项目里处理截屏优化、图像裁剪、表格区域提取这些问题。6.2 二维最大子数组和从“筛选”到“搜索”如果把“指定条件”从“某行满足某个布尔条件”升级为“行和最大的子矩阵”就变成了经典题“二维最大子数组和”。这个变体里条件不再是简单的谓词而是一个优化目标。常见解法是枚举上下行边界用前缀和把二维问题降维成一维的“最大子数组和”再用 Kadane 算法在 O(n) 内求解。// 一维 Kadane求数组中和最大的连续子数组 static int MaxSubArray(int[] nums) { int maxSoFar nums[0]; int maxEndingHere nums[0]; for (int i 1; i nums.Length; i) { maxEndingHere Math.Max(nums[i], maxEndingHere nums[i]); maxSoFar Math.Max(maxSoFar, maxEndingHere); } return maxSoFar; }二维最大子数组和的整体复杂度是 O(rows^2 * cols)。为什么是 rows^2因为你枚举了所有上下边界的组合每种组合都要跑一次 Kadane。这个推导过程在笔试面试中非常加分因为它展示了你对复杂度模型的把握。6.3 二维数组排序以列为键、以行为单位另一个高频变体是“按某一列对二维数组的行排序”。C# 里最简洁的写法是用 LINQint[][] data /* 交错数组 */; var sorted data.OrderBy(row row[colIndex]).ToArray();但笔试里有时候不让用 LINQ或者题目要求你手写排序这时候你要掌握Array.Sort的重载Array.Sort(data, (a, b) a[colIndex].CompareTo(b[colIndex]));注意这里data必须是int[][]而不是int[,]因为int[,]不是可索引集合的数组。这也是为什么我建议在动手写之前先确认清楚输入到底是一维数组的数组还是规则的二维数组。C 语言里的对应方案是qsort关键是传对的比较函数int compareByCol(const void *a, const void *b) { int *rowA *(int **)a; int *rowB *(int **)b; int colIndex 1; // 按第1列排序用全局变量或结构体传入 return rowA[colIndex] - rowB[colIndex]; }6.4 还有哪些一眼看不出来同源的问题我列一个可以用来自查的清单看你能不能把这些题都归纳到“二维数组子数组”的框架下找出二维矩阵中所有连续递增的路径条件变成了“路径相邻”找出每一行的最大最小值位置条件变成了“聚合函数”布尔矩阵里连通的 1 区域条件变成了“连通性”蛇形打印、螺旋打印矩阵条件变成了“访问顺序”这些问题表面上是不同题型本质都是在二维数组上定义“访问规则”和“保留规则”。你把最基础的筛选函数写明白之后后面每一种变体都只是换条件和遍历顺序。拿这道“取出指定条件二维数组的子数组”的题来说我的实际备考建议是不要只背代码而是准备一张空白纸把题目对应的坐标系画出来把条件的严格定义用自然语言写一遍把自己实现的接口签名默写一遍。这三步做完了无论笔试平台用什么语言、题目怎么变你都能快速迁移。还有一个小技巧真正上机前先在本地把“空数组”“单行单列”“全部满足”“全部不满足”“索引越界”这五个用例跑通再提交到判题系统。这一步能筛掉 80% 的隐藏坑是我这几年刷题和带人最实用的经验。