算法(1):遞歸

??腦抽的我上個文章爛尾,又開新坑,,,,我只能對自己前面開的爛坑說一句:青山不改,綠水長流,我們有緣再見!
??這次我想刷一刷算法題(對,我又叒叕換目標了),把常見的基礎算法做一個總結(千萬別又是起個頭就扔那里不管了,真的是廢人一個了。。。)
??好,話不多說,遞歸(Recursion)走起!



概念理解:

??首先我們分析一下定義,遞歸是一種使用某種函數來解決問題的方法(當然你可以忽略這句廢話),特殊之處便在于該函數會不斷調用它自身來作為其子程序。

??那么,如何實現這么一個函數呢?它調用自己,又是干了些什么呢?
??這個小技巧便是該遞歸函數每一次調用的時候,它都能將給定的問題變成該問題的子問題,該函數會不知疲倦的一直調用它自己(覺得像影分身之術,但是確切點說的話,是那種分身又施展影分身術的 feel...),直到子問題被解決掉,不再產生遞歸為止(所以說,不是子子孫孫無窮匱哦,遞歸沒有我們的愚公爺爺厲害)。

??所以,遞歸函數是一定存在邊界的,也就是終止條件。在遇到某種情況時,遞歸函數將不再調用自身,而是輸出一個結果(當然也可以什么也不輸出,直接結束子程序)。所以寫遞歸函數的時候,一定不要忘了寫終止遞歸的條件(個人建議,先寫這些邊界條件,后面再寫程序處理邏輯)~


??扯了這么多,腦中揮散不去的還是大家對我爆喊 “talk is cheap, show me the code!"的場景,那么,代碼兄,該你登場了(以下題目均來自LeetCode)~
??附注:建議大家看看問題3,因為提到了一個遞歸經常遇到的問題,重復計算,而解決重復計算的方法也很簡單,加入一個記憶機制(Memoization),也就是儲存我們計算過的值即可。


問題1:字符串翻轉,要求,O(1)空間復雜度(看所給的例子輸入,題目應該叫列表翻轉才更貼切)
例子:
輸入: ['a','b','c']
輸出: ['c','b','a']
解決思路:
1.取出首尾兩個字符,并做交換;
2.遞歸調用該函數,來翻轉剩下的子串。
3.設計跳出遞歸的邊界條件,這里是begin >= end,即字符串遍歷完畢。

def reverseString(begin, end,s) -> None:
    """
    Do not return anything, modify s in-place instead.
    """
    if begin >= end:
        return
    s[begin], s[end] = s[end], s[begin]
    reverseString(begin + 1, end - 1, s)

s = ['a','b','c']
reverseString(0, len(s) - 1, s)
print(s)

### output ###
# ['c', 'b', 'a']

問題2:給定一個鏈表,每兩個相鄰的節點交換位置,并返回頭節點。
例子:
輸入鏈表:1->2->3->4
輸出鏈表:2->1->4->3
解決思路:
1.交換前兩個節點的位置;
2.把剩下的鏈表傳遞給自身,遞歸調用。
3.當抵達鏈表尾部時結束遞歸。

class ListNode:   #定義節點類
    def __init__(self, x):
        self.val = x
        self.next = None

def swapPairs(head) -> ListNode:   #實現功能的遞歸函數
    if head == None or head.next == None:
        return head
    temp = head.next   #  節點2,也就是temp,即為我們所要的head
    head.next = swapPairs(temp.next)   #將節點1 和后面的鏈表串拼接,也就是(4->3)
    temp.next = head   # 將節點2的后面接上節點1
    return temp   #返回節點2

def printListNode(node):   #輔助函數,打印鏈表
    while node:
        print(node.val, end=' ')
        if node.next:
            print('->', end=' ')
        node = node.next
    print()

if __name__ == '__main__':
    head = node = ListNode(1)
    for i in range(2,5):
        node.next = ListNode(i)
        node = node.next

    printListNode(head)
    ans = swapPairs(head)
    printListNode(ans)

        

問題3楊輝三角形(聽說你不知道什么是楊輝三角形,鏈接附上,送給各位看官)

楊輝三角形

