# 第三章 :基礎資結 ## STL 標準資料結構 ### pair 一種存資料的方法,跟`struct`很像,優點是有自定義排序,會先依照前項排序再依照後項排序,||可以偷懶|| $e.g.$ ```cpp=+ pair<int, int> p1; // p1 = {0 , 0} pair<int, int> p3 = {4, 8}; cout<<p3.first<<" "<<p3.second; //4 8 pair<pair<int, int>, int> p4 = {{4, 8}, 7}; cout<<p4.first.first<<" "<<p4.first.second<<" "<<p4.second; // 4 8 7 vector<pair<int, int>> v1(100, {1, 2}); cin>>v1[0].first; vector<pair<int, int>> v2; v2.push_back({1, 2}); ``` ### queue && stack (線性容器) ![](https://github.com/FliGhtzzz/ccirc/blob/main/pictures/q.png?raw=true) **可以把`queue`想像成一個水管,只能右進左出,只能訪問最前面跟最後面 而`stack`則是像一個桶子,後進先出,只能訪問最上面** ```cpp= stack<int> s; s.push(val):將 val 加進 stack 的最上面,O(1) s.pop():將 stack 最上面的元素刪除,O(1) s.top():取得 stack 最上面的元素,O(1) queue<int> q; q.push(val):將 val 加進 queue 的最後面,O(1) q.pop():將 queue 最前面的元素刪除,O(1) q.front():取得 queue 最前面的元素,O(1) ``` ### priority_queue ![](https://github.com/FliGhtzzz/ccirc/blob/main/pictures/pq.png?raw=true) 將最重要的元素放在最頂端,只能訪問最前項,預設是將最大當作最重要 $e.g.$ ```cpp=+ priority_queue<int> pq pq.push(val):將 val 加進 pq 裡面,O(log n) • pq.pop():將 pq 最上面的元素刪除,O(log n) • pq.top():取得 pq 最上面的元素,O(1) /***************************************/ pq.push(1); pq.push(2); pq.push(2); while(!pq.empty()) { cout<<pq.top()<<" "; pq.top(); } //2 2 1 ``` * **註 : `pq`的時間常數比`set`還要低** * [進階語法](https://yuihuang.com/cpp-stl-priority-queue/) ### set&&multiset $set$主要的功能有`排序`與`去重`: 把資料送入$set$時,若$set$內已有該數值就會被刪除,若沒有就會被排序 **即所有相同資料在$set$中只能存在一個** ```cpp= set<int> s; int val=1; s.empty(); //回傳布林值:s是否為空 s.size(); //回傳整數:s的大小 s.begin(); //回傳迭代器:s最前面的位置 s.end(); //回傳迭代器:s最後位置的下一個位置 s.insert(val); //插入數值val到s中 回傳pair<iterator,bool>插入位置和是否成功 O(log n) s.count(val); //查看s中val數值的數量(set中必為1或0)O(log n) s.erase(val); //刪除s中的val元素 s.erase(iterator); //刪除iterator迭代器位置的數值 O(1) s.find(val); //查找val數值的位置 回傳iterator O(logn) ``` $multiset$是允許多個相同數值存在的$set$ 使用方法和$set$大致相同 ```cpp= multiset<int> ms; int val=1; ms.empty(); //回傳布林值:s是否為空 ms.size(); //回傳整數:s的大小 ms.begin(); //回傳迭代器:s最前面的位置 ms.end(); //回傳迭代器:s最後位置的下一個位置 ms.insert(val); //插入數值val到s中 O(log n) ms.count(val); //查看s中val數值的數量 O((val在ms中的個數)log n) ms.erase(val); //刪除s中的所有val元素 回傳整數被刪除的元素數量 O((val在ms中的個數)log n) ms.erase(iterator); //刪除iterator迭代器位置的數值 回傳iterator刪除元素的下一個位置 O(1) ms.find(val); //查找val數值的位置 若有多個數值 回傳最前面的那個 O(logn) ``` 需要注意的是,如果想要只刪除$multiset$某個元素一次而不是刪掉所有的這個元素,可以像以下的做法。 ```cpp= multiset<int> ms; ms.insert(1); ms.insert(1); ms.insert(1); ms.erase(ms.find(1)); //只會刪掉一個 cout<<ms.size(); //2 ms.erase(1); cout<<ms.size(); //0 ``` ### map ![](https://github.com/FliGhtzzz/ccirc/blob/main/pictures/mapit.png?raw=true) 用於儲存離散化的資料,像是一個陣列,但陣列索引可能是任何資料型態,特別是當需要開一個很大的陣列的時候可以考慮使用它,通常都以`map<int, int>`來做,每次對map上的東西進行操作時間複雜度都是$O(logn)$,$n$是目前在map上有多少開啟了多少個點 ```cpp= //map<key_type,value_type> mp; 這是宣告方式 map<char,int> mp; //預設每一項都是0 mp['A']; //在mp中宣告一個引索為A的數 mp['A']=1; mp['A']+=5; cout<<mp['A']; //6 mp.erase(key); //刪除該索引的 mp.find(key); //回傳該索引的位置 找不到則回傳mp.end() ``` 練習題 > [set應用](https://judge.cchs.chc.edu.tw/ShowProblem?problemid=a155) > [離散化](https://judge.cchs.chc.edu.tw/ShowProblem?problemid=a532) > [map](https://judge.cchs.chc.edu.tw/ShowProblem?problemid=a177) > [STL](https://cses.fi/problemset/task/1621) > [pair排序](https://cses.fi/problemset/task/1629/) ## 並查集 Disjoint Set [例題](https://judge.cchs.chc.edu.tw/ShowProblem?problemid=a531) 又稱互斥集或$DSU$,具有下列功能的資料結構。 > 所有元素 $P$ 編號為 $1,2,3,...,n$。 > 1. 對於$P$中的每個元素$t$都恰有一個集合包含該元素。 > 2. 把$A$,$B$兩個元素所在的集合合併到同個集合中。 > 3. 查詢$A$,$B$兩個元素所在的集合是否是同一個集合。 > 由於$STL$並沒有支援所有我們需要自己實作,值得慶幸的是,這個資料結構實作比較輕鬆。 1. 首先,我們需要紀錄每個元素的$Boss$,每個元素的$Boss$一開始都是自己,如果一個元素的$Boss$=該元素,那這個元素我們就定義它是最大的$Boss$。 2. 如果需要判斷兩個元素是否在同一個集合,可以判斷兩個元素的最大的$Boss$是否相同。 3. 如果要合併兩個集合,就把其中一個元素的最大的$Boss$指定為另外一個元素最大的的$Boss$。 範例 : ```cpp= #include<bits/stdc++.h> using namespace std; const MAXN=2e5+5; vector<int> boss(MAXN, 0); void build_dsu() { for(int i=1;i<MAX;i++) boss[i]=i; } int find(int now) { if(boss[now]=now) return now; return find(boss[now]); } bool same(int x, int y) { return find(x)==find(y); } void union(int x, int y) { int fx=find(x), fy=find(y); boss[fx]=fy; } ``` 這個程式的每次操作的時間複雜度是$O(n)$,因為可以觀察到查詢跟合併的操作都是依靠$find()$這個函式,而一個元素要找到它最大的$boss$最多需要執行$n$次,因此整體的時間複雜度式$O(Qn)$,$Q$是總共有幾次操作。 因此我們可以針對$find()$函式進行優化,我們可以發現一個元素的$Boss$的$Boss$一直到這個元素的最大的$Boss$之前,所有的$Boss$的最大的$Boss$都是相同的,因此我們可以把$find()$改成下面的程式,看起來差不多,但是每次查詢的時間複雜度根據均攤分析可以優化到$O(logn)$,這個優化我們稱之為路徑壓縮。 * [維基百科](https://en.wikipedia.org/wiki/Disjoint-set_data_structure#Time_complexity) * [OI Wiki](https://oi-wiki.org/ds/dsu/) ```cpp= int find(int now) { if(boss[now]=now) return now; return boss[now]=find(boss[now]); } ``` 快還要更快,一個很貪心且直覺的想法就是在合併兩個集合的時候,把比較小的集合的最大的$Boss$,設成比較大的集合的最大的$Boss$,一定比把比較大的集合的最大的$Boss$,設成比較小的集合的最大的$Boss$,因為很顯然的,比較大的集合在更新的最大的$Boss$時需要更多時間,因為元素更多,這樣可以把每次操作的時間優化成$O(\alpha(n))$。 > $\alpha(n)$,的中文是反阿克曼函數,增長很慢,比較直觀的是:$\alpha(2^{2^{2^{2^2}}})=4$,$2^{2^{2^{2^2}}}\approx 2\times10^{19728}$ $DSU .final$ ```cpp= #include<bits/stdc++.h> using namespace std; const MAXN=2e5+5; vector<int> boss(MAXN, 0), sz(MAXN, 1); //一開始的集合大小都是1 void build_dsu() { for(int i=1;i<MAX;i++) boss[i]=i; } int find(int now) { if(boss[now]=now) return now; return boss[now]=find(boss[now]); } bool same(int x, int y) { return find(x)==find(y); } void union(int x, int y) { int fx=find(x), fy=find(y); if(sz[fx]<sz[fy]) swap(fx, fy); sz[fx]+=sz[fy]; boss[fy]=boss[fx]; } ``` 練習題 > [DSU](https://atcoder.jp/contests/abc177/tasks/abc177_d) > [DSU](https://cses.fi/problemset/task/1676/) ## BIT 又稱作樹狀數組或二元索引樹($Binary$ $Indexed$ $Tree$ $=>$ $BIT$ ),可以在$O(logn)$的時間做到單點改值以及前墜查詢,時間常數低,空間也只要$n$,實作也簡單。 BIT上每一個節點表示的值根據它的位置編號而定,$BIT_i$定義為:以$i$為結尾,長度為$lowbit(i)$的區間和,也就是$[i-lowbit(i), i]$的和。 $lowbit(i)$的定義:把$i$轉成二進制後把除了最低位的1以外的1都變成0,例如: $$lowbit(26)_{10}=lowbit(11010)_{2}=(00010)_{2}=(10)_{2}=(2)_{10}$$ 所以$BIT_{26}$表示的就是$[24, 26]$的區間和。 在程式中可以透過`i&-i`來取得$lowbit(i)$,因為`-x`會是`x`的補數+1,所以最低位的1會變成0,但右邊所有的0都會變成1,再+1之後又會進為讓原本最低位的1從0進位成1,右邊全部的1變回0,再透過`&`運算就可以發現`i&-i`=$lowbit(i)$ 例如 $i=26$,$i=(11010)_{2}$,$-i=(1...11100101)_{2}+1=(1...11100110)_{2}$,`i&-i`=2 $BIT_0$代表空區間,$BIT_1$代表的是$[1, 1]$,所以序列的索引應該從1開始 可以把BIT畫成以下兩種形式。 * 圖一 ![image](https://hackmd.io/_uploads/SJfxS61U1x.png) * 圖二 ![image](https://hackmd.io/_uploads/HJEWH6kIJe.png) * 節點$i$的父節點是$i-lowbit(i)$ * 節點$i$的深度=2進制下$i$是1的位元數 * 節點$i$的右兄弟節點 ( 如果有的話 ) 是$i+lowbit(i)$ ### 單點改值 假設在使用$BIT$想要進行修改的動作,需要修改他的所有右兄弟節點,例如想要修改原陣列節點9的資訊,應該修改是$BIT$中包含節點9的區間:$[9, 9],[9, 10],[9, 12]$,你可以發現就是圖二的節點$9, 10, 12$,也就是節點9的右兄弟節點,實作如下。 ```cpp= void modify(int pos, int val) { //n是BIT大小 for(;pos<=n;pos+=pos&-pos) bit[pos]+=val; } ``` 可以發現$pos$會不斷增加,因此時間複雜度是$O(logn)$。 ### 區間查詢 如果要查以$i$為結尾的前綴和,需要計算節點$i$與他的祖先節點的和,因為以$i$結尾的區間就是節點$i$ ( 可以去看圖一,圖二 ) ,而節點$i$所代表的區間會完全的接在節點$i$的父節點後,因此只要計算節點$i$與他的祖先節點的和,就能算出以$i$為結尾的前綴和,實作如下。 ```cpp= //以最基礎的BIT為例 int search(int pos) { int ret=0; for(;pos;pos-=pos&-pos) { ret+=bit[pos]; } return ret; } ``` 可以發現父節點的大小會以2的冪次成長,所以時間複雜度是$O(logn)$。 ### 蓋BIT $modify$每個點,時間是$O(nlogn)$。 ### 應用 1. 求區間和: 有了前綴和當然可以求區間和,$[x, y]$的和是$[1, y]-[1, x-1],x\le y$,可以在$O(logn)$做好。 2. 區間加值: 先把原陣列做差分,可以$O(logn)$區間修改,$O(logn)$單點查詢。 3. 如果原陣列每一項都是正數,那$BIT$會是遞增的,因此可以對$BIT$做二分搜,這個技巧也被稱為$BIT$上二分,時間複雜度是$O(lognlogn)$,優化後可以$O(logn)$。 4. 高維BIT: 把$BIT$模板化,$X$維的$BIT$的每個節點存一棵$X-1$維的$BIT$。 5. 求逆序數對: 可以對陣列蓋$BIT$,每次插入$v_i$前查詢$BIT$中大於$v_i$的數量。 練習題