ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CSP矩阵重塑与转置:一维数组索引映射实战解析

CSP矩阵重塑与转置:一维数组索引映射实战解析 1. 开题这道题和“机器人复健”有什么关系先解释一下这个标题是怎么回事。第38次CCF CSP认证已经过去很久了我最近重新拿起算法题来刷状态特别像一台放了很久没校准关节的工业机器人指令还能下但每个动作都需要重新对零位、重新走一遍标定流程。所以这次刷题记录我起了个名字叫“机器人复健指南”。复健的科目不是别的正是第38次CSP的第二题“矩阵重塑其二”官方题号是202406-2。这道题在复健序列里很有代表性它不考高级数据结构不考DP更不考图论核心就是矩阵的“重塑”和“转置”两种操作。听起来很简单但实操起来非常容易踩坑。尤其当操作数量上来以后直接开二维数组跟着操作去复制、交换很容易把代码写得又臭又长还会遇到隐藏的越界错误。如果你准备下一次CSP认证或者正在恢复算法手感这道题的复盘价值相当高。我会把完整的解题思路、实现代码和踩过的坑全部整理出来直接照着写就行。先看看原题长什么样。输入第一行给出两个整数n和m表示一个n行m列的矩阵。接下来输入这个矩阵的n行数据。再下一行给一个整数q表示操作次数。接下来q行每行描述一个操作操作为1 p q把当前矩阵重塑成p行q列题目保证p和q的乘积等于矩阵总元素个数。操作为2把当前矩阵转置。所有操作执行完后输出最终矩阵即可。注意操作是连续叠加的每一步都作用在上一步的结果上。这道题最让我“复健感”强烈的地方在于它表面是矩阵题骨子里是索引映射题。你把这个问题想透后面很多二维数组到一维数组的转化、坐标系的变换、状态压缩的问题都会顺手很多。2. 方案选型重塑不是复制转置不是交换2.1 最直觉的做法开一个新的二维数组很多人第一反应是直接用vectorvectorint存矩阵遇到重塑就申请一块新内存把旧数据按新维度填进去遇到转置就把两个下标交换再填一遍。这个思路符合直觉代码也不难写但有几个问题。首先是复制开销。矩阵元素总数S n × m假设S是4×10^4操作次数q是10^5一旦有大量重塑和转置交替出现最坏情况下总复制量会达到4×10^9级别。这个量在C里已经可以稳定超时了更别说有些选手还有意无意地在循环里多写了几个无用的临时矩阵。其次是状态管理容易乱。你保存的是“当前矩阵”但每次转置之后原来的行宽、列宽全变了再遇到重塑时是以转置后的矩阵为准还是以原始矩阵为准很多人写着写着就开始在swap(a,b)和resize之间反复横跳最后干脆用一堆临时变量硬凑代码完全没法维护。所以这道题最优解不是“跟着操作改数据”而是“跟着操作改解释方式”。2.2 更聪明的做法一维底数组加索引映射核心思路是这样的矩阵元素的总个数S从头到尾不变。既然总数不变我完全可以只在最开始读一次数据存进一个一维数组data里然后用一个额外的一维数组id记录“当前逻辑位置对应原始数组中的哪个位置”。后续所有操作都只改id和行列数不改data本身。这样无论操作多少次底层数据只有一份不会反复拷贝。重塑操作本质上只是在告诉程序“你现在把这些连续元素按新形状解释”而转置操作则是把逻辑坐标映射规则反过来。用生活化的方式理解你有一串数字本来排成3行2列现在你把它重新按2行3列来读数字本身一个都没变变的只是“每行放几个数字”。转置是在这个基础上把“行优先”变成“列优先”也就是重新定义读取顺序。只要把读取顺序算对矩阵内容就是对的。2.3 为什么这种映射符合CSP第二题风格CSP第二题的难度定位是“会模拟就能拿分但满分需要控制细节”。这类题通常不会给你需要高级算法的数据规模但会在边界条件和复杂度上埋雷。矩阵重塑其二正好卡在这个点上如果你开二维数组模拟代码简单但效率堪忧如果完全不去想底层存储只在逻辑上做“纸面变换”很容易在转置后的索引处理上翻车。这道题考察的其实是计算机系统里很基础的一个思想数据布局与数据解释分离。同样的内存你既可以把它看成n×m的矩阵也可以看成p×q的矩阵甚至可以看成m×n的转置矩阵。真正花费时间去想的是“我如何用最少的代价维护好这种解释方式的变换”。3. 核心实现用虚拟索引玩转矩阵变换3.1 数据结构与状态设计我采用的方案是把矩阵压平成一维数组再维护一个长度同样为S的映射数组。C代码里先声明这几个变量#include bits/stdc.h using namespace std; using ll long long; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin n m; int S n * m; vectorll data(S); for (int i 0; i S; i) { cin data[i]; } // id[k] 表示当前逻辑位置 k 在原始 data 中的下标 vectorint id(S); iota(id.begin(), id.end(), 0); int rows n, cols m; int q; cin q; // 后续操作在这里处理 }初始情况下一维数组data的顺序就是原始矩阵逐行展开的顺序所以id[k] k。rows和cols表示当前矩阵的形状。注意这两个变量会随着操作变化但它们不会直接决定data拷贝只影响解释逻辑。3.2 重塑操作的处理遇到操作1 p q时由于题目保证p * q S而且重塑前后的一维展平顺序完全一致所以根本不需要改动data也不需要改动id。只需要更新形状变量即可。if (op 1) { int p, q; cin p q; rows p; cols q; }这里建议加一个防御性判断如果p * q ! S要么输入有问题要么你前面读错了数据。正式考试时题目会保证合法但自己写代码时期加上不难能救命int newS p * q; if (newS ! S) { cerr reshape invalid endl; return 1; }重塑操作这么处理的核心原因是data始终按“当前矩阵的行优先顺序”存储内容。无论我把这个数组解释成3行2列还是2行3列数组下标0到5对应的原数据顺序没有变。只有转置会改变这个顺序。3.3 转置操作的关键公式转置才是这个题的关键。转置会改变矩阵的形状同时改变“行优先读取顺序”对应的原始位置。假设当前矩阵是rows行cols列。转置之后新矩阵是cols行rows列。对任意一个位置新矩阵的逻辑扁平位置是k我需要知道这个k对应旧矩阵的哪个位置。推导一下。旧矩阵中行号i k / oldCols列号j k % oldCols。转置后的新矩阵中原旧矩阵第i行第j列的元素会出现在新矩阵的第j行第i列。所以新矩阵里这个元素的扁平位置应该是j * newCols i也就是j * oldRows i。把k和i、j的关系代进去得到的新逻辑位置就是newK (k % oldRows) * oldCols (k / oldRows)等一下这里要特别小心。上面这个公式里的oldRows是指转置操作发生之前的行数不是当前行数。很多人在这一步出错就是因为把oldRows和oldCols写反了。实际写代码时必须先在rows和cols交换前把旧值取出来否则下一步swap之后旧值就丢了。完整的转置处理如下if (op 2) { vectorint nxt(S); int oldRows rows; int oldCols cols; for (int k 0; k S; k) { int newK (k % oldRows) * oldCols (k / oldRows); nxt[k] id[newK]; } id.swap(nxt); swap(rows, cols); }这里把id整体更新了一轮而不是修改data。nxt[k]表示转置之后逻辑位置k应该去原始data里取哪个下标。整体更新完之后rows和cols再交换表示形状已经变成转置后的尺寸。3.4 代码与最终输出把上面几块拼起来完整的代码长这样#include bits/stdc.h using namespace std; using ll long long; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin n m; int S n * m; vectorll data(S); for (int i 0; i S; i) { cin data[i]; } vectorint id(S); iota(id.begin(), id.end(), 0); int rows n, cols m; int q; cin q; while (q--) { int op; cin op; if (op 1) { int p, q; cin p q; if (p * q ! S) { cerr reshape invalid endl; return 1; } rows p; cols q; } else if (op 2) { vectorint nxt(S); int oldRows rows; int oldCols cols; for (int k 0; k S; k) { int newK (k % oldRows) * oldCols (k / oldRows); nxt[k] id[newK]; } id.swap(nxt); swap(rows, cols); } } for (int i 0; i rows; i) { for (int j 0; j cols; j) { int k i * cols j; cout data[id[k]]; if (j 1 cols) cout ; } cout \n; } return 0; }输出时一定要区分清楚“逻辑坐标”和“数据下标”。i和j是当前矩阵的行列坐标i * cols j是当前矩阵的展平位置再通过id映射到原始data的下标。很多人写到最后一步就松懈了直接用k去取data结果拿到的完全是错位的数据。我顺手给大家验证一下这个映射的正确性。用一个2行3列矩阵数据是0 1 2 3 4 5执行三次操作重塑成3行2列此时rows3, cols2id还是0 1 2 3 4 5。转置。oldRows3, oldCols2逐个算newKk0 - (0%3)*2 0/3 0 k1 - (1%3)*2 0 2 k2 - (2%3)*2 0 4 k3 - (0)*2 1 1 k4 - (1)*2 1 3 k5 - (2)*2 1 5所以新的id {0, 2, 4, 1, 3, 5}rows2, cols3。这个顺序对应转置后的矩阵逐行展开0 2 4 1 3 5。这个结果完全符合手工转置预期。再重塑成2行3列id不变仍然输出0 2 4 1 3 5按2行3列排就是0 2 4和1 3 5。一旦看懂这个过程你就明白为什么不需要物理拷贝矩阵了所有变换都被浓缩进了一个排列数组id。4. 实录踩过的坑与排查思路4.1 转置公式写反这是我第一次写这个题时犯的错。我一开始写成了int newK (k / oldCols) (k % oldCols) * oldRows;这个写法错在把oldRows和oldCols的位置搞混。你要是用前文的例子手算一遍很快就会发现输出顺序完全不对。怎么快速自查拿一个不对称的小矩阵手算。比如原始2×3矩阵先把数据继续记为0 1 2 3 4 5转置成3×2之后期望顺序是0 3 1 4 2 5。你不需要跑完整程序脑袋里用这条公式代入几个k就能判断公式对不对。如果转置后第一个元素对、第二个元素错了多半就是行列数反了。另一个排查手段是把id数组打印出来对比手算结果。代码里临时加一行输出id看完再删掉直接肉眼定位。4.2 重塑操作忘记校验乘积我把这一条单独列出来是因为很多初学者会掉进一个思维陷阱以为p和q只是“新行数和列数”于是照常更新rows、cols。如果题目突然给你一个p * q ! S的数据整个id映射在后续转置中就会越界因为转置公式里oldRows和oldCols已经不能正确表示矩阵了。正规评测里这个情况不会出现但你自己调试别的数据时很容易怀疑错方向。与其空想不如在重塑分支里加一个显式校验用一次乘积判断挡住非法输入。这样一旦数据有问题程序会立刻提醒你而不是在几百行之后输出一坨莫名其妙的结果。4.3 多次转置的复杂度隐患要知道我这个方案中转置操作的时间复杂度是O(S)因为每次都要构造新的id数组。如果q很大并且操作里全是转置最坏情况下总复杂度是O(qS)。虽然这个方案比二维暴力复制优雅但并不是严格意义上的“每次操作O(1)”。这里有个可以优化的点连续两次转置会互相抵消理论上在转置前看一眼上一次操作是不是转置如果是就直接撤销上一次的转置操作。但在CSP这种单次评测中我一般不会主动加这个优化把代码保持简单更重要。除非你明确知道当前题目的数据规模会卡O(qS)否则先保证正确性再考虑剪枝。另外注意vectorint nxt(S)在每次转置时创建临时数组也会有一点开销。写在循环里问题不大但如果真追求极致性能可以在循环外先预留好两块数组再在每次转置时用nxt覆盖id省去反复分配内存的消耗。我刷题时为了可读性没做这一步实际比赛中你可以按需调整。4.4 输出格式与空格最后一行的空格问题说来可笑但确实废了我不少提交次数。这道题对输出格式的要求是矩阵元素之间用一个空格分隔行末不能有多余空格。C里最稳妥的写法就是检查j 1 cols再输出空格而不是在每行结束后再单独处理。用Python的同学也要小心 .join(map(str, row))的方式不会产生行末多空格但如果你自己循环拼接很容易每个元素后面都带上空格。这种问题在本地测试的时候一般看不出来因为肉眼不会盯着空格看。提交后在评测机上WA一次才意识到。所以我把这个坑写进来提醒你到输出环节多花十秒钟检查格式。4.5 调试思路构造小样例人工跑复健训练中我发现矩阵类题目最怕的就是“一步错步步错”。最优调试方法是自己构造一个不算大、且手工能算的样例比如2×3或者3×4矩阵确保第一个操作重塑、第二个操作转置、第三个操作再重塑交叉验证每一步的输出。我实际用的样例是2 3 0 1 2 3 4 5 3 1 3 2 2 1 2 3期望输出0 2 4 1 3 5如果代码跑出来和这个不一致问题几乎一定出在转置的newK公式或rows、cols的交换时机上。把这个样例保存成一个in.txt反复跑比你在脑子里空想快得多。5. 从矩阵聊到机器人这道题教给算法的三件事5.1 索引映射就是机器人的坐标变换我在复健期间正好在调一台工业机器人反复校零点、做外部轴标定脑子里全是坐标变换。回头再看这道题忽然觉得它就是坐标系变换的简化版本。在机器人系统里你有一个全局坐标系的点要转换到机械臂末端坐标系就要经过旋转矩阵、平移向量本质上是把一组坐标映射到另一组坐标。CSP这道题里的id数组跟这个非常像它保存的就是“逻辑坐标到原始坐标的映射关系”。重塑相当于换了一个视角去看同一组数据转置相当于把坐标轴做了某种交换。你在机器人里如果搞混了旋转矩阵的转置和逆矩阵关节运动会完全乱套在这题里搞混了oldRows和oldCols输出矩阵也会完全乱套。理解了这种“数据不变、解释方式变”的思想再去看SLAM里的坐标变换、导航中的位姿更新思路会通畅很多。5.2 状态机思维贯穿ROS与CSP另一个让我有感触的是这个题的“状态”设计。整个程序只需要维护两样东西当前的矩阵形状rows、cols以及映射数组id。操作来了先判断类型再更新对应的状态。这种“有限状态 状态转移函数”的写法和我在ROS里写机器人导航状态机特别像。机器人程序里经常要维护当前状态待机、定位、导航、避障、恢复。每个状态对应一套处理逻辑收到新的传感器数据或指令后根据当前状态决定要不要跳转到其他状态。CSP这道题里面的op 1和op 2就是两个转移条件矩阵形状和映射关系就是状态量。用这种思路写题代码结构很清晰不会堆一堆临时变量。所以我给新手建议是即使你只是刷题也要刻意把代码写成“状态驱动”模式。把变化量集中在几个变量上每个操作明确地修改这些变量而不是东改一块西改一块。5.3 懒更新思想在机器人定位中的应用这道题的核心技巧说白了就是“懒更新”能不复制数据就不复制数据把工作留到真正需要输出时再做。这个思想在很多机器人算法里也是杠杠的。举个例子ROS导航中维护的占据栅格地图如果每次都因为传感器更新就全图重算计算量完全爆炸。实际做法是只更新局部栅格维护一个相对变化量等全局规划真正需要完整地图时再统一去重。这和这道题里转置操作不碰data、只在最后输出时查id的思路如出一辙。再比如做机器人视觉SLAM时位姿图优化通常不会每次都重建整个图而是维护一个待优化的关键帧集合一个增量更新队列最终输出全局位姿时才做优化。这种“延迟计算”“按需计算”的思维刷一道矩阵题能体会得这么深也算意外收获。6. 复盘如果让我再刷一遍题目本身不难但我第一次完整AC的时候还是花了好几轮。走过的弯路无非是公式写反、忘记更新形状、输出格式出错。真正浪费时间的不是写代码那几分钟而是没想清楚“当前逻辑位置”和“原始数据下标”之间的映射关系就急着上手。如果让我给这份复健指南标一个重点我会说先把id数组是什么彻底搞懂再写代码。你可以把它理解成一个翻译器data是原始数据域矩阵中每个逻辑位置都在data里有一个固定的家。所有操作只是在搬家不改变家的内容。想明白这一点后面不管是加操作类型还是换数据规模都不会慌。这道题还有不少变体。比如把“转置”改成“水平翻转”或“垂直翻转”把“重塑”改成“按列优先填充”甚至扩展成三维张量的重塑。核心还是索引映射把每一步的数学关系列清楚公式一套代码自然就出来了。我自己在做完这题后又把同样的思路拿去写了几个数组重排的变式再遇到类似题目基本一次过。最后分享一个实用习惯矩阵题写完后不要只测题目样例一定要自己补测“反复执行转置偶数次”“正方形矩阵转置”“单行单列”这三类边界数据。单行转置后变成单列你再重塑再转置非常容易触发越界。这些规模小的数据手算很快但能拦住一大半隐蔽错误。如果你最近也在准备CSP认证建议也拿这道“机器人复健题”当作热身。它不会让你学到炫技的算法却能帮你把二维数组、一维数组、索引映射这些基础能力重新打扎实。基础到位了后面遇到再复杂的题目至少不会被“连数据都存不明白”绊住脚。
返回列表