有段时间没碰字符串的题了,刷到一道“1888. 使二进制字符串字符交替的最少反转次数”。这题题面很短:一个只包含 0 和 1 的字符串,允许把最左侧字符移到最右侧,也允许把任意一个字符翻转一次,求让整个字符串变成相邻字符都不相同的最小操作次数。我一开始以为就是个贪心模拟,真正动手才发现里面藏着三个关键点:旋转操作也要计入总代价、环形切口的滑动窗口怎么维护、以及奇偶长度带来的行为差异。这篇文章就把完整推导、可运行的滑动窗口代码和几个我踩过的坑一次性讲清楚,给正在刷算法题或者准备面试的朋友一个参考。
1. 题目拆解:两种操作都要计入答案
1.1 题面真正在考什么
先明确操作的含义。类型 1 是把字符串的第一个字符移到末尾,本质上就是一次循环左移。类型 2 是选择任意一个位置,把 0 变成 1 或者把 1 变成 0。题目要求的是让字符串变成“交替串”,即第 i 个字符和第 i+1 个字符永远不同。
这里有个很容易忽略的细节:类型 1 和类型 2 都算一次操作。很多解法看多了之后,会把注意力全放在翻转次数上,忘了旋转本身也是代价。实际上我们需要最小化的是“旋转次数 + 翻转次数”的总和,而不是单独某一项。
为什么不能暴力枚举?最朴素的想法是把所有可能的旋转次数 k 都试一遍,从 0 到 n-1,每次旋转后从头比较当前串和目标交替串的差异,复杂度是 O(n²)。当字符串长度到十万级别时,这个复杂度完全不可接受。所以核心任务是把枚举所有旋转的过程压缩成 O(n) 的滑动窗口。
1.2 交替串只有两种形态,但要小心环形差异
长度为 n 的交替串,一旦第一个字符确定,后面的字符就全部确定了。所以候选目标串只有两种:以 0 开头的“0101...”,和以 1 开头的“1010...”。这个结论对任何 n 都成立。
但这里有一个环形视角下的坑:长度为偶数的线性交替串,首尾字符不同,它本身可以首尾相接成一个完美的交替环;长度为奇数的线性交替串,首尾字符相同,不能首尾相接。
举个例子,n=3 时,“010”是交替串,但它左移一位变成“100”,最后两个字符都是 0,不再交替。“101”左移一位变成“011”,中间两个字符都是 1,也不再交替。也就是说,奇数长度下,一个交替串经过旋转后不再是交替串。这正是类型 1 操作不能随便忽略的原因:旋转有可能改变候选串的形态,进而改变需要翻转的字符集合。
2. 滑动窗口:用双倍字符串模拟所有旋转
2.1 双倍字符串是怎么模拟旋转的
枚举旋转次数 k,等价于看原串 s 连续读取 n 个字符,但起点从原来的 0 换成了第 k 个字符。为了统一处理,我把字符串复制一份拼成 t = s + s。这样 t 里任意长度为 n 的窗口 [i, i+n-1],恰好就是原串左移 i 次之后的结果。
比如 s = "001",t = "001001"。窗口起点 i=1 时,窗口内容是 t[1..3] = "010",这正是原串左移一次后的结果。窗口起点 i 同时就是类型 1 操作的执行次数:每把队首字符移到队尾一次,起点就前进一格。这一步是后面所有推导的地基。
2.2 固定模板的索引魔法
接下来是这题最微妙的地方。我构造一个固定模板 target = "0101...",长度至少 2n,然后定义:
diff_i = 窗口 [i, i+n-1] 中,有多少个位置 t[j] 不等于 target[j]
注意这里用的是全局索引 j,而不是窗口内部的第几个位置。为什么要这样?因为当窗口起点是 i 时,窗口里的第 0 个字符到底应该匹配 0 还是 1,取决于 i 的奇偶性。如果 i 是偶数,窗口内部的期望模式是“0 开头”;如果 i 是奇数,窗口内部的期望模式是“1 开头”。直接用全局索引 j%2 去比较,正好把起点的奇偶信息自动带进去了。
用前面的例子 s="001" 验证一下。
- i=0,窗口“001”,与 target[0..2] = "010" 比较:第 0 位相同、第 1 位不同、第 2 位不同,diff=2。因为 n=3,n-diff=1,所以窗口按 0 开头交替需要翻转 2 次,按 1 开头交替需要翻转 1 次,总代价是 0+1=1。
- i=1,窗口“010”,与 target[1..3] = "101" 比较:三位全部不同,diff=3。n-diff=0,说明这个窗口本身已经是以 0 开头交替了,翻转 0 次,但旋转已经花了 1 次,总代价 1+0=1。
也就是说,i=1 时虽然翻转次数是 0,但总操作数并不比原地更少。这个例子特别适合用来提醒自己:旋转次数必须显式加到答案里。
2.3 完整答案公式
对每一个窗口起点 i,min(diff, n-diff) 表示:把窗口按“0 开头交替”或“1 开头交替”所需的最小翻转次数。因为两种交替串每一位都互补,所以一个窗口按 0 开头的失配数是 diff,按 1 开头的失配数自然就是 n-diff。
总代价就是:
answer = min over i of ( i + min(diff_i, n - diff_i) )
这个 i 是旋转次数,也是窗口起点。这里必须特别强调:不加 i 的答案是错的。网上偶尔能看到一些讨论,直接对每个窗口取 min(diff, n-diff) 然后输出最小值,这在奇数长度测试用例下会得到明显错误的结果。还是 s="001",i=1 时 min=0,如果直接输出 0,但字符串“001”显然不可能零操作变成交替串。旋转一次已经要 1 次操作了,哪怕之后一次翻转都不用,总次数也至少是 1。
3. 增量维护 diff:把 O(n²) 优化成 O(n)
3.1 滑动窗口更新公式
如果每个窗口都重新统计 diff,复杂度还是 O(n²)。因为 diff 的定义是当前窗口与固定 target 区间的失配数,窗口向右滑动一格时,整个比较区间也会向右平移一格。这个平移过程中,只有两个字符的角色发生了变化:一个是离开窗口的旧队首 t[i-1],一个是进入窗口的新队尾 t[i+n-1]。
于是维护逻辑非常简洁:
- 先把旧队首 t[i-1] 的贡献删掉。判断方法是 t[i-1] 是否不等于 target[i-1]。
- 再把新队尾 t[i+n-1] 的贡献加进来。判断方法是 t[i+n-1] 是否不等于 target[i+n-1]。
为什么要这样更新?看窗口从 i-1 滑到 i 的过程。旧窗口是 [i-1, i+n-2],新窗口是 [i, i+n-1]。两个窗口在字符集合上的差集只有两个字符。中间的每个字符仍然在新窗口里,但它们的比较对象从 target 的某一个位置移动到了下一个位置。虽然单个字符的失配状态可能改变,但整体计数可以通过删头加尾正确转移。这也是滑动窗口处理环形问题最常用的套路。
需要注意移入下标是 i+n-1,不是 i+n。因为窗口长度是 n,起点从 i-1 变成 i,尾部会从 i+n-2 变成 i+n-1。这里写错一位,结果就会全错。
3.2 可运行代码
先给一段思路直观的版本,用 t = s+s 和 target = "01" * (2*n) 来降低理解成本:
def min_flips(s: str) -> int: n = len(s) if n == 1: return 0 t = s + s target = "01" * (2 * n) # 足够长的 0101... 模板 # 初始窗口 [0, n-1] 的失配数 diff = 0 for j in range(n): if t[j] != target[j]: diff += 1 # i = 0 的情况,旋转次数为 0 ans = min(diff, n - diff) # i 从 1 到 n-1,表示旋转 i 次 for i in range(1, n): # 窗口从 i-1 平移到 i,移出旧队首 t[i-1] if t[i - 1] != target[i - 1]: diff -= 1 # 移入新队尾 t[i + n - 1] if t[i + n - 1] != target[i + n - 1]: diff += 1 # 当前窗口下,旋转代价是 i ans = min(ans, i + min(diff, n - diff)) return ans用几个输入验证一下:
| 输入 | 输出 | 说明 |
|---|---|---|
| "001" | 1 | 旋转一次得到 010,或者原地翻转一次得到 101 |
| "010" | 0 | 本身就是交替串 |
| "00" | 1 | 翻转任意一个字符即可 |
| "111000" | 2 | 按“101010”模式翻转两个字符 |
| "00000" | 2 | 按“01010”模式翻转两个位置 |
3.3 无拼接的空间优化版本
如果不想额外构造双倍字符串,可以完全靠下标取模来访问原串。这个版本在原理上和上面完全一样,只是把 t[j] 换成 s[j % n],把 t[i+n-1] 换成 s[(i+n-1) % n]。由于 (i+n-1) % n 恰好等于 i-1,所以移入的新队尾和移出的旧队首其实是原串中的同一个字符,只是它在新窗口中对应的模板期望改变了。这个现象非常有意思,也更说明旋转的本质是首尾相接。
def min_flips_compact(s: str) -> int: n = len(s) if n == 1: return 0 # 初始窗口 [0, n-1] diff = 0 for j in range(n): if s[j] != ("0" if j % 2 == 0 else "1"): diff += 1 ans = min(diff, n - diff) for i in range(1, n): # 移出 s[i-1],它原本要匹配 target[i-1] if s[i - 1] != ("0" if (i - 1) % 2 == 0 else "1"): diff -= 1 # 移入 s[(i+n-1) % n],它现在要匹配 target[i+n-1] if s[(i + n - 1) % n] != ("0" if (i + n - 1) % 2 == 0 else "1"): diff += 1 ans = min(ans, i + min(diff, n - diff)) return ans这个版本空间复杂度是 O(1),不过第一次看的时候不太容易理解移入移出为什么会出现同一个字符。建议先用双倍串版本跑通逻辑,再考虑优化。
4. 常见问题与排查技巧实录
4.1 忘记把旋转次数加进答案
这是我见过最多人踩的坑。滑动窗口算出来的 diff 只是“翻转次数”,不是“总操作数”。如果直接 min 每个窗口的翻转次数,甚至会得到 0,但原串明明不可能零操作完成。
调试技巧:打印每个窗口的起点 i、diff、n-diff 和当前答案。拿 s="001" 跑一下,你会看到 i=1 时 diff=3,n-diff=0。如果代码里漏了 i,这里就会输出 0;正确版本会把这个点更新成 1+0=1。把中间过程打出来,一眼就能发现问题。
4.2 n=1 的边界要单独处理吗
n=1 时,字符串长度为 1,无论字符是 0 还是 1,都天然满足“没有相邻字符”的交替条件,答案是 0。代码里的循环不会进入,min(diff, n-diff) 也会得到 0,因为 n-diff 等于 1-diff,两个值里总有一个是 0。所以不写特判也能通过,但显式写一个 if n == 1: return 0 能避免后续维护逻辑里产生不必要的边界考虑。
4.3 奇偶长度的行为差异
偶数长度下,线性交替串首尾不同,可以首尾相接成环;旋转任意一位后,得到的仍然是一个交替串。因为交替串的周期是 2,偶数长度的环旋转一位,本质上只是换了一个断点。反映到公式里,当 n 为偶数时,diff_i 只会在 d0 和 n-d0 两者之间切换,min(diff, n-diff) 始终保持不变,而 i 又额外增加,所以最优解一定出现在 i=0。
奇数长度则完全不一样。奇数长度的交替串首尾相同,旋转一位后就不再是交替串。旋转会让“当前窗口对应 target 的哪一段”发生根本变化,导致有些窗口翻转需求极小。因此奇数长度必须枚举所有 i,不能只算原地。
这个差异是我在调试时通过对比输出才意识到的。如果你发现自己的算法只在偶数长度用例上正确,优先检查是不是没有把旋转次数作为独立变量参与更新。
4.4 用暴力法做交叉验证
当我写完滑动窗口版本后,习惯性写一个 O(n²) 的暴力验证函数,专门用来对拍。思路很简单:对 k 从 0 到 n-1,把字符串左移 k 次,然后分别计算与“0101...”和“1010...”两种目标的差异,取 k + min(diff0, diff1) 的最小值。
def brute_force(s: str) -> int: n = len(s) ans = n + 1 for k in range(n): t = s[k:] + s[:k] diff0 = sum(1 for i in range(n) if t[i] != ("0" if i % 2 == 0 else "1")) diff1 = n - diff0 ans = min(ans, k + min(diff0, diff1)) return ans然后用随机的小长度二进制串,让暴力结果和滑动窗口结果逐一对拍。这个方法帮我提前发现了两个 off-by-one 错误,都是移入下标写错导致的。强烈建议刷这类环形滑动窗口题时都这么做。
5. 复杂度分析与调试建议
滑动窗口版本的复杂度非常清晰:初始化和滑动过程各遍历 O(n) 次,每次只做常数次判断和加减,总时间复杂度 O(n)。双倍串版本额外用了两个长度为 2n 的数组/字符串,空间 O(n);取模版本可以把空间压到 O(1)。
| 方案 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 朴素枚举旋转 | O(n²) | O(n) | 小规模验证 |
| 双倍串 + 滑动窗口 | O(n) | O(n) | 实际提交 |
| 取模 + 滑动窗口 | O(n) | O(1) | 内存敏感环境 |
调试这段代码时,我最推荐的方式是:固定一段“01”交替模板,把窗口起点、窗口内容、与模板对应的区间、diff 值一起打印出来。拿长度 3 或者长度 5 的样例跑一遍,比如“001”“010”“10100”这种容易暴露问题的输入,很快就能定位是模板对齐问题、下标问题,还是漏加旋转代价的问题。
最后分享一个小技巧:如果你不确定某个滑动窗口维护的变量语义,就回到 diff 的定义重新推一遍。这题里 diff 的准确定义是“当前窗口与全局 target 对应区间的失配数”,只要盯着这个定义,移入移出该减谁、该加谁就不会写错。尤其是移入下标 i+n-1,很多人会因为窗口起点是 i 就写成 i+n,结果把最后一个本应进入窗口的字符漏掉,一旦字符串随机性变强,错误立刻显现。