用 DOM 與 CSS 展示二叉樹

發表於2017-06-22

本文內容較長,主要涉及如下內容:

  • 二叉樹及相關演算法
  • Flexbox 佈局
  • CSS 背景圖片,計數器等應用
  • 使用 SVG 做為背景圖片會遇到的問題

最近在講各種與樹相關的演算法與題目時,為了給學生演示,總是想要看到樹的結構,總是畫出來又慢又醜,在控制檯裡展開看的話實在太麻煩,而且不夠直觀

我就尋思能不能把樹給展示在頁面裡。

什麼是二叉樹

首先看一下二叉樹的定義:
一顆二叉樹是由一個根結點和一個左子樹和一顆右子樹組成的結構,其左右子樹分別又是一顆二叉樹。

畫成圖就是下面這種形狀:

關於二叉樹的更多內容已經超出了本文的討論範圍,有興趣的同學可以自行維基百科或者找其它相關的資料。

如何展示二叉樹

現成的工具當然也有不少,比如 LeetCode 的自測資料輸入框。一開始我也想要不自己做個這樣的好了,但是細細想,感覺還蠻複雜的,每層的樹的數量不確定,而且越往下層樹的結點越多,真要想通過一顆樹生成一個漂亮的圖片,不管是 SVG 還是畫在 Canvas 裡,都是相當複雜的。

這是其一,其二是展示成圖片的話還不利於互動,萬一以後想要與展示出來的結構做些簡單的互動,圖片很顯然是不行的,Canvas 實現互動需要計算座標;SVG 雖然可以為結點繫結事件,但 SVG 的另一個問題是元素之間不能巢狀,雖然有 g 標籤,但 g 標籤其實只是對 SVG 中的標籤進行分組,而不是實現樹狀(或遞迴)的巢狀,所以想要容易的在 SVG 裡畫出樹也並不會比在 Canvas 裡容易,一樣需要計算每個結點的大小和座標。

於是我就想,能不能用 HTML 跟 CSS 來顯示一顆樹的視覺結構呢?畢竟 CSS 可以方便的實現多級選單,而多級選單的本質其實就是多叉樹。

經過簡單的分析,我總結出如下幾點:

  • 首先,DOM 的結構就是樹狀的,用它來顯示同為樹結構的二叉樹應該是相當容易的
  • 第二,目前 CSS 有非常強大的佈局功能,用上所有 CSS 最新的功能,可以很容易的實現非常靈活的佈局
  • 第三,用 DOM 來展示樹結構,可以很方便的實現互動

從二叉樹的定義來看,它是一個遞迴結構,根結點的左子樹與右子樹分別又是一顆二叉樹,所以只要把一顆樹考慮成其根結點、左子樹和右子樹就可以了,而左右子樹的結構跟根結點一樣,就像級聯選單一樣,那麼不難構想出如下 DOM 結構:

其中左子樹與右子樹的 DOM 結構依然是你上面看到的這種,由於左右子樹自身已經被一個 div.tree 元素包著,所以上面的結構其實並不需要裡面的兩個 div,而且去掉兩個額外的 div 會在後面為我們帶來一些便利,我們可以方便的用 CSS 僅選擇表示葉子結點的 span 元素:span:only-child。

那麼前面那顆二叉樹如果按照上面的結構寫成 DOM 將會是下面這樣的(為了方便觀察,把結點用【】括起來了):

光有這個結構當然是看不出其樹形結構的,還得考慮如何用 CSS 展示它。很明顯,對於樹,我們需要按如下形式展示它————根結點的值位於左子樹與右子樹的上方且居中,左右子樹平分下方的空間:

根結點獨自在一行上佔用全部的水平空間,左子樹與右子樹平分左右的空間,所以 span 元素的寬度應該要是 100%,左右子樹的 div 寬度分別為 50%,這裡必需要使用百分比的單位來佈局,因為越往下層樹的結點越多,每個結點的空間就越小,用絕對長度單位肯定是行不通的。

由於左右子樹分別又是一個 div.tree,而且它們需要展示在左邊和右邊各佔一半的空間,所在這個 div.tree 必須要能自適應其可用空間,放在多寬的位置它就展示多寬,這樣一來,頂層的 div 結構(即根結點)也能自動佔滿可用空間。

當然是用 flex 佈局了,雖然傳統佈局手段也可以做到想要的效果

對於前面那顆樹,展示出來後有點奇怪,右子樹都往下偏了一些,就像下面這樣:

點選檢視實時 Demouser-images.githubusercontent.com

究其原因,是因為 flex 佈局中元素在側軸上預設會拉伸,這個好辦,給所有的 flex 父元素(即 div.tree)加一個 align-items: flex-start; 就可以了。

每層結點之間有點太近,這個好辦,給 span 元素加點高度就可以了。

於是乎我們得到了如下視覺效果的二叉樹,看起來很不錯!

