前言
\(\text{K-D Tree (K-Dimension Tree)}\) 是一種可以有效處理高維資訊的資料結構。
在一般資訊學競賽題目中 \(k = 2\),此時它又稱 \(\text{2-D Tree}\)。
但遺憾的是,\(k \ge 3\) 的情況並不常見,這個我們後面再說明原因。
演算法描述
問題
首先從簡單的情況考慮起,假設資訊只有一維,那我們通常用線段樹維護,這樣對於任意區間 \([l, r]\),我們可以將其表達為若干子區間的並。
但是現在資訊變成了 \(k\) 維,直接線段樹肯定是不行的。於是我們考慮類似線段樹的,對於 \(k\) 維空間進行劃分,將任意一個超立方體表示為劃分出的若干子空間的不交併。
不過上述問題過於困難,沒有什麼有效解法。於是考慮一個弱化版:
- 給定 \(k\) 維空間中的 \(n\) 個點,每次給出一個超立方體,將被這個超立方體包含的點集,用較少的結點數表示。
這就是 \(\text{K-D Tree}\) 需要解決的抽象化問題。這裡是一道模板題,可能題面中對 \(\text{K-D Tree}\) 性質的刻畫並不全面,導致有一些奇奇怪怪的莫隊可以透過,不過這部重要,大家拿去測測 \(\text{K-D Tree}\) 就好。
有人可能會問了:不就是多了幾維,寫個樹套樹上去不就是 \(\text{poly}(\log n)\) 的嗎?
但顯然並不是所有高維問題樹套樹都適用,樹套樹的本質是將兩個維度分離,而不是 \(\text{K-D Tree}\) 所使用的整體解決,這樣的結構會有如下缺陷:
-
不能支援修改。因為你的第一層樹(假設是線段樹)會將原本資訊拆分成 \(O(\log n)\) 份,每次在第一棵樹上修改時只能定位到其中一份,所以樹套樹時不支援修改的。
-
無法處理一些特殊問題。比如說線段樹的結構支援線段樹二分,線段樹分治,甚至是單側遞迴等等。這些顯然在二維線段樹上不支援,而 \(\text{K-D Tree}\) 是支援的。
前置討論:Leafy or Nodey?
我們知道樹形結構是非常優美的,很多資料結構本質上都是一棵樹(即使是序列分塊也可以看作這樣)。
但這些資料結構維護資訊的方式不完全相同:比如線段樹只有葉子結點儲存了原資訊,其他結點儲存的是若干葉子結點資訊的並;而平衡樹則不同,每個結點既合併了它所有後代的資訊,又加入了自己的資訊。
對於類似線段樹這樣的只有葉子處儲存原資訊的資料結構,我們稱它是 \(\text{Leafy}\) 的。
而對於平衡樹這樣的在每個結點處都儲存一份原資訊的資料結構,我們稱它是 \(\text{Nodey}\) 的。
常見的 \(\text{Leafy}\) 資料結構就是線段樹,以及線段樹的各種變體。還有就是 \(\text{WBLT}\) 和 \(\text{Leafy Tree}\) 也是 \(\text{Leafy}\) 的,我都沒寫過,這裡提一嘴就好。
而 \(\text{Nodey}\) 結構一般在平衡樹中出現較多,\(\text{OI}\) 界最常見的 \(\text{Treap}\) 和 \(\text{Splay}\) 就是 \(\text{Nodey}\) 的。原因很好理解:平衡樹要支援動態插入刪除,\(\text{Leafy}\) 結構不好維護它。
問題來了,\(\text{K-D Tree}\) 是 \(\text{Leafy}\) 的還是 \(\text{Nodey}\) 的呢?
其實是兩種都可以的,並且都有人寫。我個人傾向於認為將 \(\text{K-D Tree}\) 寫成 \(\text{Leafy}\) 的更好,原因是:
-
顯然 \(\text{Leafy}\) 比 \(\text{Nodey}\) 更好寫,因為 \(\text{Leafy}\) 是二分結構,而 \(\text{Nodey}\) 相當於三分結構。一般情況下也是 \(\text{Leafy}\) 的資料結構常數較小。
-
後面我們要談的 \(\text{K-D Tree}\) 分治,必須要用到 \(\text{Leafy}\) 結構。不難發現 \(\text{Nodey}\) 結構天然是無法(或者說很難,因為你當然可以每個結點下面加一個葉子強制變成 \(\text{Leafy}\) 結構,再線段樹分治,那又何必呢?)支援線段樹分治的。
-
\(\text{K-D Tree}\) 維護的很多都是離線問題,很少有要求動態插入刪除還帶強制線上的問題(不是說沒有,是很少)。如果你看到了類似上面的情況,請你反思一下這道題有沒有更好的,或者不用 \(\text{K-D Tree}\) 的做法。
於是我們主動捨棄 \(\text{Nodey}\) 結構帶來的便於插入刪除的優勢,而選擇將 \(\text{K-D Tree}\) 搭配上 \(\text{Leafy}\) 結構。
當然你要學 \(\text{Nodey}\) 的版本也是可以的,可以去隔壁 \(\text{OI-Wiki}\) 看看。不過即使你的 \(\text{K-D Tree}\) 一直就是 \(\text{Nodey}\) 的恐怕對後面的內容也沒有太大影響。
演算法流程
建樹
現在考慮給出 \(k\) 維空間中的 \(n\) 個點,如何建出一棵樹。
由於我們要快速定位一個超立方體,所以我們還是類似線段樹的對於某一維度排序後劃分為前後兩半。
線上段樹中我們不用考慮選擇哪個維度,因為只有一個。但現在擴充到 \(k\) 維,我們必須做出選擇。
容易想到我們交替劃分,比如 \(k = 2\) 的情況,我們第一次對第 \(1\) 個維度進行劃分,第 \(2\) 次對第 \(2\) 個維度進行劃分,第 \(3\) 次又回到第 \(1\) 個維度,依次類推。
比如下圖是 \(k = 2\) 的情況:
我們不斷劃分,直到點集中只有一個點,此時說明我們走到了一個葉子結點,可以直接返回。
一個實現細節是,我們相當於要找某一個維度中的前 \(k\) 小值,這個可以使用 \(\text{nth_element}\) 函式,時間複雜度為線性。
最後對於每個非葉子結點,記得維護點集中 \(k\) 維中每一維度的最大、最小座標,後面需要用這個來加速查詢。這個可以直接由兩個兒子合併上來。
與一般線段樹不同的是,\(\text{K-D Tree}\) 建樹的時間複雜度為 \(O(n \log n)\),因為題目中給定的是點集,你需要對這個點集做類似排序的操作,所以帶 \(\log\) 是無法避免的。
不過我們在後面將看到,比起查詢和其他操作而言,建樹的複雜度小的簡直可以忽略不計。
查詢
考慮我們要查詢一個超立方體,並且我們當前在 \(\text{K-D Tree}\) 上某個結點 \(p\)。
我們發現此時沒有什麼好的方法,唯一能做的就是兩件事:
- 若查詢的超立方體包含了 \(p\) 代表點集中的所有點,則定位成功,直接返回 \(p\)(或者 \(p\) 處維護的一些資訊)即可。
- 若查詢的超立方體與 \(p\) 代表點集圍成的最大超立方體相離,則 \(p\) 點集中所有點不可能在查詢的超立方體中,所有我們直接 \(\text{return}\)。
以上兩步都可以透過我們前面維護的子樹內每一維度 \(\min/\max\) 快速處理。
如果上述兩種情況都不滿足,那我們也沒有什麼好的辦法,遞迴兩顆子樹即可(顯然如果是葉子必定落入前面兩種情況中的一種)。
後面我們將證明,這樣做訪問和定位的結點數都是 \(O(n^{1 - \frac{1}{k}})\) 的。這裡我們明確幾個概念:
- 訪問指查詢時經過的結點總數。而定位指將超立方體中點集拆分到的結點,滿足這些節點兩兩不交,且並起來是你查詢的東西。
所以時間複雜度就是 \(O(n^{1 - \frac{1}{k}})\),當 \(k = 2\) 時我們將得到 \(O(\sqrt n)\)。
複雜度分析
回憶一下我們是如何證明線段樹的時間複雜度的。我們發現若查詢的是字首或字尾,則我們只需單側遞迴,時間複雜度 \(O(\log n)\)。
而任意區間怎麼分析呢?考慮若查詢的線段不包含中點,則只會單側遞迴。若包含中點,則原區間會變成兩個字首或字尾。所以時間複雜度也是 \(O(\log n)\) 的。
考慮透過類似方式分析 \(\text{K-D Tree}\) 的時間複雜度。先考慮 \(k = 2\) 的情況,我們發現任意矩形的查詢沒有性質,於是我們嘗試將它變成像字首/字尾那樣有性質的矩形。
對於任意矩形,它沒有任意一維是字首/字尾,我們稱其為 \(\text{4-side}\) 矩形,考慮類似前面線段樹的分析,將其拆為 \(O(1)\) 個 \(\text{2-side}\) 矩形。
接下來我們只分析 \(\text{2-side}\) 矩形的查詢(假設是一個右下矩形),設 \(T(n)\) 為在 \(n\) 個結點的 \(\text{K-D Tree}\) 上查詢的時間複雜度。考慮最壞情況形如下面兩種:
考慮第一種情況,右下的矩形被包含,左上的矩形不交,處理它們的時間複雜度為 \(O(1)\),而剩下兩塊仍然是 \(\text{2-side}\),則
由 Master 定理可得 \(T(n) = O(\sqrt n)\)。
第二種情況,右下的矩形完全被包含,左上的矩形是 \(\text{2-side}\),而剩餘兩個注意到它是 \(\text{1-side}\)(這樣說可能不嚴謹,不過為方便理解就不改了),我們設處理 \(\text{1-side}\) 查詢時間複雜度為 \(T_0(n)\)。
則
考慮分析 \(T_0\),顯然經過橫向和豎向分割各一次後,最多剩下兩個 \(\text{1-side}\) 矩形,於是
將 \(T_0\) 帶回去,得到
這個遞迴式用手畫一畫遞迴樹,發現它也滿足 \(T(n) = O(\sqrt n)\) 的。
這樣我們就證明了 \(k = 2\) 時 \(\text{K-D Tree}\) 時間複雜度為 \(O(\sqrt n)\)。
對於 \(k \ge 3\) 的情況,由於很少用到,於是證明就略去了,結論是 \(T(n) = 2^{k - 1}T(\frac{n}{2^k}) + O(1) = O(n^{1 - \frac{1}{k}})\)。
證明的話,我覺得用上面的方法也是可行的。先將 \(\text{2k-side}\) 矩形化為 \(\text{k-side}\) 矩形,然後分析一下會發現對所有維度進行一輪劃分後規模減半即可。
為什麼 \(k \ge 3\) 不常用
分析完複雜度,我們就很好理解為什麼 \(\text{3/4-D Tree}\) 甚至更高維度不常用了。
回到前面的複雜度分析,我們發現將 \(\text{2k-side}\) 矩形變成 \(\text{k-side}\) 矩形時,每次問題規模會 \(\times 2\)。
所以說 \(\text{K-D Tree}\) 暗含了一個 \(2^k\) 的常數(可能還有一個 \(k\) 的常數,存疑)。雖然 \(k = 2\) 時它基本可以忽略,但隨著 \(k\) 的增大,這個常數會指數級增長。
再加上 \(\text{K-D Tree}\) 本身複雜度是 \(O(n^{1 - \frac{1}{k}})\),在 \(k\) 較大時本身與 \(O(n)\) 區別以及不大,再加上它的大常數,你就可以理解為什麼有時你寫了一個 \(\text{5-D Tree}\) 然後跑不過暴力了。
當然還有就是在 \(\text{OI}\) 中三維以上的題目本身就不常見,見的最多的就是數軸和平面,這也使得 \(\text{K-D Tree}\) 少了很多用武之地。
真正遇到三維問題,一般都有 \(\text{polylog}\) 做法(比如樹套樹,\(\text{CDQ}\) 分治),最次的也可以用一些方法(可能花費一個 \(\log\))除掉一維,再上 \(\text{2-D Tree}\),這樣可以得到 \(O(\sqrt n \log n)\) 或 \(O(\sqrt n)\)(\(\text{K-D Tree}\) 的一些特點可能可以將 \(\log\) 均攤掉)的做法。
我之前還見過有人講 \(\text{K-D Tree}\) 時直接寫成 \(\text{2-D Tree}\) 的。雖然這樣可能不利於理解這個資料結構,但它並不是全無道理——因為 \(k \ge 3\) 的使用場景的確太小了。
如果叫我來總結的話:
- 如果你的演算法需要 \(\text{3-D Tree}\),請你務必謹慎思考是否使用,反覆檢查你的演算法,並明確其時間複雜度為 \(O(n^{\frac{2}{3}})\),還要在計算時間複雜度時帶上 \(10\) 的常數。
- 如果你的演算法需要 \(\text{4D}\) 或以上的 \(\text{K-D Tree}\),請你馬上放棄你現在的思路,重新思考這道題。
動態擴充
帶插入
注意到建立 \(\text{k-D Tree}\) 的時間複雜度為 \(O(n \log n)\),而查詢的時間複雜度為 \(O(\sqrt n)\),這是一個非常適合根號分治的結構。
設一閾值 \(B\),當插入的點 \(< B\) 個時不進行插入,而是統一儲存起來,查詢時算這些點對其的貢獻,當插入的點達到 \(B\) 個時重構整顆 \(\text{K-D Tree}\)。
視實現情況,時間複雜度為 \(O(n \sqrt{n \log n})\) 或 \(O(n \sqrt n)\)。
好像有二進位制分組的做法,複雜度差不多,但我覺得 \(\text{K-D Tree}\) 與根號分治更般配,一般二進位制分組用於配合線段樹之類的資料結構,可以攤掉一隻 \(\log\),還能做線段樹合併。所以這種方法就不展開了。
帶刪除
這個沒什麼好辦法,還是隻能考慮如果題目限制比較寬鬆的話,還是惰性刪除,打個刪除標記,然後定期重構吧。或者可以考慮離線。
如果題目限制嚴格,上面的方案無法接受,那就考慮寫 \(\text{Nodey K-D Tree}\) 吧。
例題
From ix35:
給定二維平面上的 \(n\) 個點,支援兩種操作 \(q\) 次:
- 將一個矩形區域內的所有點權值 \(+v\)。
- 求一個矩形區域內的點的權值的最小值。
用 \(\text{K-D Tree}\) 維護,每個矩形定位到樹上的 \(O(\sqrt n)\) 點,在結點上的操作就和線段樹差不多了。
時間複雜度 \(O(n \log n + q\sqrt n)\)。
功能擴充
眾所周知,\(\text{K-D Tree}\) 還有一個用途是找平面最近/最遠點對。
做法是列舉一個點 \(i\),在 \(\text{K-D Tree}\) 上查詢最近/最遠點,\(\text{K-D Tree}\) 的結構可以用來剪枝。對於 \(\text{K-D Tree}\) 上的每個結點算出一個邊界矩形。則矩形內距離 \(i\) 最近/最遠的點一定是矩形的四個頂點之一,如果四個頂點均不如當前 \(ans\),則無需在該子樹內進行搜尋。
\(k\) 近 / \(k\) 遠點對的做法是類似的,用堆維護當前的前 \(k\) 優答案,還是使用類似前面的方式剪枝即可。
這樣做在隨機資料下表現優秀。但是它的本質還是暴力剪枝,最劣時間複雜度還是 \(O(n^2)\) 的。
二維貢獻問題 / \(\text{2-D Tree}\) 分治
這個東西我初見是在 JOI Open 2018 Collapse。
考慮如下問題:
有 \(n\) 張圖,每張圖位於二維平面的一個位置 \((x, y)\),\(q\) 次操作,每次在一個矩形內的圖中連一條邊。求最終每個圖的連通塊數。
考慮對於一次加邊操作,定位到 \(\text{K-D Tree}\) 上的 \(O(\sqrt n)\) 個結點。最後跑類似線段樹分治的東西即可。
時間複雜度是 \(O(q \sqrt n \log n)\)。
與其他演算法的比較
就拿 Collapse 那題來說,它其實還有另外一種做法。這裡我引用一下我的題解:
首先重新描述題意:每條邊 \((u, v)\) 有出現時間 \([l, r]\),每次詢問在時間 \(t\),如果只保留 \([1, x]\) 和 \([x + 1, n]\) 內部的邊,有多少連通塊。
顯然 \([1, x]\) 和 \([x + 1, n]\) 兩個部分可以分別計算連通塊數再相加,並且兩部分是對偶的,所以我們下面只考慮計算 \([1, x]\)。
我們對所有詢問按 \(t\) 排序,再對每 \(B\) 個詢問分一個塊,考慮對於每個塊如何算答案。
考察每條邊對這些詢問的貢獻,設這條邊的出現時間為 \([l, r]\),塊內詢問的時間段為 \([L, R]\)。
若 \([l, r]\) 包含 \([L, R]\),則這條邊對整個塊都有貢獻,我們將所有這樣的邊按 \(y\) 排序,塊內的詢問按 \(x\) 排序,掃描線即可。時間複雜度 \(O(\frac{qm}{B} \log n)\)。
若 \([l, r]\) 不包含但與 \([L, R]\),那麼對於每條邊只會落入這種情況 \(O(1)\) 次,此時我們在遇到一個詢問時暴力加入這一型別的邊,然後在撤銷回去即可,並查集需要按秩合併。時間複雜度 \(O(mB \log n)\)。
總時間複雜度 \(O((\frac{qm}{B} + mB) \log n)\),程式碼中取 \(B = 333\)(隨便取的,沒仔細卡)。由於並查集和排序的 \(\log\) 常數很小,並且這題的加邊方式很難卡滿,可以透過。
考慮將這個做法套用到前面那道題目:
我們還是對所有點按 \(x\) 排序後分塊,一個矩形若在 \(x\) 維度上覆蓋當前塊內的所有點,則對它的 \(y\) 維度掃描線,否則暴力即可。
但是此時出現了問題,Collapse 中的矩形在一個維度上是字首,那麼做掃描線的過程中只加不刪。但這題是任意矩形,所以還要考慮刪除的情況。顯然並查集不好刪除,所以還要做一遍線段樹分治,那麼複雜度就是 \(O(n \sqrt n \log^2 n)\) 或 \(O(n \sqrt{n \log n} \log n)\) 的,比 \(\text{K-D Tree}\) 分治多個 \(\log\)。
所以這個分塊做法只適用於矩形為 \(\text{3-side}\) 時,此時時間複雜度為 \(O(n \sqrt n \log n)\)。
當然 \(\text{2-D Tree}\) 分治做法也有缺點,如果沒有什麼很優秀的實現的話,它的空間複雜度為 \(O(n \sqrt n)\),然後我覺得它比起分塊常數略大。但它的優點是非常直觀,也非常萬能,可以套模板、