一文详解"二进制枚举" : 精选题单,多语言多解法,深度总结

1,722 阅读14分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

二进制枚举

何为「二进制枚举」,与普通的枚举做法相比有何区别?

本质上,二进制枚举的作用不是使得枚举空间收缩,而是方便通过「位运算」来实现某些操作,而这些操作如果不使用「二进制枚举」来做的话,通常要搭配一些特殊的数据结构来实现。

今天我们将通过 55 道与二进制枚举相关的题目来学习该算法。

784. 字母大小写全排列(DFS + 二进制枚举)

基本题意

给定一个字符串 s ,通过将字符串 s 中的每个字母转变大小写,我们可以获得一个新的字符串。

返回 所有可能得到的字符串集合 。以 任意顺序 返回输出。

示例 1:

输入:s = "a1b2"

输出:["a1b2", "a1B2", "A1b2", "A1B2"]

示例 2:

输入: s = "3z4"

输出: ["3z4","3Z4"]

提示:

  • 1<=s.length<=121 <= s.length <= 12
  • s 由小写英文字母、大写英文字母和数字组成
DFS(先看看不使用二进制枚举的做法)

数据范围为 1212,同时要我们求所有具体方案,容易想到使用 DFS 进行「爆搜」。

我们可以从前往后考虑每个 s[i]s[i],根据 s[i]s[i] 是否为字母进行分情况讨论:

  • s[i]s[i] 不是字母,直接保留
  • s[i]s[i] 是字母,则有「保留原字母」或「进行大小写转换」两种决策

设计 DFS 函数为 void dfs(int idx, int n, String cur):其中 nn 固定为具体方案的长度(即原字符串长度),而 idxcur 分别为当前处理到哪一个 s[idx]s[idx],而 cur 则是当前具体方案。

根据上述分析可知,当 s[idx]s[idx] 不为字母,将其直接追加到 cur 上,并决策下一个位置 idx+1idx + 1;而当 s[idx]s[idx] 为字母时,我们可以选择将 s[idx]s[idx] 追加到 cur 上(保留原字母)或是将 s[idx]s[idx] 进行翻转后再追加到 cur 上(进行大小写转换)。

最后当我们满足 idx = n 时,说明已经对原字符串的每一位置决策完成,将当前具体方案 cur 加入答案。

一些细节:我们可以通过与 32 异或来进行大小写转换

Java 代码:

class Solution {
    char[] cs;
    List<String> ans = new ArrayList<>();
    public List<String> letterCasePermutation(String s) {
        cs = s.toCharArray();
        dfs(0, s.length(), new char[s.length()]);
        return ans;
    }
    void dfs(int idx, int n, char[] cur) {
        if (idx == n) {
            ans.add(String.valueOf(cur));
            return ;
        }
        cur[idx] = cs[idx];
        dfs(idx + 1, n, cur);
        if (Character.isLetter(cs[idx])) {
            cur[idx] = (char) (cs[idx] ^ 32);
            dfs(idx + 1, n, cur);
        }
    }
}

TypeScript 代码:

function letterCasePermutation(s: string): string[] {
    const ans = new Array<string>()
    function dfs(idx: number, n: number, cur: string): void {
        if (idx == n) {
            ans.push(cur)
            return 
        }
        dfs(idx + 1, n, cur + s[idx])
        if ((s[idx] >= 'a' && s[idx] <= 'z') || (s[idx] >= 'A' && s[idx] <= 'Z')) {
            dfs(idx + 1, n, cur + String.fromCharCode(s.charCodeAt(idx) ^ 32))
        }
    }
    dfs(0, s.length, "")
    return ans
}

Python 代码:

class Solution:
    def letterCasePermutation(self, s: str) -> List[str]:
        ans = []
        def dfs(idx, n, cur):
            if idx == n:
                ans.append(cur)
                return 
            dfs(idx + 1, n, cur + s[idx])
            if 'a' <= s[idx] <= 'z' or 'A' <= s[idx] <= 'Z':
                dfs(idx + 1, n, cur + s[idx].swapcase())
        dfs(0, len(s), '')
        return ans
  • 时间复杂度:最坏情况下原串 s 的每一位均为字母(均有保留和转换两种决策),此时具体方案数量共 2n2^n 种;同时每一种具体方案我们都需要用与原串长度相同的复杂度来进行构造。复杂度为 O(n×2n)O(n \times 2^n)
  • 空间复杂度:O(n×2n)O(n \times 2^n)
【重点】二进制枚举解法

根据解法一可知,具体方案的个数与字符串 s1 存在的字母个数相关,当 s1 存在 m 个字母时,由于每个字母存在两种决策,总的方案数个数为 2m2^m 个。

因此可以使用「二进制枚举」的方式来做:使用变量 s 代表字符串 s1 的字母翻转状态,s 的取值范围为 [0, 1 << m)。若 s 的第 jj 位为 0 代表在 s1 中第 jj 个字母不进行翻转,而当为 1 时则代表翻转。

每一个状态 s 对应了一个具体方案,通过枚举所有翻转状态 s,可构造出所有具体方案。

Java 代码:

class Solution {
    public List<String> letterCasePermutation(String str) {
        List<String> ans = new ArrayList<String>();
        int n = str.length(), m = 0;
        for (int i = 0; i < n; i++) m += Character.isLetter(str.charAt(i)) ? 1 : 0;
        for (int s = 0; s < (1 << m); s++) {
            char[] cs = str.toCharArray();
            for (int i = 0, j = 0; i < n; i++) {
                if (!Character.isLetter(cs[i])) continue;
                cs[i] = ((s >> j) & 1) == 1 ? (char) (cs[i] ^ 32) : cs[i];
                j++;
            }
            ans.add(String.valueOf(cs));
        }
        return ans;
    }
}

TypeScript 代码:

function letterCasePermutation(str: string): string[] {
    function isLetter(ch: string): boolean {
        return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
    }
    const ans = new Array<string>()
    let n = str.length, m = 0
    for (let i = 0; i < n; i++) m += isLetter(str[i]) ? 1 : 0
    for (let s = 0; s < (1 << m); s++) {
        let cur = ''
        for (let i = 0, j = 0; i < n; i++) {
            if (!isLetter(str[i]) || ((s >> j) & 1) == 0) cur += str[i]
            else cur += String.fromCharCode(str.charCodeAt(i) ^ 32)
            if (isLetter(str[i])) j++
        }
        ans.push(cur)
    }
    return ans
}

Python 代码:

class Solution:
    def letterCasePermutation(self, s1: str) -> List[str]:
        def isLetter(ch):
            return 'a' <= ch <= 'z' or 'A' <= ch <= 'Z'
        ans = []
        n, m = len(s1), len([ch for ch in s1 if isLetter(ch)])
        for s in range(1 << m):
            cur = ''
            j = 0
            for i in range(n):
                if not isLetter(s1[i]) or not ((s >> j) & 1):
                    cur += s1[i]
                else:
                    cur += s1[i].swapcase()
                if isLetter(s1[i]):
                    j += 1
            ans.append(cur)
        return ans
  • 时间复杂度:最坏情况下原串 s 的每一位均为字母(均有保留和转换两种决策),此时具体方案数量共 2n2^n 种;同时每一种具体方案我们都需要用与原串长度相同的复杂度来进行构造。复杂度为 O(n×2n)O(n \times 2^n)
  • 空间复杂度:O(n×2n)O(n \times 2^n)

1239. 串联字符串的最大长度(剪枝 DFS + 二进制枚举)

基本题意

给定一个字符串数组 arr,字符串 s 是将 arr 某一子序列字符串连接所得的字符串,如果 s 中的每一个字符都只出现过一次,那么它就是一个可行解。

