【编程珠玑】【第二章】问题B
问题B:将一个n元一维向量向左旋转i个位置。例如,当n = 8且i = 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。那么(ATBT)T等于什么?等于(BT)T(AT)T,即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的所有位置都被移动过了,每次赋值将一个元素的放置到了最终位置上,可见由于i,2i,……之间的偏移量是相同的,所以整个操作实际上就是讲序列向左移动i个位置(超过了开始位置的部分会被连接到最右边去)。
算法正确执行的前提是i是与n互质的,这样通过循环的每隔i元素并对n取余的遍历方式能够不重复的访问到数组a[n]中的每个元素,并通过一步赋值将其移动到正确的位置上,所需要的额外空间仅仅用于保存被第一个赋值操作所覆盖掉的数字,待全部的n-1个位置移动完毕后,将这个额外空间所保存的数赋值给第n个位置。
根据以上我们直到如果i和n互质,我们可以一轮循环完成左移任务。那么如果i和n不是互质的呢?那需要利用同余的结论,让i和n互质,构造一对互质的数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. 对于正整数k、n互为质数的情况,例如k = 3, n =4的0,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. 对于正整数k、n不是互为质数的情况(因为不可能所有的k、n都是互质整数对),那么我们把它分成一个个互不影响的循环链,所有序号为(j + i * k)%n(j为0到gcd(n,k) - 1)之间的某一个整数,i = 0:n-1)会构成一个循环链,一共有gcd(n,k)个循环链,对每个循环链分布进行一次内循环就可以了。
仍然不是很懂,有机会在琢磨琢磨。编程珠玑里面提到了Gries and Mills的一篇总结报告,<swap section>。该报告中提到了三种算法,其中之一就是本算法,不过它称之为Dolphine swap算法,Dolphine swap的基本思路是,把x[0]先保存起来,然后把x[i]放到x[0],把x[2i]放到x[i]...如果i和n互质的话,一个循环就能将所有的字符串都放好,最后把t填充到最后一个空位中。如果i和n不互质的话,则需要做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
如果使用求逆的方式,先把全文整体求逆,再根据空格对每个单词内部求逆,是不是很简单?另外淘宝今年的实习生笔试有道题是类似的,处理的对象规模比这个扩展中的“一篇文章”小不少,当然解法是基本一样的,只不过分隔符不是空格而已,这里就不重述了。