1. 程式人生 > >【編程珠璣】【第二章】問題B

【編程珠璣】【第二章】問題B

h+ 總結報告 off amp 質數 公約數 例如 strlen 一次

問題B將一個n元一維向量向左旋轉i個位置。例如,當n = 8i = 3時,向量abcdefgh旋轉為defghabc

方法一、使用一個字節的額外空間開銷。

采用每次向左移一位的方法,循環i次。當然也可以使用向右移動的方法,循環length - i次。以向左移動為例,共需要移動i趟,首先把str[0]賦值給臨時變量temp,剩余的字符向左移動一位,即str[k]=str[k+1],移動完成後把臨時變量temp賦值給str[n-1]

該方法比較笨,但是也是最容易想到的,它空間開銷小,但是時間開銷非常大,時間復雜度為O(n^2)這是因為兩層的嵌套循環,效率太低。

#include <stdio.h>
#include 
<string.h> #include <assert.h> void RightShift(char *str, int k){ if(str == NULL){ return; } int length = strlen(str); k = k % length; int i = 0; //雖然傳入的參數是k,但這是左移的次數,實際需要右移length-k個位置 int tmp = length - k; while(tmp--){ char temp = str[length - 1
]; for(i = length - 1; i > 0; i--){ str[i] = str[i - 1]; } str[0] = temp; } } void LeftShift(char *str, int k){ if(str == NULL){ return; } printf("string :%s\n",str); int length = strlen(str); k = k % length; int i = 0; while
(k--){ char temp = str[0]; for(i = 0; i <length-1 ;i++){ str[i] = str[i + 1]; } str[length-1] = temp; } } int main(void){ char a[]="abcdefgh123"; //使用char *a="abcdefgh123";異常退出,貌似不能賦值。 LeftShift(a,3); printf("string :%s\n",a); return 0; }

方法二、使用n個字節的額外空間開銷

減小時間開銷的一個基本思想是以空間換時間。這個算法使用一個新的長度為n的字符數組temp保存原始字符串的副本,然後利用temp對原始字符串的每個元素重新賦值成新的、旋轉後的字符串。

此算法也相對比較簡單,稍顯巧妙的是str[j] = temp[(j+k)%length];給原數組重新賦值的操作。但是此算法會開辟O(n)的空間,以加速程序執行。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void LeftShift(char *str, int k){
    int length = strlen(str);
    char * temp = (char *)malloc(length*sizeof(char));
    int j;
    for (j=0;j<length;j++){
        temp[j] = str[j];
    }
    for (j=0;j<length;j++){
        str[j] = temp[(j+k)%length];
    }
}

方法三、使用i個字節的額外空間開銷。

顯而易見,上述兩種算法遠非最佳算法,有在時空上取得雙贏的改進的可能。第三種算法將字符串的前i個元素復制到一個臨時字符數組temp中,將原始字符串余下的n-i個元素左移i個位置,最後將最初的i個元素從temp中復制到余下的位置。這樣就實現了移動。

這種算法看上去和第二種沒太大差別,但無論從時間開銷還是空間開銷上來講,都要比第二種好。原因在於雖然原始字符串中的每個位置都要發生變化,但沒有必要花費n個字節的內存開銷保存原始字符串的完整副本,只需保存前i個位置的元素。

可是此算法依然比較笨笨的,而且改進的效果不穩定,它使用了i個額外的位置仍然比較浪費空間,所以並不是十分好。

void LeftShift(char *str, int k){
    char * temp = (char *)malloc(k*sizeof(char));
    int length = strlen(str);        
    int j;
    for (j=0;j<k;j++){
        temp[j] = str[j];
    }
    for (j=k;j<length;j++){
        str[j-k] = str[j];
    }
    for (j=length-k;j<length;j++){
        str[j] = temp[j-length+k];
    }
}

方法四、“翻手”算法,也叫“求逆”算法

來看一個有趣的實現字符串循環左移的算法。在具體講這種算法之前,先來看看線性代數裏的轉置。(AB)T等於什麽?等於BTAT。那麽(ATBTT等於什麽?等於(BTTATT,即BA。啊哈!我們用三個步驟就可以完成這個字符串的循環左移了。對於字符串來講,轉置在這裏就是逆置。把原始字符串分成ab兩部分,a是前i個元素,b是後n-i個元素,首先對a求逆,得到a-1b,然後對b求逆得到a-1b-1,然後對整體求逆得到(a-1b-1-1=ba。 8個字符的字符串abcdefgh -> defghabc需要左移三個元素(或者右移5個元素),使用三次翻轉的基本思路為:

reverse(0,i-1); //cba defgh——左邊i個元素翻轉  
reverse(i,n-1); //cba hgfed——右邊n-i個元素翻轉  
reverse(0,n-1); //defghabc——整體翻轉,共三次翻轉,時間復雜度為O(n)。

void Swap(char *a, char *b){
    char temp = *a;
    *a = *b;
    *b = temp;
}
void Reverse(char *str, int left, int right){
    if(str == NULL || left >= right){    //assert((str != NULL)&&(left <= right));  
        return;
    }
    while(left < right){
        Swap(&str[left], &str[right]);
        left++;
        right--;
    }
}
/*等價的Reverse可寫作如下:
void Reverse(char* str,int left, int right) {  
    if(str == NULL || left >= right){    //assert((str != NULL)&&(left <= right));  
        return;
    }
    int mid = (left + right)/2,i,j;
    for ( i = left,j = right;i <= mid;i++,j--)  {  
        Swap(&str[i], &str[j]);
    }  
}*/
void LefttShift(char *str, int k){
    if(str == NULL){
        return;
    }
    int length = strlen(str);
    k = k % length;

    Reverse(str, 0, k - 1);
    Reverse(str, k , length - 1);
    Reverse(str, 0 , length - 1);
}

方法五、Juggling act,雜耍算法。

為了滿足O(1)空間的限制,延續方法一的思路,如果每次直接把原向量的一個元素移動到目標向量中它的應該出現新位置上就行了。先把array[0]保存起來,然後把array[i]移動到array[0]上,array[2i]移到array[i]上,直至返回取原先的array[0]。但這需要解決的問題是,如何保證所有元素都被移動過了?數學上的結論是,依次以array[0],...,array[gcd(i,n)-1]為首元進行循環即可,其中gcd(a,b)是a與b的最大公約數。

正如“雜技”一詞所暗示的一樣,這個算法就像在玩雜耍球,你要讓它們中的每一個都在合適的位置上,這些球,除了手中有一個,其它幾個都在空中。如果不熟悉,很容易手忙腳亂,把球掉的滿地都是。

先從幾個概念開始:

同余:如果兩個整數a,b除以同一個整數m得到余數相同,則稱a,b對於模m同余。記作a ≡ b (mod m)

數學描述:設m不等於0, 若m|(a-b)即a-b=km,則稱m為模,a同余於b(模m),以及b是a對模m的剩余。記作 a≡b(mod m)。

同余類:所謂同余類是指以某一特定的整數為模,按照同余的方式對全體整數進行分類。對給定的模m,有且恰有m個不同的模m同余類。它們是:0 mod m,1 mod m,…,(m-1)mod m。

完全剩余類:由上可知,所有的整數以m為模可以劃分為m個沒有交集的集合。分別從每個集合中取一個整數組成一個集合,則該集合中的m個整數互不同余(除以m的余數互不相同),這個集合就叫做完全剩余類。

基於以上知識,我們可以證明這樣一個事實,即如果i和n互質的話,那麽序列:0, i mod n , 2i mod n , 3i mod n , …… , (n-1)*i mod n,就包括了集合{0,1,2,……n-1}的所有元素,下一個元素(n)*i mod n 又是0。我們為什麽會有這樣的結論呢,下面來證明一下:

前提條件: 對於模n來說,序列0,1,2,……,n-1本身就是一個完全剩余類,即他們之間兩兩互不模n同余。

證明步驟:

1)從此序列中任取兩個數字xi,xj(0 <= i,j <= n-1),則有Xi≠Xj (mod n),

註:這裏由於不能打出不同余字符因此用不等於替代

2)由於i和n是互質的,對於序列中任意兩個數字xi,xj,有xi * i ≠ xj * i(mod n),這就說明xi從0開始一直取值到n-1,得到的序列0 * i,1 * i,2 *i,……(n-1)*n是一個完全剩余類,即集合{0,1,2,……n-1}。

概念介紹結束,有了這些結論之後,如果i和n互質,下面的賦值過程便能完成所有位置的值的移動:

    t = X[0]
    X[0] = X[i mod n]
    X[i mod n] = X[2i mod n]
    …….
    X[(n-2)*i mod n] = X[(n-1)*i mod n]
    X[ (n-1)*i mod n] = t

以上賦值操作符的兩邊都得到了一個完全剩余類,也就是說所有的0 ~ n-1的所有位置都被移動過了,每次賦值將一個元素的放置到了最終位置上,可見由於i2i,……之間的偏移量是相同的,所以整個操作實際上就是講序列向左移動i個位置(超過了開始位置的部分會被連接到最右邊去)。

算法正確執行的前提是i是與n互質的,這樣通過循環的每隔i元素並對n取余的遍歷方式能夠不重復的訪問到數組a[n]中的每個元素,並通過一步賦值將其移動到正確的位置上,所需要的額外空間僅僅用於保存被第一個賦值操作所覆蓋掉的數字,待全部的n-1個位置移動完畢後,將這個額外空間所保存的數賦值給第n個位置。

根據以上我們直到如果in互質,我們可以一輪循環完成左移任務。那麽如果in不是互質的呢?那需要利用同余的結論,讓in互質,構造一對互質的數i’和n’,其中i= i/gcd(i,n)n= n/gcd(i,n)。這意味著每g=gcd(i,n)個元素組成塊,整個數組共有n/gcd(i,n)個塊,這樣每趟循環只能針對每組中的一個位置的元素,把所有的元素處理完畢需要進g輪循環

技術分享圖片

//*把字符串循環左移k位*/  
void LeftRotateString(char* str,int k)  {  
    assert(str != NULL && k > 0);  
    int length = strlen(str);  
    int gcdNum = gcd(length,k);     //每組包含元素數目g=gcd(n,k)
    for (int i = 0;i < gcdNum;i++)  {  
        char temp = str[i];            //每組的起始位置,註意不能寫成0 
        int first = i;  
        int next = (first + k) % strLen;  
        while(next != i)  {  
            str[first] = str[next];  
            first = next;  
            next = (first + k) % strLen;  
        }  
        str[first] = temp;        //臨時變量中存儲每一趟的循環的最後一個字符  
    }  
}  

註意:對於左旋轉字符串,我們知道每個單元都需要且只需要賦值一次,什麽樣的序列能保證每個單元都只賦值一次呢?

A. 對於正整數kn互為質數的情況,例如k = 3, n =40,1,2,3情況,算法執行如下:

tmp = str[0],          //把第一個元素保存起來
str[0] = str[3],       //i==0, i*k=0,(i+1)*k%n==0*3%4==3
str[3] = str[2],       //i==1,i*k=3, (i+1)*k%n==2*3%4==2
str[2] = str[1],       //...
str[1] = tmp,          //放置第一個元素

  B. 對於正整數kn不是互為質數的情況(因為不可能所有的kn都是互質整數對),那麽我們把它分成一個個互不影響的循環鏈,所有序號為(j + i * k)%nj0gcd(n,k) - 1)之間的某一個整數,i = 0n-1)會構成一個循環鏈,一共有gcd(n,k)個循環鏈,對每個循環鏈分布進行一次內循環就可以了。

仍然不是很懂,有機會在琢磨琢磨。編程珠璣裏面提到了Gries and Mills的一篇總結報告,<swap section>。該報告中提到了三種算法,其中之一就是本算法,不過它稱之為Dolphine swap算法,Dolphine swap的基本思路是,把x[0]先保存起來,然後把x[i]放到x[0],把x[2i]放到x[i]...如果in互質的話,一個循環就能將所有的字符串都放好,最後把t填充到最後一個空位中。如果in不互質的話,則需要做gcd個循環。

void dolphine( char* s, int pos ){
    int n=strlen(s);
    int r = gcd( n, pos );
    int i=0;
    for( i=0; i<r; i++ ){
        char t=s[i];
        int j=i;
        while(1){
            int k = j+pos;
            if( k >= n )
                k -= n;
            if( k == i )
                break;
            s[j] = s[k];
            j =k ;
        }
        s[j] = t;
    }
}
//順便給出gcd的算法
int gcd( int a, int b ){
    while( a != b ){
        if( a>b )
            a -= b;
        else
            b -= a;
    }
    return a;
}

方法六、分段遞歸交換算法。

書上介紹:旋轉向量x其實就是交換向量ab的兩段,得到ba(a代表x中的前i個元素)。假設a比b短,將b分為b1和b2兩段,使b2有跟a相同的長度,然後交換a和b2,也就是ab1b2交換得到b2b1a,a的位置已經是最終的位置,現在的問題集中到交換b2b1這兩段,又回到了原來的問題。不斷遞歸下去,到b1和b2的長度長度相等交換即可。

