轉自:http://www.cnblogs.com/miloyip/archive/2010/02/25/binary_tree_distance.html
昨天花了一個晚上爲《編程之美》,在豆瓣寫了一篇書評《遲來的書評和感想──給喜愛編程的朋友》。書評就不轉載到這裏了,取而代之,在這裏介紹書裏其中一條問題的另一個解法。這個解法比較簡短易讀及降低了空間複雜度,或者可以說覺得比較「美」吧。
問題定義
如果我們把二叉樹看成一個圖,父子節點之間的連線看成是雙向的,我們姑且定義"距離"爲兩節點之間邊的個數。寫一個程序求一棵二叉樹中相距最遠的兩個節點之間的距離。
書上的解法
書中對這個問題的分析是很清楚的,我嘗試用自己的方式簡短覆述。
計算一個二叉樹的最大距離有兩個情況:
- 情況A: 路徑經過左子樹的最深節點,通過根節點,再到右子樹的最深節點。
- 情況B: 路徑不穿過根節點,而是左子樹或右子樹的最大距離路徑,取其大者。
只需要計算這兩個情況的路徑距離,並取其大者,就是該二叉樹的最大距離。
我也想不到更好的分析方法。
但接着,原文的實現就不如上面的清楚 (源碼可從這裏下載):
01 |
// 數據結構定義 |
02 |
struct
NODE |
03 |
{ |
04 |
NODE* pLeft;
// 左子樹 |
05 |
NODE* pRight;
// 右子樹 |
06 |
int
nMaxLeft; // 左子樹中的最長距離
|
07 |
int
nMaxRight; // 右子樹中的最長距離
|
08 |
char
chValue; // 該節點的值
|
09 |
}; |
10 |
|
11 |
int
nMaxLen = 0; |
12 |
|
13 |
// 尋找樹中最長的兩段距離 |
14 |
void
FindMaxLen(NODE* pRoot) |
15 |
{ |
16 |
// 遍歷到葉子節點,返回
|
17 |
if (pRoot == NULL)
|
18 |
{
|
19 |
return ;
|
20 |
}
|
21 |
|
22 |
// 如果左子樹爲空,那麼該節點的左邊最長距離爲0
|
23 |
if (pRoot -> pLeft == NULL)
|
24 |
{
|
25 |
pRoot -> nMaxLeft = 0;
|
26 |
}
|
27 |
|
28 |
// 如果右子樹爲空,那麼該節點的右邊最長距離爲0
|
29 |
if (pRoot -> pRight == NULL)
|
30 |
{
|
31 |
pRoot -> nMaxRight = 0;
|
32 |
}
|
33 |
|
34 |
// 如果左子樹不爲空,遞歸尋找左子樹最長距離
|
35 |
if (pRoot -> pLeft != NULL)
|
36 |
{
|
37 |
FindMaxLen(pRoot -> pLeft);
|
38 |
}
|
39 |
|
40 |
// 如果右子樹不爲空,遞歸尋找右子樹最長距離
|
41 |
if (pRoot -> pRight != NULL)
|
42 |
{
|
43 |
FindMaxLen(pRoot -> pRight);
|
44 |
}
|
45 |
|
46 |
// 計算左子樹最長節點距離
|
47 |
if (pRoot -> pLeft != NULL)
|
48 |
{
|
49 |
int
nTempMax = 0; |
50 |
if (pRoot -> pLeft -> nMaxLeft > pRoot -> pLeft -> nMaxRight)
|
51 |
{
|
52 |
nTempMax = pRoot -> pLeft -> nMaxLeft;
|
53 |
}
|
54 |
else |
55 |
{
|
56 |
nTempMax = pRoot -> pLeft -> nMaxRight;
|
57 |
}
|
58 |
pRoot -> nMaxLeft = nTempMax + 1;
|
59 |
}
|
60 |
|
61 |
// 計算右子樹最長節點距離
|
62 |
if (pRoot -> pRight != NULL)
|
63 |
{
|
64 |
int
nTempMax = 0; |
65 |
if (pRoot -> pRight -> nMaxLeft > pRoot -> pRight -> nMaxRight)
|
66 |
{
|
67 |
nTempMax = pRoot -> pRight -> nMaxLeft;
|
68 |
}
|
69 |
else |
70 |
{
|
71 |
nTempMax = pRoot -> pRight -> nMaxRight;
|
72 |
}
|
73 |
pRoot -> nMaxRight = nTempMax + 1;
|
74 |
}
|
75 |
|
76 |
// 更新最長距離
|
77 |
if (pRoot -> nMaxLeft + pRoot -> nMaxRight > nMaxLen)
|
78 |
{
|
79 |
nMaxLen = pRoot -> nMaxLeft + pRoot -> nMaxRight;
|
80 |
}
|
81 |
} |
這段代碼有幾個缺點:
- 算法加入了侵入式(intrusive)的資料nMaxLeft, nMaxRight
- 使用了全局變量 nMaxLen。每次使用要額外初始化。而且就算是不同的獨立資料,也不能在多個線程使用這個函數
- 邏輯比較複雜,也有許多 NULL 相關的條件測試。
我的嘗試
我認爲這個問題的核心是,情況A 及 B 需要不同的信息: A 需要子樹的最大深度,B 需要子樹的最大距離。只要函數能在一個節點同時計算及傳回這兩個信息,代碼就可以很簡單:
01 |
#include <iostream> |
02 |
|
03 |
using
namespace std; |
04 |
|
05 |
struct
NODE |
06 |
{ |
07 |
NODE *pLeft;
|
08 |
NODE *pRight;
|
09 |
}; |
10 |
|
11 |
struct
RESULT |
12 |
{ |
13 |
int
nMaxDistance; |
14 |
int
nMaxDepth; |
15 |
}; |
16 |
|
17 |
RESULT GetMaximumDistance(NODE* root)
|
18 |
{ |
19 |
if
(!root) |
20 |
{
|
21 |
RESULT empty = { 0, -1 };
// trick: nMaxDepth is -1 and then caller will plus 1 to balance it as zero.
|
22 |
return
empty; |
23 |
}
|
24 |
|
25 |
RESULT lhs = GetMaximumDistance(root->pLeft);
|
26 |
RESULT rhs = GetMaximumDistance(root->pRight);
|
27 |
|
28 |
RESULT result;
|
29 |
result.nMaxDepth = max(lhs.nMaxDepth + 1, rhs.nMaxDepth + 1);
|
30 |
result.nMaxDistance = max(max(lhs.nMaxDistance, rhs.nMaxDistance), lhs.nMaxDepth + rhs.nMaxDepth + 2);
|
31 |
return
result; |
32 |
} |
計算 result 的代碼很清楚;nMaxDepth 就是左子樹和右子樹的深度加1;nMaxDistance 則取 A 和 B 情況的最大值。
爲了減少 NULL 的條件測試,進入函數時,如果節點爲 NULL,會傳回一個 empty 變量。比較奇怪的是 empty.nMaxDepth = -1,目的是讓調用方 +1 後,把當前的不存在的 (NULL) 子樹當成最大深度爲 0。
除了提高了可讀性,這個解法的另一個優點是減少了 O(節點數目) 大小的侵入式資料,而改爲使用 O(樹的最大深度) 大小的棧空間。這個設計使函數完全沒有副作用(side effect)。
測試代碼
以下也提供測試代碼給讀者參考 (頁數是根據第7次印刷,節點是由上至下、左至右編號):
01 |
void
Link(NODE* nodes, int
parent, int left,
int right)
|
02 |
{ |
03 |
if
(left != -1) |
04 |
nodes[parent].pLeft = &nodes[left];
|
05 |
|
06 |
if
(right != -1) |
07 |
nodes[parent].pRight = &nodes[right];
|
08 |
} |
09 |
|
10 |
void
main() |
11 |
{ |
12 |
// P. 241 Graph 3-12
|
13 |
NODE test1[9] = { 0 };
|
14 |
Link(test1, 0, 1, 2);
|
15 |
Link(test1, 1, 3, 4);
|
16 |
Link(test1, 2, 5, 6);
|
17 |
Link(test1, 3, 7, -1);
|
18 |
Link(test1, 5, -1, 8);
|
19 |
cout <<
"test1: " << GetMaximumDistance(&test1[0]).nMaxDistance << endl;
|
20 |
|
21 |
// P. 242 Graph 3-13 left
|
22 |
NODE test2[4] = { 0 };
|
23 |
Link(test2, 0, 1, 2);
|
24 |
Link(test2, 1, 3, -1);
|
25 |
cout <<
"test2: " << GetMaximumDistance(&test2[0]).nMaxDistance << endl;
|
26 |
|
27 |
// P. 242 Graph 3-13 right
|
28 |
NODE test3[9] = { 0 };
|
29 |
Link(test3, 0, -1, 1);
|
30 |
Link(test3, 1, 2, 3);
|
31 |
Link(test3, 2, 4, -1);
|
32 |
Link(test3, 3, 5, 6);
|
33 |
Link(test3, 4, 7, -1);
|
34 |
Link(test3, 5, -1, 8);
|
35 |
cout <<
"test3: " << GetMaximumDistance(&test3[0]).nMaxDistance << endl;
|
36 |
|
37 |
// P. 242 Graph 3-14
|
38 |
// Same as Graph 3-2, not test
|
39 |
|
40 |
// P. 243 Graph 3-15
|
41 |
NODE test4[9] = { 0 };
|
42 |
Link(test4, 0, 1, 2);
|
43 |
Link(test4, 1, 3, 4);
|
44 |
Link(test4, 3, 5, 6);
|
45 |
Link(test4, 5, 7, -1);
|
46 |
Link(test4, 6, -1, 8);
|
47 |
cout <<
"test4: " << GetMaximumDistance(&test4[0]).nMaxDistance << endl;
|
48 |
} |
你想到更好的解法嗎?
21 條回覆
-
#1樓 yeka 2010-02-25 06:30
Milo又熬夜啦.......回覆 引用 查看 -
#2樓 陳碩 2010-02-25 08:56
第 19~21 行有線程安全問題:
static const RESULT empty = { 0, -1 }; // trick: nMaxDepth is -1 and then caller will plus 1 to balance it as zero.
if (!root)
return empty;
建議改爲:
if (!root) {
RESULT empty = { 0, -1 }; // trick: nMaxDepth is -1 and then caller will plus 1 to balance it as zero.
return empty;
// trust compiler, POD data will be optimized well.
}
因爲按標準,function static variable 只在函數第一次調用時初始化,這個的初始化只有在最新的編譯器裏纔是線程安全的。
在舊的編譯器(GCC 3 及以前)上,原來的寫法可能會讀到 partial initialized 'empty' 變量,如果兩個線程同時(首次)調用 GetMaximumDistance 的話。 -
#3樓 秋醒半夢時[未註冊用戶]2010-02-25 09:12
這不就是求樹的直徑的問題嗎?
樹的直徑最簡單的解法(無論是幾叉):
從任意一點i一次BFS找到樹中與他距離最遠的點j,從j再一次BFS找到樹中裏j最遠的點k,那麼D[j][k](j與k的距離)即爲答案。
稍微好理解的方法:樹形動態規劃 -
#4樓 Dbger 2010-02-25 10:07
@陳碩
如果非要考慮多線程安全,我傾向於用“全局變量”來表示這些常用的常量,就和向量,矩陣類中一些單元向量,單元矩陣等。 -
#5樓 Jeffrey Zhao 2010-02-25 10:46
我覺得直接把遞歸語意翻譯過來最直接和清晰吧:
01
type
BinaryTree =
02
| Node
of
BinaryTree * BinaryTree
03
| Empty
04
05
let
rec
height (tree: BinaryTree) =
06
match
tree
with
07
| Empty -> 0
08
| Node (l, r) -> 1 +
max
(height l) (height r)
09
10
let
rec
calculate (tree: BinaryTree) =
11
match
tree
with
12
| Empty -> 0
13
| Node (l, r) ->
14
(height l) + (height r)
15
|>
max
(calculate l)
16
|>
max
(calculate r)
這裏我用了F#,不過C#,C++其實也是一回事情吧。 -
#6樓 Todd Wei 2010-02-25 12:11
@秋醒半夢時
進行兩次BFS:先從樹根A出發進行廣度優先搜索(BFS),找到最遠的結點B,然後再從結點B出BFS,找到離B最遠的結點C,BC就是最大距離。
下面是正確性證明
假設存在結點X和Y,它們的距離是所有結點中最大的;分兩種情況討論:
1. 若路徑XY與路徑AB有交點O,
...A
...|
X-O--Y
...|
...B
由於|OB| >= |OX|且|OB| >= |OY|,所以,|BX| >= |XY|,|BY| >= |XY|。即從B出發可以構造出最長路徑。
2.若路徑XY與路徑AB無交點,
A...B X...Y
A是樹根,XY與B分屬不同的子樹,假設XY的最近祖先爲O,由於
|AB| >= |AO| + |AX|,所以|BY| = |AB| + |AO| + |OY| > |XY|。即從B出發構造出長於XY的路徑,與假設XY是最長路徑矛盾。 -
#7樓[樓主] Milo Yip 2010-02-25 12:19
@Dbger
@陳碩
我覺得兩個方法都可以解決潛在的多線程問題。我現在先相信compiler,改用了陳碩的寫法。
從另一個角度看這個問題,local static variable是會做成side effect,所以 thread-safe 會不成立。 -
#8樓[樓主] Milo Yip 2010-02-25 12:40
@Jeffrey Zhao
我未學過任何一個 functional programming 語言。希望趙大能指正不對的地方。
用 FP 的確可以增加可讀性,同時能減少錯誤的機會。
FP 能對 pure function 用自動的 cache optimization,這是優點也是缺點。如果沒有這優化,在你提供的代碼中,height 的調用次數估計是 O(n^2);而有了這優化,就需要O(n)的空間去儲存n 個 height()的運算結果。而這優化我估計應該需要做 table lookup,帶來額外 overhead。
我的嘗試中,並不需要O(n)的額外空間,而且仍維持每節點只遍歷一次。
又反過來說,在效能上,FP 的好處是可以自動做並行,用 procedural 語言手動做這個就會顯得複雜。 -
#9樓[樓主] Milo Yip 2010-02-25 13:09
@Todd Wei
@秋醒半夢時
多謝你們的回應,我方知道這個「距離」應該是叫「直徑」(Tree Diameter)。
這該我找到一點參考文章:
http://www.cs.duke.edu/courses/spring00/cps100/assign/trees/diameter.html
http://www.cs.cmu.edu/afs/cs.cmu.edu/project/phrensy/pub/papers/LeisersonM88/node17.html
發現前一篇文章基本上和Jeffrey的嘗試一樣,但用 procedural programming 會有O(N^2)的 height() 調用。我覺得我寫的邊界條件(那個trick)可能不需要,今晚回家試試。
第二篇談到的幾個詞彙我都不太認識,可能要再多看一些參考。也想請教,用 BFS 的方法會比現時的方法簡單或高效麼? 還是現時的方法實際上有錯誤? -
#10樓 Todd Wei 2010-02-25 13:20
@Milo Yip
BFS是O(N)的,所以複雜度更低。特別是基於BFS的方法不侷限於2叉樹,而前面遞歸方法在多叉樹情況下複雜度會更高。 -
#11樓 Jeffrey Zhao 2010-02-25 13:23
@Milo Yip
其實你的算法還是用了O(h)的空間啦,h是高度,(非尾)遞歸算法嘛,棧空間是省不了的。
的確這裏height會反覆調用,所以如果必要的話,還是要做memorization的。
作了momorization以後,時間和空間“複雜度”和你的過程式算法是一致的了。 -
#12樓[樓主] Milo Yip 2010-02-25 13:31
@Jeffrey Zhao
本文也提及,我的嘗試用了O(h)的棧空間代替原文的O(n) intrusive data,而你寫的height函數的memorization空間是O(n)。因為 h <= n,O(h)應該是比 O(n)好吧。 -
#13樓[樓主] Milo Yip 2010-02-25 13:36
我的嘗試也是O(N),而且只需遍歷一次。跟據你的描述,BFS要做兩次,而且要加入parent? 不過對於一般的多叉樹,可能BFS的方法是最好的方法。 -
#14樓 Todd Wei 2010-02-25 15:09
@Milo Yip
哦,是的,你的遞歸也是O(N),開始分析錯了。
樹哪個結點作爲parent沒關係,任選即可。圖論裏面對樹的一種定義方式是:具有n個結點和n+1條邊的連通圖。 -
#15樓 鄭暉 2010-02-25 16:06
@Milo Yip
>>我方知道這個「距離」應該是叫「直徑」(Tree Diameter)。
的確是“直徑”——在數學中直徑的定義是:一個距離空間中任意兩點間距離的上確界(supremum)。 -
#16樓 秋醒半夢時[未註冊用戶]2010-02-25 17:03
我所瞭解的樹的定義是:一個無環的無向圖 -
#17樓[樓主] Milo Yip 2010-02-25 17:04
@鄭暉
謝謝鄭老師的數學指導。在網上找到了關於這個的定義:
http://mathworld.wolfram.com/GeneralizedDiameter.html
http://mathworld.wolfram.com/Supremum.html
-
#18樓 鄭暉 2010-02-25 17:17
http://mathworld.wolfram.com/GeneralizedDiameter.html
上面對的直徑定義尚不足夠general,它只提到了歐氏空間(Euclidean space R^n ),
實際可擴展到更廣泛的距離空間(metric space)。事實上,你這裏提到的樹就不是歐氏空間(因爲這裏的距離並非歐氏距離)。 -
#19樓[樓主] Milo Yip 2010-02-25 17:27
@鄭暉
是的,我理解只要是 metric 就可以定義 diameter。 -
#20樓 flyinghearts 2010-05-19 14:05
這是我的解法:
http://blog.csdn.net/flyinghearts/archive/2010/05/19/5605995.aspx
歡迎大家指正。 -
#21樓 gzroy 2010-10-06 18:24
這是我的遞歸解法,歡迎交流:
http://blog.csdn.net/yui/archive/2010/10/06/5924020.aspx