淺談遞歸

遞歸無疑是一種威力強大的解決問題的方法,這從那個著名的“漢諾塔”問題就可以看出來。看上去無從下手的問題,需要我們從問題的整體來考慮,而不是把注意力放在“部分”的具體實現上。在解決漢諾塔問題時,我們只是找出了遞歸的策略,而把具體的操作讓計算機去完成。然後,我們驚訝地發現,原來這個問題可以用一種如此簡單美妙的方法來解決。

說到遞歸,讓我們再來看另外一種常用來解決重複問題的辦法——迭代。迭代無疑也是一種強大的方法。利用迭代,我們可以不斷求精,求得一個超越方程足夠精度的解,也可以讓結果一步步趨於最優。於是在編程語言中,我們用if,while,for來進行條件控制和不斷循環。

遞歸與之不同,在遞歸的世界,循環似乎是不需要的。我們把一個大的問題分解成小問題,而小問題的形式和大問題在本質上沒有什麼不同。求5!和求4!在問題本質上沒什麼不同,而通過關係式n!=(n-1)!*n,我們把問題簡化了,直到問題的最簡形式:1!=1。“相同形式”是遞歸方法的重點,小問題和大問題必須要有相同的形式。

或者,我們可以用下面的模板來說明遞歸:

Recursion(n)
{
	if(問題足夠簡單)
	{
		直接解決這個簡單的問題
	}
	else
	{
		把現在的問題分解爲更簡單的問題,他和原問題有相同的形式
		用遞歸的方法解決現在的子問題
		把子問題的解組合起來得到原來問題的解
	}
}

在這其中,關鍵是我們需要確定一個遞歸的分解方式,比如上面的n!=(n-1)!*n。而這,通常並不很容易想到。

讓我們先從數學角度去看,因爲遞歸在數學問題中很常見,而且數學問題可以很容易的寫出表達式。有的時候,這個表達式天然就是遞歸的形式,有的時候,就需要我們化簡變形。

意大利人斐波那契養了一羣兔子,這羣兔子給我們留下了斐波那契問題。這個問題如此著名,所以不在這裏贅述。下面的公式曾經在小學找規律填數字的問題裏面讓腦子不開竅的我想了半天:

還好,我們現在可以用遞歸的方法來解決它:

int Fabonacci(int n)   //求斐波那契數列的第n項
{
	if (n<=2) //最簡單的形式,直接可以給出結果
	{
		return 1;
	} 
	else      //數學的遞推公式已經給了我們分解問題的方法
	{
		return Fabonacci(n-1)+Fabonacci(n-2);
	}
}

而這種問題應該是最簡單的遞歸問題,我們從數學式子裏面直接得出了分解方法,從而確定瞭如何編程。這時候,初始條件的確定就顯得很重要,因爲如果初始條件給的不充分或是不對,將可能導致遞歸無法收斂到最簡問題而導致程序崩潰。

我們來看一個組合數的問題:楊輝三角可以說是古代中國人的巨大成就之一。從楊輝三角里面我們可以得出下面的組合數規律:


分解方法既然已經有了,讓我們來找一下最簡形式應該寫成什麼樣,其實,我們只要先拿一個按照上面的分解方法算一下就可以看出來大概:


(1)k=0,C(n,k)=1;

(2)k=n,C(n,k)=1;

(3)k>n,C(n,k)=0;

(4)k=1,C(n,k)=n

下面就是根據上面的結論寫出的計算函數:

int CombineNum(int n,int k)
{
	if (k==0)
	{
		return 1;
	}
	else if(k==1)
	{
		return n;
	}
	else if (n==k)
	{
		return 1;
	}
	else if (n<k)
	{
		return 0;
	}
	else
	{
		return CombineNum(n-1,k-1)+CombineNum(n-1,k);
	}
}


但是,當面對的是非數值問題的時候,麻煩似乎就來了,我如何知道怎麼分解?看了別人的思路,往往會在恍然大悟的同時伴隨着一絲懷疑:這個方法真的有效?短短几行代碼就可以解決這個看似棘手的問題?計算機在遞歸程序執行時究竟幹了些什麼?

