目录

题目描述

LCR 092. 将字符串翻转到单调递增

题意分析

给一个只含 '0''1' 的字符串 s,每次操作可以把某一位翻转(0 变 1 或 1 变 0),求把 s 变成单调递增所需的最少翻转次数。

先把「单调递增」翻译成可操作的形式。对二进制串而言,单调递增等价于不存在某个 1 排在某个 0 前面,也就是整串形如「若干个 0 后面跟着若干个 1」。注意两端是允许为空的:全 0 合法(零个 1),全 1 合法(零个 0),空的分界也合法。

这个刻画立刻给出问题的结构:任何一个合法的目标串都由一个「分界位置」唯一确定——分界左边全填 0,右边全填 1。长度为 n 的串有 n + 1 个可能的分界(从「全是 1」到「全是 0」)。于是原问题变成:在这 n + 1 个候选目标里,找一个与 s 差异最小的。

差异数也很好算:把某个分界记作 i,表示前 i 位要变成 0、后 n - i 位要变成 1,那么代价 = i 位中 1 的个数(这些要翻成 0)+ n - i 位中 0 的个数(这些要翻成 1)。至此题目已经完全数值化,不再有任何隐含条件。

约束 1 ≤ s.length ≤ 10^5,这个规模明确排除了对每个分界都重新扫一遍的 $O(n^2)$ 做法,必须让代价随分界移动而增量更新。字符只有 0 和 1 两种,答案上界是 n(最多每位都翻一次),int 完全够用。

边界:n = 1 时无论 "0" 还是 "1" 都已单调,答案 0;全 0 或全 1 的串答案也是 0,这要求分界能取到最两端;答案可能为 0,初始值不能设成 1 或负数。

解法:动态规划递推

核心思路

朴素做法是枚举全部 n + 1 个分界,对每个分界扫一遍字符串数出两侧的错位字符,总量 $O(n^2)$,n = 10^5 时上百亿次操作,必然超时。瓶颈在于相邻两个分界的代价其实只差一个字符,却每次都从头重算。

关键观察是:分界从 i 右移到 i + 1 时,只有第 i 个字符从「属于右半段」变成了「属于左半段」,其余字符的归属完全不变。所以只要维护两个计数器就够了:

left0 表示左半段(前缀)里 0 的个数right0 表示右半段(后缀)里 0 的个数。有了它们,分界 i 的代价可以写成 i - left0 + right0——前缀长度 i 减去前缀里 0 的个数就是前缀里 1 的个数(要翻成 0 的),加上后缀里 0 的个数(要翻成 1 的)。

要维持的不变量是:处理到分界 i 时,left0 恰是 s[0..i-1] 中 0 的个数,right0 恰是 s[i..n-1] 中 0 的个数,两者之和恒等于全串 0 的总数。每右移一格,若移过去的字符是 0,则 right0 减一、left0 加一;若是 1,两者都不变。代码里用 x ^ 1 把字符值转成「是否为 0」的 0/1 量,一行完成这个条件更新,避免写分支。

初始化对应分界 i = 0:前缀为空,left0 = 0right0 等于全串 0 的总数,代价就是 right0(把所有 0 翻成 1,即目标为全 1 串)。另一个端点 i = n 的代价是 n - right0(把所有 1 翻成 0,即目标为全 0 串)。答案初值取这两者的较小值,随后循环再逐个比较中间的分界。

解题步骤

  • 先统计全串 0 的总数:一趟循环把 right0 累加出来。这一步既是 i = 0 时的代价,也是后续增量更新的起点。
  • 用两个端点初始化答案answer = min(right0, n - right0),分别对应「目标全 1」和「目标全 0」。先把两端放进候选,可以让主循环专心处理中间分界,也保证了全 0 / 全 1 输入能得到 0。
  • 主循环让分界从 1 扫到 n:第 i 轮处理的是「刚刚被划入前缀的那个字符」s[i-1]。循环变量取分界而不是字符下标,是为了让代价公式 i - left0 + right0 里的 i 直接就是前缀长度。
  • 无分支地更新两个计数器:把字符转成 x'0' 为 0,'1' 为 1),则 x ^ 1 就是「该字符是不是 0」。执行 right0 -= x ^ 1left0 += x ^ 1,字符是 1 时两句都加减 0,等于什么也没做。用异或代替 if 只是写法上的简洁,语义与分支判断完全一致。
  • 更新答案answer = min(answer, i - left0 + right0)i - left0 是前缀中 1 的个数,right0 是后缀中 0 的个数,两者之和就是把串改造成「前 i 位全 0、其余全 1」的代价。

s = "00110" 走一遍,n = 5

预处理:全串有 3 个 0(下标 0、1、4),所以 right0 = 3left0 = 0。答案初值 min(3, 5 - 3) = 2,对应「全变成 0 要翻两个 1」这个方案。

i = 1,划入的字符是 s[0] = '0'right0 变 2,left0 变 1。代价 1 - 1 + 2 = 2(前缀 "0" 里没有 1,后缀 "0110" 里有两个 0)。答案仍为 2。

i = 2,划入 s[1] = '0'right0 变 1,left0 变 2。代价 2 - 2 + 1 = 1(前缀 "00" 无需改动,后缀 "110" 只有末尾一个 0 要翻成 1)。答案更新为 1

i = 3,划入 s[2] = '1':两个计数器都不变,仍是 right0 = 1left0 = 2。代价 3 - 2 + 1 = 2(前缀 "001" 里的那个 1 要翻成 0,后缀 "10" 里的 0 要翻成 1)。答案保持 1。

i = 4,划入 s[3] = '1':计数器不变。代价 4 - 2 + 1 = 3。答案保持 1。

i = 5,划入 s[4] = '0'right0 变 0,left0 变 3。代价 5 - 3 + 0 = 2(全变成 0,要翻掉两个 1)。答案保持 1。

返回 1,对应把末尾的 0 翻成 1 得到 "00111",确实单调递增且只改了一位。若忘记初始化时考虑 i = 0 这个端点,输入 "111" 会得到 min 从主循环第一轮开始比较,虽然本例仍能得到 0,但输入 "11" 时若把答案初值写成 n 之外的错误值就会偏大。

代码实现

class Solution {
    public int minFlipsMonoIncr(String s) {
        int n = s.length();
        // left0:前缀中 0 的个数;right0:后缀中 0 的个数。
        int left0 = 0, right0 = 0;
        for (int i = 0; i < n; ++i) {
            if (s.charAt(i) == '0') {
                ++right0;
            }
        }
        // 两个端点:目标全 1 的代价是 right0,目标全 0 的代价是 n - right0。
        int answer = Math.min(right0, n - right0);
        // i 是分界:前 i 位变 0,其余变 1。
        for (int i = 1; i <= n; ++i) {
            int x = s.charAt(i - 1) == '0' ? 0 : 1;
            // x ^ 1 即「该字符是不是 0」,字符为 1 时两句都不产生变化。
            right0 -= x ^ 1;
            left0 += x ^ 1;
            // i - left0 是前缀中 1 的个数,right0 是后缀中 0 的个数。
            answer = Math.min(answer, i - left0 + right0);
        }
        return answer;
    }
}
func minFlipsMonoIncr(s string) int {
    n := len(s)
    // left0:前缀中 0 的个数;right0:后缀中 0 的个数。
    left0, right0 := 0, 0
    for _, c := range s {
        if c == '0' {
            right0++
        }
    }
    // 两个端点:目标全 1 的代价是 right0,目标全 0 的代价是 n - right0。
    answer := min(right0, n-right0)
    for i, c := range s {
        x := 0
        if c == '1' {
            x = 1
        }
        // x ^ 1 即「该字符是不是 0」,字符为 1 时两句都不产生变化。
        right0 -= x ^ 1
        left0 += x ^ 1
        // 分界为 i+1:前 i+1 位变 0,其余变 1。
        answer = min(answer, i+1-left0+right0)
    }
    return answer
}

复杂度分析

  • 时间复杂度:$O(n)$,一趟统计全串 0 的个数,再一趟枚举分界并增量更新,共两次线性扫描,每格只做常数次加减与比较。
  • 空间复杂度:$O(1)$,只用了 left0right0answer 等几个整数计数器,不随字符串长度增长。原串只读不改,无需额外副本。

关键点总结

  • 把「求最少修改次数」转化成「枚举所有合法目标,取差异最小」是这类题的通用起手式。前提是合法目标能被一个简单参数完整刻画——本题是分界位置,参数只有一维,于是问题立刻变成一维枚举。
  • 相邻候选之间只差一个元素时,用增量维护把 $O(n^2)$ 降到 $O(n)$。这就是前缀和 / 滑动统计的本质,凡是「枚举分割点并计算两侧统计量」的题都该条件反射地想到它。
  • 两个端点分界必须被纳入候选,它们对应「全 1」和「全 0」两个目标。忘记它们会在全 0 或全 1 的输入上给出非零答案。
  • i - left0 就是前缀中 1 的个数,这类「用长度减去某类计数得到另一类计数」的技巧,可以省掉一半的计数器,值得记住。
  • 面试视角:这道题也可以写成两个状态的线性 DP(当前位最终是 0 或 1 分别的最小代价),两种写法都是标准答案。被问到时能同时说出「分界枚举 + 前缀统计」和「二状态 DP」,并指出两者复杂度相同、前者更容易解释,是很好的加分点。

易错点总结

  • 只枚举 1..n-1 的中间分界s = "111" 会漏掉「保持全 1」这个零代价方案,输出 1 而不是 0。
  • 答案初值设成 0s = "10" 会直接返回 0,而正确答案是 1,因为最小值永远不会被更新上去。
  • 代价写成 left0 + right0s = "00110"i = 2 处会算出 2 + 1 = 3,把前缀里的 0(本来就不用改)也算进了代价。
  • 代价写成 i - left0 + (n - i - right0):这算的是后缀中 1 的个数,s = "00110" 会在全 0 目标处得到 0,等于把「后缀要变成 1」写反成了「后缀要变成 0」。
  • 对每个分界重新扫一遍统计两侧计数s 为 10 万个字符时是 $10^{10}$ 量级操作,直接超时。
  • 忘记先统计全串 0 的总数就进入主循环right0 从 0 开始递减会变成负数,s = "00110" 输出负值。
  • 更新顺序写成先 left0 += x ^ 1 再用 i - left0,却把 right0 的减法放到取答案之后s = "010" 时两个计数器不同步,代价算出的是不存在的中间态。
  • s.charAt(i) 而不是 s.charAt(i - 1)i = n 时越界抛异常;即使不越界,整个统计也会错位一位。
  • 把单调递增误读成严格递增s = "00" 会被认为需要修改,而全 0 本就合法,答案应为 0。
  • 误以为必须让 0 和 1 各占一半或至少各有一个s = "1" 的正确答案是 0(视作零个 0 加一个 1),强行要求两端非空会输出 1。

相似题目

题目 难度 考察点
926. 将字符串翻转到单调递增 中等 与本题完全同题,代码可原样提交
738. 单调递增的数字 中等 同样以单调性为目标,但字符集是 0~9 且只能减小,改用贪心从高位借位
801. 使序列递增的最小交换次数 困难 同为「最少修改使序列有序」,但每位的选择是换或不换的二状态 DP
995. K 连续位的最小翻转次数 困难 也是 0/1 串的最少翻转,但每次必须翻固定长度的一段,需差分记录影响
845. 数组中的最长山脉 中等 同样枚举分界点并结合两侧预处理,只是两侧要求的是递增与递减长度
42. 接雨水 困难 前后缀统计的经典题,可对照体会「左右两侧信息同时维护」的通用手法
1109. 航班预订统计 中等 差分数组的入门题,是「增量更新代替重复求和」这一思想的另一种形态
198. 打家劫舍 中等 若改写成二状态线性 DP,本题的转移结构与它高度相似,可对照理解