暴力递归——N皇后详解 && 如何用位运算进行优化


N皇后

N皇后问题是指在N*N的棋盘上要摆N个皇后,要求任何两个皇后不同行、不同列,也不在同一条斜线上。给定一个整数n,返回n皇后的摆法有多少种。n=1,返回1。n=2或3,2皇后和3皇后问题无论怎么摆都不行,返回0。n=8,返回92

N皇后游戏原则:

  • 不能在同一行
  • 不能在同一列
  • 不能在同一条斜线上(左右斜线都不行)

现在我们来分析题目:

在一行上每次只摆一个皇后,从左往右开始摆,每一行都如此。并且记下这个皇后的位置。后续行数摆的时候只用避免共列和共斜线的问题。如果到某一行发现违反规则,那就退回上一行重新摆,将皇后右移一个位置,后续行数再接着递归。

如何记录皇后的位置,也就是记录皇后的坐标(x,y),只需要用一个数组既可。比如recored[7]=13,代表第7行的皇后在第13列;record[0]=3代表第0行的皇后在第3列

如何检测皇后是否冲突,共行问题不用考虑,因为一开始我们就规定每一行都是只摆放一个。假设有两个皇后的位置如下:

(a,b) 和 (c,d)

不共列:b != d

不共斜线:|a-c| != |b-d|

如果i来到了第n行,那就说明越界了,并且隐含的条件是之前行的摆放都有效,所以可以返回一种摆放结果。

如果没有来到第n行,说明当前行可以摆放皇后,那么就尝试这一行上所有的列,看是否与0~i-1行的皇后冲突。 如果不冲突,就记录好这一行皇后的位置并且去下一行递归;如果冲突就跳到下一列。

// 尝试i行上所有的列(0~n-1列)
        // 如果当前i行的皇后,放在第j列,
        // 不会和之前行(0~i-1行)的皇后冲突(不共行&&不共列&&不公斜线)
        // 那放第j列可以,认为有效,接着去i+1行递归
        // 否则,无效,放下一列
        for(int j=0; j<n; j++) {
            if(isValid(record, i, j)) {// record记录的是0~i-1行皇后的位置
                record[i]=j;// 第i行的皇后在第j列
                ans+=process1(i+1, record, n);
                // 因为在每一行都是直接改要放的列数,所以无需恢复现场
            }
        }

完整代码:

package com.harrison.class12;

public class Code08_NQueens {
    // 0~i-1行的皇后位置都摆放好了,不用再考虑,并且符合要求:不共行、不共列、不共斜线
    // i    表示当前来到第i行摆皇后
    // record[0~i-1]    存放摆好的记录
    // record[0]=3  ->  第0行皇后摆在第3列
    // n    表示一共有多少行(0~n-1行),如果来到n行就越界了
    // 在[0~i-1]行皇后都摆好的情况下,
    // 返回i及其后面行数摆放有效的方式有多少种(整个棋盘都摆好)
    public static int process1(int i,int[] record,int n) {
        // 如果i来到终止行,说明之前行数的摆放都有效,
        // 返回一种合理的摆放方式
        if(i==n) {
            return 1;
        }
        // 如果i没有到终止行,那么当前行可以摆
        int ans=0;
        // 尝试i行上所有的列(0~n-1列)
        // 如果当前i行的皇后,放在第j列,
        // 不会和之前行(0~i-1行)的皇后冲突(不共行&&不共列&&不公斜线)
        // 那放第j列可以,认为有效,接着去i+1行递归
        // 否则,无效,放下一列
        for(int j=0; j<n; j++) {
            if(isValid(record, i, j)) {// record记录的是0~i-1行皇后的位置
                record[i]=j;// 第i行的皇后在第j列
                ans+=process1(i+1, record, n);
                // 因为在每一行都是直接改要放的列数,所以无需恢复现场
            }
        }
        return ans;
    }
    
    // record[0~i-1]的皇后需要看,第i行的皇后不需要
    // 返回第i行皇后放在了第j列是否有效
    public static boolean isValid(int[] record,int i,int j) {
        for(int k=0; k<i; k++) {// 0~i-1行的某一行的皇后,假设是第k行的皇后
            if(record[k]==j || (Math.abs(record[k]-j)==Math.abs(i-k))) {
                return false;
            }
        }
        return true;
    }
    
