完美洗牌问题(数组间数据有规律的交叉)

题目详情:有个长度为2n的数组{a1,a2,a3,…,an,b1,b2,b3,…,bn},希望排序后{a1,b1,a2,b2,….,an,bn},请考虑有无时间复杂度o(n),空间复杂度0(1)的解法。

问题描述

题目详情:有个长度为2n的数组{a1,a2,a3,…,an,b1,b2,b3,…,bn},希望排序后{a1,b1,a2,b2,….,an,bn},请考虑有无时间复杂度o(n),空间复杂度0(1)的解法。

题目来源:此题是2013年UC的校招笔试题,看似简单,按照题目所要排序后的字符串蛮力变化即可,但若要完美的达到题目所要求的时空复杂
度,则需要我们花费不小的精力。OK,请看下文详解,一步步优化。

解法一、蛮力变换

题目要我们怎么变换,咱们就怎么变换。此题@陈利人也分析过,在此,引用他的思路进行说明。为了便于分析,我们取n=4,那么题目要求我们把
a1,a2,a3,a4,b1,b2,b3,b4
变成
a1,b1,a2,b2,a3,b3,a4,b4

1.1、步步前移

仔细观察变换前后两个序列的特点,我们可做如下一系列操作:

  • 确定b1的位置,即让b1跟它前面的a2,a3,a4交换: a1,b1,a2,a3,a4,b2,b3,b4

  • 接着确定b2的位置,即让b2跟它前面的a3,a4交换: a1,b1,a2,b2,a3,a4,b3,b4

  • b3跟它前面的a4交换位置: a1,b1,a2,b2,a3,b3,a4,b4

  • b4已在最后的位置,不需要再交换。

    如此,经过上述3个步骤后,得到我们最后想要的序列。但此方法的时间复杂度为O(N^2),我们得继续寻找其它方法,看看有无办法能达到题目所预期的O(N)的时间复杂度。

1.2、中间交换

当然,除了如上面所述的让b1,b2,b3,b4步步前移跟它们各自前面的元素进行交换外,我们还可以每次让序列中最中间的元素进行交换达到目的。还是用上面的例子,针对a1,a2,a3,a4,b1,b2,b3,b4

  • 交换最中间的两个元素a4,b1,序列变成(待交换的元素用粗体表示):
    a1,a2,a3,b1,a4,b2,b3,b4
  • 让最中间的两对元素各自交换: a1,a2,b1,a3,b2,a4,b3,b4

  • 交换最中间的三对元素,序列变成:a1,b1,a2,b2,a3,b3,a4,b4

    同样,此法同解法1.1、步步前移一样,时间复杂度依然为O(N^2),我们得下点力气了。

解法二、完美洗牌算法

玩过扑克牌的朋友都知道,在一局完了之后洗牌,洗牌人会习惯性的把整副牌大致分为两半,两手各拿一半对着对着交叉洗牌,如下图所示这里写图片描述

如果这副牌用a1 a2 a3 a4 b1 b2 b3 b4表示(为简化问题,假设这副牌只有8张牌),然后一分为二之后,左手上的牌可能是a1 a2 a3 a4,右手上的牌是b1 b2 b3 b4,那么在如上图那样的洗牌之后,得到的牌就可能是b1 a1 b2 a2 b3 a3 b4 a4。

技术来源于生活,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。读者可以看到,这个完美洗牌问题本质上与本题完全一致,只要在完美洗牌问题的基础上对它最后的序列swap两两相邻元素即可。
即:a1,a2,a3,…an,b1,b2,b3..bn
通过完美洗牌问题,得到:
b1,a1,b2,a2,b3,a3… bn,an
再让上面相邻的元素两两swap,即可达到本题的要求:
a1,b1,a2,b2,a3,b3….,an,bn
也就是说,如果我们能通过完美洗牌算法(时间复杂度O(N),空间复杂度O(1))解决了完美洗牌问题,也就间接解决了本题。
虽然网上已有不少文章对上篇论文或翻译或做解释说明,但对于初学者来说,理解难度实在太大,再者,若直接翻译原文,根本无法看出这个算法怎么一步步得来的,故下文将从完美洗牌算法的最基本的原型开始说起,以让读者能对此算法一目了然。

