☰
UVa 752题解:打乱图像的恢复其实是一道数组排序题
2026/10/9 10:38:17 网站建设 项目流程

最近整理UVa老题单的时候,又翻到了第752题,题目名是Unscrambling Images,直译过来就是“恢复打乱的图像”。当年我第一次看到这个标题,下意识觉得这题怕不是要用到什么图像识别、边缘检测的黑科技,等把数据格式逐字读完才反应过来——它本质上就是一道排序题。UVa上很多题目都有这种毛病,背景故事讲得天花乱坠,样例输入一看全是数字,读懂数据流之后代码往往二十行就AC了。

这篇文章我就把这道题的建模思路、代码实现和实战中容易踩的坑完整拆一遍。如果你也在刷UVa老题,或者在准备机试、算法面试,看到这种“名字唬人”的题目先别慌,把输入数据流理顺,大概率会发现它只是换个说法让你排序。这题就是个很典型的例子。

1. 题目模型拆解——每块瓦片上的编号,就是解题的钥匙

1.1 图像分块的物理过程

先看题目描述里到底发生了什么事。一幅完整的正方形图片,如果想把它切成n×n个等大的方形瓦片,最自然的做法是按行优先顺序切:第一行切n片,第二行再切n片……切出来的每一片都顺势得到一个固定编号,编号就等于它在原图中的位置索引。

举个例子,4×4的图片被切成16片,编号就是0到15。第0片位于左上角,第3片位于第一行最右边,第15片在右下角。这个编号不是一个随意的标签,它本身携带了图像正确排列的全部信息。后续无论这些瓦片怎么被打乱,只要每片上的编号还能读,恢复图像就等价于把瓦片按编号重新放到各自的位置上。

理解到这一层,这道题的“图像”外衣就可以脱掉了。我们根本不需要看任何图像内容,不需要比边缘花纹,不需要管颜色过渡,只需盯住编号和位置的映射关系。这和拼图的本质区别在于:真实拼图没有编号,只能靠特征匹配;而UVa 752相当于给了你一个“作弊器”,每块拼图背面都写了它该待的位置。

1.2 打乱之后,输入到底给了什么

从做题角度看,输入通常表现为两种模式。

第一种是完整模式:直接给出N个(N = n×n)数据对,每对包含两个整数——瓦片编号和它当前所在的物理位置。比如说“编号7的瓦片现在位于位置12”,意思是第7块瓦片被打乱后塞进了原本第12块瓦片该在的地方。我们需要做的就是把这个对应关系反着用:在数组的下标12处填上7,最后按数组顺序打印出来,图像就恢复了。

第二种是缺失模式:只给出部分瓦片的编号-位置对应关系,剩下一些瓦片的编号信息被隐去,可能是被擦除了,也可能是题目故意不给全。这时我们需要利用题目给出的附加约束来推断缺失的编号应该放在哪里。这种变体比完整模式多了一点推理成分,但核心数据模型仍然是同一个数组。

处理任何一题之前,先拿支笔在草稿纸上把这个数据模型画出来:一个数组,下标是位置,内容是瓦片编号。剩下的问题全部是在问“怎么往这个数组里填值”。

1.3 缺失编号时的补集思维

如果题目声明“所有瓦片编号恰好是0到N-1的一个排列,只是部分信息缺失”,这就给了我们一个非常强的条件:已经出现的编号集合的补集,就是要填入的缺失编号;已经被占据的位置集合的补集,就是要填充的空位。两个补集之间的对应关系一旦确定,整个问题就落幕了。

这里有一个常见的贪心策略:把剩余编号按从小到大排序,依次填入从左到右的第一个空位。在很多变体中这个策略都能构造出一个可行解。至于这个贪心策略什么时候合法、什么时候需要更精确的约束推理,我会在第三章的代码部分详细讲。

1.4 顺便聊聊搜这道题时总看到的11742

搜索量里经常和UVa 752一起出现的是另一道题——UVa 11742 Social Constraints,中文可以理解成“社交约束”。11742讨论的是n个人排队,给定若干条约束,比如某两个人必须相隔特定距离,或者某人必须排在某人前面,要求统计满足所有约束的排列数量。

两道题放在一起对比很有意思。752是“位置恢复”,11742是“排列计数”,它们的共同点在于:都得先把故事背景翻译成数组或排列,再决定用排序、枚举还是回溯。很多人搜752时看到11742,是因为两道题都涉及到“位置”“顺序”“约束”这些关键词,但其实一个用数组直出,一个用dfs或next_permutation暴力统计,难度不是一个量级。做题的时候先分清题目考的是“构造解”还是“计数”,思路会清晰很多。

2. 输入输出的坑——多案例、空行、行末空格一个都不能大意

2.1 多案例结构与读入方式

UVa老题非常喜欢考多案例(multiple scenarios),752也不例外。通常第一行是场景总数S,然后每个场景内部先给n,再给若干行数据。场景之间可能隔一个空行,也可能没有空行,这个完全取决于题面描述。

读入方式我只有一个建议:如果数据全是整数,就老老实实用cin >> x或者scanf("%d", &x)。流运算符天然会跳过空白符,空行根本不影响结果。最容易翻车的写法是getline和>>混用——比如你先用getline读了一整行,又用>>去读下一个整数,缓冲区里残留的换行符会让你多解析出一个空字符串,数组越界和死循环就是这么来的。

我自己的习惯是:整数输入一律不用getline,除非题目要求读取含空格的字符串。这样能省掉至少一半的输入坑。

2.2 位置编号的语义问题

位置编号的起点是个隐藏陷阱。有的题目用0到N-1表示位置,有的用1到N。如果是1起始,读入之后一定要立刻做pos -= 1的转换,否则tileAt[0]永远是空的,而数组末尾会被写入越界数据。UVa的判题机制对越界读写的态度是“不一定报RE,但答案一定是错的”,这种错误最难排查,因为样例可能恰好不出问题。

稳妥做法是定义一个统一的内部表示:所有下标一律从0开始。读入一行数据后立即规约成内部格式,这样后续代码不需要反复判断当前是“零基”还是“一基”。变量名也可以起得直白一点,比如tileAt[pos] = tile,避免中途忘记语义。

2.3 输出格式的细节

输出通常要求按行优先打印编号,每行n个数字,数字之间用空格分隔。这个环节有两个细节很容易让人吃WA或PE(Presentation Error):

一是行末空格。如果你用循环打印,习惯性地在每个数字后面加空格,那么每行末尾也会多一个空格。有的裁判机接受这种输出,有的会判Presentation Error。最保险的写法是:每行第一个数字前不打空格,后续每个数字前打一个空格,这样行末绝对干净。

二是案例之间的空行。如果题目要求“两个场景之间输出一个空行”,那最后一个场景之后不能有多余空行。实现上可以用一个“是不是第一个场景”的布尔标记,或者像我在代码里处理的那样,在循环体末尾根据“是否还剩场景”决定是否输出额外换行。

2.4 边界条件自查

提交前花30秒自查这几个边界:

  • n = 1时,只有1个瓦片,程序不能因为数组开太小而崩。
  • N可能达到65536(n=256),很多老题解里静态开int tileAt[10000]的做法,换个数据规模直接越界。
  • 如果题目里出现了“编号为0”的瓦片,那么你用来标记“未知位置”的初始值就不能再用0,必须用-1或其他不会产生歧义的值。

这些边界看起来琐碎,但WA(Wrong Answer)十次里有八次是栽在这种地方。

3. 核心实现——一个数组,两版代码,直接跑通

3.1 方案A:信息完整时,数组原地还原

先写最朴素的版本。假设输入是:场景数S,然后每个场景一个n,接着n×n行,每行两个整数tile和pos,表示编号为tile的瓦片当前位于位置pos。注意,这里我是按“常见的数据规范”来写的,如果实际题目把顺序调换成了pos和tile,读入时交换一下变量顺序即可,逻辑完全不变。

#include <cstdio> #include <vector> using namespace std; int main() { int S, n; scanf("%d", &S); while (S--) { scanf("%d", &n); int N = n * n; vector<int> tileAt(N, -1); for (int i = 0; i < N; ++i) { int tile, pos; scanf("%d%d", &tile, &pos); // 如果题面给的是 pos tile,就把这两行的读入顺序对调 tileAt[pos] = tile; } for (int r = 0; r < n; ++r) { for (int c = 0; c < n; ++c) { if (c) putchar(' '); printf("%d", tileAt[r * n + c]); } putchar('\n'); } if (S) putchar('\n'); } return 0; }

这段代码的核心逻辑就一个赋值语句:tileAt[pos] = tile。为什么不需要排序?因为数组下标天然就是位置顺序,从左到右、从上到下遍历数组,等同于按原图顺序扫描每个位置。数组填充完毕后,图像已经恢复了,剩下的只是打印。

时间复杂度O(N),空间复杂度O(N)。N = n×n,n最大256时也才65536个int,完全无压力。这是名副其实的“排序都不用排”的题目。

3.2 方案B:缺失编号时,补集贪心填充

如果输入只给了一部分瓦片的编号-位置对应关系,我们需要把剩下的空位填上。做法分三步走:

  1. 先把已知的编号和位置标记好。
  2. 统计“没出现过的编号集合”和“还没填充的位置集合”。
  3. 按约束把缺失编号填入空位——最直接的策略是贪心:缺失编号排序后按顺序填入空位。
#include <cstdio> #include <vector> #include <algorithm> using namespace std; int main() { int S, n; scanf("%d", &S); while (S--) { scanf("%d", &n); int N = n * n; vector<int> tileAt(N, -1); vector<bool> used(N, false); int k; scanf("%d", &k); for (int i = 0; i < k; ++i) { int tile, pos; scanf("%d%d", &tile, &pos); tileAt[pos] = tile; used[tile] = true; } vector<int> missing; for (int t = 0; t < N; ++t) { if (!used[t]) missing.push_back(t); } int idx = 0; for (int p = 0; p < N; ++p) { if (tileAt[p] == -1 && idx < (int)missing.size()) { tileAt[p] = missing[idx++]; } } for (int r = 0; r < n; ++r) { for (int c = 0; c < n; ++c) { if (c) putchar(' '); printf("%d", tileAt[r * n + c]); } putchar('\n'); } if (S) putchar('\n'); } return 0; }

这段代码里的贪心填充,本质上是在做“排序后的相遇”:缺失编号排序后,遇到第一个空位就放最小的,遇到第二个空位放第二小的。如果题目要求构造出任意可行解,这个策略通常没有问题;但如果题目明确要求“唯一复原”,那就需要更严格的约束推理,单纯贪心不足以覆盖所有情况。

3.3 约束更强的变体——用图传播位置关系

有些加强版题目除了给部分编号,还额外给出一些瓦片之间的相邻约束,比如“编号A的瓦片必须紧挨着编号B的瓦片”。这种约束一旦出现,题目就从排序题升级成了约束满足问题。

处理思路是把每个瓦片看作图上的节点,相邻约束看作边。从已知位置的节点出发做BFS/DFS,逐层传播位置信息。举个例子,如果知道编号A在位置5,并且约束说A的上方是B,那B就一定在位置5-n。这种推理可以用一个队列反复迭代,直到无法推出新位置为止。

当然,如果约束之间存在矛盾,那就需要DFS回溯或者精确覆盖来解决。不过一般来说,UVa的老题很少把这一步做绝,绝大多数场景还是停留在“排序+补集”的层面。写代码前先把题目问清楚:我是要构造可行解,还是要统计方案数,还是要判断唯一性?这三种目标对应完全不同的算法选型。

3.4 数据结构选型背后的原因

我在这题里首选vector而不是定长数组,是因为N的值在不同测试场景里可能差很多。开小了越界,开大了浪费,vector能自动适配上限。你可能会觉得定长数组静态分配更快,但在UVa的数据规模下,vector的分配开销可以忽略不计。

另外一个细节是标记未知位置时用-1而不是0。因为瓦片编号从0开始,0是一个合法的瓦片编号,拿它当“空”标记会冲突,导致填充逻辑出错。这个小点我第一次写的时候就中招了,后面会专门讲。

4. 从UVa 752到现实世界——图像分块重组的算法映射

4.1 图像处理里到处都是“分块+索引”

别以为UVa 752的模型只存在于算法题里。真实世界里的图像处理几乎离不开分块操作。JPEG压缩的第一步就是把图像切成8×8的小块,然后对每个块单独做DCT变换和量化;视频编码里的宏块、WebP的分块、各种多分辨率金字塔,底层都是“把图像切成小块再分别处理”的思路。

分块之后必须解决一个问题:处理完的小块怎么放回原图?答案就是块坐标。每个块的坐标就是这个块的“编号”,还原时按坐标把块内容填回数组。这个过程和UVa 752的tileAt[pos] = tile结构一模一样,只不过数组元素从整数变成了像素块或DCT系数矩阵。

4.2 网络传输中的乱序重组

