完美洗牌算法——java实现

    技术2022-07-13  65

    问题描述

    前面部分为原理讲解,想直接看代码的直接跳转至代码部分。 完美洗牌:给定一个数组 a 1 a 2 . . . . . . a n − 1 a n b 1 b 2 . . . . . . b n − 1 b n a_1a_2......a_{n-1}a_nb_1b_2......b_{n-1}b_n a1a2......an1anb1b2......bn1bn将其变为 a 1 b 1 a 2 b 2 . . . . . . a n − 1 b n − 1 a n b n a_1b_1a_2b_2......a_{n-1}b_{n-1}a_nb_n a1b1a2b2......an1bn1anbn。 要求:算法的时间复杂度为O(n),空间复杂度为O(1)。 问题分析:时间复杂度为O(n)这个不难理解,要求算法用的时间是线性的。难点在于空间复杂度为O(1),如果空间复杂度不这么设置问题将会非常简单即再设置一个数组用双指针遍历一遍就ok了。对于O(1)的理解:对于数组中每一个元素,我们总是能直接获取到该元素移动后的位置! 下面讲解该算法的核心内容直接获取到每一个元素移动后的位置。

    算法讲解

    为了方便起见我们设置一个原数组、排序后数组和序号如下图所示: 我们可以很轻松的根据上图得到如下对应关系: 观察上图我们可以得出以下2个结论

    1->2->4->8->7->5->13->6->3

    拿1来举例,原来数组下标为1的移动到了2,原来为2的移动到了4,最后由5移回了1。好美的规律!难道是巧合么?不会吧不会吧! 我们继续观察上图,仍旧能发现一些一些比较有意思的事情。

    对于元素下标为1,2,3,4的元素其移动后的位置分别为2,4,6,8对于元素下标为5,6,7,8的元素其移动后的位置分别为1,3,5,7

    设元素全长为2n(此例中n=4),原始元素下标设为 i i i(此例中 i = 1 , 2 , 3... , 6 , 7 , 8 i=1,2,3...,6,7,8 i=1,2,3...,6,7,8)移动后的元素下标设为 i ′ i' i我们用a来表示整个数组。所以我们可以有以下结论:

    i ⩽ n i\leqslant n in i ′ = 2 i i'=2i i=2i n < i ⩽ 2 n n<i\leqslant2n n<i2n i ′ = 2 i − ( 2 n + 1 ) i'=2i-(2n+1) i=2i(2n+1)

    在本例中 n = 4 n=4 n=4,可以带进入算一下,发现确实符合上述式子。细心的你一定发现,其实上述2个式子可以合并成一个式子即: i ′ = 2 i % ( 2 n + 1 ) i'=2i\%(2n+1) i=2i%(2n+1) 这个式子很好理解,稍微思考一下或者带入尝试一下就能得出这个结论。 至此我们似乎已经找到一个方法,该方法可以将数组中每个元素直接映射到它需要到达的位置即 f ( i ) = i ′ f(i)=i' f(i)=i,而且我们还知道这个对应的映射关系就是上述提到的那2个环。那事情会是这么简单么?当然不是! 这里我们提供一篇论文中的结论,具体的证明过程可以去该论文中一探究竟,这里只提供最终结论:论文地址A Simple In-Place Algorithm for In-Shuffle 对于 2 ∗ n = 3 k − 1 2*n =3^k-1 2n=3k1这种长度的数组,恰好只有k个环,且每个环的起始位置分别是 1 , 3 , 9 , … 3 k − 1 1,3,9,…3^{k-1} 1,3,9,3k1。本例中n=4,k=2所以2个环的开始分别为1和3 那么你或许仍旧有疑问——如果这个2n它不凑巧不等于 3 k − 1 3^k-1 3k1怎么办呢? 答案是:如果这样,那么总存在一个最大的 m m m使得 2 m = 3 k − 1 ⩽ 2 n 2m=3^k-1\leqslant2n 2m=3k12n,剩下的一对 n − m n-m nm长度的元素可以通过递归的方式直到最后剩下2个值我们交换一下位置就可以了!为了方便理解我们可以看如下图分析: 在具体的数组处理时我们需要将上图中黄色部分和绿色部分合并在一起,如下图所示: 这样我们可以使用上述的结论去处理前面2个黄色块部分,且我们知道它一定满足 2 m = 3 k − 1 2m=3^k-1 2m=3k1,剩下绿色的块我们通过递归的方式去处理就好了。

    代码讲解

    接下来使用java代码来实现上述算法,这里将对每块代码进行分析讲解,文后会附上所有的代码。 首先我们需要解决的问题就是对于数组m的即上文中绿色和黄色的合并问题。这里可以看作是一种循环右移操作,并且对于该操作的空间复杂度需要控制在O(1)毕竟我们该算法的目的就在于此。先介绍一个基本的逻辑代数的一个公式: ( A ′ B ′ ) ′ = B A (A'B')'=BA (AB)=BA 看上去似乎与本文无关,且听我细说。举个例子,假如有如下数组: 对比原数组和移位后的可以发现,通过上述变换,成功将原数组循环右移了3位或者说循环左移了2位。并且空间复杂度为O(1)时间复杂度为线性的。 具体代码如下:

    /* * 对于数组a,从下标from到to循环右移n个单位 * */ public static void rightCircle(char[] a, int from, int to, int n) { int m = n % (to - from + 1);//防止移动越界 reverse(a, to - m + 1, to);//求A’ reverse(a, from, to - m);//求B' reverse(a, from, to);//求(A'B')' } //用于翻转即求反运算 public static void reverse(char[] a, int from, int to) { while (from < to) { MyMath.swap(a, from++, to--);//调用一个交换函数 } } class MyMath{ public static void swap(char[] a,int from,int to){ char temp=a[from]; a[from]=a[to]; a[to]=temp; }

    在解决完移位问题之后,我们需要计算出最大的m值,代码如下:

    int k = 0;//用来记录3^k中的k int m;//算法中的m int n2 = 2 * n;//算法中的2n int p = (n2 + 1);//p=3^k 3^k-1<2n<3^(k+1)-1->3^k<2n+1=p int k_3 = 1;//用于记录3^k的 while (k <= p / 3) {//通过不断除以3找到最大k值,使得p>3^k成立 k++; p /= 3; k_3 *= 3; } //2m=3^k-1->m=(3^k-1)/2 至此得到了最大2m=3^k-1<2n,当然还有其他更好的办法 m = (k_3 - 1) / 2;

    得到了最大的m值以及相应的k值后,我们需要对数组进行k次处理即上例中的环来循环的进行赋值。具体代码如下:

    //数组下标从1开始,主要是因为环是1->2->4->8->7->5->1 public static void circle(char[] a, int from, int i, int n2) { for (int k = 2 * i % n2; k != i; k = 2 * k % n2) { char temp = a[i + from]; a[i + from] = a[k + from]; a[k + from] = temp; } }

    需要对上述代码的说明的是:因为我们在计算时的数组下标是从1开始但是实际存储的数组下标是从0或者说由于递归的存在他是从一个任意的数值开始,所以这里通过from来传递此值。 至此,我们已经处理算法中有关m的部分,下面将展示递归部分以及递归结束的状态。具体代码如下:

    //结束条件 if (from >= to) { return;//如果递归到最后则直接返回 } else if (from == to - 1) { MyMath.swap(a, from, to);//只剩2个数,直接交换并返回 return; } /*some code*/ //递归 perfectShuffle(a, 2 * m + from, to, (to - (2 * m + from )+1) / 2);

    至此所以的代码过程讲解完毕,接下来是全部代码,以及参考文献部分。

    全部代码

    public class Shuffle { public static void main(String[] args) { String str = "1234abcd"; char[] card = str.toCharArray(); int from = 0; int to = card.length - 1; perfectShuffle(card, from, to, (to - from + 1) / 2); System.out.println(String.valueOf(card)); } public static void perfectShuffle(char[] a, int from, int to, int n) { if (from >= to) { return;//如果递归到最后则直接返回 } else if (from == to - 1) { MyMath.swap(a, from, to);//只剩2个数,直接交换并返回 return; } int k = 0;//用来记录3^k中的k int m;//算法中的m int n2 = 2 * n;//算法中的2n int p = (n2 + 1);//p=3^k 3^k-1<2n<3^(k+1)-1->3^k<2n+1=p int k_3 = 1;//用于记录3^k的 while (k <= p / 3) {//通过不断除以3找到最大k值,使得p>3^k成立 k++; p /= 3; k_3 *= 3; } m = (k_3 - 1) / 2;//2m=3^k-1->m=(3^k-1)/2 至此得到了最大2m=3^k-1<2n,当然还有其他更好的办法 rightCircle(a, from + m, from + n + m - 1, m);//循环右移的时候需要注意加上偏移量即可 for (int i = 0, t = 1; i < k; ++i, t *= 3) { /* * 运用之前推导的环开始计算,因为算法中数组下标是从1开始的,我们这里的下标是从0开始 * 所以在传偏移量的时候要减去1,这样偏移量加上算法计算出的值正好相互抵消符合实际下标 * * */ circle(a, from - 1, t, m * 2 + 1); } /* * 递归计算剩下的值 * 起始下标为2m+偏移量,终止下标即为数组尾部,长度则是终止-起始+1再除以2因为我们需要的是n不是2n * */ perfectShuffle(a, 2 * m + from, to, (to - (2 * m + from )+1) / 2); } //数组下标从1开始,主要是因为环是1->2->4->8->7->5->1 public static void circle(char[] a, int from, int i, int n2) { for (int k = 2 * i % n2; k != i; k = 2 * k % n2) { char temp = a[i + from]; a[i + from] = a[k + from]; a[k + from] = temp; } } /* * 对于数组a,从下标from到to循环右移n个单位 * */ public static void rightCircle(char[] a, int from, int to, int n) { int m = n % (to - from + 1); reverse(a, to - m + 1, to); reverse(a, from, to - m); reverse(a, from, to); } public static void reverse(char[] a, int from, int to) { while (from < to) { MyMath.swap(a, from++, to--); } } } class MyMath{ public static void swap(char[] a,int from,int to){ char temp=a[from]; a[from]=a[to]; a[to]=temp; } }

    引申与提高

    这里提供一些有意思的想法,供读者思考。 我们先在得到的排序结果为"a1b2c3d4",如果想得到"1a2b3c4d"代码该如何改动? 答:只需要从数组下标1开始到end-1结束运用此算法,即from=1,to=card.length-2即可。

    参考文献

    A Simple In-Place Algorithm for In-ShuffleB站up主
    Processed: 0.129, SQL: 9