☰
字符串翻转到单调递增:从暴力枚举到O(1)扫描的完整解法拆解
2026/10/1 18:27:40 网站建设 项目流程

最近在刷题群里又看到有人贴这道“将字符串翻转到单调递增”,不少新手拿到题第一反应是“不就排序吗”,但真上手才发现,这题考的根本不是排序,而是对状态的理解。字符串本身只有0和1两种字符,目标也不是把所有字符变成同一个值,而是让整个串满足“前面全是0、后面全是1”的单调形态。今天我就把这题的几种典型解法从头到尾拆一遍,从最朴素的暴力枚举一直讲到空间复杂度O(1)的扫描法,顺便把容易踩的边界坑也一并说清楚。

1. 题目本质与核心难点

先明确一下题目到底在问什么。给定一个只包含字符'0'和'1'的字符串,你可以把任意一个位置的字符翻转:'0'变成'1','1'变成'0'。每翻转一个字符算一次操作,要求用最少的操作次数,让最终字符串满足单调递增——也就是从左到右读过去,不会出现先遇到'1'后面又冒出来'0'的情况。

单调递增这个条件在二进制字符串里有一个非常直观的等价说法:最终形态必然形如若干个'0'后跟若干个'1'。空串和全0、全1都算单调递增。比如"000111"、"001111"、"000001"都满足条件,而"010"、"101"、"1100"都不满足。

这个等价关系是整个题目的突破口。既然最终形态一定是“一串0加一串1”,那本质上只需要确定一个分割线:分割线左边全部是0,右边全部是1。只要确定了分割线的位置,剩下要做的就是把分割线左边的所有'1'翻成'0',把分割线右边的所有'0'翻成'1'。翻转次数就是这两部分的数量之和。

那核心难点就变成了:分割线选在哪里,能让这个和最小?字符串可以任意长,分割点一共有n+1个候选位置(包括最左边和最右边),朴素的思维就是把每个位置都试一遍,每个位置都要扫描一遍两侧的字符,时间复杂度就变成了 O(n²)。对于长度几百万的字符串,这个复杂度是撑不住的,所以需要寻找更聪明的办法。

再往深想一层:分割线左边要消掉的是'1'的数量,右边要消掉的是'0'的数量。这两个数字在枚举分割线的过程中是连续变化的——分割线往右移动一格,左边就多了一个字符,右边就少了一个字符。这种连续性正是可以用前缀和、动态规划或贪心扫描来优化的理论基础。

从题目场景来说,这道题在实际中对应文本规范化、序列单调性修正、信号二值化处理等需求。比如把一组二值传感器的输出序列调整为无回跳的稳定状态,或者处理基因序列中碱基的某种单调约束,思路都能迁移过来。所以别看它是一道字符串题,背后的“找分割点最小化代价”模型非常通用。

2. 暴力枚举:先想清楚朴素做法再谈优化

很多人刷题有个坏习惯,上来就背模板。但遇到不熟悉的题目,我强烈建议先写一个最直观的暴力版本,哪怕跑不过大数据,至少能验证你后续优化版本的正确性。暴力枚举的思路非常直接:枚举每一个可能的分割线位置i,i的取值范围是 0 到 n,表示前i个字符最终全部变成'0',后n-i个字符最终全部变成'1'。

对于每个分割线i,你需要统计两个数字:前i个字符里'1'的个数(这些要翻成'0'),以及后n-i个字符里'0'的个数(这些要翻成'1')。两者相加就是该分割线下的总翻转次数。对所有i取最小值即可。

这段逻辑写成伪代码大概是这样的:

for i in 0..n: left_ones = 统计 s[0..i-1] 中 '1' 的个数 right_zeros = 统计 s[i..n-1] 中 '0' 的个数 ans = min(ans, left_ones + right_zeros)

复杂度很好算:分割线有n+1个,每个分割线要扫描一次字符串,时间复杂度 O(n²),空间复杂度 O(1)。这个复杂度在面试中通常不会被接受,但对拍验证时很好用。我实际调试时发现,暴力代码最容易出错的地方是分割线边界的开闭:前i个字符具体是哪些下标、后n-i个字符从哪个下标开始,如果搞混了,输出就会差 1。

我个人的习惯是,暴力的验证代码里把分割线的语义写清楚:i表示“左边保留的字符个数”,这样左边下标区间就是[0, i),右边区间就是[i, n)。这个语义统一之后,后面的优化代码也沿用同样的定义,逻辑上就非常顺。

暴力枚举虽然慢,但它能帮你确认一个非常重要的直觉:分割线从 0 移到 n 的过程中,左边的'1'数量单调不减,右边的'0'数量单调不增。这个单调性说明,答案是先下降后上升还是其他形态,其实有迹可循。理解到这个层面,就自然想知道能不能利用这种连续变化来加速。

