動態規劃入門——動態規劃與數據結構的結合,在樹上作DP



259e891dcc7e1c344a74fed69b2f6e64.jpeg



今天是算法與數據結構的第15篇,也是動態規劃系列的第4篇。node

以前的幾篇文章當中一直在聊揹包問題,不知道你們有沒有以爲有些膩味了。雖然經典的文章當中揹包一共有九講,但除了競賽選手,咱們能理解到單調優化就已經很是出色了。像是帶有依賴的揹包問題,和混合揹包問題,有些劍走偏鋒,因此這裏很少作分享。若是你們感興趣能夠自行百度揹包九講查看,今天咱們來看一個有趣的問題,經過這個有趣的問題,咱們來了解一下在樹形結構當中作動態規劃的方法。算法

這個問題題意很簡單,給定一棵樹,並不必定是二叉樹,樹上的樹枝帶有權重,能夠當作是長度。要求樹上最長的鏈路的長度是多少?數組

好比咱們隨手畫一棵樹,可能醜了點,勿怪:數據結構

若是讓咱們用肉眼來看,稍微嘗試一下就能找到答案,最長的路徑應該是下圖當中紅色的這條:架構

fe32ff09de4ee66e97a44359959b0889.jpeg

可是若是讓咱們用算法來算,應該怎麼辦呢?app

這道題其實有一個很是巧妙的辦法,咱們先不講,先來看看動態規劃怎麼解決這個問題。ide


樹形DP


動態規劃並不僅是能夠在數組當中運行,實際上只要知足動態規劃的狀態轉移的條件和無後效性就可使用動態規劃,不管在什麼數據結構當中。樹上也是同樣的,明白了這點以後,就只剩下了兩個問題,第一個是狀態是什麼,第二個問題是狀態之間怎麼轉移?優化

在以前的揹包問題當中,狀態就是揹包當前用的體積,轉移呢就是咱們新拿一個物品的決策。可是這一次咱們要在樹上進行動態規劃,相對來講狀態和對應的轉移會隱蔽一些。沒有關係,我會從頭開始整理思路,一點一點將推導和思考的過程講解清楚。編碼

首先,咱們都知道,狀態之間轉移其實本質上是一個由局部計算總體的過程。咱們經過相對容易的子狀態進行轉移,獲得總體的結果。這個是動態規劃的精髓,某種程度上來講它和分治法也比較接近,都存在大問題和小問題之間邏輯上的關係。因此當咱們面臨一個大問題束手無策的時候,能夠借鑑一下分治法,思考一下從小問題入手。spa

因此,咱們從小到大,由微觀到宏觀,來看看最簡單的狀況:

959693dca85c8a18b3dde2b87fa027b0.jpeg

這種狀況很明顯,鏈路只有一條,因此長度天然是5 + 6 = 11,這顯然也是最長的長度。這種狀況都沒有問題,下面咱們來把狀況稍微再變得複雜一些,咱們在樹上多加入一層:

5bdfb3ee20f0e23db8e4023ddf1753a8.jpeg

這張圖稍微複雜了一些,可是路徑也不難找到,應該是E-B-F-H。路徑的總長度爲12:

d6674c842dc45bca0b09d6a4ee26707a.jpeg

可是若是咱們變動一下路徑長度呢,好比咱們把FG和FH的路徑加長,會獲得什麼結果呢?

95d1c75fcb8aed59e005e824a2825048.jpeg

顯然這種狀況下答案就變了,FGH是最長的。

舉這個例子只爲了說明一個很簡單的問題,即對於一棵樹而言它上面的最長路徑並不必定通過根節點。好比剛纔的例子當中,若是路徑必需要通過B的話,最長只能構造出4+2+16=22的長度,可是若是能夠不用通過B的話,能夠獲得最長的長度是31。

得出這個結論看似好像沒有用,但其實對於咱們理清思路頗有幫助。既然咱們不能保證最長路徑必定會通過樹根,因此咱們就不能直接轉移答案。那咱們應該怎麼辦呢?

回答這個問題光想是不夠的,依然須要咱們來觀察問題和深刻思考。


轉移過程


咱們再觀察一下下面這兩張圖:

5284946a5f74d7b20538301104f78b24.jpeg

有沒有發現什麼規律?

