【譯】繪製一棵漂亮的樹

街角小林發表於2022-03-24
ps.本文是對https://llimllib.github.io/pymag-trees/文章的翻譯,原文使用的是python語言,譯者改用JavaScript實現,並在文章的最後加上了譯者詳細的解析,有思維導圖等樹形結構繪製需求的朋友千萬別錯過。

當我需要為我的專案繪製一些樹的時候,我覺得肯定會有一種經典又簡單的演算法,但最終我發現了一些有意思的事情:樹的佈局不僅僅是一個NP完全問題,在樹的繪製演算法背後有一段漫長而有趣的歷史。接下來,我會逐一介紹歷史中出現的樹繪製演算法,嘗試其中的每一種,並最終實現一個完全O(n)複雜度的樹繪製演算法。

問題是什麼?

img

給我們一棵樹T,我們要做的就是試著把它畫出來,讓別人一眼就能理解它,本篇文章中的每個演算法都會給樹節點一個(x,y)座標,所以在演算法執行之後它能在螢幕中繪製出來,或者列印出來。

為了儲存樹繪製演算法的結果,我們會建立一個DrawTree資料結構來映象我們繪製的樹,我們唯一要假設的事情就是每個樹節點都可以迭代其子節點,DrawTree的基本實現如下:

// 程式碼1
class DrawTree {
    constructor(tree, depth = 0) {
        this.x = -1
        this.y = depth
        this.tree = tree
        this.children = tree.children.map((child) => {
            return new DrawTree(child, depth + 1)
        })
    }
}

隨著我們的方法越來越複雜,DrawTree的複雜度也會隨之增加,現在來說,它只是把每個節點的x座標賦值為-1y座標賦值為它在樹中的深度,以及儲存對樹節點的引用。然後,它會遞迴的為每個節點建立一個DrawTree,從而構建該節點的子節點列表。這樣一來,我們就構建了一個DrawTree來表示將要繪製的樹,並給每個節點新增了特定的繪製資訊。

隨著我們在本文中實現更好的演算法,我們將利用每個人的經歷總結成的原則來幫助我們構建下一個更好演算法,儘管生成一個“漂亮”的樹形圖是一個品味問題,但是這些原則還是會幫助我們優化程式輸出。

故事是從Knuth開始的

我們要畫的是一種根節點在頂部的特殊型別,它的子節點在它下面,以此類推,這類圖形,以及這類問題的解決,在很大程度上歸功於Donald Knuth,我們會從他這裡得出前兩個原則:

原則1:樹的邊不應該交叉

原則2:相同深度的節點應該繪製在同一水平線,這能讓樹的結構更清晰

Knuth的演算法簡單快速,但它只適用於二叉樹,以及會生成一些相當畸形的圖形,它是一個簡單的樹的中序遍歷,設定一個全域性的計數變數,用來作為x座標,計數器會隨著節點遞增,程式碼如下:

// 程式碼2
let i = 0
const knuth_layout = (tree, depth) => {
    if (tree.left_child) {
        knuth_layout(tree.left_child, depth + 1)
    }
    tree.x = i
    tree.y = depth
    i += 1
    if (tree.right_child) {
        knuth_layout(tree.right_child, depth + 1)
    }
}

img

如上圖所示,這個演算法生成的樹滿足原則1,但它不是很美觀,你可以看到Knuth的圖會迅速橫向擴充套件,因為它沒有重用x座標,即使這會使樹明顯更窄一點,為了避免像這樣浪費空間,我們可以得出第三個原則:

原則3:樹應該儘可能畫的緊湊一點

一個簡短的回顧

在我們繼續學習更高階的演算法之前,先讓我們停下來了解一些術語,首先,在描述我們的資料節點之間的關係時,我們將使用家族樹的比喻,節點的下面可以有子節點,左邊或右邊可以有兄弟節點,以及上面會有父節點。

我們已經討論了樹的中序遍歷,接下來我們還會看到前序遍歷和後序遍歷,你可能在很久以前的“資料結構”課程上看到過這三個術語,但除非你最近一直在和樹打交道,否則你可能已經對它們有點模糊了。

遍歷方式只是決定我們在給定的節點上進行處理的時機,中序遍歷,也就是上面的Knuth演算法,只接受一個二叉樹,意味著我們會先處理左子節點,然後是當前節點,然後是右子節點,前序遍歷,意味著我們先處理當前節點,然後處理它的所有子節點,後序遍歷則剛好和它相反。

最後,你可能已經瞭解了大寫的O符號的概念,用來表示演算法的時間複雜度,在本文中,我們會時不時的提起它,用它來作為一個簡單的工具來判斷一個演算法在執行時間上能不能被接受。如果一個演算法在它的主迴圈中頻繁的遍歷它的一個節點的所有子節點,我們稱它的時間複雜度為O(n^2),其他情況則為O(n),如果你想了解更多細節,本文最後引用的論文中包含了大量關於這些演算法時間複雜度的內容。

自下而上

Charles WetherellAlfred Shannon這兩個人在1979年出現了,也就是在Knuth提出了樹的佈局演算法的8年後,他們引入了一些創新技術,首先,他們展示瞭如何生成滿足前面三個原則的儘可能緊湊的樹,通過後序遍歷,只需要維護同一深度的下一個節點的位置:

// 程式碼3
const nexts = []
const minimum_ws = (tree, depth = 0) => {
    if (nexts[depth] === undefined) {
        nexts[depth] = 0
    }
    tree.x = nexts[depth]
    tree.y = depth
    nexts[depth] += 1
    tree.children.forEach((child) => {
        minimum_ws(child, depth + 1)
    })
}

img

儘管這個演算法生成的樹滿足我們所有的原則,但是你可能也會同意實際繪製出來是很醜的,即使是在上圖這樣一個簡單的樹中,也很難快速的確定樹節點之間的關係,而且整個樹看著似乎都被擠在一起了。現在是時候引入下一個原則了,它會幫助優化Knuth樹和最小寬度樹:

原則4:父節點應該位於子節點中間

