程序員編程藝術第三十四~三十五章:格子取數問題,完美洗牌算法

第三十四~三十五章:格子取數,完美洗牌算法


作者:July、caopengcs、綠色夾克衫。致謝:西芹_new,陳利人,Peiyush Jain,白石,zinking
時間:二零一三年八月二十三日。


題記

    再過一個半月,即到2013年10月11日,便是本博客開通3週年之際,巧的是,那天剛好也是我的25歲生日。寫博近3年,訪問量趨近500萬,無法確切知道幫助了多少人影響了多少人,但有些文章和一些系列是我比較喜歡的,如這三篇:從B樹、B+樹、B*樹談到R 樹教你如何迅速秒殺掉:99%的海量數據處理面試題支持向量機通俗導論(理解SVM的三層境界)

    以及這2個系列:數據挖掘十大算法系列程序員編程藝術
    當然,還有很多文章或系列自己也比較喜歡(如微軟面試100題系列經典算法研究系列等等),只是上面的文章或系列更具代表性。
    但若論在上述文章或系列中,哪篇文章或系列對人找工作的幫助最大,則應該是:
    其中,尤以編程藝術系列更佳。
    OK,話休絮煩,本文講解此文http://blog.csdn.net/v_july_v/article/details/7974418中的第75題、第79題:
  • 第三十四章:格子取數問題;
  • 第三十五章:完美洗牌算法的變形
   若有任何問題,歡迎讀者隨時批評指正,感謝。


第三十四章、格子取數問題

    題目詳情:有n*n個格子,每個格子裏有正數或者0,從最左上角往最右下角走,只能向下和向右,一共走兩次(即從左上角走到右下角走兩趟),把所有經過的格子的數加起來,求最大值SUM,且兩次如果經過同一個格子,則最後總和SUM中該格子的計數只加一次。
    題目分析:此題是去年2013年搜狗的校招筆試題。初看到此題,因爲要讓兩次走下來的路徑總和最大,讀者可能最初想到的思路可能是讓每一次的路徑都是最優的,即不顧全局,只看局部,讓第一次和第二次的路徑都是最優。
    但問題馬上就來了,雖然這一算法保證了連續的兩次走法都是最優的,但卻不能保證總體最優,相應的反例也不難給出,請看下圖:
    上圖中,圖一是原始圖,那麼我們有以下兩種走法可供我們選擇:
  • 如果按照上面的局部貪優走法,那麼第一次勢必會如圖二那樣走,導致的結果是第二次要麼取到2,要麼取到3,
  • 但若不按照上面的局部貪優走法,那麼第一次可以如圖三那樣走,從而第二次走的時候能取到2 4 4,很顯然,這種走法求得的最終SUM值更大;
    爲了便於讀者理解,我把上面的走法在圖二中標記出來,而把應該正確的走法在上圖三中標示出來,如下圖所示:
    也就是說,上面圖二中的走法太追求每一次最優,所以第一次最優,導致第二次將是很差;而圖三第一次雖然不是最優,但保證了第二次不差,所以圖三的結果優於圖二。由此可知不要只顧局部而貪圖一時最優,而喪失了全局最優。

解法一、直接搜索

    局部貪優不行,我們可以考慮窮舉,但最終將導致複雜度過高,所以咱們得另尋良策。
    @西芹_new,針對此題,可以使用直接搜索法,一共搜(2n-2)步,每一步有四種走法,考慮不相交等條件可以剪去很多枝,代碼如下:
//copyright@西芹_new 2013
#include "stdafx.h"  
#include <iostream>  
using namespace std;  
  
#define N 5  
int map[5][5]={  
    {2,0,8,0,2},  
    {0,0,0,0,0},  
    {0,3,2,0,0},  
    {0,0,0,0,0},  
    {2,0,8,0,2}};  
int sumMax=0;  
int p1x=0;  
int p1y=0;  
int p2x=0;  
int p2y=0;  
int curMax=0;  
  
void dfs( int index){  
    if( index == 2*N-2){  
        if( curMax>sumMax)  
            sumMax = curMax;  
        return;  
    }  
  
    if( !(p1x==0 && p1y==0) && !(p2x==N-1 && p2y==N-1))  
    {  
        if( p1x>= p2x && p1y >= p2y )  
            return;  
    }  
  
    //right right  
    if( p1x+1<N && p2x+1<N ){  
        p1x++;p2x++;  
        int sum = map[p1x][p1y]+map[p2x][p2y];  
        curMax += sum;  
        dfs(index+1);  
        curMax -= sum;  
        p1x--;p2x--;  
    }  
  
    //down down  
    if( p1y+1<N && p2y+1<N ){  
        p1y++;p2y++;  
        int sum = map[p1x][p1y]+map[p2x][p2y];  
        curMax += sum;  
        dfs(index+1);  
        curMax -= sum;  
        p1y--;p2y--;  
    }  
  
    //rd  
    if( p1x+1<N && p2y+1<N ) {  
        p1x++;p2y++;  
        int sum = map[p1x][p1y]+map[p2x][p2y];  
        curMax += sum;  
        dfs(index+1);  
        curMax -= sum;  
        p1x--;p2y--;  
    }  
  
    //dr  
    if( p1y+1<N && p2x+1<N ) {  
        p1y++;p2x++;  
        int sum = map[p1x][p1y]+map[p2x][p2y];  
        curMax += sum;  
        dfs(index+1);  
        curMax -= sum;  
        p1y--;p2x--;  
    }  
}  
  
int _tmain(int argc, _TCHAR* argv[])  
{  
    curMax = map[0][0];  
    dfs(0);  
    cout <<sumMax-map[N-1][N-1]<<endl;  
    return 0;  
}  

解法二、動態規劃

    上述解法一的搜索解法是的時間複雜度是指數型的,如果是隻走一次的話,是經典的dp。

2.1、DP思路詳解

    故正如@綠色夾克衫所說:此題也可以用動態規劃求解,主要思路就是同時DP 2次所走的狀態。

  1、先來分析一下這個問題,爲了方便討論,先對矩陣做一個編號,且以5*5的矩陣爲例(給這個矩陣起個名字叫M1):
M1
0  1  2  3  4
1  2  3  4  5
2  3  4  5  6
3  4  5  6  7
4  5  6  7  8
  從左上(0)走到右下(8)共需要走8步(2*5-2)。我們設所走的步數爲s。因爲限定了只能向右和向下走,因此無論如何走,經過8步後(s = 8)都將走到右下。而DP的狀態也是依據所走的步數來記錄的。
  再來分析一下經過其他s步後所處的位置,根據上面的討論,可以知道:

  • 經過8步後,一定處於右下角(8);
  • 那麼經過5步後(s = 5),肯定會處於編號爲5的位置;
  • 3步後肯定處於編號爲3的位置;
  • s = 4的時候,處於編號爲4的位置,此時對於方格中,共有5(相當於n)個不同的位置,也是所有編號中最多的。

  故推廣來說,對於n*n的方格,總共需要走2n - 2步,且當s = n - 1時,編號爲n個,也是編號數最多的
  如果用DP[s,i,j]來記錄2次所走的狀態獲得的最大值,其中s表示走s步,i和j分別表示在s步後第1趟走的位置和第2趟走的位置。
  2、爲了方便描述,再對矩陣做一個編號(給這個矩陣起個名字叫M2):
M2
0  0  0  0  0
1  1  1  1  1
2  2  2  2 2
3  3  3  3  3
4  4  4  4  4

    把之前定的M1矩陣也再貼下:
M1 
0  1  2  3  4
1  2  3  4  5
2  3  4  5  6
3  4  5  6  7
4  5  6  7  8
  我們先看M1,在經過6步後,肯定處於M1中編號爲6的位置。而M1中共有3個編號爲6的,它們分別對應M2中的2 3 4。故對於M2來說,假設第1次經過6步走到了M2中的2,第2次經過6步走到了M2中的4,DP[s,i,j] 則對應 DP[6,2,4]。由於s = 2n - 2,0 <= i<= <= j <= n,所以這個DP共有O(n^3)個狀態。
