跳转至

二进制枚举

引入

有 ${n}$ 枚硬币,正面记为 $1$,反面记为 $0$。我们想枚举所有可能的抛掷结果。

$n=3$ 时,共 $2^3=8$ 种情况:

\[\begin{cases} \texttt{000}\\ \texttt{001}\\ \texttt{010}\\ \texttt{011}\\ \texttt{100}\\ \texttt{101}\\ \texttt{110}\\ \texttt{111} \end{cases}\]
for (int i = 0; i <= 1; i++)
    for (int j = 0; j <= 1; j++)
        for (int k = 0; k <= 1; k++)
            cout << i << " " << j << " " << k << "\n";

缺点

  • 循环嵌套层数与 $n$ 等价,难以扩展
  • 无法在编译期或运行期灵活支持任意 $n$。

当 $n$ 增大时

  • $n=4$:需要四重循环
  • $n=10$:需要十重循环,代码臃肿,易出错。
  • 如何优雅地枚举任意 $n$ 枚硬币的所有情况?

这就需要学习二进制枚举!


什么是子序列?

子序列指的是:在一个序列中,按原顺序选出若干个元素(可以不连续) 形成的新序列。

例如:

若 $a = [1, 2, 3, 4]$,那么:

  • $[1, 3]$
  • $[1, 3, 4]$

都属于它的子序列。


示例一:硬币正反面的所有组合 = 子序列所有情况

将硬币 正面 视为被选中,反面 视为未选中,则正反面组合实际上就是对所有子序列的一种表示。

例如 $3$ 个硬币的组合:

000
001
010
011
100
101
110
111

好比有一个长度为 $3$ 的序列 $a_1,a_2,a_3$。被选中的数字用 $1$ 表示,未选中的数字用 $0$ 表示。

则以上 $8$ 种情况对应了 长度为 $3$ 的序列 $a_1,a_2,a_3$ 的所有子序列

这些组合情况也可以看作是二进制数 $000\sim 111$,因此也被称作二进制枚举。


示例二:子序列筛选问题

问题描述: 给定 $n$ 个数字,从中任意选择 $m$ 个,求有多少种方式使得这 $m$ 个数的和不超过 $k$。

分析:

  • 数字可以任意选择(不要求相邻),所以是子序列问题。
  • 限定子序列长度为 $m$,相当于正好选中 $m$ 个硬币正面朝上。

按照二进制的角度理解:相当于二进制数中恰好含有 $m$ 个 $1$。