请返回所有可行解 s 中最长长度。

示例 1:

输入:arr = ["un","iq","ue"]

输出:4

解释:所有可能的串联组合是 "","un","iq","ue","uniq""ique",最大长度为 4

示例 2:

输入:arr = ["cha","r","act","ers"]

输出:6

解释:可能的解答有 "chaers""acters"

示例 3:

输入:arr = ["abcdefghijklmnopqrstuvwxyz"]

输出:26

提示:

  • 1<=arr.length<=161 <= arr.length <= 16
  • 1<=arr[i].length<=261 <= arr[i].length <= 26
  • arr[i] 中只含有小写英文字母
基本分析

根据题意,可以将本题看做一类特殊的「数独问题」:在给定的 arr 字符数组中选择,尽可能多的覆盖一个 1×261 \times 26 的矩阵。

对于此类「精确覆盖」问题,换个角度也可以看做「组合问题」。

通常有几种做法:DFS、剪枝 DFS、二进制枚举、模拟退火、DLX

其中一头一尾解法过于简单和困难,有兴趣的同学自行了解与实现。

剪枝 DFS(还是先看看不使用二进制枚举的做法)

根据题意,可以有如下的剪枝策略:

  1. 预处理掉「本身具有重复字符」的无效字符串,并去重;
  2. 由于只关心某个字符是否出现,而不关心某个字符在原字符串的位置,因此可以将字符串使用 int 进行表示;
  3. 由于使用 int 进行表示,因而可以使用「位运算」来判断某个字符是否可以被追加到当前状态中;
  4. DFS 过程中维护一个 total,代表后续未经处理的字符串所剩余的“最大价值”是多少,从而实现剪枝;
  5. 使用 lowbit 计算某个状态对应的字符长度是多少;
  6. 使用「全局哈希表」记录某个状态对应的字符长度是多少(使用 static 修饰,确保某个状态在所有测试数据中只会被计算一次);
  7. 【未应用】由于存在第 44 点这样的「更优性剪枝」,理论上我们可以根据「字符串所包含字符数量」进行从大到小排序,然后再进行 DFS 这样效果理论上会更好。想象一下如果存在一个包含所有字母的字符串,先选择该字符串,后续所有字符串将不能被添加,那么由它出发的分支数量为 00;而如果一个字符串只包含单个字母,先决策选择该字符串,那么由它出发的分支数量必然大于 00。但该策略实测效果不好,没有添加到代码中。

代码:

class Solution {
    // 本来想使用如下逻辑将「所有可能用到的状态」打表,实现 O(1) 查询某个状态有多少个字符,但是被卡了
    // static int N = 26, M = (1 << N);
    // static int[] cnt = new int[M];
    // static {
    //     for (int i = 0; i < M; i++) {
    //         for (int j = 0; j < 26; j++) {
    //             if (((i >> j) & 1) == 1) cnt[i]++;
    //         }
    //     }
    // }

    static Map<Integer, Integer> map = new HashMap<>();
    int get(int cur) {
        if (map.containsKey(cur)) {
            return map.get(cur);
        }
        int ans = 0;
        for (int i = cur; i > 0; i -= lowbit(i)) ans++;
        map.put(cur, ans);
        return ans;
    }
    int lowbit(int x) {
        return x & -x;
    }

    int n;
    int ans = Integer.MIN_VALUE;
    int[] hash;
    public int maxLength(List<String> _ws) {
        n = _ws.size();
        HashSet<Integer> set = new HashSet<>();
        for (String s : _ws) {
            int val = 0;
            for (char c : s.toCharArray()) {
                int t = (int)(c - 'a');
                if (((val >> t) & 1) != 0) {
                    val = -1;
                    break;
                } 
                val |= (1 << t);
            }
            if (val != -1) set.add(val);
        }

        n = set.size();
        if (n == 0) return 0;
        hash = new int[n];

        int idx = 0;
        int total = 0;
        for (Integer i : set) {
            hash[idx++] = i;
            total |= i;
        }
        dfs(0, 0, total);
        return ans;
    }
    void dfs(int u, int cur, int total) {
        if (get(cur | total) <= ans) return;
        if (u == n) {
            ans = Math.max(ans, get(cur));
            return;
        }
        // 在原有基础上,选择该数字(如果可以)
        if ((hash[u] & cur) == 0) {
            dfs(u + 1, hash[u] | cur, total - (total & hash[u]));
        }
        // 不选择该数字
        dfs(u + 1, cur, total);
    }
}
【重点】二进制枚举解法

首先还是对所有字符串进行预处理。

然后使用「二进制枚举」的方式,枚举某个字符串是否被选择。

举个🌰,(110)2(110)_{2} 代表选择前两个字符串,(011)2(011)_{2} 代表选择后两个字符串,这样我们便可以枚举出所有组合方案。

代码:

class Solution {
    static Map<Integer, Integer> map = new HashMap<>();
    int get(int cur) {
        if (map.containsKey(cur)) {
            return map.get(cur);
        }
        int ans = 0;
        for (int i = cur; i > 0; i -= lowbit(i)) ans++;
        map.put(cur, ans);
        return ans;
    }
    int lowbit(int x) {
        return x & -x;
    }

    int n;
    int ans = Integer.MIN_VALUE;
    Integer[] hash;
    public int maxLength(List<String> _ws) {
        n = _ws.size();
        HashSet<Integer> set = new HashSet<>();
        for (String s : _ws) {
            int val = 0;
            for (char c : s.toCharArray()) {
                int t = (int)(c - 'a');
                if (((val >> t) & 1) != 0) {
                    val = -1;
                    break;
                } 
                val |= (1 << t);
            }
            if (val != -1) set.add(val);
        }

        n = set.size();
        if (n == 0) return 0;
        hash = new Integer[n];
        int idx = 0;
        for (Integer i : set) hash[idx++] = i;

        for (int i = 0; i < (1 << n); i++) {
            int cur = 0, val = 0;
            for (int j = 0; j < n; j++) {
                if (((i >> j) & 1) == 1) {
                    if ((cur & hash[j]) == 0) {
                        cur |= hash[j];
                        val += get(hash[j]);
                    } else {
                        cur = -1;
                        break;
                    }
                }
            }
            if (cur != -1) ans = Math.max(ans, val);
        }
        return ans;
    }
}
  • 时间复杂度:仍是指数级别的复杂度
  • 空间复杂度:仍是指数级别的复杂度

1601. 最多可达成的换楼请求数目(二进制枚举)

基本题意

我们有 n 栋楼,编号从 0 到 n - 1 。每栋楼有若干员工。由于现在是换楼的季节,部分员工想要换一栋楼居住。

给你一个数组 requestsrequests ,其中 requests[i]=[fromi,toi]requests[i] = [from_i, to_i] ,表示一个员工请求从编号为 fromifrom_i 的楼搬到编号为 toito_i 的楼。

一开始 所有楼都是满的,所以从请求列表中选出的若干个请求是可行的需要满足 每栋楼员工净变化为 00 。意思是每栋楼 离开 的员工数目 等于 该楼 搬入 的员工数数目。比方说 n=3n = 3 且两个员工要离开楼 00 ,一个员工要离开楼 11 ,一个员工要离开楼 22 ,如果该请求列表可行,应该要有两个员工搬入楼 00 ,一个员工搬入楼 11 ,一个员工搬入楼 22 。

请你从原请求列表中选出若干个请求,使得它们是一个可行的请求列表,并返回所有可行列表中最大请求数目。

示例 1:

输入:n = 5, requests = [[0,1],[1,0],[0,1],[1,2],[2,0],[3,4]]

