完美洗牌算法

部分内容转载自:http://blog.csdn.net/sunnyyoona/article/details/43795243,代码重新使用C++实现。

题目

玩过扑克牌的朋友都知道,在一局完了之后洗牌,洗牌人会习惯性的把整副牌大致分为两半,两手各拿一半对着对着交叉洗牌,我们的问题是:如何才能保证依次洗牌过后左右手牌是一一交叉的呢?

2004年,microsoft的Peiyush Jain在他发表一篇名为:“A Simple In-Place Algorithm for In-Shuffle”的论文中提出了完美洗牌算法。

什么是完美洗牌问题呢?即给定一个数组a1,a2,a3,…an,b1,b2,b3..bn,最终把它置换成b1,a1,b2,a2,…bn,an。

对原始位置的变化做如下分析:
这里写图片描述

依次考察每个位置的变化规律:

a1:1 -> 2 
a2:2 -> 4 
a3:3 -> 6 
a4:4 -> 8 
b1:5 -> 1 
b2:6 -> 3 
b3:7 -> 5 
b4:8 -> 7

对于原数组位置i的元素,新位置是(2*i)%(n+1),注意,这里用2n表示原数组的长度。后面依然使用该表述方式。有了该表达式,我们可以新建一个数组,然后遍历每一个元素,将原数组相应元素依次放入新数组对应的新位置,就可以了。这时候时间复杂度O(n),空间复杂度O(n)。


我们能不能j进行实现原址的洗牌算法呢,意味着空间复杂度为O(1)?原址洗牌的困难的不是寻找元素在新数组中的位置,而是如何在原数组中为该元素“腾位置”。如果使用暂存的办法,空间复杂度必然要达到O(N),因此,需要换个思路。

环操作:

步骤(3):我们这么思考:a1从位置1移动到位置2,那么,位置2上的元素a2变化到了哪里呢?继续这个线索,我们得到一个“封闭”的环:

1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1

沿着这个环,可以把a1、a2、a4、b4、b3、b1这6个元素依次移动到最终位置;显然,因为每次只移动一个元素,代码实现时,只使用1个临时空间即可完成。
此外,该变化的另外一个环是:

3 -> 6 -> 3

沿着这个环,可以把a3、b2这2个元素依次移动到最终位置。我们给出一个环交换算法,start是该环的起始位置。

void cycle(vector& t, int start){
	int pre = t[start];
	// 新位置为 2 * i % (n + 1)
	int mod = t.size() + 1;
	int next = start * 2 % mod;
	while (next != start){
		swap(pre, t[next]);
		next = 2 * next % mod;
	}
	t[start] = pre;
}

步骤(4):上述过程可以通过若干的“环”的方式完整元素的移动,这是巧合吗?事实上,该问题的研究成果已经由Peiyush Jain在10年前公开发表在A Simple In-Place Algorithm for In-Shuffle, Microsoft, 2004中。原始论文直接使用了一个结论,这里不再证明:对于2*n =(3^k-1)这种长度的数组,恰好只有k个环,且每个环的起始位置分别是1,3,9,…3^(k-1)。
对于上面的例子,长度为8,是3^2-1,因此,只有2个环。环的起始位置分别是1和3。

步骤(5):至此,完美洗牌算法的“主体工程”已经完工,只存在一个“小”问题:如果数组长度不是(3^k-1)呢?

若2n!=(3^k-1),则总可以找到最大的整数m,使得m< n,并且2m =(3^k-1)。我们的做法是使用循环左移算法将[m+1……n]之间的元素移动到m+n之后,将[1……m] 和[n+1……n+m]合并成为一个长度为2*m的数组进行环操作,这个2m数组是可以被完整移动的,我们直接找到所有的环,进行环操作(步骤3,4),剩下的元素2*(n-m)的元素如果是(3^k-1),直接进行环操作(步骤3,4),如果不是就找到对应的m再次移动元素进行环操作(步骤5)。

以上文给出的数组进行举例:

这里写图片描述
这里写图片描述

循环左移

问题:循环左移就是将前m个元素整体移动到后n个元素之后,保持两个子区间内各自的元素位置不变。介绍一下时间复杂度为O(n),空间复杂度为O(1)的循环移位操作。

 
思路: 把第一段和第二段先各自翻转一下,再将整体翻转下。我们直接给出代码:

//翻转[s,t]之间的元素
void Reverse(vector& a, int s, int t)
{
	while (s < t) swap(a[s++], a[t--]);
}

//将[s,m]之间的元素循环右移到[m+1,t]之后
void LeftRotate(vector& a, int s, int m, int t)
{
	Reverse(a, s, m);
	Reverse(a, m + 1, t);
	Reverse(a, s, t);
}


最后,我们给出完美洗牌算法完整的算法过程,时间复杂度为O(n),空间复杂度为O(1)。
#include 
#include 
using namespace std;

//注意环操作和上文中略有不同,原因在于:
//文中下标是从1开始,我们编程数组下标从0开始,交换元素时位置需要-1,
//另外递归过程要考虑初始位置与环操作的长度。
void cycle(vector& a, int s, int begin, int length){
	int pre = a[begin + s-1];
	int mod = length+1;
	int next = s * 2 % mod;
	while (next != s){
		swap(pre,a[begin+next-1]);
		next = 2 * next % mod;
	}
	a[begin+s-1] = pre;
}


//翻转[s,t]之间的元素
void Reverse(vector& a, int s, int t)
{
	while (s < t) swap(a[s++], a[t--]);
}


//将[s,m]之间的元素循环右移到[m+1,t]之后
void LeftRotate(vector& a, int s, int m, int t)
{
	Reverse(a, s, m);
	Reverse(a, m + 1, t);
	Reverse(a, s, t);
}


//对数组a进行洗牌算法,s代表洗牌的首元素,t代表洗牌的尾元素
void PerfectShuffle(vector& a, int s, int t){
	if (s > t) return;
	int gap = t - s + 1;
	//判断数组长度是不是3^k-1
	int k = 1, p = 3;
	while (p - 1 <= gap) { k++; p *= 3; }
	//循环右移,确定m,n长度
	int n = gap/2, m = n;
	if (p != gap){
		m = (p / 3 - 1)/2;
		LeftRotate(a, s+m, s+n-1, s+m+n-1);
	}
	//前3^k-1个元素进行环操作
	int start = 1;
	while (--k){
		cycle(a, start, s, 2*m);
		start *= 3;
	}
	//最后剩余进行递归操作
	PerfectShuffle(a, s+2*m, t);
}


int main()
{
	vector a ({ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 });
	PerfectShuffle(a, 0, a.size()-1);
	return 0;
}


如果我们想得到a1,b1,a2,b2……an,bn这样的洗牌序列,只需要将确定新位置的推导式改成(2*i)%n 或者我们直接从第2个元素到倒数第二个元素进行上文的排序。


拓展:

问题:如果输入是a1,a2,……an, b1,b2,……bn, c1,c2,……cn,要求输出是c1,b1,a1,c2,b2,a2,……cn,bn,an怎么办?
分析: 这个问题本质上其实还是上面的完美洗牌算法一样,我们一样还是分析其规律。

这里写图片描述

对于原数组位置i的元素,新位置是(3*i)%(n+1)

这里写图片描述
这里写图片描述

图中所说的步骤三四五和上面的三四五大体一样,只是细节不太一样,看图就明白了。


你可能感兴趣的:(算法)