所有人都聽過這樣一個歌謠:從前有座山,山裡有座廟,廟裡有個和尚在講故事:從前有座山。。。。,雖然這個歌謠並沒有一個遞迴邊界條件跳出迴圈,但無疑地,這是遞迴演算法最樸素的落地實現,本次我們使用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程式碼可讀性更高。
結語
遞迴併非是刻板印象中的效能差又難懂的演算法,正相反,它反而可以讓程式碼更加簡潔易懂,在程式中使用遞迴,可以更通俗、更直觀的描述邏輯。