遞歸

什麼是遞歸?

  遞歸,就是函數在運行的過程中調用自己。

代碼示例

1
2
3
4
5
6
def recursion(n):
 
    print(n)
    recursion(n+1)
 
recursion(1)  

 出現的效果就是,這個函數在不斷的調用自己,每次調用就n+1,相當於循環了。

 

可是爲何執行了900多次就出錯了呢?還說超過了最大遞歸深度限制,爲什麼要限制呢?

通俗來講,是因爲每個函數在調用自己的時候 還沒有退出,佔內存,多了肯定會導致內存崩潰。

本質上講呢,在計算機中,函數調用是通過棧(stack)這種數據結構實現的,每當進入一個函數調用,棧就會加一層棧幀,每當函數返回,棧就會減一層棧幀。由於棧的大小不是無限的,所以,遞歸調用的次數過多,會導致棧溢出。

 

函數調用的棧結構:

 

 

 

遞歸的特點

讓我們通過現象來看本質, 下面是是用遞歸寫的,讓10不斷除以2,直到0爲止。

爲何結果先打印了10、5、2、1,然後又打印了1、2、5、10呢?打印10、5、2、1你可以理解,因爲函數在一層層的調用自己嘛,但1、2、5、10是什麼邏輯呢? 因爲當前函數在執行過程中又調用了自己一次,當前這次函數還沒結束,程序就又進了入第2層的函數調用,第2層沒結束就又進入了第3層,只到n/2 > 0不成立時才停下來, 此時問你,程序現在直接結束麼?no,no,no, 現在遞歸已經走到了最裏層,最裏層的函數不需要繼續遞歸了,會執行下面2句

打印的是1, 然後最裏層的函數就結束了,結束後會返回到之前調用它的位置。即上一層,上一層打印的是2,再就是5,再就是10,即最外層函數,然後結束,總結,這個遞歸就是一層層進去,還要一層層出來。

 

通過上面的例子,我們可以總結遞歸幾個特點:

  1. 必須有一個明確的結束條件,要不就會變成死循環了,最終撐爆系統
  2. 每次進入更深一層遞歸時,問題規模相比上次遞歸都應有所減少
  3. 遞歸執行效率不高,遞歸層次過多會導致棧溢出

 

遞歸有什麼用呢?

可以用於解決很多算法問題,把複雜的問題分成一個個小問題,一一解決。

比如求斐波那契數列、漢諾塔、多級評論樹、二分查找、求階乘等。用遞歸求斐波那契數列、漢諾塔 對初學者來講可能理解起來不太容易,所以我們用階乘和二分查找來給大家演示一下。

 

求階乘

任何大於1的自然數n階乘表示方法: 
n!=1×2×3×……×n 
或 
n!=n×(n-1)!

 

即舉例:4! = 4x3x2x1 = 24 

用遞歸代碼來實現

