七個步驟解決面試中的動態規劃問題

七個步驟解決面試中的動態規劃問題

附註:本文翻譯自 《Dynamic Programming – 7 Steps to Solve any DP Interview Problem》python

即便有豐富的構建軟件產品的經驗,不少工程師都會對面試中的算法問題感到緊張。我面試過數百名工程師,其中最讓工程師以爲困難的是動態規劃問題。面試

不少技術公司喜歡在面試中考察動態規劃問題。雖然這些問題是否能有效評估工程師的能力仍需討論,動態規劃一直是工程師在求職道路上最容易被絆倒的地方。算法

動態規劃——可預測、可準備

我我的認爲動態規劃不能很好地測試工程師能力的緣由之一是:這些問題是可預測的,而且容易套用模板。這些問題讓更可能是讓咱們考察候選人的準備狀況,而不是工程能力。編程

這些問題一般從外表看上去很是複雜,可能給你一種印象:解決這些問題的人很是精通算法。一樣的,一些沒法繞過動態規劃問題中思惟陷阱的人看上去算法知識不足。數組

事實並不是如此。影響面試表現的最主要因素是準備。讓咱們確保每一個人都作好準備。緩存

解決動態規劃問題的最主要因素是準備狀況。安全

七個步驟解決動態規劃問題

在本文的剩餘部分,我會介紹一個「菜譜」,讓你能夠遵守它來判斷一個問題是否是動態規劃問題,並找到解決問題的方法。具體來講,我將遵守下面的步驟:框架

  1. 如何確認動態規劃問題
  2. 識別變量
  3. 清晰表述遞歸關係
  4. 識別邊界狀況
  5. 決定使用迭代仍是遞歸
  6. 增長記錄表
  7. 計算時間複雜度

簡單的動態規劃問題

爲了不過於抽象,咱們介紹一個簡單的問題做爲示例。在下面的每一節我都會討論這個問題。不過你也能夠拋開這個問題獨立地閱讀每一節。編程語言

問題陳述

在這個問題中,咱們坐在一個跳躍球上,嘗試將球停下來,同時躲避地面的尖刺。函數

規則

1. 給定一個跑道,上面有一些尖刺。跑道用一個布爾數組描述,每一個元素表示跑道上一點是否有尖刺。True表示沒有尖刺,False表示有尖刺。

示例數組

2. 給定一個初始速度S。S是一個非負整數,表示下次跳躍時前進的步數。

3. 每次跳躍到一個點後,在下次跳躍前,你能夠調整速度最多1個單位。

4. 你須要將跳躍球安全的停在跑道上(不要求停在跑道盡頭)。跳躍球的速度變爲0時就會停下來。若是你跳躍到一個尖刺上,跳躍球會爆炸,遊戲結束。

你須要編寫一個輸出布爾值的函數,代表跳躍球是否能夠安全中止在跑道上。

步驟1:如何確認動態規劃問題

首先咱們要明確的是,動態規劃主要是一項優化技術。動態規劃方法將問題分解爲一組更簡單的子問題,解決每一個子問題,將子問題的解保存起來。當下次一樣的輸入時,再也不重複計算,而是查詢早前的計算結果。這種技術節省了計算時間,成本是少許的存儲空間。

確認一個問題可否使用動態規劃技術是解決問題的第一步,一般也是最難的一步。你應當問問本身,問題的解可否表達成類似子問題解的函數。

在咱們的示例問題中,給定一個點,一個速度和一個跑道,咱們能夠決定跳躍到哪一點上。並且從當前點開始以指定速度向前跳躍時,可否停下來僅依賴於咱們從所選的下一個跳躍點出發後可否中止。這一點很是重要,向前移動時咱們縮短了剩餘的跑道長度,下降了問題規模。咱們能夠重複這一過程直到咱們到達一個容易判斷可否中止跳躍的點。

確認一個動態規劃問題一般是解決過程當中最難的一步。問題的解能夠表述爲一些類似子問題解的函數嗎?

步驟2:識別變量

如今咱們已經認識到這些子問題中存在某種遞歸結構。接下來咱們須要使用函數和參數來表述這個問題,而且觀察哪些參數會發生變化。一般在面試中,你會觀察到1到2個參數會發生變化,但理論上的數量也能夠是任意多個。一個經典的單變量問題是計算第n個斐波那契數。一個雙變量問題的例子是計算字符串的編輯距離。若是你不熟悉這些問題也不用擔憂。

一個判斷變量數目的方法是列出幾個子問題做爲例子,並對參數進行比較。計算變量的數目有助於判斷子問題的數量,同時也有助於咱們增強對步驟1中遞歸關係的理解。

在咱們的例子中,子問題中兩個可能發生變化的參數是:

  1. 數組位置(P)
  2. 速度(S)

可能有人認爲剩餘的跑道也在發生變化。考慮到跑道總體不變,位置P的變化已經包含了這部分信息。

如今,使用這2個變量和其餘靜態參數,咱們完整的描述子問題了。

識別變量並肯定子問題數目。

步驟3:清晰表述遞歸關係

這是重要的一步,常被人匆匆掠過而直接開始編碼。儘可能清晰表述遞歸關係將增強你對問題的理解,並讓接下來的事情變得簡單。

一旦你指出遞歸關係並經過參數來描述問題,這是很天然的一步。子問題之間是如何關聯的?換言之,假設你已經計算出了子問題,你將如何解決主問題?

下面是咱們對示例問題的思考:

因爲在下次跳躍前能夠調整速度最多一個單位,有3個可能的速度和3個跳躍點。

更正式的,若是咱們的速度是S,位置是P,咱們能夠從(S,P)跳躍到:

  1. (S,P+S); # 若是咱們沒有改變速度
  2. (S-1,P+S-1); # 若是咱們下降速度1
  3. (S+1,P+S+1); # 若是咱們增長速度1

若是咱們能夠找到辦法在上述的任意一個子問題中停下來,咱們就能夠從(S,P)處停下來。這是由於咱們能夠從(S,P)跳躍到上述3個點。

這是典型的對問題的準確理解(用文字來表達)。有時你須要用數學語言來表達。咱們把要計算的函數稱做canStop:

canStop(S,P) = canStop(S,P+S) || canStop(S-1,P+S-1) || canStop(S+1,P+S+1)

Woohoo,看來咱們已經找到了遞歸關係。

遞歸關係:假設你已經計算出了子問題,你將如何計算主問題?

步驟4:識別邊界狀況

邊界狀況是一類特殊的子問題,這類子問題不依賴於任何其餘子問題。爲了找到這樣的子問題,你一般要嘗試找一些例子,觀察問題是如何被簡化爲子問題的,還有在什麼狀況下子問題沒法被進一步簡化。

子問題不能被進一步簡化的緣由是某些參數的值將會再也不知足問題的約束。

在咱們的示例問題中,咱們有兩個變量S和P。思考有哪些值會讓S和P變得不合法法:

  1. P應當在跑道的邊界內。
  2. P不能是讓runway[P]爲False的點。這代表咱們跳躍到尖刺上。
  3. S不能是負數。S等於0表示咱們已經完成了工做。

將咱們對變量的斷言轉換爲可編程的邊界條件有時候存在一些挑戰。這是由於,除了列出斷言外,若是你想要代碼更精確並省去沒必要要的條件,你須要考慮哪些狀況可能成立。

在咱們的例子中:

  1. P<0 || P >= length_of_runway 看起來是正確的。一個可選方案是考慮讓P == end_of_runway做爲邊界狀況。不管怎樣,細分出的子問題可能出現超出跑道的狀況,咱們要對這種狀況進行檢查。
  2. 看起來很明顯。咱們能夠直接檢查runway[P]是否是False。
  3. 相似#1,咱們能夠直接檢查S<0和S==0。咱們能夠說明爲什麼S不可能小於0,由於S每次最多調整1個單位。因此咱們能夠直接檢查S==0的狀況。故此S==0是有效的邊界狀況。

步驟5:決定使用迭代仍是遞歸

目前爲止咱們討論的內容可能讓你認爲咱們會採用遞歸方式來實現解決方案。其實咱們的討論的徹底沒有假定你的實現方法是遞歸仍是迭代。不管那種方式你都須要找出遞歸關係和邊界狀況。

爲了決定使用迭代仍是遞歸,你須要仔細權衡。

  遞歸 迭代
漸進時間複雜度 相同(若是使用記錄表) 相同
內存使用 遞歸棧、稀疏表(sparse memoization) 內存表(full memoization)
執行速度 較快,依賴於輸入 較慢,須要一些額外工做
棧溢出 可能 只要內存足夠,沒有問題。
直觀/易於實現 易於解釋 難以解釋

棧溢出的問題一般是你不想在生產系統中使用遞歸的緣由。在面試時,只要說起兩者的權衡,一般可使用任意一種實現方式。你應當對兩種方案都感受良好。

對於咱們的示例問題,我同時實現了兩個版本。下面是python代碼。

這是遞歸版本:

def canStopRecursive(runway, initSpeed, startIndex = 0):
  # negative base cases need to go first
  if (startIndex >= len(runway) or startIndex < 0 or
      initSpeed < 0 or not runway[startIndex]):
    return False
  # base case for a stopping condition
  if initSpeed == 0:
    return True
  # Try all possible paths
  for adjustedSpeed in [initSpeed, initSpeed - 1, initSpeed + 1]:
    # Recurrence relation: If you can stop from any of the subproblems,
    # you can also stop from the main problem
    if canStopRecursive(
        runway, adjustedSpeed, startIndex + adjustedSpeed):
      return True
  return False

這是迭代版本:

def canStopIterative(runway, initSpeed, startIndex = 0):
  # maximum speed cannot be larger than length of the runway. We will talk about
  # making this bound tighter later on.
  maxSpeed = len(runway)
  if (startIndex >= len(runway) or startIndex < 0 or initSpeed < 0 or initSpeed > maxSpeed or not runway[startIndex]):
    return False
  # {position i : set of speeds for which we can stop from position i}
  memo = {}
  # Base cases, we can stop when a position is not a spike and speed is zero.
  for position in range(len(runway)):
    if runway[position]:
      memo[position] = set([0])
  # Outer loop to go over positions from the last one to the first one
  for position in reversed(range(len(runway))):
    # Skip positions which contain spikes
    if not runway[position]:
      continue
    # For each position, go over all possible speeds
    for speed in range(1, maxSpeed + 1):
      # Recurrence relation is the same as in the recursive version.
      for adjustedSpeed in [speed, speed - 1, speed + 1]:
        if (position + adjustedSpeed in memo and
            adjustedSpeed in memo[position + adjustedSpeed]):
          memo[position].add(speed)
          break
  return initSpeed in memo[startIndex]

步驟6:增長記錄表

記錄表(memoization)是一種和動態規劃算法緊密聯繫的技術。記錄表把函數的結果保存起來,在遇到相同的輸入時,返回緩存的結構。爲什麼要在遞歸中使用記錄表?沒有記錄表,咱們遇到的子問題會重複計算。反覆的計算一般會致使時間複雜度以指數增加。

在遞歸裏增長記錄表是很是直觀的。咱們來看緣由。記住記錄表只是函數結果的緩存。有時你會想違背這個定義以得到一些微小的優化,可是將記錄表看做是函數結果的緩存是最直觀的實現方法。

這意味着你應當:

  1. 在返回前保存函數結果到內存中。
  2. 在進行計算以前查詢內存中的結果。

下面是增長了記錄表以後的遞歸版本:

def canStopRecursiveWithMemo(runway, initSpeed, startIndex = 0, memo = None):
  # Only done the first time to initialize the memo.
  if memo == None:
    memo = {}
  # First check if the result exists in memo
  if startIndex in memo and initSpeed in memo[startIndex]:
    return memo[startIndex][initSpeed]
  # negative base cases need to go first
  if (startIndex >= len(runway) or startIndex < 0 or
      initSpeed < 0 or not runway[startIndex]):
    insertIntoMemo(memo, startIndex, initSpeed, False)
    return False
  # base case for a stopping condition
  if initSpeed == 0:
    insertIntoMemo(memo, startIndex, initSpeed, True)
    return True
  # Try all possible paths
  for adjustedSpeed in [initSpeed, initSpeed - 1, initSpeed + 1]:
    # Recurrence relation: If you can stop from any of the subproblems,
    # you can also stop from the main problem
    if canStopRecursiveWithMemo(
        runway, adjustedSpeed, startIndex + adjustedSpeed, memo):
      insertIntoMemo(memo, startIndex, initSpeed, True)
      return True
  insertIntoMemo(memo, startIndex, initSpeed, False)
  return False

