tags |
---|
成大高階競技程式設計 2020, ys |
這週要介紹的搜尋方法是以數列呈現
顧名思義,是將集合的所有子集挑出來,所有可能子集的集合又稱冪集合1
例如
void powerset(int dep) {
if(dep == N) {
for (int i = 0; i < N; i++) printf("%d ", bit[i]);
putchar('\n');
return;
}
bit[dep] = 1;
powerset(dep+1);
bit[dep] = 0;
powerset(dep+1);
}
1 1 1
1 1 0
1 0 1
1 0 0
0 1 1
0 1 0
0 0 1
0 0 0
for (int i = 0; i < (1<<N); i++) {
for (int p = 0; p < N; p++) printf("%d ", bool(i&(1<<p)));
putchar('\n');
}
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
在許多場合,我們不一定只有 0 和 1 兩種佔位符 (placeholder),可能有三種甚至更多 所以遞迴法或許比二進制法還要更為泛用。
許多文章介紹二分搜沒交代好前提,是採用大量案例佐證二分搜的實作很正確。 我認為這樣不優,這裡會花較多的篇幅把一些約定俗成的事情交代清楚再給出實作
給定一個
考慮數列
- 可能的 lower bound index 為
0
,1
- 可能的 upper bound index 為
3
,4
,5
,6
,7
, ..
當然,通常會希望 upper bound 與 lower bound 越緊越好
所以上面拿的 upper bound 要是 3
、lower bound 要是 1
才好?
在繼續進到實作之前,先討論信仰
upper bound 與 lower bound 真的越緊越好嗎?
以上面例子,有沒有可能 lower bound 取 0
、upper bound 取 4
在應用中會比較易用?
給定長度
大部份習慣數數從 0
開始3。
-
總之,對於這個數列,可以用整數子集
$[0, N)$ 寫下他的 index 範圍 這會比符號$[0, N-1]$ 來的簡潔一些。 -
而且若是問
$[l, r)$ 的長度為何? 只需計算$r-l$ 但問$[l, r]$ ,就得計算$r-l+1$ -
對所有自然數除以
$P$ 4 的餘數收集起來,會有${[0], [1], .., [P-1]}$ ,表達這件事只要寫$[0, P)$ -
有時會需要用到空區間這個狀態,這時可以
$[l, l)$ -
先前提及的,C++的 STL 中,迭代器是以左閉右開區間來實作的。
講這麼多左閉右開區間的好(?),回到信仰話題,
普遍實作中會認為 lower bound 為 1
、upper bound 為 4
會比較好。
==(以後 lower bound & upper bound 若沒特別設定,都依此慣例)==
若要搜尋的數不在數列中,那應該輸出哪個 index? 普遍實作會輸出當此數插入到這個數列中它最適合的位置 何謂最適合?就是要保持著數列仍然單調5。
對於數列的 index 區間
而對於所有可能輸出的 bound,可以用空區間表示:
也就是
先簡單思考,用最直覺的枚舉方式
來實作找 lower bound 的演算法吧:
假設
簡單的作法就是,一直遞增左界:
- 最後發現
$A_k$ 就是目標,這樣就能求得$[k, k)$ - 或著發現
$A_k$ 大於目標,$[k, k)$ 還是解 (最適合原則)
while (l != r) {
int m = l;
if (A[m] >= target) r = l;
else l = m + 1;
}
return l;
另一個從右界遞減的演算法:
while (l != r) {
int m = r - 1;
if (A[m] >= target) r = m;
else l = r;
}
return l;
兩個演算法都使 l
, r
相等且得到
回到小節標題,二分搜?這名字就是演算法的動機,將數列切成兩份以做到搜尋: 將上述演算法合併起來會得到
/* random lower bound search*/
while (l != r) {
int m = l + rand()%(r-l); // 切成兩份
if (A[m] >= target) r = m;
else l = m + 1;
}
return l;
因為不知道 m
該遵從哪個演算法,就改成在區間中隨機挑了
這一步非常重要,先思考這樣寫==真的正確嗎?==
而 lower bound 普遍的二分搜實作,就僅把 m
改成均等的兩份:
int m = (l+r)/2;
m
其實就是 middle 的縮寫哦
而 upper bound 的二分搜實作也是類似的:
/* upper bound */
while (l != r) {
int m = (l+r)/2;
if (A[m] <= target) l = m + 1;
else r = m;
}
return l;
二分搜複雜度為
同學就跟著 lower bound 的發明過程,試實作 upper bound 二分搜!
光只會使用 STL 中的std::lower_bound
, std::upper_bound
函數還不夠對付所有問題,因應不同場合常得親自設計 (e.g. 三分搜、隱式數列)
:::spoiler 在第三週教材的練習中,這題的 small dataset 很輕易的就能用枚舉做出來,但對於 large dataset,$O(N^3)$ 就不夠快了。
仔細想想,雖然對數列排序後不會使
乘積
sort(A, A+N);
long long cnt = 0; // cnt := counter
for (int i = 0; i < N-1; i++) {
for (int j = i+1; j < N; j++) {
long long t = A[i]*A[j]; // t := target
if (t || !A[j]) cnt += upper_bound(A+j+1, A+N, t) - lower_bound(A+j+1, A+N, t);
else cnt += upper_bound(A+i+1, A+j, 0) - lower_bound(A+i+1, A+j, 0);
}
}
此題其實還能讓複雜度從
範例 TIOJ 1432 骨牌遊戲:
:::spoiler
int const maxn = 1001 + 10;
簡單的,採用枚舉的方式去找出哪個"最大傷害強度"是可行的 把可行的"最大傷害強度"找出來,接著在其之中把最小值輸出就行了
首先做出一個可判定此"最大傷害強度"是否可行的函數:
bool check(int strength) {
int cost = 0, cnt = 0;
for (int i = 0; i < n; i++) {
cost += s[i];
if (cost > strength) cost = s[i], cnt++;
}
return cnt <= w;
}
接著枚舉一下:
int l = *max_element(s, s+n), r = maxn*maxn;
for (int i = l; i <= r; i++)
if (check(i)) return i;
可以發現到,第一個遇到可行的"最大傷害強度" (strength
) 就是題目要求的最終答案。
但可惜的是,這樣
其中
$N^2$ 來自於r = maxn*maxn
研究一下 check
函數可知,因為每次把 strength
條大,會造成 cnt
越來越小,所以返回的 bool
值形成一個單調數列,
於是,可以使用二分搜去改進原本枚舉的做法:
while (l != r) {
int m = (l+r)/2;
if (check(m)) r = m;
else l = m+1;
}
return l;
複雜度改進成
:::info
在給定
$a = (\textbf{1}, 4, \textbf{2}, 3, 8, \textbf{3}, \textbf{4}, 1, \textbf{9})$ 則 LIS 為$(1, 2, 3, 4, 9)$ 或$(1, 2, 3, 8, 9)$ 建議複習第三週教材的 LIS 做法
:::spoiler
貪心的看,當前 LIS 末項數字越小,那麼越有可能使得 LIS 繼續變長
例如
$(1, 4, 2)$ 有 LIS$(1, 4), (1, 2)$ ,但之後欲接$3$ ,則只有$(1, 2)$ 能接得$(1, 2, 3)$
則定義狀態
例如
$a$ 的$i = 5$ 前綴為$(\textbf{1}, 4, \textbf{2}, 3, 8)$ ,則$S(5, 2) = 2$
求解
綜合上述,狀態轉移方程: $$ S(i, l) = \begin{cases} \min(S(i-1, l), a_i) &\text{if } a_i > S(i-1, l-1) \ S(i-1, l) &\text{otherwise} \end{cases} $$
$S(i, l)$ 有可能無解, 或許找不到長度為$l$ 的 LIS
邊界:
S[1][1] = a[1];
int L = 1; // LIS 當前長度,由於現在只解出 i = 1 時的狀態
for(int i = 2; i <= N; i++) {
for(int l = 1; l <= L; l++) {
if(a[i] > S[i-1][l-1]) S[i][l] = min(S[i-1][l], a[i]);
else S[i][l] = S[i-1][l];
}
if(a[i] > S[i-1][L]) L++, S[i][L] = a[i]; // 考慮讓 LIS 增長
}
有了
for(int l = 1; l <= L; l++) printf("%d ", S[n-L+l][l]);
由於狀態的解是
S[1] = a[1];
int L = 1;
for(int i = 1, j; i <= N; i++) { // j 紀錄哪個 S[l] 被解
for(int l = 1; l <= L; l++)
if(a[i] > S[l-1] && a[i] <= S[l]) j = l;
if(a[i] > S[L]) L++, j = L; // 考慮讓 LIS 增長
// 紀錄 LIS 的樣子
p[j] = i;
if(j > 1) f[i] = p[j-1];
else f[i] = i;
S[j] = a[i];
}
但因為把前綴 f
紀錄 LIS
其中 p[j]
為 S[j]
在原本
如第三週所教的操作,可將 LIS 利用 f
印出
void print_LIS(int i) {
if(i == f[i]) {
printf("%d ", a[i]);
return;
}
print_LIS(f[i]);
printf("%d ", a[i]);
}
等一等...
仔細觀察 a[i]
解 s[j]
時,就使用二分搜!
for(int i = 1; i <= N; i++) {
int j = lower_bound(S+1, S+1+L, a[i]) - S;
if(j > L) L++;
p[j] = i;
if(j > 1) f[i] = p[j-1];
else f[i] = i;
S[j] = a[i];
}
複雜度為
UVa OJ 10131 Is Bigger Smarter? UVa OJ 10534 Wavio Sequence UVa OJ 437 The Tower of Babylon CODEFORCES 1257E The Contest
:::
Sprout OJ 48 二元搜尋樹 AIZU Online Judge 0524 星座探し CODEFORCES 1118D Coffee and Coursework CODEFORCES 1111C Creative Snap GCJ Kickstart Round G 2018 B Combining Classes: Small dataset CODEFORCES 1263C Everyone is a Winner! CODEFORCES 1077D Cutting Out