输出:5
解释:请求列表如下:
从楼 0 离开的员工为 x 和 y ,且他们都想要搬到楼 1 。
从楼 1 离开的员工为 ab ,且他们分别想要搬到楼 20 。
从楼 2 离开的员工为 z ,且他想要搬到楼 0 。
从楼 3 离开的员工为 c ,且他想要搬到楼 4 。
没有员工从楼 4 离开。
我们可以让 x 和 b 交换他们的楼,以满足他们的请求。
我们可以让 y,a 和 z 三人在三栋楼间交换位置,满足他们的要求。
所以最多可以满足 5 个请求。

示例 2:

输入:n = 3, requests = [[0,0],[1,2],[2,1]]

输出:3

解释:请求列表如下:
从楼 0 离开的员工为 x ,且他想要回到原来的楼 0 。
从楼 1 离开的员工为 y ,且他想要搬到楼 2 。
从楼 2 离开的员工为 z ,且他想要搬到楼 1 。
我们可以满足所有的请求。

示例 3:

输入:n = 4, requests = [[0,3],[3,1],[1,2],[2,0]]

输出:4

提示:

  • 1<=n<=201 <= n <= 20
  • 1<=requests.length<=161 <= requests.length <= 16
  • requests[i].length==2requests[i].length == 2
  • 0<=fromi,toi<n0 <= fromi, toi < n
二进制枚举解法

为了方便,我们令 requestsrequests 的长度为 mm

数据范围很小,nn 的范围为 2020,而 mm 的范围为 1616

根据每个 requests[i]requests[i] 是否选择与否,共有 2m2^m 种状态(不超过 7000070000 种状态)。我们可以采用「二进制枚举」的思路来求解,使用二进制数 statestate 来表示对 requests[i]requests[i] 的选择情况,当 statestate 的第 kk 位为 11,代表 requests[k]requests[k] 被选择。

我们枚举所有的 statestate 并进行合法性检查,从中选择出包含请求数的最多(二进制表示中包含 11 个数最多)的合法 statestate,其包含的请求数量即是答案。

其中统计 statestate11 的个数可以使用 lowbit,复杂度为 O(m)O(m),判断合法性则直接模拟即可(统计每座建筑的进出数量,最后判定进出数不相等的建筑数量是为 00),复杂度为 O(m)O(m),整体计算量为不超过 21062*10^6,可以过。

代码:

class Solution {
    int[][] rs;
    public int maximumRequests(int n, int[][] requests) {
        rs = requests;
        int m = rs.length, ans = 0;
        for (int i = 0; i < (1 << m); i++) {
            int cnt = getCnt(i);
            if (cnt <= ans) continue;
            if (check(i)) ans = cnt;
        }
        return ans;
    }
    boolean check(int s) {
        int[] cnt = new int[20];
        int sum = 0;
        for (int i = 0; i < 16; i++) {
            if (((s >> i) & 1) == 1) {
                int a = rs[i][0], b = rs[i][1];
                if (++cnt[a] == 1) sum++;
                if (--cnt[b] == 0) sum--;
            }
        }
        return sum == 0;
    }
    int getCnt(int s) {
        int ans = 0;
        for (int i = s; i > 0; i -= (i & -i)) ans++;
        return ans;
    }
}
  • 时间复杂度:令 mmrequestsrequests 长度,共有 2m2^m 种选择状态,计算每个状态的所包含的问题数量复杂度为 O(m)O(m),计算某个状态是否合法复杂度为 O(m)O(m);整体复杂度为 O(2mm)O(2^m * m)
  • 空间复杂度:O(n)O(n)

805. 数组的均值分割(折半搜索 + 二进制枚举)

基本题意

给定你一个整数数组 nums

我们要将 nums 数组中的每个元素移动到 A 数组 或者 B 数组中,使得 A 数组和 B 数组不为空,并且 average(A) == average(B) 。

如果可以完成则返回 true, 否则返回 false

注意:对于数组 arr,  average(arr) 是 arr 的所有元素除以 arr 长度的和。

示例 1:

输入: nums = [1,2,3,4,5,6,7,8]

输出: true

解释: 我们可以将数组分割为 [1,4,5,8][2,3,6,7], 他们的平均值都是4.5。

示例 2:

输入: nums = [3,1]

输出: false

提示:

  • 1<=nums.length<=301 <= nums.length <= 30
  • 0<=nums[i]<=1040 <= nums[i] <= 10^4

折半搜索 + 二进制枚举 + 哈希表 + 数学

提示一:将长度为 nn,总和为 sumsum 的原数组划分为两组,使得两数组平均数相同,可推导出该平均数 avg=sumnavg = \frac{sum}{n}

若两数组平均数相同,则由两数组组成的新数组(对应原数组 nums)平均数不变,而原数组的平均数可直接算得。

提示二:原数组长度为 3030,直接通过「二进制枚举」的方式来做,计算量为 2302^{30},该做法无须额外空间,但会 TLE

所谓的直接使用「二进制枚举」来做,是指用二进制表示中的 01 分别代表在划分数组两边。

如果直接对原数组进行「二进制枚举」,由于每个 nums[i]nums[i] 都有两种决策(归属于数组 AB),共有 2302^{30} 个状态需要计算。同时每个状态 state 而言,需要 O(n)O(n) 的时间复杂度来判定,但整个过程只需要有限个变量。

因此直接使用「二进制枚举」是一个无须额外空间 TLE 做法。

提示三:空间换时间

我们不可避免需要使用「枚举」的思路,也不可避免对每个 nums[i]nums[i] 有两种决策。但我们可以考虑缩减每次搜索的长度,将搜索分多次进行。

具体的,我们可以先对 nums 的前半部分进行搜索,并将搜索记录以「二元组 (tot,cnt)(tot, cnt) 的形式」进行缓存(mapset),其中 tot 为划分元素总和,cnt 为划分元素个数;随后再对 nums 的后半部分进行搜索,假设当前搜索到结果为 (tot,cnt)(tot', cnt'),假设我们能够通过“某种方式”算得另外一半的结果为何值,并能在缓存结果中查得该结果,则说明存在合法划分方案,返回 true

通过「折半 + 缓存结果」的做法,将「累乘」的计算过程优化成「累加」计算过程。

提示四:何为“某种方式”

假设我们已经缓存了前半部分的所有搜索结果,并且在搜索后半部分数组时,当前搜索结果为 (tot,cnt)(tot', cnt'),应该在缓存结果中搜索何值来确定是否存在合法划分方案。

假设存在合法方案,且在缓存结果应当被搜索的结果为 (x,y)(x, y)。我们有 tot+xcnt+y=avg=sumn\frac{tot' + x}{cnt' + y} = avg = \frac{sum}{n}

因此我们可以直接枚举系数 kk 来进行判定,其中 kk 的取值范围为 [max(1,cnt),n1][\max(1, cnt'), n - 1],结合上式算得 t=k×sumnt = k \times \frac{sum}{n},若在缓存结果中存在 (ttot,kcnt)(t - tot', k - cnt'),说明存在合法方案。

代码:

class Solution {
    public boolean splitArraySameAverage(int[] nums) {
        int n = nums.length, m = n / 2, sum = 0;
        for (int x : nums) sum += x;
        Map<Integer, Set<Integer>> map = new HashMap<>();
        for (int s = 0; s < (1 << m); s++) {
            int tot = 0, cnt = 0;
            for (int i = 0; i < m; i++) {
                if (((s >> i) & 1) == 1) {
                    tot += nums[i]; cnt++;
                }
            }
            Set<Integer> set = map.getOrDefault(tot, new HashSet<>());
            set.add(cnt);
            map.put(tot, set);
        }
        for (int s = 0; s < (1 << (n - m)); s++) {
            int tot = 0, cnt = 0;
            for (int i = 0; i < (n - m); i++) {
                if (((s >> i) & 1) == 1) {
                    tot += nums[i + m]; cnt++;
                }
            }
            for (int k = Math.max(1, cnt); k < n; k++) {
                if (k * sum % n != 0) continue;
                int t = k * sum / n;
                if (!map.containsKey(t - tot)) continue;
                if (!map.get(t - tot).contains(k - cnt)) continue;
                return true;
            }
        }
        return false;
    }
}
  • 时间复杂度:对原数组前半部分搜索复杂度为 O(2n2)O(2^{\frac{n}{2}});对原数组后半部分搜索复杂度为 O(2n2)O(2^{\frac{n}{2}}),搜索同时检索前半部分的结果需要枚举系数 k,复杂度为 O(n)O(n)。整体复杂度为 O(n×2n2)O(n \times 2^{\frac{n}{2}})
  • 空间复杂度:O(2n2)O(2^{\frac{n}{2}})

2044. 统计按位或能得到最大值的子集数目(二进制枚举 + 状压 DP + DFS)

基本题意

给你一个整数数组 numsnums ,请你找出 numsnums 子集 按位或 可能得到的 最大值 ,并返回按位或能得到最大值的 不同非空子集的数目

如果数组 aa 可以由数组 bb 删除一些元素(或不删除)得到,则认为数组 aa 是数组 bb 的一个 子集 。如果选中的元素下标位置不一样,则认为两个子集 不同 。

对数组 aa 执行 按位或 ,结果等于 a[0]a[0] OR a[1]a[1] OR ... OR a[a.length1]a[a.length - 1](下标从 00 开始)。

示例 1:

输入:nums = [3,1]

输出:2

解释:子集按位或能得到的最大值是 3 。有 2 个子集按位或可以得到 3 :
- [3]
- [3,1]

示例 2:

输入:nums = [2,2,2]

输出:7

解释:[2,2,2] 的所有非空子集的按位或都可以得到 2 。总共有 23 - 1 = 7 个子集。

示例 3:

输入:nums = [3,2,1,5]

输出:6

解释:子集按位或可能的最大值是 7 。有 6 个子集按位或可以得到 7 :
- [3,5]
- [3,1,5]
- [3,2,5]
- [3,2,1,5]
- [2,5]
- [2,1,5]

提示:

  • 1<=nums.length<=161 <= nums.length <= 16
  • 1<=nums[i]<=1051 <= nums[i] <= 10^5
二进制枚举

nnnumsnums 的长度,利用 nn 不超过 1616,我们可以使用一个 int 数值来代指 numsnums 的使用情况(子集状态)。

假设当前子集状态为 statestatestatestate 为一个仅考虑低 nn 位的二进制数,当第 kk 位为 11,代表 nums[k]nums[k] 参与到当前的按位或运算,当第 kk 位为 00,代表 nums[i]nums[i] 不参与到当前的按位或运算。

在枚举这 2n2^n 个状态过程中,我们使用变量 max 记录最大的按位或得分,使用 ans 记录能够取得最大得分的状态数量。

代码:

class Solution {
    public int countMaxOrSubsets(int[] nums) {
        int n = nums.length, mask = 1 << n;
        int max = 0, ans = 0;
        for (int s = 0; s < mask; s++) {
            int cur = 0;
            for (int i = 0; i < n; i++) {
                if (((s >> i) & 1) == 1) cur |= nums[i];
            }
            if (cur > max) {
                max = cur; ans = 1;
            } else if (cur == max) {
                ans++;
            }
        }
        return ans;
    }
}
  • 时间复杂度:令 numsnums 长度为 nn,共有 2n2^n 个子集状态,计算每个状态的按位或得分的复杂度为 O(n)O(n)。整体复杂度为 O(2nn)O(2^n * n)
  • 空间复杂度:O(1)O(1)
状压 DP

为了优化解法一中「每次都要计算某个子集的得分」这一操作,我们可以将所有状态的得分记下来,采用「动态规划」思想进行优化。

需要找到当前状态 statestate 可由哪些状态转移而来:假设当前 statestate 中处于最低位的 11 位于第 idxidx 位,首先我们可以使用 lowbit 操作得到「仅保留第 idxidx11 所对应的数值」,记为 lowbitlowbit,那么显然对应的状态方程为:

f[state]=f[statelowbit]nums[idx]f[state] = f[state - lowbit] \wedge nums[idx]

再配合我们从小到大枚举所有的 statestate 即可确保计算 f[state]f[state] 时所依赖的 f[statelowbit]f[state - lowbit] 已被计算。

最后为了快速知道数值 lowbitlowbit 最低位 11 所处于第几位(也就是 idxidx 为何值),我们可以利用 numsnums 长度最多不超过 1616 来进行「打表」预处理。

代码:

class Solution {
    static Map<Integer, Integer> map = new HashMap<>();
    static {
        for (int i = 0; i < 20; i++) map.put((1 << i), i);
    }
    public int countMaxOrSubsets(int[] nums) {
        int n = nums.length, mask = 1 << n;
        int[] f = new int[mask];
        int max = 0, ans = 0;
        for (int s = 1; s < mask; s++) {
            int lowbit = (s & -s);
            int prev = s - lowbit, idx = map.get(lowbit);
            f[s] = f[prev] | nums[idx];
            if (f[s] > max) {
                max = f[s]; ans = 1;
            } else if (f[s] == max) {
                ans++;
            }
        }
        return ans;
    }
}
  • 时间复杂度:O(2n)O(2^n)
  • 空间复杂度:O(2n)O(2^n)
DFS

解法一将「枚举子集/状态」&「计算状态对应的得分」两个过程分开进行,导致了复杂度上界为 O(2nn)O(2^n * n)

事实上,我们可以在「枚举子集」的同时「计算相应得分」,设计 void dfs(int u, int val)DFS 函数来实现「爆搜」,其中 uu 为当前的搜索到 numsnums 的第几位,valval 为当前的得分情况。

对于任意一位 xx 而言,都有「选」和「不选」两种选择,分别对应了 dfs(u + 1, val | nums[x])dfs(u + 1, val) 两条搜索路径,在搜索所有状态过程中,使用全局变量 maxans 来记录「最大得分」以及「取得最大得分的状态数量」。

该做法将多条「具有相同前缀」的搜索路径的公共计算部分进行了复用,从而将算法复杂度下降为 O(2n)O(2^n)

代码:

class Solution {
    int[] nums;
    int max = 0, ans = 0;
    public int countMaxOrSubsets(int[] _nums) {
        nums = _nums;
        dfs(0, 0);
        return ans;
    }
    void dfs(int u, int val) {
        if (u == nums.length) {
            if (val > max) {
                max = val; ans = 1;
            } else if (val == max) {
                ans++;
            }
            return ;
        }
        dfs(u + 1, val);
        dfs(u + 1, val | nums[u]);
    }
}
  • 时间复杂度:令 numsnums 长度为 nn,共有 2n2^n 个子集状态。整体复杂度为 O(2n)O(2^n)
  • 空间复杂度:忽略递归带来的额外空间开销,复杂度为 O(1)O(1)

总结

通过上述 55 道题目我们可以感受到,通过二进制枚举相比于常规的 DFS 做法更优优势。

同时实现二进制枚举需要我们熟悉 &>>|~ 的基本位运算操作。

这里再做简单总结:

// 获取二进制数 num 的第 i 位
(num >> i) & 1
// 将二进制数 num 的第 i 位变成 1
num | (1 << i)
// 将二进制数 num 的第 i 位变成 0
num & (~(1 << i))