【PKU算法课0x00】枚举

枚举

何谓枚举?

所谓枚举,就是逐个尝试答案的求解策略。听上去傻傻的,就是把所有的可能都试一遍而已,实则不然。

为什么要枚举?

现实中有很多问题是可以直接用公式求解的。比如已知华氏温度求摄氏温度等,这类问题的时间复杂度是O(1)的,因为这样直接套用公式就能解决,这也是程序设计的最高境界。
然而有很多问题是没有一个确定的数学公式可以解出来的,那我们就只能采用某些策略一个一个的试,这就叫枚举。
当然我们不能傻傻的枚举,我们可以通过一些技巧:比如缩小枚举的范围(剪枝),设置枚举的顺序(制定有利的策略)来提高枚举的效率。

枚举的经典例题

完美立方

问题描述:

形如 a3 = b3 + c3 + d3 的等式被称为完美立方等式。例如123 = 63 + 83 +103 。编写一个程序,对任给的正整数 N (N≤ 100),寻找所有的四元组 (a, b, c, d),使得 a3 = b3+c3 + d3 ,其中 a,b,c,d 大于 1, 小于等于 N ,且 b<=c<=d。

输入:

一个正整数N (N≤100)

输出:

每行输出一个完美立方。
输出格式为: Cube = a, Triple = ( b,c,d)
其中 a,b,c,d所在位置分别用实际求出四元组值代入。

说明:

请按照a 的值,从小到大依次输出。
当两个完美立方等式中 a 的值相同,则 b 值小的优先输出、仍相同则 c 值小的优先输出、再相同则 d值小的先输出。

样例输入:

24

样例输出:

Cube = 6, Triple = (3,4,5)
Cube = 12, Triple = (6,8,10)
Cube = 18, Triple = (2,12,16)
Cube = 18, Triple = (9,12,15)
Cube = 19, Triple = (3,10,18)
Cube = 20, Triple = (7,14,17)
Cube = 24, Triple = (12,16,20)

问题分析:
枚举的谁都会,最烂的枚举算法就是完全无脑暴力破解,这种解法的耗时也是最长的。我们想要用效率高的枚举算法,就要对问题进行透彻的分析。
其中 a,b,c,d 大于 1, 小于等于 N ,且 b<=c<=d。题目中是这么说的,而且成立的公式是a3=b3+c3+d3.
我们先进行第一轮的分析,根据a,b,c,d大于1,小于等于N,我们可以得出结论:
a到d肯定在[2,N]这个区间中取值。
再进行第二轮分析:根据b<=c<=d,我们可以得出结论:
c肯定在[b,N]这个区间中取值。
d肯定在[c,N]这个区间中取值。
再进行第三轮分析:由于a,b,c,d都是正整数,所以a3和b3+c3+d3都是增函数,我们可以得出结论:
b、c、d都小于a。(不能等于a,因为b3、c3、d3肯定都是正数)
综上所述,我们可以得到各值的取值范围:
a:[2,N]
b:[2,a-1]
c:[b,a-1]
d:[c,a-1]
我们到这儿就可以书写代码了。

由于值比较少,逻辑比较清晰,我们可以直接写几个for循环。

AC代码:

class Solution:
    def __init__(self,N):
        self.N = N
    def solution(self):
        for a in range(2,self.N+1):
            for b in range(2,a):
                for c in range(b,a):
                    for d in range(c,a):
                        if a**3 == b**3 + c**3 + d**3:
                            print('Cube = %s, Triple = (%s,%s,%s)' %(a,b,c,d))
s = Solution(24)
s.solution()

生理周期

问题描述:

人有体力、情商、智商的高峰日子,它们分别每隔 23 天、 28 天和33 天出现一次。 对于每个人,我们想知道何时三个高峰落在同一天。
给定三个高峰出现 的日子 p,e 和 i (不一定是第一次高峰出现的日子 )), 再给定另一个指定的日子d ,你的任务是输出 日子 d之后 ,下一次三个高峰落在同一天的日子用距离d的天数表示 )。例如:给定日子为10 ,下次出现三 个高峰同一天的日子是 12 ,则输出 2 。

输入:

输入四个整数: p, e, i 和 d 。p, e, i 分别表示体力、情感和智力高峰出现的日子 。 d 是给定的日子 ,可能小于 p, e或 i 。所有给定日子 是非负的并且小于或等于 365 ,所求的日子 小于或等于21252 。