2.1、位置置换pefect_shuffle1算法

为方便讨论,我们设定数组的下标从1开始,下标范围是[1..2n]。 还是通过之前n=4的例子,来看下每个元素最终去了什么地方。
           起始序列:a1 a2 a3 a4 b1 b2 b3 b4
           数组下标:1  2  3  4  5  6  7  8
           最终序列:b1 a1 b2 a2 b3 a3 b4 a4

从上面的例子我们能看到,前n个元素中,
第1个元素a1到了原第2个元素a2的位置,即1->2;
第2个元素a2到了原第4个元素a4的位置,即2->4;
第3个元素a3到了原第6个元素b2的位置,即3->6;
第4个元素a4到了原第8个元素b4的位置,即4->8;

那么推广到一般情况即是:前n个元素中,第i个元素去了 第(2 * i)的位置。
上面是针对前n个元素,那么针对后n个元素,可以看出:
第5个元素b1到了原第1个元素a1的位置,即5->1;
第6个元素b2到了原第3个元素a3的位置,即6->3;
第7个元素b3到了原第5个元素b1的位置,即7->5;
第8个元素b4到了原第7个元素b3的位置,即8->7;

推广到一般情况是,后n个元素,第i个元素去了第  (2 * (i - n) ) - 1 =  2 * i - (2 * n + 1)  = (2 * i) % (2 * n + 1) 个位置。

再综合到任意情况,任意的第i个元素,我们最终换到了 (2 * i) % (2 * n + 1)的位置。为何呢?因为:
当0< i

// 时间O(n),空间O(n) 数组下标从1开始 
void pefect_shuffle1(int *a,int n) {  
int n2 = n * 2, i, b[N];  
    for (i = 1; i <= n2; ++i) {  
        b[(i * 2) % (n2 + 1)] = a[i];  
    }  
    for (i = 1; i <= n2; ++i) {  
        a[i] = b[i];  
    }  
}

但很明显,它的时间复杂度虽然是O(n),但其空间复杂度却是O(n),仍不符合本题所期待的时间O(n),空间O(1)。我们继续寻找更优的解法。
与此同时,我也提醒下读者,根据上面变换的节奏,我们可以看出有两个圈,
一个是1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1;
一个是3 -> 6 -> 3。

2.2、分而治之perfect_shuffle2算法

熟悉分治法的朋友,包括若看了此文的读者肯定知道,当一个问题规模比较大时,则大而化小,分而治之。对于本题,假设n是偶数,我们试着把数组从中间拆分成两半(为了方便描述,只看数组下标就够了):

  • 原始数组的下标:1….2n,即(1 .. n/2, n/2+1..n)(n+1 .. n+n/2, n+n/2+1 .. 2n)
  • 前半段(1 .. n/2, n/2+1..n)和后半段(n+1 .. n+n/2, n+n/2+1 .. 2n)的长度皆为n。
    接下来,我们把前半段的后n/2个元素(n/2+1 .. n)和后半段的前n/2个元素(n+1..n+n/2)交换,得到
  • 新的前n个元素A:(1..n/2 n+1.. n+n/2)
  • 新的后n个元素B:(n/2+1 .. n n+n/2+1 .. 2n)

    换言之,当n是偶数的时候,我们把原问题拆分成了A,B两个子问题,继而原n的求解转换成了n‘ = n/2 的求解。
    可当n是奇数的时候呢?我们可以把前半段多出来的那个元素a先拿出来放到末尾,后面所有元素前移,于此,新数列的最后两个元素满足已满足要求,只需考虑前2*(n-1)个元素即可,继而转换成了n-1的问题。

    针对上述n分别为偶数和奇数的情况,下面举n=4和n=5两个例子来说明下。

  • n=4时,原始数组即为 a1 a2 a3 a4 b1 b2 b3 b4 按照之前n为偶数时的思路,把前半段的后2个元素a3 a4同后半段的前2个元素b1 b2交换,可得: a1 a2 b1 b2 a3 a4 b3 b4
    因此,我们只要用pefect_shuffle1算法继续求解A(a1 a2 b1 b2)和B(a3 a4 b3 b4)两个子问题就可以了。

  • 当n=5时,原始数组则为 a1 a2 a3 a4 a5 b1 b2 b3 b4 b5
    还是按照之前n为奇数时的思路,先把a5先单独拎出来放在最后,然后所有剩下的元素全部前移,变为: a1 a2 a3 a4 b1 b2 b3
    b4 b5 a5 此时,最后的两个元素b5 a5已经是我们想要的结果,只要跟之前n=4的情况一样考虑即可。

    参考代码如下:


//时间O(nlogn) 空间O(1) 数组下标从1开始    
void perfect_shuffle2(int *a,int n) {    
int t,i;    
    if (n == 1) {    
        t = a[1];    
        a[1] = a[2];    
        a[2] = t;    
        return;    
    }    
    int n2 = n * 2, n3 = n / 2;    
    if (n % 2 == 1) {  //奇数的处理    
        t = a[n];    
        for (i = n + 1; i <= n2; ++i) {    
            a[i - 1] = a[i];    
        }    
        a[n2] = t;    
        --n;    
    }    
    //到此n是偶数    

    for (i = n3 + 1; i <= n; ++i) {    
        t = a[i];    
        a[i] = a[i + n3];    
        a[i + n3] = t;    
    }    

    // [1.. n /2]    
    perfect_shuffle2(a, n3);    
    perfect_shuffle2(a + n, n3);    
}  
分析下此算法的复杂度: 每次,我们交换中间的n个元素,需要O(n)的时间,n是奇数的话,我们还需要O(n)的时间先把后两个元素调整好,但这不
影响总体时间复杂度。
《算法导论》中文第二版44页的主定理,可最终解得T(n) = O(nlogn)。至于空间,此算法在数组内部折腾的,
所以是O(1)(在不考虑递归的栈的空间的前提下)。

2.3、完美洗牌算法perfect_shuffle3

2.3.1、走圈算法cycle_leader

因为之前无论是perfect_shuffle1,还是perfect_shuffle2,这两个算法的均未达到时间复杂度O(N)并且空间复杂度O(1)的要求,所以我们必须得再找一种新的方法,以期能完美的解决本节开头提出的完美洗牌问题。
让我们先来回顾一下2.1节位置置换perfect_shuffle1算法,还记得我之前提醒读者的关于当n=4时,通过位置置换让每一个元素到了最后的位置时,所形成的两个圈么?我引用下2.1节的相关内容:

当n=4的情况:

     起始序列:a1 a2 a3 a4 b1 b2 b3 b4
     数组下标:1  2  3  4  5  6  7  8
     最终序列:b1 a1 b2 a2 b3 a3 b4 a4

即通过置换,我们得到如下结论:
“于此同时,我也提醒下读者,根据上面变换的节奏,我们可以看出有两个圈,
一个是1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1;
一个是3 -> 6 -> 3。”


这两个圈可以表示为(1,2,4,8,7,5)和(3,6),且perfect_shuffle1算法也已经告诉了我们,不管你n是奇数还是偶数,每个位置的元素都将变为第
(2*i) % (2n+1)个元素:

这里写图片描述
因此我们只要知道圈里最小位置编号的元素即圈的头部,顺着圈走一遍就可以达到目的,且因为圈与圈是不想交的,所以这样下来,我们刚好走了O(N)步。
还是举n=4的例子,且假定我们已经知道第一个圈和第二个圈的前提下,要让1 2 3 4 5 6 7 8变换成5 1 2 7 3 8 4:
第一个圈:1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1
第二个圈:3 -> 6 -> 3:

    原始数组:1 2 3 4 5 6 7 8
    数组小标:1 2 3 4 5 6 7 8

    走第一圈:5 1 3 2 7 6 8 4
    走第二圈:5 1 6 2 7 3 8 4
上面沿着圈走的算法我们给它取名为cycle_leader,这部分代码如下:

//数组下标从1开始,from是圈的头部,mod是要取模的数 mod 应该为 2 * n + 1,时间复杂度O(圈长)  
void cycle_leader(int *a,int from, int mod) {  
int last = a[from],t,i;  

    for (i = from * 2 % mod;i != from; i = i * 2 % mod) {  
        t = a[i];  
        a[i] = last;  
        last = t;  

    }  
    a[from] = last;  
}  

2.3.2、神级结论:若2*n=(3^k - 1),则可确定圈的个数及各自头部的起始位置

下面我要引用此论文“A Simple In-Place Algorithm for In-Shuffle”的一个结论了,
即,对于2*n = (3^k-1)这种长度的数组,恰好只有k个圈,且每个圈头部的起始位置分别是1,3,9,...3^(k-1)。


论文原文部分为:

这里写图片描述

也就是说,利用上述这个结论,我们可以解决这种特殊长度2*n = (3^k-1)的数组问题,那么若给定的长度n是任意的咋办呢?此时,我们可以借鉴2.2节、分而治之算法的思想,把整个数组一分为二,即拆分成两个部分:
让一部分的长度满足神级结论:若2*m = (3^k-1),则恰好k个圈,且每个圈头部的起始位置分别是1,3,9,…3^(k-1)。其中m小于n,m往神级结论所需的值上套;剩下的n-m部分单独计算;

当把n分解成m和n-m两部分后,原始数组对应的下标如下(为了方便描述,我们依然只需要看数组下标就够了):

  原始数组下标:1..m m+1.. n,   n+1 .. n+m, n+m+1,..2*n

参照之前2.2节、分而治之算法的思路,且更为了能让前部分的序列满足神级结论2*m = (3^k-1),我们可以把中间那两段长度为n-m和m的段交换位置,即相当于把m+1..n,n+1..n+m的段循环右移m次(为什么要这么做?因为如此操作后,数组的前部分的长度为2m,而根据神级结论:当2m=3^k-1时,可知这长度2m的部分恰好有k个圈)。而如果读者看过本系列第一章、左旋转字符串的话,就应该意识到循环位移是有O(N)的算法的,其思想即是把前n-m个元素(m+1.. n)和后m个元素(n+1 .. n+m)先各自翻转一下,再将整个段(m+1.. n, n+1 .. n+m)翻转下。
这个翻转的代码如下:


//翻转字符串时间复杂度O(to - from)  
void reverse(int *a,int from,int to) {  
int t;  
    for (; from < to; ++from, --to) {  
        t = a[from];  
        a[from] = a[to];  
        a[to] = t;  
    }  

}  

//循环右移num位 时间复杂度O(n)  
void right_rotate(int *a,int num,int n) {  
    reverse(a, 1, n - num);  
    reverse(a, n - num + 1,n);  
    reverse(a, 1, n);  
}  

翻转后,得到的目标数组的下标为:

目标数组下标:1..m n+1..n+m m+1 .. n n+m+1,..2*n

当给定n=7时,若要满足神级结论2*n=3^k-1,k只能取2,继而推得n‘=m=4。
原始数组:a1 a2 a3 a4 (a5 a6 a7) ( b1 b2 b3 b4 ) b5 b6 b7
既然m=4,即让上述数组中有括号的两个部分交换,得到:

目标数组:a1 a2 a3 a4 b1 b2 b3 b4 a5 a6 a7 b5 b6 b7

继而目标数组中的前半部分a1 a2 a3 a4 b1 b2 b3 b4部分可以用2.3.1、走圈算法cycle_leader搞定,
于此我们最终求解的n长度变成了n’=3,即n的长度减小了4,单独再解决后半部分a5 a6 a7 b5 b6 b7即可。

2.3.3、完美洗牌算法perfect_shuffle3

从上文的分析过程中也就得出了我们的完美洗牌算法,其算法流程为:

输入数组 A[1..2 * n]

  1. 找到 2 * m = 3^k - 1 使得 3^k <= 2 * n < 3^(k +1)

  2. 把a[m + 1..n + m]那部分循环移m位

  3. 对每个i = 0,1,2..k - 1,3^i是个圈的头部,做cycle_leader算法,数组长度为m,所以对2 * m + 1取模。

  4. 对数组的后面部分A[2 * m + 1.. 2 * n]继续使用本算法, 这相当于n减小了m。

    上述算法流程对应的论文原文为:
    这里写图片描述

以上各个步骤对应的时间复杂度分析如下:

  1. 因为循环不断乘3的,所以时间复杂度O(logn)

  2. 循环移位O(n)

  3. 每个圈,每个元素只走了一次,一共2*m个元素,所以复杂度omega(m), 而m < n,所以 也在O(n)内。
  4. T(n - m)

    因此总的时间复杂度为 T(n) = T(n - m) + O(n) ,m = omega(n) ,解得:T(n) = O(n)。

    此完美洗牌算法实现的参考代码如下:


//时间O(n),空间O(1)  
void perfect_shuffle3(int *a,int n) {  

int n2, m, i, k,t;  

    for (;n > 1;) {  
        // step 1  
        n2 = n * 2;  
        for (k = 0, m = 1; n2 / m >= 3; ++k, m *= 3)  
        ;  
        m /= 2;  
        // 2m = 3^k - 1 , 3^k <= 2n < 3^(k + 1)  

        // step 2  
        right_rotate(a + m, m, n);  

        // step 3  

        for (i = 0, t = 1; i < k; ++i, t *= 3) {  
            cycle_leader(a , t, m * 2 + 1);  

        }  

        //step 4  
        a += m * 2;  
        n -= m;  

    }  
    // n = 1  
    t = a[1];  
    a[1] = a[2];  
    a[2] = t;  
}  

2.3.4、perfect_shuffle3算法解决其变形问题

以上代码即解决了完美洗牌问题,那么针对本章要解决的其变形问题呢?是的,如本章开头所说,
在完美洗牌问题的基础上对它最后的序列swap两两相邻元素即可,代码如下:

//时间复杂度O(n),空间复杂度O(1),数组下标从1开始,调用perfect_shuffle3  
void shuffle(int *a,int n) {  
    int i,t,n2 = n * 2;  
    perfect_shuffle3(a,n);  
    for (i = 2; i <= n2; i += 2) {  
        t = a[i - 1];  
        a[i - 1] = a[i];  
        a[i] = t;  

    }  
}  

上述的这个“在完美洗牌问题的基础上对它最后的序列swap两两相邻元素”的操作
(当然,你也可以让原数组第一个和最后一个不变,中间的2 * (n - 1)项用原始的标准完美洗牌算法做),
只是在完美洗牌问题时间复杂度O(N)空间复杂度O(1)的基础上再增加O(N)的时间复杂度,
故总的时间复杂度O(N)不变,且理所当然的保持了空间复杂度O(1)。至此,咱们的问题得到了圆满解决!


参考网址
【1】程序员编程艺术第三十四~三十五章:格子取数问题,完美洗牌算法 - 结构之法 算法之道 - 博客频道 - CSDN.NET
http://blog.csdn.net/v_july_v/article/details/10212493#t8
【2】面试算法:格子取数问题,完美洗牌算法-面试题-about云开发 http://www.aboutyun.com/thread-9995-1-1.html
【3】完美洗牌算法 - 推酷
http://www.tuicool.com/articles/rUju2m

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值