維諾圖之平面掃描法

維諾圖(Voronoi Diagram),簡單來說,是一種平面區域的劃分方式。假設平面上有 n 個點:P1 ~ Pn,那麼對應維諾圖則劃分成 n 個區域:S1 ~ Sn,並且 Si 內所有點到 Pi 的距離小於等於到其他任意點的距離。維諾圖還經常和德洛內三角(Delaunay 三角網)扯上關係,德洛內三角是一系列相連不重疊的三角形集合,特點有兩個:1、任意三角形的外接圓不包含面內其他三角形頂點,2、相鄰兩個三角形構成的凸四邊形,交換對角線,六個內角的最小角不會增大。如下圖,實線構成德洛內三角,虛線構成維諾圖,德洛內三角每一個三角形的頂點便是維諾圖的初始點集。

理論上,兩種圖形可以互相轉化。1、生成維諾圖後,連接有公共邊的初始點集,便可構成德洛內三角。2、生成德洛內三角後,針對每一個三角形邊,生成垂直平分線,並將垂直平分線的端點設置爲三角形邊所在外接圓的圓心即可(內部三角形邊的垂直平分線爲線段,邊界三角形邊的垂直平分線爲射線)。

下面先推薦幾篇我看過寫得比較好的學習資料,可以用於參考。(吐槽:這幾篇是我從海量博客中篩選出來的,網上能搜出一堆相關博客,可真正有內容的卻沒有幾篇)

1、http://www.cnblogs.com/zhiyishou/p/4430017.html 講解如何生成Delaunay三角網的博客。這篇文章是講解地比較細,比較全,比較容易理解的,不過同樣有很多坑,閱讀時不要遺漏了博客評論區,那裏指出了很多坑點。最坑的一點,即使你排除萬難,寫出了和作者一模一樣的代碼,仍然有很多BUG,比如點數少的情況下有可能沒有任何生成,比如最終生成的德洛內三角不是凸包等。當然,文章確實好,值得一看,用來理解德洛內三角是很有幫助的。

2、https://en.wikipedia.org/wiki/Fortune%27s_algorithm 講解如何生成維諾圖的維基百科,有個gif 圖可以幫助理解,中間那段英文的算法描述寫得很好,讀下來大致就有了生成維諾圖的思路(英文不好的可以百度翻譯一下)。下面還附了個僞代碼,不過這僞代碼我是完全看不懂了,百度翻譯也不管用了。文章末尾還附加了幾個算法源碼,不過不推薦閱讀,代碼可讀性太差了,反正我是啃不下來,後面會推薦一個寫得比較清楚的源碼。

3、https://www.cnblogs.com/Seiyagoo/p/3339886.html 講解如何生成維諾圖的博客,內容比較少,不過比較清晰,附加的僞代碼也比較容易理解,建議有了大致思路後根據這篇博客來完善代碼。

4、https://www.cs.hmc.edu/~mbrubeck/voronoi.html 提供源碼的博客,其他很多博客都只是簡單介紹方法,而像codeproject 或Wikipedia 上的源碼可讀性太差(不是我吐槽,是真的太差,完全看不懂),而這篇博客提供的代碼很適合用來學習,定義清晰明瞭,雖然代碼效率達不到logn,但這僅僅是存放海岸線的數據結構差異,其餘內容與平面掃描法一致,可以參照該源碼去解讀推薦的第三篇博客。不過該源碼也有些BUG,有一些特殊情況會返回不正確的維諾圖。

下面就介紹通過平面掃描法來生成維諾圖,首先介紹平面掃描法的幾個基礎定義:

1、掃描線:掃描線將從 y = 0 一直掃描到 y = maxY,掃描完成後,維諾圖也將生成完畢。(上圖的黑色線)

2、海岸線:由多段拋物線組成,拋物線的焦點是初始點集,準線爲掃描線。(上圖的藍色線)

3、站點事件:掃描線掃描到了某個初始點。

4、圓事件:掃描線掃描到了某個圓(三個站點共圓)的最低點。

算法思路:

我們需要維護一條掃描線和一條海岸線,這兩條線都隨着程序的運行,通過整個平面。掃描線是一條直線,我們可以假定它是水平的,在平面上從上到下地移動。在算法運行期間,掃描線上方的初始點已經被納入Voronoi圖,而掃描線下方的點暫未考慮。海灘線不是一條直線,而是一條複雜的多段曲線,位於掃描線的上方,它將已經確定的區域和未確定的區域分割開,即不管後續還有多少個點,海岸線上方的維諾圖都已經是確定的了。對於掃描線上方的初始點,我們可以定義距離該點和掃描線等距的點的曲線(即以該點爲焦點,以掃描線爲準線的拋物線),海岸線就是這些拋物線並集的邊界。隨着掃描線的推進,海岸線中相鄰拋物線交點(相交的點)將勾勒出維諾圖的邊。海岸線也隨着掃描線的推進而推進,始終保持其上的點到焦點的距離和到掃描線的距離相等。

該算法使用了排序二叉樹來維護海岸線,使用優先隊列來維護可能引起海岸線變化的事件。這些事件包括新增拋物線到海岸線(掃描過一個初始點,稱之爲站點事件)和從海岸線中刪除某一條拋物線(這條拋物線縮小成一個點時,即海岸線中相鄰的三個拋物線焦點生成的圓與掃描線相切,稱之爲圓事件)。每一個事件可以用發生該事件時的掃描線 y 座標來確定優先級。然後,我們要做的就是反覆從優先隊列中取出事件,進行處理,可能會影響到海岸線結構,可能會新增圓事件。

所以,該算法的重點便是如何處理站點事件和圓事件。首先看站點事件,當掃描線遇到 P4 時,過 P4 做掃描線的垂線,垂線和海岸線相交點到 P4 和 P2 距離相等,當掃描線越過 P4 時,將生成一條以 P4 爲焦點,掃描線爲準線的拋物線,該拋物線和 P2 對應的海岸線相交於兩點,這兩點會隨着掃描線的移動而分離,事實上,這兩點將勾勒出同一條維諾圖邊(可以確定該邊上點到 P4 和 P2 距離相等)。P4 拋物線與 P2 拋物線交於兩點,會將 P2 拋物線分割成兩段拋物線,命名爲S1、S2,假設 P4 下方沒有新的站點,隨着掃描線繼續移動,P4 對應拋物線將越變越寬,可能會將原先 S1、S2 擠兌沒,即 S1 可能由一段拋物線縮小成一個點,而 P1 拋物線和 S1 的交點,與 P4 拋物線和 S1 的交點重合,這便產生了一個圓事件,即縮小成的那一點到 P1、P2、P4 距離相等。當然程序不可能做到一點一點的移動掃描線,所以生成圓事件,是在遇到 P4 的一瞬間決定的,即遇到 P4 時,判斷 P1、P2、P4 是否共圓。接着我們來看圓事件,當發生了圓事件後,就說明有某一段拋物線縮小成一個點,所以我們就需要將該段拋物線刪除,並生成已經確定下來的維諾圖邊。 

維諾圖的目標是找出所有站點對應區域的邊,而邊是隨着掃描線移動,由相鄰拋物線交點勾勒出來的,所以我們把邊記在弧上,一條弧左右各有一個交點,所以我們每條弧記兩條邊S0、S1。當發生站點事件時,先找到其正上方的弧,然後這條弧中間部分將被新的拋物線取代,即由一段弧變成兩個交點Inter1、Inter2 和 三段弧Arc1、Arc2、Arc3,其中Arc2 是新增的弧,Arc1、Arc3 是原弧分裂出來的,所以Arc1 的S0 繼承自原弧的S0,Arc3 的S1 繼承自原弧的S1,Arc1 的S1 與 Arc2 的S0 將指向根據左交點創建的一條新邊,Arc2 的S1 和Arc3 的S0 將指向根據右交點創建的一條新邊。當發生圓事件時,弧Arc 消失,所以 Arc 的S0、S1 將完成構造,即S0、S1 的終點設置在圓事件的圓心,而 Arc 消失後,其前置弧和後置弧相交在一起,所以前置弧的S1 和後置弧的S0 將指向根據圓心創建的一條新邊。當所有事件處理完畢後,我們需要假設一條掃描線,使剩餘的所有相鄰弧交點位於維諾圖邊界外,計算此時這些交點的位置,完成剩餘的維諾圖邊。

數據結構:

1、我們需要按 y 順序遍歷站點和圓事件,所以引入優先隊列這個數據結構(Y 越小越靠前,Y 相同則 X 越小越靠前)。

2、我們需要快速獲取某點正上方的拋物線,所以引入排序二叉樹,排序二叉樹每一個葉子結點代表一段拋物線,每一個內部結點代表相鄰拋物線的交點,排序依據就是交點的 x 座標,從而可以用logn 的時間,快速獲取某點正上方的拋物線。(排序二叉樹可能會退化成鏈表,真正使用的時候可以考慮是否可以替換成平衡二叉樹)

3、我們需要快速檢查相鄰的三段拋物線對應焦點是否共圓,所以引入雙向鏈表,用於管理排序二叉樹的葉子結點,即每個葉子結點記錄上一片葉子和下一片葉子指針。

源碼鏈接:僞代碼可以看上面的第三篇博客,或者直接閱讀下面的源代碼,註釋應該是比較全了。該源碼僅用於交流學習,內部有挺多細節沒有考慮最優的算法。

https://github.com/hchlqlz/VoronoiDiagram

歡迎大家指出源碼 BUG。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章