南海石門中學第六屆創新班 2015-5-30 課時總結
這次創新班,老師給我們講了今年考的試題。其中,涉及到了幾個重要的思想:並查集和揹包問題。
並查集的建立
所謂並查集,以我自己的理解而言,就是合併時有著進一步的速度優勢的集合。首先,我們有一定的資料,這些資料間有一定的連線,而我要找出連通塊,就需要通過合併來找出一些點是否連通。
首先,我們有一些資料。這些資料首先有一些父節點,所有的父節點初始都指向自己。
這些節點都是一些集合,一開始只有他們這一個成員。
Data: 1 2 3 4 5 6
Father:1 2 3 4 5 6
然後,我們還有一些連線。
Link:1-2
4-5
1-4
下面開始合併過程:
1.連線1和2。通過查詢可知1和2不在同一個集合裡,所以合併兩個集合。通過對Father的查詢可知,1和2都是自己所在集合的根節點。所以,將1的父節點設為2。
Data: 1 2 3 4 5 6
Father:2 2 3 4 5 6
Link:4-5
1-4
2.連線4和5。和上面的過程一樣,將4的父節點設為5。
Data: 1 2 3 4 5 6
Father:2 2 3 5 5 6
Link:1-4
3.連線1和4。在這裡找到1所在集合的根節點是2,而4所在集合的根節點是5,所以這裡就可以直接將2的父節點設為5,這樣就一次性合併了兩個集合。
Data: 1 2 3 4 5 6
Father:2 5 3 5 5 6
最後合併完畢。
那麼,並查集的合併方面的優勢又在哪裡呢?如果利用一般的思路而言,將兩個陣列的長度相加,並逐個複製到一個新陣列去,這意味著進行了多次的複製,既浪費記憶體,也浪費時間。
一開始我看到並查集時,認為這是一個類似於連結串列的結構。畢竟連結串列的合併過程也類似於此。但是,卻又不同於連結串列,因為這是樹的結構,而且連結串列的合併過程雖然可以和這一樣是O(1),但這個並查集所產生的數卻可以在查詢上進一步優化,平均下來也差不多是O(1),但連結串列卻基本上都是O(n)。
並查集的路徑壓縮
樹的結構令其找根節點無比艱難,但路徑壓縮卻能使其全部合併在一起。當樹的結構不是那麼重要時,我們就可以利用並查集加速查詢。
首先,因為樹的結構不重要,所以我們就可以讓所有的子節點全部指向根節點。那麼,這個操作什麼時候做呢?就是在查詢的時候!
過程如下:
首先,我們有一個序列,來查詢一個節點所在的集合的根節點。以剛剛我們建立的那個並查集來做例子:
Data: 1 2 3 4 5 6
Father:2 5 3 5 5 6
Search_Root:1 1
1.從1開始,先直接去找它的根節點,途徑1,和2這兩個節點。
Data: 1 2 3 4 5 6
Father:2 5 3 5 5 6
Search_Root:1
Answer_Root:5
Past_Point:1 2
2.將1的Father直接設為得出的Answer,即其所在集合的根節點,對操作中途經的其他節點也這麼做。
Data: 1 2 3 4 5 6
Father:5 5 3 5 5 6
Search_Root:1
Answer_Root:5
Past_Point:1 2
3.對剩下的節點也執行這樣的過程,可以發現,第二次尋找1的根節點時,可以一步到位。也許在這樣小的樣例下不算什麼,但如果是一個有幾千條複雜關係的超大集合,還要查詢幾百次時呢?那提升的就非常恐怖了。畢竟O(1)和O(n)的區別還是非常大的。
下面給出一個例程,先輸入有幾個點,有幾條關係,然後逐一輸入關係,最後輸入需查詢根節點的點的個數,最後輸入哪幾個點。注意,點不是從0開始計數,而是從1開始計數,如果需要輸入的從0開始計數,則請把第一行註釋掉。
#define ChooseOneFirst
#include <iostream>
using namespace std;
int main(){
int n;
cin>>n;
int point[n];
//建立整個並查集的結構
for (int i=0;i<n;++i){
point[i]=i;
}
int f,s;
int k;
cin>>k;
//建立並查集的關係
for (int i=0;i<k;++i){
cin>>f>>s;
#ifdef ChooseOneFirst
point[f-1]=s-1;
#endif
#ifndef ChooseOneFirst
point[f]=s;
#endif
}
int m;
int t,lt;
cin>>m;
int root;
for (int i=0;i<m;++i){
//f_u
cin>>t;
#ifdef ChooseOneFirst
t-=1;
#endif
root=t;
//查詢
while(point[root]!=root){
root=point[root];
}
//並查集的路徑優化
while(point[t]!=root){
lt=point[t]; //儲存當前點的父親
point[t]=root; //將當前點設定為直接指向Root
t=lt; //將當前點的父親賦回給迴圈變數
}
#ifdef ChooseOneFirst
cout<<root+1<<endl;
#endif
#ifndef ChooseOneFirst
cout<<root<<endl;
#endif
}
}
01揹包
01揹包,其實原問題是這樣的:
給定一個能裝K斤的揹包,有N種物品,每個都只能取一次,第i物品都有其相應的重量h[i]和價值w[i],問怎樣取能使得在能放得下揹包的情況下使得揹包中物品的價值最大?
其中,一件物品可分為取和不取兩種,用二進位制表示,取為1,不取為0,而這就是01揹包的本質。
最簡單的方法,就是列舉。顯然,時間複雜度為O(2^N),雖說像N=20這樣的資料不會超時,但稍微提高一點,N=30就肯定會超時了。
接下來這種方法,才是動態規劃。或者說,這種方法似乎更接近於遞推。
首先,我們有一些資料,各自代表一種物品,這些物品互相組合,可以得到一些新的物品(組成一些新的價值和重量),最後輸出價值最大且重量小於等於揹包容量的結果並輸出。實際上,這種方法類似於湊數,將一些物品湊在一起,自然就成了一個新的價值與重量。這樣的方法比剛剛的純列舉好很多,但還是不夠好,這樣的複雜度貌似是O(n^2),相當不錯了!
但是,仍然有更好的方法。
回到之前上組合數學的那幾節課的內容,我猜想:能不能將每一個物品的取與不取使某一段物品的價值引起的變化,直接關聯成一個代數公式,再用max()求出最大,最後就可以知道最大可以有多少了。這就是動態規劃的思想!
首先,這樣做滿足了動態規劃的無後效性原則,只和之前的兩個狀態(取該件物品時將前面的物品放入剩下的揹包時的最大價值和不取時的最大價值)相關;而又有我們的這一思路可知滿足最優化原理,所以:我們可以用動態規劃做這一道題目。