完美洗牌算法

完美洗牌算法

题目描述:

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

分析和解法:

解法一:蛮力变换

题目要我们怎么变换,咱们就怎么变换。为了便于分析,我们取 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 个步骤后,得到我们最后想要的序列。

源代码如下:

#include <iostream>

using namespace std;

void Move(int a[], int n)
{
    if (n < 3 || n % 2 == 1)
        return;
    
    int size = n / 2;  //规模 
    int index, count;
    for (int i = size; i < n - 1; i++)
    {
        count = size - (i - size) - 1;  //交换个数 
        index = i;  //待交换的数的下标 
        for (int j = 1; j <= count; j++)
        {
            swap(a[index], a[i - j]);
            index = i - j;
        }
    }
}

int main()
{
    int a[100];
    int n = 0;
    while(cin.peek() != '\n')  cin >> a[n++];
    Move(a, n);
    for (int i = 0; i < n; i++)
        cout << a[i] << " " ;
    cout << endl;
    return 0;
}

分析:但此方法的时间复杂度为 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)。

源代码如下:

#include <iostream>

using namespace std;

void Move(int a[], int n)
{
    if (n < 3 || n % 2 == 1)
        return;
        
    int size = n / 2;
    int count = 1;
    for (int i = size; i > 1; i--)
    {
        for (int j = size - count; j < size + count; j += 2)
        {
            swap(a[j], a[j + 1]);   
        }   
        count++;
    }   
} 

int main()
{
    int a[100];
    int n = 0;
    while(cin.peek() != '\n')  cin >> a[n++];
    Move(a, n);
    for (int i = 0; i < n; i++)
        cout << a[i] << " " ;
    cout << endl;
    return 0;
}

分析:此思路同上述思路一样,时间复杂度依然为 O(n^2),仍然达不到题目要求。

解法二:完美洗牌算法

玩过扑克牌的朋友都知道,在一局完了之后洗牌,洗牌人会习惯性的把整副牌大致分为两半,两手各拿一半对着对着交叉洗牌。

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

什么是完美洗牌问题呢?即给定一个数组

a1,a2,a3, …, an, b1, b2, b3, ..., bn

最终把它置换成

b1, a1, b2, a2, a3, b3,…, bn, an

这个完美洗牌问题本质上与本题完全一致,只要在完美洗牌问题的基础上对它最后的序列 swap 两两相邻元素即可。

2.1、位置置换 perfect_shuffle1 算法

(1)对原始位置的变化做如下分析:
位置变化
(2)依次考察每个位置的变化规律:

从上面的例子我们能看到,前 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) 的位置。

当 0 < i < n 时, 原式 = (2 * i) % (2 * n + 1) = 2 * i;
当 i > n 时,原式 (2 * i) % (2 * n + 1) 保持不变。

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

因此,如果题目允许我们再用一个数组的话,我们直接把每个元素放到该放得位置就好了。也就产生了最简单的方法 perfect_shuffle1 。

源代码如下:

#include <iostream>

using namespace std;

void PerfectShuffle1(int a[], int n)
{
    if (n < 3 || n % 2 == 1)
        return;
        
    int b[n];
    for (int i = 1; i < n - 1; i++)
        b[(i * 2) % (n - 1)] = a[i];
    for (int j = 1; j < n - 1; j++)
        a[j] = b[j];
}

int main()
{
    int a[100];
    int n = 0;
    while(cin.peek() != '\n')  cin >> a[n++];
    PerfectShuffle1(a, n);
    for (int i = 0; i < n; i++)
        cout << a[i] << " " ;
    cout << endl;
    return 0;
}

分析:它的时间复杂度虽然是 O(n),但其空间复杂度却是 O(n),仍不符合本题所期待的时间 O(n),空间 O(1) 。我们继续寻找更优的解法。

2.2、完美洗牌算法 perfect_shuffle2

2.2.1、走圈算法 cycle_leader

根据上面变换的节奏,我们可以看出有两个圈

一个是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)步。

2.2.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)。

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

若 2 * n != (3^k - 1),则总可以找到最大的整数 m,使得 m < n,并且 2 * m = (3^k - 1)。

对于长度为 2 * m 的数组,整理元素后剩余的 2 *(n - m)长度,递归调用完美洗牌算法即可。