因爲咱們的數據結構就是樹形的,因此這個最長路徑無論它連通的哪兩個節點,必定能夠保證,它會通過某一棵子樹的根節點。不要小看這個不起眼的結論,實際上它很是重要。有了這個結論以後,咱們將整條路徑在根節點處切開。

56ca125eb1c330020528c7d8cf096896.jpeg

切開以後咱們獲得了兩條通往葉子節點的鏈路,問題來了,根節點通往葉子節點的鏈路有不少條,爲何是這兩條呢?

很簡單,由於這兩條鏈路最長。因此這樣加起來以後就能夠保證獲得的鏈路最長。這兩條鏈路都是從葉子節點通往A的,因此咱們獲得的最長鏈路就是以A爲根節點的子樹的最長路徑。

咱們前面的分析說了,最長路徑是不能轉移的,可是到葉子的最長距離是能夠轉移的。咱們舉個例子:

c7ab73900f2cf0e6147473a028304a21.jpeg

F到葉子的最長距離顯然就是5和6中較大的那個,B稍微複雜一些,D和E都是葉子節點,這個容易理解。它還有一個子節點F,對於F來講它並非葉子節點,可是咱們前面算到了F到葉子節點的最長距離是6,因此B經過F到葉子節點的最長距離就是2 + 6 = 8。這樣咱們就獲得了狀態轉移方程,不過咱們轉移的不是要求的答案而是從當前節點到葉子節點的最長距離和次長距離

由於只有最長距離是不夠的,由於咱們要將根節點的最長距離加上次長距離獲得通過根節點的最長路徑,因爲咱們以前說過,全部的路徑必然通過某棵子樹的根節點。這個想明白了是廢話,可是這個條件的確很重要。既然全部的鏈路都至少通過某一個子樹的根節點,那麼咱們算出全部子樹通過根節點的最長路徑,其中最長的那個不就是答案麼?

下面咱們演示一下這個過程:

上圖當中用粉色筆標出的就是轉移的過程,對於葉子節點來講最長距離和次長距離都是0,主要的轉移過程發生在中間節點上。

轉移的過程也很容易想通,對於中間節點i,咱們遍歷它全部的子節點j,而後維護最大值和次大值,咱們寫下狀態轉移方程:

狀態轉移想明白了,剩下的就是編碼的問題了。可能在樹上尤爲是遞歸的時候作狀態轉移有些違反咱們的直覺,但實際上並不難,咱們寫出代碼來看下,咱們首先來看建樹的這個部分。爲了簡化操做,咱們能夠把樹上全部的節點序號當作是int,對於每個節點,都會有一個數組存儲全部與這個節點鏈接的邊,包括父親節點。因爲咱們只關注樹上的鏈路的長度,並不關心樹的結構,樹建好了以後,無論以哪個點爲總體的樹根結果都是同樣的。因此咱們隨便找一個節點做爲整棵樹的根節點進行遞歸便可。強調一下,這個是一個很重要的性質,由於本質上來講,樹是一個無向無環全連通圖。因此無論以哪一個節點爲根節點均可以連通整棵子樹。咱們建立一個類來存儲節點的信息,包括id和兩個最長以及次長的長度。咱們來看下代碼,應該比大家想的要簡單得多。

 
 

class Node(object):
    def __init__(self, id):
        self.id = id
        # 以當前節點爲根節點的子樹到葉子節點的最長鏈路
        self.max1 = 0
        # 到葉子節點的次長鏈路
        self.max2 = 0
        # 與當前節點相連的邊
        self.edges = []

    # 添加新邊
    def add_edge(self, v, l):
        self.edges.append((v, l))


# 建立數組,存儲全部的節點
nodes = [Node(id) for id in range(12)]

edges = [(013), (021), (131), (144), (152), (565), (576), (287), (792), (7108)]

# 建立邊
for edge in edges:
    u, v, l = edge
    nodes[u].add_edge(v, l)
    nodes[v].add_edge(u, l)

因爲咱們只是爲了傳達思路,因此省去了許多面向對象的代碼,可是對於咱們理解題目思路來講應該是夠了。下面,咱們來看樹上作動態規劃的代碼:

 
 

