LeetCode 課前預習 - 掌握 Recursion 的思維指南
前言
會想要寫這篇是因為在碰到binary tree的時候會大量的使用recursion,因此想先瞭解Recursion的核心思維,希望能讓自己將遞迴的概念內化,讓他從此不再高不可攀。那我們就開始吧!首先,來聊聊基礎遞迴常見的思維方式之一,
- 先將一個大問題,拆解成幾個較小的問題
- 如果發現這些較小的問題當中,也能依照相同的方式拆解成更小的問題
- 每當小問題解決時,大問題也可以依靠小問題的結果來解決
- 從大問題到小問題,每層的解決方法都是一樣的,除了最小問題以外
- 根據以上幾點,我可以不段套用相同的函數,讓他自己幫自己解決問題
以上就是遞迴的基本核心精神,如何分析看不懂沒關係,我們先來看個最基本的例子
實戰範例
從 1 加到 n
如果使用迴圈的方式
1 | def sum(n): |
我們的思考方式如下:
- 大問題:從1加到n
- 小一點的問題:從1加到n-1
- 那我們只要先處理好小問題,最後再把其答案加上n就好了
1 | def sum_1_to_n(x): |
這個思維就是,如果我想算 sum(1~10),我可以改成算 10 + sum(1~9)。那如何算 sum(1~9) 呢?當然就是 9 + sum(1~8) 依此類推下去。
但最後還有個小問題,如果我們要算 sum(1~1),該怎麼做?如果寫成 1 + sum(1~0) 似乎有點不合理,所以這就是我們所謂的最小問題需要額外的處理,很幸運的是我們的最小問題非常容易,因為我們已經知道答案是 1。因此將上面的程式碼稍作修改後,最終答案如下:
1 | def sum_1_to_n(x): |
到此為止,我們用兩種方法解決了相同的問題,而他們兩個思維本質上的差異在於:
- 迴圈(Iteration):當用迴圈(Iteration)思考時,你則總是得很明確的告訴電腦每一個變數的值要如何改變,所以得想清楚這些值的所有變化過程。
- 遞迴(Recursion):當用遞迴(Recursion)思考時,我們並不直接思考如何解決問題,而是先思考問題能否被拆小,還有大問題跟小問題間的關聯性。
迴圈應該對大多工程師來說都非常的直覺了,那我們為何還需要學遞迴的做法呢?以上述例子來說,Recursion雖然讓程式碼簡潔了一點點,不過思考起來稍微難了點,似乎有利有弊。但我們要把吃苦當吃補,這是因為這種思維的轉換,是遞迴最重要的地方,因為有些演算法,靠遞迴的思路來寫可能會非常容易,像是Binary Tree
重新用遞迴思維看一次上面的例子,當我們有了一個空殼的 function 如下
1 | def sum_1_to_n(x): |
我們並不清楚該如何實作他,但我想的是假如這個 function 可以運作,那我如何利用他自己來幫自己解決問題。於是我們想的東西是
從大到小問題
- 我發現一個轉移式
sum_1_to_n(x) = x+ sum_1_to_n(x-1)
- 那依此類推,
sum_1_to_n(x-1) = (x-1) + sum_1_to_n(x-2)
… 這個等式可以繼續無限循環下去。 - 而我也知道最小問題
sum_1_to_n(1)
的答案是 1,所以在最小問題可以停下直接給答案。
從小問題到大問題 - 我知道
sum_1_to_n(1)
的答案是 1 - 那套用轉移式,我可以知道
sum_1_to_n(2) = 2 + sum_1_to_n(1) = 2 + 1 = 3
,你會發現 x 就是 2 而 x-1 要放入sum_1_to_n
函式裡面 - 繼續以上,我們可以知道
sum_1_to_n(3) = 3 + sum_1_to_n(2) = 3 + 3 = 6
… 如此循環下去,我就能知道任意的sum_1_to_n(x)
的值了
河內塔
我們有三根柱子,並有 N 個圓盤套在最左邊柱子上面(上圖 N = 4),現在我們要把它們全部移動到最右邊的柱子上,請問我們最少需要移動幾次?
以下是移動的規則:
- 每次只能移動一個最上方的圓盤到任意柱子上
- 大圓盤不能放在小圓盤上面
遞迴思維:大問題沒頭緒的話,先想一下,如果能解決稍微小一點的問題,是不是就能靠他幫忙,來解決大問題了。
- 大問題:移動 4 個圓盤總共需要幾步
- 小一點的問題:移動 3 個圓盤總共需要幾步(相同的問題,但範圍變小)
假設我們知道小問題的答案(如何移動3個圓盤到特定柱子),我們可以把最後一步的步驟想成,假如移動3個盤子需要的步驟是K,那移動4個盤子就是
- 先移動3個盤子到中間的柱子,這個步驟需要K步
- 先把最下面的盤子移動到最右邊,這個步驟需要1步
- 然後把剩下的3個盤子從中間移動到右邊的柱子,這個步驟需要K步
1. 找到關係式
因此我們得到了一個寶貴的關係式:(移動 X 個盤子的步驟數)= 2 * (移動 X - 1 個盤子的步驟數) + 1
寫成程式碼的話就是:
1 | def hanoi_tower_step(x): |
2. 最小問題的解答
當然,一樣不能忘記,我們都會有一個”最小問題”,他不能再繼續遞迴下去,在這邊我們的最小問題,當然就是只有 1 個圓盤的時候該如何處理,而很顯然的,這個答案也是 1。最終我們就會得到非常簡潔的答案如下。
1 | def hanoi_tower_step(x): |
靠著遞迴的思維,我們甚至根本就不知道移動盤子的最佳策略是什麼,或說我們唯一需要知道的策略,只有如何移動 1 個盤子,也就是 1 步,或 0 個盤子也行,就是 0 步,剩下的就只剩找到大小問題之間的關聯性就解決了,是不是很強大。
其他常見的簡單經典例子還有:階乘、費氏數列、輾轉相除法、爬梯子 等等。
費氏數列
以費氏數列(Fibonacci)為例
- 大問題:找到費氏數列中,第 X 個數的數值
- 小問題1:找到費氏數列中,第 X-1 個數的數值
- 小問題2:找到費氏數列中,第 X-2 個數的數值
- 大小問題的關聯式:fibonacci(x) = fibonacci(x-1) + fibonacci(x-2)
- 最小問題:第 1 個與第 2 個數的數值
所以程式碼,照抄下來就是:
1 | def fibonacci(x): |
遞迴還有另一個小缺點是深度限制。簡單來說,就是遞迴的過程中,由於每個 function 都還沒執行完就又 call 了自己,那你就會堆積一堆執行到半路的 function,電腦需要一定的記憶體去記錄這些狀態,所以當你深度太超過就會炸掉(Stack Overflow),有興趣更深入理解的讀者可以看這邊的解說,常見的處理方式叫做 Tail Recursion。若以刷 Leetcode 題來說,由於題目會設計好,你幾乎是不會遇到這問題的,但實務中則要小心注意。
總結
思考的時候我們想三個方向
- 他們的大問題跟小問題分別可以是什麼
- 大小問題的關聯式子怎麼寫
- 最小問題又是什麼