3. 前缀和优化:一次扫描算出所有分割点代价

既然暴力枚举慢在对每个分割点都从头统计,那我们可以提前把统计结果算好。这就是前缀和思想登场的地方。维护两个数组:

  • ones_prefix[i]:字符串前i个字符中'1'的个数
  • zeros_suffix[i]:字符串从下标i到末尾中'0'的个数

有了这两个数组,任意分割点i的翻转代价就是ones_prefix[i] + zeros_suffix[i],查询时间降到 O(1)。整体流程分三步:

  1. 从左到右扫一遍,计算ones_prefix,递推公式是ones_prefix[i] = ones_prefix[i-1] + (s[i-1] == '1')。
  2. 从右到左扫一遍,计算zeros_suffix,递推公式是zeros_suffix[i] = zeros_suffix[i+1] + (s[i] == '0')。
  3. 枚举所有i从 0 到 n,用公式求最小值。

这个做法时间复杂度 O(n),空间复杂度 O(n)。它好理解、不容易写错,是面试时最稳妥的答案之一。我给一个 C++ 版本,方便和暴力对拍:

int minFlipsMonoIncr(string s) { int n = s.size(); vector<int> ones_prefix(n + 1, 0); vector<int> zeros_suffix(n + 1, 0); for (int i = 0; i < n; i++) { ones_prefix[i + 1] = ones_prefix[i] + (s[i] == '1'); } for (int i = n - 1; i >= 0; i--) { zeros_suffix[i] = zeros_suffix[i + 1] + (s[i] == '0'); } int ans = n; for (int i = 0; i <= n; i++) { ans = min(ans, ones_prefix[i] + zeros_suffix[i]); } return ans; }

这里有个细节值得展开:为什么不直接统计zeros_prefix然后用总数减?当然可以,但我更推荐直接用后缀数组,因为语义和分割线定义一一对应,不容易绕晕。再提醒一点,数组ones_prefix长度要开n+1,ones_prefix[i]表示前i个字符中'1'的数量,注意当i=0时前 0 个字符不存在任何'1',所以初始化为 0。

实际运行中我最常遇到的 bug 是字符串遍历时的下标差一。比如s[i] == '1'判断的是第i个字符,但写的却可能变成s[i-1]。这类问题在暴力对拍里会立刻暴露,所以我还是建议第一步的暴力对照代码别省,多写几分钟,能省后面几小时的排查时间。

这种“从左到右预处理 + 从右到左预处理 + 枚举分割点”的套路,在很多字符串题里都通用,比如平衡括号的最少修改次数、分割数组让左右满足某种条件的最值问题。花时间掌握这个套路,远比背一道题有价值。

4. 动态规划视角:把问题看成状态转移

前缀和的解法已经能通过题目,但如果你接触过一些动态规划题,会想到另一种建模方式:从左到右扫描字符串,每个位置最终只能处于两种状态之一——当前还是“前半段”,即这个字符最终是'0';或者已经跨过分割线,进入最终全'1'的后半段。由于单调递增的限制,状态只能从“全0阶段”单向切换到“全1阶段”,不能回退。

这样就能定义 DP 数组:

  • dp[i][0]:处理完前i个字符,且第i个字符处于“全0阶段”的最小翻转次数
  • dp[i][1]:处理完前i个字符,且第i个字符处于“全1阶段”的最小翻转次数

转移方程也不复杂。当前字符如果是'0',处于状态 0 时不需要翻转,处于状态 1 时需要翻转为'1';当前字符如果是'1',处于状态 0 时需要翻转为'0',处于状态 1 时不需要翻转。同时,状态 1 可以从状态 0 转移过来,意味着分割线发生在当前位置之前。

写成代码:

def minFlipsMonoIncr(s: str) -> int: dp0 = 0 # 当前结尾认为还在全0阶段 dp1 = 0 # 当前结尾已经进入全1阶段 for ch in s: # 新状态基于旧状态推导,注意要同时更新,不能原地覆盖 new_dp0 = dp0 + (ch == '1') new_dp1 = min(dp0, dp1) + (ch == '0') dp0, dp1 = new_dp0, new_dp1 return min(dp0, dp1)

这个写法实际上不需要二维数组,两个滚动变量就够了。刚接触的时候很容易犯一个错:更新dp1时使用了已经更新过的dp0,导致状态在同一个字符内发生了“先分割再翻转”的串扰。解决办法就是像上面代码一样,先把两个新状态算出来,再统一赋值。

