ETH官方钱包

前往
大廳
主題

ZeroJudge - h319: 超級(jí)常數(shù)測(cè)試 解題心得

Not In My Back Yard | 2022-03-26 12:00:18 | 巴幣 100 | 人氣 303

題目連結(jié):


題目大意:
輸入第一列給定兩正整數(shù) n 、 q(n 、 q ≦ 5 × 10 ^ 6),代表現(xiàn)在有 n 個(gè)人依序站在位置 1 ~ n 上,並且有 q 筆的操作。

接著的 q 列輸入,每列先給定一個(gè) '?' 或是 '-',並跟隨一個(gè)正整數(shù) x(1 ≦ x ≦ n)。前者('?')代表我們要詢問(wèn)位置 x 上往右(即往 x 、 x + 1 、 x + 2 等等的方向看去,注意記得 x 本身是有被包含的)第一個(gè)還存在的人之位置為何,如果右側(cè)沒(méi)有人就輸出 -1;而後者('-')則代表我們要把位置 x 的人移出隊(duì)伍之中。

請(qǐng)輸出每次 '?' 之詢問(wèn)的結(jié)果。

(原作者注:「由於生測(cè)資的時(shí)候出現(xiàn)意外,每筆測(cè)資除了第一行以外每一行最後面都有一個(gè)空格。」)

時(shí)限:0.6s
測(cè)資量:> 50MB



範(fàn)例輸入:
5 10
? 1
- 3
? 3
- 2
? 1
? 2
- 4
? 3
- 5
? 3


範(fàn)例輸出:
1
4
1
4
5
-1


解題思維:
這題可以用併查集(Union-Find Set)解,不過(guò)不是一般常見(jiàn)的型態(tài)。因此下面就先介紹一下併查集這個(gè)資料結(jié)構(gòu)。

任何資料結(jié)構(gòu)只要滿足以下操作就可以叫做「併查集」:
建立(Make-set):顧名思義,建立一個(gè)新的集合,該集合包含一個(gè)作為參數(shù)傳入的元素 x。由於以下兩個(gè)操作的重要性較大,因此此操作通常會(huì)被某些人忽略不算在「必需」之內(nèi)。
查詢(Find):查找一元素目前位於哪個(gè)集合之中。通常一個(gè)集合會(huì)有一個(gè)代表(Representative),可以用來(lái)判斷兩集合是否相同。
合併(Union):將兩個(gè)集合合而為一。

因?yàn)橛泻蟻阋约安樵儍蓚€(gè)操作,因故而稱為併查集(Union-Find set)。



而併查集常見(jiàn)的有兩種表示法,一種為連結(jié)串列(Linked List)、另一種為森林(Forest)。因?yàn)榻^大部分的時(shí)候只會(huì)用到森林,因此以下只介紹森林的表示法:
下圖為一個(gè)有著兩棵樹(shù)的例子。
其中每棵樹(shù)代表一個(gè)集合,且 e 和 f 為各自的樹(shù)之根節(jié)點(diǎn)(root)以及「代表」。

可以看到在這樣的表示法之下,每個(gè)操作的時(shí)間複雜度為:
Make-Set(x):O(1),因?yàn)橹皇且⒁粋€(gè)新的節(jié)點(diǎn)而已。
Find(x):O(h),其中 h 為 x 所在的樹(shù)之高度,因?yàn)槲覀円獜?x 循著邊跑到 root。
Union(x, y):O(h),因?yàn)橐日业?x 的根節(jié)點(diǎn)以及 y 的根節(jié)點(diǎn),再將 x 的指向 y 的(反過(guò)來(lái)指也可以)。



而我們有兩種啟發(fā)式(Heuristic)可以最佳化上述操作時(shí)間複雜度的方式:
第一種為按秩合併(Union-by-rank),其代表的意思為在合併兩棵樹(shù)的時(shí)候把比較矮的(或是節(jié)點(diǎn)數(shù)少的)根節(jié)點(diǎn)指向比較高的(或是節(jié)點(diǎn)數(shù)多的)根節(jié)點(diǎn)。

因?yàn)榘臉?shù)中的節(jié)點(diǎn)被詢問(wèn)「代表」的比例比起高的樹(shù)中的節(jié)點(diǎn)還要來(lái)得低。因此在大部分情形下(或是以平均來(lái)看),高的樹(shù)節(jié)點(diǎn)多走了一層會(huì)比矮的樹(shù)節(jié)點(diǎn)多走一層還要虧。以上面的圖為例,把兩棵樹(shù)合併的話會(huì)是這樣:


第二種為路徑壓縮(Path Compression),即當(dāng)每次呼叫為 Find(x) 找到 x 所在的樹(shù)之根節(jié)點(diǎn) r 後,把 x 改成直接指向 r。承上例,當(dāng)我們呼叫完 Find(c) 後,我們會(huì)得到:
呼叫為 Find(d) 後,我們會(huì)得到:

如果我們把上述兩種策略一起使用的話則就會(huì)變成一般我們常見(jiàn)到的併查集(Union-Find Set)之形式,其中均攤下(Amortized)單次 Find 和 Union 的操作之時(shí)間複雜度變?yōu)?α(n),其中 n 為全部元素的總數(shù)、α(n) 為反阿克曼函數(shù)(Inverse Ackermann Function),其函數(shù)值成長(zhǎng)速度極慢(例如若 n = 2 ^ 2 ^ 2 …… ^ 2 之 k 層的指數(shù)塔,則 α(n) = k)。

有興趣探究為何是這樣的時(shí)間複雜度且英文程度尚可的讀者,可以考慮參見(jiàn)演算法的經(jīng)典教科書(shū)——Introduction to Algorithms, Third Edition(或以四位作者的名字之開(kāi)頭字母稱為 CLRS)裡的第 21 章。

實(shí)際上遇到需要併查集的問(wèn)題時(shí),實(shí)作森林的方式基本上不是真的用好幾個(gè)樹(shù)節(jié)點(diǎn)來(lái)表示,而是直接把所有東西攤平成若干個(gè)陣列來(lái)儲(chǔ)存。例如現(xiàn)在每個(gè)元素剛好是編號(hào) 1 ~ n,那麼我們就直接利用一個(gè)陣列 parents 來(lái)代表每個(gè)元素所指向的元素;而秩(rank)可以用另一個(gè)陣列來(lái)表示。



回到本題原先的目標(biāo)——我們要找到從每個(gè)位置 x 開(kāi)始往右第一個(gè)遇到有人在的位置(如果都沒(méi)有人,則為 -1)。正如一開(kāi)始預(yù)告的,本題可以使用並查集解出。那麼要怎麼做呢?一樣,我們一開(kāi)始有一個(gè)長(zhǎng)度為 n + 2 的陣列 parents,其中 parents[i] 代表從位置 i 開(kāi)始往右第一個(gè)有人的位置。

一開(kāi)始對(duì)於所有 i = 1 ~ n 之值 parents[i] = i,因?yàn)槊總€(gè)位置都有站一個(gè)人。然後為了實(shí)作方便 parents[n + 1] = -1,代表再往右就沒(méi)有人了。因此,我們可以看到每個(gè)位置 x 所在的集合之「代表」,即為從 x 開(kāi)始往右第一個(gè)有人的位置。

現(xiàn)在定義一個(gè)函式 Find(x),其可以找到 x 所在集合之「代表」。由於現(xiàn)在我們還沒(méi)套用任何最佳化的策略,所以我們的 Find 函式只有
while (parents[x] != x && parents[x] != -1)
  ++x;
return parents[x];
之內(nèi)容,也就是相當(dāng)暴力地去往右找第一個(gè)有人的位置。

那麼現(xiàn)在我們便可以把題目定義的兩種指令,'?' 以及 '-' 給實(shí)作出來(lái)。

因?yàn)?'?' 就是詢問(wèn)本身,所以我們輸出 Find(x) 即可;而 '-' 是要移除位置 x 上的人,因此我們可以視為位置 x 所在的集合與位置 x + 1 所在的集合合併,因此 parents[x] = Find(x + 1),可以看到我們把原先併查集的 Union 操作藏在了這裡。



接著我們?cè)囍子每纯聪惹疤岬降膬煞N啟發(fā)式的最佳化策略,即按秩合併以及路徑壓縮。

首先可以看到我們所有位置 x 的「箭頭」都是指向右邊(也就是 x + 1 之方向)。而按秩合併是看兩者集合之高度或大小來(lái)決定合併方向,因此有可能會(huì)使箭頭往左指,而這不是我們樂(lè)見(jiàn)的結(jié)果。因此無(wú)法套用按秩合併。

接著是路徑壓縮,而這個(gè)策略是可以套用的。因此現(xiàn)在我們的 Find 函式會(huì)變得像是如下的結(jié)果:
第一部分是在找出「代表」,如下:
int index = x;
while (parents[index] != x && parents[index] != -1)
  index = parents[index];
int result = parents[index];

第二部分是把路上經(jīng)過(guò)所有位置的「代表」變成我們剛剛找到的「代表」,而這個(gè)行為即是路徑壓縮(Path Compression):
int buffer = x;
while (buffer < index) {
  int temporary = parents[buffer];
  parents[buffer] = result;
  buffer = temporary;
}
return result;

而這樣便完成了我們的併查集最佳化。

那麼總體時(shí)間複雜度呢?可以看到我們有 n 個(gè)位置,所以有 n 個(gè) O(1) 時(shí)間的 Make-Set 以及至多 n - 1 次的 Union,並假設(shè)我們總共呼叫 Find() 函式 f 次。由於我們的併查集只有使用路徑壓縮的策略,因此跟經(jīng)常使用到的併查集不一樣,我們的時(shí)間(根據(jù) CLRS)會(huì)是
也就是當(dāng) f 之值越大時(shí),單次 Find() 平均所花的時(shí)間越少。



但是很不幸的是本題到這裡並還沒(méi)有結(jié)束,可以看到本題的測(cè)資量異常地大且時(shí)限非常地緊。所以我們需要使用如這題中所使用的輸出入最佳化以及編譯器最佳化等等之手段。




此次分享到此為止,如有任何更加簡(jiǎn)潔的想法或是有說(shuō)明不清楚之地方,也煩請(qǐng)各位大大撥冗討論。

創(chuàng)作回應(yīng)

雞塊
為什麼不要直接用 LinkedList 解...
2022-03-26 23:24:08
Not In My Back Yard

當(dāng)然可以啊,只是我比較習(xí)慣併查集。畢竟陣列很好用。
2022-03-26 23:26:15
Not In My Back Yard
而且我本來(lái)就想要好好介紹一下併查集了,所以趁這個(gè)機(jī)會(huì)。
2022-03-26 23:27:06
雞塊
既然要用並查集的話
可以在並查集維護(hù)當(dāng)前集合的最大值 這樣就能繼續(xù)套啟發(fā)式合併達(dá)到 alpha(n)
2022-03-27 00:39:38
Not In My Back Yard
唉呀?jīng)]想到這點(diǎn)。確實(shí),這樣如此就可以套按秩合併了。感謝你的建議。
2022-03-27 01:21:01

更多創(chuàng)作