M1
0  1  2  3  4
1  2  3  4  5
2  3  4  5  6
3  4  5  6  7
4  5  6  7  8
  再來分析一下狀態轉移,以DP[6,2,3]爲例(就是上面M1中加粗的部分),可以到達DP[6,2,3]的狀態包括DP[5,1,2],DP[5,1,3],DP[5,2,2],DP[5,2,3]。

  3、下面,我們就來看看這幾個狀態:DP[5,1,2],DP[5,1,3],DP[5,2,2],DP[5,2,3],用加粗表示位置DP[5,1,2]    DP[5,1,3]    DP[5,2,2]    DP[5,2,3] (加紅表示要達到的狀態DP[6,2,3]
0 1 2 3 4    0 1 2 3 4    0 1 2 3 4    0 1 2 3 4
1 2 3 4 5    1 2 3 4 5    1 2 3 4 5    1 2 3 4 5
2 3 4 6    2 3 4 5 6    2 3 4 6    2 3 4 6
3 4 5 6 7    3 4 6 7    3 4 5 6 7    3 4 6 7
4 5 6 7 8    4 5 6 7 8    4 5 6 7 8    4 5 6 7 8
  因此:

DP[6,2,3] = Max(DP[5,1,2] ,DP[5,1,3],DP[5,2,2],DP[5,2,3]) + 6,2和6,3格子中對應的數值    (式一) 

  上面(式一)所示的這個遞推看起來沒有涉及:“如果兩次經過同一個格子,那麼該數只加一次的這個條件”,討論這個條件需要換一個例子,以DP[6,2,2]爲例:DP[6,2,2]可以由DP[5,1,1],DP[5,1,2],DP[5,2,2]到達,但由於i = j,也就是2次走到同一個格子,那麼數值只能加1次。
  所以當i = j時,

DP[6,2,2] = Max(DP[5,1,1],DP[5,1,2],DP[5,2,2]) + 6,2格子中對應的數值                         (式二

  4、故,綜合上述的(式一),(式二)最後的遞推式就是
if(i != j)
DP[s, i ,j] = Max(DP[s - 1, i - 1, j - 1], DP[s - 1, i - 1, j], DP[s - 1, i, j - 1], DP[s - 1, i, j]) + W[s,i] + W[s,j]
else
DP[s, i ,j] = Max(DP[s - 1, i - 1, j - 1], DP[s - 1, i - 1, j], DP[s - 1, i, j]) + W[s,i]

    其中W[s,i]表示經過s步後,處於i位置,位置i對應的方格中的數字。下一節我們將根據上述DP方程編碼實現。

2.2、DP方法實現

    爲了便於實現,我們認爲所有不能達到的狀態的得分都是負無窮,參考代碼如下:
//copyright@caopengcs 2013
const int N = 202;
const int inf = 1000000000;  //無窮大
int dp[N * 2][N][N];  
bool isValid(int step,int x1,int x2,int n) { //判斷狀態是否合法
int y1 = step - x1, y2 = step - x2;
    return ((x1 >= 0) && (x1 < n) && (x2 >= 0) && (x2 < n) && (y1 >= 0) && (y1 < n) && (y2 >= 0) && (y2 < n));
}

int getValue(int step, int x1,int x2,int n) {  //處理越界 不存在的位置 給負無窮的值
    return isValid(step, x1, x2, n)?dp[step][x1][x2]:(-inf);
}

//狀態表示dp[step][i][j] 並且i <= j, 第step步  兩個人分別在第i行和第j行的最大得分 時間複雜度O(n^3) 空間複雜度O(n^3) 
int getAnswer(int a[N][N],int n) {
int P = n * 2 - 2; //最終的步數
int i,j,step;
    
    //不能到達的位置 設置爲負無窮大
    for (i = 0; i < n; ++i) {
        for (j = i; j < n; ++j) {
            dp[0][i][j] = -inf;
        }
    
    }
    dp[0][0][0] = a[0][0];

      for (step = 1; step <= P; ++step) {
        for (i = 0; i < n; ++i) {
            for (j = i; j < n; ++j) {
                dp[step][i][j] = -inf;
                if (!isValid(step, i, j, n)) { //非法位置
                    continue;
                }
                //對於合法的位置進行dp
                if (i != j) {
                    dp[step][i][j] = max(dp[step][i][j], getValue(step - 1, i - 1, j - 1, n));
                    dp[step][i][j] = max(dp[step][i][j], getValue(step - 1, i - 1, j, n));
                    dp[step][i][j] = max(dp[step][i][j], getValue(step - 1, i, j - 1, n));
                    dp[step][i][j] = max(dp[step][i][j], getValue(step - 1, i, j,n));
                    dp[step][i][j] += a[i][step - i] + a[j][step - j];  //不在同一個格子,加兩個數
                }
                else {
                    dp[step][i][j] = max(dp[step][i][j], getValue(step - 1, i - 1, j - 1, n));
                    dp[step][i][j] = max(dp[step][i][j], getValue(step - 1, i - 1, j,  n));
                    dp[step][i][j] = max(dp[step][i][j], getValue(step - 1, i, j,  n));
                    dp[step][i][j] += a[i][step - i]; // 在同一個格子裏,只能加一次
                }
                
            }
        }
    }
    return dp[P][n - 1][n- 1];
}

    複雜度分析:狀態轉移最多需要統計4個變量的情況,看做是O(1)的,共有O(n^3)個狀態,所以總的時間複雜度是O(n^3)的,且dp數組開了N^3大小,故其空間複雜度亦爲O(n^3)。

2.3、DP實現優化版

    如上節末所說,2.2節實現的代碼的複雜度空間複雜度是O(n^3),事實上,空間上可以利用滾動數組優化,由於每一步的遞推只跟上1步的情況有關,因此可以循環利用數組,將空間複雜度降爲O(n^2)。
    即我們在推算dp[step]的時候,只依靠它上一次的狀態dp[step - 1],所以dp數組的第一維,我們只開到2就可以了。即step爲奇數時,我們用dp[1][i][j]表示狀態,step爲偶數我們用dp[0][i][j]表示狀態,這樣我們只需要O(n^2)的空間,這就是滾動數組的方法。滾動數組寫起來並不複雜,只需要對上面的代碼稍作修改即可,優化後的代碼如下:
//copyright@caopengcs 8/24/2013
int dp[2][N][N];

bool isValid(int step,int x1,int x2,int n) { //判斷狀態是否合法
int y1 = step - x1, y2 = step - x2;
    return ((x1 >= 0) && (x1 < n) && (x2 >= 0) && (x2 < n) && (y1 >= 0) && (y1 < n) && (y2 >= 0) && (y2 < n));
}

int getValue(int step, int x1,int x2,int n) {  //處理越界 不存在的位置 給負無窮的值
    return isValid(step, x1, x2, n)?dp[step % 2][x1][x2]:(-inf);
}

//狀態表示dp[step][i][j] 並且i <= j, 第step步  兩個人分別在第i行和第j行的最大得分 時間複雜度O(n^3) 使用滾動數組 空間複雜度O(n^2) 
int getAnswer(int a[N][N],int n) {
int P = n * 2 - 2; //最終的步數
int i,j,step,s;
    
    //不能到達的位置 設置爲負無窮大
    for (i = 0; i < n; ++i) {
        for (j = i; j < n; ++j) {
            dp[0][i][j] = -inf;
        }
    
    }
    dp[0][0][0] = a[0][0];

      for (step = 1; step <= P; ++step) {
        for (i = 0; i < n; ++i) {
            for (j = i; j < n; ++j) {
                dp[step][i][j] = -inf;
                if (!isValid(step, i, j, n)) { //非法位置
                    continue;
                }
                s = step % 2;  //狀態下表標
                //對於合法的位置進行dp
                if (i != j) {
                    dp[s][i][j] = max(dp[s][i][j], getValue(step - 1, i - 1, j - 1, n));
                    dp[s][i][j] = max(dp[s][i][j], getValue(step - 1, i - 1, j, n));
                    dp[s][i][j] = max(dp[s][i][j], getValue(step - 1, i, j - 1, n));
                    dp[s][i][j] = max(dp[s][i][j], getValue(step - 1, i, j,n));
                    dp[s][i][j] += a[i][step - i] + a[j][step - j];  //不在同一個格子,加兩個數
                }
                else {
                    dp[s][i][j] = max(dp[s][i][j], getValue(step - 1, i - 1, j - 1, n));
                    dp[s][i][j] = max(dp[s][i][j], getValue(step - 1, i - 1, j,  n));
                    dp[s][i][j] = max(dp[s][i][j], getValue(step - 1, i, j,  n));
                    dp[s][i][j] += a[i][step - i]; // 在同一個格子裏,只能加一次
                }
                
            }
        }
    }
    return dp[P % 2][n - 1][n- 1];
}
    本第34章分析完畢。


第三十五章、完美洗牌算法

    題目詳情:有個長度爲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,a4b4

1.1、步步前移

仔細觀察變換前後兩個序列的特點,我們可做如下一系列操作:
  第①步、確定b1的位置,即讓b1跟它前面的a2,a3,a4交換:
a1,b1,a2,a3,a4,b2b3b4
  第②步、接着確定b2的位置,即讓b2跟它前面的a3,a4交換:
a1,b1,a2,b2,a3,a4,b3b4
  第③步、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,a2b1,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”的論文中提出了完美洗牌算法。
    這個算法解決一個什麼問題呢?跟本題有什麼聯繫呢?
   Yeah,顧名思義,完美洗牌算法解決的就是一個完美洗牌問題。什麼是完美洗牌問題呢?即給定一個數組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)的位置。爲何呢?因爲:
  1. 當0< i <n時, 原式= (2i) % (2 * n + 1)  = 2i;
  2. 當i>n時,原式(2 * i) % (2 * n + 1)保持不變。
  因此,如果題目允許我們再用一個數組的話,我們直接把每個元素放到該放得位置就好了。也就產生了最簡單的方法pefect_shuffle1,參考代碼如下:
// 時間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. 一個是1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1
  2. 一個是3 -> 6 -> 3
    下文2.3.1、走圈算法cycle_leader將再次提到這兩個圈。

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的情況一樣考慮即可。
  參考代碼如下:
//copyright@caopengcs 8/23/2013
//時間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)的時間先把後兩個元素調整好,但這不影響總體時間複雜度。
    故事實上,當我們採用分治算法的時候,其時間複雜度的計算公式爲: T(n) = 2*T(n / 2) + 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. 一個是1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1
        2. 一個是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

    OK,理論講清楚了,再舉個例子便會更加一目瞭然。當給定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. step 1 找到 2 * m = 3^k - 1 使得 3^k <= 2 * n < 3^(k +1)
  2. step 2 把a[m + 1..n + m]那部分循環移m位
  3. step 3 對每個i = 0,1,2..k - 1,3^i是個圈的頭部,做cycle_leader算法,數組長度爲m,所以對2 * m + 1取模。
  4. step 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)。

   此完美洗牌算法實現的參考代碼如下:

