目录

题目描述

1151. 最少交换次数来组合所有的 1

题意分析

给一个只含 01 的数组 data,每次操作可以交换任意两个位置的元素,问最少交换多少次能让所有的 1 变成连续的一段。

「任意两个位置」这五个字是全题的钥匙,必须先读出来:它意味着交换不是相邻冒泡,不存在「把一个 1 从远处挪过来要付出距离代价」这回事。任何一个位置上的 0 和任何一个位置上的 1,一次操作就能对调。这直接排除了模拟移动过程、按距离累加代价的思路。

第二个关键观察藏在「最终形态」里:交换不会改变 1 的总数,所以答案状态一定是某个长度恰好等于 1 的总数的连续区间被 1 填满,区间外全是 0。这就把一个「求最少操作次数」的问题,转化成了「选一个定长区间」的问题——目标区间的长度是题目直接给定的,不是要枚举的自由变量。

第三步是把代价算清楚。选定某个目标区间后,区间里已经有的 1 不用动;区间里剩下的每个 0,都必须和区间外的某个 1 对调,一次交换正好消灭一个区间内的 0 并补进一个 1。所以代价就等于区间内 0 的个数,也等于「1 的总数减去区间内 1 的个数」。区间外剩余的 1 的个数与区间内 0 的个数必然相等,配对不会有富余或短缺,所以这个代价是可达的。

数组长度可以到 $10^5$ 量级,暗示答案要在线性时间内得到,不能对每个候选区间都重新数一遍。

边界:数组里一个 1 都没有,或只有一个 1,本身就已经是「连续的一段」,答案是 0;此时目标区间长度为 01,定长扫描的逻辑退化,最好单独挡掉。另外要注意本题的数组是线性的,首尾不相接。

解法:固定长度滑动窗口

核心思路

暴力做法是枚举所有长度为 ones 的区间起点,每个区间重新数一遍里面有多少个 1。区间有 $n - ones + 1$ 个,每个数 ones 次,最坏情况(1 占一半)是 $O(n^2)$,$10^5$ 的规模会超时。

瓶颈在于相邻两个区间被重复统计了。观察它们的关系:起点右移一格,区间只丢掉最左边一个元素、补进最右边一个元素,中间那 ones - 1 个元素完全没变。既然没变,就没有必要重新遍历。

于是维护一个变量 windowOnes 表示当前区间内 1 的个数,右移时执行 windowOnes += 新进来的元素windowOnes -= 出去的元素(因为元素只可能是 01,加减元素值就等于加减是否为 1 的判定,省掉一次比较)。每次移动是 $O(1)$。

循环不变量写清楚:在处理完下标 right 之后,windowOnes 恒等于闭区间 [right - ones + 1, right]1 的个数(当 right < ones - 1 时这个区间尚未成形,是从下标 0 开始的前缀,不参与答案统计)。代码里 right >= ones 才做减法,正是为了让窗口在成形之前先自然长满,成形之后再保持定长。

有了这个不变量,maxOnes 取所有已成形窗口中 windowOnes 的最大值,最终答案就是 ones - maxOnes。注意最大化窗口内的 1 与最小化交换次数是同一件事,因为 ones 是常量,二者只差一个减号。

解题步骤

  • 先统计全局 1 的个数 ones。为什么第一步就要它:它同时是「目标窗口的长度」和「答案公式里的被减数」,后面所有逻辑都依赖它,必须先扫一遍拿到。
  • 特判 ones <= 1 直接返回 0。为什么:零个或一个 1 天然已经聚在一起,无需交换;而且 ones == 0 时窗口长度为 0,定长滑窗失去意义,提前挡掉比让它退化更清晰。
  • 右边界 right0 扫到 n - 1,先做加法 windowOnes += data[right]。为什么可以直接加元素值:数组只含 01,元素值本身就是「是不是 1」的指示量。
  • 再做减法:right >= ones 时执行 windowOnes -= data[right - ones]。为什么条件是 right >= ones 而不是 right >= ones - 1:窗口长度为 ones,当前窗口是 [right - ones + 1, right],被挤出去的是下标 right - ones;只有 right >= ones 时这个下标才非负,写成 right >= ones - 1 会在第一次就访问 data[-1]
  • 窗口成形后更新最大值:right >= ones - 1maxOnes = max(maxOnes, windowOnes)。为什么要这个门槛:right < ones - 1 时窗口还没长到 ones 长度,它统计出的数字对应的是一个更短的区间,不是合法候选。
  • 返回 ones - maxOnes。为什么不是 maxOnesmaxOnes 是窗口里已经就位的 1,还缺的那部分才是要交换的次数。

data = [1, 0, 1, 0, 1] 走一遍。先统计得 ones = 3,所以目标是找一个长度为 3 的窗口,让窗口内 1 最多。

right = 0windowOnes += data[0] = 1,得 1right >= 3 不成立,不减;right >= 2 不成立,不更新。当前只是前缀 [1]

right = 1windowOnes += data[1] = 0,仍是 1。仍不减、不更新。前缀 [1, 0]

right = 2windowOnes += data[2] = 1,得 2right >= 3 不成立,不减。right >= 2 成立,窗口首次成形为 [0, 2] = [1, 0, 1],含 2 个 1maxOnes = 2

right = 3windowOnes += data[3] = 0,仍是 2right >= 3 成立,windowOnes -= data[0] = 1,得 1。窗口是 [1, 3] = [0, 1, 0],确实只有 1 个 1,与 windowOnes 一致。maxOnes 保持 2

right = 4windowOnes += data[4] = 1,得 2windowOnes -= data[1] = 0,仍是 2。窗口是 [2, 4] = [1, 0, 1],含 2 个 1,一致。maxOnes 仍是 2

循环结束,返回 3 - 2 = 1。验证一下:把下标 01 与下标 30 对调,数组变成 [0, 0, 1, 1, 1],三个 1 连成一段,确实一次交换就够。

反过来看错误写法的后果:若把减法条件写成 right >= ones - 1,在 right = 2 时就会去取 data[2 - 3] = data[-1],Java 直接抛数组越界;若把返回值写成 maxOnes,本例会输出 2,比正确答案还大。

代码实现

class Solution {
    public int minSwaps(int[] data) {
        int ones = 0;
        for (int num : data) {
            if (num == 1) {
                ones++;
            }
        }
        if (ones <= 1) {
            return 0;
        }

        int windowOnes = 0;
        int maxOnes = 0;
        for (int right = 0; right < data.length; right++) {
            windowOnes += data[right];
            if (right >= ones) {
                windowOnes -= data[right - ones];
            }

            // 窗口长度固定为 ones,窗口里 1 越多,需要换入的 1 越少。
            if (right >= ones - 1) {
                maxOnes = Math.max(maxOnes, windowOnes);
            }
        }

        return ones - maxOnes;
    }
}
func minSwaps(data []int) int {
    ones := 0
    for _, num := range data {
        if num == 1 {
            ones++
        }
    }
    if ones <= 1 {
        return 0
    }

    windowOnes := 0
    maxOnes := 0
    for right := 0; right < len(data); right++ {
        windowOnes += data[right]
        if right >= ones {
            windowOnes -= data[right-ones]
        }

        // 窗口长度固定为 ones,窗口里 1 越多,需要换入的 1 越少。
        if right >= ones-1 && windowOnes > maxOnes {
            maxOnes = windowOnes
        }
    }

    return ones - maxOnes
}

复杂度分析

  • 时间复杂度:$O(n)$。第一遍循环统计 ones 是 $O(n)$;第二遍循环里每个下标只被「加入窗口」一次、「移出窗口」至多一次,循环体内全是常数操作,没有嵌套遍历。
  • 空间复杂度:$O(1)$。只用了 oneswindowOnesmaxOnesright 这几个标量,没有开前缀和数组或哈希表;这也是滑动窗口相对「先求前缀和再枚举区间」的优势。

