遞歸/回溯/深度優先搜索/廣度優先搜索 /動態規划/二分搜索/貪婪算法


遞歸(Recursion)
算法思想
遞歸算法是一種調用自身函數的算法(二叉樹的許多性質在定義上就滿足遞歸)。遞歸的基本性質就是函數調用,在處理問題的時候,遞歸往往是把一個大規模的問題不斷地變小然后進行推導的過程。

舉例:(漢諾塔問題)有三個塔 A、B、C,一開始的時候,在塔 A 上放着 n 個盤子,它們自底向上按照從大到小的順序疊放。現在要求將塔 A 中所有的盤子搬到塔 C 上,讓你打印出搬運的步驟。在搬運的過程中,每次只能搬運一個盤子,另外,任何時候,無論在哪個塔上,大盤子不能放在小盤子的上面。

通俗來說,把要實現的遞歸函數看成是已經實現好的, 直接利用解決一些子問題,然后需要考慮的就是如何根據子問題的解以及當前面對的情況得出答案。這種算法也被稱為自頂向下(Top-Down)的算法。

解題步驟

  • 合法性判斷;判斷當前情況是否非法,如果非法就立即返回,這一步也被稱為完整性檢查(Sanity Check)。例如,看看當前處理的情況是否越界,是否出現了不滿足條件的情況。通常,這一部分代碼都是寫在最前面的。
  • 遞歸結束條件判斷;判斷是否滿足結束遞歸的條件。在這一步當中,處理的基本上都是一些推導過程當中所定義的初始情況。
  • 遞歸主程序;將問題的規模縮小,遞歸調用。在歸並排序和快速排序中,我們將問題的規模縮小了一半,而在漢諾塔和解碼的例子中,我們將問題的規模縮小了一個。
  • 結果整合;利用在小規模問題中的答案,結合當前的數據進行整合,得出最終的答案。

##斐波那契數列
def fibnacci(n):
    if n == 1 or n == 2:
        return 1
    else:
        return fibnacci(n-1) + fibnacci(n-2)
##非遞歸寫法
def fibnacci_no_recurision(n):
    f = [0,1,2]
    if n > 2:
        for i in range(n -2):
            num = f[-1]+f[-2]
            f.append(num)
    return f[n]
print(fibnacci_no_recurision(100))

回溯(Backtracking)
算法思想
回溯實際上是一種試探算法,這種算法跟暴力搜索最大的不同在於,在回溯算法里,是一步一步地小心翼翼地進行向前試探,會對每一步探測到的情況進行評估,如果當前的情況已經無法滿足要求,那么就沒有必要繼續進行下去,也就是說,它可以幫助我們避免走很多的彎路。

回溯算法的特點在於,當出現非法的情況時,算法可以回退到之前的情景,可以是返回一步,有時候甚至可以返回多步,然后再去嘗試別的路徑和辦法。這也就意味着,想要采用回溯算法,就必須保證,每次都有多種嘗試的可能。

解題步驟

  • 判斷當前情況是否非法,如果非法就立即返回;
  • 當前情況是否已經滿足遞歸結束條件,如果是就將當前結果保存起來並返回;
  • 當前情況下,遍歷所有可能出現的情況並進行下一步的嘗試;
  • 遞歸完畢后,立即回溯,回溯的方法就是取消前一步進行的嘗試。

遞歸和回溯可以說是算法面試中最重要的算法考察點之一,很多其他算法都有它們的影子。例如,二叉樹的定義和遍歷就利用到了遞歸的性質;歸並排序、快速排序的時候也運用了遞歸;動態規划,它其實是對遞歸的一種優化;二分搜索,也可以利用遞歸去實現。 

注意:要能熟練掌握好分析遞歸復雜度的方法,必須得有比較扎實的數學基礎,比如對等差數列、等比數列等求和公式要牢記。

深度優先搜索(Depth-First Search / DFS)
深度優先搜索,從起點出發,從規定的方向中選擇其中一個不斷地向前走,直到無法繼續為止,然后嘗試另外一種方向,直到最后走到終點。就像走迷宮一樣,盡量往深處走。 

DFS 解決的是連通性的問題,即,給定兩個點,一個是起始點,一個是終點,判斷是不是有一條路徑能從起點連接到終點。起點和終點,也可以指的是某種起始狀態和最終的狀態。問題的要求並不在乎路徑是長還是短,只在乎有還是沒有。有時候題目也會要求把找到的路徑完整的打印出來。

DFS 是圖論里的算法,分析利用 DFS 解題的復雜度時,應當借用圖論的思想。圖有兩種表示方式:鄰接表、鄰接矩陣。假設圖里有 V 個頂點,E 條邊。 

