計算幾何入門 1.6:凸包的構造——Graham Scan算法

上文簡要分析出了凸包構造問題算法的下界:O(nlogn),在此就引入一種下界意義上最優的算法:Graham Scan算法。這種算法可以保證在最壞情況下時間複雜度也不超過nlogn。我們先大致瞭解一下算法的流程,然後通過一個例子深入算法的細節,最後給出理論性的分析。

 

一、Graham Scan算法流程

假設待處理點集S共有n個點。

 

Graham Scan首先要做的是一個預處理排序操作(presorting)。即找到某個基準點,然後將其餘所有的點按照相對於基準點的極座標排序。如下圖:

點的排序可以套用任意排序算法的框架,只是將排序對象由數值變爲了平面上的點,而比較器改爲to left test實現。

 

以點1爲基準點,其餘點按照相當於點1的極角依次排序爲2、3、4......理論上講任何一個點都能當第一個基準點,爲了簡化算法通常選擇lowest-then-leftmost point(LTL)作爲基準點。

 

然後對於與基準點1極角最小的點,也就是圖中點2(假設沒有三點共線的情況)。將點1和點2作爲算法的起始點。

 

再來看Graham Scan用到的數據結構。整個算法非常簡明,核心數據結構只有兩個,分別記作棧S和棧T。便於理解我們將S和T畫成開口相對的形式,如下圖:

算法開始前先將起始點1和2入棧S,其他的n-2個點入棧T,如上圖。注意S和T中元素的入棧順序。至此presorting已經完成。

 

完成預處理之後,就能開始算法的核心:scan操作。scan的過程中要時刻關注三個點:棧S的棧頂(S[0])、次棧頂(S[1])和棧T的棧頂(T[0])。也就下圖紅色標註的三個點:

算法的總體框架爲:

 while( !T.empty() )    //檢查棧T中所有點

        if ( toLeft( S[1], S[0], T[0]) )    //判斷點T[0]位於邊S[0]S[1]的左邊還是右邊,

                S.push( T.pop() );     //左邊則將T棧頂推入S,即向前擴展一條邊

        else

                S.pop();    //右邊則彈出S棧頂點,即回溯,將此前認爲是極點的點丟棄

 

可以觀察到,每次待處理的S[0]和S[1]構成的邊一定是一條極邊(如上圖點1和點2),算法關鍵步驟就是對邊這條極邊和T[0]做to left test,判斷T[0]位於邊S[0]S[1]的左邊還是右邊。若在左邊則繼續拓展,若在右邊則否定掉此前認定的極邊。無論結果如何,每次判定都會將問題規模縮小一個單元,算法結束時T最終肯定爲空。T空後,S中存留下的點正是凸包的極點,這些點自底而上正是凸包邊界點的逆時針遍歷,也得到了整個凸包構造問題的解。

 

先來看一個最簡單的例子,即點集S中所有的點都在凸包邊界上。如下圖:

先找到LTL,也就是圖中點1。然後基於點1對其餘點按極角排序爲點2、3、4......(實際上以一個點爲中心的有序的點集,構成了所謂的星形多邊形(star-shaped polygon),中心點正是星形多邊形核(kernel)的一部分。凸多邊形必然是星形多邊形,反之則不然。)然後找到點1的後繼2,點1和點2構成第一條極邊。初始化棧S和棧T。

 

現在要關心S[1], S[0]和T[0],就是點1,2和3。點3位於邊12左側,to left關係爲true,S.push( T.pop() ),向前拓展了一條暫定極邊。

接下來重複上述過程。考慮點2,3和4。to left關係爲true,S.push( T.pop() )......最終棧T空,算法結束,凸包由棧S自底向上得到。S和T的變化過程如下圖:

===>===>===>

 

二、舉例

上面列舉了最簡單的情況下Graham Scan的過程,接下來列舉一個更有代表性的實例深入算法的細節。

 

輸入的點集S,並進行預處理排序,並初始化棧S、T,如下圖:

接下來對點1,2和3進行to left測試,本質上就是判斷邊2→3(圖中黃色邊)能否被暫時採納。測試結果爲true,暫時採納邊2→3,S.push( T.pop() )。如下圖所示:

注意圖中藍色邊表示已經被暫時接納的邊,也就是算法暫時認定的極邊。上一次操作將藍色邊推進一個單元,接下來關注點2,3和4,來判斷下一條黃色邊3→4能否被接納。to left測試爲true,S.push( T.pop() ),接納邊3→4。如下圖右側所示:

然後判斷點3,4和5。點5在邊3→4的右側,即to left測試爲false。S.pop(),也就是判斷出點4不可能爲極點,丟棄4。因此算法回溯到點3,判斷點2,3和5的關係。5在2→3的左側,暫時接納邊3→5,S.push( T.pop() )。如下圖:

算法經歷了無效操作,進行了回溯,得到了目前來說最優的“極邊”。雖然這些”極邊“不一定能最終保留,但問題的規模得到了削減。

 

下一次scan考察的就是3,5和6了。

3,5和6的to left測試爲false,S.pop(),捨棄點5。然後考察點2,3和6,to left測試爲false,S.pop()捨棄點3。如下圖:

考察點2,6和7,點7在邊2→6左側,暫時接納邊6→7,S.push( T.pop() )。然後考察點6,7和8。

點8在邊6→7右側,S.pop(),捨棄點7。然後考察點2,6和8,如下圖:

點8在邊2→6左邊,S.push( T.pop() ),接納邊6→8。

此時棧T已經空了,算法結束。棧S自底向上依次爲1、2、6、8,也就構造出了凸包。

 

三、算法正確性

瞭解了算法的整體流程之後,我們再來論證一下算法的正確性。證明一個算法正確性的方法有很多,在此選用數學歸納法。數學歸納法的思想可用多米諾骨牌類比,要做的無非是兩件事:證明第1張骨牌會倒;證明如果第n張骨牌會倒則第n+1張骨牌也會倒下。

 

Graham Scan過程就是一個個引入點的過程。每當我們得到第k個點的時候,算法所得到的就是前k個點對應的“最好的凸包”。因此當k = n時得到的是整體的凸包。

 

歸納的第一步就是證明k = 3時得到的是當前點集S‘ = {1,2,3}中的極邊,也就是證明第1張骨牌會倒。顯然邊1→2是S’的一條極邊。而根據預處理的方式,3相較於1的極角一定小於2,因此點3一定在邊1→2的左側,因此邊2→3會得到保留。對於三個點來說,任意兩條邊一定都是極邊,2→3也是一條極邊。

 

然後證明:假設已經處理到第k個點,得到的是前點集S' = {1,2,3,...,k}中所謂“最好的凸包”。根據算法處理方式,接下來從S'' = {1,2,3,...,k,k+1}得到的結果是否也是正確的。也就是證明第n張骨牌會倒則第n+1張骨牌也會倒下。

 

預處理的方式是對2-n所有點相較於點1按極角排序,因此下一個要處理點k+1一定出現在線1→k的左側,也就是下圖藍色區域和綠色區域(假設k = 9):

而根據目前接納的最後一條極邊 k-1→k (例如圖中8→9)來劃分,點k+1可能出現的區域又分爲兩塊,即該極邊的左側(綠色區域)和右側(藍色區域)。這也正對應於算法判定的兩個分支。

 

左側的情況很簡單,點k+1顯然會是一個新的極點。Graham Scan要做的正是暫時接納邊k→k+1,拓展了一個新的單位。

 

再看k+1落在右側的情況。如下圖點10:

Graham Scan要做的是丟棄點k(圖中點9),也就是判定出點k不可能是極點。這樣做的原因:是引入點k+1後,點k一定會被包含在三角形(1, k-1, k+1)內部。如圖中點9一定包含於三角形(1, 8, 10)內部。正如極點法中排除非極點的做法,點k被排除是正確的做法。接下來點k-1,k-2等(如圖中點8,點7等)也可能是非極點,按照算法的流程,它們總會被判定在某個三角形的內部(例如點7在三角形(1, 5, 10)內部)而被排除,直到left test爲true,回溯停止。

 

換個角度考慮,回溯停止時得到的新邊正是增量構造法中每步得到的support line,即切線。例如圖中線5→10正是算法當前保留的”凸包“的切線。這也能論證Graham Scan處理方式的正確性。

 

至此,算法思路上的正確性已經證明完畢。

 

接下來還要考慮算法的表述方式是否有漏洞:代碼中每次to left test之前並沒有判斷S棧中是否有≥2個元素。這也可以由預處理的方式來論證。點1選取的是LTL,而點2是相對於點1極角最小的點,這樣的做法保證了除了點1和點2之外所有的點一定是在邊1→2左側的。因此算法回溯最多到點2,永遠不可能把點2丟棄,S中元素任何時候至少有兩個。

 