//copyright@caopengcs 8/24/2013
//時間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兩兩相鄰元素即可,代碼如下:

//copyright@caopengcs 8/24/2013
//時間複雜度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)。至此,咱們的問題得到了圓滿解決!

2.3.5、神級結論是如何來的?

    我們的問題得到了解決,但本章尚未完,即決定完美洗牌算法的神級結論:若2*n=(3^k - 1),則恰好只有k個圈,且每個圈頭部的起始位置分別是1,3,9,...3^(k-1),是如何來的呢?

    要證明這個結論的關鍵就是:這所有的圈合併起來必須包含從1到M之間的所有證書,一個都不能少。這個證明有點麻煩,因爲證明過程中會涉及到羣論等數論知識,但再遠的路一步步走也能到達。

    ,讓咱們明確以下相關的概念,定理,及定義(搞清楚了這些東西,咱們便證明了一大半):

  • 概念1    mod表示對一個數取餘數,比如3 mod 5 =3,5 mod 3 =2;
  • 定義1    歐拉函數ϕ(m) 表示爲不超過m(即小於等於m)的數中,與m互素的正整數個數
  • 定義2    若ϕ(m)=Ordm(a) 則稱am的原根,其中Ordm(a)定義爲:a ^d ( mod m),其中d=0,1,2,3…,但取讓等式成立的最小的那個d。
    結合上述定義1、定義2可知,2是3的原根,因爲2^0 mod 3 = 1, 2^1 mod 3 = 2, 2^2 mod 3 = 1, 2^3 mod 3 = 2,{a^0 mod m,a^1 mod m,a^2}得到集合S={1,2},包含了所有和3互質的數,也即d=ϕ(3)=2,滿足原根定義
    而2不是7的原根,這是因爲2^0 mod 7 = 1, 2^1 mod 7 = 2, 2^2 mod 7 = 4, 2^3 mod 7 = 1,2^4 mod 7 = 2,2^5 mod 7 = 4,2^6 mod 7 = 1,從而集合S={1,2,4}中始終只有1、2、4三種結果,而沒包含全部與7互質的數(3、6、5便不包括),,即d=3,但ϕ(7)=6,從而d != ϕ(7)不滿足原根定義
    再者,如果說一個數a,是另外一個數m的原根,代表集合S = {a^0 mod m, a^1 mod m, a^2 mod m…… },得到的集合包含了所有小於m並且與m互質的數,否則a便不是m的原根。而且集合S = {a^0 mod m, a^1 mod m, a^2 mod m…… }中可能會存在重複的餘數,但當a與m互質的時候,得到的{a^0 mod m, a^1 mod m, a^2 mod m}集合中,保證了第一個數是a^0 mod m,故第一次發現重複的數時,這個重複的數一定是1,也就是說,出現餘數循環一定是從開頭開始循環的
  • 定義3    對模指數,a對模m的原根定義爲 ,st:中最小的正整數d
    再比如,2是9的原根,因爲,爲了讓除以9的餘數恆等於1,可知最小的正整數d=6,而ϕ(m)=6,滿足原根的定義
  • 定理1    同餘定理:兩個整數a,b,若它們除以正整數m所得的餘數相等,則稱a,b對於模m同餘,記作,讀做a與b關於模m同餘。
  • 定理2    當p爲奇素數且a的原根時 a也是的原根
  • 定理3    費馬小定理:如果a和m互質,那麼a^ϕ(m) mod m = 1
  • 定理4    (a,m)=1 且am的原根,那麼a(Z/mZ)*的生成元。
    取a = 2, m = 3。
    我們知道2是3的原根,2是9的原根,我們定義S(k)表示上述的集合S,並且取x = 3^k(
x表示爲集合S中的數)。
    所以:
      S(1) = {1, 2}
      S(2) = {1, 2, 4, 8, 7, 5}
    我們沒改變圈元素的順序,由前面的結論S(k)恰好是一個圈裏的元素,且認爲從1開始循環的,也就是說從1開始的圈包含了所有與3^k互質的數。 
    那與3^k不互質的數怎麼辦?如果0 < i < 3^k與 3^k不互質,那麼i 與3^k的最大公約數一定是3^t的形式(只包含約數3),並且 t < k。即gcd(i , 3^k) = 3^t,等式兩邊除以個3 ^ t,即得gcd( i/(3^t),3^(k - t) )  = 1, i/(3^t) 都與3^(k - t) 互質了,並且i / (3^t) < 3^(k - t), 根據S(k)的定義,可見i/(3^t) 在集合S(k - t)中。 
    同理,任意S(k - t)中的數x,都滿足gcd(x , 3^k)  = 1,於是gcd(3^k , x* 3^t) = 3 ^ t, 並且x*3^t < 3^k。可見S(k - t)中的數x*3^t 與 i形成了一一對應的關係。
      也就是說S(k - t)裏每個數x* 3^t形成的新集合包含了所有與3^k的最大公約數爲3^t的數,它也是一個圈,原先圈的頭部是1,這個圈的頭部是3^t
    於是對所有的小於 3^k的數,根據它和3^k的最大公約數,我們都把它分配到了一個圈裏去了,且k個圈包含了所有的小於3^k的數

    下面,舉個例子,如caopengcs所說,當我們a = 2, m = 3時,

    我們知道2是3的原根,2是9的原根,我們定義S(k)表示上述的集合S,並且x= 3^k。
    所以S(1) = {1, 2}
      S(2) = {1, 2, 4, 8, 7, 5}
    比如k = 3。 我們有:
  1. S(3) = {1, 2 ,4 , 8, 16, 5, 10, 20, 13, 26, 25, 23, 19, 11, 22, 17, 7, 14} 包含了小於27且與27互質的所有數,圈的首部爲1,這是原根定義決定的。
  2. 那麼與27最大公約數爲3的數,我們用S(2)中的數乘以3得到。 S(2) * 3 = {3, 6, 12, 24, 21, 15}, 圈中元素的順序沒變化,圈的首部是3
  3. 與27最大公約數爲9的數,我們用S(1)中的數乘以9得到。 S(1) * 9 = {9, 18}, 圈中得元素的順序沒變化,圈的首部是9
    因爲每個小於27的數和27的最大公約數只有1, 3, 9這3種情況,又由於前面所證的一一對應的關係,所以S(2) * 3包含了所有小於27且與27的最大公約數爲3的數,S(1) * 9 包含了所有小於27且和27的最大公約數爲9的數。