前置技能

  • 二进制 和 十进制 相互转换
  • 常见位运算:按位与(&)、或(|)、异或(^)、左移(<<)、右移(>>)、取反(~

进制转换

十进制转二进制

将一个整数不断除以 $2$,记录余数,直到商为 $0$,再将余数逆序排列。

例如:十进制 $10$ 转二进制:

10 ÷ 2 = 5 ... 0
5 ÷ 2 = 2 ... 1
2 ÷ 2 = 1 ... 0
1 ÷ 2 = 0 ... 1
→ 1010(二进制)

具体实现

  • 对待转换数 $n$ 做数位拆分。
    • 获取 n % 2 的结果即余数。
    • 执行 n /= 2 继续拆分。
  • 对过程中的余数保存起来,以便将来 逆序 输出。

时间复杂度:$O(\log{n})$

string s;
while (n)
{
    s += n % 2 + '0';
    n /= 2;
}
reverse(s.begin(), s.end()); // 逆序所有余数

二进制转十进制

将二进制每一位从右往左乘以 $2^i$ 并相加:

例如:二进制 1010 转十进制:

\[ 1\times 2^3 + 0\times 2^2 + 1\times 2^1 + 0\times 2^0 = 8 + 0 + 2 + 0 = 10 \]

具体实现

  • 将输入的二进制数存到字符串 s 中,然后遍历字符串 $s$。
  • 定义变量 $sum$ 初始化为 $0$ 保存最后的结果。
    • 首先将 $sum\leftarrow sum\times 2$。相当于二进制下左移一位。
    • 然后将 $sum\leftarrow sum + s[i]$。相当于二进制下加 $s[i]$ 这一位的结果。
    • 注意具体实现时需要将 s[i] - ‘0’ 而不是用 ASCII 码。
int sum = 0;
for (char c : s)
{
    sum = sum * 2 + c - '0';
}
cout << sum;

位运算

原码、反码、补码

在进行位运算前,我们需要理解计算机中数值的二进制表示方式:

1. 原码(Sign-Magnitude)

  • 正数的原码就是其二进制形式。加一个符号位(最高位为 $0$)
  • 负数的原码是其二进制,加一个符号位(最高位为 $1$)。

例如:

  • +1 的原码:$\textcolor{red}00000000\ 0000000\ 00000000 \ 00000001$
  • -1 的原码:$\textcolor{red}10000000\ 0000000\ 00000000 \ 00000001$

2. 反码(Ones' Complement)

  • 正数的反码与原码相同。
  • 负数的反码是对其原码除符号位外的每一位取反。

例如:

  • +1 的反码:$\textcolor{red}00000000\ 00000000\ 00000000 \ 00000001$
  • -1 的反码:$\textcolor{red}11111111\ 11111111\ 11111111 \ 11111110$

3. 补码(Two's Complement)

  • 正数的补码仍与原码相同。
  • 负数的补码 = 反码 $+ 1$。

例如:

  • +1 的补码:$\textcolor{red}00000000\ 00000000\ 00000000 \ 00000001$
  • -1 的补码:$\textcolor{red}11111111\ 11111111\ 11111111 \ 11111111$

计算机中 所有数值的存储与计算 都使用 补码


基本位运算规则

所有的位运算计算都是用 补码 计算。

运算符 名称 描述 示例(按位)
& 同为 $1$ 结果为 $1$ 1101 & 1011=1001
| 有 $1$ 则为 $1$ 1101 | 1011 = 1111
^ 异或 相同为 $0$ 不同为 $1$ 1101 ^ 1011 = 0110
~ 取反 所有位翻转 ~1101 = 0010(按位)
<< 左移 所有位左移 $n$ 位,低位补 $0$ 0001 << 2 = 0100
>> 右移 所有位右移 $n$ 位,高位补符号位(有符号)或 $0$(无符号) 1000 >> 2 = 0010(无符号)

  • 与运算示例

计算 10 & 4

\[ \begin{aligned} &\textcolor{red}0000\,0000\,0000\,0000\,0000\,0000\,0000\,1010\;(10)\\ &\textcolor{red}0000\,0000\,0000\,0000\,0000\,0000\,0000\,0100\;(4)\\ \hline &\textcolor{red}0000\,0000\,0000\,0000\,0000\,0000\,0000\,0000\;(0) \end{aligned} \]

  • 或运算示例

计算 10 | 4

\[ \begin{aligned} &\textcolor{red}0000\,0000\,0000\,0000\,0000\,0000\,0000\,1010\;(10)\\ &\textcolor{red}0000\,0000\,0000\,0000\,0000\,0000\,0000\,0100\;(4)\\ \hline &\textcolor{red}0000\,0000\,0000\,0000\,0000\,0000\,0000\,1110\;(14) \end{aligned} \]

  • 异或运算示例

计算 10 ^ 4

\[ \begin{aligned} &\textcolor{red}0000\,0000\,0000\,0000\,0000\,0000\,0000\,1010\;(10)\\ &\textcolor{red}0000\,0000\,0000\,0000\,0000\,0000\,0000\,0100\;(4)\\ \hline &\textcolor{red}0000\,0000\,0000\,0000\,0000\,0000\,0000\,1110\;(14) \end{aligned} \]

  • 左移示例

计算 10 << 2

\[\begin{array}{ccccc} & \textcolor{red}{0} & 00000000 & 00000000 & 00000000 \, 00001010 \\ % 10的补码(32位) & \downarrow & \quad & \quad \\ & \textcolor{red}{0} & 0000000 & 00000000 & 00000000 \, 001010\textcolor{blue}{00} \\ % 左移2位后的结果(32位) \end{array}\]
\[\begin{array}{c} 10 << 2 = 40 \\ \end{array}\]

  • 右移示例

计算 10 >> 2

\[\begin{array}{ccccc} & \textcolor{red}{0} & 0000000 & 00000000 & 00000000 \, 00001010 \\ % 10的补码(32位) & \downarrow & \quad & \quad \\ & \textcolor{red}{0} & 0000000 & 00000000 & 00000000 \, 000000\textcolor{blue}{10} \\ % 左移2位后的结果(32位) \end{array}\]
\[\begin{array}{c} 10 >> 2 = 2 \\ \end{array}\]

  • 取反运算示例

计算 ~5

\[\begin{array}{cc} \textcolor{red}{0} & 0000000 \, 00000000 \, 00000000 \, 00000101 \\ % 5的补码 \hline \textcolor{red}{1} & 1111111 \, 11111111 \, 11111111 \, 11111010 \\ % 取反后的结果 \end{array}\]

转回十进制原码:$-6$。


位运算的常见应用

我们将二进制的最低位称为第 $0$ 位,依次向高位编号至第 $n-1$ 位。

这样 第 $i$ 位 就对应数值权重 $2^i$。

  • 例如二进制最低位表示 $2^0$,因此将最低位命名为第 $0$ 位。

检测二进制第 0 位 —— n & 1

直观理解:

十进制的任何任何整数 $n$ 都可以写成:

\[ n = (\cdots b_2\,b_1\,b_0)_2,\quad b_i\in\{0,1\}. \]

其中 $b_0$ 就是 “最低位”。

为何 n & 1 可以检测二进制第 0 位?

  • 写出二者的低 $4$ 位(二进制补码)对齐对比:
\[\begin{aligned} n &= \,\,b_3\,b_2\,b_1\,\textcolor{red}{b_0}\\ 1 &= \,\,0\,0\,0\,\textcolor{red}{1} \end{aligned} \quad\Longrightarrow\quad n \;\&\; 1 = 0\,0\,0\,(b_0\!\ \&\ \!1) = b_0. \]

& 运算只有同为 $1$ 时才得 $1$,其它均为 $0$。

  • (n & 1) == 1 $\leftrightarrow b_0=1$(奇数);

  • (n & 1) == 0 $\leftrightarrow b_0=0$(偶数)。

由于 奇数加偶数还是奇数,且 $2$ 的次幂只有 $2^0$ 是奇数,因此若 $n$ 二进制最后一位是 $1$,则前面的偶数会最后累加一个奇数,故而导致 $n$ 是奇数。


检测二进制第 1 位 —— n & 2

第 $1$ 位 $b_1$ 对应权重 $2^1=2$,只需和二进制 $0\ldots 0010$ 做“按位与”:

\[\begin{aligned} n &= \,\,b_3\,b_2\,\textcolor{red}{b_1}\,b_0\\ 2 &= \,\,0\,0\,\textcolor{red}{1}\,0 \end{aligned} \quad\Longrightarrow\quad n \;\&\; 2 = 0\,0\,0(b_1\!\ \&\ \!1)\,0 = b_1\times 2. \]
  • (n & 2) != 0 可知 $n$ 的二进制第 $1$ 位是 $1$。
  • 反之二进制第 $1$ 位是 $0$。

同理检测二进制第 2 位 —— n & 4


通用检测第 $i$ 位

  • n & 1 << i 方法

将 $1$ 左移 $i$ 位,生成二进制数 $0\ldots 01\underbrace{0\cdots0}_{i}$,与 $n$ 按位与可提取第 $i$ 位:

\[ (\texttt{n & 1 << i})\;\neq0\;\Longleftrightarrow\; b_i=1. \]
  • 更安全写法:$\texttt{n >> i & 1}$

先将 $n$ 右移 $i$ 位,再取最低位:

\[ (\texttt{n >> i & 1}) \;=\; b_i. \]

无左移溢出风险,更推荐用于一般场景。


代码示例

// 检测 n 的第 0、1、2 位
if (n & 1) 
    cout << n << "二进制第 0 位是 1";
if (n & 2) 
    cout << n << "二进制第 1 位是 1";
if (n & 4) 
    cout << n << "二进制第 2 位是 1";

// 通用方法:检测第 i 位
for (int i = 0; i <  3; i++) 
{
    if (n >> i & 1) // 或写作 n & 1 << i
        cout << n << "二进制第" << i << "位是 1\n";
}

统计二进制中 1 的个数

数位拆分时同步记录余数 $1$ 出现多少次即可。

  • 时间复杂度:$O(\log{n})$
  • 保证 $n$ 非负;若包含负数,请先取绝对值或用无符号类型。
int f(long long n) 
{
    int cnt = 0;
    while (n > 0) 
    {
        if (n % 2 == 1)    
            cnt++;
        n /= 2;            
    }
    return cnt;
}

哪些位含有 1

由于 $\texttt{long long}$ 是 $8$ 字节 $64$ 位,因此其可能的二进制位从高到低编号为

\[ \textcolor{red}{b_{63}},b_{62},\,b_{61},\,b_{60},\,\dots,\,b_{2},\,b_{1},\,b_{0} \]

其中 $b_i$ 对应权重 $2^i$,$b_{63}$ 是符号位。

因此可枚举第 $62$ 到第 $0$ 位:for (int i = 62; i >= 0; i--)

  • 对每个 $i$:计算 n >> i & 1,判断是否为 $1$。
// 输出 n 二进制中所有为 1 的位号
for (int i = 62; i >= 0; i--) 
{
    if (n >> i & 1) 
    {
        cout << i << "\n";
    }
}

二进制枚举概述

二进制枚举:枚举 $n$ 个元素“选择若干个”的所有情况。

  • 对于每个元素,用二进制的「1」(选中)或「0」(不选中)来标识。

$n$ 枚硬币,每枚都有正($1$)反($0$)两面,组合总数为 $2^n$。

通过二进制枚举,一次性输出所有 $2^n$ 种结果:

\[ \begin{cases} 00\cdots00_2=(0)_{10}\\ 00\cdots01_2=(1)_{10}\\ 00\cdots10_2=(2)_{10}\\ \vdots\\ 11\cdots11_2=(2^n-1)_{10} \end{cases} \]

数学知识——二进制全 “1” 的十进制含义

\[\begin{aligned} &\underbrace{11\cdots1}_{n}\\ = &2^{n-1} + 2^{n-2} + \cdots + 2^{1}+2^0\\ =&2^{n-1}+2^{n-2}+\cdots+2^1+\underbrace{2^0+2^0}-2^0\\ =&2^{n-1}+2^{n-2}+\cdots+\underbrace{2^1+2^1}-2^0\\ =&2^n-2^0\\ =&2^n-1 \end{aligned}\]

题型特征

  • 特征一:问题规模小
    • 一般 $n \leq 20$,因为枚举所有子集的时间复杂度是 $O(2^n)$。
    • 举例:$n = 20$ 时,总共 $2^{20} \approx 10^6$,约等于 1 秒内可接受的计算量。
  • 特征二:选择个数不确定
    • 不像排列组合中限定选 $k$ 个,二进制枚举通常表示“选或不选”,不限定选几个
  • 特征三:子序列模型
    • 给定一个数组,从中选择若干个元素(不要求连续)——这正是子序列问题的典型应用场景。

状态压缩

是指:使用一个整数的二进制位表示一组元素的选择状态。

举例:有 $5$ 个物品,编号从 $0$ 到 $4$。若选中编号为 $3,2,0$ 的物品,则可以用整数 $13$ 表示这种选择状态,因为:

\[ 13 \rightarrow \text{二进制 } 01101 \]
  • 每一位代表对应编号物品是否被选择,从右到左依次为编号 $0,1,\dots,n-1$。
  • 例如:$i = 0$ 表示一个物品都未选,$i = 2^n - 1$ 表示所有物品都被选。
  • 总共有 $2^n$ 个状态,恰好对应所有选法。

小练习:判断选中哪些物品

有 $6$ 个物品,若选择状态为 $13$ 和 $31$,各选中了哪些物品?

  • $13 = 001101_2$,对应选中编号为 $3,2,0$ 的物品。
  • $31 = 011111_2$,对应选中编号为 $4,3,2,1,0$。

一般的解题步骤

  • 确定枚举状态
    • 通常为 $0$ 到 $2^n - 1$,每个状态对应一种选择方案。
  • 枚举所有状态
    • 使用循环遍历 $0$ 到 $2^n - 1$。
  • 检查状态中被选的物品
    • 使用位运算判断哪些物品被选中,再进行题目所要求的处理。

通用写法:枚举所有选择状态

  • 每个整数 $\texttt{mask}$($0 \le \texttt{mask} < 2^n$)代表一种选法。
  • 二进制中的每一位代表某个物品是否被选。
for (int mask = 0; mask < (1 << n); mask++) 
{
    // 每次循环代表一个状态
}
  • 判断编号为 $i$ 的物品是否被选中
for (int i = 0; i < n; i++) 
{
    if (mask >> i & 1) 
    {
        // 第 i 个物品被选中了
    }
}

例题

模板题一

给定一个大小为 $n$ 的数组 $a_0, a_1, \ldots, a_{n-1}$,请你输出它的所有子集。

例如:输入 $a = {1, 2}$,输出可能为:

  • 空集
  • 1
  • 2
  • 1 2

子集枚举的基本思想

一个 $n$ 元集合的子集总共有 $2^n$ 个。

每个子集都可以用一个长度为 $n$ 的 二进制数 表示:

  • 第 $i$ 位为 $1$ 表示选了 $a_i$,为 $0$ 表示没选。

所有子集对应的状态就是 $0$ 到 $2^n - 1$。

状态的二进制表示

举个例子:设 $n = 3$,数组为 $a = {10, 20, 30}$

所有子集的状态如下(用 mask 表示):

  • $000$:空集
  • $001$:选了 $a_0$,即 ${10}$
  • $010$:选了 $a_1$,即 ${20}$
  • $011$:选了 $a_0, a_1$,即 ${10, 20}$
  • $\cdots$
  • $111$:全选,即 ${10, 20, 30}$

代码结构

总复杂度为 $O(n \cdot 2^n)$

for (int mask = 0; mask < (1 << n); mask++)
{
    for (int i = 0; i < n; i++)
    {
        if (mask >> i & 1)  // 如果选了 a[i]
            cout << a[i] << " ";
    }
    cout << "\n";
}

PERKET

题目给出 $n$ 种食材,每种食材有两个值:酸度 $s_i$ 和 苦度 $b_i$。

你可以从中选择一个或多个食材进行搭配,计算:

  • 所选食材酸度的乘积(初始为 $1$)
  • 所选食材苦度的总和(初始为 $0$)

最终目标是:最小化两者的绝对差 $|S - B|$

本题没有限制选几种食材,因此是一个典型的 子集枚举问题 或者说子序列问题,即从 $n$ 个食材挑选若干个组成一个子序列。

  • 每种食材可选或不选,状态数是 $2^n$
    • $n \leq 10$,即 $2^{10} = 1024$,可以接受

使用二进制状态 $\texttt{mask}$ 表示一个选择方案:

  • 第 $i$ 位为 $1$ 表示选择了第 $i$ 个食材

对于当前状态 mask 维护出其对应选择了哪些食材,求出酸度的乘积和苦度的和即可。


限制数量的子集

相比于模板题,这里要求我们:输出所有恰好选择了 $m$ 个元素的子集

  • 也就是说:每个合法的选择状态,其二进制中 必须恰好有 $m$ 个 $1$

枚举所有子集的状态 $\texttt{mask}$,然后判断:

  • $\texttt{mask}$ 中 恰好有 $m$ 个 $1$
    • 朴素做法:实现数位拆分求二进制有几个 $1$ 的函数。
    • C++ 提供了现成函数:__builtin_popcount(mask)

满足后再输出该子集对应的元素。


选数

本题是在“限制数量的子集”基础上加了一层条件:

  • 从 $n$ 个数中恰好选择 $k$ 个
  • 选出的数字之和必须是一个质数

使用二进制枚举每一个子集,用整数 mask 表示状态:

  • 若 $\texttt{mask}$ 中恰好有 $k$ 个 $1$,说明选了 $k$ 个数
  • 对于每个合法的 $\texttt{mask}$,遍历它包含的元素并求和
  • 检查该和是否为质数,是则计数器加一

完美的数

给定 $n$ 个整数,要将其划分为两组,使得:

  • 两组中的数字不重复、不遗漏

这实际上是将所有数分成两个子序列,等价于选择其中一个子序列,另一个自动确定。

思路

本质上是一个子集枚举问题:

  • 枚举某一个子集(记作状态 $\texttt{mask}$),代表其中一组
  • 未被选中的元素自然构成另一组
  • 计算两组的和,若两组和插值的绝对值小于等于 $10$,则方案数加 $1$ 即可。

总状态数为 $2^n$,$n \leq 20$ 时暴力完全可行。


kkksc03考前临时抱佛脚


[COCI 2015/2016 #2] GEPPETTO

原料个数 $N\leq 20$,因此可以枚举所有原料组合共 $2^N$ 个状态,然后一一检验当前状态是否合法即可。

枚举所有状态记作 mask,同时枚举所有冲突组合 x[i], y[i]。若 mask 二进制第 x[i] 位和第 y[i] 位是 $1$,说明当前状态不合法。

注意: 输入时将 x[i], y[i] 都减去 $1$,使得其编号从 $0$ 开始编和二进制位对应起来。


[ABC249C] Just K

在 $n$ 个字符串中选择任意个,这又是一个子序列模型。因为题目只在意字母出现的次数,并不在意以什么顺序组合这些字符串。

注意到 $n\leq 15$,因此枚举 $2^n$ 个所有的子序列是可行的做法。

那么接下来就是对于每一个状态 mask 首先容易求出 mask 哪些二进制位为 $1$,即当满足 mask >> i & 1 说明第 $i$ 个字符串被选了。

为了验证哪些字母恰好出现 $k$ 次,需要定义一个大小为 $26$ 的计数数组 cnt,初始化 cnt[i] = 0 代表每个字母出现 $0$ 次。

接下来当第 $i$ 个字符串被选择,我们就需要遍历这个字符串,把它里面字母的次数更新到 cnt 数组。即:

  • for (auto x : s[i]) cnt[x - 'a']++

最后遍历计数数组 cnt,若满足 cnt[i] == k 说明字母 $i$ 恰好出现 $k$ 次,记录恰好 $k$ 次的个数 $sum$。

结束后对 $sum$ 取 $\max$ 即可。

时间复杂度为:$O(2^n\times n\times |S|)$。其中 $|S|$ 代表字符串的长度。


[ABC167C] Skill Up

注意到关键是一共有 $N$ 本书,每本书要么购买要么不购买,且 $N\leq 12$,因此可以使用二进制枚举枚举所有的购买方案。

  • 假设当前的购买方案的状态是 mask,假设 mask 二进制上 $1$ 的位置代表购买了第 $i$ 本,反之没有。

购买了第 $i$ 本书会让每个算法增加一定的能力,因此需要使用一个大小为 $M$ 的计数数组 cnt,来记录每个算法的理解水平,初始均为 $0$。

同时记录买书的花费 $sum$。

接下来遍历 cnt 数组,检验 cnt[i] 的值是否 $\geq X$,若有不满足的则当前状态 $i$ 不合法,可以打标记实现。

若当前方案合法,则对 $sum$ 求最小值即可。

实现时建议数组编号都从 $0$ 开始。

时间复杂度为 $O(2^n\times n\times m)$


[ABC128C] Switches

一共 $N$ 个开关,每个开关有两个状态。由于 $N\leq 10$ 因此可以枚举所有的开关组合,一共 $2^N$ 种情况。

定义一个大小为 $N$ 的标记数组 vis 记录哪个开关被开了,初始化 vis[i] = 0 代表第 $i$ 个开关是关着的。

  • 那么对于每个状态 mask,若 mask 二进制第 $i$ 位是 $1$,即第 $i$ 个开关开了,设置 vis[i] = 1 即可。

接下来求出被点亮的灯泡有几个,记录为 $sum$,若满足 $sum=m$ 则说明说明灯泡都被点亮,方案数加 $1$ 即可。

  • 定义 $sum=0$ 记录点亮个数。
  • 枚举每个灯泡 $i$,其中 $i\in [1,m]$。
  • 枚举灯泡 $i$ 连接的开关,因此在预处理存储开关的时候就要存储好,方便拿出来使用。下面给一种简单的实现方式。
vector<vector<int>> a(m + 1); // 定义 m + 1 个 vector 保存每一个灯泡连接的开关
for (int i = 1; i <= m; i++)
{
    int k;
    cin >> k;
    for (int j = 1; j <= k; j++)
    {
        int x;
        cin >> x;
        x--; // 让 x - 1 的目的是让开关的编号从 0 开始,因为二进制枚举的状态 i 是开关的状态。
        a[i].push_back(x);
    }
}    
  • 接下来对于当前第 $i$ 个灯泡的开关就可以通过 for (auto x : a[i] 来遍历。
  • 此时若 vis[x] == 1 说明 $x$ 这个开关被开了,记录开的总个数 $num$。
  • 遍历结束后,若 num % 2 == p[i] 说明这个灯泡被点亮了,执行 sum++ 即可。
  • sum == m 说明当前状态 $i$ 可行,ans++ 即可。

[ABC197C] ORXOR

长度为 $n$ 的序列划分为若干个非空子段。可以看作是在若干个位置进行断开,那么一共有 $n$ 个位置可以断开,因此最多 $2^n$ 种划分方案。由于 $n\leq 10$因此可以枚举所有的情况。

接下来记录到底哪些位置是分断点,即 for (int i = 0; i < n; i++) if (mask >> i & 1),此时 $i$ 就是一个断点。

我们需要记录上一个断点位置 last 初始化为 $0$,那么当前这一段就是 $last\sim i$,遍历这个范围求出或的结果。在和总异或值进行异或即可。

注意有一个隐藏的断点,即 $n$ 这个位置,也就是最后剩余一些元素也要计算一次。需要单独计算一次。

时间复杂度为 $O(2^n\times n\times n)$。


[CSP-S 2024] 染色

只做前 $20$ 分即可,注意到 $20$ 分的范围中,$n\leq 15$,且每个位置要么染色红色要么染色蓝色,是一个二选一的行为,因此可以使用二进制枚举枚举所有的染色方案,定义 $1$ 代表红色,$0$ 代表蓝色即可。

在具体实现中,对于每个状态 $i$,首先定义两个数组 $c$ 长度为 $n$ 记录红色和蓝色的位置。

vector<int> c(n);
for (int i = 0; i < n; i++)
{
    if (mask >> i & 1)
        c[i] = 1; // 第 i 个位置是红色
    else
        c[i] = 0; // 第 i 个位置是蓝色
}

接下来使用两个变量 $last1,last2$ 分别记录上一个红色和蓝色的位置。那么就可以遍历整个数组来计算得分了。

int last1 = -1, last2 = -1; // 初始化为 -1 代表上一个颜色位置不存在
int sum = 0;
for (int i = 0; i < n; i++)
{   
    if (c[i] == 1) // 如果是红色
    {
        if (last1 != -1 && a[last1] == a[i]) // 上一个红色位置存在且数值还相同
            sum += a[i];
        last1 = i; // 更新红色位置
    }
    else
    {
        if (last2 != -1 && a[last2] == a[i])
            sum += a[i];
        last2 = i;
    }
}
mx = max(mx, sum);

注意本题多组数据。


寻找团伙

注意到 $n$ 个人随便挑一些出来,且 $n\leq 21$ 因此不难想到使用二进制枚举,枚举所有的挑选方案求解最大值。

注意到题目说一个能力如果被选了偶数次,则不参与计算。因此对于每个状态 $i$ 应该定义一个大小为 $k+1$ 的数组 cnt 统计每个能力被选了多少次。初始化 cnt[i] = 0

这个题输入和前面某一题类似,可以用 vector 便捷实现。注意编号从 $0$ 开始输入,因为人的编号是我们二进制枚举用的,所以人的编号需要从 $0$ 开始和二进制位一一对应。

vector<vector<int>> a(n);
for (int i = 0; i < n; i++) 
{
    int c;
    cin >> c;
    for (int j = 1; j <= c; j++)
    {
        int x;
        cin >> x;
        a[i].push_back(x);
    }
}

接下来对于每一个状态 $i$ 首先求出每个能力被选的次数。

vector<int> cnt(k + 1, 0);
for (int i = 0; i < n; i++)
    if (mask >> i & 1)
        for (auto x : a[i]) // 遍历 a[i] 这个人的所有能力 x
            cnt[x]++; 

接下来枚举每一个能力值 $i$,其中 $i\in [1,k]$,若 cnt[i] % 2 == 1。说明这个能力可以使用,那么累加它的能力值即可,注意要乘以对应的权重,第 $j$ 个的权重就是 $2^{k-i}$。

long long sum = 0;
for (int i = 1; i <= k; i++)
{
    if (cnt[i] & 1)
    {
        sum += 1ll << (k - i); // 用位运算快速实现 2 的 次幂
    }
}
ans = max(ans, sum);

涂色

首先可以把行和列分开处理,本题也是子序列模型,即在行 $\sim n$ 中挑选若干个数字,在列 $1\sim m$ 中挑选若干个数字。

由于 $n,m\leq 6$,那么枚举行和列的所有组合就是 $2^n\times 2^m$,不会超时。 注意首先行和列的情况是互相组合的,因此需要循环嵌套的枚举。

for (int i = 0; i < 1 << n; i++) // 枚举行的状态
    for (int j = 0; j < 1 << m; j++) // 枚举列的状态

假设二进制位里值为 $1$ 代表这一行或这一列没有被去掉。我们需要做的就是在剩下的行和列组合的矩阵中求出字符 # 的个数,是否恰好等于 $K$,因此如何求出剩余的字符 # 个数是本题的关键所在。

要解决这个问题,我们就需要继续遍历到底是哪些行和哪些列被留下了。

for (int x = 0; x < n; x++)
    for (int y = 0; y < m; y++)

此时若满足 i >> x & 1 and j >> y & 1 说明第 $x$ 行第 $y$ 列这个格子被留下了。

假设原始输入的矩阵是 $a$,此时若满足 a[x][y] == '#',则计数变量加 $1$ 即可。

循环结束后验证计数变量的值是否恰好等于 $K$,若等于则方案数加 $1$。 时间复杂度为 $O(2^n\times 2^m\times n\times m)$。