1
2
3
4
5
6
7
8
9
def factorial(n):
 
    if == 0#是0的時候,就運算完了
        return 1
    return * factorial(n-1# 每次遞歸相乘,n值都較之前小1
 
 
= factorial(4)
print(d)

  

2分查找 

我們首先引入這樣一個問題:如果規定某一科目成績分數範圍:[0,100],現在小明知道自己的成績,他讓你猜他的成績,如果猜的高了或者低了都會告訴你,用最少的次數猜出他的成績,你會如何設定方案?(排除運氣成分和你對小明平時成績的瞭解程度)

①最笨的方法當然就是從0開始猜,一直猜到100分,考慮這樣來猜的最少次數:1(運氣嘎嘎好),100(運氣嘎嘎背);

②其實在我們根本不知道對方水平的條件下,我們每一次的猜測都想盡量將不需要猜的部分去除掉,而又對小明不瞭解,不知道其水平到底如何,那麼我們考慮將分數均分,

將分數區間一分爲2,我們第一次猜的分數將會是50,當回答是低了的時候,我們將其分數區域從【0,100】確定到【51,100】;當回答高了的時候,我們將分數區域確定到【0,49】。這樣一下子就減少了多餘的50次猜想(從0數到49)(或者是從51到100)。

③那麼我們假設當猜完50分之後答案是低了,那麼我們需要在【51,100】分的區間內繼續猜小明的分數,同理,我們繼續折半,第二次我們將猜75分,當回答是低了的時候,我們將其分數區域從【51,100】確定到【76,100】;當回答高了的時候,我們將分數區域確定到【51,74】。這樣一下子就減少了多餘的猜想(從51數到74)(或者是從76到100)。

④就此繼續下去,直到回覆是正確爲止,這樣考慮顯然是最優的

 

轉換成代碼

在一個已排序的數組data_set中,使用二分查找n,假如這個數組的範圍是[low...high],我們要的n就在這個範圍裏。查找的方法是拿low到high的正中間的值,我們假設是mid,來跟n相比,如果mid>n,說明我們要查找的n在前數組data_set的前半部,否則就在後半部。無論是在前半部還是後半部,將那部分再次折半查找,重複這個過程,知道查找到n值所在的地方。

代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#data_set = [1,3,4,6,7,8,9,10,11,13,14,16,18,19,21]
data_set = list(range(101))
 
 
def b_search(n,low,high,d):
 
    mid = int((low+high)/2# 找到列表中間的值
    if low == high:
        print("not find")
        return
    if d[mid] > n: # 列表中間值>n, 代數要找的數據在左邊
        print("go left:",low,high,d[mid])
        b_search(n,low,mid,d) # 去左邊找
    elif d[mid] < n: # 代數要找的數據在左邊
        print("go right:",low,high,d[mid])
        b_search(n,mid+1,high,d) # 去右邊找
    else:
        print("find it ", d[mid])
 
 
b_search(1880,len(data_set),data_set)

 

那需要找多少次呢?

1
2
3
4
5
6
7
go right: 0 101 50
go right: 51 101 76
go right: 77 101 89
go right: 90 101 95
go right: 96 101 98
go right: 99 101 100
not find

最多將會操作7次,其實因爲每一次我們都拋掉當前確定的區間的一半的區間作爲不可能解部分,那麼相當於求最多操作次數,就是在區間內,最多將有多少個一半可以拋去、那麼就是將100一直除以2,直到不能除爲止。

那麼這個運算過程,其實就是相當於求了一個log2(100)≈7。

 

 

補充:

在講特性時,我們說遞歸效率不高,因爲每遞歸一次,就多了一層棧,遞歸次數太多還會導致棧溢出,這也是爲什麼python會默認限制遞歸次數的原因。但有一種方式是可以實現遞歸過程中不產生多層棧的,即尾遞歸,

尾遞歸

如果一個函數中所有遞歸形式的調用都出現在函數的末尾,我們稱這個遞歸函數是尾遞歸的。當遞歸調用是整個函數體中最後執行的語句且它的返回值不屬於表達式的一部分時,這個遞歸調用就是尾遞歸。尾遞歸函數的特點是在迴歸過程中不用做任何操作,這個特性很重要,因爲大多數現代的編譯器會利用這種特點自動生成優化的代碼。

當編譯器檢測到一個函數調用是尾遞歸的時候,它就覆蓋當前的活動記錄而不是在棧中去創建一個新的。編譯器可以做到這點,因爲遞歸調用是當前活躍期內最後一條待執行的語句,於是當這個調用返回時棧幀中並沒有其他事情可做,因此也就沒有保存棧幀的必要了。通過覆蓋當前的棧幀而不是在其之上重新添加一個,這樣所使用的棧空間就大大縮減了,這使得實際的運行效率會變得更高。

尾遞歸例子

1
2
3
4
def calc(n):
    print(n - 1)
    if n > -50:
        return calc(n-1)

  

我們之前求的階乘是尾遞歸麼?

1
2
3
4
5
6
7
8
9
def factorial(n):
 
    if == 0#是0的時候,就運算完了
        return 1
    return * factorial(n-1# 每次遞歸相乘,n值都較之前小1
 
 
= factorial(4)
print(d)

上面的這種遞歸計算最終的return操作是乘法操作。所以不是尾遞歸。因爲每個活躍期的返回值都依賴於用n乘以下一個活躍期的返回值,因此每次調用產生的棧幀將不得不保存在棧上直到下一個子調用的返回值確定。

  

 

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