換言之,若定義爲整數,假設/N定義爲整數Z除以N後全部餘數的集合,包括{0...N-1}等N個數,而/N)*則定義爲Z/N中{0...N-1}這N個餘數內與N互質的數集合。

則當n=13時,2n+1=27,即得/N ={0,1,2,3,.....,26},/N)*相當於就是{0,1,2,3,.....,26}中全部與27互素的數的集合;

     而2^k(mod 27)可以把(/27)*取遍,故可得這些數分別在以下3個圈內:

  • 取頭爲1,(/27)*={1,2,4,8,16,5,10,20,13,26,25,23,19,11,22,17,7,14},也就是說,與27互素且小於27的正整數集合爲{1,2,4,8,16,5,10,20,13,26,25,23,19,11,22,17,7,14},因此ϕ(m) = ϕ(27)=18, 從而滿足的最小= 18,故得出227的原根
  • 取頭爲3,就可以得到{3,6,12,24,21,15},這就是以3爲頭的環,這個圈的特點是所有的數都是3的倍數,且都不是9的倍數。爲什麼呢?因爲2^k和27互素。
    具體點則是:如果3×2^k除27的餘數能夠被9整除,則有一個n使得3*2^k=9n(mod 27),即3*2^k-9n能夠被27整除,從而3*2^k-9n=27m,其中n,m爲整數,這樣一來,式子約掉一個3,我們便能得到2^k=9m+3n,也就是說,2^k是3的倍數,這與2^k與27互素是矛盾的,所以,3×2^k除27的餘數不可能被9整除。
    此外,2^k除以27的餘數可以是3的倍數以外的所有數,所以,2^k除以27的餘數可以爲1,2,4,5,7,8,當餘數爲1時,即存在一個k使得2^k-1=27m,m爲整數。
