週而復始,往復迴圈,遞迴、尾遞迴演算法與無限極層級結構的探究和使用(Golang1.18)

劉悅的技術部落格發表於2022-12-26

所有人都聽過這樣一個歌謠:從前有座山,山裡有座廟,廟裡有個和尚在講故事:從前有座山。。。。,雖然這個歌謠並沒有一個遞迴邊界條件跳出迴圈,但無疑地,這是遞迴演算法最樸素的落地實現,本次我們使用Golang1.18回溯遞迴與迭代演算法的落地場景應用。

遞迴思想與實現

遞迴思想並非是鮮為人知的高階概念,只不過是一種相對普遍的逆向思維方式,這一點我們在:人理解迭代,神則體會遞迴,從電影藝術到Python程式碼實現神的逆向思維模式中已經探討過,說白了就是一個函式直接或者間接的呼叫自己,就是遞迴,本文開篇和尚講故事的例子中,和尚不停地把他自己和他所在的廟和山呼叫在自己的故事中,因此形成了一個往復迴圈的遞迴故事,但這個故事有個致命問題,那就是停不下來,只能不停地講下去,所以一個正常的遞迴必須得有一個遞迴邊界條件,用來跳出無限遞迴的迴圈:



package main  
  
import (  
	"fmt"  
)  
  
func story(n int) int {  
	if n <= 0 {  
		return 0  
	}  
	return story(n - 1)  
  
}  
  
func main() {  
  
	res := story(5)  
  
	fmt.Println(res)  
  
}  



這裡我們宣告瞭一個故事函式,引數為n,即講n遍同樣的故事,並且呼叫自己,每講一次n減1,即減少一次講故事總數,但如果我們不設定一個遞迴邊界條件,那麼函式就會無限遞迴下去,所以如果n小於等於0了,那麼我們就結束這個故事:

➜  mydemo git:(master) ✗ go run "/Users/liuyue/wodfan/work/mydemo/tests.go"  
0

所以 if n <= 0 就是遞迴邊界條件。

那麼遞迴的底層是如何實現的呢?假設我們要針對n次故事做一個高斯求和:

package main  
  
import (  
	"fmt"  
)  
  
func story(n int) int {  
	if n <= 0 {  
		return 0  
	}  
	return n + story(n-1)  
  
}  
  
func main() {  
  
	res := story(5)  
  
	fmt.Println(res)  
  
}

程式輸出:

➜  mydemo git:(master) ✗ go run "/Users/liuyue/wodfan/work/mydemo/tests.go"  
15

那麼這一次遞迴高斯求和函式的底層實現應該是這樣:

5+story(4)  
5+(4+ story(3))  
5+(4+(3+ story(2)))  
5+(4+(3+(2+ story(1))))  
5+(4+(3+(2+1)))  
15

當story函式每次被呼叫時,都會在記憶體中建立一個幀,來包含函式的區域性變數和引數,對於遞迴函式,棧上可能同時存在多個函式幀。當每呼叫一次函式story(n)時,棧頂指標就會往棧頂移動一個位置,直到滿足退出遞迴的條件(n<=0)之後再依次返回當前的結果直接,棧頂指標被壓入棧底方向。

也就是說,記憶體棧會儲存每一次遞迴的區域性變數和引數,這也就是遞迴演算法的效能被人們所詬病的原因,即不是自己呼叫自己而效能差,而是自己呼叫自己時,系統需要儲存每次呼叫的值而效能差。

尾遞迴最佳化

尾遞迴相對傳統的普通遞迴,其是一種特例。在尾遞迴中,先執行某部分的計算,然後開始呼叫遞迴,所以你可以得到當前的計算結果,而這個結果也將作為引數傳入下一次遞迴。這也就是說函式呼叫出現在呼叫者函式的尾部,因為是尾部,所以其有一個優越於傳統遞迴之處在於無需去儲存任何區域性變數,從記憶體消耗上,實現節約特性:



package main  
  
import (  
	"fmt"  
)

func tail_story(n int, save int) int {  
  
	if n <= 0 {  
		return save  
	}  
	return tail_story(n-1, save+n)  
  
}  
  
func main() {  
  
	save := 0  
  
	res := tail_story(5, save)  
  
	fmt.Println(res)  
  
}


程式返回:

➜  mydemo git:(master) ✗ go run "/Users/liuyue/wodfan/work/mydemo/tests.go"  
15

可以看到,求和結果和普通遞迴是一樣的,但過程可不一樣:

tail_story(5,0)  
tail_story(4,5)  
tail_story(3,9)  
tail_story(2,12)  
tail_story(1,14)  
tail_story(0,15)

因為尾遞迴透過引數將計算結果進行傳遞,遞迴過程中系統並不儲存所有的計算結果,而是利用引數覆蓋舊的結果,如此,就不會到處棧溢位等效能問題了。

遞回應用場景

在實際工作中,我們當然不會使用遞迴講故事或者只是為了計算高斯求和,大部分時間,遞迴演算法會出現在迭代未知高度的層級結構中,即所謂的“無限極”分類問題:

package main  
  
import (  
	"fmt"  
)  
  
type cate struct {  
	id   int  
	name string  
	pid  int  
}  
  
func main() {  
	allCate := []cate{  
		cate{1, "計算機課程", 0},  
		cate{2, "美術課程", 0},  
		cate{3, "舞蹈課程", 0},  
		cate{4, "Golang", 1},  
		cate{5, "國畫", 2},  
		cate{6, "芭蕾舞", 3},  
		cate{7, "Iris課程", 4},  
		cate{8, "工筆", 5},  
		cate{9, "形體", 6},  
	}  
  
	fmt.Println(allCate)  
  
}

程式輸出:

[{1 計算機課程 0} {2 美術課程 0} {3 舞蹈課程 0} {4 Golang 1} {5 國畫 2} {6 芭蕾舞 3} {7 Iris課程 4} {8 工筆 5} {9 形體 6}]

可以看到,結構體cate中使用pid來記錄父分類,但展示的時候是平級結構,並非層級結構。

這裡使用遞迴演算法進行層級結構轉換:

type Tree struct {  
	id   int  
	name string  
	pid  int  
	son  []Tree  
}

新增加一個Tree的結構體,新增一個子集的巢狀屬性。

隨後建立遞迴層級結構函式:

func CategoryTree(allCate []cate, pid int) []Tree {  
	var arr []Tree  
	for _, v := range allCate {  
		if pid == v.pid {  
			ctree := Tree{}  
			ctree.id = v.id  
			ctree.pid = v.pid  
			ctree.name = v.name  
  
			sonCate := CategoryTree(allCate, v.id)  
  
			ctree.son = sonCate  
  
			arr = append(arr, ctree)  
		}  
	}  
	return arr  
}

隨後呼叫輸出:

package main  
  
import (  
	"fmt"  
)  
  
type cate struct {  
	id   int  
	name string  
	pid  int  
}  
  
type Tree struct {  
	id   int  
	name string  
	pid  int  
	son  []Tree  
}  
  
func CategoryTree(allCate []cate, pid int) []Tree {  
	var arr []Tree  
	for _, v := range allCate {  
		if pid == v.pid {  
			ctree := Tree{}  
			ctree.id = v.id  
			ctree.pid = v.pid  
			ctree.name = v.name  
  
			sonCate := CategoryTree(allCate, v.id)  
  
			ctree.son = sonCate  
  
			arr = append(arr, ctree)  
		}  
	}  
	return arr  
}  
  
func main() {  
	allCate := []cate{  
		cate{1, "計算機課程", 0},  
		cate{2, "美術課程", 0},  
		cate{3, "舞蹈課程", 0},  
		cate{4, "Golang", 1},  
		cate{5, "國畫", 2},  
		cate{6, "芭蕾舞", 3},  
		cate{7, "Iris課程", 4},  
		cate{8, "工筆", 5},  
		cate{9, "形體", 6},  
	}  
  
	arr := CategoryTree(allCate, 0)  
	fmt.Println(arr)  
  
}

程式返回:

[{1 計算機課程 0 [{4 Golang 1 [{7 Iris課程 4 []}]}]} {2 美術課程 0 [{5 國畫 2 [{8 工筆 5 []}]}]} {3 舞蹈課程 0 [{6 芭蕾舞 3 [{9 形體 6 []}]}]}]

這裡和Python版本的無限極分類:使用Python3.7+Django2.0.4配合vue.js2.0的元件遞迴來實現無限級分類(遞迴層級結構)有異曲同工之處,但很顯然,使用結構體的Golang程式碼可讀性更高。

結語

遞迴併非是刻板印象中的效能差又難懂的演算法,正相反,它反而可以讓程式碼更加簡潔易懂,在程式中使用遞迴,可以更通俗、更直觀的描述邏輯。

相關文章