再往远处想一步,网络数据包的乱序重组也是同一个数学模型。发送端给每个数据包编上序号,接收端维护一个缓冲区,数据包到达后根据序号填入对应槽位。等所有槽位填满,整个报文就能按顺序取出了。

这不就是“瓦片编号+位置数组”的翻版吗?发送端的包序号相当于tile,接收端缓冲区的槽位相当于pos。如果你写过TCP协议的模拟,或者做过滑动窗口的练习题,再看UVa 752会非常亲切。算法题里的很多模型不是凭空捏造的,它们是对工程系统的高度抽象。

4.3 真实拼图没有编号,怎么办

当然,现实世界的拼图不会给你编号。这时候“恢复打乱的图像”就变成了真正的视觉计算问题:提取每块拼图边缘的特征向量,计算两块拼图相邻边的匹配度,然后试图找到一个整体一致的拼接方案。

常见做法是给所有可能的拼合关系打分,然后构建一个带权图,节点是拼图块,边是“这两块可能相邻”的相似度。接下来要么用贪心迭代逐步锁定高置信度的拼合,要么在限定条件下做搜索。这类问题的计算量远远超过UVa 752,而752的巧妙之处在于:它把视觉匹配这个最难的部分抽象掉了,直接给你每块砖的编号,让你只关注“秩序恢复”这一件事。理解这个抽象过程,才是做这道题最大的收获。

5. 我当年做题踩过的坑——排查链路的完整复盘

5.1 把tile和pos读反,样例过但答案错

这题我第一个WA版本,问题出在输入顺序。原题数据行可能是“位置 编号”而不是我习惯的“编号 位置”。我直接按自己的预设写成了scanf("%d%d", &tile, &pos),结果把两个变量的语义彻底反了。样例数据恰好是某些对称排列,打印出来对着看不太出来,一交上去就WA。

排查过程很简单:在填充数组之后printf一下原始读入的两个值,手动对照题目样例里的前两行,立刻发现tile和pos的位置对调了。这个经历让我养成一个习惯——每读一行输入,先打印出来确认变量赋值符合预期,再往下写逻辑。多花十秒钟,省下半小时的调试时间。

5.2 数组用-1初始化,却忘了刷新

第二个坑是多案例场景下数据结构没有重置。因为我在循环外声明了vector<int> tileAt,第一次场景跑完后,第二次循环还在用同一个vector,而我只覆盖了有输入数据的位置,没有把整个数组重新刷成-1。结果上一场残留的数据污染了下一场,输出里出现莫名其妙的重复编号。

排查这类问题的标准动作是:在while(S--)循环体内,也就是每个场景内部,重新声明所有数据结构。这样做不仅逻辑清晰,而且彻底阻断跨场景的数据残留。我后来写多案例题全部采用“场景内部声明”的写法,基本没再犯过同类错误。

5.3 输出末尾多了一个空格,被判PE

这个坑最让人心态爆炸。我当时的输出循环是:

for (int c = 0; c < n; ++c) { printf("%d ", tileAt[r * n + c]); } printf("\n");

每行行尾多一个空格。本地跑起来完全正常,样例输出比对肉眼也看不出差别,但UVa判我Presentation Error。PE在理论上不算WA,但你要过题就必须处理。

改成“第一个数字前不打空格,后续每个数字前打一个空格”的写法后,一次通过。现在看到任何要打印空格分隔序列的题目,我第一反应都是这个写法,已经形成肌肉记忆了。

5.4 手工验算才是最快的调试手段

最后分享一个老办法:拿n=2的4块小样例,手工把映射关系列在草稿纸上,一步一步推出期望输出,再拿程序跑一遍对比。4个数的规模心算完全来得及,却能覆盖绝大多数逻辑错误。很多WA问题其实不需要debugger,一支笔一张纸半小时内就能定位完。

题外话:这道题对我的启示

UVa 752教给我的不是图像处理,而是建模时别被题目背景带偏。看到一个看似复杂的问题,先冷静提取数据流:输入是什么、输出要求什么、数据规模多大、有没有特殊约束。在这道题里,“图像”“瓦片”“打乱”这些词全部是干扰项,真正的主干只有一个数组的赋值与遍历。

如果你也在刷UVa的老题单,遇到这种名字吓人的题目,先别急着上高级算法,把输入输出理解透彻再说。很多时候,答案就摆在数据格式里,等着你把它翻译成代码。祝各位AC顺利。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询