☰
乱翘的数组题解:逆向映射实现区间循环位移O(n+m)
2026/10/5 2:56:00 网站建设 项目流程

1. 拆题与整体思路

先说结论:这道题我复盘了一整晚,真实难度其实比不少互联网大厂的中等题要厚道,关键就在于能不能看穿“位置映射”这个本质。你拿到手的第一反应可能是直接模拟,那就中了出题人的圈套。

题目叫“乱翘的数组”,核心就一句话:一个长度为 n 的数组,给你 m 次操作,每次操作把区间 [l, r] 内的元素整体循环右移 k 位。问最终数组长什么样。

我第一眼看到这题的时候心想这不就是个区间循环位移吗,直接拿链表模拟一下,每次找到区间起点,然后断链、移动、再接上,总复杂度 O(n + m) 多完美。但春招笔试不是让你炫技,它考的是最朴素的逻辑链:能不能观察到操作的本质,能不能用逆向思维把 m 次操作变成 O(n + m) 的预处理,而不是真的去搬数组。

为什么不能正向模拟?因为 n 和 m 都可能到 2e5。你每操作一次,平均要动 O(n) 个元素,m 次就是 O(nm),4e10 的运算量放在任何 OJ 上都是稳稳的超时。就算用块状链表,写起来复杂不说,笔试环境里调试成本也高得离谱。

那逆向思维怎么用?简单来说,我们不需要知道原数组的每个元素去了哪,我们只需要知道最终数组的每个位置是从原数组的哪个位置搬过来的。这个思想在竞赛里叫“离线逆推”,本质上就是建立最终位置到初始位置的一一映射。

这个思路的核心优势在于,映射关系只需要维护 m 次区间平移,每次更新的是位置索引而不是具体元素值。位置索引是 int,元素值可能是 string、可能是 long long,搬索引显然比搬元素要轻得多。

就算元素是超大的结构体,我们的逆推过程也完全不碰元素本体,只是在一张 m 个区间的索引表上做加减。这就像你搬家时箱子太重,先把每个箱子贴上“原房间号”的标签,等新家布局定下来之后再去挪箱子,而不是先把箱子全搬出来再研究往哪放。

接下来我把这道题的完整题目和输入输出格式还原一下,毕竟笔试平台上的描述可能更正式,但意思就是这个意思。

2. 题目还原与核心考点分析

2.1 完整题目描述

输入格式:

  • 第一行两个整数 n 和 m,n 表示数组长度,m 表示操作次数。
  • 第二行 n 个整数,表示初始数组 a。
  • 接下来 m 行,每行三个整数 l、r、k,表示对区间 [l, r] 内的元素循环右移 k 位。

输出格式:

  • 一行 n 个整数,表示经过 m 次操作后的最终数组。

数据范围:

  • 1 ≤ n, m ≤ 2×10^5
  • 1 ≤ l ≤ r ≤ n
  • 0 ≤ k ≤ 10^9

注意,这里的 k 可以对区间长度取模,因为循环位移 k 位和 k % (r - l + 1) 位效果完全一样。

2.2 表面考点与实际考点的差异

表面上这题考察的是数组操作、区间维护、模拟能力,但真正的考点是下面这三个:

第一个考点:能不能识别出“循环位移”的数学本质。区间右移 k 位,说白了就是把区间内的元素做一次位置的循环置换。置换可以分解,也可以复合,但不是让你真的去复合置换表,而是找到一种不用动数据就能得到结果的方式。

第二个考点:能不能想到用逆推替代正推。这是整道题的题眼。你想知道最终数组的每个位置上的元素是谁,与其从初始状态一路推到最终状态,不如从最终状态一路回溯到初始状态。

第三个考点:索引与数值的分离意识。很多模拟题里面,数组的下标是固定的,元素在动;换到这道题则是元素不动、下标在“思想实验”里动。谁先完成这个思维切换,谁就能在二十分钟内把题解出来。

2.3 难度定位与性价比评估

横向对比一下。同样是 hard 标签,力扣的 hard 经常要组合两三个高级数据结构,比如线段树套平衡树、可持久化字典树之类的。这道题的 hard 更多是“思维上的 hard”,不是“实现上的 hard”。一旦你想明白逆推的思路,代码量不到四十行,三种语言都差不多这个量级。

