算法基础详解(二)枚举算法——普通枚举与二进制枚举

欢迎来到我的频道[【点击跳转专栏】]

作者说:我想说 基础 不等于 简单 ;算法能力不是一蹴而就的,而是来自日积月累的积累和练习!积小流终成江海,诸君 加油!!

文章目录

  • [0. 枚举](#0. 枚举)
  • [1. 普通枚举](#1. 普通枚举)
    • [1.1 铺地毯(枚举顺序的影响)](#1.1 铺地毯(枚举顺序的影响))
    • [1.2 回⽂⽇期(枚举策略)](#1.2 回⽂⽇期(枚举策略))
    • [1.3 扫雷游戏(枚举并非都是for)](#1.3 扫雷游戏(枚举并非都是for))
  • [2. 二进制枚举(难点)](#2. 二进制枚举(难点))
    • [2.1 子集](#2.1 子集)
    • [2.2 费解的开关(挺难的)](#2.2 费解的开关(挺难的))
    • [2.3 Even Parity](#2.3 Even Parity)
    • [2.4 二进制枚举总结](#2.4 二进制枚举总结)

0. 枚举

顾名思义,就是把所有情况全都罗列出来,然后找出符合题目要求的那一个。因此,枚举是一种纯暴力的算法。

一般情况下,枚举策略都是会超时的。此时要先根据题目的数据范围来判断暴力枚举是否可以通过。

如果不行的话,就要用后面要学的各种算法来进行优化(比如二分,双指针,前缀和与差分等)。

使用枚举策略时,重点思考枚举的对象(枚举什么),枚举的顺序(正序还是逆序),以及枚举的方式(普通枚举?递归枚举?二进制枚举)

1. 普通枚举

1.1 铺地毯(枚举顺序的影响)

https://www.luogu.com.cn/problem/P1003


【解法】

策略1:

枚举所有的地毯,判断哪一个地毯能够覆盖 ( x , y ) (x,y) (x,y) 这个位置。

策略2:

优化枚举方式:

  • 因为我们要的是最后一个能够覆盖 ( x , y ) (x,y) (x,y) 位置的地毯,那么逆序枚举所有的地毯,第一次找到覆盖 ( x , y ) (x,y) (x,y) 位置的就是结果;
  • 如果从前往后枚举,我们至少要把所有地毯都枚举完,才能知道最终结果。

那么如何判断点 ( x , y ) (x,y) (x,y) 是否覆盖了呢?

我们要保证 a<=x、b<=y、x<=a+g、y<=b+k 一定要记得取等 因为边界我们也算覆盖!

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
const int N=1e4+10;
int n;//地毯数量
int a[N],b[N],g[N],k[N];//用于存储第i号地毯的左下角下标(a,b)和地毯的长宽
int x,y;


int find()
{
    //倒着枚举
    for(int i=n;i>=1;i--)
    {
        if(x>=a[i]&&y>=b[i]&&x<=a[i]+g[i]&&y<=b[i]+k[i])
        {
            return i;
        }
    }
    return -1;
}


int main()
{
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i]>>b[i]>>g[i]>>k[i];
    }
    cin>>x>>y;
    //find判断最上层是哪一块地毯
    cout<<find();
}

1.2 回⽂⽇期(枚举策略)

https://www.luogu.com.cn/problem/P2010



⚠️:4年一闰百年不闰,400年一闰!

  1. 策略1:枚举x~y直接所有数字,然后判断是否回文,如果回文,那就拆分乘年月日,判断是否合法!(暴力枚举!时间复杂度2e7左右
  2. 策略2:枚举所有年份,那么回文形式月日固定,我们只要判断日期是否在范围内然后是否合法即可!(时间复杂度:2e3左右
  3. 策略3:枚举所有的月和日组合,然后拼接成相应的年份,判断是否合法且符合范围!(一共366个日期 时间复杂度:366!最优解!)

然后关于如何把月份转换成年份 如: 07 21 只需要21%10*1000 + 21/10*100 + 7%10*10 + 7/10即可!

cpp 复制代码
//枚举日月!
#include<bits/stdc++.h>
using namespace std;
int num1, num2;
int ret;//存储回文数
//枚举每月日期
int date[]={0,31,29,31,30,31,30,31,31,30,31,30,31};

int main()
{
    cin>>num1>>num2;
    for(int i=1;i<=12;i++)
    {
        for(int j=1;j<=date[i];j++)
        {
            int a=j%10*1000+j/10*100+i%10*10+i/10;
            int num3=a*10000+i*100+j;
            if(num3>=num1&&num3<=num2) 
            {
                ret++;
            }
        }
    }
    cout<<ret;
}

1.3 扫雷游戏(枚举并非都是for)

https://www.luogu.com.cn/problem/P2327


我们发现,当第一列中,第一行的小格子的状态确定了之后,其实后续行的状态也跟着固定下来。而第一列中,第一行的状态要么有雷,要么没有雷,所以最终的答案就在0, 1, 2 中。(其实多画画就能得到规律了 画几个小例子(如 2×2, 3×3 网格),会立刻发现:一旦左上角定了, 全局就定了;且最多只有两个起点可行 )

当然 也可以尝试推导(发现只要第一个格子确定了 后面都定了!):

因此,我们枚举第一列中,第一行的两种状态:要么有雷,要么没雷。然后依次计算剩下行的值,看看是否能满足所给的数据。

这道题最重要的就是要找到a[i]=b[i-1]-a[i-1]-a[i-2]对应关系!

如果 出现a[i]<0或者a[i]>1这样的非法值 就说明这种方法行不通!


注意:这道题有一个闭环点 那就是a[n+1]这个位置 我们枚举的时候要把它考虑进去,这样才能保证b[i]全部枚举完,因为第 n 个格子的数字 b[n] 无法仅靠 a[n-1]a[n] 来满足。我们的地图只要n行所以n+1行绝对不能有雷!

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
const int N=1e4+10;
int n;
int ret=0;//存储输出
int a[N],b[N];
int check1()
{
    a[1]=0;
    //这个n+1是个难点 看我上面解析
    for(int i=2;i<=n+1;i++)
    {
        a[i]=b[i-1]-a[i-1]-a[i-2];
        if(a[i]<0||a[i]>1)
        {
            return 0;
        }
    }
    //逻辑闭环点 看我上面解析 算是个难点
    if(a[n+1]==0)
    {
        return 1;
    }
    else
    {
        return 0;
    }
}

int check2()
{
    a[1]=1;
    //这个n+1是个难点 看我上面解析
    for(int i=2;i<=n+1;i++)
    {
        a[i]=b[i-1]-a[i-1]-a[i-2];
        if(a[i]<0||a[i]>1)
        {
            return 0;
        }
    }
    //逻辑闭环点 看我上面解析 算是个难点
    if(a[n+1]==0)
    {
        return 1;
    }
    else
    {
        return 0;
    }
}

int main()
{
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        cin>>b[i];
    }
    //分别判断第一个格子为0或1 是否能成功
    ret+=check1();
    ret+=check2();
    cout<<ret;
}

2. 二进制枚举(难点)

二进制枚举:用一个数二进制表示中的 0/1 表示两种状态,从而达到枚举各种情况。

  • 利用二进制枚举时,会用到一些位运算的知识。不熟悉需要去补一下位运算相关的知识。
  • 关于用二进制中的 0/1 表示状态这种方法,会在动态规划中的状态压缩 dp 中继续使用到。
  • 二进制枚举的方式也可以用递归实现,后续搜素算法中会再讲到。
运算符 名称 规则 示例 (A=5, B=3)
& 按位与 两位均为 1 则结果为 1,否则为 0 0000 0101 & 0000 0011 = 0000 0001 (1)
按位或 两位有一个为 1 则结果为 1,全 0 才 0
^ 按位异或 两位不同则结果为 1,相同则为 0 0000 0101 ^ 0000 0011 = 0000 0110 (6)
~ 按位取反 0 变 1,1 变 0 (一元运算符) ~0000 0101 = 1111 1010 (-6)
<< 左移 整体左移,低位补 0,高位丢弃 0000 0101 << 1 = 0000 1010 (10)
>> 右移 整体右移,低位丢弃,高位补符号位(算术右移) 0000 0101 >> 1 = 0000 0010 (2)

⚠️:一定要拿出纸笔进行推导!!这个算是个难点!

2.1 子集

https://leetcode.cn/problems/subsets/


利用二进制枚举的方式,把所有情况都枚举出来!

【解法】

枚举 [0 , 1 << n) 之间所有的数,每一个数的二进制中 (1) 的位置可以表示数组中对应位置选上该元素。那么 [0 ~ 1 << n) 就可以枚举出原数组中所有的子集。

⚠️:图上 范围是[0,1<<3)->[0,7]


(st >> i) & 1
作用 :取出数字 st 的二进制表示中,第 i+1 位 的值(0 或 1)。

比如说(0100>>2)&1==1 表示第三位数字是1!此时我们只需要插入2号位置的数就行!比如说图上的[3] 参考0~8中数字5的插入方式

需知(可以插入空的vector哦~):

cpp 复制代码
/ 向 ret 中添加一个空的 vector<int>
ret.push_back(vector<int>());

// 或者使用 C++11 的列表初始化写法,更简洁
ret.push_back({});
cpp 复制代码
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++)
        {
            vector<int> tmp;
            //依次取出第i位数字判断是否是1 这个看不懂的建议自己上手写一下 
           for(int i=0;i<n;i++)
         {
            if((st>>i)&1==1)
            {
             tmp.push_back(nums[i]);
            }
         }
         //关于空集 在st=0的时候就会自动添加 空的vector了
          ret.push_back(tmp);
        }
        return ret;
    }
        
};

2.2 费解的开关(挺难的)

https://www.luogu.com.cn/problem/P10449



想做出这道题 必须知道三个性质!

  1. 每一个开关「最多只会被按一次」。因为按两次及以上是没有意义的,只会让按的次数增多;(如下图 点多次等于白点)
  2. 按每一个开关的「先后顺序」不会影响最后的结果。可以想象,当所有开关按的方式确定之后,每一个开关被改变的「次数」也就被确定了,也就是说不管你先按谁后按谁,改变的次数是固定的,那么结果就是固定的;
  3. 如果「确定了第一行」的按法,后续行的按法就也固定下来了(这里可以参考之前《扫雷》这道题,有相似点)。因为第一行的按法固定之后,第二行的按法需要把第一行「全部点亮」(因为第三行开始也影响不了第一行了!);当第二行的按法确定之后,第三行的按法需要把第二行「全部点亮」......依次类推,后续行的按法就都确定下来了。(根据第1个性质 当我们按完第一行 其实这辈子都不会再管第一行了!)

所以这道题的解法就是让我们通过枚举第一行的按法来确定后面的走向!

  1. 暴力枚举第一行所有按法
  2. 根据第一行的按法,计算出当前行以及下一行被按之后的结果
  3. 根据第二行的按法,推导出第二行的按法
  4. 重复2.3过程 直到最后一行,然后判断是否全量

⚠️:解法看着简单,其实难就难在如何实现!

  1. 如何枚举第一行?

我们可以用二进制0表示亮 1表示不亮 枚举范围[0,1<<5) 这一步看不懂说明你2.1的题没吃透!(因为这样的话只需要盘点是否为0就能判断出是不是全亮!和题目反着来更方便!


  1. 如何计算出一共按了多少次?

直接在二进制表示中一共多少个1就行!这里一个有两种方法!

1 . 基础方法(这个简单 我不想解释):
int count = 0; while (n) { count += n & 1; // 检查最低位 n >>= 1; // 右移一位
2 .Brian Kernighan 算法(循环次数等于1的个数)

这行代码堪称位运算里的"手术刀",专门用来消除二进制数中最右边的那个 1

为什么 n & (n - 1) 能做到这一点?

当你把一个二进制数减 1 时,会发生"借位"现象:

  • 最右边的 1 会变成 0
  • 这个 1 右边的所有 0 都会变成 1
  • 这个 1 左边的位保持不变。

当你把原数 nn-1 进行按位与 (&) 运算时:

  • 最右边的 1 位置:原数是 1,减 1 后是 0,相与变 0
  • 右边的 0 位置:原数是 0,减 1 后是 1,相与变 0
  • 左边的位置:保持不变。

结果就是:只有最右边的那个 1 被"消灭"了,其他位都不受影响。

假设 n = 12,二进制是 1100

  1. n1100

  2. n - 11011 (11 的二进制)

  3. n & (n - 1)

    text 复制代码
      1100  (12)
    & 1011  (11)
    ------
      1000  (8)

结果1000。你可以看到,原本 1100 最右边的那个 1(第 3 位)变成了 0

cpp 复制代码
int count = 0; 
while (n)
 {
    n = n & (n - 1); // 每次消灭一个 1
    count++; 
    } // count 就是结果

  1. 用二进制表示,来存储灯的初始状态!

用一个数组a[5]就能存储整个灯的状态,因为每一行主要要一个数字就能表示 比如说a[1]=15 15的二进制是11111 那么不就表示第一行全灭吗!


  1. 如何计算当前行 a[i] 以及下一行a[i+1]被按之后的状态?

可以用位运算快速计算出被按之后的状态!直接用^1操作进行修改!(1与任何数字^ 此时1变成0 0变成1)

这里补充一下0和任何数字^ 数字不变!

我们用push 存储被按的状态 比如 00100 那么就代表按下第三个灯 此时假设a[i]=10111(二进制) 那么按下后a[i] 变成亮11001

具体方式a[i]=a[i]^push^(push<<1)^(push>>1)! (不过这么做又会有一个问题 那就是push如果是10000的时候 最后一位的后一位也会变成1 比如说01000 会变成101000 那么就要清空非法的最后一位的下一位)

可以用a[i]=a[i]&[(1<<5)-1]

关于下一行的状态 其实也很容易a[i+1]^push就行了!


  1. 如何求出下一行怎么按呢?

下一行的按法很简单 上一行按完后的结果其实就是下一行的按法!(因为你肯定要把上一行给按灭才行!这也就是为什么 4. 并不写回上一行的影响的原因!)

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
const int N=10;
int a[N];//存储灯的状态
int t[N];//遍历按法的时候暂存灯的状态
int n=5;
//记录二进制1的个数
int calc(int push)
{
    int ret=0;
    while(push)
    {
        push=(push-1)&push;
        ret++;
    }
    return ret;
}

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]=a[i]|(1<<j);
                }
            }
        }

        int ret=0x3f3f3f3f;//记录最小按的次数
        //枚举第一行的按法
        for(int tmp=0;tmp<(1<<n);tmp++)
        {
            int push=tmp;
            //拷贝临时的数组
            memcpy(t,a,sizeof(a));
            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<<ret<<endl;
        }
        else
        {
            cout<<-1<<endl;
        }
    }
}

2.3 Even Parity

https://www.luogu.com.cn/problem/UVA11464


游戏性质:

  1. 针对每个0 只有变一次或者不变两种情况,因此每个0都有两种状态!
  2. 当我们决定改变那些0的时候,改变的顺序不影响结果!我们只需要关心哪些0最终被该变即可
  3. 当我们第一行最终结果确定后,后续行最终结果也跟着确定!

解法:

1 . 暴力枚举第一行"所以状态"

  1. 判断最终状态是否合法

  2. 如果合法,推导出下一行的最终状态。重复23流程即可

  3. 直到推导到最后一行是否合法


如何写代码:

  1. 直接二进制枚举所有结果 [0,1<<n)
  2. 如何判断这个状态是否合法

一层for循环,判断a[i]和change 相应的二进制表示中:

如果0->1 合法,搞一个变量sum对其进行记录!

如果是1->0 不合法,返回一个 -1 即可!


  1. 当前行最终状态 a[i]确定之后,如何推导出一下行呢?(找规律)

这涉及到 任意两个相同的数字^后一定是0

如图: 根据规则 x+y+z+? 难点就是是求这个

所以nextchange=a[i-1]^(a[i]<<!)^a[i]>>1

同时不要忘记消除 最后一位的后一位的影响
nextchange&=(1<<n)

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
int a[20];
int t[20];
int n;
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;
}

2.4 二进制枚举总结

  1. 关于^:(1全变 0全不变)
  • 1与任何数字^ 都是0变1 1变0
  • 0与任何数字^ 都是0还是0 1还是1
  • 消消乐规则:任何两个相同的数^一起是0!
  1. 关于&(0能把1全变成0)
  • 1与任何数字& 都是1不变 0不变 10110&11111=10110
  • 0与任何数字& 都是0不变 1变0 10110&00000=00000
  1. 关于(1能把0全变成1)
  • 1与任何数字 都是 1不变 0变1 10110|11111=11111
  • 0与任何数字 都是 1不变 0不变 10110|00000=10110
相关推荐
承渊政道8 小时前
【优选算法】(实战:栈、队列、优先级队列高频考题通关全解)
数据结构·c++·笔记·学习·算法·leetcode·宽度优先
py有趣8 小时前
力扣热门100题之将有序数组转为二叉搜索树
算法·leetcode
天若有情6738 小时前
Python精神折磨系列(完整11集·无断层版)
数据库·python·算法
凌波粒8 小时前
LeetCode--383.赎金信(哈希表)
java·算法·leetcode·散列表
xiaoye-duck8 小时前
《算法题讲解指南:动态规划算法--子数组系列》--23.等差数列划分,24.最长湍流子数组
c++·算法·动态规划
小O的算法实验室8 小时前
2026年SEVC,高密度仓库中结合任务分配的多AGV无冲突调度框架,深度解析+性能实测
算法·论文复现·智能算法·智能算法改进
abant28 小时前
leetcode 108 有序数组转平衡二叉树
算法·leetcode·职场和发展
汀、人工智能9 小时前
[特殊字符] 第7课:移动零
数据结构·算法·数据库架构·图论·bfs·移动零
计算机安禾9 小时前
【数据结构与算法】第25篇:静态查找(一):顺序查找与折半查找
java·开发语言·数据结构·学习·算法·visual studio code·visual studio