??在討論這個問題之前,為了讓大家看的更清晰,我們先提綱挈領一波,在此討論一下兩個概念,遞推關系和邊界條件。
??遞推關系說白了就是問題結果和子問題的結果之間的關系,而邊界條件我們前面提到過,便是遞歸到了終點,問題無法繼續分解為子問題,可以直接得到答案。
??那么通過這個例子,大家一起來看看這兩個概念的具現到底是何方神圣把~
??首先,我們定義一個函數,f(i,j), i 代表第 i 行, j 代表第 j 列,那么,我們可以列出遞歸關系式:
f(i,j) = f(i-1,j-1) + f(i-1,j)
其次,再列出邊界條件:
f(i,j) = 1, \space where \space j =1 \space or \space j = i
??這樣子,思路是不是就清晰明了一些了呢?當以后遇到復雜的遞歸問題的時候,可以先嘗試列出遞歸關系式和邊界條件,可以方便我們更清晰的整理思路呦~

當然,問題以及代碼如下:
輸入: 5
輸出:
[
[1],
[1,1],
[1,2,1],
[1,3,3,1],
[1,4,6,4,1]
]

def generate(numRows) -> list:
    def helper(i, j):
        if j == 0 or j == i:
            return 1
        return helper(i - 1, j - 1) + helper(i - 1, j)

    ans = []
    for i in range(0, numRows):
        temp = []
        for j in range(0, i + 1):
            temp.append(helper(i, j))
        ans.append(temp)

    print(ans)
    return ans

if __name__ ==  '__main__':
    generate(5)

??上面我們的helper函數,完美的按照邊界條件和遞歸關系式要求所寫,結果也是沒有任何問題。但是,但是,但是!各位小伙伴試試把generate(5) 換成generate(30)試試(測測你電腦性能 /斜眼笑)?花費的時間多的讓你懷疑人生,哈哈~ 那么問題來了,到底是什么原因導致的呢?
??我們思考一下,當計算f(5,3)的時候,我們需要計算f(4,2)f(4,3),而這兩個都需要計算一次f(3,2),聰明的朋友,你是不是發現了問題所在?對,那就是重復計算(這個地方一定要加黑加粗,因為遞歸非常容易遇到這個問題,大家一定要留意才行)!當你需要顯示的行數愈多時,重復計算的量就會越大。
??所以,上面的程序也就逗小孩子玩玩,實用性幾乎為零(為什么用幾乎?因為它還能逗小孩子玩玩。所以這里充分體現了作者嚴謹的撰文態度)。那么,這種問題該如何解決呢?正解,加入記憶機制,將我們之前計算過的全部儲存下來即可~
??關門,上代碼!

def generate(numRows) -> list:
    cache = {}
    def helper(i, j):
        if (i,j) in cache:
            return cache[(i,j)]

        if j == 0 or j == i:
            return 1

        result =  helper(i - 1, j - 1) + helper(i - 1, j)
        cache[(i,j)] = result
        return result

    ans = []
    for i in range(0, numRows):
        temp = []
        for j in range(0, i + 1):
            temp.append(helper(i, j))
        ans.append(temp)

    print(ans)
    return ans

if __name__ ==  '__main__':
    generate(5)

??這個時候,我們哪怕把5換成500,也是分分鐘的事情,不,秒秒鐘。


問題4:鏈表翻轉(又是一道可惡的鏈表題)
例子:
輸入: 1->2->3->4->5
輸出: 5->4->3->2->1
解決思路:
1.遞歸函數傳遞兩個參數,head 和 pre,head 保存的是原來的鏈表,pre 保存的是翻轉的鏈表。
2.在每次遞歸時,從head上取下來一個節點,然后把 pre 接在該節點后面。
3.在遞歸到最后時,head 節點為空,而 pre 里面則為翻轉好的鏈表。

class ListNode:   #定義節點類
    def __init__(self, x):
        self.val = x
        self.next = None

def reverseList( head, pre = None):
    if not head: return pre
    cur, head.next = head.next, pre
    return reverseList(cur, head)

def printListNode(node):   #輔助函數,打印鏈表
    while node:
        print(node.val, end=' ')
        if node.next:
            print('->', end=' ')
        node = node.next
    print()

if __name__ == '__main__':
    head = node = ListNode(1)
    for i in range(2,6):
        node.next = ListNode(i)
        node = node.next

    printListNode(head)
    ans = reverseList(head)
    printListNode(ans)

問題5:鏈表相加(跟鏈表杠上了)
一個鏈表相當于存了一個數,如鏈表(2 -> 4 -> 3)相當于數字342,我們要做的就是把數讀出來,然后相加,再將結果寫成鏈表形式。
輸入: (2 -> 4 -> 3) + (5 -> 6 -> 4)
輸出: 7 -> 0 -> 8
解釋: 342 + 465 = 807.