输出:

从给定日子起,下一次三个高峰同一天的日子(距离给定日子的天数)。

输入样例:

0 0 0 0
0 0 0 100
5 20 34 325
4 5 6 7
283 102 23 320
203 301 203 40
-1 -1 -1 -1

输出样例:

Case 1: the next triple peak occurs in 21252 days.
Case 2: the next triple peak occurs in 21152 days.
Case 3: the next triple peak occurs in 19575 days.
Case 4: the next triple peak occurs in 16994 days.
Case 5: the next triple peak occurs in 8910 days.
Case 6: the next triple peak occurs in 10789 days.

问题分析

首先我们分析下问题,由于体力、情商、智商高峰出现的日子是不确定的,指定的日子d也是不确定的,所以这道题只能用枚举的方式来做。
认真读题后我们发现,在1-23*28*33中肯定有答案,为啥?因为23*28*33是它们的最小公倍数。所以所有的答案肯定在这个范围内。
然后我们找到的这个值有个什么特征呢?它距离p的距离肯定可以模23,距离e的距离肯定可以模28,距离i的距离肯定可以模33.
所以我们可以写出来一种朴素的代码,即遍历所有可能的日子,即1-22*28*33,每次加1即可。
但是这样做我们如果运气非常不好的话,要上万次的遍历。效率有点低,所以我们想想有没有办法可以改进这一点。
我们发现在遍历的过程中,每次都+1是没有必要的,因为假设我们遍历到了某个值是23的倍数,我们再往下遍历+1就没有意义了,因为给出的解肯定可以模23,所以我们可以往下遍历时+23. 以此类推,如果我们这样遍历到某个值同时是23和28的倍数,那我们每次往下遍历时就应该+23*28.为啥呢?因为我们的候选值肯定是23*28的倍数。

AC代码1(普通迭代):

class Solution:
    def __init__(self,p,e,i,d):
        self.p = p
        self.e = e
        self.i = i
        self.d = d
    def solution(self):
        for i in range(1,23*28*33+1):
            if (i-self.p)%23 or (i-self.e)%28 or (i-self.i)%33:
                pass
            else:
                print(i-self.d)
                return
s = Solution(0,0,0,0)
s.solution()
s = Solution(0,0,0,100)
s.solution()
s = Solution(5,20,34,325)
s.solution()
s = Solution(4,5,6,7)
s.solution()
s = Solution(283,102,23,320)
s.solution()
s = Solution(203,301,203,40)
s.solution()

AC代码2(高效跳着找):

class Solution:
    def __init__(self,p,e,i,d):
        self.p = p
        self.e = e
        self.i = i
        self.d = d
        '''
        跳着查,以求高效。
        '''
    def solution(self):
        step = 1
        i = 1
        while i < 23*28*33+1:
            if (i-self.p)%23 or (i-self.e)%28 or (i-self.i)%33:
                if (i-self.p)%23:
                    pass
                elif (i-self.e)%28: # 如果已经找到可以模23的了,那么每次加23,就每次都能整除self.p
                    step = 23
                else: # 同理。
                    step = 23*28
                i += step
            else:
                print(i-self.d)
                return
s = Solution(0,0,0,0)
s.solution()
s = Solution(0,0,0,100)
s.solution()
s = Solution(5,20,34,325)
s.solution()
s = Solution(4,5,6,7)
s.solution()
s = Solution(283,102,23,320)
s.solution()
s = Solution(203,301,203,40)
s.solution()

称硬币

问题描述

有12枚硬币。其中有11枚真币和1枚假币。假币和真币重量不同,但不知道假币比真币轻还是重。现在,用一架天平称了这些币三次,告诉你称的结果,请你找出假币并且确定假币是轻是重(数据保证一定能找出来)。

输入:

第一行是测试数据组数。 每组数据有三行,每行表示一次称量的结果。银币标号为A-L。每次称量的结果用三个以空格隔开的字符串表示:天平左边放置的硬币 天平右边放置的硬币 平衡状态。其中平衡状态用 up, down, 或even 表示 , 分别为右端高、右端低和平衡。 天平左右的硬币数总是相等的。

输出:

输出哪一个标号的银币是假币,并说明它比真币轻还是重。

输入样例:

1
ABCD EFGH even
ABCI EFJK up
ABIJ EFGH even

输出样例:

K is the counterfeit coin and it is light.

问题分析:

这道题我们一看就一头雾水,这怎么能确定到底是哪个呢?找来找去写一堆判定逻辑,最后把自己写晕了也没A。我们可以用模拟的思想,对所有的硬币进行遍历。假币的特征就是它跟真币的重量不一样,所以我们可以假设某个硬币为假币,先认为它比真币轻,看看符合不符合判定结果,然后再认为它比真币重,看看符合不符合。如果都不符合,故它是一枚真币。怎么认为它比真币轻还是重呢?我们可以给真币赋值为2,给轻假币赋值为1,给重假币赋值为3,这样就可以进行比较了。

AC代码:

class Solution:
    def __init__(self):
        self.left = []
        self.right = []
        self.result = []
        self.light = 1 # 认为轻币的重量为1
        self.heavy = 3 # 认为重币的重量为3
        self.normal = 2 # 认为正常硬币的重量为2
    def initData(self): # 初始化输入数据
        for i in range(3):
            curs = input().split()
            self.left.append(curs[0])
            self.right.append(curs[1])
            self.result.append(curs[2])
    def isFake(self,c,light=True): # 初始认为它轻
        if light:
            curWeight = self.light
        else:
            curWeight = self.heavy
        for resIndex in range(len(self.result)):
            # 计算天平两边硬币的重量
            leftWeight = self.left[resIndex].count(c)*curWeight + (len(self.left[resIndex])-self.left[resIndex].count(c))*self.normal
            rightWeight = self.right[resIndex].count(c) * curWeight + (len(self.right[resIndex]) - self.right[resIndex].count(c)) * self.normal
            # 对假设正确性进行校验
            if leftWeight == rightWeight and self.result[resIndex] == 'even':
                continue
            elif leftWeight < rightWeight and self.result[resIndex] == 'down':
                continue
            elif leftWeight > rightWeight and self.result[resIndex] == 'up':
                continue
            else:
                return False
        return True # 通过了所有校验,证明我们的假设是正确的
    def main(self):
        for i in range(0,ord('L')-ord('A')+1): # 遍历操作
            curs = chr(i + ord('A'))
            if self.isFake(curs):
                print('%s is the counterfeit coin and it is light.' %curs)
                break
            elif self.isFake(curs,False):
                print('%s is the counterfeit coin and it is heavy.' %curs)
                break


s = Solution()
s.initData()
s.main()

熄灯问题

问题描述

有一个由按钮组成的矩阵 , 其中每行有6个按钮 , 共5行
每个按钮的位置上有一盏灯
当按下一个按钮后 , 该按钮以及周围位置上边 , 下边 , 左边 , 右边的灯都会改变状态
如果灯原来是点亮的 , 就会被熄灭
如果灯原来是熄灭的 , 则会被点亮
在矩阵角上的按钮改变3盏灯的状态
在矩阵边上的按钮改变4盏灯的状态
其他的按钮改变5盏灯的状态
与一盏灯毗邻的多个按钮被按下时 一个操作会抵消另一次操作的结果
给定矩阵中每盏灯的初始状态,求一种按按钮方案,使得所有的灯都熄灭

输入:

第一行是一个正整数N, 表示需要解决的案例数
每个案例由 5 行组成 , 每一行包括 6 个数字
这些数字以空格隔开 , 可以是0或1
0 表示灯的初始状态是熄灭的
1 表示灯的初始状态是点亮的

输出:

对每个案例 , 首先输出一行 ,输出字符串"PUZZLE #m", 其中 m 是该案例的序号
接着按照该案例的输入格式输出5行。1表示需要把对应的按钮按下,0表示不需要按对应的按钮.每个数字以一个空格隔开。

样例输入:

2
0 1 1 0 1 0
1 0 0 1 1 1
0 0 1 0 0 1
1 0 0 1 0 1
0 1 1 1 0 0
0 0 1 0 1 0
1 0 1 0 1 1
0 0 1 0 1 1
1 0 1 1 0 0
0 1 0 1 0 0

样例输出:

PUZZLE #1
1 0 1 0 0 1
1 1 0 1 0 1
0 0 1 0 1 1
1 0 0 1 0 0
0 1 0 0 0 0
PUZZLE #2
1 0 0 1 1 1
1 1 0 0 0 0
0 0 0 1 0 0
1 1 0 1 0 1
1 0 1 1 0 1

