<style> .reveal .slides { text-align: left; font-size:35px } </style> ## Tree & Disjoint Set 11/10 preTrain --- ## Table of Contents - Tree 簡介 - 複習: 樹的定義 - 有根樹 - 生成樹 - 樹的性質 - 樹上遍歷 - BFS - DFS 與性質 ---- - 經典樹上演算法 - 子樹大小 - 找樹高 - 找樹的深度 - 找樹的直徑 - 找樹的重心 - 常見的樹狀資料結構 - Disjoint Set (Union-Find) - 經典 DSU 例題 - 找最小生成樹 : Kruskal 演算法 --- ## Tree 簡介 ---- ### 複習: 樹的定義 常見有四種定義方式,**他們其實都一樣** 1. 連通無環圖 2. 連通且邊數為 $n-1$ 的圖 3. 無環且邊數為 $n-1$ 的圖 4. 無自環且任兩點僅存在唯一路徑的圖 ---- ### 哪裡會看到樹? - 資料結構 - 演算法 - 賽局 - 作業系統 - 編譯器 / 形式語言 tree 無所不在,所以我們沒有理由不學 tree <!-- .element: class="fragment" data-fragment-index="1" --> ---- 以下這個就是樹 ![image](https://hackmd.io/_uploads/SJab3Rhkbx.png) ---- ### 有根樹 我們可以取一點當作根,然後把其他節點都掛在根底下 如此一來節點之間就會出現祖先與後代的關係 ![image](https://hackmd.io/_uploads/HyPy6C3kbe.png) ---- ### 有根樹的名詞解釋 給定節點 $x$,與根 $r$ - 祖先 : 從 $x$ 到 $r$ 的所有節點們 - 後代 : 從 $x$ 遠離 $r$ 的所有節點們 這些定義有時有包含 $x$ 本人,有時沒有 <!-- .element: class="fragment" data-fragment-index="1" --> ---- 給定節點 $x$,與根 $r$ - 父節點 : 與 $x$ 相鄰且更靠近 $r$ 的點 - 子節點 : 與 $x$ 相鄰且更遠離 $r$ 的點 ---- ### 子樹 (subtree) 刪掉與父親相連的邊後,該節點所在的子圖 ![image](https://hackmd.io/_uploads/S18Ty1pJ-x.png) ---- ### 生成樹 (spanning tree) 由於樹是「最小連通子圖」 所以我們常會想在圖中找到一棵樹 定義 : 圖 $G$ 取出一棵樹 $T$ 使 $V(T)=V(G)$,則 $T$ 為 $G$ 的生成樹 ---- 右圖是左圖的生成樹 :evergreen_tree: <div style="position: absolute; right: 0%; top:100%;"> ![](https://i.imgur.com/Tn3xRfH.png =450x) </div> <div style="position: absolute; left: 0%; top:100%;"> ![](https://i.imgur.com/UdEIk6Z.png =500x) </div> ---- 題外話 : 上堂課說的 DFS / BFS 也會形成生成樹喔 ---- ### 有根樹的性質 - 兩點距離 : 兩點之間的邊數 - 節點深度 : 從點到根的距離 - 子樹高度 : 子樹從根到最遠葉子的距離 - 直徑 : 樹中最長的距離 - 重心 : 等等再說 --- ## 樹上遍歷 ---- 由於每個節點只會有一個父節點 相較於一般圖上遍歷 可以不用 $\text{vis[ ]}$ 紀錄節點是否走過 沒有環所以就不會有重複走的問題 ---- ### 樹上 BFS 可以用 pair 維護當前節點與父節點 ```cpp= void bfs(int s){ queue<pair<int,int>> q; q.push(make_pair(s, 0)); while(!q.empty()){ auto [x, y] = q.front(); q.pop(); for(int i : g[x]) if(i != y) q.push(make_pair(i, x)); } } ``` ---- ### 樹上 DFS 函數多傳一個參數就可以維護父節點 ```cpp= void dfs(int x, int father){ for(int i : edge[x]){ if(i != father) dfs(i, x); } } ``` ---- ![](https://miro.medium.com/v2/resize:fit:1280/1*GT9oSo0agIeIj6nTg3jFEA.gif) ---- ### DFS 的名詞 DFS 時,我們可以把每個點分成三個狀態 - 白點 : 尚未被發現 - 灰點 : 被發現但後代還沒找完 - 黑點 : 被發現且後代都找完 ---- 小提醒 : 在樹上做處理時要想清楚,節點要在什麼狀態時做處理 - 剛被發現時處理 : 灰點 - 子樹處理完再處理 : 黑點 --- ## 經典樹上演算法 ---- ### 子樹大小 求出一棵有根樹中 $x$ 的子樹大小 (節點數量) - 一個點的子樹大小為 $1+\sum_{i}\text{sz}[子樹_i]$ - 葉節點的子樹大小為 $1$ ```cpp= int sz[MXN]; int dfs(int x, int father) { sz[x] = 1; for(int i : edge[x]){ if(i != father){ // 先 dfs 算子樹的結果, 算出後再加入 sz[x] sz[x] += dfs(i, x); // 此時 i 已經是黑點 (拜訪完所有後代) } } return sz[x]; } ``` ---- ![tree size - 2](https://hackmd.io/_uploads/SyWppD6k-e.gif =700x) ---- ### 找樹的深度 求出一棵有根樹每個節點的深度 - 子節點深度為父節點深度 $+1$ (或邊權) - 根節點的深度為 $0$ (或 $1$,視題目要求) ```cpp= int d[MXN]; void dfs(int x, int father){ for(int i : edge[x]){ if(i != father){ // 在 i 還是灰點時就處理 d[i] = d[x] + 1; dfs(i, x); } } } ``` ---- ![tree depth](https://hackmd.io/_uploads/rkMvxdpJbg.gif =700x) ---- ### 找樹高 求出一棵有根樹每個節點的高度 - 一個點的高度為所有子節點中,高度最高者 $+1$ - 葉節點的高度為 $0$ (或 $1$,視題目要求) ```cpp= void dfs(int x, int father){ h[x] = 0; for(int i : edge[x]){ if(i != father){ dfs(i, x); h[x] = max(h[x], h[i] + 1); } } } ``` ---- ![tree height](https://hackmd.io/_uploads/ByaRfuTkWx.gif =700x) ---- ### 找樹的[直徑](https://cses.fi/problemset/task/1131) (方法 1) 求出一棵樹最遠兩點的距離 - 先任選一點作為根,將其視作有根樹 - 考慮分治法:直徑可能 1. 經過根節點,也就是前兩高度 2. 不經過根節點,也就是在某棵子樹上,因此遞迴計算子樹的答案再取$\max$ - 兩者再取$\max$即為答案 ---- ```cpp int ans; int dfs(int x, int father){ int mh1 = 0, mh2 = 0, cur; for(int i : edge[x]){ if(i != father){ cur = dfs(i,x); if(cur > mh1) mh2 = mh1, mh1 = cur; else if(cur > mh2) mh2 = cur; } } ans = max(ans, mh1 + mh2); return mh1 + 1; } ``` ---- ### 找樹的[直徑](https://cses.fi/problemset/task/1131) (方法 2) 1. 任選一點開始做 DFS,找出最遠(深)的點,記為 $\text{mx}$ 2. 從 $\text{mx}$ 開始做 DFS,其與最遠點的距離即為直徑 (也就是第二次DFS的高度$+1$) ---- ```cpp int md, mx; void dfs(int x, int father, int d = 0){ if(d > md) md = d, mx = x; for(int i : edge[x]){ if(i != father) dfs(i, x, d + 1); } } int find_diameter(){ md = -1; dfs(0, 0); md = -1; dfs(mx, mx); return md; } ``` ---- ### 樹重心 求一棵樹的重心,以下是重心的定義: - 如果在樹中選擇某個節點並刪除,這棵樹將分為若干棵子樹,統計子樹節點數並記錄最大值。取遍樹上所有節點,使此最大值取到最小的節點稱為整棵樹的重心。 ---- 簡單來說就是一個演算法式的定義,可以整理成以下 我們對所有節點 $x\in V$ 執行以下操作 $f(x)$ : 1. 刪除節點 $x$,刪除後會出現森林 2. 統計每棵樹節點數,並記錄最大值 $m$ 3. $f(x)$ 的值即為 $m$ 樹的重心為 $c$,$f(c)=\begin{split}\min_{x\in V}f(x)\end{split}$ ---- 以這圖為例: <div style="position: absolute; left: 60%; top:60%;"> ![image](https://hackmd.io/_uploads/rJWfsmbKC.png =300x) </div> 砍掉點 $1$,剩下的最大子樹為 $2$-$5$-$6$-$7$,$\text{sz}$ 為 $4$ 砍掉點 $2$,剩下的最大子樹為 $1$-$3$-$4$,$\text{sz}$ 為 $3$ 砍掉點 $5$,剩下的最大子樹為 $1$-$2$-$3$-$4$-$6$,$\text{sz}$ 為 $5$ 所以樹重心為節點 $2$ ---- 樹的重心也不一定唯一 右圖樹重心為 $1$ 和 $2$ <div style="position: absolute; right: 20%; top:0%;"> ![image](https://hackmd.io/_uploads/r18uk3GtR.png =250x) </div> ---- <div style="position: absolute; left: 0%; top:0%;"> ### 怎麼找樹重心 關鍵的地方在於,要找到刪掉某個點後, 剩下最大的子樹的 $\text{sz}$ 為多少 - 假設要刪掉點 $x$ - 則要去拿 $x$ 的所有的子樹 $\text{sz}$ - 以及 $x$ 往上的樹的 $\text{sz}$ 假設要刪掉 $2$ ,除了要檢查 $5$ 和 $6$ 的子樹大小, 還要檢查 $2$ 上方的子樹大小,大小為 $(n - (2的子樹大小) )$ </div> <div style="position: absolute; right: 10%; top:0%;"> ![image](https://hackmd.io/_uploads/BJVRe3fF0.png =200x) </div> ---- ### 找樹的[重心](https://cses.fi/problemset/task/2079) (方法 1) 1. 預處理 $\text{sz[ ]}$ 2. 探索每個點 - 算最大子樹大小 - 紀錄答案 ---- 1. 預處理 (跟前面找子樹大小相同) ```cpp= int sz[MXN]; int dfs(int x, int father) { sz[x] = 1; for(int i : edge[x]){ if(i != father) sz[x] += dfs(i, x); } return sz[x]; } ``` ---- 2. 探索每個點 (算最大子樹大小 + 紀錄答案) ```cpp= int cen_sz = inf, cen = -1; void find_cen(int x, int father){ // 找孩子 sz int mx_sz = 0; for(int i : edge[x]){ if(i == father) continue; mx_sz = max(mx_sz, sz[i]); } // 找上方的樹 sz mx_sz = max(mx_sz, n - sz[x]); // 紀錄答案 if(cen_sz > mx_sz){ cen_sz = mx_sz; cen = x; } //算完就往下遞迴算 for(int i : edge[x]){ if(i == father) continue; find_cen(i, x); } } ``` 這樣就找到重心了 ---- 另外還有一個名詞叫[樹中心](https://www.tutorialspoint.com/centers-of-a-tree),跟樹重心類似, 只是判斷的條件從 sz(大小) 變成 depth(深度) ---- ### 找樹的[重心](https://cses.fi/problemset/task/2079) (方法 2) 先看個定理 : 將樹以 $c$ 為根, $c$ 為重心若且唯若對任一子樹 $T_i$ 節點數 $|T_i|$ 都有 $|T_i| \;\le\; \bigl\lfloor\tfrac{n}{2}\bigr\rfloor$ ---- $|T_i| \;\le\; \bigl\lfloor\tfrac{n}{2}\bigr\rfloor$ ![](https://hackmd.io/_uploads/B110PWEdxx.png) ---- 所以只要檢查每棵子樹 (加上頭上那個樹) 是不是都 $\le \bigl\lfloor\frac{n}{2}\bigr\rfloor$ ```cpp= int cent; // 這就是重心 void dfs(int u, int pre){ sz[u] = 1; int mx = 0; for(int &v : g[u]){ if(v == pre) continue; dfs(v, u); sz[u] += sz[v]; // 此時 v 已是黑點 mx = max(mx, sz[v]); } mx = max(mx, n - sz[u]); if(mx <= n / 2) cent = u; } ``` 這種方法直接不需要預處理了,可以跟子樹大小寫在一起 ---- 再複習一下 我們對所有節點 $x\in V$ 執行以下操作 $f(x)$ : 1. 刪除節點 $x$,刪除後會出現森林 2. 統計每棵樹節點數,並記錄最大值 $m$ 3. $f(x)$ 的值即為 $m$ 樹的重心為 $c$,$f(c)=\begin{split}\min_{x\in V}f(x)\end{split}$ ---- ### 找樹的重心 (方法 3) <div style="position: absolute; right: 0%; top:0%;"> ![image](https://hackmd.io/_uploads/rk1J_GEugg.png =380x) </div> 再來看看一個定理 : 從任意點 $x$ 沿著重心 $c$ 方向移動,則 - $f(x)$ 非遞增 - 在到達 $c$ 時達到最小值 ---- 這可以推得 : 令 $r$ 為根,則若存在 $|T_i| \ge\bigl\lfloor\tfrac{n}{2}\bigr\rfloor$,則重心存在於子樹 $T_i$, 所以直接找這棵樹 $T_i$ 即可 ---- ```cpp= int dfs(int u, int pre){ // 這個先預處理 sz[u] = 1; for(int v : g[u]){ if(v == pre) continue; sz[u] += dfs(v, u); } return sz[u]; } int get_cent(int u, int pre){ for(int v : g[u]){ if(v != pre && sz[v] > n / 2) // 這條件代表重心在 v 的子樹 return get_cent(v, u); } return u; // 每個子樹都 <= (n / 2),則符合重心定義 } dfs(1, 0); ans = get_cent(1, 0); ``` 這方法或許可以壓掉一些常數,因為可能不會找完整棵樹 有個技巧叫重心剖分就要用這個方法 ---- 三種找樹重心的方法[等價的證明](https://hackmd.io/@ShanC/special-vertices-on-tree#重心-Centroid) 可以自己翻來看看,看不懂就畫畫看圖 --- ## Disjoint Set (Union-Find) #### 併查集、DSU ---- ### DSU 簡介 併查集是一種有向有根的樹狀結構,專門處理集合問題, 主要有以下兩個操作 : - 查詢元素所在集合(find) - 合併兩個集合(union) ---- ### 小觀察 樹上每個節點只會有一個父節點 $\Rightarrow$ 只需要一個陣列就可以存一整棵樹 ---- ### 儲存資料 併查集需要用一個長度為 $n$ 的陣列 $\text{f[ ]}$, $\text{f}[i]$ 值為第 $i$ 個節點的父節點編號 ```cpp= int f[n]; f[3] // 節點 3 的父節點編號 f[5] // 節點 5 的父節點編號 ``` ---- ### 根節點 如果一個點為根節點,他的父節點為自己 $(\text{f}[x] == x)$ 以下圖為例, $1$、$6$ 為根節點 ![](https://i.imgur.com/BeMQ981.png =450x) ---- ### 判斷集合 定義兩個相異元素如果屬於同一個集合,則兩個元素會在並查集的同一棵樹上 ---- 如何判斷在同一棵樹上? ---- ### find 函數 find 函數會找到某個節點 $x$ 的根節點 如果兩個點的根節點相同,代表在同一棵樹上, 也就是屬於同一個集合 ``` find(x) == find(y); ``` 則 x 與 y 所屬同一個集合 ---- ### find 函數 ```cpp= int find(int x){ if (x == f[x]) // 如果當前節點為 f[x] == x return x; // 則為根節點 return find(f[x]); } ``` ---- ### union 函數 有兩個節點 $a, b$ 所在的集合想要合併成一個集合 : 1. 找到兩集合的根節點 2. 將其中一個集合的根節點連到另外一個的根節點 ---- 以下圖為例 <div style="position: absolute; right: 10%; top:30%;"> ![](https://i.imgur.com/BeMQ981.png =400x) </div> <div style="position: absolute; left: 0%; top:30%;"> ![](https://i.imgur.com/uNrfG79.png =400x) </div> ---- 合併 $2$ 所在的的集合跟 $7$ 所在的集合 : 1. 先找到 $2$ 的根節點 =$1$,$7$ 的根節點 =$6$ 2. 將其中一個根節點的父節點設為另一個根節點 $7$ 所在的集合所有元素的根節點都會變成 $1$ ---- ### union 函數 這邊合併的函數名稱不使用 union 因為 union 為關鍵字 (撞名內建函數),因此名稱改用 merge ```cpp= void merge(int x,int y) { // 合併 x, y 所在集合 x = find(x); // x 找到其根節點 y = find(y); // y 找到其根節點 if(x != y) // 如果 x,y 原本就屬於同一個集合則不需合併 f[y] = x; // 將其中一個根節點連到另一個 } ``` ---- ### 初始化 一開始每個元素皆屬於屬於不同集合 (自己就是一個集合) - 將節點指向自己,每個元素都是根節點 ```cpp= int f[N]; void init(){ for(int i=0;i<n;i++) f[i] = i; // 將每個元素根節點設為自己 // 或者也可以使用 iota(f, f+n, 0); 代替迴圈 } ``` ---- ### 完整程式碼 ```cpp= void init(){ for(int i=0;i<n;i++) f[i]=i; } int find(int x){ if ( x == f[x] ) // 如果當前節點為 f[x] == x return x; // 則為根節點 return find(f[x]); // 否則繼續往父節點方向找根節點 } void merge(int x,int y){ x = find(x), y = find(y); if(x != y) f[y] = x; } ``` ---- ### 優化 1 在最差的情況下, 合併後的樹有可能會變成一條鏈 $\Rightarrow$ find() 複雜度退化成 $O(n)$ 以右圖為例, find($5$) 需要跑完全部節點才能找到根 <div style="position: absolute; right: 10%; top:0%;"> ![](https://i.imgur.com/38mxLz3.png) </div> ---- 顯然是因為樹高太高,導致 find 最差複雜度太大 所以該如何降低樹高呢? ---- ### 啟發式合併 #### Union by Rank 記錄每棵樹的大小,在每次合併的時候, 將小的集合合併到大的集合 ---- ### 做法 宣告 $\text{sz}$ 陣列,紀錄每個節點的為根的集合大小 在初始化的時候將每個集合大小 $\text{sz}[i]$ 都設成 $1$ ```cpp= void init(){ for(int i = 0; i < n; i++){ f[i] = i; sz[i] = 1; } } ``` ---- 當遇到合併操作時,將兩個集合合併成一個時 把小的集合往大的集合合併 ---- 找到根節點,根節點儲存整個集合的資訊 合併時,把小的集合的資訊加給大的集合 ```cpp= int f[N], sz[N]; void merge(int x, int y) { x = find(x), y = find(y); if(x == y) return; if(sz[x] < sz[y]) swap(x, y); // 將 x 變成大的 sz[x] += sz[y]; // 把集合 y 的大小加到集合 x f[y] = x; } ``` ---- ### 分析 合併兩棵樹高不同的樹 ![](https://hackmd.io/_uploads/SkPPcX4-a.png) ---- 兩個不同的集合合併,如果把樹高比較矮的連往比較高的, 合併後的樹高不會改變 ![](https://hackmd.io/_uploads/rJ9NjmEba.png) ---- 而如果合併的兩個集合樹高相同, 或者高的往矮的合併,則合併後樹高會+1 ![](https://hackmd.io/_uploads/Hyy_3QV-6.png =x400) ![](https://hackmd.io/_uploads/Bkio3QVZa.png =x400) ---- 每次把小的合併到大的方法,稱之為啟發式合併 使用此方法的合併的樹, 在最差情況下,每次合併時兩棵樹的樹高都相同 ---- 在樹高相同的情況下,會發現每次樹高要 $+1$ 所需節點數量會變 $2$ 倍 ![](https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExZnZremlsb3U0azFhM2I0YnRmNjNpNWRyNGZxeG1iaTE1b3NlbHJ3NiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/HEp4DH9XYsUXwozTtR/giphy.gif) 因此 $n$ 個節點時,使用啟發式合併樹高最高為 $O(\log n)$ ---- ### 優化 2 #### 路徑壓縮 在每次 find 的時候,把經過節點的父節點全部設成根節點 ```cpp= int find(int x){ if(f[x] == x) return x; f[x] = find(f[x]); // 直接將 x 的父節點設成根節點 return f[x]; } ``` <!--可以再模擬一下 f[x] = find(f[x]) 的地方 --> ---- 呼叫 find($5$) 會經過節點 $5$ $4$ $3$ $2$ $1$ 將中間每個節點的父節點直接設為根節點 <div style="position: absolute; right: 70%; top:100%;"> ![](https://i.imgur.com/rlVcpOJ.png) </div> <div style="position: absolute; right: 60%; top:200%;"> $\rightarrow$ </div> <div style="position: absolute; right: 10%; top:150%;"> ![](https://i.imgur.com/KfwSYzi.png) </div> 修改後,之後詢問這些節點時,只需要 $O(1)$ 就會找到根節點 ---- 使用啟發式合併+路徑壓縮 能使得單次操作複雜度降到 $O(\alpha(N))\approx O(1)$ ($\alpha( 2^{2^{10^{19729}}} ) \approx 5$) 因此併查集操作複雜度幾乎是常數時間 ---- ### 完整程式碼 要特別注意合併時,如果兩個元素本來就在同一個集合 要直接回傳,不要重複加到 sz (15行) ```cpp= int f[N], sz[N]; void init(){ for(int i=0;i<n;i++) { f[i] = i; sz[i] = 1; } } int find(int x){ if ( x == f[x] ) // 如果當前節點為 f[x]==x return x; // 則為根節點 f[x] = find(f[x]); return f[x]; } void merge(int x, int y) { x = find(x), y = find(y); if(x==y) return; if(sz[x] < sz[y]) swap(x, y); // 將 x 變成大的 sz[x] += sz[y]; f[y] = x; } ``` ---- ### 紀錄集合資訊 如果題目為給定 $n$ 個元素,每次操作為以下其中一種 : 1. 查詢元素 $x$ 所在的集合有幾個元素 2. 合併元素 $x, y$ 分別所在的集合 ---- ### 查詢集合大小 會發現集合大小在做啟發式合併時, 就已經記錄過此資訊 $\text{sz[ ]}$ 了 查詢元素 $x$ 所在的集合大小只需要找到 $x$ 的集合的根節點, ```cpp sz[find(x)] ``` 即為所在的集合的大小 ---- ### 其他資訊查詢 如果題目需要求其他資訊,如集合內編號最小/大值等等, 則多為一個陣列 $\text{mn[ ]}$/$\text{mx[ ]}$ 之類維護每個集合內的資訊 合併集合時,則把兩個集合內的資訊合併 ```cpp= void merge(int x){ x = find(x); y = find(y); if(x == y) return; if(sz[x] < sz[y]) swap(x, y); mn[x] = min(mn[x], mn[y]); mx[x] = max(mx[x], mx[y]); f[y] = x; } ``` ---- ### 相異集合的數量 要找總共有幾個集合,可以找總共有幾個根節點即可 ```cpp= int countComponent(){ int ret = 0; for(int i = 0; i < n; i++){ if(find(i) == i) ret++; } return ret; } ``` --- ## 經典 DSU 例題 ---- ### 連通塊 / 連通分量 / 連通元件 #### (connected component) 一個無向圖中的極大連通子圖 ![image](https://hackmd.io/_uploads/Hkqe7GC1-g.png) ---- ### 找最大連通塊 給一張 $n$ 個節點的圖,一開始有 $m$ 條邊, 接下來有 $k$ 次操作,每次操作為新增一條邊, 每次操作完輸出最大的連通塊大小 ? - $n, m, k\le 10^5$ ---- 下圖為例,黑色為一開始的邊,紅色為依序要加入的邊 ![](https://hackmd.io/_uploads/HyB1Qw4ZT.png) 當加完 $1$ 號邊之後,最大連通塊大小為 $5$ 當加完 $2$ 號邊之後,最大連通塊大小為 $5$ 當加完 $3$ 號邊之後,最大連通塊大小為 $6$ ---- 不難發現,原圖可以看成三個獨立的節點集合 加入邊的行為相當於對集合做兩兩合併 ![](https://hackmd.io/_uploads/HyNsLz0kZg.gif =450x) ---- ### [Almost-union-find](https://zerojudge.tw/ShowProblem?problemid=f292) 一個有三種操作的並查集 : 1. $\text{Union}(x, y)$: 把元素 $x, y$ 分別所在集合合併成同一集合 2. $\text{Move}(x, y)$: 將元素 $x$ 移動到元素 $y$ 所在的集合 3. $\text{Return}(x)$: 詢問元素 $x$ 所在的集合內元素個數與元素編號總和 $1\le n, m\le 10^5$ ---- 可以發現操作 1、3 都是正常的並查集操作 只需要在 merge 的時候維護總和(sum)跟大小(sz) ```cpp void merge(int x, int y){ x = find(x); y = find(y); if(x == y) return; if(sz[x] < sz[y]) swap(x, y); sz[x] += sz[y]; num[x] += num[y]; f[y] = x; } ``` 而操作 2 比較不一樣,需要做到刪除的操作。 ---- 我們可以從兩種情況來看刪除 1. 移除的是葉節點 2. 移除的不是葉節點 ---- ### 1. 移除的是葉節點 在這個情況下, 只要將根節點記錄大小跟總和的變數把這個節點減掉即可 ---- ### 2. 移除的不是葉節點 感覺很麻煩,我也不會 ---- ### 捨棄原本的節點 每次詢問,需要回傳集合內元素個數與元素編號總和 把元素 $x$ 移除,其實只需把 $x$ 儲存在集合內的資訊移除即可 ```cpp= void remove(int x){ root = find(x); sz[root]--; sum[root] -= x; } ``` 移除後,原本的節點就不重要了。 ---- ### 加入到新的集合中 要將 $x$ 加入新集合, 可以先給 $x$ 一個新的編號 $\text{newid}$ 來代表數字 $x$ 因此需要開一個陣列 $\text{id[ ]}$ 維護每個元素當前代表的編號, 並初始化新編號的 $\text{sz[ ], num[ ]}$ ---- ### 換新編號 & 合併 x 與 y 的集合 ```cpp void add(int x){ f[id[x]] = id[x] = newid++; sz[id[x]] = 1; sum[id[x]] = x; } remove(x); add(x); merge(id[x], id[y]); ``` --- ## 找最小生成樹 #### Kruskal 演算法 ---- ### 最小生成樹定義 (Minimum Spanning Tree) 給一張帶權圖, 最小生成樹是其中所有的生成樹中,權重總和最小的 簡稱 MST ---- ### 複習: 邊陣列 ```cpp= struct Edge{ int u, v, w; // 點 u 連到點 v 並且邊權為 w }; Edge g[200005]; // 宣告 Edge 型態的陣列 g ``` ---- struct 可以自定義運算子 多載 "$<$" 運算子,使用邊權重比較兩條邊的大小關係 ```cpp= struct Edge{ int u, v, w; // 點 u 連到點 v 並且邊權為 w bool operator<(const Edge& rhs){ return w < rhs.w; //兩條邊比較大小用邊權比較 } }; Edge g[200005]; // 宣告 Edge 型態的陣列 graph ``` ---- ### 找最小生成樹 1. 將所有邊照權重大小排序 2. 從權重小的邊開始窮舉,依序窮舉到大 - 當邊兩側的節點原本不連通就加邊 - 否則就捨棄這條邊 3. 當大小為 $n$ 時,停止窮舉 Kruskal 演算法其實是一種 greedy algorithm <!-- .element: class="fragment" data-fragment-index="1" --> ---- ### 加入邊 使用併查集,一開始所有點都沒連任何邊 因此所有點都屬於自己的集合 當兩個點有連邊,代表他們屬於同一個集合 因此可以用並查集判斷,判斷是否已經為同一個集合 ---- <div style="background-color:white; position: absolute; right: 20%;"> ![image alt](https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/MST_kruskal_en.gif/600px-MST_kruskal_en.gif) </div> ---- ### 程式碼 將邊照大小依序嘗試加入圖中, 如果邊的兩點未連通,則連通兩點 ```cpp= sort(graph, graph + m); // 將邊照大小排序 int ans = 0; for(int i = 0; i < m; i++){ //>:) if(find(graph[i].u) != find(graph[i].v)){ // 如果兩點未聯通 merge(graph[i].u, graph[i].v); // 將兩點設成同一個集合 ans += graph[i].w; // 權重加進答案 // 終止條件 : 當並查集大小等價於樹內點的數量 if(sz[find(graph[i].u)] == n) break; } } cout << ans << endl; ``` ---- ### 複雜度分析 依照權重排序所有邊 $O(M\log M)$ 窮舉每條邊加入 $O(M\cdot \alpha (N))$ $\Rightarrow$ 總複雜度 $O(M\log M)$ ---- ### 演算法的正確性 憑什麼 Kruskal 演算法跑出來的 $T$ 就是 MST 呢? ---- 在加入邊 $e$ 時,若未形成環,則當然是當前最佳選擇 在加入邊 $e$ 時,若形成一個環 - 先違反一下 Kruskal 的方法, 挑 $e$ 後拔掉環上其他邊也會是一棵生成樹 - 由於演算法會先對權重排序,環上其他邊的權重 $\le w(e)$ $\Rightarrow$ 挑 $e$ 後拔掉環上其他邊不會比較好 - 因此反證回來 Kruskal 的挑法的確比較好 最後得到一棵生成樹,自然就是 MST ---- ### 瓶頸生成樹 令 $\tau = \{T_i\}_{i\in I}$ 是這張圖的所有生成樹, 瓶頸生成樹 $T^*$ 的最大邊權值為所有 $T_i$ 的最小 --- ## 作業 & 練習時間 ---- Any question?
{"title":"Tree & DSU","contributors":"[{\"id\":\"4f67a8cd-06ae-45dc-a8e3-62c6a41e5a37\",\"add\":19429,\"del\":1924,\"latestUpdatedAt\":1762700985768}]","description":"11/10 preTrain"}
    173 views
   Owned this note