N皇后问题及其优化

一个无关紧要的序 (欢迎跳过)

我第一次看到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到的! 

(下面这张图只是我画着玩的..毕竟每个人都有自己的理解方式~)

N皇后问题及其优化_第1张图片

 

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) 对称优化

这个比较难想到,  我这里直接贴一张<算法竞赛宝典>上的图:

N皇后问题及其优化_第2张图片

可以发现, 对称具体来说有两个优化细节

1) 利用棋盘的对称, 只用回溯一半 

2) 当第一个皇后放在奇数棋盘正中间时, 左右对称也是重复的

当然, 对于最后的结果要x2.

 

我们上面的函数是place(n), 我们现在写成place(n,k)的形式, 即加上一个变量k来控制回溯的上界

分析到这里, 基本可以直接上代码啦, 如果懂了基础的回溯思想和这两处优化, 一定也不难理解~~

#include
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
#include 
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
#include 
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 
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;

}

 

 

 

你可能感兴趣的:(算法设计与分析,回溯)