问题分析:
看到这题我的内心是崩溃的,5行6列,遍历的话写for循环要写n个,而且每个元素有0和1两个状态,遍历完全的话就有230个状态。这个量级的计算量,不管是用for循环硬上还是写一个优美的递归函数来搞都不会有太好的结果。肯定会超时的。而且给定的灯的状态是不固定的,所以我们还需要判定怎样按开关才能得到想要的结果。而且我们要想办法存储灯的状态,还要分心判断此时符不符合题意。而且我们还要存储开关是怎么按的这一集合。而且,开关还能按很多次,有可能按1次,还有可能按2次,3次。。。还有可能不按。分析到这儿简直想原地爆炸。
但是,心态不要崩。
首先我们要想清楚,开关不按是有意义的,按一下也是有意义的,按两下就没有意义了。为啥?因为按两下相当于没按。而且,这些按钮先按哪个后按哪个是没有关系的,我们要的就是按钮最终的一个状态。
对于一个看上去不咋容易的题目,我们首先要考虑枚举,枚举能搞出来,再想数据量问题,以及怎么快速枚举。实在不行再用别的算法。当然我们这里用了枚举。我们可以运用各种技巧来使我们的程序写起来简单,而且跑起来很快。
通过简单的枚举思考,我们发现可能的方案数是230,肯定会超时,所以对枚举进行优化。枚举时我们一个很常见的思路就是:枚举时可以先考虑枚举局部,如果局部被确定之后,剩下的部分就都确定了是一种方案,或者是不多的n种方案的话,我们就可以只枚举局部的状态。
我们考虑了一下:发现第一行就是这样的一个局部。第一行会影响第一行自己和第二行,…除了最后一行之外,第n行都会影响第n行和第n+1行的亮灭状态。说到这里,你可能会说,不对吧?第二行开关也可以影响第一行的亮灭啊?对!关键点就在这里,我们第一行的枚举并不非要保证能把第一行的灯全部关掉,这个任务交给谁?交给第二行。所以说,第i行开关作用后还没灭掉的灯,交给i+1行进行处理。所以如果第i行的开关状态确定了,第i+1行的开关状态就是确定的,否则会给第一行添乱。所以我们把这题从230降到了26

思路有了,看看怎么实现吧。二维数组,可不好实现呢。因为如果用二维数组存储的话,我们在改变灯的状态时,即枚举时需要写6个for循环来控制。而且我们通过观察发现这题的数据只有0和1,所以我们完全可以用char类型的数组来搞定这个题目。(为什么用char呢?其实对于java来讲,用byte数组也一样的,这里用char是为了追求极致的空间复杂度,毕竟C语言可没有byte)。char类型的变量有8个bit, 而一行才6个bit。 所以我们要存储灯的状态,只需要开一个char类型的数组即可。
我们要开关起作用的话,就是说,会改变这个开关所在位置的开关的状态以及灯的状态以及与它相邻的四个位置的灯的状态。改变状态无非就是把某一位从0变成1,或者从1变成0,我们可以用位运算来进行,因为我们是把每一行存在一个char类型的变量里面的,我们改变状态都是对char中的某个bit进行操作,所以我们就会用到位运算。

AC代码:

package program;