def dfs(u, f, ans):
    nodeu = nodes[u]
    # 遍歷節點u全部的邊
    for edge in nodes[u].edges:
        v, l = edge
        # 注意,這其中包括了父節點的邊
        # 因此咱們要判斷v是否是父節點的id
        if v == f:
            continue
        # 遞歸,更新答案
        ans = max(ans, dfs(v, u, ans))
        nodev = nodes[v]
        # 轉移最大值和次大值
        if nodev.max1 + l > nodeu.max1:
            nodeu.max1 = nodev.max1 + l
        elif nodev.max1 + l > nodeu.max2:
            nodeu.max2 = nodev.max1 + l
    # 返回當前最優解
    return max(ans, nodeu.max1 + nodeu.max2)

看起來很複雜的樹形DP,其實代碼也就只有十來行,是否是簡單得有些出人意料呢?可是仍是老生常談的話題,這十幾行代碼看起來簡單,可是其中的細節仍是有一些的,尤爲是涉及到了遞歸操做。對於遞歸不是特別熟悉的同窗可能會有些吃力,建議能夠根據以前的圖手動在紙上驗算一下,相信會有更深入的認識。

另外一種作法


文章還沒完,咱們還有一個小彩蛋。其實這道題還有另一種作法,這種作法很是機智,也同樣介紹給你們。以前咱們說了,因爲樹記錄的是節點的連通狀態,因此無論以哪一個節點爲根節點,都不會影響整棵樹當中路徑的長度以及結構。既然如此,若是咱們富有想象力的話,咱們把一棵樹壓扁,是否是能夠當作是一串連在一塊兒的繩子或者木棍?咱們來看下圖:b298c5a15b2c3bcbdfdb58e45ade4764.jpeg咱們把C點向B點靠近,並不會影響樹的結構,畢竟這是一個抽象出來的架構,咱們並不關注樹上樹枝之間的夾角。咱們能夠想象成咱們拎起了A點,其餘的幾點因爲重力的做用下垂,最後就會被拉成一條直線。好比上圖當中,咱們拎起了A點,BCD都垂下。這個時候位於最下方的點是D點。那麼咱們再拎起D點,最下方的點就成了C點,那麼DC之間的距離就是樹上的最長鏈路:1a17b08c163cbb05d5469d5d07ca9646.jpeg咱們把整個過程梳理一下,首先咱們隨便選了一個點做爲樹根,而後找出了距離它最遠的點。第二次,咱們選擇這個最遠的點做爲樹根,再次找到最遠的點。這兩個最遠點之間的距離就是答案。這種作法很是直觀,可是我也想不到能夠嚴謹證實的方法,有思路的小夥伴能夠在後臺給我留言。若是有些想不通的小夥伴能夠本身試着用幾根繩子連在一塊兒,而後拎起來作個實驗。看看這樣拎兩次獲得的兩個點,是否是樹上距離最遠的兩個點。最後,咱們來看下代碼:

 
 

def dfs(u, f, dis, max_dis, nd):
    nodeu = nodes[u]
    for edge in nodes[u].edges:
        v, l = edge
        if v == f:
            continue
        nodev = nodes[v]
        # 更新最大距離,以及最大距離的點
        if dis + l > max_dis:
            max_dis, nd = dis+l, nodev
        # 遞歸
        _max, _nd = dfs(v, u, dis+l, max_dis, nd)
        # 若是遞歸獲得的距離更大,則更新
        if _max > max_dis:
            max_dis, nd = _max, _nd
    # 返回
    return max_dis, nd

# 第一次遞歸,獲取距離最大的節點
_, nd = dfs(0-100None)
# 第二次遞歸,獲取最大距離
dis, _ = dfs(nd.id, -100None)
print(dis)

到這裏,這道有趣的題目就算是講解完了,不知道文中的兩種作法你們都學會了嗎?第一次看可能會以爲有些蒙,問題不少這是正常的,但核心的原理並不難,畫出圖來好好演算一下,必定能夠獲得正確的結果。另外,今天第一次在次條當中接到了廣告,嘿嘿,不過這筆錢我並不會本身留着,我會把廣告以及以前粉絲打賞的相關收入所有以送書的形勢回饋粉絲。具體的形式以及相關書籍尚未想好,我會在後序的文章底部同步相關信息的,千萬不要錯過哈。

相關文章
相關標籤/搜索