tags |
---|
成大高階競技程式設計 2020, ys |
本章先以最大連續和問題探討常見的設計演算法切入點:
- 枚舉
- 分治
- 動態規劃
- 貪心
接著會對各個常見思考方式提供一些範例題目
給定一個長度為
注意 數列中可能會有負數
所謂枚舉,通俗點說就是數出部份給定的集合中元素。 下面直接給出程式來解決最大連續和問題:
int best = A[1]; //與其用無限小,不如這樣初始化更不易出錯
for (int L = 1; L <= N; i++) {
for (int R = L; R <= N; R++) {
int sum = 0;
for (int k = L; k <= R; k++) sum += A[k];
best = max(best, sum);
}
}
通常枚舉會做為解題的起手式,有了正確性再考量改進方法 枚舉可將困難的求值問題化為簡單的判定問題
雖然計算量可能會變高,但針對問題特性能再改進
仔細觀察上面的演算法,會發現遞增
for(int L = 0; L <= N; L++) {
int sum = 0;
for(int R = L; R <= N; R++) {
sum += A[R];
best = max(best, sum);
}
}
GCJ Kickstart Round G 2018 A Product Triplets: Small dataset
分治 (divide & conquer) 簡稱 D&C,就是將一個大的問題,分成幾個互相獨立的子問題,然後再將子問題分成子子問題1,一直重複分割的動作,直到最小的問題足夠和別的最小問題合併求解出父問題。
將數列切一半,問左半的以及右半的最大連續和多少,以及問包含切開的那道分水嶺的最大連續和為多少,選出三者中最大值,它就是整個數列(原問題)的最大連續和:
int maxsum(int l, int r) { // 此為左閉右開區間 [l, r)
if (r-l == 1) return A[l];
int m = (r+l)/2, sum, centre = A[m];
sum = 0;
for (int i = m; i < r; i++)
centre = max(centre, sum += A[i]);
sum = centre;
for (int i = m-1; i >= l; i--)
centre = max(centre, sum += A[i]);
return max(centre, max(maxsum(l, m), maxsum(m, r)));
}
要驗證分治法的正確性,只需考慮子問題2們解完後(假設已拿到解),再合併為父問題看是否解完即可,並考慮最小的孫子問題到的邊界是否正確。
部份朋友可能知道可以令
構造
S[0] = 0;
for (int i = 1; i <= N; i++) S[i] = S[i-1] + A[i];
從邊界遞推地記錄所有問題的解,且一個項用到前一項的最佳結果,就是動態規劃的精神。 通常應用動態規劃思考的問題會是求極值或是確定值的問題。
$S_i$ 的值就是個確定值的例子
而程式可改為:
for (int L = 1; L <= N; L++)
for (int R = L; R <= N; R++) best = max(best, S[R] - S[L-1]);
複雜度為
籠統的講,每次做一個在當下看起來最佳的決策,進而漸漸求出全局最佳解
這種短視近利的心態,居然也是個不錯的思維( 貪心法是動態規劃的特例,貪心可用動態規劃的角度去看
一直累加只會增大的話就繼續累加 而若是途中會減少,但後面還會有正數,所以繼續加 但如果累計值為負的話,不如捨棄掉這個累計值
int best = A[N], sum = 0;
for (int R = 1; R <= N; R++) {
sum = max(A[R], sum + A[R]);
best = max(best, sum);
}
:::info
給定一長度 $N$ 字串
例如
回文為正著看與反著看一樣的字
而在其中,$\text{aba, bab}$ 是最長的,所以答案為 3
可以採用上面連續和的做法,先將每個區間數出來,再判斷其是否為回文
int ans = 0;
for(int L = 0; L < N; L++)
for(int R = L; R < N; R++)
if(is_palindrome(L, R)) ans = max(ans, R-L+1);
但判斷字串為回文 (is_palindrome(L, R)
) 需
由於要正著和反著一一比對
仔細觀察回文字串的特性,回文的組成是兩字元相同成對的
當有回文
所以對每個字元往外擴充,或是從字元跟字元的隙縫擴充,看是否為回文
int ans = 1;
// 從單個字元擴充
for(int i = 0; i < N; i++)
for(int l = 1; i-l >= 0 && i+l < N; l++) {
if(S[i-l] != S[i+l]) break;
ans = max(ans, l*2 + 1);
}
// 從字元跟字元間擴充
for(int i = 0; i < N; i++)
for(int l = 1; i-l >= 0 && i+l-1 < N; l++) {
if(S[i-l] != S[i+l-1]) break;
ans = max(ans, l*2);
}
也就是說找對對象很重要,若不研究題目性質而草率枚舉效率也就一般般。
先數一下到底有幾個九,好,共
$15$ 個
若是九九乘法表(九只有兩個)應該能很輕易的寫出來吧?
for(int L = 1; L <= 9; L++)
for(int R = 1; R <= 9; R++)
printf("%d * %d = %d\n", L, R, L*R);
但
int const depth = 15;
void dfs(int i, long long res) { // res := result
if(i > depth) {
for(int j = 1; j <= depth; j++)
printf("%d %c ", q[j], (j != depth)? '*' : '=');
printf("%lld\n", res);
return;
}
for(int j = 1; j <= depth; j++) {
q[i] = j;
dfs(i+1, res * j);
}
}
呼叫函式為 dfs(1, 1)
如果好奇
dfs
是甚麼的話,第五週會教 DFS (Depth-First Search)
範例 TIOJ 1080 A.逆序數對:
:::info
給長度為
先試試枚舉:
int cnt = 0;
for(int i = 0; i < n; i++)
for(int j = i+1; j < n; j++)
if(a[i] > a[j]) cnt++;
了無新意,來開始研究題目的性質吧 一次要處理太多數字很不知所措,那麼就先觀察小規模的問題
這不是廢話嗎?
但小規模的話可能會一對對數字去看,大規模則可以切成兩區去比對 這給了一種解法的動機:分而治之
每次切成兩個區塊,區塊內的數對假設已計算好了 接著從分界的兩邊去看,左區若是有比右區還大的數字,就記一筆
假設左區有
int cnt = 0;
for(int i = 0; i < N; i++)
for(int j = 0; j < M; j++)
if(a[i] > b[j]) cnt++;
整個完整程式碼就變成如下:
int count(int l, int r) { // [l, r) 左閉右開區間
if(r-l == 1) return 0;
int m = (l+r)/2, cnt = 0;
cnt += count(l, m);
cnt += count(m, r);
for(int i = l; i < m; i++)
for(int j = m; j < r; j++)
if(a[i] > a[j]) cnt++;
return cnt;
}
但估算一下,這複雜度不是沒改善嗎?
再回去觀察一下問題,例如
從中間切開的話則為
這個給了個契機讓左區的數們都按照升序排列
也讓右區的數字升序的話,在遇到右區數
int count(int l, int r) { // [l, r)
if(r-l == 1) return 0;
int m = (l+r)/2, cnt = 0;
cnt += count(l, m);
cnt += count(m, r);
vector<int> b; // 保存升序數列
int j = m;
for(int i = l; i < m; i++) {
while(j < r && a[i] > a[j]) b.push_back(a[j++]);
b.push_back(a[i]);
cnt += j-m;
}
while(j < r) b.push_back(a[j++]);
copy(b.begin(), b.end(), a+l);
return cnt;
}
若這節沒法好好理解,先看個感覺就行,第七週將會有動態規劃的完整介紹
:::info
上樓梯每走一次可以走
若
得知造成答案變動的決策就只有每次要走
在程式中,$dp(i-1), dp(i-2)$ 會是已經解完的狀態(子問題)
而透過這兩個狀態,能將尚未求解的
dp[0] = dp[1] = 1;
for(int i = 2; i <= n; i++) dp[i] = dp[i-1] + dp[i-2];
這其實就是在求費式數
:::info
給定
例如
1 | 2 | 3 |
---|---|---|
6 | 5 | 4 |
7 | 8 | 9 |
粗體路徑上面的數加總起來比其他路徑加總的和還要小 |
根據題目,每次能做的決策只有兩個:往右、往下
往右,那麼位置
定義
for(int t = 1; t <= max(M, N); t++) dp[0][t] = dp[t][0] = 0; // 對豎軸或橫軸為 0 初始化
for(int i = 1; i <= M; i++)
for(int j = 1; j <= N; j++)
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + a[i][j];
注意到,當 token 為
可以從邊界觀察出解法切入點
一局先手贏則後手輸,反之亦然;所以看先手狀態即可
先手輸贏只看移動決策得知,即狀態轉移只看從左來或右來
所以狀態為
從
因為先手 Alice 只要從
$i$ 移動到$j$ ,後手 Bob 就無法移動了
for(int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
idx[a[i]] = i;
}
memset(w, false, sizeof w); // w[n] 已解先手必輸
for(int ai = n-1; ai >= 1; ai--) {
for(int j = idx[ai]%ai; j <= n; j+=ai)
if(a[j] > ai && !w[j]) w[idx[ai]] = true;
}
idx
是記錄每個數字分別對應的位置
注意計算順序從 n-1
減至 1
,因為對於欲解
沿用最小路徑和的作法, 可將左上與左下到見面點,以及見面點到右上與右下的最大和都求出來
for(int i = 1; i <= n; i++)
for(int j = 1; j <= m; j++)
LT_mt[i][j] = a[i][j] + max(LT_mt[i-1][j], LT_mt[i][j-1]);
for(int i = n; i >= 1; i--)
for(int j = m; j >= 1; j--)
RB_mt[i][j] = a[i][j] + max(RB_mt[i+1][j], RB_mt[i][j+1]);
for(int i = n; i >= 1; i--)
for(int j = 1; j <= m; j++)
LB_mt[i][j] = a[i][j] + max(LB_mt[i+1][j], LB_mt[i][j-1]);
for(int i = 1; i <= n; i++)
for(int j = m; j >= 1; j--)
RT_mt[i][j] = a[i][j] + max(RT_mt[i-1][j], RT_mt[i][j+1]);
注意題意,若是他們只能見面恰好一次,
那麼對於見面的點
於是,將所有可能的見面點都考慮可得:
int best = 0;
for(int i = 2; i < n; i++)
for(int j = 2; j < m; j++)
best = max({best, LT_mt[i-1][j]+RB_mt[i+1][j] + LB_mt[i][j-1]+RT_mt[i][j+1],
LT_mt[i][j-1]+RB_mt[i][j+1] + LB_mt[i+1][j]+RT_mt[i-1][j]});
:::info
給你一把吉他,和
一般人類
$L = 5$ ,因為只有$5$ 根手指,但假設非人類存在(e.g. 外星人、機器人)
明顯的,定義狀態
但等一下,$y$ 是甚麼?這裡並沒有提出該用哪根手指彈下個音符! 於是需要提供更多資訊,好讓狀態能滿足問題所求
memset(dp, 0x3f, sizeof dp); // 初始為無限大
for(int x = 1; x <= L; x++) dp[N][x] = 0; // 邊界
for(int i = N-1; i >= 1; i--)
for(int x = 1; x <= L; x++)
for(int y = 1; y <= L; y++)
dp[i][x] = min(dp[i][x], dp[i+1][y] + d(a[i], x, a[i+1], y));
int best = 0x3f3f3f3f;
for(int x = 1; x <= L; x++) best = min(best, dp[1][x]);
Longest Increasing Subsequence 中譯為 最長遞增子序列
:::info
在給定
例如
仔細考慮,若某數字在某遞增子序列後出現, 且它比此序列的末項還大,那麼加入它就能形成更長的遞增子序列! 不過在那之前,這個遞增序列是如何求得的?
通過上述,定義狀態
意思是在
$a_1, a_2, .., a_n$ 之間找個一定要包含$a_n$ 的 LIS
且狀態轉移方程為
也就是找出所有在
$a_n$ 之前的遞增子序列,選出最長的
若找不到
for (int n = 1; n <= N; n++) { // N 為 a 的總長度
S[n] = 1, f[n] = n;
for (int i = 1; i <= n; i++)
if (a[i] < a[n] && S[n] < S[i] + 1) {
S[n] = S[i] + 1;
f[n] = i; // 紀錄遞增子序列
}
}
複雜度為
其中 f[n]
代表遞增子序列中 a[f[n]]
下個接 a[n]
例如
所以利用 f
就能將其中一個 LIS 輸出!
$f_i = i$ 表示$a_i$ 為欲輸出的 LIS 首項