式子兩邊同時乘以3得到:3*2^k-3=81m是27的倍數,從而3*2^k除以27的餘數爲3;
同理,當餘數爲2時,2^k - 2 = 27m,=> 3*2^k- 6 =81m,從而3*2^k除以27的餘數爲6;
當餘數爲4時,2^k - 4 = 37m,=> 3*2^k - 12 =81m,從而3*2^k除以27的餘數爲12;
同理,可以取到15,21,24。從而也就印證了上面的結論:取頭爲3,就可以得到{3,6,12,24,21,15}。
  • 取9爲頭,這就很簡單了,這個圈就是{9,18}
     你會發現,小於27的所有自然數,要麼在第一個圈裏面,也就是那些和27互素的數;要麼在第二個圈裏面,也就是那些是3的倍數,但不是9的倍數的數;要麼在第三個圈裏面,也就是是9倍數的數,而之所以能夠這麼做,就是因爲2是27的本原根。證明完畢
    最後,咱們也再驗證下上述過程:

    因爲,故:

i  = 1  2  3  4   5   6   7  8   9  10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27

    由於n=13,2n+1 = 27,據此公式可知,上面第 位置的數將分別變成下述位置的:

i  = 2  4  6  8  10 12 14 16 18 20 22 24 26  1   3   5  7   9  11 13 15 17 19 21 23 25  0   

    根據i 和 i‘ 前後位置的變動,我們將得到3個圈:

  • 1248165102013262523191122177141;
  • 36122421153
  • 9189
    沒錯,這3個圈的數字與咱們之前得到的3個圈一致吻合,驗證完畢。
2.3.6、完美洗牌問題的幾個擴展

    至此,本章開頭提出的問題解決了,完美洗牌算法的證明也證完了,是否可以止步了呢?OH,NO!讀者有無思考過下述問題:

  1. 既然完美洗牌問題是給定輸入:a1,a2,a3,……aN,b1,b2,b3,……bN,要求輸出:b1,a1,b2,a2,……bN,aN;那麼有無考慮過它的逆問題:即給定b1,a1,b2,a2,……bN,aN,,要求輸出a1,a2,a3,……aN,b1,b2,b3,……bN ?
  2. 完美洗牌問題是兩手洗牌,假設有三隻手同時洗牌呢?那麼問題將變成:輸入是a1,a2,……aN, b1,b2,……bN, c1,c2,……cN,要求輸出是c1,b1,a1,c2,b2,a2,……cN,bN,aN,這個時候,怎麼處理?
    以上兩個完美洗牌問題的幾個擴展請讀者思考,具體解答請參看參考鏈接第15條。

    本第35章完。


參考鏈接

  1. huangxy10,http://blog.csdn.net/huangxy10/article/details/8071242
  2. @綠色夾克衫,http://www.51nod.com/answer/index.html#!answerId=598
  3. 格子取數的蠻力窮舉法:http://wenku.baidu.com/view/681c853b580216fc700afd9a.html
  4. @陳立人,http://mp.weixin.qq.com/mp/appmsg/show?__biz=MjM5ODIzNDQ3Mw==&appmsgid=10000141&itemidx=1&sign=4f1aa1a2269a1fac88be49c8cba21042
  5. caopengcs,http://blog.csdn.net/caopengcs/article/details/10196035
  6. 完美洗牌算法的原始論文“A Simple In-Place Algorithm for In-Shuffle”,http://att.newsmth.net/att.php?p.1032.47005.1743.pdf
  7. 原始根模:http://en.wikipedia.org/wiki/Primitive_root_modulo_n
  8. 洗牌的學問:http://www.thecodeway.com/blog/?p=680
  9. 關於完美洗牌算法:http://cs.stackexchange.com/questions/332/in-place-algorithm-for-interleaving-an-array/400#400
  10. 關於完美洗牌算法中圈的說明:http://www.emis.de/journals/DMTCS/pdfpapers/dm050111.pdf
  11. 關於神級結論的討論:http://math.stackexchange.com/questions/477125/how-to-prove-algebraic-structure-of-the-perfect-shuffle左邊鏈接中的討論中有錯誤,以在本文2.3.5節進行了相關修正
  12. caopengcs關於神級結論的證明:http://blog.csdn.net/caopengcs/article/details/10429013
  13. 同餘的概念:http://zh.wikipedia.org/wiki/%E5%90%8C%E9%A4%98
  14. 神奇的費馬小定理:http://www.xieguofang.cn/Maths/Number_Theory/Fermat's_Little_Theorem_1.htm
  15. 完美洗牌問題的幾個擴展:http://blog.csdn.net/caopengcs/article/details/10521603
  16. 原根與指數的介紹:http://wenku.baidu.com/view/bbb88ffc910ef12d2af9e738
  17. 《數論概論》Joseph H. Silverman著,推薦理由:因寫上文中的完美洗牌算法遇到了一堆數論定理受了刺激,故推薦此書;

後記

    以上第35章可能是整個系列迄今爲止我最滿意的一篇,不僅僅是因爲此章思路清晰,過渡自然,代碼風格良好,更因爲有了@曹鵬博士 的加入,編程藝術如虎添翼,質量更上一層!
    :編程藝術通過解決一個個實際的編程面試題,讓廣大初學者一步步學會分析問題解決問題優化問題的能力,且每個問題的講解足夠通俗,希望後續我(們)做得越來越好!July、二零一三年八月二十四日凌晨零點三十七分。
發佈了166 篇原創文章 · 獲贊 1萬+ · 訪問量 1604萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章