時間復雜度:

  • 鄰接表

訪問所有頂點的時間為 O(V),而查找所有頂點的鄰居一共需要 O(E) 的時間,所以總的時間復雜度是 O(V + E)。

  • 鄰接矩陣

查找每個頂點的鄰居需要 O(V) 的時間,所以查找整個矩陣的時候需要 O(V2) 的時間。

舉例:利用 DFS 在迷宮里找一條路徑的復雜度。迷宮是用矩陣表示。

解法:把迷宮看成是鄰接矩陣。假設矩陣有 M 行 N 列,那么一共有 M × N 個頂點,因此時間復雜度就是 O(M × N)。

空間復雜度:

DFS 需要堆棧來輔助,在最壞情況下,得把所有頂點都壓入堆棧里,所以它的空間復雜度是 O(V),即 O(M × N)。

例題:利用 DFS 去尋找最短的路徑。

解題思路

思路 1:暴力法。

尋找出所有的路徑,然后比較它們的長短,找出最短的那個。此時必須嘗試所有的可能。因為 DFS 解決的只是連通性問題,不是用來求解最短路徑問題的。 

思路 2:優化法。

一邊尋找目的地,一邊記錄它和起始點的距離(也就是步數)。

從某方向到達該點所需要的步數更少,則更新。

廣度優先搜索(Breadth-First Search / BFS)
廣度優先搜索,一般用來解決最短路徑的問題。和深度優先搜索不同,廣度優先的搜索是從起始點出發,一層一層地進行,每層當中的點距離起始點的步數都是相同的,當找到了目的地之后就可以立即結束。

廣度優先的搜索可以同時從起始點和終點開始進行,稱之為雙端 BFS。這種算法往往可以大大地提高搜索的效率。

舉例:在社交應用程序中,兩個人之間需要經過多少個朋友的介紹才能互相認識對方。

解法:

  • 只從一個方向進行 BFS,有時候這個人認識的朋友特別多,那么會導致搜索起來非常慢;
  • 如果另外一方認識的人比較少,從這一方進行搜索,就能極大地減少搜索的次數;
  • 每次在決定從哪一邊進行搜索的時候,要判斷一下哪邊認識的人比較少,然后從那邊進行搜索。

動態規划
Wikipedia 定義:它既是一種數學優化的方法,同時也是編程的方法。

1. 是數學優化的方法——最優子結構

動態規划是數學優化的方法指,動態規划要解決的都是問題的最優解。而一個問題的最優解是由它的各個子問題的最優解決定的。

由此引出動態規划的第一個重要的屬性:最優子結構(Optimal Substructure)。

一般由最優子結構,推導出一個狀態轉移方程 f(n),就能很快寫出問題的遞歸實現方法。

 2. 是編程的方法——重疊子問題

#動態規划
p = [0,1,5,8,10,13,17,18,22,25,30]
##含遞歸,重復計算效率低
def cut_rod_recurision(p,n):
    if n == 0:
        return 0
    else:
        res = p[n]
        for i in range(1,n):
            res = max(res,cut_rod_recurision(p,i)+cut_rod_recurision(p,n-i))
        return res
cut_rod_recurision(p,9)

#最優子結構、重復子問題
def cut_rod_recurision2(p,n):
    if n == 0:
        return 0
    else:
        res = 0
        for i in range(1,n+1):
            res = max(res,p[i]+cut_rod_recurision2(p,n-i))
        return res
print(cut_rod_recurision2(p,9))

 

動態規划是編程的方法指,可以借助編程的技巧去保證每個重疊的子問題只會被求解一次。

引出了動態規划的第二個重要的屬性:重疊子問題(Overlapping Sub-problems)。

因此,判斷一個問題能不能稱得上是動態規划的問題,需要看它是否同時滿足這兩個重要的屬性:最優子結構(Optimal Substructure)和重疊子問題(Overlapping Sub-problems)

自底向上(Bottom-Up)
自底向上指,通過狀態轉移方程,從最小的問題規模入手,不斷地增加問題規模,直到所要求的問題規模為止。依然使用記憶化避免重復的計算,不需要遞歸。

根據動態規划問題的難易程度,把常見的動態規划面試題分成如下三大類。

線性規划
面試題中最常見也是最簡單的一種。

線性,就是說各個子問題的規模以線性的方式分布,並且子問題的最佳狀態或結果可以存儲在一維線性的數據結構里,例如一維數組,哈希表等。

解法中,經常會用 dp[i] 去表示第 i 個位置的結果,或者從 0 開始到第 i 個位置為止的最佳狀態或結果。例如,最長上升子序列。dp[i] 表示從數組第 0 個元素開始到第i個元素為止的最長的上升子序列。