點選檢視實時 Demouser-images.githubusercontent.com

但還差一件事,那就是父子結點之間的連線,這個好像不太好辦,雖然可以使用邊框生成斜線,但真心不太好控制;當然,還可以使用 2D 變幻來實現,但計算量還是有的,主要在於不同層級的連線,傾斜程度不同(如下圖),能不能找到一個簡單點的辦法顯示結點間的連線呢?

user-images.githubusercontent.com

通過觀察我們注意到父結點與兩個子結點的相對位置比例總是保持不變的,如果把一顆樹佔用的水平寬度計為 100%,那麼父結點總是在上方 50% 的位置,而兩顆子樹的根結點總是在下方 25% 和 75% 的位置。

所以只要能夠實現一個能夠隨元素自動按比例拉伸的效果就可以了。

很顯然,背景圖片可以滿足我們的這個要求,而且足夠簡單。

事實上我們只需要一張像下面這樣的倒 V 型的圖片即可:

然後把它展示為 span 元素的背景圖片,再做些簡單的位置和大小調整就可以了!由於這張圖片要佔用一定的空間,span 元素的高度也要相應增加一丟丟:

為了方便演示,我給這張圖片打了些底色以方便觀察:

user-images.githubusercontent.com

程式碼也很簡單,使用背景圖片把元素設定上去就可以了:

由於圖片中的倒 V 的頂點總是要在結點文字的下方,這裡使用了 background-size 以及 calc 設定了圖片的高度總為 100% – 1em,再合適 background-position 讓圖片從頂部往下偏 1em 的距離;當然,因為垂直方向上圖片的高度是固定的,所以給圖片留白也是行的。

大功告成!

處理多餘的連線

等一下!最後一行的葉子結點怎麼還有多餘的連線?

理論上這也不過分,因為葉子結點實際上就是左右子樹為空的二叉樹,這麼畫出來,沒毛病!

不過做為強迫症患者,我是無法接受這種效果的,再說,也從來沒人會這麼畫二叉樹。

於是乎前面我們設計的 HTML 結構派上用場了,對於葉子結點來說,它不再有兩個 div 的兄弟結點,所以使用 span:only-child 選中它然後把它的背景圖片隱藏就可以了!這也為什麼我要把用於表示連線的背景圖片設定到 span 元素上的原因。

顯示效果如下:

user-images.githubusercontent.com

只有單邊子樹的情況

你們以為這樣就又大功告成了嗎?

如下這顆樹的展示就有問題了:

首先,7 是 3 的右子樹,但它卻展示在 3 的左邊,原因也很明顯,因為表示 7 這個顆的 div 是從左往右展示的,這時我開始懷念 float 了,如果佈局是使用 float 實現的,那麼給用於表示左子樹的元素一個左浮動,給表示右子樹的元素一個右浮動就可以了,這樣即使只有單邊的子樹,它們也會自動顯示在一邊。然而我們使用的是 flex 佈局。不過 flex 佈局一樣有辦法實現這種效果,比如說 align-self,不過遺憾的是它是在側軸方向控制位置的。

想要實現讓左子樹往左偏的效果,我們可以讓表示右子樹的 div 元素的 margin-left 為 auto(同理讓表示左子樹的 div 元素的 margin-right 為 auto),在 flex 佈局中,如果一個 flex 子元素的在主軸方向上的 margin 為 auto 且該方向還有空間,而且元素自身沒有 flex-grow 的話,這個 margin 會盡量的大,可以方便的用這個特性來實現元素的居左或者居右,也可以實現居中。

其次,在頁面的展示中,因為【4】所在的 span 元素總是有一個倒 V 型的背景圖片,所以它總是會展示它與其左右子樹的連線,即使它並沒有右子樹,同樣的情況也發生在【3】這個結點上,它展示了與其不存在的左子樹的連線。

這當然也是不能接受的,要怎麼辦呢?

如何選擇【只有左子樹】或者【只有右子樹】的樹中的 span 元素呢?

比較奇技淫巧的做法是給表示左子樹與表示右子樹的元素分別加上相應的類,比如 div.tree.left,div.tree.right,然後把 span 元素放在 div 的後面,然後當一顆樹只有左子樹時,其結構就是這樣:

然後使用 order 屬性把 span 調到前面,通過 div.left:first-child + span 選中只有左子樹的 span 元素,然後把它的背景圖片調整成相應的只有向左方連線的圖片即可,右子樹也類似。

但這樣總感覺怪怪的,而且如果要實現互動功能的話可能會有些問題,畢竟 DOM 順序不大對勁。

更簡單的做法是,如果一顆樹只有左子樹或者只有右子樹,我們給它加上額外的一個類比如 only-has-left,only-has-right,這樣就可以很容易的選中不同情況的 span 了:

這樣一來,總算離大功告成又進一步了!!!

自動生成二叉樹的 HTML 程式碼

最後,我們不可能手寫出上面的 HTML 結構,而是用程式生成出上面的巢狀 HTML 結構:給定一顆樹,程式自動構建出上面說到的 HTML 結構,看起來好像很複雜,其實熟悉樹的相關演算法的話,這個小函式是很好寫的:

解釋一下,我們根據一顆樹是否有左子樹、右子樹、或者兩顆子樹都有或都沒有,來為它加上相應的 class,以方便我們選擇其內的 span 元素:

但是,這樣並沒有大功告成,很多細節上還是不夠完美。

小問題比如說,樹中各層之間的連線會隨著層次的加深而變的更粗(從前面的示圖中是可以看出來的),原因也是很明顯的,越往下層,展示的空間越小,而背景圖片總是被壓縮的顯示到那個空間中,線就會顯得比較粗。

大的問題比如說,如果給定的一顆樹非常的不平衡(平衡樹的意思就是一顆樹的根結點及任意子樹的兩顆子樹的高度之差都不超過 1),那麼我們的展示效果也非常差,會一直往一邊擠。類似下面這樣的效果。而 LeetCode 的展示中,能夠很好的適應這種情況。

user-images.githubusercontent.com

這兩個問題看起來都不太好解決。

先說第一個,使用可能被壓縮的圖片當做背景圖片肯定是行不通了,使用邊框或者變幻來模擬我們也不考慮。要是有一張圖片設定為背景後不會被壓縮,而其中的線條可以按圖片大小的百分比顯示就好了。

很容易想到使用 SVG 圖片來做為背景圖片,然後使用 <line/> 標籤來生成結點間的連線,然後我寫出瞭如下簡單的 SVG 程式碼:

然後把它展示為 span 元素的背景圖片,但是得到的效果並不能讓我們滿意,線條還是會隨著層次的往下而變的粗起來(圖就不貼了)。也就是說 SVG 影象還是被拉伸了。

而實際上我們想要的是 SVG 的不位伸大小就與 background-size 所設定的大小一樣,盲目的試了幾下後我發現好像並不太容易調成功,甚至不確實能否實現我們想要的效果;最終我找到了這個文件:Scaling of SVG backgrounds,裡面詳細講述了 SVG 在做背景圖片時,其是被變形拉伸還是會讓自身尺寸變為 background-size 所設定的大小。情況比較多,我就不在這裡解釋了,有必要的話各位可以自行閱讀該文件。

最終的結果是隻要不給 SVG 圖片設定明確的寬高,它的大小就將是 background-size 的大小,於是 SVG 圖片的原始碼如下(與上面的區別就是去掉了 svg 標籤的 width 與 height 屬性):

這樣一來,解決了不同層級連線粗細不一樣的問題,最終的效果就是前面的某張非常對稱的截圖。

下一個問題,樹過於不平衡時的展示問題。

如果某一個結點沒有左/右子樹,那麼按照目前的展示方法,不存的子樹還是會佔用下方整整一半的空間,最終會導致不平衡的樹展示效果較差。

其實這個也不難辦,當一個結點只有一顆子樹時,讓這顆子樹佔用下方几乎所有的空間就可以了(之所以不是所有的是為了呈現出一種向一邊偏的效果),比方說對於一個只有左子樹的結點來說,其內部只有表示左子樹的 div 結點,讓這個結點的寬度為 90% 即可,剩餘的 10% 留白,可以簡單的使用 margin-right: 10% 來實現(此時 10% 取的也是父元素的內容寬度),其實不寫或者寫成 auto 也可以。

但這樣一來如果繼續使用之前的連線,就對不齊了,這個好辦,換一種連線就可以了,可以算出,線的起點在上方 50% 處,而終點在下方的 45% 處(即左邊 90% 空間的中點),對於只有右子樹的情況來說,終點則是在下方 55% 處(右邊 90% 空間的中點)。

最終上面那顆非常不平衡的樹會展示成如下效果:

user-images.githubusercontent.com

看起來好多了。

到這裡,我們處理了遇到的幾乎所有問題:

  • 讓沒有子樹的結點不展示連線
  • 讓只有左/右子樹的結點只展示單方向的連線
  • 讓各層之間的連線粗細相同
  • 讓只有單邊子樹的元素的單邊子樹佔用更大的空間

但是還有最後一種情況我們沒有處理,即如果一顆樹有左子樹且左子樹依然有後代子樹,而右子樹沒有後代子樹,我們的程式碼還是會讓這兩邊的子樹佔用相同的空間,實際上此時右子樹也應該只佔用很少的空間。考慮到此文篇幅已經很長,這個優化我們就不在此文討論了,留給讀者自己思考吧。

最後,完整的 Demo,原始碼中有註釋:xieranmaya.github.io/bl

相關文章