- 问题描述:
有一个长度为2n的数组{a1,a2,a3…,an,b1,b2,b3,…,bn},希望将其变换为{a1,b1,a2,b2,…,an,bn}。要求时间复杂度为O(n),空间复杂度为O(1)。 - 问题分析:
完美洗牌算法作为名企面试过程中经常遇到的算法题之一,若没有任何限制条件,可以使用很多的方法解决。但是,加上时间复杂度和空间复杂度的要求之后,就可以考虑本文介绍的完美洗牌算法完美的解决该类问题。
本文主要介绍笔者在解决该问题过程中遇到的问题以及最终总结的解决方法,在撰写过程中参考了July大神的编程之法一书,并对网络上解决该问题的方法进行了汇总和比较。互联网时代,缺少的不是资源,而是如何有效的获取和梳理资源。相信静心阅读本文,搞清楚原理后自己去尝试和比较,一定能够让你有所收获。 - 问题解法:
本文共介绍四种解法,按照复杂程度逐步的解决该问题。 - 解法一:不考虑空间复杂度。
当不考虑空间复杂度时,可以单独申请一个长度为2n的数组b[2n],使用两个指针分别指向问题描述中的数组(后面称为a[2n]),顺序遍历一遍问题a[2n],按照题目要求的顺序将元素挨个赋值到b[2n]中,最后将数组b[2n]赋值给a[2n]即可。
该解法时间复杂度为O(n),空间复杂度为O(n)。 - 解法二:步步前移
以数组{a1,a2,a3,b1,b2,b3}为例,先将b2与a2,a3交换位置,这样b1就到了最终的位置,得到{a1,b1,a2,a3,b2,b3};然后按照上面的步骤继续,将b2与a3交换,最终即可得到最终的序列。采用此解法时,只要注意每次移位时的起始位置的准确性即可。
该解法的时间复杂度为O(n^2),空间复杂度为O(1)。
代码如下(其中在交换位置使用了常见的reverse()函数):
int card_solve1(int a[] , int n){
int m = n/2;
int i = 1 , j=0;
while(j <= n/2-1){
reverse(a , i , m+j-1);
reverse(a , i , m+j);
i+=2;
j+=1;
}
return 0;
}
int reverse(int a[] , int start , int end){
int t = 0;
while(start < end){
t = a[start];
a[start] = a[end];
a[end] = t;
start++;
end--;
}
return 0;
}
- 解法三:中间交换
步步前移的解法每次交换可以将一个元素放置到正确的位置上,除了该方法外,还可以采用相邻元素交换的方法。仍以{a1,a2,a3,b1,b2,b3}为例,具体做法是:先交换最中间两个元素的值得到{a1,a2,b1,a3,b2,b3},然后交换中间两对元素的值,得到{a1,b2,a2,b2,a3,b3},每次交换使得各元素的值离最终的位置更进一步,直到得到最终结果为止。
每次交换的序列的长度依次为2,4,6 …,2(n-1),因此,长度为2n的序列共需交换n-1次,每次变换时将待遍历的序列依次交换相邻的两个元素的值即可。因此,该算法的时间复杂度为O(n^2),空间复杂度为O(1)。
代码如下:
int card_solve2(int a[] , int n){
int i = 1;
while(i <= n/2-1){
swap(a , n/2-i , n/2+i-1);
i++;
}
return 0;
}
int swap(int a[] , int start , int end){
int t = 0;
int i = start;
while(i < end){
t = a[i];
a[i] = a[i+1];
a[i+1] = t;
i+=2;
}
return 0;
}
解法四:完美洗牌算法
下面介绍本文的重点,采用完美洗牌算法来解决该问题。该算法由微软公司Peiyush Jain在其发表的论文”A Simple In-Place Algorithm for In-Shuffle”中提出了完美洗牌算法。该算法的描述为:给定数组{a1,a2,a3,…an,b1,b2,b3,…,bn},可以在时间复杂度为O(n)内,将其变换为{b1,a1,b2,a2,…bn,an}。将完美洗牌算法得到的序列中的元素两两交换,即可解决本文的算法题。
对于本文中的问题,假定将原序列存储在一个长度为2n的数组中,数组的序号从0开始。即a[2n]={a1,a2,a3…,an,b1,b2,…,bn}中存储了原序列,将其变换为b[2n]={b1,a1,b2,a2,…,bn,an}时,第i个元素最终的位置为(2i)%(2n+1)。
对于一个a[2n],可以通过位置置换算法(走环算法),通过数组下标循环,将数组中各元素置换到各自的最终位置,其位置变换策略即为(2i)/(2n+1)。将数组a[2n]分为多个圈,在每个圈内执行上述的走环算法,即可将整个数组元素的位置均置换到最终的位置上。
至于数组a[2n]具体可以划分的圈的个数,有以下结论:若2n=3^k-1,则可确定圈的个数及各自头部的起始位置,即对于长度满足2n=3^k-1的数组,恰好只有k个圈,且每个圈的起始位置分别为1,3,9,…,3^k-1。
基于上述结论,我们可以总结得到完美洗牌算法的流程如下:
(1)对于数组a[2n],找到2m=3^k-1,使得3^k<=2n<3^(k+1);
(2)通过resverse()函数,变换数组a[m+1…n…n+m],将{a1,a2…am,b1,b2…bm}组成长度为2m的新的数组,根据前述结论,该数组可以恰好分为k个圈,可以通过一次走环算法可以将{a1,a2…am,b1,b2…bm}变换为{b1,a1,b2,a2,…,bm,am}。
(3)对于a[2n]数组中后面的部分递归(1)(2)中的操作,即可得到最终序列。
(4)将完美洗牌算法得到的序列两两交换位置,即可得到最终的序列,该算法的时间复杂度为O(n),空间复杂度为O(1),因此,不愧为完美洗牌算法。
代码实现如下:int shuffle_with_limit(int *a , int count){ int n = count/2; int t = 0; if(n == 1){ t = a[0]; a[0] = a[1]; a[1] = t; return 0; } int k = 0 , m = 0 , i = 0; int mul = 1; //1 , cut into circle while(mul < count){ mul = mul * 3; k++; } mul = mul/3; k--; m = (mul - 1)/2; //2 , reverse if(m < n){ reverse(a , m , n-1); reverse(a , n , n+m-1); reverse(a , m , n+m-1); } //3 , CycleLeader mul = 1; for(i=0 ; i<k ; i++){ cycleLeader(a , mul - 1 , m*2+1); mul = mul*3; } //4 , again a = a + 2*m; count = count - 2*m; if(count == 0){ return 0; } shuffle_with_limit(a , count); return 0; } void cycleLeader(int a[] , int from , int mod){ from = from + 1; int t = 0 , i = 0; for(i = from*2%mod ; i != from ; i = i*2%mod){ t = a[i-1]; a[i-1] = a[from-1]; a[from-1] = t; } }
以上即为完美洗牌算法的整个过程,在不知道该算法的情况下,要完美解决本文的问题颇为困难;而在知晓本算法存在的情况下,即可顺利的解决该问题。因此,博闻强识对于成为一个优秀的程序员颇为重要。
博学而笃志,切问而近思。共勉!