【學習筆記】樹上啟發式合併 概念+基礎運用
引入
現在考慮這樣一類樹上統計問題:
-
無修改操作,詢問允許離線
-
對子樹資訊進行統計(鏈上的資訊在某些條件下也可以統計
用什麼來做呢?暴力?樹上差分?樹上莫隊?
今天要介紹的這種演算法 將吊打以上
有請它閃亮登場——dsu on tree!!!
看到這個名字,嗯?dsu?不是並查集嘛(至少我第一眼是這麼想的),樹上並查集?
其實它和並查集沒有半毛錢關係,用dsu這個名字可能只是因為它和並查集都運用了一種思想——啟發式合併
什麼意思呢?還記得我們並查集的按秩合併嗎?
在這個合併的過程中 我們把小的集合合併到了大集合中去
同樣的 樹上啟發式合併實際就是把一棵高度小的子樹合併到了大的子樹去,這樣的一個優化。
演算法內容
先思考下面這個問題
給定一棵樹 樹的每個結點都有一個確定的顏色,詢問一些子樹的顏色數量(顏色可以重複)
我們先想想O(N²)的暴力做法
對於每個節點,暴力遍歷子樹,將它們的資料統計出來得到當前節點的答案,然後再暴力將這棵子樹的資料清空,以免影響到別的節點。
由於空間的限制,我們不可能對於每一個節點開一個數組來記錄資料,只能開一個全域性陣列。
在這個全域性陣列內,如果不清空,就會影響到別的子樹,答案混淆於是導致WA
但是可以發現,統計兒子節點時最後那個節點其實沒有必要清空,因為它不再會影響到它的兄弟節點。那麼該如何優化呢?
輕重鏈剖分優化
對於節點x,可以在做子樹答案時保留最後一棵子樹u的資料不清空
子樹來儲存答案,對於其他的非重兒子進行暴力,為了複雜度最優,當然是選子樹個數多的咯
於是我們可以通過重兒子進行演算法優化。
可以發現,每個節點的答案由其子樹和其本身得到,考慮利用這個性質處理問題。
首先我們可以先預處理出每個節點子樹的size和它的重兒子,重兒子同樹鏈剖分一樣,是擁有節點最多子樹的兒子,這個過程顯然可以O(n)完成
接下來,對於每一個結點,我們按照這樣的方式進行遍歷
-
遍歷所有輕兒子,遞迴結束時消除它們的貢獻
-
遍歷所有重兒子,保留它的貢獻
-
再計算當前子樹中所有輕子樹的貢獻
-
更新答案
-
如果當前點是輕兒子,消除當前子樹的貢獻
這樣,對於一個節點,我們遍歷了一次重子樹,兩次非重子樹,顯然是最划算的。
通過執行這個過程,我們獲得了這個節點所有子樹的 ans
思路已經很清晰了,通過這樣的優化,最終的時間複雜度我們發現應該為那麼總複雜度就是 O (n log n)的
最主要的原因
- 根據樹鏈剖分相關理論,每個點到根的路徑上有logn條重鏈與logn條輕鏈
- 即一個點的資訊只會上傳logn次(詳細證明暫且不寫,如需可查閱其他大佬證明)
大致的程式碼如下所示(隨便口糊的,不要真的去測,有個思路就好)
預處理:
inline void dfs1(int x,int fa){ size[x]=1; //depth for(int i=head[x];i;i=t[i].nxt){ int to=t[i].to; if(to!=fa){ dfs1(to,x); size[x]+=size[to]; if(size[to]>size[son[x]]) son[x]=to; } } }//dfs預處理
求ans
inline int dfs2(int u,int fa,int isson,int keep){ if(keep){ for(int i=head[u];i;i=t[i].nxt){ int to=t[i].to; if(to!=fa&&to!=son[u]){ dfs2(to,u,0,1); } } } int tmp=0; if(!keep&&son[u])tmp+=dfs2(son[u],u,1,0); else if(son[u]) tmp+=dfs2(son[u],u,1,1); for(int i=head[u];i;i=t[i].nxt){ int v=t[i].to; if(v!=fa&&v!=son[u]){ tmp+=dfs2(v,u,0,0); } } if(!cnt[c[u]]){ tmp++; } cnt[c[u]]++; if(keep)ans[u]=tmp; if(keep&&!isson) memset(cnt,0,sizeof(cnt)); return tmp; }
練習
給一些很不錯的例題
CF600ELomsatgelral
樹上數顏色
CF208E Blood Cousins
CF570D Tree Requests
CF246E Blood Cousins Return