字符串旋转判断这个问题,看起来像是算法题里最不起眼的那一类,但我在实际带人做代码评审的时候发现,能一次写对、写稳、写到工程可用的人其实并不多。大部分人第一反应是"把A所有可能的旋转都枚举出来跟B比一遍",这个思路没错,可是真落到代码里,边界条件、空串处理、长度校验、性能取舍,处处都是坑。这篇就把这道题从暴力解法到最优解法完整拆一遍,顺带聊聊我在真实项目里遇到过的类似场景,以及那些文档里不会写的经验。不管你是刚刷算法题的新手,还是想复习字符串处理的老手,应该都能从里面捞到点东西。
1. 先把题目翻译成人话:旋转到底在做什么
1.1 分割点与旋转的对应关系
题目说的是:把字符串A从中间某个位置切成左右两部分,两部分都不能是空串,然后把左边那部分挪到右边那部分的后面,拼成一个新串,如果这个新串能等于B,就返回true。
我用一个具体例子把它说透。假设A = "abcde",我们从位置2切开(也就是下标2之前),得到左半部分"ab"和右半部分"cde"。把左边挪到右边后面,就变成"cde" + "ab" = "cdeab"。这就是一次旋转。
关键在于"某个位置"这四个字。位置可以是1、2、3、4(对于长度5的串),对应切出来的左半部分长度分别是1、2、3、4。也就是说,一个长度为n的字符串,最多有n-1种有效的旋转结果,因为左右两部分都不能为空,所以切点不能在最开头也不能在最末尾。
这里有个特别容易搞混的点:很多人会把"旋转"理解成循环移位,觉得旋转n次会回到原串,所以有n种。但题目明确要求两部分都不为空,所以切点在两端的情况被排除了。这个细节直接决定了后面代码里循环的边界怎么写。
1.2 为什么这个定义值得单独拿出来讲
你可能会想,不就是个旋转吗,有什么好讲的。但恰恰是这个"都不为空串"的约束,让这道题和很多人的直觉产生了偏差。
举个边界例子:A = "a",长度为1。这时候你能切吗?切点只能在位置0或位置1,但这两个位置都会导致某一边是空串。所以长度为1的字符串没有任何合法旋转,直接返回false(除非B等于A本身,但按题目定义,A无法通过合法旋转变成任何串,包括它自己)。
再比如A = "aa",切点在位置1,左"a"右"a",旋转后还是"aa"。所以"aa"可以旋转成"aa"。但如果B = "aa"而A = "aa",返回true;如果A = "a"而B = "a",按严格定义应该返回false,因为没有合法切点。
注意:实际面试或工程中,这类边界到底怎么处理,一定要先跟出题人或需求方确认。我见过太多因为边界定义不一致导致的"我觉得对但测试挂了"的情况。
1.3 和"循环同构"这个概念的关系
从数学角度讲,两个字符串如果互为旋转,它们就是"循环同构"的。判断循环同构有一个非常经典的结论:B是A的旋转,当且仅当B是A+A的子串,并且len(A) == len(B)。
这个结论是后面最优解法的理论基础。为什么?因为A+A包含了A所有可能的旋转结果。比如A = "abcde",A+A = "abcdeabcde",你在这个长串里找长度为5的窗口,从下标1开始是"bcdea",下标2是"cdeab",下标3是"deabc",下标4是"eabcd",正好覆盖了所有合法旋转。下标0是"abcde"本身,对应切点在末尾的情况,但那个情况左右有一边为空,被排除了——不过在做子串判断时它不影响正确性,因为如果B等于A本身,它本来也可能通过其他切点得到(当A有重复结构时),或者根本不该返回true(当A无重复结构时)。这个细微差别我在第3节会专门展开。
2. 暴力枚举法:能跑通,但你要知道它慢在哪
2.1 最直白的实现思路
暴力法的逻辑非常符合直觉:把所有合法切点都试一遍,每次拼出新串,跟B比。
def is_rotation_brute(a, b): if len(a) != len(b): return False n = len(a) for i in range(1, n): # 切点从1到n-1,保证两边都不为空 rotated = a[i:] + a[:i] if rotated == b: return True return False这段代码有几个地方值得说。
第一,先做长度校验。长度不一样,直接false,这是最基本的剪枝,省得白跑循环。
第二,循环范围是range(1, n),不是range(n)。这就是前面说的"都不为空"约束的体现。如果写成range(n),i=0时会得到a[0:] + a[:0]= a本身,这对应切点在开头的非法情况。
第三,a[i:] + a[:i]这个切片拼接是Python里做旋转最简洁的写法。a[i:]是右半部分,a[:i]是左半部分,拼起来正好是"左半挪到右半后面"。
2.2 时间与空间复杂度拆解
暴力法的时间复杂度是O(n²)。为什么?外层循环n-1次,每次内部要做一次长度为n的字符串拼接和一次长度为n的字符串比较,这两步都是O(n),所以总共O(n²)。
空间复杂度是O(n),因为每次循环都创建了一个新的长度为n的字符串。
这个复杂度在n比较小的时候完全够用。比如n=1000,O(n²)也就是一百万次基本操作,现代机器毫秒级就出结果。但如果n到了10⁵甚至10⁶级别,O(n²)就顶不住了。我在实际项目里处理日志行匹配、DNA序列片段比对这类场景时,字符串长度动辄几万几十万,暴力法直接超时。
2.3 暴力法里那些容易写错的细节
别看代码短,坑不少。
坑一:长度校验的位置。有人把长度校验写在循环里面,每次都比一次,纯属浪费。应该放在最前面。
坑二:空串处理。如果a和b都是空串,len相等,循环range(1, 0)根本不执行,直接返回false。但两个空串算不算互为旋转?按题目"两部分都不为空"的定义,空串没有合法切点,应该返回false。这个行为是否符合预期,取决于你的需求定义。
坑三:切片越界。Python的切片很宽容,a[i:]当i等于len(a)时返回空串,不会报错。但在C++或Java里,substring越界是会抛异常的,写的时候要格外小心边界。
坑四:提前返回。一旦找到匹配就该立刻return true,不要傻乎乎跑完整个循环。这个优化虽然不改变最坏复杂度,但平均情况下能省不少时间。
3. 最优解法:A+A子串判断,一行顶十行
3.1 核心原理:为什么A+A能覆盖所有旋转
前面提过那个经典结论,这里把它讲透。
把A复制一份拼在自己后面,得到A+A。这个长串里,任何一个长度为len(A)的连续子串,都对应A的一次旋转。我拿A = "abcde"演示:
| 起始下标 | 长度为5的子串 | 对应的切点 |
|---|---|---|
| 0 | abcde | 切点在末尾(非法) |
| 1 | bcdea | 切点在1 |
| 2 | cdeab | 切点在2 |
| 3 | deabc | 切点在3 |
| 4 | eabcd | 切点在4 |
可以看到,下标1到4正好对应所有合法切点,下标0对应的是非法的那一种。所以只要在A+A里能找到B,并且len(A) == len(B),就说明B是A的旋转。
那下标0那个非法情况会不会导致误判?会,但只在一种情况下:B恰好等于A本身,且A没有任何合法切点能旋转出A。这种情况只发生在A的所有字符都相同吗?不是。让我想想——A = "abab",切点在2得到"abab",等于A本身,所以有合法切点。A = "abcabc",切点在3得到"abcabc",也等于A本身。实际上,只要A是周期串(由某个更短的串重复构成),就存在合法切点旋转出A本身。只有当A不是周期串时,才没有合法切点能得到A本身。
所以严格来说,用A+A子串判断会有一个理论上的偏差:当B == A且A不是周期串时,标准答案应该是false(因为没有合法切点),但A+A方法会返回true。
这个偏差在实际工程里几乎没人关心,因为大多数场景下"字符串和它自己互为旋转"是符合直觉的。但如果面试官较真,你得能说清楚这个区别。我的建议是:先跟对方确认定义,别自己闷头写。
3.2 代码实现与语言特性选择
def is_rotation(a, b): if len(a) != len(b): return False if len(a) == 0: return False # 空串无合法切点 return b in (a + a)就这几行。b in (a + a)利用了语言内置的子串查找,底层通常是优化过的字符串匹配算法(比如Python用的是混合了Boyer-Moore和Horspool的算法),平均性能远好于手写。
不同语言里这个思路的写法略有差异:
- Java:
(a + a).contains(b),注意contains接收的是CharSequence。 - C++:
(a + a).find(b) != string::npos。 - JavaScript:
(a + a).includes(b)。 - Go:
strings.Contains(a + a, b)。
3.3 复杂度分析与实际性能
A+A方法的时间复杂度取决于子串查找算法的实现。理论上最坏是O(n²)(比如朴素的逐字符比较),但实际语言内置实现通常是O(n)或接近O(n)。空间复杂度是O(n),因为要构造A+A这个长度为2n的新串。
对比暴力法,A+A方法在代码简洁度和实际性能上都占优。但有一个例外:如果n特别大,构造A+A会额外占用一倍内存。在内存敏感的场景下(比如嵌入式),可能需要考虑用KMP之类的算法直接在逻辑上的A+A上做匹配,而不真正构造这个串。
我实测过一组数据,在Python里,n=100000的随机字符串,暴力法要跑好几秒,A+A方法基本是毫秒级。差距非常明显。
4. 那些真正会坑到你的边界与陷阱
4.1 长度不等时的快速失败
这是最基本也是最重要的一条。长度不等直接false,不要有任何犹豫。我见过有人忘了这一步,结果在A+A里找到了B,但B其实比A短,属于误判。比如A = "abc",B = "ab",A+A = "abcabc","ab"确实是子串,但B显然不是A的旋转。
4.2 空串与单字符的诡异行为
前面反复提到空串和单字符。这里系统整理一下:
| A | B | 暴力法结果 | A+A方法结果 | 标准答案(严格定义) |
|---|---|---|---|---|
| "" | "" | false | false | false |
| "a" | "a" | false | true | false |
| "a" | "b" | false | false | false |
| "aa" | "aa" | true | true | true |
| "ab" | "ba" | true | true | true |
可以看到,唯一有分歧的是A = "a", B = "a"这一行。A+A方法返回true,但严格定义下应该是false。这个分歧点我在前面解释过原因。
实操建议:如果你的业务里"自己算自己的旋转"是合理的,那就用A+A方法,简单省事。如果业务严格要求切点合法,那就在A+A方法前面加一个特判:当len(a) <= 1时,直接返回false。
4.3 重复字符带来的"假阳性"排查
有一种情况特别容易让人怀疑代码写错了:A = "aaa",B = "aaa"。暴力法会返回true(切点在1或2都能得到"aaa"),A+A方法也返回true。这是对的,因为"aaa"确实是周期串,有合法切点。
但如果A = "abc",B = "abc",暴力法返回false(没有合法切点能得到"abc"),A+A方法返回true。这时候如果你不知道背后的原理,就会觉得A+A方法有bug。其实不是bug,是定义差异。
我在代码评审时遇到过同事因为这个差异跟测试吵起来,最后发现是需求文档没写清楚"自己算不算自己的旋转"。所以再次强调:定义先行。
4.4 大小写与编码问题
如果字符串包含非ASCII字符,比如中文、emoji,长度计算和切片行为在不同语言里可能不一样。Python 3里len("中")是1,切片也按字符来,没问题。但Java里String.length()返回的是UTF-16码元数,一个emoji可能占2个,切片时如果切在码元中间会产生乱码。做这类题时,如果输入可能包含多字节字符,一定要确认清楚按什么单位切。
5. 从算法题到工程:这个思路还能用在哪
5.1 循环缓冲区与环形队列的匹配
环形缓冲区是操作系统和网络编程里的常客。它的本质就是一个循环数组,读写指针绕圈走。判断两个环形缓冲区的数据序列是否等价,本质上就是判断循环同构,A+A子串判断可以直接搬过去用。
具体做法:把缓冲区A的数据按逻辑顺序展开成一个串,缓冲区B同样展开,然后判断B是否是A+A的子串。这样就不用真的去模拟指针绕圈了。
5.2 周期性数据的检测
日志分析里经常要检测某个模式是否周期性出现。比如某服务每隔一段时间就报一次同样的错误,你想知道这个错误序列是不是某个基础序列的重复。这时候可以先判断两个序列是否互为旋转,再进一步分析周期。
A+A方法在这里的价值是:它把"旋转判断"这个操作降到了O(n)级别,让你可以在大量数据上快速筛选。
5.3 字符串指纹与去重
在数据去重场景里,如果两条记录的某个字段互为旋转,它们可能代表同一个实体(比如不同系统对同一ID的不同编码方式)。用A+A方法可以快速判断,比逐个字符比对高效得多。
我做过一个项目,需要合并两个来源的用户行为序列,两边对同一行为的记录顺序可能因为采集时机不同而有旋转偏移。用A+A方法做预筛选,把候选对缩小到很小范围,再做精细比对,整体性能提升了一个数量级。
6. 我在实际写这道题时踩过的坑
第一次写这道题是在很多年前的一次内部算法分享上,我当时信心满满地写了暴力法,结果被问"如果字符串很长怎么办",当场卡壳。后来补了A+A方法,又被人追问"那A等于B的时候呢",才发现自己根本没想过这个边界。
后来带新人的时候,我发现大家踩的坑高度集中在这几个地方:
第一,循环边界写错。range(n)和range(1, n)的区别,看起来小,但直接决定了切点在两端的情况会不会被错误地纳入。我建议写完之后拿n=1、n=2、n=3的小例子手动跑一遍,比什么都管用。
第二,忘了长度校验。这个错误在A+A方法里尤其致命,因为A+A里可能包含比A短的子串,不校验长度就会误判。
第三,对"自己算不算自己的旋转"没有明确答案。这个不是代码问题,是需求问题。我的经验是:写代码前先把这个定义跟相关方对齐,写进注释里,省得后面扯皮。
第四,性能测试只测小数据。很多人写完拿几个短串测一下就交差了,结果上线遇到长串直接崩。我的习惯是至少测三组:n=10、n=1000、n=100000,观察耗时曲线是否符合预期。
第五,忽略了多字节字符。在处理国际化文本时,这个坑特别隐蔽。建议在测试用例里加几个中文和emoji,看看切片行为是否符合预期。
最后分享一个我自己的小技巧:判断旋转这类问题,我习惯先在纸上画出A+A的展开图,把每个切点对应的子串标出来,确认覆盖完整了再动手写代码。这个习惯帮我省下了大量调试时间,尤其是边界情况多的时候,画一遍比想十遍都清楚。