目录

题目描述

剑指 Offer 58 - II. 左旋转字符串

image-20241107212245259

题意分析

输入一个字符串 s 和一个整数 n,要求把 s 前面的 n 个字符整体搬到字符串的尾部,返回搬运之后的新字符串。题面把这个动作叫做「左旋转」,但本质上只是一次位置重排,字符本身既不增加也不减少,出现次数也完全不变。

约束里透露的信号很直接:1 <= n < s.length,也就是说旋转量严格小于长度,既不会出现「不旋转」的退化输入,也不会出现绕圈超过一整轮的情况。字符集是普通小写字母,没有多字节字符,因此可以放心按下标定位,不必担心索引落在半个字符中间。数据规模很小,$O(n)$ 的做法绰绰有余,题目真正想考的是你能不能一眼看穿「旋转 = 前后两段互换位置」,而不是老老实实地搬 n 次。

边界方面唯一需要留意的是:如果题目放宽约束允许 n 大于等于长度,就必须先把 n 对长度取模,否则按下标截取会直接越界;空串则要在取模之前挡掉,避免除零。

解法:字符串切分

核心思路

最朴素的模拟是执行 n 轮操作,每一轮把首字符取下来接到末尾。这样做完全正确,但每一轮都要重排整个字符串,总代价是 $O(n \times len)$,而且中途产生了大量只存在一瞬间的中间串。瓶颈在于它把「一次整体重排」拆成了 n 次局部重排,重复搬运了同一批字符。

观察一下最终结果长什么样:设长度为 len,旋转量为 n,那么答案的第 0 个字符是原串的第 n 个字符,第 1 个是原串第 n + 1 个……一直到原串末尾;紧接着才轮到原串的第 0 到第 n - 1 个字符。换句话说,原串被下标 n 切成了前缀 s[0, n) 和后缀 s[n, len) 两段,答案就是这两段调换顺序后的拼接,段内部的相对顺序丝毫未变。

于是不变量可以写成:答案等于 s[n, len) + s[0, n),且对任意 i,答案的第 i 位就是原串的第 (i + n) mod len。这个恒等式一次性描述了全部结果,不需要任何中间状态,因此只要按这个公式构造一次字符串即可,中间过程可以完全跳过。

解题步骤

第一步,取出字符串长度 len。之所以要先拿到长度,是因为后面的切分点和取模都依赖它,把它缓存下来也避免在表达式里反复调用长度方法。

第二步,执行 n %= len(Go 版本写作 k := n % len(s))。虽然题目保证 n < len,取模在这种输入下等价于不做任何事,但它让函数对「转一整圈回到原点」这类扩展输入也成立,是一个零成本的健壮性保险。Go 版本额外在取模前判断了空串并直接返回,因为对长度为 0 的串取模会触发除零。

第三步,截取后缀 s.substring(n),它对应旋转后排在最前面的那一段。选择从 n 开始而不是 n - 1n + 1,是因为下标为 n 的字符恰好是「没有被搬走的第一个字符」,前 n 个字符的下标范围是 0 到 n - 1

第四步,截取前缀 s.substring(0, n),它对应被搬到末尾的那一段。Java 的 substring 是左闭右开的,所以右端写 n 才能恰好取到 n 个字符。

第五步,把后缀和前缀按这个顺序拼接并返回。顺序不能反,反过来就变成了右旋转。整个过程只构造了一次新串,没有任何循环。

s = "abcdefg"n = 2 走一遍:len = 7,取模后 n 仍为 2;后缀 s.substring(2) 从下标 2 取到末尾,得到 "cdefg";前缀 s.substring(0, 2) 取下标 0 和 1 两个字符,得到 "ab";拼接得到 "cdefgab",正好是把 ab 两个字符搬到尾部的结果。再验证一下恒等式:答案第 0 位是 c,而 (0 + 2) mod 7 = 2,原串下标 2 正是 c;答案第 5 位是 a(5 + 2) mod 7 = 0,原串下标 0 正是 a,完全吻合。

代码实现