書中說需要用遞歸解之,單個人感覺並無必要用遞歸,兩次交換數據塊即可。

//交換操作,如下所示
//swap x[a .. a+offset-1] and x[b .. b+offset-1]
void swap(int array[], int a, int b, int offset){
    int temp;
    for (int i = 0; i < offset; i++) {
        temp = array[a + i];
        array[a + i] = array[b + i];
        array[b + i] = temp;
    }
}
//交換主要代碼
void swapShift(int *array, int n, int rotdist){
    int p = rotdist;
    int i = p;
    int j = n - p;
    while (i != j)  {
        if (i > j)  {
            swap(array, p - i, p, j);
            i -=j;
        }  else  {
            swap(array, p - i, p + j - i, i);
            j -= i;
        }
    }
    swap(array, p - i, p, i);
}

求逆算法擴展

求逆算法通過使用void Reverse(char* str,int left, int right)轉置函數函數,能夠對任意的字符串向量求逆。常見於面試題目中,如:(google面試題)用線性時間和常數附加空間將一篇文章的所有單詞倒序。舉個例子:This is a paragraph for test 處理後: test for paragraph a is This

如果使用求逆的方式,先把全文整體求逆,再根據空格對每個單詞內部求逆,是不是很簡單?另外淘寶今年的實習生筆試有道題是類似的,處理的對象規模比這個擴展中的“一篇文章”小不少,當然解法是基本一樣的,只不過分隔符不是空格而已,這裏就不重述了。

【編程珠璣】【第二章】問題B