Graham Scan算法的正確性論證完畢。

 

最後來思考一下預處理操作:presorting。仔細回顧上述論證過程會發現,每一步的正確性都是建立在最初的排序上的。那麼這個預處理排序真的是必要的嗎?可以來舉極端的反例,每次選取下一個點都是隨機的,例如下圖的路徑:

上圖中從點1開始出發進行to left測試,可以發現,每次判定結果都爲true,最終所有的點都被保留了下了,而顯然這並不是一個凸包。因此presorting是整個算法成立的基礎。

 

四、算法分析

上面證明了Graham Scan算法的正確性,接下來分析其複雜度是否滿足O(nlogn),實現所謂的最優算法。

 

直觀上無法斷定Graham Scan是一個最優的算法,尤其是以下極端情況令人質疑其效率:

算法複雜度由三部分決定:

 

  • persorting,採用一般排序算法,複雜度是O(nlogn)
  • 逐步迭代,O(n)
  • scan,O(?)

 

算法的總體複雜度就是O(nlogn + n * ?)。可見scan的複雜度決定了算法總體的複雜度

算法一步步納入新點,會迭代n步。但是在每個點上都有可能做回溯操作,所以scan的複雜度是不確定的。我們來以上圖最壞情況爲例,到第8個點時判定爲false,捨棄點7,回溯。下一步判斷也爲false,捨棄點6,回溯。如此回溯直到算法開始的點2。這次scan倒退了高達O(n)個點,如果每次scan都是如此那麼算法整體複雜度就爲:O(nlogn + n * n) = O(n^2)了,那這種算法的意義也就不大了。

 

其實上述分析並非錯誤,只是不夠精確。O(n^2)確實是Graham Scan算法的一個上界,但是這個上界並不是緊的。問題就出在分析假定了每次都會出現回退高達O(n)個點。

 

下圖展示了整個Graham Scan的流程:

圖中黃色邊是沒有采納的,就是to left測試判定爲false後直接捨去的。紫色邊則是曾經被認爲是極邊而接納的,後來經過回溯又捨去了。無論是黃邊還是紫邊,在其上耗費的都是常數時間,關鍵就在於黃色邊和紫色邊的數目了。

 

通過觀察可以發現,從圖論的角度看,所有的黃色邊和紫色邊連在一起構成了一張平面圖,也就是它們互相是不可能內部相交的。平面圖的一個重要性質:

平面圖中所有邊的數目和頂點數目保持同階

這個性質來自歐拉公式:有n個點的平面圖,邊的數目上限是3n,也就是O(3n)

 

根據這個性質,在persorting之後的整個流程中,Graham Scan所能走過的所有邊不僅不會到達n^2,而頂多到達和n同階的一個線性數目。因此整個算法的複雜度也就取決於persorting的O(nlogn)了。

 

五、算法推廣*

Graham Scan算法不僅可以用於凸包構造問題,在其他許多場景下中也十分有效。爲了推廣Graham Scan算法,首先可以對其做簡化,以方便利用在其他問題。

 

首先再來回顧一下預處理排序,這是算法成立必不可少的一步。排序算法套用成熟的方法即可,利用數學方法計算偏角不僅複雜而且引入了誤差,所以要採用to left test。要做的就是兩點:

 

  • 套用成熟的排序算法,將待排序元素由數值變爲點
  • 將排序算法的比較器改爲to left test實現

按照這樣的流程就能間接地實現persorting。

 

 

有時候我們並不是從零開始構造凸包,例如得到的待處理點集已經是有某種次序的(比如已經按x座標大小排序,如下圖)。

這種情況也不一定非得進行persorting構造新的次序,通常改變觀察的角度,換一種理解方式就能免去預處理而直接進行後面的線性的scan操作了。

 

考慮y軸負方向無窮遠一個點,所有的點相對於這個點的極角排序恰好就是各點的x座標序!也就是將無窮遠的點看作起始點①,最右側點(圖中點8)看作點②,進行scan過程直到最左邊的點(圖中點1)結束,就得到了凸包的上半部分(upper hull),也就是下圖的8→7→2→1:

下半部分凸包(lower hull)的構造也是如此。考慮一個在y軸正方向無窮遠的一個點,以此爲起點進行scan,最終得到lower hull:1→4→7。最後將兩個凸包合二爲一即可。

 

本文是學堂在線課程《計算幾何》的筆記,幫助理解和記錄思考過程,不夠嚴謹請見諒。

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