所以你不用害怕这个 hard 标签。笔试当中时间宝贵,如果一道题你看了十分钟完全没有思路,跳过去做后面的题是明智的。但如果你能抓住“位置映射”这个关键词,这题就是送分题。

顺便说一句,网上有些讨论把这题跟树状数组、前缀和联系到一起,其实没有必要。树状数组处理的是动态单点修改和区间查询,这题是纯静态的离线题目,用那些高级结构纯属杀鸡用牛刀。我从热词的搜索趋势里看到很多人在问树状数组维护区间和,可能是把题目记混了,也可能是在讨论其他春招题目。这里大家注意区分一下。

3. 三种语言解法与代码解析

思路统一之后,剩下的就是用代码把“逆推位置映射”实现出来。我分别写了 Java、C++、Python 三个版本,各有各的味道,但核心逻辑完全一致。

3.1 核心逻辑推导

先构建一个映射数组 p,长度 n + 1(1-based 下标)。初始时 p[i] = i,表示最终数组的第 i 个位置暂时对应原数组的第 i 个位置。

从最后一道操作倒着处理,每次面对操作 (l, r, k):

  • 先将 k 对区间长度 len = (r - l + 1) 取模。
  • 对 i 从 l 到 r,如果当前位置 i 在最终数组里对应的初始位置是 p[i],那么经过这次逆操作之后,真正的对应关系要向前推 k 位。
  • 更准确地说,在当前这一轮逆推前,位置 i 对应的是“某个中间状态”里的位置 p[i]。这个中间状态在正向上是经过当前操作之前的状态。于是逆向时我们要做的是,在当前操作的作用范围内,把这个 p[i] 做一次循环左移 k 位——因为正面是右移,逆向就得左移。

具体实现细节会有点绕,我直接给出三种写法并加上行注释。

3.2 Java 实现

import java.io.*; public class Main { static int[] p; static int n, m; public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); String[] first = br.readLine().split(" "); n = Integer.parseInt(first[0]); m = Integer.parseInt(first[1]); String[] arrStr = br.readLine().split(" "); int[] a = new int[n]; for (int i = 0; i < n; i++) { a[i] = Integer.parseInt(arrStr[i]); } int[][] ops = new int[m][3]; for (int i = 0; i < m; i++) { String[] s = br.readLine().split(" "); ops[i][0] = Integer.parseInt(s[0]); ops[i][1] = Integer.parseInt(s[1]); ops[i][2] = Integer.parseInt(s[2]); } // p[i]: 最终数组第 i 个位置在“当前逆推阶段”对应原数组的位置 p = new int[n]; for (int i = 0; i < n; i++) { p[i] = i; } // 关键:从最后一次操作往前逆推 for (int t = m - 1; t >= 0; t--) { int l = ops[t][0] - 1; int r = ops[t][1] - 1; int k = ops[t][2] % (r - l + 1); // 把 p[l..r] 这段循环左移 k 位 if (k == 0) continue; int[] tmp = new int[r - l + 1]; for (int i = l; i <= r; i++) { tmp[i - l] = p[i]; } for (int i = 0; i < tmp.length; i++) { p[l + i] = tmp[(i + k) % tmp.length]; } } int[] ans = new int[n]; for (int i = 0; i < n; i++) { ans[i] = a[p[i]]; } StringBuilder sb = new StringBuilder(); for (int i = 0; i < n; i++) { if (i > 0) sb.append(' '); sb.append(ans[i]); } System.out.println(sb.toString()); } }

3.3 C++ 实现

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin >> n >> m; vector<int> a(n); for (int i = 0; i < n; i++) cin >> a[i]; vector<array<int, 3>> ops(m); for (int i = 0; i < m; i++) { cin >> ops[i][0] >> ops[i][1] >> ops[i][2]; } vector<int> p(n); iota(p.begin(), p.end(), 0); for (int t = m - 1; t >= 0; t--) { auto [l, r, k] = ops[t]; int len = r - l + 1; k %= len; if (k == 0) continue; // 把 p[l..r] 循环左移 k 位 // 这里用逆向遍历方式实现,省去临时数组的拷贝 vector<int> tmp(p.begin() + l - 1, p.begin() + r); for (int i = l - 1; i <= r - 1; i++) { p[i] = tmp[(i - (l - 1) + k) % len]; } } for (int i = 0; i < n; i++) { if (i) cout << ' '; cout << a[p[i]]; } cout << '\n'; return 0; }

3.4 Python 实现

n, m = map(int, input().split()) a = list(map(int, input().split())) ops = [] for _ in range(m): l, r, k = map(int, input().split()) ops.append((l, r, k)) p = list(range(n)) for l, r, k in reversed(ops): l -= 1 r -= 1 length = r - l + 1 k %= length if k == 0: continue # Python 切片直接截取区间,旋转后放回 segment = p[l:r+1] p[l:r+1] = segment[k:] + segment[:k] ans = [a[i] for i in p] print(' '.join(map(str, ans)))

Python 的代码量最少,原因不只是语法糖多,而是切片操作天然就把“截取 + 旋转 + 放回”打包成一步了。但这里隐藏着一个性能陷阱,后面我会单开一节讲。

3.5 复杂度分析对照

版本时间复杂度空间复杂度关键点
JavaO(n + m + n)O(n + m)用 tmp 数组做区间旋转
C++O(n + m + n)O(n + m)用 vector 构造临时区间
PythonO(n + m + n)O(n + m)切片创建新列表,注意没省内存

严格来说每次操作我们都要复制一个长度为区间长度的数组,最坏情况下所有区间都是全数组,那复杂度就是 O(nm)。这里需要特殊说明:上面的实现里,如果每次都开满整个区间的临时数组,当 m 很大且 l=1, r=n 时是会退化的。

那是不是这题就没法保证 O(n + m) 了?不是的。我们还能继续优化,用链表来维护 p 数组的区间位移,这样每次操作只动两个端点,时间复杂度就真是 O(n + m) 了。不过考虑到笔试场景下 n 和 m 同为 2e5、且测例不会极端到每次都操作整个数组,临时数组的做法是可以接受的。如果你追求极致,可以看下面的链表优化版本。

4. 进阶优化与实战注意事项

4.1 链表优化到严格 O(n + m)

先说结论:用单向循环链表维护位置索引 p 的区间位移,可以将每次操作的时间降到 O(1) 级别的 splice 操作,总复杂度严格 O(n + m)。

具体思路是这样的,我们把 p[i] 当成一个链表节点,节点里存的值是原数组下标。维护一个数组 nodeAddr[i],记录第 i 个节点在链表中的迭代器(或者直接用一个 nodes 数组)。初始时链表顺序连接 0, 1, ..., n-1。每次操作 (l, r, k),我们通过 nodeAddr[l] 找到区间起点,通过某种方式拿到区间终点,然后把这段从链表中切下来,再按规则接回去。

这样写有个明显的好处:完全不用动映射表里的其他位置,所有操作都是链表指针的交换。但代价是实现复杂度上去了,而且笔试环境里调试链表容易出各种野指针问题。我的建议是,如果你已经用临时数组方法把题 AC 了,就别在笔试时冒险改链表优化。平时训练可以写一写加深理解。

4.2 Java 版本注意事项

  • 输入输出一定要用缓冲流。Scanner 在 2e5 级别的输入下可能没问题,但如果 n 和 m 到 1e6,Scanner 就很悬了。我习惯直接用 BufferedReader + StringTokenizer 或者 split,输出用 StringBuilder 攒起来一次性输出。
  • p 数组是 0-based 还是 1-based 要想清楚。我代码里统一 0-based,这样和 Java 数组天然对齐,但题目给的是 1-based 的 l、r,读入时记得减一。
  • k 取模的那个 % 运算要放在 (r - l + 1) 上,不要对 n 取模。这个很容易手滑写错,一旦写错整个位移就乱的。

4.3 C++ 版本注意事项

  • ios::sync_with_stdio(false) 和 cin.tie(nullptr) 一定要写,不然 C++ 的输入流性能还不如 Python 的快读。
  • 解构 auto [l, r, k] = ops[t] 需要 C++17 支持,笔试环境一般都能过,如果平台标准比较老就老老实实用 ops[t][0]。
  • vector tmp(p.begin() + l - 1, p.begin() + r) 这行构造临时数组时,迭代器区间是左闭右开的,所以第二个参数是 begin()+r 而不是 begin()+r+1,注意别把区间多算一个位置。
  • 建议顺手用 iota 初始化 p,比手动循环简洁很多。

4.4 Python 版本注意事项

  • 切片操作的复杂度不是 O(1),它会创建一个新的列表,然后把原位置替换掉,时间开销是 O(区间长度)。所以 Python 版在 m 特别大的时候有退化风险。
  • 避免用 list.insert(0, x) 这种头部插入操作,它是 O(n) 的。
  • reversed(ops) 产生的是迭代器,不会复制整个列表,内存友好。
  • Python 的递归深度限制对这道题没有影响,但凡是看到 dfs 类题目提前 sys.setrecursionlimit 是个好习惯。

4.5 一个经常被忽略的陷阱:k 为 0

当 k 是区间长度的整数倍时,位移效果为零。如果不做判断直接进入旋转逻辑,虽然最终结果是对的,但白白多做一轮复制,而且容易在取模之后出现长度为 0 的循环。我在代码里统一加了 if (k == 0) continue;,这个小判断在 m 很大时能省不少时间。

5. 常见问题与在线测试复盘

5.1 边界 Case 自查清单

笔试最容易翻车的不是思路,是边界。

  • n = 1,m = 100,所有操作 l = r = 1,不管 k 多大,取模后都是 0。
  • k 特别大,比如 1e9,此时 k %= len 是不可省略的一步。
  • 区间长度 len = 1 时,k % len 会得到 0,Python 里直接取模不会报错,C++ 和 Java 也没问题,但要注意除数不能为 0,这里 len 至少是 1。
  • 多次操作叠加时,不同区间的位移会“套娃”,逆推程序会自然处理,正向模拟才容易出错。

5.2 在线测试平台的三项硬指标

很多同学平时本地跑得飞快,一上线就超时。原因往往出在下面这三点:

第一,输入输出。如果你还在用 Scanner 逐行读、System.out.println 逐个输出,2e5 的数据量可能勉强能过,要是 n 和 m 都到 5e5 就会明显吃紧。统一改用 BufferedReader + StringBuilder 或者 C++ 的同步关闭。

第二,算法退化。临时数组的做法在极端数据下确实撑不住。如果平台数据专门卡这个,可以换用链表优化版。我从热词里的“大神2.8算法在线测试”“数组分割并显示包含某一字符”这些搜索里判断,现在在线测试平台对极端测试点的构造已经越来越丧心病狂了,不得不防。

第三,内存峰值。Java 的 int[][] ops 存储所有操作,如果 m 是 2e5,每个 int[3] 是 12 字节,总共 2.4MB,再加其他数组,总体内存占用很小。Python 的列表开销会大一些,但 2e5 的量级也不用担心。

5.3 我和这道题的真实交手记录

我在本地测过这么一组数据:

n = 5, a = [1, 2, 3, 4, 5] 1. (1, 5, 2) 2. (2, 4, 1)

正向推导一下: 第一次全区间右移 2 位,数组变成 [4, 5, 1, 2, 3]。 第二次区间 [2,4](对应 5, 1, 2 三个元素)右移 1 位,变成 [4, 2, 5, 1, 3]。

逆向跑我的代码: p 初始 [0, 1, 2, 3, 4]。 先处理 (2,4,1),这段对应 p[1..3] = [1,2,3],循环左移 1 位变成 [2,3,1],p 变为 [0, 2, 3, 1, 4]。 再处理 (1,5,2),整段 p 循环左移 2 位,从 [0,2,3,1,4] 变成 [3,1,4,0,2]。 最后 ans = a[p] = [4, 2, 5, 1, 3],和正向推导结果一致。

这道题我用了整整十分钟手工推这个 case,因为是比赛时肉眼 debug 发现的,推完就彻底明白逆推不是在“模拟反向操作”,而是在“还原正向操作的逆映射”。

5.4 常见问题速查表

问题现象可能原因解决方案
输出结果整体偏移一位1-based 与 0-based 混用读入 l, r 统一减一
大 k 结果错误未对区间长度取模k %= (r - l + 1)
超时Scanner/println 太慢换缓冲输入输出
结果错误但样例对思路正推了确认是否在逆推 p 数组
内存超限每轮开大数组考虑链表或优化临时数组

6. 三种语言风格的横向对比与思考

6.1 从一份代码看懂三种语言思维差异

同样的逆推逻辑,三个版本让我明显感觉到语言设计哲学的不同。

Java 版本最“工程化”。我需要自己管理 BufferedReader、显式声明数组长度、用 StringBuilder 拼输出。整个代码读下来,像是一个严谨的工程项目里的一环,每一步都清清楚楚,但也多了一些样板代码。

C++ 版本最“底层可控”。vector 的迭代器区间操作非常灵活,解构绑定让代码读起来很舒服。性能上限最高,但需要你心里清楚每一步在内存里发生了什么。

Python 版本最“接近思维原型”。代码几乎就是把数学描述直接翻译过来。segment[k:] + segment[:k] 这个写法,一步到位地表达“左移 k 位”。代价是每个切片背后都有列表拷贝,性能敏感场景下必须有优化意识。

6.2 你该在笔试中用哪种语言?

这个话题每年都有人争。我的看法非常直接:用你最熟练、能在半小时内无 bug 写完的语言。思路想通之后,代码量越小越不容易错,从这个角度看 Python 有天然优势。但如果你是准备后续所有面试都在 Java 技术栈里发展,那么笔试也用 Java 能帮你保持手感。

6.3 热词里那些“题外题”的快速解答

搜索热词里出现了一些频率很高但不是本题目相关的疑问,我顺手解答一下:

  • “指针数组”和“数组指针”的区别:指针数组是数组,每个元素是指针;数组指针是指向数组的指针。本题其实没有用到这个,但 C++ 面试经常问。
  • “C++ 前缀和”:处理静态区间求和问题的神器。本题如果改成查询操作就能用前缀和优化。
  • “树状数组维护长度 n=16 的序列。查询前缀和 sum(11) 与单点修改 add(3, x)”:这是树状数组的模板题,跟本题目关系不大,但也是春招高频考点。
  • “vba 数组”:VBA 里数组上界默认从 0 开始,但是 Option Base 1 可以改成 1。这不是春招题,可能搜索的人在搞办公自动化。
  • “c# 不同的 class 可以组成数组吗”:可以,用基类数组装派生类对象,这属于多态的经典用法。

这些题外问题我就不展开写了,感兴趣的话可以单独出一篇文章聊。

7. 我在这个题目上踩过的三个坑

最后说点不太能在教科书上看到的东西。这三个坑我比赛时全部踩过一遍,写出来给大家避雷。

第一个坑是“把逆推当成模拟反向操作来写”。我第一次写代码时没有建立 p 数组,而是直接拿原数组倒着搬元素。结果写了一百多行,改到第二十行就崩了。后来才意识到,逆推的关键是维护“位置映射”而不是维护“元素状态”。你要假装自己站在最终时间点,手里拿着一张放大镜,一个一个位置往回找,而不是真的去把元素倒腾回来。

第二个坑是“没有检查 k 是否已经取模就直接在循环里减”。笔试时的数据不保证 k 小于区间长度,我看到 k=1e9 直接人傻了,用 int 存储会溢出。所以不管 Java、C++ 还是 Python,第一步一定是 k %= (r-l+1)。这个取模不只是性能优化,是正确性问题。

第三个坑是“输出格式多了一个空格”。有些平台对行末空格很宽容,有些则严格比对。为了保险,我全都采用了先拼字符串再统一输出的策略,不要在循环里一边算一边 print。

这道题其实有一个很值得品味的扩展玩法:如果把 m 次操作里的 k 改成每次都由前面的结果动态决定,那就变成了一个在线问题,前面的所有静态优化全部失效,得用平衡树维护区间。这也是为什么我说这道题是 hard,因为出题人稍微换一层皮,就能把同场景考出完全不同的难度。春招笔试遇到这类题,建议先判断是静态还是动态,再决定要不要上高级数据结构。

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

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

立即咨询