一个无关紧要的序 (欢迎跳过)
我第一次看到N皇后问题 是在C++补充递归习题里.
然后, 大概期中前, 老师布置了算法娱乐题, 其中有两道是跟N皇后有关的:
第一个是输出(一个)符合要求的解序列, 第二个是输出解个数.
第一遍撸oj的时候那个通过率简直了...
再之后, 我, 踏入了慢慢优化探索路, 结果迄今为止还是有一个sample无法在规定时间内通过. (╯‵□′)╯︵┻━┻
当然, 我决定把这篇拖欠许久的CDSN整理出来的重要原因是:
今天讲到了DFS, 我又看不懂自己以前写的啥了 (╯‵□′)╯︵┻━┻
(虽然我知道我肯定理解了最基础的版本..
(再感慨一句, 想不到以前爱写空间日志45°忧伤望天的少女 最近重写blog竟然是为了这个..
人森啊!!!!!!!!!!!!!!!!
提纲
1.基础回溯法的N皇后问题
2.标记优化及对称优化
3.*二进制优化
1.基础回溯法的N皇后问题
其实只要看过, "回溯法"是很好理解的. 我喜欢用"试错"来理解它.
想象一下我们面对的不是抽象的代码, 而是一个实体的棋盘, 然后我们要解这个问题, 怎么办呢?
很简单, 我们一个一个放.
比如,把第1个皇后放在第一行第一列, 然后我们看哪里可以放第2个, 显然周围3个格子都不行.
以此类推, 相信你很容易就能找到(试出)一个可行解.
(如果不理解的话, 网上搜一下, 图示怎么回溯的很多, 或者手动的画一个棋盘就懂了.这里不再赘述.
接下来问题就转换为: 我需要如何找到所有可行解?
这个问题也很直观: 我们把所有的皇后从棋盘上拿下来, 然后再重新放. 当然, 要和之前放的不一样.
有了以上基础的思想之后, 我们在考虑第三个问题: 如何模型化?
模型化有两个方面: 第一, 可行解的表示;
第二, 如何把N皇后的条件 "行不冲突, 列不冲突, 对角线不冲突"表示出来.
首先, 关于第一个问题: 可行解的表示.
这里, 用二维数组表示棋盘的方式很直观, 但我们是不需要二维数组的.
原因是, 皇后问题和数独不同, 一行只能摆一个皇后. 想像一下, 假设你建立了一个queen[i][j], 对于某个给定的i, 如queen[1][j], 其中只有一个数会是1, 其他都是0. 这本身就会比较浪费空间, 而且, 不利于行冲突的判断.
因此, 我们只需用a[i]=j 的一维数组, 表示在点(i,j)处放置皇后.
其次, 第二个问题.
假设已有a[i]=j了, 就说明这一行已经放置皇后, 就无需再另外对这一行写判断条件了.
对于任意两行i 与 t, 假设i和t列冲突, 考虑其对应的点(i, iy); (t,ty), 必有iy==ty. 如果再联系我们数组的表示方式, 判断条件即可表示为a[i]==a[t].
类似地, 我们可以表示出斜线的条件: abs(i-t)==abs(a[i]-a[t]).
用斜线的斜率为1来理解就好了, 但是要考虑到有正反两种情况, 所以要加绝对值.
分析到这里, 我们已经可以写出一个判断函数了:
bool place(int t)//判断是否放置的函数, 对于第t行往回看
{
for (int i = 1; i < t; i++)
{
if (a[i] == a[t] || abs(i - t) == abs(a[i] - a[t]))
return 0;
}
return 1;
}
不过这里的需要for循环, 需要结合回溯的部分来理解
回溯法的思想我前面已经解释过了, 那么现在我们就直接来看代码 (我自己也是配合着代码理解清楚的~
void NQueen(int t) //第t行,e.g通常为1初始,往后直到n,判断出所有的解
{
if (t > n)
sum++;
else
{
for (int i = 1; i <= n; i++)
{
a[t] = i; //将皇后先放置在第i列上, 此时对应的坐标是(t,i);如果不通过if,就放在第i+1列上, 以此类推;
if (place(t))
NQueen(t + 1);
//可以放置,紧接着判断下一行,直到第n行, 下一次返回NQueen(t+1),找到一种可能;
/*注意理解这里: 递归是在if里完成的, 也就是说,找出一种可能解之后,还会返回for循环体内
探索的顺序是,先把皇后放在(1,1),然后探寻所有可能解(每一个子问题都是类似的)->放在(1,2)->放在(1,3)..由此可确保遍历所有的情况
而NQueen(t+1),我们知道初始值是t=1,那么NQueen2可以看作解一个子问题,棋盘缩小为第二行到第n行(因为如果能放置,第一行第一列都不能再放棋盘)*/
}
}
}
如果还是不好理解的话, 手动走几波递归的过程, 你会形成一个自己的理解方式.
我自己的理解方式是, 把中间的Nqueen(t+1)递归在脑海里大致的展开, 可能就会出现一个类似于以下的结构:
Nqueen{1: a[1]=1
{Nqueen2:a[2]=1->if不通过->a[2]=2...
{Nqueen3: ....}
}
}
也就是, 把相对抽象的递归式Nqueen(n+1)具象化.
如果还是不理解, 就手动画一个, 配合着代码, 相信你能get到的!
(下面这张图只是我画着玩的..毕竟每个人都有自己的理解方式~)
2.标记及对称优化
这一部分主要参考了<算法竞赛宝典>
仍然采用回溯法, 但是采取了两部分的优化:
1)标记优化
即采用y, x1, x2三个数组, 分别标记列,向右倾斜的斜线, 向左倾斜的斜线.
如果不存在冲突(没有放皇后)就是0, 如果放了皇后就把所在的列, 右斜线, 左斜线标记为1.
列很容易想到. 如果是放在(x,i)处, 我们只要把y[i]改为1即可.
而 右斜线和左斜线怎么表示呢?
我们发现, 有左斜线:x=i+k, 即差为定值;
而对于右斜线, 有x=-i+k, 即和为定值.
而在NxN的棋盘上, 各有正斜线, 反斜线2*N-1条.
下面我们考虑k的取值范围, 以考虑数组下表的考虑方式.
对于右斜线, x+i=k, k的取值是从1~15, 均是正数, 所以可以直接用x1[x+i]来进行标记 ;
左斜线x=-i+k, k的取值是-7~7, 但是数组下标没有负数. 我们只要用模的思想加个n就可以, 即用x2[x-i+n]来表示
//由于某喵实在是不想另外在画图, 这部分直接阅读起来可能会有点抽象><可以直接在上面的草稿图上笔画一下嘻嘻
或者参考这篇文章, 写的很清晰, 且配有图片: https://www.cnblogs.com/Peper/p/7966253.html
2) 对称优化
这个比较难想到, 我这里直接贴一张<算法竞赛宝典>上的图:
可以发现, 对称具体来说有两个优化细节:
1) 利用棋盘的对称, 只用回溯一半
2) 当第一个皇后放在奇数棋盘正中间时, 左右对称也是重复的
当然, 对于最后的结果要x2.
我们上面的函数是place(n), 我们现在写成place(n,k)的形式, 即加上一个变量k来控制回溯的上界
分析到这里, 基本可以直接上代码啦, 如果懂了基础的回溯思想和这两处优化, 一定也不难理解~~
#include<iostream>
using namespace std;
int n, sum = 0;
#define N 32
#define N1 65
int a[N] = { 0 };
bool y[N1], x1[N1], x2[N1];
//y标记列, x1标记右斜线, x2标记左斜线
void change(int x, int i, int flag)
{
if (flag != 0 && flag != 1) return;
y[i] = flag;
x1[x + i] = flag;
x2[x - i + n] = flag;
}
void Nqueen(int x, int k) //x表示行
{
if (x > n)
{
++sum;
}
else
{
for (int i = 1; i <= k; ++i)
{
if ((y[i] == 0) && (x1[x + i] == 0) && (x2[x - i + n] == 0)) //如果都没有放置的话
{
a[x] = i;
change(x, i, 1);
if (n % 2 != 0 && x == 1 && a[1] == (n + 1) / 2)
Nqueen(2, (n + 1) / 2 - 1);
else
Nqueen(x + 1, n);
change(x, i, 0);
}
}
}
}
int main()
{
cin >> n;
Nqueen(1, (n + 1) / 2);
sum *= 2;
cout << sum << endl;
system("pause");
return 0;
}
附上<算法竞赛宝典>这一部分的贴吧链接供参考: http://tieba.baidu.com/p/4585273415
3. *二进制优化
优化到第2步, 求sum的还是有两个没通过, 手动微笑:)
于是我孜孜不倦第尝试了二进制优化. (其实到了这部分, 并不是在算法本身上优化了, 所以我觉得也不算特别重点的内容)
初始版本的二进制优化就不另外解释了, 直接贴代码
更详尽的解释请戳: https://www.cnblogs.com/albert1017/archive/2013/01/15/2860973.html
这篇博客也写的很好
#include<iostream>
#include <bitset>
using namespace std;
long sum = 0, upperlim = 1;
void Nqueen(long row, long ld, long rd)
{
if (row != upperlim)
{
long pos = upperlim & ~(row | ld | rd); //或运算:把有1的位都找出来,取反置0
//与操作之后,1&0=0,不可以放; 1&1=1,剩下的就是可以放的地方;
while (pos)//pos=0时均不能放置
{
long p = pos & -pos; //p是取出最右边的1(不是最右边1位, 而是最右的一个1...)
pos -= p; //将pos取的数清0, 获取下一次可用的位置
//row, 列限制; 另外两个是对角线限制
Nqueen(row + p, (ld + p) << 1, (rd + p) >> 1);
}
}
else
{
sum++;
}
}
int main()
{
int n = 0;
cin >> n;
upperlim = (upperlim << n) - 1;
Nqueen(0, 0, 0);
cout << sum << endl;
//system("pause");
return 0;
}
下面这个代码 是我基于对称的思想做的修改;
但是为什么奇数跳过了呢.. 因为考虑我在2里面讲到的"第一个皇后放中间"的对称情况, 你直接求一半*2肯定会有重复解, 必须要做相应的处理.
我在第一次搞2进制优化时比较懒, 就直接跳过奇数部分的优化了.
#include<iostream>
#include <bitset>
using namespace std;
long sum = 0;
long upperlim = 1;
long halfup = 0;
void Nqueen(long row, long ld, long rd)
{
if (row != upperlim)
{
long pos = upperlim & ~(row | ld | rd);
while (pos)
{
long p = pos & -pos;
pos -= p;
if (row == 0 && p > halfup) continue;
else Nqueen(row + p, (ld + p) << 1, (rd + p) >> 1);
}
}
else
{
sum++;
}
}
int main()
{
int n = 0;
cin >> n;
if (n % 2 == 0)
{
halfup = (n - 1) / 2;
halfup = upperlim << halfup;
}
else
halfup = (upperlim << n) - 1;
upperlim = (upperlim << n) - 1;
Nqueen(0, 0, 0);
if (n % 2 == 0) sum *= 2;
cout << sum << endl;
//system("pause");
return 0;
}
最近一次二进制优化是号称"目前最快的N皇后算法", 作者是Jeff Somers. 我基本上是一行一行对着注释才看懂的
(不过由于翻译注释的原因, 有些部分仍然存疑, 为了避免理解误会, 我也没有删掉)
这里也直接贴带注释的代码吧~
Jeff Somers 整个代码比较长, 我做了改动, 只选取了求sum的核心部分; 有些变量名也换成了让自己容易理解的名称.
完整版可以参考: http://www.bubuko.com/infodetail-709521.html
或者百度一下"最快的N皇后算法"都很容易找到
#include <iostream>
using namespace std;
#define MaxSize 21 //win32
//Ref: Jeff Somers'Solution
int sum = 0;
void Nqueen(int size)
{
int Res[MaxSize];//结果列;
int Col[MaxSize]; //列冲突标记
int PDiag[MaxSize]; //正对角线标记
int NDiag[MaxSize]; //负对角线标记;
int Stack[MaxSize + 2]; //代替回溯
register int* pStack;
register int numrows = 0; //可以用stack的标记
register unsigned int lsb; //最小为1的位, 即所有为1的位中最靠右的
register unsigned int bitfield; //放置可能的位置, 参考二进制位递归版本的pos
int i;
int odd = size & 1; //取最后一位, 如果是偶数, 取出的是0; 奇数取出1, 很好理解
int size_1 = size - 1;
int uplim = (1 << size) - 1; //构造一个对应棋盘数全是1的数列
//stack数组初始化
Stack[0] = -1;
for (i = 0; i < (1 + odd); ++i) //如果是偶数, i=0就可以退出循环了
//注意这是整体循环
{
bitfield = 0;
if (0 == i) //如果是0, 无论奇数偶数都一样处理
{
int half = size >> 1; //即除以2;
bitfield = (1 << half) - 1; //类似upplim, 往右移half即可
pStack = Stack + 1;
Res[0] = 0;
Col[0] = PDiag[0] = NDiag[0] = 0;
}
else //i==1, 也就是整体size是奇数时, 将最中间的bit设置为1
//并且将下一行的一半设置为1序列, 所以只需要处理第一行及接下来的一半
//这个情况必须考虑到,否则都对半解会漏解 (参照初始位操作版本的折半优化demo)
//也就是类似于数组优化里, 只考虑第一行放在中间时,做一个特殊处理, 避免对称解
{
bitfield = 1 << (size >> 1); //将1右移size的一半;
numrows = 1;
Res[0] = bitfield;
Col[0] = PDiag[0] = NDiag[0] = 0;
Col[1] = bitfield; //中间那列就不能再放;
//处理第二行
NDiag[1] = (bitfield >> 1); //在负对角线上,放在中间的元素会影响下一行右边一个数
PDiag[1] = (bitfield << 1); //正对角线同理
pStack = Stack + 1;
*pStack++ = 0; //?第一行也放完了, 所以stack置0--存疑.
bitfield = (bitfield - 1) >> 1; //bitfield-1: 所有的1最左边? all 1's to the left of the single 1?
}
/*关键的循环*/
for (; ; )
{
lsb = -((signed)bitfield) & bitfield; //参考pos & -pos的原因; 取出第一个1;
if (0 == bitfield) //while(pos)
{
bitfield = *--pStack; //将前一位取出来,相当于手动回溯
if (pStack == Stack) {//如果已经退回到首地址了, 就填UI出循环, 这很重要
break;
}
--numrows;
continue;
}
bitfield = bitfield & (~lsb); //把bitfield中lsb取出来的这一位置0;
Res[numrows] = lsb; //标记这一行皇后放置位置;
if (numrows < size_1) //因为是从0开始的下标,所以size-1, 否则不能继续循环
//如果还有没有试完的列
{
int n = numrows++;
Col[numrows] = Col[n] | lsb; //对于下一行来说, 这一列不能再放数
NDiag[numrows] = (NDiag[n] | lsb) >> 1; //同样的,负对角线影响在下一行右移一位
PDiag[numrows] = (PDiag[n] | lsb) << 1;
*pStack++ = bitfield; //即:赋值完后再++pStack;
bitfield = uplim & ~(Col[numrows] | NDiag[numrows] | PDiag[numrows]);
continue;
}
else //已经找到一个解
{
++sum;
bitfield = *(--pStack);
--numrows; //相当于回溯
continue;
}
}
}
sum*=2;
}
int main()
{
int size;
cin >> size;
Nqueen(size);
cout << sum << endl;
system("pause");
return 0;
}