區間規划
區間規划,就是說各個子問題的規模由不同的區間來定義,一般子問題的最佳狀態或結果存儲在二維數組里。一般用 dp[i][j] 代表從第 i 個位置到第 j 個位置之間的最佳狀態或結果。

解這類問題的時間復雜度一般為多項式時間,對於一個大小為 n 的問題,時間復雜度不會超過 n 的多項式倍數。例如,O(n)=n^k,k 是一個常數,根據題目的不同而定。

約束規划
在普通的線性規划和區間規划里,一般題目有兩種需求:統計和最優解。

這些題目不會對輸出結果中的元素有什么限制,只要滿足最終的一個條件就好了。但是在很多情況下,題目會對輸出結果的元素添加一定的限制或約束條件,增加了解題的難度。

舉例:0-1 背包問題。

給定 n 個物品,每個物品都有各自的價值 vi 和重量 wi,現在給你一個背包,背包所能承受的最大重量是 W,那么往這個背包里裝物品,問怎么裝能使被帶走的物品的價值總和最大。

因為很多人都熟悉這道經典題目,因此不去詳細講解,但是建議大家好好去做一下這道題。

NP 完全問題

該例題為 NP 完全問題。NP 是 Non-deterministic Polynomial 的縮寫,中文是非決定性多項式。通俗一點來說,對於這類問題,我們無法在多項式時間內解答。這個概念很難,但是理解好它能幫助你很好的分析時間復雜度。

時間復雜度

時間復雜度並不是表示程序解決問題需要花費的具體時間,而是說程序運行的時間隨着問題規模擴大增長的有多快。

如果程序具有 O(1) 的時間復雜度,那么,無論問題規模有多大,運行時間都是固定不變的,這個程序就是一個好程序。如果程序運行的時間隨着問題規模的擴大線性增長,復雜度是 O(n),也很不錯。還有一些平方數 O(n2)、立方數 O(n3) 的復雜度等,比如冒泡排序。另外還有指數級的復雜度,例如 O(2n),O(3n) 等。還有甚至 O(n!) 階乘級的復雜度,例如全排列算法。分類如下:

  • 多項式級別時間復雜度

O(1)、O(n)、O(n×logn)、O(n2)、O(n3) 等,可以表示為 n 的多項式的組合

  • 非多項式級別時間復雜度

O(2n),O(3n) 等指數級別和 O(n!) 等階乘級別 。

二分搜索(Binary Search)
二分搜索(折半搜索)的 Wikipedia 定義:是一種在有序數組中查找某一特定元素的搜索算法。從定義可知,運用二分搜索的前提是數組必須是排好序的。另外,輸入並不一定是數組,也有可能是給定一個區間的起始和終止的位置。

優點:時間復雜度是 O(lgn),非常高效。

因此也稱為對數搜索。

缺點:要求待查找的數組或者區間是排好序的。

對數組進行動態的刪除和插入操作並完成查找,平均復雜度會變為 O(n)。此時應當考慮采取自平衡的二叉查找樹:

  • 在 O(nlogn) 的時間內用給定的數據構建出一棵二叉查找樹;
  • 在 O(logn) 的時間里對目標數據進行搜索;
  • 在 O(logn) 的時間里完成刪除和插入的操作。

因此,當輸入的數組或者區間是排好序的,同時又不會經常變動,而要求從里面找出一個滿足條件的元素的時候,二分搜索就是最好的選擇。

二分搜索一般化的解題思路如下。

  1. 從已經排好序的數組或區間中取出中間位置的元素,判斷該元素是否滿足要搜索的條件,如果滿足,停止搜索,程序結束。
  2. 如果正中間的元素不滿足條件,則從它兩邊的區域進行搜索。由於數組是排好序的,可以利用排除法,確定接下來應該從這兩個區間中的哪一個去搜索。
  3. 通過判斷,如果發現真正要找的元素在左半區間的話,就繼續在左半區間里進行二分搜索。反之,就在右半區間里進行二分搜索。

二分搜索看起來簡單,但是 Programming Pearls 這本書的作者 Jon Bentley 提到,只有 10% 的程序員能正確地寫出二分搜索的代碼。面試題經常是經典二分搜索的變形,但萬變不離其中,需要把握好二分搜索的核心。

遞歸解法
優點:簡潔;缺點:執行消耗大

例題分析一:找確定的邊界
邊界分上邊界和下邊界,有時候也被成為右邊界和左邊界。確定的邊界指邊界的數值等於要找的目標數。

例題:LeetCode 第 34 題,在一個排好序的數組中找出某個數第一次出現和最后一次出現的下標位置。

