目录

题目描述

474. 一和零

题意分析

给一组只含 '0''1' 的字符串 strs,再给两个上限 mn,要从 strs 里挑出一个子集,使得子集内所有字符串加起来用掉的 '0' 不超过 m 个、'1' 不超过 n 个,问这个子集最多能有几个字符串。注意问的是个数,不是长度,也不是要求恰好用完。

两个信号很直白。第一,每个字符串要么整个选、要么整个不选,不能拆开用一半,这是典型的「每个物品选或不选」结构。第二,约束不是一个而是两个,且两个约束互相独立、都必须同时满足——一个字符串同时消耗两种不同的资源。这两点合起来决定了状态里必须同时记住「还剩多少个 0 的额度」和「还剩多少个 1 的额度」,一维状态是不够的。

还有一点值得注意:题目只关心 0 和 1 各有多少个,字符串内部的顺序完全无关。所以每个字符串可以提前压缩成一对数字 (zeros, ones),原始字符串本身在决策阶段就没用了。

数据范围里 mn 都不超过 100,strs 长度不超过 600,这个规模允许对每个字符串把 $101 \times 101$ 的状态表整体刷一遍。

边界:strs 可能为空,答案是 0;mn 可能是 0,此时只能选那些完全不含对应字符的字符串;某个字符串本身就超额(比如 "111"n = 2),它永远进不了任何合法子集。

解法:二维 0-1 背包

核心思路

暴力做法是枚举 strs 的全部子集,逐个统计 0 和 1 的总数再取合法子集的最大规模,复杂度 $O(2^L \cdot L)$,L = 600 时完全不可能。瓶颈在于大量子集其实是「等价」的:两个不同子集,只要用掉的 0 数和 1 数相同,它们对后续决策的影响就完全一样,剩下唯一有意义的区别就是各自已经选了几个字符串。既然如此,就不该按子集枚举,而该按「用掉多少 0、用掉多少 1」来归并。

于是定义状态:dp[i][j] 表示在只允许使用不超过 i'0' 和不超过 j'1' 的前提下,从已经考虑过的那些字符串里最多能选出的字符串个数。「已经考虑过」这个限定很关键——dp 表是随着外层遍历字符串而逐轮演进的,第 k 轮结束时表里存的是「只用前 k 个字符串」的答案。

转移就是对当前字符串的两种决策取较大者:不选它,dp[i][j] 保持不变;选它,则要先腾出 zeros 个 0 和 ones 个 1 的额度,剩下的额度交给前面的字符串去填,即 dp[i - zeros][j - ones] + 1。写成 dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1)

状态定义里的「不超过」而非「恰好」是个刻意选择,它带来两个好处:dp 全部初始化为 0 就天然合法(一个字符串都不选永远可行),并且答案直接取 dp[m][n] 而不需要在末行末列里再扫一遍最大值。

最后是滚动数组的正确性。这里把「第 k 个字符串」这一维压掉了,转移读的 dp[i - zeros][j - ones] 必须是上一轮的值(还没考虑当前字符串),才符合「每个字符串只能选一次」。由于 zerosones 都非负,被读的格子下标不大于被写的格子,所以只要两层容量循环都从大往小走,被读时它就还没在本轮被覆盖过。这就是倒序枚举的全部理由,也是 0-1 背包与完全背包在代码上唯一的差别。

解题步骤

  • (m + 1) × (n + 1) 的二维数组 dp,全部初始化为 0。多出来的那一行一列对应容量为 0 的情形,有了它转移时就不必对 i - zeros == 0 之类的下标做特判。初值 0 表示「一个都不选」,这是任何容量下都成立的合法解,所以它既是合法初值也是下界。
  • 外层遍历每个字符串:外层必须是物品维、内层才是容量维。反过来写会让同一个容量格在不同物品间来回跳,破坏「逐个物品扩展」的语义。
  • 统计当前字符串的 zerosones:把字符串压缩成两个数字,后续转移只依赖这两个量。统计放在容量双循环之外,避免在 $O(mn)$ 次转移里重复扫描字符串。
  • im 递减到 zerosjn 递减到 ones:递减是为了保证读到的是上一轮的值;下界取 zeros / ones 是因为容量不足时这个字符串根本放不下,dp[i][j] 保持上一轮的值即可,顺便也避免了负下标。
  • 转移 dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1):等号右边的 dp[i][j] 代表「不选」,另一项代表「选」,取大者。这里用 max 而不是直接赋值,因为「不选」有时才是更优解。
  • 返回 dp[m][n]:状态定义是「不超过」,所以容量最大的那一格就是全局最优,不需要再遍历取最大值。

strs = ["10", "0001", "1", "0"]m = 3n = 2 走一遍(答案应为 3,例如选 "10""1""0")。为了看得清楚,下面只列出每轮结束后发生变化的格子,格子写作 dp[i][j]

初始:整张表全为 0。

第一轮,"10"(zeros, ones) = (1, 1)i 从 3 降到 1,j 从 2 降到 1,每格由 dp[i-1][j-1] + 1 = 0 + 1 = 1 更新。结果是所有满足 i ≥ 1 且 j ≥ 1 的格子都变成 1,即 dp[1][1] = dp[1][2] = dp[2][1] = dp[2][2] = dp[3][1] = dp[3][2] = 1。此时 dp[3][2] = 1

第二轮,"0001"(zeros, ones) = (3, 1)。只有 i = 3 满足条件,j 取 2、1。dp[3][2] = max(1, dp[0][1] + 1) = max(1, 1) = 1dp[3][1] = max(1, dp[0][0] + 1) = 1。这一轮实际没有提升——因为选了它就用光了三个 0,剩下的额度装不下第二个字符串,和只选 "10" 打平。

第三轮,"1"(zeros, ones) = (0, 1)i 从 3 降到 0,j 从 2 降到 1。关键格 dp[3][2] = max(1, dp[3][1] + 1);注意 j = 2 先于 j = 1 被处理,所以此刻 dp[3][1] 仍是上一轮的 1,得 dp[3][2] = 2(对应选 "10""1")。随后 dp[3][1] = max(1, dp[3][0] + 1) = 1。倒序的作用在这里显形:若正序处理,dp[3][1] 会先被更新,dp[3][2] 就可能读到本轮的值,等于把 "1" 用了两次。

补充一点第三轮的细节:i = 2 时同样有 dp[2][2] = max(1, dp[2][1] + 1) = 2,对应用 2 个 0 额度、2 个 1 额度选出 "10""1"

第四轮,"0"(zeros, ones) = (1, 0)。关键格 dp[3][2] = max(2, dp[2][2] + 1) = max(2, 3) = 3,对应选 "10""1""0",恰好用掉 2 个 0 和 2 个 1,在 m = 3n = 2 之内。

遍历结束,返回 dp[3][2] = 3

代码实现

class Solution {
    // dp[i][j] 表示在最多使用 i 个 0、j 个 1 时,最多能选择多少个字符串。
    public int findMaxForm(String[] strs, int m, int n) {
        int[][] dp = new int[m + 1][n + 1];

        for (String s : strs) {
            int zeros = 0;
            int ones = 0;
            for (char c : s.toCharArray()) {
                if (c == '0') {
                    zeros++;
                } else {
                    ones++;
                }
            }
            for (int i = m; i >= zeros; i--) {
                for (int j = n; j >= ones; j--) {
                    dp[i][j] = Math.max(dp[i][j], dp[i - zeros][j - ones] + 1);
                }
            }
        }

        return dp[m][n];
    }
}
func findMaxForm(strs []string, m int, n int) int {
    // dp[i][j] 表示在最多使用 i 个 0、j 个 1 时,最多能选择多少个字符串。
    dp := make([][]int, m+1)
    for i := range dp {
        dp[i] = make([]int, n+1)
    }

    for _, s := range strs {
        zeros := 0
        ones := 0
        for i := 0; i < len(s); i++ {
            if s[i] == '0' {
                zeros++
            } else {
                ones++
            }
        }
        for i := m; i >= zeros; i-- {
            for j := n; j >= ones; j-- {
                val := dp[i-zeros][j-ones] + 1
                if val > dp[i][j] {
                    dp[i][j] = val
                }
            }
        }
    }

    return dp[m][n]
}

复杂度分析

  • 时间复杂度:$O(T + L \cdot m \cdot n)$,其中 L 是字符串个数、T 是所有字符串的总长度。凭什么:统计每个字符串的 0、1 个数合计扫过全部字符,贡献 $O(T)$;随后每个字符串都要把 $(m+1) \times (n+1)$ 的状态表刷一遍,每格是常数次比较赋值,贡献 $O(L \cdot m \cdot n)$。
  • 空间复杂度:$O(mn)$。凭什么:物品维已被滚动数组压掉,只保留一张 $(m+1) \times (n+1)$ 的表,与字符串条数无关;zerosones 等是常数级变量。

关键点总结

  • 多维背包的本质是「一个物品同时消耗多种资源」,状态维数等于资源种类数,转移式的形状和一维背包完全一样,只是下标多减了一项——认出这一点,879 这类三维变体也能直接推广。
  • 状态定义写「不超过」而不是「恰好」,能让初值全 0 天然合法、答案直接落在 dp[m][n],省掉初始化的 -∞ 和末尾的取最值扫描。面试时主动说明这个取舍是加分点。
  • 0-1 背包压维后必须倒序枚举容量,理由是「保证读到的是上一轮的值」;完全背包正序,理由是「就是要读本轮的值以允许重复选」。把理由讲成这一句话,两类题就不会记混。
  • 与顺序无关的输入应尽早压缩成决策真正需要的特征(这里是把字符串压成 (zeros, ones)),既降复杂度也让转移式更干净。
  • 容量循环的下界取 zeros / ones 而非 0,既是性能优化也是天然的越界防护,比在循环体里写 if 判断更简洁。

易错点总结

  • 两层容量循环写成正序递增strs = ["1"]m = 0n = 3dp[1] 更新后 dp[2] 会读到本轮的 dp[1],把同一个 "1" 反复计入,返回 3 而不是 1。
  • 只把外层 i 写成倒序、内层 j 写成正序strs = ["01"]m = 3n = 3dp[3][3] 会经由本轮已更新的 dp[2][2] 累加,同一字符串被用两次,答案偏大。
  • 物品维和容量维的循环嵌套写反(外层容量、内层物品):strs = ["10","0001","1","0"] 这类用例下,同一个容量格会在一次内层循环里被所有物品连续更新,等价于允许重复选取,结果偏大。
  • dp 初始化成 -1Integer.MIN_VALUE:改成「恰好装满」语义却没同步改返回值,strs = ["10"]m = 3n = 3dp[3][3] 仍是负数,返回负值。
  • 忘记 Math.max,直接写 dp[i][j] = dp[i - zeros][j - ones] + 1strs = ["0","1"]m = 1n = 1 时第二轮会把已经算好的更优解覆盖成更差的值,返回 1 而不是 2。
  • 容量循环下界写成 0 而不做越界保护strs = ["0001"]m = 3i = 0 会访问 dp[-3][...],Java 抛数组越界异常,Go 触发 panic。
  • zerosones 的统计放进容量双循环内部:功能正确但每轮多做 $O(mn)$ 次字符串扫描,L = 600m = n = 100 时直接超时。
  • 误以为要在整张表里取最大值返回strs = ["0"]m = 3n = 3 时确实无害,但一旦有人把状态改成「恰好」语义又保留 dp[m][n] 的返回方式,m 用不满的用例就会返回 0。
  • 统计时用 s.length() - zerosones 却没考虑非 0/1 字符:本题输入保证只有 0 和 1,但把这个假设写死在代码里而不显式统计,迁移到含其他字符的变体时会静默给出错误的容量消耗。

相似题目

题目 难度 考察点
416. 分割等和子集 中等 单一容量维、状态是布尔可行性而非最大个数,是本题降到一维的原型
494. 目标和 中等 先把正负号问题转化成子集和,状态存的是方案计数,转移用加法而非取最值
1049. 最后一块石头的重量 II 中等 把两堆石头相消转化为「尽量接近总和一半」的一维背包,目标是最小化剩余差值
879. 盈利计划 困难 同样是双约束,但利润维是「至少达到」而非「不超过」,下标需要截断到 0
518. 零钱兑换 II 中等 完全背包,物品可重复选,容量循环改为正序,正好和本题的倒序形成对照
322. 零钱兑换 中等 完全背包求最少个数,初值要设成不可达的大数,与本题初值全 0 的取舍相反
LCR 101. 分割等和子集 简单 与 416 同题,可直接套用一维可行性背包
LCR 102. 目标和 中等 与 494 同题,用来练习计数型背包的初值 dp[0] = 1