爲了展現不一樣方法以及記錄表技術的效果,咱們作一些測試。我測試了所有的3中方法。下面是測試方法:

  1. 我創建了一個長度爲1000的跑道,尖刺隨機分佈。(每一個點存在尖刺的機率是20%)
  2. initSpeed = 30。
  3. 每一個函數運行10次而後計算平均運行時間。

下面是測試結果(單位爲秒)

  Time(s)
canStopRecursive 10.239
canStopIterative 0.021
canStopRecursiveWithMemo 0.008

你能夠看到,單純遞歸的方式相比迭代方式多消耗了500倍以上的時間,相比使用記錄表遞歸方式多消耗了1300倍以上的時間。要注意這種差別會隨着跑道長度的增長急速增加。我建議你實際運行一下代碼。

步驟7:計算時間複雜度

有一些簡單的規則可讓動態規劃問題的時間複雜度變得易於計算。你須要作兩個步驟:

  1. 計算狀態的數量——這將依賴於問題中變量的數量
  2. 考慮每一個狀態須要的計算量。換句話說,若是隻剩一個狀態沒有計算,你還須要多少工做來計算出最終狀態

在咱們的例子中,狀態數量是|P|*|S|。其中

  1. P是所有跳躍點的集合(|P|是集合P中元素的數量)
  2. S是所有合法速度的集合

每一個狀態須要的工做是O(1),由於給定所有其餘狀態,咱們只須要簡單查看3個子問題來肯定當前狀態。

根據咱們以前的源代碼,|S|由跑道的長度限制(|P|)。因此咱們能夠說狀態的數量是|P|^2 並且每一個狀態的工做量是O(1),所有的時間複雜度是O(|P|^2)。

可是看起來|S|能夠被進一步縮小範圍,若是|P|明確表示中止。

因此咱們能夠對|S|設定一個邊界。咱們來計算最大速度S。假設咱們從位置0開始,若是咱們爲了儘快中止而忽略尖刺,咱們能夠多快停下來?

在第一次迭代中,咱們須要跳躍到(S-1)點。經過調整速度減一,咱們下一次能夠到達(S-2)點。依次類推。

對於長度爲L的跑道,下面的公式成立:

=> (S-1) + (S-2) + ... + 1 < L

=> S * (S-1) / 2 < L

=> S^2 - S - 2L < 0

這個方程的兩個根是:

r1 = 1/2 + sqrt(1/4 + 2L) 和 r2 = 1/2 - sqrt(1/4 + 2L)

咱們能夠將公式寫爲:

(S - r1)*(S - r2) < 0

當S>0和L>0時,S - r2 > 0。因此只須要

S - 1/2 - sqrt(1/4 + 2L) < 0

=> S < 1/2 + sqrt(1/4 +2L)

在一個長度L的跑道上,這是最大的速度。若是超過這個速度,不管尖刺的位置如何分佈,咱們的跳躍球都不可能停下來。

這意味着所有的時間複雜度依賴於跑道的長度L:

O(L * sqrt(L))比O(L^2)更好。

O(L * sqrt(L))是時間複雜度上界。

很是棒,你解決了難題。

咱們一塊兒學習的這7個步驟能夠給你一個系統的框架來解決動態規劃問題。我建議你使用這個方法來作一些練習,來完善你本身的的方法。

你接下來能夠:

  1. 擴展這個簡單的問題,嘗試找到一個跳躍路徑。咱們解決的問題告訴了你是否可以停下來,可是若是你想知道須要跳躍到哪些點上,你會如何修改上面的代碼?
  2. 有一個方法能夠加強你對記錄表做爲函數緩存的理解,你能夠學習python中的裝飾器(decorator)或者其餘編程語言中的相似概念。思考這種機制如何幫助你實現通常函數可使用的記錄表。
  3. 按照上面步驟,解決更多的動態規劃問題。你能夠在網上(LeetCode或GeeksForGeeks)找到大量習題。在你練習時,記住一件事情:學習思路而不是具體問題。思路的數量更少,更容易記憶,但會給你更多的幫助。

若是你感受能夠解決這些問題,能夠訪問 Refdash 。在這裏你能夠獲得一位高級工程師的面試,並獲得在編碼、算法和系統設計方面的詳細的反饋。

相關文章
相關標籤/搜索