关键点总结

  • 目标窗口的长度不是要枚举的自由变量,而是由「交换不改变 1 的总数」这个守恒量直接算出来的,识别出守恒量是这类题的第一步。
  • 「最少交换次数」被等价改写成「最大化窗口内 1 的个数」,把最小化问题翻成最大化问题往往能让滑动窗口直接适用,这个转化值得在面试里明确讲出来。
  • 定长滑窗的模板是「先加右端、再按下标条件减左端、最后在窗口成形后统计」,三个动作的顺序和各自的边界条件互相咬合,改动其一必须同步检查其余两个。
  • 元素只有 01 时,sum += data[right] 就等于计数,省掉分支;这类小技巧在面试里可以顺口提一句,但要说明前提是值域受限。
  • 面试时务必先确认数组是线性还是环形:本题是线性的,而 2134 题是同一个模型的环形版,处理方式是把数组复制一遍或用取模下标,答错这一点等于答错整题。

易错点总结

  • 误以为要找「最长的连续 1 段」然后用总数减去它[1, 0, 1, 0, 1] 里最长连续 1 段长度是 1,会算出 3 - 1 = 2,而正确答案是 1;最优窗口未必是现成的连续段。
  • 返回 maxOnes 而不是 ones - maxOnes[1, 0, 1, 0, 1] 会输出 2,把「已经就位的 1」当成了「需要交换的次数」。
  • 减法条件写成 right >= ones - 1ones = 3right = 2 就去访问 data[-1],Java 抛 ArrayIndexOutOfBoundsException,Go 直接 panic。
  • 移出的下标写成 right - ones + 1:窗口实际长度缩成 ones - 1[1, 1, 0, 1, 1]ones = 4)的最优窗口 [1, 0, 1, 1] 有 3 个 1,缩短后的三长窗口最多只看到 2 个 1,答案从 1 变成 2
  • windowOnes += data[right] 误写成 windowOnes++:窗口里的 0 也被计入,[1, 0, 1, 0, 1] 的每个满窗口都被当成有 3 个 1,答案变成 0
  • 按环形数组处理首尾相接[1, 0, 0, 1] 若允许跨越首尾,会认为两个 1 已经相邻而返回 0,但本题数组是线性的,正确答案是 1
  • 试图模拟交换过程,把散落的 1 逐个往中心挪[1, 0, 1, 0, 1] 这样数会得到 2 次,而实际只需 1 次;题目允许交换任意两个位置,代价与距离无关。
  • 漏掉 ones == 0 的特判并在别的写法里踩坑:换成 while (right - left + 1 > ones) left++ 这类变长写法时,窗口长度上限为 0 会让左指针越过右指针,产生负长度或死循环。
  • maxOnes 初值设成 ones[1, 0, 1, 0, 1] 直接返回 0,任何输入都会输出 0,因为最大值永远不会被真实窗口刷新。
  • 统计 ones 时误用数组长度或误统计 0 的个数[0, 0, 1] 会把窗口长度定为 2,找的是错误尺寸的区间,答案随之偏离。

相似题目

题目 难度 考察点
643. 子数组最大平均数 I 简单 定长窗口求和,长度由题目直接给出,无需先推导
1052. 爱生气的书店老板 中等 定长窗口最大化「额外收益」,窗口外的固定收益要单独累加
1456. 定长子串中元音的最大数目 中等 定长窗口计数,统计目标直接就是答案,不必再做减法转换
1423. 可获得的最大点数 中等 取两端等价于挖掉中间的定长窗口,需要先做一次问题反转
485. 最大连续 1 的个数 简单 不允许任何修改,只数现成的连续段,是本题的退化基线
487. 最大连续1的个数 II 中等 允许翻转一个 0,窗口长度不再固定,改成变长窗口
1004. 最大连续1的个数 III 中等 允许翻转 k0,约束从「长度固定」变成「窗口内 0 数受限」
1493. 删掉一个元素以后全为 1 的最长子数组 中等 必须删掉一个元素,答案要在窗口长度上再减 1
424. 替换后的最长重复字符 中等 值域从二值扩展到 26 个字母,窗口内要维护最高频字符的出现次数
1156. 单字符重复子串的最大长度 中等 同样靠交换聚合,但只允许交换一次,且答案受该字符总数封顶