2.2.3、完美洗牌算法 perfect_shuffle3

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

输入数组 a[1..2 * n]
step 1 找到 2 * m = 3^k - 1 使得 3^k <= 2 * n < 3^(k +1)
step 2 把 a[m + 1..n + m]那部分循环移 m 位
step 3 对每个 i = 0,1,2..k - 1,3^i 是个圈的头部,做 cycle_leader 算法,数组长度为 m,所以对 2 * m + 1 取模。
step 4 对数组的后面部分 a[2 * m + 1.. 2 * n] 继续使用本算法, 这相当于 n 减小了 m 。

2.2.4、perfect_shuffle2 算法解决其变形问题

原始问题要输出 a1, b1, a2, b2……an, bn,而完美洗牌却输出的是b1, a1, b2, a2,……bn, an。解决办法非常简单:交换两两相邻元素即可(当然,你也可以让原数组第一个和最后一个不变,中间的 2 * (n - 1) 项用原始的标准完美洗牌算法做),只是在完美洗牌问题时间复杂度 O(N) 空间复杂度 O(1) 的基础上再增加 O(N) 的时间复杂度,故总的时间复杂度 O(N) 不变,且理所当然的保持了空间复杂度 O(1) 。至此,咱们的问题得到了圆满解决!

源代码如下:

#include <iostream>
using namespace std;

class Solution
{
    public:
        // 完美洗牌算法
        void PerfectShuffle(int *a, int n)
        {
            while(n >= 1)
            {
                // 计算环的个数
                int k = 0;
                // 3^1
                int r = 3;
                // 2 * m  = 3^k - 1
                // m <= n  ->  2 * m <= 2 * n  -> 3^k - 1 <= 2 * n
                // 寻找最大的k使得3^k - 1 <= 2*n
                while(r - 1 <= 2 * n)
                {
                    r *= 3;
                    ++k;
                }//while
                int m = (r / 3 - 1) / 2;
                // 循环左移n-m位
                LeftRotate(a + m, n - m, n);
                // k个环 环起始位置start: 1,3...3^(k-1)
                for(int i = 0, start = 1; i < k; ++i, start *= 3)
                {
                    // 走圈
                    CycleLeader(a, start, m);
                }//for
                a += 2 * m;
                n -= m;
            }
        }
    private:
        // 翻转 start 开始位置 end 结束位置
        void Reverse(int *a, int start, int end)
        {
            while(start < end)
            {
                swap(a[start], a[end]);
                ++start;
                --end;
            }//while
        }
        // 循环右移m位 n数组长度 下标从1开始
        void LeftRotate(int *a, int m, int n)
        {
            // 翻转前m位
            Reverse(a, 1, m);
            // 翻转剩余元素
            Reverse(a, m + 1, n);
            // 整体翻转
            Reverse(a, 1, n);
        }
        // 走圈算法
        void CycleLeader(int *a, int start, int n)
        {
            int pre = a[start];
            // 2 * i % (2 * n + 1)
            int mod = 2 * n + 1;
            // 实际位置
            int next = start * 2 % mod;
            // 按环移动位置
            while(next != start)
            {
                swap(pre,a[next]);
                next = 2 * next % mod;
            }//while
            a[start] = pre;
        }
};


int main()
{
    Solution solution;
    int a[100];
    int n = 0;
    while(cin.peek() != '\n')  cin >> a[++n];
    solution.PerfectShuffle(a, n/2);
    for (int i = 1; i <= n; i += 2)
        swap(a[i], a[i + 1]);
    for (int i = 1; i <= n; i++)
    {
        cout << a[i] << " " ;
    }//for
    cout << endl;
    return 0;
}

分析:时间复杂度为 O(n),空间复杂度为 O(1)。

特别注意:

本次题目比较简单,但是要求限制有点苛刻。完美洗牌算法和神级结论还需细致的了解。

参考资料:《编程之法》The Art of Programming By July
[经典面试题]完美洗牌算法

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,757评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,478评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,540评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,593评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,903评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,329评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,659评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,383评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,055评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,337评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,864评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,227评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,820评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,999评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,750评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,365评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,260评论 2 258

推荐阅读更多精彩内容