字符串和數組在存儲上是類似的,把它們歸爲同一主題之下。本文主要介紹三大類問題和它們衍生的問題,以及相應算法。
本文主要介紹和討論的問題和介紹的算法(點擊跳轉):
字符串循環移位(左旋轉)問題
問題敘述:
將一個n元一維向量向左旋轉i個位置。例如,當n=8且i=3時,"abcdefgh"旋轉爲"defghabc",要求時間爲O(n),額外存儲佔用爲O(1)。(《編程珠璣》第二章問題B)
分析:
嚴格來說這並不是一個字符串,因爲'\0'是不會移動的。爲了敘述方便,可以把它認爲是字符串,只是不對'\0'進行操作罷了。
如果不考慮時間要求爲O(n),那麼可以每次整體左移一位,一共移動i次。只使用O(1)的空間的條件下,一共要進行元素交換O(n*i)次;
如果不考慮空間要求爲O(1),那麼可以把前i個存入臨時數組,剩下的左移i位,再把臨時數組裏的內容放入後i個位置中。
很可惜,由於兩個限制條件,以上兩種思路都不滿足要求。
對於算法1和算法2,如果理解有困難,不必強求,能掌握算法3就好。
爲了滿足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的最大公約數。因此算法可寫爲:
int vec_rotate(char *vec,int rotdist, int length) { int i,j,k,times; char t; times = gcd(rotdist,length); printf("%d\n",times); for(i=0;i<times;i++) { t = vec[i]; j = i; while(1) { k = j+ rotdist; if(k>=length) k-=length; if(k==i) break; vec[j] = vec[k]; j = k; } vec[j]=t; } return 0; }
正如“雜技”一詞所暗示的一樣,這個算法就像在玩雜耍球,你要讓它們中的每一個都在合適的位置上,這些球,除了手中有一個,其它幾個都在空中。如果不熟悉,很容易手忙腳亂,把球掉的滿地都是。
考慮第二個思路,相當於把向量x分爲兩部分a和b,左移就是把ab變成ba,其中a包含了前i個元素。假設a比b短,把b分爲bl和br,那麼需要先把a與br交換得到brbla,再對brbl遞歸左旋。而a比b長的情況類似,當a與b長度相等時直接兩兩交換元素就能完成。同時可以看到,每次待交換的向量長度都小於上一次,最終遞歸結束。
int vec_rotate_v1(char *vec,int i, int length) { int j = length - i; if(i>length) i = i%length; if(j==0) return 0; else if(j>i) { //case1: ab -> a-b(l)-b(r) swap(vec,0,j,i); vec_rotate_v1(vec,i,j); } //case2: ab -> a(l)-a(r)-b //i becomes less else if(j<i) { swap(vec,i,2*i-length,j); vec_rotate_v1(vec,2*i-length,i); } else swap(vec,0,i,i); return 0; } int swap(char* vec,int p1,int p2, int n) { char temp; while(n>0) { temp = vec[p1]; vec[p1] = vec[p2]; vec[p2] = temp; p1++; p2++; n--; } return 0; }
這個算法的缺陷是,需要對三種情況進行討論,而且下標稍不注意就會出錯。
延續算法2的思路,並假定有一個輔助函數能對向量求逆。這樣,分別對a、b求逆得到arbr,再對整體求逆便獲得了ba!難怪作者也要稱之爲“靈光一閃”的算法。可能以前接觸過矩陣運算的讀者對此很快便能理解,因爲(ATBT)T = BA。算法如下:
int vec_rotate_v2(char *vec,int i,int length){ assert((i<=0)||(length<=0)) if(i>length) i = i%length; if (i==length) { printf("i equals n. DO NOTHING.\n"); return 0; } reverse(vec,0,i-1); reverse(vec,i,length-1); reverse(vec,0,length-1); return 1; } int reverse(char *vec,int first,int last){ char temp; while(first<last){ temp = vec[first]; vec[first] = vec[last]; vec[last] = temp; first++; last--; } return 0; }
如果能想到,算法3無疑既高效,也難以在編寫時出錯。有人曾主張把這個求逆的左旋方法當做一種常識。
來看看這種思想的應用吧:
擴展:(google面試題)用線性時間和常數附加空間將一篇文章的所有單詞倒序。
舉個例子:This is a paragraph for test
處理後: test for paragraph a is This
如果使用求逆的方式,先把全文整體求逆,再根據空格對每個單詞內部求逆,是不是很簡單?另外淘寶今年的實習生筆試有道題是類似的,處理的對象規模比這個擴展中的“一篇文章”小不少,當然解法是基本一樣的,只不過分隔符不是空格而已,這裏就不重述了。
以字符串散列爲例的哈希表
關於哈希表,這裏就不做解釋了,主要是演示一個基於哈希表的用於單詞計數的程序。
typedef struct node *nodeptr; typedef struct node { char *word; int count; nodeptr next; } node;
#define NHASH 29989 //在寬鬆“單詞”的定義下《聖經》裏也只有29131個不同的單詞 //與之可能數接近的質數 #define MULT 31//乘數 nodeptr bin[NHASH];
unsigned int hash(char *p) { unsigned int h = 0; for(;*p;p++) h = MULT *h +*p; return h%NHASH; }
void incword(char *s) { nodeptr p; int h= hash(s); for(p=bin[h];p!=NULL;p=p->next) if(strcmp(s,p->word)==0) { (p->count)++; return; } p = malloc(sizeof(node)); p->count = 1; p->word = malloc(strlen(s)+1); strcpy(p->word,s); p->next = bin[h]; bin[h] = p; }
int main(void) { int i; nodeptr p; char buf[100]; for(i=0;i<NHASH;i++) bin[i] = NULL; while(scanf("%s",buf)!=EOF) incword(buf); for(i=0;i<NHASH;i++) for(p=bin[i];p!=NULL;p=p->next) printf("%s\t%d\n",p->word,p->count); return 0; }
最長重複子序列問題的後綴數組解法
最長重複子序列問題除了使用窮舉法,還可以使用後綴數組和後綴樹來求解。這裏給出使用後綴數組解決的最長重複子序列的過程,並以“banana”爲例進行演示。首先寫下一個比較兩個字符串從頭開始共同部分的長度的函數:
int comlen(char *p, char *q) { int i = 0; while(*p&&(*p++ == *q++)) i++; return i; }
設定該程序最多處理MAXN個字符,並存放在數組c中,a是對應的後綴數組:
#define MAXN 5000000 char c[MAXN],*a[MAXN];
讀取輸入時,對a進行初始化:
//n是已讀入的字符數目 int input(int n) { int ch; while((ch = getchar())!=EOF) { a[n] = &c[n]; c[n++] = ch; } c[n] = 0; return n; }
這樣,對於“banana”,對應的後綴數組爲:
a[0]:banana
a[1]:anana
a[2]:nana
a[3]:ana
a[4]:na
a[5]:a
它們是"banana"的所有後綴,這也是“後綴數組”命名原因。
如果某個長字符串在數組c中出現了兩次,那麼它必然出現在兩個不同的後綴中,更準確的說,是兩個不同後綴的同一前綴。通過排序可以尋找相同的前綴,排序後的後綴數組爲:
a[0]:a
a[1]:ana
a[2]:anana
a[3]:banana
a[4]:na
a[5]:nana
掃描排序後的數組的相鄰元素就能得到最長的重複字串,本例爲“ana”。
這裏做一個擴展:(習題16.8)如何尋找至少出現過n次的最長重複子序列?
解法是比較a[i...i+n]中第一個和最後一個的最長公共前綴長度,上文是對n=1的特例。因此寫出一般化的掃描函數:
int scan(int n,int k) { int maxi=0,i; int temp,maxlen = 0; for(i=0;i<n-k;i++) { temp = comlen(a[i],a[i+k]); if(temp>maxlen) { maxlen = temp; maxi = i; } } printf("%d times the longest:%.*s\n",k,maxlen,a[maxi]); return 0; }
以及主程序:
int pstrcmp(char **p, char **q) { return strcmp(*p, *q); } int main(void) { int i,n; n = input(0); qsort(a,n,sizeof(char *),pstrcmp); printf("\n"); for(i=0;i<n;i++) printf("%s\n",a[i]); scan(n,1); scan(n,2); return 0; }
這種後綴數組排序的方法同樣可以解兩個字符串最長公共字符串的問題,如習題15.6和習題15.9。
最大連續子序列
(《編程珠璣》第八章,同樣的問題見於《編程之美》2.14節)輸入是具有n個浮點數的向量x,輸出時輸入向量的任何連續子向量的最大和。爲了避免不能處理最大和小於0的情況,這裏直接把習題8.9的處理方法拿來,將最大和初值設爲一個很小的負數,這裏用NI表示。同時爲了簡單起見,這裏的數組使用int型而不是要求的浮點型表示,maxsum()用於求兩個數的最大值。
最直接的方式是遍歷所有可能的連續子向量,用i和j分別表示向量的首元和最後的尾元,k表示真實的尾元:
int max_array_v1(int *array,int length) { int sum,maxsofar = NI; int i,j,k; for(i=0;i<length;i++) for(j=i;j<length;j++) { sum = 0; for(k=i;k<=j;k++) { sum += array[k]; maxsofar = maxnum(maxsofar,sum); } } return maxsofar; }
第1種方法的代碼具有顯而易見的浪費:對於一個子序列可能重複計算了多次。並且具有O(n3)的時間複雜度。其實k是多餘的,依靠首尾兩個變量i、j足以表示一個子向量。同時,j增長時,可以直接使用上一次的計算和與新增元素相加。因此改寫爲:
int max_array_v2_1(int *array,int length) { int sum,maxsofar = NI; int i,j; for(i=0;i<length;i++) { sum = 0; for(j=i;j<length;j++) { sum += array[j]; maxsofar = maxnum(maxsofar,sum); } } return maxsofar; }
另外一方面,由這個避免重複計算累加和的角度出發,構造一個累加和數組cumarr,cumarr[i]表示array[0...i]各個數的累加和,這樣,array[i...j]的和就可以用cumarr[j]-cumarr[i-1]來表示了。考慮到邊界值,令cumarr[-1]=0,(習題8.5)在C中的做法是令cumarr指向一個數組的第1個元素,cumarr = recumarr +1。有了cumarr[]就可以遍歷所有的i、j來求最大值了:
int max_array_v2_2(int *array,int length) { int sum,maxsofar; int i,j; int *realcumarr,*cumarr; realcumarr = (int *)malloc((length+1)*sizeof(int)); if(realcumarr == NULL) return -1; cumarr = realcumarr + 1; cumarr[-1] = 0; for(i=0;i<length;i++) cumarr[i] = cumarr[i-1] + array[i]; maxsofar = NI; for(i=0;i<length;i++) for(j=i;j<length;j++) { sum = cumarr[j] - cumarr[i-1]; maxsofar = maxnum(maxsofar,sum); } return maxsofar; }
雖然這個累加和數組的解法與後面兩個相比,時間複雜度遠不是最優的,然而這種數據結構很有用,在後面會看到這一點。
分治法的基本思想是,把n個元素的向量分成兩個n/2的子向量,遞歸地解決問題再把答案合併。
分容易,合併就要花點心思了。因爲對於初始大小爲n的向量,它的最大連續子向量可能整體在分成的兩個子向量中之一,也可能跨越了兩個子向量。每次合併都需要計算這個跨越分界點的最大連續子向量,佔據了很大的開銷。
int max_array_v3(int *array,int l,int u) { int i,m; int lmax,rmax,sum; lmax = rmax = NI; if(l>u) return 0; else if(l == u) return maxnum(NI,array[l]); m = (l+u)/2; sum = 0; for(i=m;i>=1;i--) { sum += array[i]; lmax = maxnum(sum,lmax); } sum = 0; for(i=m+1;i<=u;i++) { sum += array[i]; rmax = maxnum(sum,rmax); } return maxnum(lmax+rmax,maxnum(max_array_v3(array,l,m),max_array_v3(array,m+1,u))); }
這種解法使我聯想到了《算法導論》用分治法求解最近點對的問題:通過將區域劃分成兩個子區域,遞歸地求解。然而合併時更加複雜:對於左邊區域和右邊區域獲得的最近距離δ,需要找到是否存在距離小於δ的兩個點,一個在左邊區域,另一個在右邊區域。而且這兩個點都在距離分界線爲δ的區域內。同時可以證明,對於這個區域的每個點,只需考慮後續的7個點即可。具體的思路和證明可以參考《算法導論》第33.4節,這個問題也說明了對於分治法,合併是難點。雖然看上去與最大子序列的形式很像,但是合併操作要複雜得多。同時,最近點對問題在《編程之美》2.11節也出現了,如果沒心思去翻《算法導論》,看看《編程之美》上的說明也可以。
延伸:分治法的最壞情況討論
根據合併過程,如果每次的最長子向量都恰好位於邊界,即下圖中灰色部分,兩者其中之一:
結果導致每次合併時都重複計算了在左邊和右邊的最長子向量,相當的浪費。解決方法是返回值中給出邊界,如果邊界在分界點上,合併時就不需要重複計算了。
從頭到尾掃描數組,掃描至array[i]時,可能的最長子向量有兩種情況:要麼在前i-1個元素中,要麼以i結尾。前者的大小記爲maxsofar,後者記爲maxendinghere。
int max_array_v4(int *array,int length) { int i; int maxsofar = NI; int maxendinghere = 0; for(i=0;i<length;i++) { maxendinghere = maxnum(maxendinghere + array[i],array[i]); //分析:maxendinghere必須包含array[i] //當maxendinghere>0且array[i]>0,maxendinghere更新爲兩者和 //當maxendinghere>0且array[i]<0,maxendinghere更新爲兩者和 //當maxendinghere<0且array[i]<0,maxendinghere更新爲array[i] //當maxendinghere<0且array[i]>0,maxendinghere更新爲array[i] maxsofar = maxnum(maxsofar,maxendinghere); } return maxsofar; }
這個算法可以看做是動態規劃,把長度爲n的數組化成了遞歸的子結構,並從首開始掃描求解。時間複雜度只有O(n)。
Q:maxsofar的初值不設爲一個很大的負數而是0會有什麼結果?
A:如果最大子向量爲負數,將檢測不出來。雖然原書正文中是將初值設爲0並在習題8.9給與提醒,但很容易讓人迷惑。
Q:爲什麼這些算法在處理int數組時工作的很好,但處理float型時結果不一致?
A:這是由於浮點數的近似,當兩個浮點數的差別不大時,是可以認爲它們相等的。(習題8.7)
1.(習題8.10)查找總和最接近0的連續子序列,儘量用最優方法。進一步地,查找總和最接近某一給定實數t的子向量。
分析:
延續對以上幾種方法的思路進行討論。
平方方法遍歷每對可能的i和j組成的array[i...j]是最直接的,但肯定不是最快。
對於分治法,跨越分界點的子序列總要全部遍歷一次,但是形式是類似的,雖然使用O(nlongn)能夠解決,仍並非最佳。
對於掃描算法,如果其他不變,在更新maxendinghere時遇到了困難:在增加array[i]時,如何確定前面的幾個連續元素的去留情況?掃描算法無法簡單地解決這個問題。
這時考慮累加和數組的方法。對於累加和,可以看出如果array[l-1]和array[u]的值越接近,那麼array[l...u]就越近0。爲了在數組中尋找兩個最接近的數,有一個簡單方法是排序,用O(n)時間遍歷每一對相鄰元素。根據這個思路,僅僅需要把累加數組中的結果排序遍歷一次,就能獲得結果了。但是要注意的是,如果想從排序後的數組中獲得原向量的子向量的下標,需要比較大小,較小的是首元,較大的是尾元。
另外,考慮一般情況:查找總和最接近某一給定實數t的子向量,我是按照平移的思想來處理的,比如t=25,那麼50到t的距離是50-t=25,-50到t的距離是-50-25=-75,把原向量做了一個變化。
#include <stdio.h> #include <stdlib.h> #define UT 32768 typedef struct ap ap; struct ap { //array plus int key; int pos; }; int comp(const void *ap1,const void *ap2){ if(((ap*)ap1)->key > ((ap*)ap2)->key) return 1; else if(((ap*)ap1)->key < ((ap*)ap2)->key) return -1; else return 0; } int rabs(int x,int n) { if(x>n) return x-n; else return n-x; } int most_close_array(int *array,int length,int n){ int i,j,k; int offset = UT; int temp; ap *realcumarr,*cumarr; realcumarr = (ap *)malloc((length+1)*(sizeof(ap))); cumarr = realcumarr + 1; (cumarr-1)->key = 0; (cumarr-1)->pos = -1; for(i=0;i<length;i++) { (cumarr+i)->key = (cumarr+i-1)->key + array[i] - n; //n for shifting to array[i] (cumarr+i)->pos = i; } qsort(cumarr,length,sizeof(ap), comp); //cumarr is in increasement order for(i=0;i<length;i++) printf("i:%d key:%d pos:%d\n",i,(cumarr+i)->key,(cumarr+i)->pos); i=j=0; for(k=0;k<length-2;k++) { //complex fix if((cumarr+k+1)->pos > (cumarr+k)->pos) { //u = (cumarr+k+1)->pos //l-1 = (cumarr+k)->pos temp = rabs((cumarr+k+1)->key - (cumarr+k)->key,n); if(temp<offset) { offset = temp; i = (cumarr+k)->pos +1; j = (cumarr+k+1)->pos; } } else { temp = rabs((cumarr+k)->key - (cumarr+k+1)->key,n); if(temp<offset) { offset = temp; i = (cumarr+k+1)->pos +1; j = (cumarr+k)->pos; } } } //[l,u] = c[u] - c[l-1] (not c[l]) printf("[%d,%d]\n",i,j); return 0; } int main(){ int n; int a[] = {31,-41,59,26,-53,58,97,-93,-23,84}; printf("please input a base:\n"); scanf("%d",&n); most_close_array(a,sizeof(a)/sizeof(int),n); return 0; }
2.(習題8.12)對數組array[0...n-1]初始化爲全0後,執行n次運算:for i = [l,u] {x[i] += v;},其中l,u,v是每次運算的參數,0<=l<=u<=n-1。直接用這個僞碼需要O(n2)的時間,請給出更快的算法。
分析:
每次運算時取一個子向量進行所有元素相同的操作,那麼可以把這個子向量的開始和結尾作爲操作的定界,把所有操作疊加起來一次性地完成。
即,用下面的代碼來代替for i = [l,u] {x[i] += v;},只使用了O(n)就能完成:
for(...) {//每次迭代 cum[u]+=v; cum[l-1]-=v; } for(i=n-1;i>=0;i--) x[i] = x[i+1]+cum[i];
3.(習題8.14)給定m、n和數組array[n],請找出使總和array[i...i+m]最接近0的整數i。(0<=i<n-m)
分析:
經過上面多次使用累加和數組,這個應該非常簡單了。只需要計算出array[0...m],對一個i,更新sumnew = sumold- array[i-1] +array[i+m]足矣。
4.(習題8.13,同樣的問題見於《編程之美》2.15)求m*n實數數組的矩形子數組最大和。(即求矩陣最大元素和的子矩陣)
分析:
初看這個問題簡直無從下手(當然,除了暴力解法),這裏的提示是,它和之前的一維數組中的類似問題有什麼關係?
其實這纔是一維數組求最大連續子數組問題的最初形式,當時爲了簡化分析,Ulf Grenander把二維形式轉化爲一維,以深入瞭解其結構。當然這句話看上去並不是那麼合理:二維數組壓成了一維,少了一維的信息量,怎麼反而“深入”了呢?如果這麼理解,壓縮後只是在一個方向上變化,相當是對問題的抽象,纔好理解。
有了這個啓發,嘗試把二維數組壓縮了先。可以看出,每個子矩陣都對應一個一維數組,下圖的灰色表示把這些元素壓縮成了一個,灰色的這一行就是當前處理的子矩陣:
同時這個壓縮有個好處:當處理的子矩陣在m維度上移動時,可以把灰色部分的元素和直接與新增的元素求和,就得到了新的子矩陣的壓縮後的灰色部分。處理灰色的等價一維數組用前面的方式完成,這也是爲什麼答案提示“在m的維度上使用算法2,在n的維度上使用算法4”。
int MaxSubMatrix(int m[4][4],int row,int col){ int i,j,k; int maxsofar=NI,maxendinghere; int *vec; vec = (int*)malloc(sizeof(int)*col); for(i=0;i<row;i++) { bzero(vec,sizeof(int)*col); for(j=i;j<row;j++) { maxendinghere = 0; for(k=0;k<col;k++) {//合併了 vec[k] += m[j][k]; maxendinghere = maxnum(maxendinghere + vec[k],vec[k]); maxsofar = maxnum(maxsofar,maxendinghere); } } } return maxsofar; }
答案上提到還有更快的算法,不過提升有限,運行時間爲O(n3[(loglogn)/(logn)]1/2),想必很複雜,這裏也沒有進一步研究的必要了。
到本文爲止,“珠璣之櫝”系列就結束了,我已經對《編程珠璣》上的算法和相關的引申問題做了一個分析和介紹,希望讀者能有所收穫,也希望能作爲便於速查的資料。後續和算法相關的博文不再使用這個前綴。
往期回顧: