欢迎光临
我们一直在努力

【算法学习笔记】二进制枚举——用二进制表示状态

前言

之前做枚举题的时候,通常都是通过循环把所有可能的情况试一遍。但如果每个元素都有选和不选两种状态,随着元素数量增加,需要枚举的情况也会越来越多。

这时候就可以用到二进制枚举。

二进制枚举的核心思路并不复杂,就是利用二进制中的 0 和 1 表示不同状态,再通过位运算枚举所有可能的情况。

这篇文章主要整理二进制枚举的基本用法,以及三个相关例题:子集、费解的开关和 Even Parity。

一、二进制枚举的基本思想

假设有一个数组:

nums = {1, 2, 3};

现在要求枚举数组中所有可能的子集。

对于每个元素来说,只有两种选择:

  • 选择这个元素,用 1 表示。
  • 不选择这个元素,用 0 表示。

因此,可以用一个二进制数来表示当前的选择情况。

这里规定二进制数的最低位对应数组的第一个元素,也就是 nums[0]。

那么所有情况如下:

二进制状态十进制对应子集
000 0 空集
001 1 {1}
010 2 {2}
011 3 {1, 2}
100 4 {3}
101 5 {1, 3}
110 6 {2, 3}
111 7 {1, 2, 3}

三个元素,每个元素都有两种状态,所以总共有:2^3=8 种情况。

如果数组有 n 个元素,那么总共有 (2^n) 种状态。

所以直接枚举:

for(int st = 0; st < (1 << n); st++)
{
// st 代表当前枚举到的状态
}

其中 1 << n 就是 (2^n)。

这样就可以把所有可能的状态全部枚举出来。

二、二进制枚举常用的位运算

使用二进制枚举,离不开位运算。这里主要记录几个经常用到的操作。

1. 左移运算 <<

左移运算可以快速计算 2 的整数次幂。

例如:

1 << 3

二进制变化:

0001 << 3 = 1000

转换成十进制就是 8。

因此:1 << n = 2^n

在枚举所有状态时,经常会看到:

for(int st = 0; st < (1 << n); st++)

2. 右移运算 >> 和按位与 &

这两个运算经常配合使用,用来判断二进制某一位的状态。

常见写法:

(st >> i) & 1

它的作用是取出 st 的第 i 位。

例如:

int st = 5; // 二进制:101

cout << ((st >> 0) & 1) << endl; // 1
cout << ((st >> 1) & 1) << endl; // 0
cout << ((st >> 2) & 1) << endl; // 1

先右移 i 位,让目标位置来到最低位,再和 1 进行按位与操作,就能得到这一位的值。

如果结果为 1,说明对应位置被选中;如果结果为 0,说明没有被选中。

3. 异或运算 ^

异或运算在二进制枚举相关题目中也很常见。

它有一个很重要的特点:

aba ^ b
0 0 0
0 1 1
1 0 1
1 1 0

可以看到,相同为 0,不同为 1。

所以异或经常用来实现状态翻转:

x ^= 1;

如果原来是 0,就变成 1;如果原来是 1,就变成 0。

另外,异或还满足交换律和结合律。

三、例题一:子集

题目:子集

给定一个没有重复元素的整数数组 nums,要求返回数组所有可能的子集。

例如:

nums = [1, 2, 3]

输出:

[[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]

思路分析

这道题刚好可以使用前面介绍的二进制枚举。

数组中有 n 个元素,每个元素只有选和不选两种情况,因此总共有 (2^n) 种可能。

我们可以让 st 从 0 开始,一直枚举到 (2^n-1)。

每次枚举一个状态,就检查它的每个二进制位:

  • 如果第 i 位为 1,就把 nums[i] 放入当前子集。
  • 如果第 i 位为 0,就跳过这个元素。

这样每个状态都能对应一个子集,最终就能得到所有可能的结果。

代码实现

class Solution
{
public:
vector<vector<int>> subsets(vector<int>& nums)
{
vector<vector<int>> ret;
int n = nums.size();

// 枚举所有的状态
for(int st = 0; st < (1 << n); st++)
{
// 根据 st 的状态,还原出要选的数
vector<int> tmp; // 从当前选的子集
for(int i = 0; i < n; i++)
{
if((st >> i) & 1) tmp.push_back(nums[i]);
}
ret.push_back(tmp);
}

return ret;
}
};

复杂度分析

一共有 (2^n) 种状态,每种状态都需要遍历 n 个元素。

  • 时间复杂度:(O(n * 2^n))
  • 额外空间复杂度:(O(n)),不考虑返回结果所占用的空间。

这道题算是二进制枚举最基础的应用。只要理解了如何用二进制表示选择状态,代码其实很好写。

四、例题二:费解的开关

题目:费解的开关

有 25 盏灯,排列成一个 5×5 的矩阵,每盏灯只有开和关两种状态。

每次按下一个开关,会改变当前位置以及上下左右相邻位置的灯的状态。

给定初始状态,要求判断能否在 6 步以内让所有灯都亮起来,如果可以,输出最少按动次数,否则输出 -1。

1. 分析题目性质

如果直接枚举 25 个开关的所有按法,那么总共有:2^{25} 种情况。

显然,这样枚举的范围比较大。

仔细观察题目,可以发现几个性质:

  • 每个开关最多只需要按一次。因为同一个开关按两次,相当于没有按。
  • 开关按下的先后顺序不会影响最终结果,真正影响结果的是哪些开关被按过。
  • 如果第一行的按法确定了,那么后面每一行的按法也就确定了。
  • 第三点是这道题的关键。

    为什么第一行确定之后,后面的行也就确定了?

    假设第一行某个位置的灯没有亮,那么想要改变这个位置的状态,只能按它正下方的开关。

    因为第二行的开关只能影响第一行中对应位置,第一行其他位置的开关已经无法再影响它了。

    所以,只要确定第一行的按法,就可以从上到下依次推出后面每一行的按法。

    原本需要枚举 (2^{25}) 种情况,现在只需要枚举第一行的:2^5=32 种情况。

    2. 使用二进制存储每一行

    每行有 5 个位置,可以直接使用一个整数的低 5 位来表示这一行的状态。

    例如:

    10101

    就可以表示这一行的灯的状态。

    这里还有一个小技巧:读取数据时,可以把原来的 0 和 1 反过来存储。

    也就是:

    • 原来的 0 存成 1。
    • 原来的 1 存成 0。

    这样,题目要求所有灯都亮,就变成了所有位置都为 0。

    最后只需要判断最后一行是否为 0 即可。

    3. 枚举第一行的按法

    第一行有 5 个开关,因此直接枚举:

    for(int st = 0; st < (1 << 5); st++)
    {
    // st 表示第一行的一种按法
    }

    如果 st 的第 i 位为 1,就说明第一行第 i 个开关需要按下。

    另外,还需要统计当前按法一共按了多少次开关。

    可以使用下面这个函数统计二进制中 1 的个数:

    int calc(int x)
    {
    int cnt = 0;

    while(x)
    {
    cnt++;
    x &= x – 1;
    }

    return cnt;
    }

    这里的:

    x &= x – 1;

    每执行一次,就会消掉 x 二进制中最低位的一个 1。

    所以循环执行的次数,就是二进制中 1 的个数。

    4. 如何推出下一行的按法?

    这部分是整道题最重要的地方。

    假设当前行的按法已经确定,记作 push。

    按下开关后,会影响当前行和下一行:

    • 当前行对应位置的灯会改变。
    • 当前行左右相邻位置的灯会改变。
    • 下一行对应位置的灯会改变。

    由于按下开关就是把 0 变成 1,或者把 1 变成 0,所以可以使用异或运算来实现状态翻转。

    当前行更新:

    t[i] = t[i] ^ push ^ (push << 1) ^ (push >> 1);

    其中:

    • push:改变当前位置。
    • push << 1:改变一个相邻位置。
    • push >> 1:改变另一个相邻位置。

    不过,左移可能会产生超出 5 位范围的高位,因此需要进行位截断:

    t[i] &= (1 << 5) – 1;

    下一行只会受到当前行对应位置的影响:

    t[i + 1] ^= push;

    那么下一行应该怎么按?

    其实也很简单。

    当前行按完之后,哪些位置还没有达到目标状态,下一行就按下这些位置对应的开关。

    所以:

    push = t[i];

    这样就能得到下一行的按法。

    不断重复这个过程,直到最后一行。

    如果最后一行的状态为 0,就说明所有灯都已经亮了。此时统计按动次数,并更新最小值。

    最后判断最小次数是否超过 6:

    • 不超过 6,输出最小次数。
    • 超过 6,输出 -1。

    5.代码实现

    #include <iostream>
    #include <cstring>

    using namespace std;

    const int N = 10;

    int n = 5;
    int a[N]; // 用二进制表示,来存储灯的状态
    int t[N]; // 备份 a 数组

    // 计算 x 的二进制表示中一共有多少个 1
    int calc(int x)
    {
    int cnt = 0;
    while(x)
    {
    cnt++;
    x &= x – 1;
    }
    return cnt;
    }

    int main()
    {
    int T; cin >> T;

    while(T—)
    {
    // 多组测试时,一定要注意清空之前的数据
    memset(a, 0, sizeof a);

    for(int i = 0; i < n; i++)
    {
    for(int j = 0; j < n; j++)
    {
    char ch; cin >> ch;
    // 存成相反的
    if(ch == '0') a[i] |= 1 << j;
    }
    }

    int ret = 0x3f3f3f3f; // 统计所有合法的按法中的最小值
    // 枚举第一行所有的按法
    for(int st = 0; st < (1 << n); st++)
    {
    memcpy(t, a, sizeof a);
    int push = st; // 当前行的按法
    int cnt = 0; // 统计当前按法下一共按了多少次

    // 依次计算后续行的结果以及按法
    for(int i = 0; i < n; i++)
    {
    cnt += calc(push);
    // 修改当前行被按的结果
    t[i] = t[i] ^ push ^ (push << 1) ^ (push >> 1);
    t[i] &= (1 << n) – 1; // 清空影响
    // 修改下一行的状态
    t[i + 1] ^= push;
    // 下一行的按法
    push = t[i];
    }

    if(t[n – 1] == 0) ret = min(ret, cnt);
    }

    if(ret > 6) cout << –1 << endl;
    else cout << ret << endl;
    }

    return 0;
    }

    6. 这道题的核心

    这道题比较值得学习的地方,不只是使用二进制枚举第一行,而是通过观察题目性质,把原本需要枚举整个矩阵的过程,变成了只枚举第一行。

    后面的状态不需要继续枚举,而是根据前面的状态直接推导出来。

    做这类题时,不能只想着暴力枚举所有情况,有时候找到状态之间的联系,才能真正减少枚举量。

    五、例题三:Even Parity

    题目:Even Parity

    给定一个 n×n 的 01 矩阵,要求尽可能少地把 0 变成 1,使矩阵变成偶数矩阵。

    所谓偶数矩阵,就是每个位置上下左右相邻元素(如果存在)的和都必须是偶数。

    如果不存在合法方案,输出 -1。

    1. 分析题目性质

    这道题和上一道费解的开关有些相似。

    首先可以发现:

  • 每个位置只能从 0 变成 1,不能从 1 变成 0。
  • 当某一行的最终状态确定之后,下一行的最终状态也就确定了。
  • 所以我们依然不需要枚举整个矩阵的状态,只需要枚举第一行的最终状态,然后根据规则推出后面的行。

    2. 枚举第一行的最终状态

    第一行有 n 个位置,每个位置都有 0 和 1 两种状态。

    因此直接枚举:

    for(int st = 0; st < (1 << n); st++)

    每个 st 就代表第一行的一种最终状态。

    不过需要注意,题目只允许 0 变成 1,因此还需要判断当前枚举的状态是否合法。

    比较原始状态 x 和最终状态 y:

    • 0 变成 1:合法,改变次数加一。
    • 1 变成 0:不合法,直接跳过当前状态。
    • 0 变成 0:不需要改变。
    • 1 变成 1:不需要改变。

    可以通过逐位比较来完成这个判断。

    int calc(int x, int y)
    {
    int sum = 0;

    for(int i = 0; i < n; i++)
    {
    // 0 -> 1,改变次数加一
    if(((x >> i) & 1) == 0 &&
    ((y >> i) & 1) == 1)
    {
    sum++;
    }

    // 1 -> 0,不合法
    if(((x >> i) & 1) == 1 &&
    ((y >> i) & 1) == 0)
    {
    return –1;
    }
    }

    return sum;
    }

    3. 根据当前行推出下一行

    这是本题另一个关键点。

    因为题目要求每个位置上下左右相邻元素的和都是偶数,所以可以利用异或运算的性质来推导下一行。

    当一个位置上下左右相邻元素中 1 的数量是偶数时,这几个位置异或起来的结果就是 0。

    因此,当上一行和当前行的最终状态确定之后,下一行的状态也就确定了。

    推导公式:

    a[i + 1] = (a[i] >> 1)
    ^ (a[i] << 1)
    ^ a[i – 1];

    这里:

    • a[i] >> 1:当前行右移一位。
    • a[i] << 1:当前行左移一位。
    • a[i – 1]:上一行对应位置的状态。

    最后同样需要进行位截断:

    a[i + 1] &= (1 << n) – 1;

    这是为了保证结果只保留 n 位,避免多余的高位影响后续判断。

    4. 统计最小改变次数

    每次枚举第一行的最终状态后,都需要依次推出后面的行。

    在推导过程中:

  • 判断当前行的最终状态是否合法。
  • 如果合法,统计需要改变的 0 的数量。
  • 根据当前行和上一行的状态,计算下一行。
  • 重复以上过程,直到最后一行。
  • 如果整个过程都合法,那么当前方案就是一个可行方案。

    最后在所有可行方案中取改变次数最少的结果。

    如果没有任何合法方案,就输出 -1。

    5.代码实现

    #include <iostream>
    #include <cstring>

    using namespace std;

    const int N = 20;

    int n;
    int a[N]; // 用二进制存储状态
    int t[N]; // 备份

    // 判断 x->y 是否合法
    // 返回 -1,表示不合法
    // 其余的数,表示合法,并且表示 0->1 的次数
    int calc(int x, int y)
    {
    int sum = 0;

    for(int i = 0; i < n; i++)
    {
    if(((x >> i) & 1) == 0 && ((y >> i) & 1) == 1) sum++;
    if(((x >> i) & 1) == 1 && ((y >> i) & 1) == 0) return –1;
    }

    return sum;
    }

    int solve()
    {
    int ret = 0x3f3f3f3f; // 记录最小的改变次数

    // 枚举第一行的最终状态
    for(int st = 0; st < (1 << n); st++)
    {
    memcpy(t, a, sizeof a);

    int change = st;
    int cnt = 0; // 统计 0->1 的次数
    bool flag = 1;

    for(int i = 1; i <= n; i++)
    {
    // 先判断 change 是否合法
    int c = calc(t[i], change);
    if(c == –1)
    {
    flag = 0;
    break;
    }

    cnt += c; // 累加次数
    // 当前行的最终状态
    t[i] = change;
    // 计算下一行的最终状态
    change = t[i – 1] ^ (t[i] << 1) ^ (t[i] >> 1);
    change &= (1 << n) – 1;
    }
    if(flag) ret = min(ret, cnt);
    }

    if(ret == 0x3f3f3f3f) return –1;
    else return ret;
    }

    int main()
    {
    int T; cin >> T;

    for(int k = 1; k <= T; k++)
    {
    // 多组测试数据,记得清空
    memset(a, 0, sizeof a);

    cin >> n;
    for(int i = 1; i <= n; i++) // 避免越界访问
    {
    for(int j = 0; j < n; j++)
    {
    int x; cin >> x;
    if(x) a[i] |= 1 << j;
    }
    }

    printf("Case %d: %d\\n", k, solve());
    }

    return 0;
    }

    这道题和费解的开关的整体思路其实很像:先枚举第一行,再利用已经确定的状态推导后面的行。

    只不过两道题的状态转移规则不同,具体实现也有所区别。

    六、总结

    二进制枚举本质上还是暴力枚举,只不过利用二进制来表示每个元素的状态,再通过位运算完成状态的判断和修改。

    这一部分主要需要掌握以下几点:

    1. 状态表示

    使用二进制中的 0 和 1 表示每个元素的两种状态。

    2. 枚举所有状态

    通过:

    for(int st = 0; st < (1 << n); st++)

    就能枚举 n 个元素的所有可能状态。

    3. 判断某一位的状态

    通过:

    (st >> i) & 1

    可以快速取出第 i 位的值。

    4. 利用题目性质优化枚举

    费解的开关和 Even Parity 这两道题都没有直接枚举整个矩阵,而是通过第一行的状态推导后面的状态,大幅减少了枚举范围。

    最后还需要注意一点:二进制枚举虽然方便,但它的时间复杂度仍然可能是指数级的。n 稍微增大,(2^n) 就会增长得很快,所以做题时一定要结合数据范围判断是否适合使用。

    赞(0)
    未经允许不得转载:171主机测评 » 【算法学习笔记】二进制枚举——用二进制表示状态
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址