class ListNode(object):
    def __init__(self, x):
        self.val = x
        self.next = None
        self.prev = None

def printListNode(node):   #輔助函數,打印鏈表
    while node:
        print(node.val, end=' ')
        if node.next:
            print('->', end=' ')
        node = node.next
    print()

def addTwoNumbers( l1: ListNode, l2: ListNode) -> ListNode:
    def toint(node):
        return node.val + 10 * toint(node.next) if node else 0

    def tolist(n):
        node = ListNode(n % 10)
        if n >= 10:
            node.next = tolist(n // 10)
        return node
    return tolist(toint(l1) + toint(l2))

if __name__ == '__main__':
    head1 = node1 = ListNode(1)

    for i in range(2,10,2):
        node1.next = ListNode(i)
        node1 = node1.next

    printListNode(head1)
    ans = addTwoNumbers(head1,head1)
    printListNode(ans)


??最后的最后,渴望變成天使,,,啊呸,最后的最后,我們來分析一下遞歸問題的時間復雜度和空間復雜度,這也是一個需要我們下功夫思考的地方,那么,Let’s begin!

  • 時間復雜度
    ??遞歸問題時間復雜度可以由一個公式來表示:
    O(T)=R?O(s)??其中,O(T)為遞歸問題的時間復雜度,R 表示為該問題共調用遞歸函數的次數(遞歸花時間,一般問題出在這里。重復計算導致計算機吐血而亡,GG前,面對電腦前的你,帶著疑惑和不甘,向你道出了最后的遺言:“我跟你...咳咳...什么仇什么怨,我如此強大的計算能力,咳咳,精通千萬種高級計算方法,你為什么要...咳咳...要讓我算1+1算到死?”。這時,你會如何回答?),而O(s)表示單個遞歸函數的時間復雜度。很好理解吧~
    ??在此,我給大家列兩個例子,方便各位理解:
    ??例子1:我們來一起重溫一下問題1,字符串翻轉問題。每次我們取出兩個字符,剩下的繼續調用遞歸函數,那么我們需要調用n/2次(n代表字符串長度),那么可得R =n/2。在每個遞歸函數當中,我們只做了一個交換動作,s[begin], s[end] = s[end], s[begin],所以時間復雜度為O(1),那么,該算法的復雜度便為:R*O(1) = n/2 * O(1) = O(n)是不是很簡單呢?
    ??例子2:大家可以回想一下斐波那契數列,如果我們用遞歸來表示的話,可以得到如下公式:f(i) = f(i-1) + f(i-2) ??看起來,好像R的復雜度也是O(n),但是!但是!但是!他的復雜度為O(2^n)!因為當它計算到第n個數時,需要調用該遞歸函數共(2^n - 1)次!
    ??如下圖所示(為了表示方便,我們在該數列前面加上一個數字0),我們要得到f(4),那么需要計算f(2)和f(3),而計算f(3),我們又需要再算一遍f(2)!從圖中可以看出,通過這種方式計算,調用遞歸函數次數是指數級上升的!那么,當你回想問題3當中的楊輝三角形時,會不會發現了什么異曲同工之處?那么,如何解決這種指數爆炸問題,是不是各位老爺也心中有數了呢?(是的,就是加入記憶機制,把之前計算結果全部保存下來即可~這時你會驚奇的發現,時間復雜度變成了n*O(1) = O(n)!
    斐波那契數列計算.png
  • 空間復雜度
    ??空間復雜度我們也是分兩部分來考慮,一部分是遞歸相關的空間,另一部分是非遞歸相關的空間。
    ??遞歸相關空間:每次調用遞歸函數時,計算機都會從一個棧(stack)當中給該函數分配一些空間,如,當該函數執行結束時,需要返回原先運算的地方,這便需要一塊內存來存儲之前中斷的位置(計算機需要該地址,才知道該函數執行結束后,從哪里開始下一步運算),還有傳遞給該遞歸函數的參數,以及該函數的局部變量等等,這些都是跟遞歸相關的空間使用。
    ??如果只是一個普通的函數,那么當他運行完之后,該空間就會被釋放,但是對于遞歸調用來說,直到遇到邊界條件之前,所有被調用到的遞歸函數占用的空間都不會被釋放,相當于每調用一次遞歸函數,空間使用是累計增加的。所以如果不注意,便會遇到 “stack overflow”的場景。
    ??對于問題1當中的字符串翻轉問題來說,遞歸函數里只進行了一步交換元素位置的操作,所以需要的額外空間為O(1),遞歸共進行了n次,所以該算法的遞歸相關空間的復雜度為O(n)。
    ??非遞歸相關空間:這部分空間指的是不直接跟遞歸相關的那部分空間使用情況。諸如全局變量(常儲存在堆(heap)當中),如我們為了克服重復計算問題,所加入的記憶機制,還有算法程序的輸入以及輸出數據所占用的空間等。

尾遞歸(Tail Recursion)
??有一種遞歸較為特殊,叫尾遞歸。在此簡單講解一下:尾遞歸是一種遞歸,其中遞歸調用是遞歸函數中的最后一條指令。 并且函數中應該只有一個遞歸調用。說白了,其他地方不能出現遞歸調用,只能是最后一句是遞歸調用。那么,這么搞到底有什么好處呢?大家先來看兩個例子(功能很簡單,列表求和):

def sum_non_tail_recursion(ls):
    """
    :type ls: List[int]
    :rtype: int, the sum of the input list.
    """
    if len(ls) == 0:
        return 0
    
    # not a tail recursion because it does some computation after the recursive call returned.
    return ls[0] + sum_non_tail_recursion(ls[1:])


def sum_tail_recursion(ls):
    """
    :type ls: List[int]
    :rtype: int, the sum of the input list.
    """
    def helper(ls, acc):
        if len(ls) == 0:
            return acc
        # this is a tail recursion because the final instruction is a recursive call.
        return helper(ls[1:], ls[0] + acc)
    
    return helper(ls, 0)

??第一個便不是尾遞歸,雖然遞歸調用只出現了一次且出現在最后一句,即return語句里,但是,該return里面還包含了加法運算,相當于是先遞歸調用,再計算加法,然后把該加法結果返回。這樣子看,最后一步運算確實不是遞歸調用。
??那么大家明白了什么是尾遞歸,可能就有疑惑了,這么做到底有啥好處,代碼變得略復雜了,并且還多了個參數,那么這么做到底能得到什么呢?答案就是,極大的降低了空間復雜度!各位懵逼的少年,且聽我慢慢道來:
??從前有座山,山上呢有座廟,廟里呢,有個老和尚...(此處省略十萬八千字),于是,琦玉老師對杰諾斯說到,假設,我們的遞歸函數為f(x),如果我們的遞歸調用如下:
f(x_1) -> f(x_2)->... -> f(x_n) ??那么正常情況下,代碼運行結束前,每次調用需要開辟一塊空間,最終需要開辟n塊空間才行。即空間復雜度為O(n)。但是如果是尾遞歸呢?我們只需要開辟兩塊空間,空間復雜度驟降為O(1)!
??因為尾遞歸函數在遞歸結束時不需要再進行任何計算,直接將結果返回給上一層遞歸函數,那么也就意味著,遞歸到f(x_n)的時候,可以直接將返回值送給f(x_1),而不是f(x_{n-1})!那么,當我計算完f(x_{2})的時候,f(x_{2})函數所占用的內存便可以釋放掉,無需保留!
??杰諾斯趕緊拿小本本記下來,心中想道,這可能就是老師這么強的原因,我要趕快記下來才行。
??當然,還有一點需要提醒一下大家,那就是這種內存優化是需要看語言的。C 以及 C++ 都支持尾遞歸的這種優化方式,但是據我所知,Java 和 Python 好像還不行(當然不排除以后會支持)。所以當你所使用的語言不支持時,什么尾不尾的,那都是浮云一片~

總結
接下來說三點遞歸算法使用小貼士:

  1. 毫無疑問,首先寫下遞歸關系式。
  2. 只要有可能,咱就用記憶機制。
  3. 當發生stack overflow 的時候,嘗試使用尾遞歸。

附注
Answer 1. 你向瀕死的計算機冷笑道:“誰讓你只認識 0 和 1 兩個數呢”。
Answer 2. 你掏出了另一臺閃閃發光的計算機,“對不起,我有新歡了?!?br> Answer 3. 你語重心長、態度堅定的說到:“為了實現中華民族偉大復興?!?/p>


各位看官老爺,希望可以不吝賜教,如有不妥之處還望指出~
也熱切希望大家可以給個小贊,或點個關注!
在此謝謝各位爺勒~

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容