    public static int nums1(int n) {
        if(n<1) {
            return 0;
        }
        int[] record=new int[n];// record[i] i行的皇后 放在了第几列
        return process1(0, record, n);
    }
    
    public static void main(String[] args) {
        int n=8;
        long start=System.currentTimeMillis();
        System.out.println(nums1(n));
        long end=System.currentTimeMillis();
        System.out.println("cost time:"+(end-start)+"ms");
    }
}

上面这种方法对于解决N皇后问题在如何尝试的方式上已经是最优的了,如果我们不去搞学术的话,对于工作尝试到这里就可以了,但是!!!不知道大家知不知道位运算是比普通运算快很多的呢?所以我们还可以在常数项方面优化一下,但是注意:用位运算优化后的时间复杂度还是跟原来一样,只是在常数项方面进行了优化,但这么一优化还是快了很多的,请接着往下看。

我们把N皇后问题中的列想象成一个正整数的二进制位。什么意思,比如,8皇后问题就只用最后面的八个二进制位;9皇后问题就只用最后面九个二进制位,,,依次类推。如果在哪一列放了皇后,就将该二进制位设为1(一开始全部为0)。也就是说,1代表放了皇后,0代表没有放。

比如8皇后,在第一行的第二列摆了皇后,

那么就是 01000000

当然,在Java里面,int是只占4个字节的整型,所以最多也只有32个二进制位,所以,用这种方式的话,不能超过32个皇后,因为摆不下。

注意:以下文字描述都是以8皇后举例子!!!

那么,当前行如何知道在哪该放皇后呢?主要看两点,第一,当前行是不是跟之前行的皇后共列,第二,之前行的皇后的左右斜线贯穿的位置,当前行不能放。

所以,接下来准备三个变量,列限制columnLimit,左斜线限制leftLimit,右斜线限制rightLimit。

columnLimit:00000000

leftLimit:00000000

rightLimit:00000000

在第一行的时候,是没有任何限制的,那就可以随便放。

假如放在第4列(下标从0开始算),那么上述三个变量就会变成:

columnLimit:00001000

leftLimit:00010000(columnLimit往左移一位)

rightLimit:00000100(columnLimit往右移一位)

ok,第i行上的皇后放好了,那么如何知道第i+1行上哪些列可以放皇后呢?

上述三个变量一起做或运算的结果就是总的限制:假设第i行第4列放了皇后(下标从0开始算),columnLimit | leftLimit |rightLimit 结果就是:00011100,代表第i+1行上第3、4、5列都不可以再放了。

00001000

00010000

00000100

00011100

好的,那么当前情况就是00011100, 0的位置还可以放皇后,所以每个位置都会去尝试一遍。

假设又在第1列放了个皇后(01000000),那么对于i+1行来说,此时的列限制 columnLimit 就是 01001000(之前是00001000),左斜线限制就是 10100000,右斜线限制就是 00100010。不仅受上一行列限制的影响,而且受之前所有行延申的左右斜线的影响。

给大家画个图加深理解:

暴力递归——N皇后详解 && 如何用位运算进行优化

那接下来对于i+1行的下一行的限制就是 上面三个变量一起做或运算后的答案——11101010,只能在剩下的三个零上放皇后。

01001000

10100000

00100010

11101010

处理完三个变量后,继续往下这么玩。。。

补充知识:如何提取二进制数中最右侧的1, => int Ans=N&((~N)+1),请参考这篇文章——利用异或运算的性质进行骚操作

两种方法的完整代码:

package com.harrison.class12;