class Solution {
    // 注意 n 可能大于长度,需要取模。
    public String reverseLeftWords(String s, int n) {
        int len = s.length();
        n %= len;

        return s.substring(n) + s.substring(0, n);
    }
}
func reverseLeftWords(s string, n int) string {
    // 注意 n 可能大于长度,需要取模。
    if len(s) == 0 {
        return s
    }
    k := n % len(s)

    return s[k:] + s[:k]
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 n 为字符串长度。两次截取各自复制一段字符,拼接再把两段写入结果,每个字符总共被复制常数次。
  • 空间复杂度:$O(n)$。Java 与 Go 的字符串都不可变,无法原地改写,必须新建一个等长的结果串,这部分空间是题目本身要求的输出,除此之外没有额外开销。

关键点总结

  • 把「重复执行 k 次的操作」先展开看终态:很多旋转、移位、循环右移类题目一旦写出「答案第 i 位来自原串第 (i + n) mod len 位」这样的闭式,模拟循环就自动消失了。
  • 切分点的语义要说清楚:下标 n 是「留在原地的第一个字符」,前缀长度恰好是 n,把这句话背下来可以避免在左闭右开区间上反复试错。
  • 取模是旋转类问题的标配前置动作:即使当前约束保证不越界,加上它的成本为零,却能让代码对更一般的输入直接成立。
  • 明确不可变字符串的空间下界:面试官如果追问「能不能 $O(1)$ 空间」,正确回答是 Java/Go 的字符串不可变所以做不到,但若输入换成字符数组,就可以用「先整体反转、再分别反转两段」的三次反转法原地完成。
  • 面试视角:先说 n 次模拟的 $O(n \times len)$ 及其瓶颈,再给出切片拼接的一行解,最后主动补充字符数组场景下的三次反转法和取模处理,这条从模拟到公式再到原地优化的链路,比直接甩出一行 substring 更能体现思考过程。

易错点总结

  • 拼接顺序写反成 s.substring(0, n) + s.substring(n)s = "abcdefg"n = 2 会原样返回 "abcdefg",因为这等价于把串在下标 2 处切开又按原顺序接回去。
  • 误把后缀起点写成 s.substring(n - 1):同样的用例会返回 "bcdefgab",字符 b 被复制了两份,结果长度变成 8。
  • 把前缀右端写成 n - 1s.substring(0, n - 1)n = 2 时只取到 "a",结果 "cdefga" 丢了一个字符,长度短了 1。
  • 空串时不做保护直接取模:s = ""n = 3 在 Java 里 n %= 0 抛出算术异常,Go 里同样触发整数除零 panic。
  • 忘记取模而 n 大于长度:s = "abc"n = 5 会调用 s.substring(5),直接抛出越界异常,而正确答案应是取模后等价于 n = 2"cab"
  • 用循环逐字符搬运且每轮都做字符串拼接:s 长度一万、n 为五千时会产生五千个中间串,时间退化到平方级并触发大量垃圾回收。
  • 把「左旋转」理解成整体反转:对 s = "abcdefg" 返回 "gfedcba",字符相对顺序被破坏,而本题要求两段内部顺序保持不变。
  • 想当然地对索引取模却忘了处理负数:如果自行推广到「左旋转 n 位,n 可能为负」的版本,n = -1 时 Java 的 % 会得到负余数,substring 立刻越界,需要写成 ((n % len) + len) % len
  • 在 Go 里对含中文等多字节字符的串按字节切分:s = "中国人"n = 1 会从字节下标 1 处切开,切出半个 UTF-8 码点,输出乱码,此时应先转成 []rune 再切。

相似题目

题目 难度 考察点
48. 旋转图像 中等 二维矩阵的原地旋转,靠转置加行反转实现
61. 旋转链表 中等 链表右旋,需先成环再按取模后的位置断开
151. 反转字符串中的单词 中等 以单词为单位反转,还要处理多余空格
186. 反转字符串中的单词 II 中等 字符数组上原地完成,正是三次反转法的标准考场
189. 轮转数组 中等 数组可变,可用三次反转做到 $O(1)$ 额外空间
344. 反转字符串 简单 三次反转法的最小组成单元,纯双指针对撞
541. 反转字符串 II 简单 按固定步长分段反转,重点在末尾不足一段的处理
557. 反转字符串中的单词 III 简单 只反转每个单词内部,单词之间的顺序保持不变
796. 旋转字符串 简单 判断能否由旋转得到,用 s + s 包含判断一步解决
848. 字母移位 中等 移动的是字符取值而非位置,靠后缀和累计位移量
917. 仅仅反转字母 简单 反转时要跳过非字母字符并保持它们的原位置