对比前缀和方案,DP 方案的好处在于它不需要预先知道分割线在哪,也不需要后缀数组,一次正向扫描就完成全部计算。从本质上讲,DP 是把“找分割点”隐式地编码进了状态转移里,每一步都在权衡“继续留在 0 段”还是“切换到 1 段”。这个思路对处理带约束的序列问题非常典型。

我还想多说一句:这道题还有个三维空间的变体,如果字符串里有第三种字符,比如'2',要求最终序列变成 0、1、2 三段式,那 DP 状态就变成三个:dp0, dp1, dp2,转移时状态只能从低向高切换,不可回退。思路完全一致,代码只是在每个字符上多一层状态枚举。如果你在面试中被延伸提问,冷静地把状态定义讲清楚,基本就能拿下。

5. 贪心扫描法:空间压缩到底的极致写法

前缀和需要 O(n) 的辅助空间,DP 已经能压到 O(1),但我还见过更“野”的写法:只用两个计数变量完成一次扫描。这个做法本质上是贪心思想的体现,理解它需要先想明白一个关键点——在扫描过程中,每当遇到一个'0',你有两个选择:把这个'0'翻成'1',让前面的'1'继续保留;或者假设分割线已经越过当前字符,把前面已经出现过的所有'1'全部翻成'0'。两种选择的代价分别是多少?前者代价是 1(翻转当前这个 0),后者代价是此前'1'的累计个数。

贪心策略就是每遇到一个'0',都在这两种代价里取较小值加到答案上。遇到'1'时则先不管,只需要把它计入ones_count,因为它是候选的需要翻转的对象。这个思路的代码极其简洁:

int minFlipsMonoIncr(string s) { int flips = 0; int ones_count = 0; for (char c : s) { if (c == '1') { ones_count++; } else { // c == '0' flips = min(flips + 1, ones_count); } } return flips; }

很多人第一次看到这段代码时会有疑问:flips到底在统计什么?它其实是“已经确定最优的、截至当前位置的翻转次数”的累计值,并在遇到'0'时动态修正。这里有一点需要特别提防:flips不是简单地在原有答案上加 1,而是取当前局部最优解。这个“取 min”的动作实际上是在做 DP 的等价变换,所以严格说它更像 DP 的空间压缩版,只是在代码形式上表现得像贪心。

实测下来,这段代码是我自己最常用的版本,因为它好写、好记、不依赖任何额外数组,一次遍历就能搞定。但我也发现一个现象:很多初学者第一次看到这段代码完全懵掉,不知道它在干什么。如果你也是这种情况,我建议你回到第3节的前缀和版本,那个版本的语义最直白,能够看到每个分割点的完整代价。理解了前缀和,再倒过来看这个扫描版本,才能体会到它每一步都在“隐式地维护候选分割点的最优代价”,而不是靠背下来的。

这个写法的另外一个好处是适用于流式处理的场景:如果字符串是以流的形式到达,无法预先知道全貌,前缀和和后缀和都不能用,但这个扫描版本每来一个字符就能实时给出当前的最优翻转次数。这一点在真实系统里特别有价值,比如处理实时到达的序列信号并保持单调性。

6. 三种解法对比与复杂度分析

把三种主要思路放在一起对比一下,方便你在不同场景下做选择。

解法时间复杂度空间复杂度代码量理解难度适用场景
暴力枚举O(n²)O(1)少低对拍验证、理解题意
前缀和O(n)O(n)中中面试最稳妥的答案
贪心扫描O(n)O(1)极少较高竞赛、流式处理、最简代码

从复杂度分析的角度,O(n²) 到 O(n) 的优化关键在于:把“每个分割点都重新统计”变成“所有分割点的统计结果一次性预处理”。这其实是很多字符串题从朴素到高效的通用优化路径。至于空间,前缀和的 O(n) 辅助空间在字符串长度达到百万量级时依然友好,但如果你在嵌入式环境或者内存极受限的场景下,能写成 O(1) 的扫描版显然更有优势。

我在实际面试中更倾向于先给前缀和版,因为它的每个变量、每一步都说得清楚,面试官容易跟上;然后在追问“能不能优化空间”时,再给出扫描版,并解释它和前缀和的等价性。这样能展示你既掌握了通用套路,也理解了一题多解的本质。

性能实测上,我用长度为 100 万的随机二进制串跑过:前缀和版本大约耗时 8 毫秒,扫描版本大约 4 毫秒。差距来自数组初始化和大数组的内存访问,但通常都不是瓶颈。真正影响这题是否 AC 的,不是常数优化,而是处理边界数据时会不会出错。

7. 边界条件、常见坑点与调试技巧

这题的通过率看起来不低,但实际写起来 glitch 特别多。我自己就踩过不少坑,挑几个典型的说说。

第一个坑是空字符串。字符串长度为 0 时,它天然满足单调递增,不需要任何翻转,答案应该是 0。前缀和版本如果有循环依赖空数组长度,容易出现数组越界。扫描版本直接返回 0,不会出问题,所以扫描版在边界处理上天然更安全。

第二个坑是字符串已经单调递增,比如"000111",答案必须是 0。有些思路不清晰的人在算前缀和时会把右边那些'1'也当成需要处理的对象,加进去之后答案就错了。记住,你的目标不是把所有字符变为同一个值,而是保持递增结构,所以已经合法的部分不需要动。

第三个坑是全相等的字符串,比如"111111"或"000000"。前者把所有'1'翻成'0'需要 6 次,但保持全'1'需要 0 次,正确答案是 0。后者同理。这暴露出一个常见误区:不要把分割线默认放在字符串正中间,分割线放在最左或最右都是合法的。

第四个坑存在于滚动 DP 版本,就是我在第4节提到的“新旧状态串扰”。如果你写成:

dp0 = dp0 + (ch == '1') dp1 = min(dp0, dp1) + (ch == '0')

那么dp1更新时用的dp0已经被当前字符污染了。这个 bug 非常隐蔽,因为大部分测试数据下结果依然正确,只有在某些特定序列下会差 1。我的排查经验是:如果本地对拍时发现少部分用例差 1,优先怀疑滚动更新的顺序。

还有一个高频问题:有些解法用count函数统计'1'的总数,然后从右往左扫描,遇到'1'就减计数,遇到'0'就更新答案。这个思路是前缀和的镜像版本,也正确。但我见过有人把方向搞反,导致统计的是右边的'1'而不是'0',答案自然是错的。这类方向性错误,最好的排查方法就是找几个手算例子,比如"010"、"110"、"001",把中间结果打印出来看一眼。

调试技巧方面,我强烈建议写一个随机数据生成器配合暴力对拍。生成几百个长度不超过 10 的随机二进制串,对比暴力枚举和优化版的答案。长度小才能手算验证,数据多才能覆盖边界。这个习惯在刷题时几乎救了我无数次。

8. 题目延伸与思维迁移

掌握了这道题之后,可以往几个方向扩展,让这道题的价值最大化。

第一个延伸是“最小翻转次数”问题家族的变体。比如 LeetCode 上有类似题目要求把字符串变成交替字符串(即"010101"或"101010"),解法就是枚举两种目标形态,统计不同的字符数,取最小值。这题和单调递增的区别在于,单调递增只有一种“形态模式”,只是分割点不确定;交替有两种“形态模式”,但分割点是固定的。理解这两种问题的差异,能帮你建立“形态模式 + 分割点/对齐方式”的思维框架。

第二个延伸是带权重的翻转。如果翻转'0'和翻转'1'的代价不同,比如把'1'变成'0'花费 3,把'0'变成'1'花费 1,那这个方法还能用吗?能,而且改动很小。前缀和版本里只需要把“统计个数”改成“统计代价”,扫描版本里也只需要把+1换成相应权重。这提醒我们,很多思路依赖的核心不是字符本身,而是代价函数。

第三个延伸是数据结构上的推广:多维数组的单调性修正。比如给你一个二维 0/1 矩阵,要求每一行都单调递增,最少翻转次数,这就可以拆成每一行独立求解再求和。如果还要求每列也单调递增,问题就变得复杂,可能要用到更高级的 DP 或图模型。从单串到多串再到矩阵,问题模型的复杂度逐步上升,但核心思想依然延续。

第四个延伸是实际工程中的序列修正场景。比如在数据清洗时,一组二元类别标签因为传感器噪声出现来回抖动(表现为101010这样),你想做最小程度的修正让它变成“先A后B”的稳定序列,这道题的解法就能直接用上。再比如信号处理里二值化后的信号存在毛刺,修正成单调形态用于后续状态机识别,也是同一个模型。我在实际项目里就做过类似的事:处理一组设备状态记录,要求状态不能从“运行”回到“待机”,需要找出最少的修正点。当时第一反应就是这道题的变形。

所以别看它只是字符串题,背后的“最小代价修正为单调”模型,应用面相当广。刷题如果能从一道题提炼出模型,再迁移到相关变体,收获就不仅仅是 AC 一道题这么简单了。

说一下我最后的体会:这道题我反复写过很多次,最开始也是暴力枚举起步,后来才逐步理解前缀和和扫描法之间的等价关系。每次重写都有新的理解,特别是从“分割点”视角切换到“状态机”视角那次,感觉整个题瞬间通透了。如果你刷这道题卡住,我建议可以先放下代码,拿出纸笔画一画分割线移动时两个计数的变化曲线,很多疑问会迎刃而解。

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

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

立即咨询