import java.math.*;
import java.io.*;
import java.util.*;
/*
* 题目思路:
*   第一行随机,然后更新第一行和第二行的状态
*   第二行初始化switch默认为能关掉第一行的所有灯,然后初始化第二行的状态,改第三行
*   以此类推。
*   如果最后一行的switch正好能关掉最后一行的所有灯,那么找到答案。
* */
public class Main {
    public static void main(String[] args) {
        int T; // T组数据
        char switchs; // 代表某一行的开关
        Scanner scanner = new Scanner(System.in);
        T = scanner.nextInt();
        for(int t = 0; t < T; ++t){
            // T组数据
            Solution solution = new Solution();
            // 读入数据操作
            for(int i = 0; i < 5; ++i){
                for(int j = 0; j < 6; ++j){
                    int curStatus;
                    curStatus = scanner.nextInt();
                    solution.setBit(i,j,curStatus);
                }
            }
            //主逻辑
            for(int n = 0; n < 64; ++n){
                // 还原lights 为 originLights.
                for(int j = 0; j < 5; ++j){
                    solution.lights[j] = solution.oriLights[j];
                }
                switchs = (char)n; // 第i行的开关状态
                for(int i = 0; i < 5; ++i){
                    solution.result[i] = switchs;
                    for(int j = 0; j < 6; ++j){
                        // 用当前按下的开关改变当前行灯的状态
                        if(solution.getBit(i,j) > 0){
                            if(j > 0){
                                // 处理灯不在边缘的情况
                                solution.flipBit(i,j-1);
                            }
                            solution.flipBit(i,j);
                            if(j < 5){
                                solution.flipBit(i,j+1);
                            }
                        }
                    }
                    if(i < 4){
                        // 如果不是最后一行,则还要改变下一行的状态
                        // 至于为什么不改变上一行的状态?为啥?
                        // 因为本行的switch一定是从上一行的结果中捡漏捡下来的
                        // 它对上一层的影响就是让上一层的灯全部关闭
                        // switch怎么捡漏请看下面更新switch的代码
                        solution.lights[i+1] ^= switchs;
                        /*
                        * 异或特点: 0与0异或得0,0与1异或得1
                        *          1与0异或得1,1与1异或得0
                        * 所以如果要按位取反,则要用异或*/
                    }
                    // switchs定成这样的目的就是把上层未关闭的灯(即为1的灯)关闭
                    switchs = solution.lights[i];
                }
                if(solution.lights[4] == 0){
                    solution.outPutResult(t+1);
                    break;
                }
            }
        }
    }
}

class Solution{
    /*
    * 0与别人异或,不改变别人的值; 1与别人异或,改变别人的值。
    * 所以要取反某一位的话,就用0000010000与别人异或,哪里要改哪里取1
    *
    * 查看某一位的状态时,可以让这一位右移到最右边,与1做与运算。
    *
    * 强制把某一位设置为1,应当用|运算的特点。
    * |的特点是,0与别人或,没影响。1与别人或,把别人搞成1.
    * 
    * 强制把某一位设置为0,应当用&运算的特点。
    * &的特点是,1与别人&,不改变别人。0与别人&, 把别人搞成0.
    * */
    char[] oriLights = new char[5];
    char[] lights = new char[5];
    char[] result = new char[5];
    int getBit(int index, int i){
        return (this.result[index]>>i) & 1; // 获取第i位的状态,0 or 1
    }
    // 所谓的setBit,其实就是init originLights.
    void setBit(int index, int i, int v){
        if(v > 0){
            this.oriLights[index] |= (1<<i); // 将c的第i位设置为1
        }else{
            this.oriLights[index] &= ~(1<<i); // 将c的第i位设置为0
        }
    }
    void flipBit(int index, int i){
        this.lights[index] ^= (1<<i);
    }
    void outPutResult(int t){
        System.out.printf("PUZZLE #%d\n",t);
        for(int i = 0; i < 5; ++i){
            for(int j = 0; j < 6; ++j){
                System.out.printf("%d",this.getBit(i,j));
                if(j < 5){
                    System.out.print(" ");
                }
            }
            System.out.println();
        }
    }
}

总结

我们写程序的总体思路:
先分析题意,确定用何种方法来解题。先考虑有没有公式能解决这个问题,有公式就用公式,没公式的话先考虑用枚举。如果枚举数量级很大,我们就用聪明的枚举,分析题意,查找规律,看看能否剪枝,或者是说枚举局部管不管用,都不管用的话再另求他法。
确定好我们的策略之后,我们就要开始写代码。我们要考虑怎么存储数据更加有效,更加易于程序的编写,更加友好的空间复杂度。
关于位运算的技巧我们也给出来了。

/*
    * 0与别人异或,不改变别人的值; 1与别人异或,改变别人的值。
    * 所以要取反某一位的话,就用0000010000与别人异或,哪里要改哪里取1
    *
    * 查看某一位的状态时,可以让这一位右移到最右边,与1做与运算。
    *
    * 强制把某一位设置为1,应当用|运算的特点。
    * |的特点是,0与别人或,没影响。1与别人或,把别人搞成1.
    *
    * 强制把某一位设置为0,应当用&运算的特点。
    * &的特点是,1与别人&,不改变别人。0与别人&, 把别人搞成0.
    * */
发布了333 篇原创文章 · 获赞 22 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/weixin_41687289/article/details/104097944