public class Code08_NQueens {
    // 0~i-1行的皇后位置都摆放好了,不用再考虑,并且符合要求:不共行、不共列、不共斜线
    // i    表示当前来到第i行摆皇后
    // record[0~i-1]    存放摆好的记录
    // record[0]=3  ->  第0行皇后摆在第3列
    // n    表示一共有多少行(0~n-1行),如果来到n行就越界了
    // 在[0~i-1]行皇后都摆好的情况下,
    // 返回i及其后面行数摆放有效的方式有多少种(整个棋盘都摆好)
    public static int process1(int i,int[] record,int n) {
        // 如果i来到终止行,说明之前行数的摆放都有效,
        // 返回一种合理的摆放方式
        if(i==n) {
            return 1;
        }
        // 如果i没有到终止行,那么当前行可以摆
        int ans=0;
        // 尝试i行上所有的列(0~n-1列)
        // 如果当前i行的皇后,放在第j列,
        // 不会和之前行(0~i-1行)的皇后冲突(不共行&&不共列&&不公斜线)
        // 那放第j列可以,认为有效,接着去i+1行递归
        // 否则,无效,放下一列
        for(int j=0; j<n; j++) {
            if(isValid(record, i, j)) {// record记录的是0~i-1行皇后的位置
                record[i]=j;// 第i行的皇后在第j列
                ans+=process1(i+1, record, n);
                // 因为在每一行都是直接改要放的列数,所以无需恢复现场
            }
        }
        return ans;
    }
    
    // record[0~i-1]的皇后需要看,第i行的皇后不需要
    // 返回第i行皇后放在了第j列是否有效
    public static boolean isValid(int[] record,int i,int j) {
        for(int k=0; k<i; k++) {// 0~i-1行的某一行的皇后,假设是第k行的皇后
            if(record[k]==j || (Math.abs(record[k]-j)==Math.abs(i-k))) {
                return false;
            }
        }
        return true;
    }
    
    public static int nums1(int n) {
        if(n<1) {
            return 0;
        }
        int[] record=new int[n];// record[i] i行的皇后 放在了第几列
        return process1(0, record, n);
    }
    
    public static int nums2(int n) {
        if(n<1 || n>32) {
            return 0;
        }
        // n==8 limit最右边8个1,其余全是0
        // n==9 limit最右边9个1,其余全是0
        // n==32 最右边32位全是1 -> 十进制-1
        // n!=32 1左移n位再减一
        // 比如n==8 limit=100000000-00000001 -> 11111111
        int limit=n==32?-1:((1<<n)-1);
        // 一开始第0行的时候,没有任何限制
        return process2(limit, 0, 0, 0);
    }
    
    // limit 划定了问题的规模,是固定不变的参数
    public static int process2(int limit,int columnLimit,int leftLimit,int rightLimit) {
        if(columnLimit==limit) {// base case
            return 1;
        }
        // pos 当前行可以放皇后的位置 1:不能放    0:可以放
        // (columnLimit | leftLimit | rightLimit ) 总限制
        // 为什么要 & limit
        // 1)~(columnLimit | leftLimit | rightLimit ) 左侧的一坨0取反后变成1会干扰
        // 2)左斜线限制会越界,右斜线不会
        int pos=limit&(~(columnLimit | leftLimit | rightLimit ));
        int ans=0;
        int mostRightOne=0;
        // 尝试pos上的每一个1
        while(pos!=0) {
            mostRightOne=pos & ((~pos)+1);
            pos=pos-mostRightOne;
            ans+=process2(limit, 
                          columnLimit | mostRightOne,
                          (leftLimit | mostRightOne)<<1, 
                          (rightLimit | mostRightOne)>>>1);
        }
        return ans;
    }
    
    public static void main(String[] args) {
        int n=13;
        long start=System.currentTimeMillis();
        System.out.println(nums1(n));
        long end=System.currentTimeMillis();
        System.out.println("cost time:"+(end-start)+"ms");
        
        start=System.currentTimeMillis();
        System.out.println(nums2(n));
        end=System.currentTimeMillis();
        System.out.println("cost time:"+(end-start)+"ms");
    }
}

两种方法的时间复杂度都是O(N^N)。

但是用位运算优化后,还是肉眼可见的快。

以下是13皇后的测试结果,

暴力递归——N皇后详解 && 如何用位运算进行优化

N皇后问题比较难,尤其是用位运算优化比较难理解,博主费心写下这篇文章做个记录,因为有的面试官可能会问到这些经典的问题。

感谢您的三连或点赞评论支持????‍????‍????‍


上一篇:动态规划——StickersToSpellWord


下一篇:暴力递归——从左往右的尝试模型2,背包问题