到目前為止,我們能使用非常簡單的演算法去繪製樹是因為我們沒有真正的考慮每個節點自身,我們依賴全域性的計數器來防止節點重疊,為了滿足父節點位於子節點中間的原則,我們需要考慮每個節點的自身上下文狀態,那麼需要一些新的策略。

WetherellShannon介紹的第一個策略是通過樹的後序遍歷從底部開始構建樹,而不是像程式碼2那樣從上到下,或者像程式碼3一樣從中間穿過,只要你以這種方式看待這棵樹,那麼讓父節點居中是一個很簡單的操作,只要把它子節點的x座標分成兩半。

img

但是我們必須記住,在構建樹的右側時,要注意樹的左側,如上圖所示,樹的右側被推到右邊為了容納左側,為了實現這一分離,WetherellShannon程式碼2的基礎上通過陣列維護下一個可用點,但只有在將父樹居中會導致樹的右側與左側重疊時,才使用下一個可用的位置。

ModsRockers

在我們看更多程式碼之前,讓我們仔細看看自下而上構建樹的結果,如果節點是葉子節點,我們會給它下一個可用的x座標,如果它是一個分支,則把它居中在它的子節點之上,然而,如果將分支居中會導致它與樹的另一部分發生衝突,我們就需要正確的把它移動足夠的距離來避免衝突。

當我們把分支移動正確時,我們需要移動它的所有子節點,否則我們將失去我們一直在努力維護的中心父節點,寫一個將分支及其子樹移動正確的函式是很容易的:

// 程式碼4
const move_right = (branch, n) => {
    branch.x += n
    branch.children.forEach((child) => {
        move_right(child, n)
    })
}

上面這個函式可以工作,但是存在一個問題,如果我們使用這個函式來向右移動一個子樹,我們將在遞迴(放置樹節點)中進行遞迴(移動樹),這意味著我們的演算法效率很低,時間複雜度為O(n²)

為了解決這個問題,我們將為每個節點新增一個mod屬性,當我們到達一個分支時我們需要正確的移動n個空間,我們會把x座標加上n,並賦值給mod屬性,然後愉快的繼續執行佈局演算法,因為我們是自下而上移動,所以不需要擔心樹的底部發生衝突(我們已經證明了它們不會),我們等一會再把它們移動正確。

一旦執行完了第一個樹的遍歷,我們就開始進行第二個遍歷過程,將需要正確移動的分支進行移動,因為我們只遍歷了每個節點一次,並且執行的只是算術運算,我們可以確定它的時間複雜度和第一次一樣,都為O(n),所以兩次合起來還是O(n)

下面的程式碼演示了父節點居中和使用mod屬性來提高程式碼的效率:

// 程式碼5
class DrawTree {
    constructor(tree, depth = 0) {
        this.x = -1;
        this.y = depth;
        this.tree = tree;
        this.children = tree.children.map((child) => {
            return new DrawTree(child, depth + 1);
        });
        this.mod = 0;
    }
}
const setup = (tree, depth = 0, nexts = {}, offset = {}) => {
    tree.children.forEach((child) => {
        setup(child, depth + 1, nexts, offset);
    });
    tree.y = depth;
    let place;
    let childrenLength = tree.children.length
    if (childrenLength <= 0) {
        place = nexts[depth] || 0;
        tree.x = place;
    } else if (childrenLength === 1) {
        place = tree.children[0].x - 1;
    } else {
        let s = tree.children[0].x + tree.children[1].x;
        place = s / 2;
    }
    offset[depth] = Math.max(offset[depth] || 0, (nexts[depth] || 0) - place);
    if (childrenLength > 0) {
        tree.x = place + offset[depth];
    }
    if (nexts[depth] === undefined) {
        nexts[depth] = 0;
    }
    nexts[depth] += 2;
    tree.mod = offset[depth];
};
const addmods = (tree, modsum = 0) => {
    tree.x = tree.x + modsum;
    modsum += tree.mod;
    tree.children.forEach((child) => {
        addmods(child, modsum);
    });
};
const layout = (tree) => {
    setup(tree);
    addmods(tree);
    return tree;
};

樹作為Block塊

雖然在很多情況下它確實產生了不錯的效果,但是程式碼5也會產生一些奇怪的樹,就像上圖(抱歉,圖已丟失在歲月的長河中),Wetherell-Shannon演算法的另一個理解上的困難是,相同的樹結構,當放在樹的不同位置時,可能會繪製出不同的結構。為了解決這個問題,我們會從Edward ReingoldJohn Tilford的論文中借鑑一個原則:

原則5:同一個子樹無論在樹的哪個位置,繪製的結果都應該相同

儘管這會擴大我們的繪製寬度,但是這個原則會有助於它們傳達更多資訊。它還有助於簡化自下而上的遍歷,比如,一旦我們計算出一個子樹的x座標,我們只需要將它作為一個單位向左或向右移動。

下面是程式碼6的演算法大致過程:

  • 對樹進行後序遍歷
  • 如果一個節點是葉子節點,那麼給它一個值為0x座標
  • 否則,在不產生衝突的情況下,將它的右子樹儘可能靠近左子樹

    • 使用與前面相同的mod方式,在O(n)時間內移動樹
  • 將節點放置在其子節點中間
  • 再遍歷一次樹,將累積的mode值加到x座標上

這個演算法很簡單,但是要執行它,我們需要引入一些複雜性。

輪廓

img

樹的輪廓是指樹的一邊最大或最小的座標組成的列表,如上圖,有兩棵樹,它們重疊在一起,如果我們沿著左邊樹的左邊,取每層的最小x座標,我們可以得到[1, 1, 0],我們把它叫做樹的左輪廓,如果我們沿著右邊,取每一層最右邊的x座標,可以得到[1, 1, 2],也就是樹的右輪廓

為了找出右邊樹的左輪廓,我們同樣取每一層最左邊節點的x座標,可以得到[1, 0, 1],此時,可以看到輪廓有一個有趣的特性,就是並非所有節點都以父子關係連線,第二層的0不是第三層的1的父節點。

如果我要根據程式碼6連線這兩個樹,我們可以找到左邊樹的右輪廓,以及右邊樹的左輪廓,然後我們就可以輕鬆的找到我們需要的將右邊的樹推到右邊使它不會和左邊樹重疊的最小值,下面的程式碼是一個簡單的實現:

// 程式碼7
const lt = (a, b) => {
    return a < b
}
const gt = (a, b) => {
    return a > b
}
// [a, b, c],[d, e, f] => [[a, d], [b, e], [c, f]]
const zip = (a, b) => {
    let len = Math.min(a.length, b.length)
    let arr = []
    for(let i = 0; i < len; i++) {
    arr.push([a[i], b[i]])
    }
    return arr
}
const push_right = (left, right) => {
    // 左邊樹的右輪廓
    let wl = contour(left, lt)
    // 右邊樹的左輪廓
    let wr = contour(right, gt)
    let res = zip(wl, wr)
    let arr = res.map((item) => {
        return item[0] - item[1]
    })
    return Math.max(...arr) + 1
}
// 獲取一棵樹的輪廓
const contour = (tree, comp, level = 0, cont = null) => {
    // 根節點只有一個,所以直接新增
    if (cont === null) {
        cont = [tree.x]
    } else if (cont.length < level + 1) {// 該層級尚未新增,直接新增
        cont.push(tree.x)
    } else if (comp(cont[level], tree.x)) {// 該層級已經有值,所以進行比較
        cont[level] = tree.x
    }
    tree.children.forEach((child) => {
        contour(child, comp, level + 1, cont)
    })
    return cont
}

如果我們用上圖的兩棵樹執行push_right方法,我們可以得到左邊樹的右輪廓[1, 1, 2]和右邊樹的左輪廓[1, 0, 1],然後比較這些列表,找出它們之間的最大空間,並新增一個空格填充。對於上圖的兩棵樹,將右邊的樹向右推兩個空格將能防止它與左邊的樹重疊。

新執行緒

使用程式碼7,我們可以正確的找到需要把右邊樹推多遠的值,但是為了做到這個,我們需要掃描兩個子樹的每個節點去得到我們需要的輪廓,因為它需要的時間複雜度很可能是O(n^2)ReingoldTilford為此引入了一個令人困惑的概念,叫做執行緒,它們與用於並行執行的執行緒意義完全不同。

img

執行緒是一種方法,它通過在輪廓上的節點之間建立連結(如果其中一個節點已經不是另一個節點的子節點)來減少掃描子樹輪廓所需要的時間,如上圖所示,虛線表示一個執行緒,而實線表示父子關係。

我們也可以利用這個事實,如果一棵樹比另一棵樹深,我們只需要往下走到較矮的那棵樹。任何更深的內容都不需要這兩棵樹再進行分離,因為它們之間不可能會有衝突。

使用執行緒以及遍歷到較矮的樹,我們可以得到一個樹的輪廓,並使用下面的程式碼線上性的時間複雜度內設定執行緒。

// 程式碼8
const nextright = (tree) => {
    if (tree.thread) {
        return tree.thread
    } else if (tree.children.length > 0) {
        return tree.children[tree.children.length - 1]
    } else {
        return null
    }
}
const nextleft = (tree) => {
    if (tree.thread) {
        return tree.thread
    } else if (tree.children.length > 0) {
        return tree.children[0]
    } else {
        return null
    }
}
const contour = (left, right, max_offset = 0, left_outer = null, right_outer = null) => {
    if (left_outer === null) {
        left_outer = left
    }
    if (right_outer === null) {
        right_outer = right
    }
    if (left.x - right.x > max_offset) {
        max_offset = left.x - right.x
    }
    let lo = nextleft(left)
    let li = nextright(left)
    let ri = nextleft(right)
    let ro = nextright(right)
    if (li && ri) {
        return contour(li, ri, max_offset, lo, ro)
    }
    return max_offset
}

很明顯可以看到,這個過程只訪問被掃描的子樹中每一層的兩個節點。

把它們組合起來

程式碼8計算輪廓的過程簡潔且快速,但它不能和我們更早的時候討論的mod方式一起工作,因為一個節點實際的x座標是該節點的x值加上從它到根節點路徑上的所有mod值之和。為了解決這個問題,我們需要給輪廓演算法增加一些複雜度。

我們要做的第一件事就是需要維護兩個額外的變數,左子樹上的mod值之和以及右子樹的mod值之和,這些對於計算輪廓上的每個節點實際的位置來說是必需的,這樣我們就可以檢查它是否與另一側的節點發生衝突:

// 程式碼9
const contour = (left, right, max_offset = null, loffset = 0, roffset = 0, left_outer = null, right_outer = null) => {
    let delta = left.x + loffset - (right.x + roffset)
    if (max_offset === null  || delta > max_offset) {
        max_offset = delta
    }
    if (left_outer === null) {
        left_outer = left
    }
    if (right_outer === null) {
        right_outer = right
    }
    let lo = nextleft(left_outer)
    let li = nextright(left)
    let ri = nextleft(right)
    let ro = nextright(right_outer)
    if (li && ri) {
        loffset += left.mod
        roffset += right.mod
        return contour(li, ri, max_offset, loffset, roffset, lo, ro)
    }
    return [li, ri, max_offset, loffset, roffset, left_outer, right_outer]
}

我們要做的另外一件事是在退出的時候返回函式的當前狀態,這樣我們就可以線上程節點上設定適當的偏移量。有了這些資訊,我們就可以看看這個函式,它使用程式碼8去讓兩個樹儘可能的靠在一起:,

// 程式碼10
const fix_subtrees = (left, right) => {
    let [li, ri, diff, loffset, roffset, lo, ro]  = contour(left, right)
    diff += 1
    diff += (right.x + diff + left.x) % 2
    right.mod = diff
    right.x += diff
    if (right.children.length > 0) {
        roffset += diff
    }
    if (ri && !li) {
        lo.thread = ri
        lo.mod = roffset - loffset
    } else if (li && !ri) {
        ro.thread = li
        ro.mod = loffset - roffset
    }
    return (left.x + right.x) / 2
}

等我們執行完輪廓的過程,我們將左樹和右樹之間的最大差加1,這樣他們就不會發生衝突,如果中間點是奇數,那麼就再加1,這讓我們測試更方便 - 所有的節點都有整數x座標,不會損失精度。

然後我們將右邊的樹向右移動相應的距離,請記住,我們在x座標上加上diff並且也把diff儲存到mod屬性上的原因是mod值只用於當前節點下面的節點。如果右子樹有超過一個子節點,我們將差異新增到roffset,因為右節點的所有子節點都要向右移動那麼遠。

如果樹的左邊比右邊深,或反過來,我們需要設定一個執行緒。我們只是檢查一側的節點指標是否比另一側的節點指標前進得更遠,如果是的話,將執行緒從淺的樹的外側設定到深的樹的內側。

為了正確處理我們之前提到的mod值,我們需要線上程節點上設定一個特殊的mod值,因為我們已經更新了我們右側偏移值來反應右側樹的移動量,我們需要做的就是將執行緒節點的mod值設定為更深層次樹的偏移量與它本身的差值。

現在我們就已經有了合適的程式碼來找到樹的輪廓,並將兩棵樹儘可能近的放在一起,我們可以很容易的實現上面描述的演算法。我將不加註釋地呈現其餘的程式碼:

// 程式碼11
const layout = (tree) => {
    return addmods(setup(tree))
}
const addmods = (tree, mod = 0) => {
    tree.x += mod
    tree.children.forEach((child) => {
        addmods(child, mod + tree.mod)
    })
    return tree
}
const setup = (tree, depth = 0) => {
    if (tree.children.length === 0) {
        tree.x = 0
        return tree
    } else if (tree.children.length === 1) {
        tree.x = setup(tree.children[0], depth + 1).x
        return tree
    }
    left = setup(tree.children[0], depth + 1)
    right = setup(tree.children[1], depth + 1)
    tree.x = fix_subtrees(left, right)
    return tree
}

擴充套件到N叉樹

現在我們終於得到一個畫二叉樹的演算法,並且滿足我們所有的原則,在大部分情況下看起來都不錯,並且為線性時間複雜度,那麼很自然的就會想到如何把它擴充套件為支援任意多個子節點的樹。如果你一直看到這裡,你可能會想,我們是不是隻需要把剛定義的美妙的演算法應用到節點的所有子節點上即可。

擴充套件前面的演算法使之能在多叉樹上工作:

  • 對樹進行後序遍歷
  • 如果節點是葉子節點,那麼給它一個值為0x座標
  • 否則,遍歷它的子節點,將其子節點放置在儘可能靠近其左邊兄弟節點的位置
  • 將父節點放置在其最左邊和最右邊子節點的中間

img

這個演算法可以工作,並且很快,但是會有一個問題,它把節點的所有子樹都儘可能填到左邊,如果最右邊的節點與最左邊的節點發生衝突,那麼中間的樹都將被填充到右邊。讓我們採用繪製樹的最後一個原則來解決這個問題:

原則6:同一個父節點的子節點應該間隔均勻

為了對稱且快速地畫出N叉樹,我們需要用到目前為止我們學到的所有技巧,並且還要再加上一些新技巧,這要感謝Christoph Buchheim等人最近發表的一篇論文,我們已經有了所有的知識儲備來做這些並且仍然能夠線上性時間內完成。

對上述演算法進行修改,使其滿足原則6,我們需要一個方法來分隔兩棵衝突的大樹之間的樹,最簡單的方法是,每當兩棵樹發生衝突時,將可用空間除以樹的數量,然後移動每棵樹,使它的和它相鄰的樹分隔那個距離。舉個例子,在上圖,右邊和左邊的大樹之間存在一個距離n,在它們之間存在3棵樹,如果我們把第一棵樹和最左邊的間隔n/3距離,下一個又和這個間隔n/3,以此類推,就會得到滿足原則6的樹。

到目前為止,我們在本文中看到的每一個簡單的演算法,我們都會發現它的不足之處,這個也不例外,如果我們必須在每兩棵有衝突的樹之間移動所有的樹,我們又會在演算法中引入一個O(n^2)複雜度的運算。

這個問題的解決方式和前面我們解決移位問題類似,我們引入mod,而不是每次有衝突時都在中間移動每個子樹,我們把我們在中間需要移動的樹的值儲存起來,在放置完所有節點後再應用。

為了正確的求出我們需要移動中間節點的距離,我們需要能夠找到兩個衝突節點之間的樹的數量,當我們只有兩棵樹時,很明顯,所有的衝突都發生在左樹和右樹之間,當有任意棵樹時,找出是哪棵樹引起了衝突就成為了一個挑戰。

為了應對這個挑戰,我們將引入一個預設的祖先變數,並向樹的資料結構中新增另一個成員,我們稱之為ancestorancestor要麼指向自身,要麼指向它所屬樹的根,當我們需要找到一個節點屬於哪一棵樹的時候,如果這個屬性設定了就使用它,否則使用default_ancestor

當我們放置一個節點的第一個子樹,我們把default_ancestor指向這個子樹,假設如果下一棵樹發生了衝突,那麼一定是與第一棵樹發生的,當我們放置好了第二棵樹後,我們區分兩種情況,如果第二棵子樹沒有第一棵深,我們遍歷它的右輪廓,將ancestor 屬性設定為第二棵樹的根,否則,第二棵樹就是比第一棵樹深,這意味著與下一棵樹衝突的任何內容都將被放置在第二棵樹中,因此我們只需設定default_ancestor 來指向它。

話不多說,我們來看看由Buchheim提出的一個時間複雜度為O(n)的樹繪製演算法:

請看下下下節: )

總結

在本文中,我略去了一些東西,因為我覺得為了最終演算法嘗試並呈現一個邏輯進展更重要,而不是用純程式碼過載文章。如果你想要檢視更多細節,或者想知道在各個程式碼清單中使用的樹的資料結構,你可以去https://github.com/llimllib/pymag-trees/這個倉庫下載每個演算法的原始碼、一些基本的測試、以及用於生成本文的樹圖片的程式碼。

引用

1 K. Marriott, NP-Completeness of Minimal Width Unordered Tree Layout, Journal of Graph Algorithms and Applications, vol. 8, no. 3, pp. 295-312 (2004). http://www.emis.de/journals/J...

2 D. E. Knuth, Optimum binary search trees, Acta Informatica 1 (1971)

3 C. Wetherell, A. Shannon, Tidy Drawings of Trees, IEEE Transactions on Software Engineering. Volume 5, Issue 5

4 E. M. Reingold, J. S Tilford, Tidier Drawings of Trees, IEEE Transactions on Software Engineering. Volume 7, Issue 2

5 C. Buchheim, M. J Unger, and S. Leipert. Improving Walker's algorithm to run in linear time. In Proc. Graph Drawing (GD), 2002. http://citeseerx.ist.psu.edu/...

譯者的個人發揮

演算法詳細分解

雖然作者已經花了這麼大篇幅來引出最後的演算法,但是直接放出來,大概率還是看不懂的,所以譯者嘗試分解一下,想直接看原版的可以點此listing12.py

節點類如下,請務必仔細看一下right()left()方法:

// 樹節點類
class DrawTree {
    constructor(tree, parent = null, depth = 0, number = 1) {
        // 節點名稱
        this.name = tree.name;
        // 座標
        this.x = -1;
        this.y = depth;
        // 子節點
        this.children = tree.children.map((child, index) => {
            return new DrawTree(child, this, depth + 1, index + 1);
        });
        // 父節點
        this.parent = parent;
        // 執行緒節點,也就是指向下一個輪廓節點
        this.thread = null;
        // 根據左兄弟定位的x與根據子節點中間定位的x之差
        this.mod = 0;
        // 要麼指向自身,要麼指向所屬樹的根
        this.ancestor = this;
        // 記錄分攤偏移量
        this.change = this.shift = 0;
        // 最左側的兄弟節點
        this._lmost_sibling = null;
        // 這是它在兄弟節點中的位置索引 1...n
        this.number = number;
    }

    // 關聯了執行緒則返回執行緒節點,否則返回最右側的子節點,也就是樹的右輪廓的下一個節點
    right() {
        return (
            this.thread ||
            (this.children.length > 0
             ? this.children[this.children.length - 1]
             : null)
        );
    }

    // 關聯了執行緒則返回執行緒節點,否則返回最左側的子節點,也就是樹的左輪廓的下一個節點
    left() {
        return (
            this.thread || (this.children.length > 0 ? this.children[0] : null)
        );
    }

    // 獲取前一個兄弟節點
    left_brother() {
        let n = null;
        if (this.parent) {
            for (let i = 0; i < this.parent.children.length; i++) {
                let node = this.parent.children[i];
                if (node === this) {
                    return n;
                } else {
                    n = node;
                }
            }
        }
        return n;
    }

    // 獲取同一層級第一個兄弟節點,如果第一個是自身,那麼返回null
    get_lmost_sibling() {
        if (
            !this._lmost_sibling &&
            this.parent &&
            this !== this.parent.children[0]
        ) {
            this._lmost_sibling = this.parent.children[0];
        }
        return this._lmost_sibling;
    }

    // 同一層級第一個兄弟節點
    get leftmost_sibling() {
        return this.get_lmost_sibling();
    }
}

進入第一次遞迴,處理如下:

1.當前節點是葉子節點且無左兄弟,x設為0

2.當前節點是葉子節點且有左兄弟,x為左兄弟的x加上間距,即根據左兄弟定位

3.當前節點非葉子節點且無左兄弟,x為第一個子節點的x加上最後一個子節點的x除以2,即根據子節點定位

4.當前節點非葉子節點且有左兄弟,x為左兄弟的x加上間距,mod設為x相對子節點定位的差值

// 第一次遞迴
const firstwalk = (v, distance = 1) => {
    if (v.children.length === 0) {
        // 當前節點是葉子節點且存在左兄弟節點,則其x座標等於其左兄弟的x座標加上間距distance
        if (v.leftmost_sibling) {
            v.x = v.left_brother().x + distance;
        } else {
            // 當前節點是葉節點無左兄弟,那麼x座標為0
            v.x = 0;
        }
    } else {
        // 後序遍歷,先遞迴子節點
        v.children.forEach((child) => {
            firstwalk(child);
        });
        // 子節點的中點
        let midpoint =
            (v.children[0].x + v.children[v.children.length - 1].x) / 2;
        // 左兄弟
        let w = v.left_brother();
        if (w) {
            // 有左兄弟節點,x座標設為其左兄弟的x座標加上間距distance
            v.x = w.x + distance;
            // 同時記錄下偏移量(x座標與子節點的中點之差)
            v.mod = v.x - midpoint;
        } else {
            // 沒有左兄弟節點,x座標直接是子節點的中點
            v.x = midpoint;
        }
    }
    return v;
};

第二次遞迴將mod值加到x上,使父節點仍舊居中於子節點:

// 第二次遍歷
const second_walk = (v, m = 0, depth = 0) => {
    // 初始x值加上所有祖宗節點的mod值(不包括自身的mod)
    v.x += m;
    v.y = depth;
    v.children.forEach((child) => {
        second_walk(child, m + v.mod, depth + 1);
    });
};

整個過程也就是兩次遞迴:

const buchheim = (tree) => {
    let dt = firstwalk(tree);
    second_walk(dt);
    return dt;
};

第一次遞迴後的節點位置:

image-20220318102931215.png

第二次遞迴後的節點位置:

image-20220318102949466.png

明視訊記憶體在衝突的子樹可以看到是GP兩棵子樹,子樹P需要向右移動一定的距離才行,這個距離怎麼算呢?

1.進入子樹GP的第二層,找到子樹G在這一層中的最右側子節點,為F,找到子樹P在這一層的最左側子節點,為I,比較它們的x座標,原始x值加上它們祖先節點的mod值之和,比較後發現沒有交叉,於是進入下一層。

2.進入第三層,同樣找到子樹G在這一層中的最右側子節點,為E,子樹P在這一層的最左側子節點,為J,比較它們的x,發現存在交叉,這個差值再加上節點的間隔distance就是子樹P需要向右移動的距離

3.重複以上,直到最底層。

那麼怎麼最快速的找到每一層的最左側或最右側節點呢,當然可以直接遞迴,但是時間複雜度就非線性了,所以就引入了前面所說的執行緒的概念。

以上圖中的G節點為例介紹執行緒的連線過程,從它的子節點C回來後因為C沒有左兄弟,所以不處理,進入F節點遞迴,從F節點回來之後緊接著處理F節點,它存在左兄弟C,因為每棵樹都有內側和外側,所以我們設定四個指標:

image-20220318104203798.png

vInnerLeft為當前節點的左兄弟節點,vOuterLeft為當前節點的最左側的兄弟節點,當然對於F節點來說,這兩個指標都指向C節點,vInnerRightvOuterRight初始都指向當前節點。

接下來我們就將執行緒從淺的樹的外側設定到深的樹的內側

1.因為CF節點都存在子節點,所以這一層還無法判斷哪棵樹深哪棵樹淺,所以就下移一層,同時更新四個指標,這裡就會用到節點的left()right()方法:

image-20220318105921843.png

這裡存在四個指標,怎麼判斷是否還有下一層呢,因為我們要檢查節點衝突是根據兩棵樹的內側節點進行比較,所以這裡也只需要檢查兩個內側節點指標來判斷是否還有下一層,我們只需走到較淺的樹即可停止,另一棵樹更深的節點不會發生衝突,所以判斷vInnerLeft.right()vInnerRight.left()是否都存在即可。

2.下移一層後發現已經達到F的葉子節點了,那麼接下來就進行判斷,重複一下我們的原則:

將執行緒從淺的樹的外側設定到深的樹的內側

淺的樹為F子樹,深的樹為C子樹,那麼從F的外側設定到C的內側,也就是要將E節點和A節點通過執行緒連線起來。

具體的判斷規則為:

2.1.如果vInnerLeft.right()節點(也就是B節點所在樹的右側輪廓的下一個節點,這裡是存在的,為A節點)存在,且vOuterRight.right()節點(也就是E節點所在樹的右側輪廓的下一個節點,這裡是不存在的)不存在,那麼就在vOuterRight節點上設定執行緒thread屬性指向vInnerLeft.right()節點,這裡剛好滿足這個條件,所以E.thread指向了A節點。

2.2.否則如果vOuterLeft.left()節點(也就是B節點所在樹的左輪廓的下一個節點,這裡是存在的,為A節點)不存在,且vInnerRight.left()節點(也就是D節點所在樹的左輪廓的下一個節點,這裡是不存在的)存在,那麼就在vOuterLeft節點上設定執行緒thread屬性指向vInnerRight.left()節點,顯然這裡不滿足條件。

對於其他所有節點,都用這種方法判斷,最終這棵樹上執行緒節點連線為:

image-20220318112225285.png

因為我們是後序遍歷樹,所以越下層的節點執行緒連線的越早,比如處理O節點時候就會把IJ節點連線起來了,那麼在後面處理P節點時,雖然也走到了I節點,但是I節點因為有了執行緒節點,所以一定程度上它就不是“葉子節點”了,所以I不會再被連線到其他節點上。

// 第一次遞迴
const firstwalk = (v, distance = 1) => {
    if (v.children.length === 0) {
        // ...
    } else {
        v.children.forEach((child) => {
            firstwalk(child);
            apportion(child);// ++
        });
        // ...
    }
    // ...
}

const apportion = (v) => {
    let leftBrother = v.left_brother();
    // 存在左兄弟才處理
    if (leftBrother) {
        // 四個節點指標
        let vInnerRight = v;// 右子樹左輪廓
        let vOuterRight = v;// 右子樹右輪廓
        let vInnerLeft = leftBrother;// 當前節點的左兄弟節點,左子樹右輪廓
        let vOuterLeft = v.leftmost_sibling;// 當前節點的最左側的兄弟節點,左子樹左輪廓

        // 一直遍歷到葉子節點
        while(vInnerLeft.right() && vInnerRight.left()) {
            // 更新指標
            vInnerLeft = vInnerLeft.right()
            vInnerRight = vInnerRight.left()
            vOuterLeft = vOuterLeft.left()
            vOuterRight = vOuterRight.right()
        }

        // 將執行緒從淺的樹的外側設定到深的樹的內側
        if (vInnerLeft.right() && !vOuterRight.right()) {
            vOuterRight.thread = vInnerLeft.right();
        } else {
            if (vInnerRight.left() && !vOuterLeft.left()) {
                vOuterLeft.thread = vInnerRight.left();
            }
        }
    }
};

執行緒節點連線好了,接下來就可以根據輪廓判斷兩棵樹是否存在交叉,同樣因為我們是後序遍歷,所以判斷某個子樹是否存在衝突時它下面的節點執行緒肯定已經連線完成了,可以直接使用。

根據輪廓判斷的邏輯同樣也放在apportion方法裡:

// 第一次遞迴
const firstwalk = (v, distance = 1) => {
    if (v.children.length === 0) {
        // ...
    } else {
        v.children.forEach((child) => {
            firstwalk(child);
            apportion(child, distance);// distance++
        });
        // ...
    }
    // ...
}

const apportion = (v, distance) => {
    let leftBrother = v.left_brother();
    if (leftBrother) {
        // ...

        // 從當前節點依次往下走,判斷是否和左側的子樹發生衝突
        while(vInnerLeft.right() && vInnerRight.left()) {
            // ...

            // 左側節點減右側節點
            let shift = vInnerLeft.x + distance - vInnerRight.x 
            if (shift > 0) {
                // 大於0說明存在交叉,那麼右側的樹要向右移動
                move_subtree(v, shift)
            }
        }

        // ...
    }
}

// 移動子樹
const move_subtree = (v, shift) => {
    v.x += shift// 自身移動
    v.mod += shift// 後代節點移動
}

以節點P為例,過程如下:

image-20220318154717319.png

vInnerLeft.right()存在(H.right()=F),vInnerRight.left()存在(P.left()=I),所以下移一層:

image-20220318154901212.png

比較FI節點的x座標之差可以發現它們不存在衝突,於是繼續下一層:

image-20220318155104532.png

這一次比較會發現EJ節點發生衝突,那麼子樹P需要整體向右移動一定距離。

當然,上述程式碼是有問題的,因為一個節點真正的最終x座標是還要加上它所有祖宗節點的mod值,所以我們新增四個變數來累加mod值:

const apportion = (v, distance) => {
    if (leftBrother) {
        // 四個節點指標
        // ...

        // 累加mod值,它們的父節點是同一個,所以往上它們要加的mod值也是一樣的,那麼在後面shift值計算時vInnerLeft.x + 父節點.mod - (vInnerRight.x + 父節點.mod),父節點.mod可以直接消掉,所以不加上面的祖先節點的mod也沒關係
        let sInnerRight = vInnerRight.mod;
        let sOuterRight = vOuterRight.mod;
        let sInnerLeft = vInnerLeft.mod;
        let sOuterLeft = vOuterLeft.mod;

        // 從當前節點依次往下走,判斷是否和左側的子樹發生衝突
        while (vInnerLeft.right() && vInnerRight.left()) {
            // ...

            // 左側節點減右側節點,需要累加上mod值
            let shift = vInnerLeft.x + sInnerLeft + distance - (vInnerRight.x + sInnerRight);
            if (shift > 0) {
                // ...
                // v.mod,也就是節點P.mod增加了shift,sInnerRight、sOuterRight當然也要同步增加
                sInnerRight += shift;
              sOuterRight += shift;
            }

            // 累加當前層節點mod
            sInnerRight += vInnerRight.mod;
            sOuterRight += vOuterRight.mod;
            sInnerLeft += vInnerLeft.mod;
            sOuterLeft += vOuterLeft.mod;
        }
        // ...
    }
};

效果如下:

image-20220318155814623.png

但是這樣依然是有問題的,為啥呢,比如對於節點E來說,它累加上了節點FHmod值,但問題是H節點並不是E節點的祖先,它們只是通過一根執行緒虛線產生了連線而已,實際要加上的應該是節點FGmod值才對,這咋辦呢,還是以例子來看,我們假設部分節點的mod值如下:

image.png

那麼對於節點A真正要累加的mod值應該為:

B.mod + C.mod + G.mod = 1 + 2 + 3 = 6

但是因為執行緒連線,實際累加的mod值變成了:

E.mod + F.mod + H.mod = 0 + 4 + 0 = 4

少了2,如果能線上程節點EH上設定一個特殊的mod值上,來彌補上這相差的值豈不美哉,反正因為它們兩個下面也沒有子節點了,所以無論給它們設定什麼mod值都不會有影響。那麼這個特殊的mod值又怎麼計算呢?很簡單,比如在第一次處理F節點時,它存在左節點C,所以進行它們下面的節點的執行緒連線判斷,因為它們都存在子級,所以下移一層,此時F子樹到頭了,C子樹沒有,此時滿足vInnerLeft.right() && !vOuterRight.right()的條件,會把E連線到A,對於CF來說,它們的祖先節點都是一樣的,所以祖先節點的mod值不用管,那麼對於A節點來說,它真正要累加的mod值為B.mod + C.mod,而根據執行緒連線它會加上的mod值為E.mod + F.mod,兩個式子的運算結果要相同,那麼求E.mod顯然等於B.mod + C.mod - F.mod,也就是sInnerLeft - sOuterRight,修改程式碼如下:

// 將執行緒從淺的樹的外側設定到深的樹的內側
if (vInnerLeft.right() && !vOuterRight.right()) {
    vOuterRight.thread = vInnerLeft.right();
    // 修正因為執行緒影響導致mod累加出錯的問題,深的樹減淺的樹
    vOuterRight.mod += sInnerLeft - sOuterRight// ++
} else {
    if (vInnerRight.left() && !vOuterLeft.left()) {
        vOuterLeft.thread = vInnerRight.left();
        vOuterLeft.mod += sInnerRight - sOuterLeft// ++
    }
}

此時效果如下:

image.png

到這裡衝突是沒有了,但是H的位置應該居中才對,顯然是要向右移動,移動多少呢,子樹P向右移動了shift距離,那麼這個距離需要平分到GHP三個節點之間的間距上,假設兩個衝突子樹之間的子樹數量為n,那麼就是shift / (n + 1),子樹H向右移動這個距離即可。

換言之,我們先要找到是哪兩棵子樹發生了衝突,才能修正它們之間的樹,上圖可以看到發生衝突的是EJ節點,對於J節點,我們肯定知道它屬於當前的頂層子樹P,那麼只要能找出E節點所屬的樹即可,我們可以一眼就看出來是G節點,但是程式碼沒有眼,可以直接通過向上遞迴來找,但是為了線性時間複雜度我們也不能這麼做。

我們給每個節點都設定一個ancestor屬性,初始都指向自身,對於E節點,雖然在衝突判斷時它屬於vInnerLeft節點,但是在它所屬的樹上,它屬於vOuterRight節點,所以線上程連線階段,我們可以順便設定一下每層的vOuterRight節點的ancestor,讓它指向當前的頂層節點v,但是這個指向有時不一定滿足我們的要求,比如上圖的N節點,它的ancestor成功的指向了P節點,但是對於E節點來說,它的ancestor指向的是它的父節點F,而我們需要的是G,所以我們再設定一個變數default_ancestor,當一個節點的ancestor指向不滿足我們的要求時就使用default_ancestor指向的節點,default_ancestor初始指向一個節點的第一個子節點,然後從每個子節點回來時都更新該指標,如果前一個子節點沒有後一個子節點深,那麼default_ancestor就更新為指向後一個子節點,因為如果右側有子樹和左側發生衝突,那麼一定是和較深的那一棵。

const firstwalk = (v, distance = 1) => {
    if (v.children.length === 0) {
        // ...
    } else {
        let default_ancestor = v.children[0]// ++初始指向第一個子節點
        v.children.forEach((child) => {
            firstwalk(child);
            default_ancestor = apportion(child, distance, default_ancestor);// 遞迴完每一個子節點都更新default_ancestor
        });
    }
}

const apportion = (v, distance, default_ancestor) => {
    let leftBrother = v.left_brother();
    if (leftBrother) {
      // ...
      while (vInnerLeft.right() && vInnerRight.left()) {
          // ...
          // 節點v下面的每一層右輪廓節點都關聯v
          vOuterRight.ancestor = v;// ++
          // ...
      }
        // ...
      if (vInnerLeft.right() && !vOuterRight.right()) {
        // ...
      } else {
        // ...
        default_ancestor = v// ++,前面的節點沒有當前節點深,那麼default_ancestor指向當前節點
      }
    }
    return default_ancestor;// ++
}

然後我們就可以找出左側樹發生衝突的節點所屬的根節點:

const apportion = (v, distance, default_ancestor) => {
    let leftBrother = v.left_brother();
    if (leftBrother) {
      // ...
      while (vInnerLeft.right() && vInnerRight.left()) {
          // ...
          let shift = vInnerLeft.x + sInnerLeft + distance - (vInnerRight.x + sInnerRight);
          if (shift > 0) {
              // 找出vInnerLeft節點所屬的根節點
              let _ancestor = ancestor(vInnerLeft, v, default_ancestor)// ++
              move_subtree(v, shift);
              // ...
            }
            // ...
      }
      // ...
    }
    return default_ancestor;// ++
}

// 找出節點所屬的根節點
const ancestor = (vInnerLeft, v, default_ancestor) => {
    // 如果vInnerLeft節點的ancestor指向的節點是v節點的兄弟,那麼符合要求
    if (v.parent.children.includes(vInnerLeft.ancestor)) {
        return vInnerLeft.ancestor;
    } else {
        // 否則使用default_ancestor指向的節點
        return default_ancestor
    }
}

找出了是哪兩棵樹發生衝突後我們就能找到這兩棵樹之間的子樹,然後把shift分攤給它們即可,不過我們還是不能直接遍歷它們進行修正,沒錯,還是為了保持線性時間複雜度,所以只能先把分攤資料儲存到這兩棵衝突的樹根節點上,然後等它們的所有兄弟節點都遞迴完成了再一次性設定。

const firstwalk = (v, distance = 1) => {
    if (v.children.length === 0) {
        // ...
    } else {
        let default_ancestor = v.children[0]
        v.children.forEach((child) => {
            firstwalk(child);
            default_ancestor = apportion(child, distance, default_ancestor);
        });
        // 將shift分攤新增到中間節點的x及mod值上
        execute_shifts(v)// ++
        // ...
    }
}

const apportion = (v, distance, default_ancestor) => {
    let leftBrother = v.left_brother();
    if (leftBrother) {
      // ...
      while (vInnerLeft.right() && vInnerRight.left()) {
          // ...
          if (shift > 0) {
              let _ancestor = ancestor(vInnerLeft, v, default_ancestor)
              move_subtree(_ancestor, v, shift);// ++
              // ...
            }
            // ...
      }
      // ...
    }
    return default_ancestor;// ++
}

const execute_shifts = (v) => {
    let change = 0
    let shift = 0
    // 從後往前遍歷子節點
    for(let i = v.children.length - 1; i >= 0; i--) {
      let node = v.children[i]
      node.x += shift
      node.mod += shift

      change += node.change// change一般為負值
      shift += node.shift + change// 越往左,節點新增的shift值越小
    }
}

const move_subtree = (leftV, v, shift) => {
    let subTrees = v.number - leftV.number// 索引相減,得到節點之間被分隔的數量
    let average = shift / subTrees// 平分偏移量
    v.shift += shift// 完整的shift值新增到v節點的shift屬性上
    v.change -= average// v左邊的節點從右往左要新增的偏移量是遞減的,所以是加上負的average
    leftV.change += average// v.change減了average,為了不影響leftV左側的節點,這裡需要恢復

    // ...
};

接下來以下圖為例來看一下這個過程,假設P節點最終計算出來的shift = 3,那麼P.number - G.number = 4 - 1 = 3,中間節點分攤的值3 / 3 = 1,節點GP之間的節點要距離相等的話,H需要向右移動1H2要移動1 + 1,這樣它們的座標為1,3,5,7,等差數列,間距相等,如果還有更多節點,以此類推,因為越右邊的節點移動了本身的1後,還被前面的n個節點向右推了n * 1,我們把這兩個值儲存到節點GP上:

image.png

然後執行execute_shifts方法從後往前遍歷Q的子節點:

1.change=0shift=0,首先更新最後一個節點P2P2.xP2.mod加上shift,即加0,更新changechange + P2.change = 0 + 0 = 0,更新shiftshift + P2.shift + change = 0 + 0 + 0 = 0

2.更新P節點:P.xP.mod加上shift,即加0,更新changechange + P.change = 0 + (-1) = -1,更新shiftshift + P.shift + change = 0 + 3 + (-1) = 2

3.更新H2節點:H2.xH2.mod加上shift,即加2,更新changechange + H2.change = -1 + 0 = -1,更新shiftshift + H2.shift + change = 2 + 0 + (-1) = 1

4.更新H節點:H.xH.mod加上shift,即加1,更新changechange + H.change = -1 + 0 = -1,更新shiftshift + H.shift + change = 1 + 0 + (-1) = 0

5.更新G節點:G.xG.mod加上shift,即加0,更新changechange + G.change = -1 + 1 = 0,更新shiftshift + G.shift + change = 0 + 0 + 0 = 0

6.更新G0節點:G0.xG0.mod加上shift,即加0,更新changechange + G0.change = 0 + 0 = 0,更新shiftshift + G0.shift + change = 0 + 0 + 0 = 0

以上就是譯者馬後炮式的理解,最終效果:

image.png

xy交換一下:

image.png

實現思維導圖

上述演算法還是不能直接應用於思維導圖的,因為前面考慮的樹每個節點的大小都是一樣的,而思維導圖每個節點的寬高都是有可能不同的,需要在上述演算法的基礎上進行一定修改,因為本文已經很長了,所以就不細說了,線上示例https://wanglin2.github.io/tree_layout_demo/,完整程式碼在https://github.com/wanglin2/tree_layout.

image.png

參考連結

1.原生javascript實現樹佈局演算法

2.樹型介面繪製演算法(二)簡單明瞭的First-Second

3.樹型介面繪製演算法(三) 磨人的apportion

4.樹形介面繪製演算法(小結)

5.A Node-positioning Algorithm for General Trees

相關文章