前言
最近学了一下算法,刷几个题还蛮有意思的,在此记录一下。
一、枚举算法是什么?
枚举就是逐个考虑会导致目标结果的所有可能。也就是列举出所有可能的答案,一个一个看他们是否符合要求。
比如求小于n的所有质数,可以从2到n-1一个一个去试,这就是枚举。当然也有更好的方法。
下面详解一道枚举题(听了郭炜老师的算法课后用C语言写的),该题出自POJ1222。
二、题目
有一个由按钮组成的矩阵,其中每行有6个按钮,共5行。每个按钮的位置上有一盏灯。当按下一个按钮后,该按钮以及周围位置(上、下、左、右)的灯都会改变一次。即,如果灯原来是点亮的,就会被熄灭;如果灯原来是熄灭的,则会被点亮。在矩阵角上的按钮改变3盏灯的状态;在矩阵边上的按钮改变4盏灯的状态;其他的按钮改变5盏灯的状态。比如:
左上角按钮按下:
第一行第三个按钮按下:
第三行第四个按钮按下:
与一盏灯毗邻的多个按钮被按下时,一个操作会抵消另一次操作的结果;
现在给定矩阵中每盏灯的初始状态,求一种按按钮的方案,使得所有的灯都熄灭。
输入:
5行组成,每一行包括6个数字(0或1)。相邻两个数字之间用单个空格隔开。0表示灯的初始状态是熄灭的,1表示灯的初始状态是点亮的。
要求输出:
5行组成,每一行包括6个数字(0或1)。相邻两个数字之间用单个空格隔开。其中的1表示需要把对应的按钮按下,0则表示不需要按对应的按钮。
输入样例:(第一行的2代表有两组数据)
前五行是第一组数据,后五行是第二组。
输出样例:
PUZZLE#1代表第一组数据对应的输出。
三、解析
首先要明确一个按钮需要按几次。对于一个灯的亮灭,按一次取反,再按一次会恢复到原来的状态,所以一个按钮最多按一次。
其次,一共有30个按钮,每个按钮有两种状态(1:按下,0:不按),若枚举所有按钮的状态,一共有2^30个可能,这需要判断的次数太多,会超时。
对于有些问题,如果确定了局部的一些状态,那么其余的状态也随之而定,那么可以对该局部进行枚举,这样可以省下很多时间。
比如之前找小于n的全部质数,没必要从2一直枚举到n-1,从2枚举到根号n即可。因为:
若在小于n的正整数中,存在a,b,使得n=a*b,假设a大于根号下n,那么b一定小于根号n,所以b一定小于a,因此在枚举2到根号n的时候,已经枚举了所有情况。
在这道题中,第一行的按法决定了后面几行的按法。因为,第一行的按钮按下后,一定有些灯是亮着的,有些是灭了的。为了让第一行亮着的灯灭掉,必须按下对应的第二行的按钮。比如,第一行第三个灯是亮着的,那么第二行的第三个按钮必须按下才能让这个灯灭掉。所以第二行的按法由第一行决定,且第二行的按法唯一。在第二行的状态确定后,同理,第三行的按法要保证第二行的灯全是灭着的,所以第三行怎么按也是确定的,以此类推,后面几行的按法都是确定的。
因此枚举第一行的按法就可以了。第一行一共有6个按钮,那么需要考虑2^6种按法。对于第一行的每一种按法,主要观察第5行按钮在保证第4行的灯都灭的同时,第5行的灯也都灭,那说明第一行的按法是可行的,否则需要考虑下一种按法。
思路理清后,便可以开始写程序了。
第一步是获取这30个按钮的初始状态。题目给的数据是一个5×6 的矩阵,因此可以用一个二维数组存放。
但是,这些数据不是0就是1,而且每行的数据比较短,看到这样的数字,最好的存放方式应是将每个数字当作1bit,也就是位操作。第一行的每个数字占1bit,一共六个bit;而一个char类型的数据所占空间是8bit,所以可以用char类型变量的低6bit存放一行的数据。因此想存放这5×6个按钮的状态,只需要一个char类型的一维数组即可,数组长度是5,代表5行按钮;数组中每个元素对应一行的按钮状态。
2^6=64,可以让一个char变量从00000000自增到00111111,每一个数的低六位就是一种按法,正好可以表示64种状态。
位操作优点:占用空间小,运算速度快。因为按下按钮后,按钮所在位置的灯和周围的灯都取反,所以可以用1来和这些需要取反的灯(bit)进行异或,大大提高效率。
注:位操作中,1和一个位相异或,可以把该位取反;0和一个位相与,可以把该位置0;1和一个位相或,可以把该位置1。
位操作在嵌入式编程用的比较多,比如一个8位寄存器R1,想把最高位变成0,而其他位不变,那么可以用以下写法:
R1 &= ~(1 << 7);
1左移7位,变成10000000,取反,变为01111111,和R1相与后,则R1最高位变为0,其余位置不变。
这样不仅运算效率提高,而且程序的可读性增强,一眼便可看出要操作的是第几位。
四、编程
将需要用到的位操作封装成函数以便调用:
#include <stdio.h>
#include <stdlib.h>
#include <memory.h>
char inilight[5]; //每个元素对应灯矩阵的一行 初始矩阵
char lights[5]; // 每次按下按钮后 变化后的灯矩阵
char result[5]; // 最终结果 按钮的矩阵
int getbit(char c,int i) //取字符C的第i个比特
{
return (c >> i) & 1;
}
void setbit(char *c,int i,int v) //把字符C的第i个比特改成v
{
if(v)
(*c) |= (1<<i);
else
(*c) &= ~(1<<i);
}
void verbit(char *c,int i) //把第i位反转
{
(*c) ^= (1 << i);
}
有关参数传递:改变函数的形参,不会改变实参,除非传地址。
也就是说,一个函数f1调用另一个函数f2的时候,传入一个参数a,那么f2是不能改变这个a的值的,除非传地址。比如以上代码中的函数verbit(char *c,int i),目的是改变传入的字符c的第i个bit,那么必须传入字符c的地址才能实现。
然后按照输出格式要求,定义一个输出结果的函数,其中result数组用来保存最终按法的结果,1代表按下,0代表不按:
void output(int t,char result[])
{
//数组中每个元素代表按钮矩阵的一行
//矩阵第i行第j列的按钮就是数组第i行第j列的比特
printf("PUZZLE #%d\n",t);
int i,j;
for(i=0;i<5;i++)
{
for(j=0;j<6;j++)
{
printf("%d",getbit(result[i],j));
if(j<5)
printf(" ");//数据用空格隔开
}
printf("\n");//每输出一行需要换行
}
}
准备工作做完后,就该写main()函数了。下面是main()函数的全部代码:
int main()
{
printf("Hello world!\n");
int T; //测试数据的组数
int i,j,s,t,n,now; //t:当前输入是第几组,now是当前按钮矩阵的状态
scanf("%d",&T);
for(t=1 ; t <= T;t++)
{
for(i=0; i<5; i++)
for(j=0;j<6;j++)
{
scanf("%d",&s); //s是第i行第j列的数据
setbit(&inilight[i],j,s);
} //这两层循环把数据都读了进来
//接下来枚举第一行按钮的所有按法
//一共64种 让一个二进制数int,从0变到63
for(n=0; n<64; n++) //每个n代表第一行的一种状态
{
memcpy(lights,inilight,sizeof(inilight));//lights存放每次按钮作用后灯的状态
//每一次枚举都要在原始的状态改变,所以要把inilight拷贝到lights。
now = n; //当前行的按钮状态
for(i = 0;i<5;i++) //假设行号从0开始
{
//第一行的每一种按法,余下四行都确定的与之对应
result[i] = now; //先把第i行的按钮状态存起来
for(j=0;j<6;j++) //第i行第j个灯
{ //下面这段就是对i行的灯进行处理
if(getbit(now,j))//若是1,表示按下,按下才执行
{
if( j >0 )
verbit(&lights[i],j-1);//相邻左侧的灯取反
verbit(&lights[i],j);//自身取反
if( j<5 )
verbit(&lights[i],j+1);//相邻右侧的灯取反
}
}
if(i<4)
lights[i+1] ^= now; //反转下一行,1代表按下,与1异或就会取反
now = lights[i];//此处now存放的是第i+1行的按钮状态
//因为第i+1行的按钮需要保证第i行的灯全灭
//所以第i行灯的状态是什么样的,那第i+1行按钮的状态就是什么样的
}
if(lights [4] == 0)
{ //若最后一行lights [4] 全灭,则输出结果,其中t是第t个样例
output(t,result);
break;
}
}
}
return 0;
}
两组数据的运行结果:
(忘了删hello world了…)
总结
编程时,可根据问题本身特点,巧妙运用位运算,可大大执行算法效率。