我們要有對遞歸跳躍的信任。無論是在寫一個遞歸函數還是在試圖理解一個遞歸函數時候,都必須達到忽視單個遞歸調用細節的地步,只要選擇了正確的分解,確認了相應的簡單情景,並且正確實現了子問題的組合,那遞歸調用就能夠自己運行,我們不必過多考慮它的細節,這是繁瑣的,也是沒有必要的。當然,用這樣方法寫出的程序也很有可能是錯的,但這個"錯誤是在遞歸的實現裏面,而不是遞歸機制的本身。如果程序出現了問題,我們應該在一個簡單的遞歸層次上去尋找Bug,分析遞歸的其它層次不會有什麼用。如果簡單情景起作用並且遞歸分解師正確的,那麼子調用就會自己正常的工作。如果沒有,那就要檢查遞歸函數本身了。"——《C程序設計的抽象思維》

下面我們用這個方法解決字符串反向的問題:

char * Reverse(char *str)把字符串str反向。我們可以用下面的代碼來測試:

int main()
{
	//測試用例
	char str[20];
	while (scanf("%s",str)!=EOF)
	{
		printf("%s\n",Reverse(str));
	}
	return 0;
}

我們把原來的問題進行分解:

(1)要把str反向,可以先把str[1]~str[n-2]反向,也就是說可以先把str的開頭和結尾字符去掉,把剩下的部分反向

(2)然後我們要把str[0]和str[n-1]交換位置,這樣就完成了str的反向操作。

(3)我們 考慮遞歸在何處收斂,也就是那個最簡情景:

          顯然,當str的長度是1或者2的時候,反向是很簡單的,只需要不動或者把str[0]和str[1]交換就行了。

然而,我們會發現我們的策略將會依賴於str的長度。而Reverse函數的參數列表裏並沒有str的長度,每次函數調用都用strlen函數求一次字符串長是一種方法,但是在頻繁調用庫函數的時候未免會造成時間的浪費。這種情況下,我們可以把Reverse函數作爲所謂的“包裝函數”,也是就說,Reverse並不直接執行遞歸操作,而是在裏面再調用一個遞歸函數,在這個函數裏,字符串長作爲參數。不過,我還是願意採用下面的方法,把字符串的起始和結束都作爲參數,這樣沒有別的目的,只是個人覺得較爲清楚:

char* Reverse(char str[])
{
	DiguiReverse(str,0,strlen(str)-1);
	return str;
}
下面我們看DiguiReverse函數的實現。根據上面的分解,我們已經能夠寫出DiguiReveerse的大概過程:

void DiguiReverse(char str[],int start,int end)
{
	if(start>=end)    //字符串長度爲1
		//什麼都不做
	else if(end-start==1)    //字符串長度爲2
		//Exchange(str[start],str[end])
	else
	{
		DiguiReverse(str,start+1,end-1);
		//Exchange(str[start],str[end])
	}
}

根據上面的僞碼,可以很容易寫出C代碼:

/*
交換兩個字符的位置
*/
void ChangeTwoChar(char *p1,char *p2)
{
	char tem;
	tem=*p1;
	*p1=*p2;
	*p2=tem;
}
/*
遞歸解決字符串反轉問題
*/
void DiguiReverse(char str[],int start,int end)
{
	if (start>=end)
	{
		return;
	} 
	else if(end-start==1)
	{
		ChangeTwoChar(&str[start],&str[end]);
	}
	else
	{
		DiguiReverse(str,start+1,end-1);
		ChangeTwoChar(&str[start],&str[end]);
	}
}


完整代碼:

#include <stdio.h>
#include <string.h>
/*
交換兩個字符的位置
*/
void ChangeTwoChar(char *p1,char *p2)
{
	char tem;
	tem=*p1;
	*p1=*p2;
	*p2=tem;
}
/*
遞歸解決字符串反轉問題
*/
void DiguiReverse(char str[],int start,int end)
{
	if (start>=end)
	{
		return;
	} 
	else if(end-start==1)
	{
		ChangeTwoChar(&str[start],&str[end]);
	}
	else
	{
		DiguiReverse(str,start+1,end-1);
		ChangeTwoChar(&str[start],&str[end]);
	}
}
/*
包裝函數
*/
char* Reverse(char str[])
{
	DiguiReverse(str,0,strlen(str)-1);
	return str;
}
int main()
{
	//測試用例
	char str[20];
	while (scanf("%s",str)!=EOF)
	{
		printf("%s\n",Reverse(str));
	}
	return 0;
}



如此這樣,就解決了字符串的反轉問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章