示例:輸入的數組是:{5, 7, 7, 8, 8, 10},目標數是 8,那么返回 {3, 4},其中 3 是 8 第一次出現的下標位置,4 是 8 最后一次出現的下標位置。

解題思路

在二分搜索里,比較難的是判斷邏輯,對這道題來說,什么時候知道這個位置是不是 8 第一次以及最后出現的地方呢?

把第一次出現的地方叫下邊界(lower bound),把最后一次出現的地方叫上邊界(upper bound)。

那么成為 8 的下邊界的條件應該有兩個。

  1. 該數必須是 8;
  2. 該數的左邊一個數必須不是 8:
  • 8 的左邊有數,那么該數必須小於 8;
  • 8 的左邊沒有數,即 8 是數組的第一個數。

而成為 8 的上邊界的條件也應該有兩個。

  1. 該數必須是 8;
  2. 該數的右邊一個數必須不是 8:
  • 8 的右邊有數,那么該數必須大於8;
  • 8 的右邊沒有數,即 8 是數組的最后一個數。

例題分析二:找模糊的邊界
二分搜索可以用來查找一些模糊的邊界。模糊的邊界指,邊界的值並不等於目標的值,而是大於或者小於目標的值。

例題:從數組 {-2, 0, 1, 4, 7, 9, 10} 中找到第一個大於 6 的數。

解題思路

在一個排好序的數組里,判斷一個數是不是第一個大於 6 的數,只要它滿足如下的條件:

  1. 該數要大於 6;
  2. 該數有可能是數組里的第一個數,或者它之前的一個數比 6 小。

只要滿足了上面的條件就是第一個大於 6 的數。

例題分析三:旋轉過的排序數組
二分搜索也能在經過旋轉了的排序數組中進行。

例題:LeetCode 第 33 題,給定一個經過旋轉了的排序數組,判斷一下某個數是否在里面。

示例:給定數組為 {4, 5, 6, 7, 0, 1, 2},target 等於 0,答案是 4,即 0 所在的位置下標是 4。

解題思路

對於這道題,輸入數組不是完整排好序,還能運用二分搜索嗎?思路如下。

一開始,中位數是 7,並不是我們要找的 0,如何判斷往左邊還是右邊搜索呢?這個數組是經過旋轉的,即,從數組中的某個位置開始划分,左邊和右邊都是排好序的。

如何判斷左邊是不是排好序的那個部分呢?只要比較 nums[low] 和 nums[middle]。nums[low] <= nums[middle] 時,能判定左邊這部分一定是排好序的,否則,右邊部分一定是排好序的。

例題分析四:不定長的邊界

前面介紹的二分搜索的例題都給定了一個具體范圍或者區間,那么對於沒有給定明確區間的問題能不能運用二分搜索呢?

例題:有一段不知道具體長度的日志文件,里面記錄了每次登錄的時間戳,已知日志是按順序從頭到尾記錄的,沒有記錄日志的地方為空,要求當前日志的長度。

解題思路

可以把這個問題看成是不知道長度的數組,數組從頭開始記錄都是時間戳,到了某個位置就成為了空:{2019-01-14, 2019-01-17, … , 2019-08-04, …. , null, null, null ...}。

思路 1:順序遍歷該數組,一直遍歷下去,當發現第一個 null 的時候,就知道了日志的總數量。很顯然,這是很低效的辦法。

思路 2:借用二分搜索的思想,反着進行搜索。

  1. 一開始設置 low = 0,high = 1
  2. 只要 logs[high] 不為 null,high *= 2
  3. 當 logs[high] 為 null 的時候,可以在區間 [0, high] 進行普通的二分搜索

二分搜索算法是重中之重,因為它看似簡單,但要寫對卻不那么容易。

貪婪(Greedy)
貪婪算法的 Wikipedia 定義:是一種在每一步選中都采取在當前狀態下最好或最優的選擇,從而希望導致結果是最好或最優的算法。

優點:對於一些問題,非常直觀有效。

缺點:

並不是所有問題都能用它去解決;

得到的結果並一定不是正確的,因為這種算法容易過早地做出決定,從而沒有辦法達到最優解。

下面通過例題來加深對貪婪算法的認識。例題:0-1 背包問題,能不能運用貪婪算法去解決。

有三種策略:

  1. 選取價值最大的物品
  2. 選擇重量最輕的物品
  3. 選取價值/重量比最大的物品

由上,貪婪算法總是做出在當前看來是最好的選擇。即,它不從整體的角度去考慮,僅僅對局部的最優解感興趣。因此,只有當那些局部最優策略能產生全局最優策略的時候,才能用貪婪算法。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM