Difficulty:
⭐️⭐️⭐️⭐️
# Problem Description 題敘:
Link
Easy Version - Link (單調對列優化)
- 由下而上給你 隻烏龜,你可以挑選一些烏龜,把他們融合起來成一隻,如此,他們的違和度也會相加。
- 個烏龜合出的大烏龜會有強度 的違和光芒。(斜率優化版才有)
- 一隻違和度 的烏龜如果放在烏龜塔的第 層 ,看起來會有 的違和度。(層 = 融合後,由下而上的數第 隻烏龜)
- 烏龜塔的違和度是所有烏龜的違和度減掉所有違合光芒的強度。(斜率優化版才需考慮光芒)
- 為了避免烏龜塔太矮,每個要融合的連續區段不能包含超過 隻烏龜。
- 簡易版與困難版唯一的不同就是 "光芒的有無"。
# Thinking 思考歷程:
最直覺的方法可能像是: $dp [i][j] = $ 考慮前 隻烏龜,有了 個堆。不過然後呢?我們分成了 個堆,每個堆都乘以違和度 層數?好像不太好做。怎麼辦?不會了。 (跑去看題解 (又作弊
# Solution 題解:
這題真的比較難,有滿多的門檻要跨過。光轉移就不是很好理解,題解也看了好久才有一點頭緒。我們先此題的簡單版下手,最後再把斜率優化加上去就是這題的正解。
# 狀態與轉移 💡
這個轉移式可能不太好理解,我們慢慢來。
可以觀察一個很特別的條件。違和度是乘上第 層,這意味著,若是第 層的話,根本不用算,因為 。如果我們正的做 ,越往上,要乘的層數數字會越來越大... 這.... 好像有點難算。
那... 如果倒著做 (由上而下) 呢?或許會想說: 「阿 可是這樣不是只是先乘大的層數再乘小的層數,有差嗎?-_-」,其實是有差的喔,而且若倒著做,計算方式不必那麼複雜。
觀察到這個條件後,我們就能定義狀態: 以 為最底層,考慮前 的最佳解。
之後就是轉移,我們可以得到:
其中, 的範圍的訂定是由於題目連續區段不能超過 的限制。而 倒著做 的精髓是,我們當前枚舉到的 隻是 最底層 的,所以轉移中我們只需要枚舉他的數量,再加上之前算過的答案。
又由於我們是從上往下 (序列的後面做到前面) 做 ,最底層那堆的違和度會 ,因此,不管烏龜多大隻或多小隻,跟本不用去考慮他的違和度。
踩在上述的特性上,每前進一個 ,其實就是 的答案 再多一個違和度前綴和 。為甚麼?
想像一下,因為多枚舉了最底層 (第 層),那原本的最底層 (第 層) 第 層,第 層 第 層 ... 第 層 第 層。層數一起都往上挪動而增加了 ,且每個烏龜又最多只能被融合一次,因此不管之前的分堆有多麼的複雜,都不用去理他。有點像分配律的概念,反正要求的是烏龜塔違和度 總和,所以我們只要加上一個前綴和,剛剛好。 --- Code-1
# 單調隊列優化 💡
講完了轉移,我們再回顧一下遞迴式,我們發現, 裡面的東西只跟 有關係,且每次要求 極值 (最大值),這個是一個單調隊列使用的觀察。我們看到在 一直往左走的同時, 會往右看,是一個有 時間性 的區間範圍。這是甚麼意思呢?在我們的區間內若有一個最大值 ,只要我們的 跟 的距離超過了題目給定了 ,他就超過了時限,不能再是最大值了。
於是我們用 deque
維護一個單調遞減的序列,我們也能看到,如果當前新的 值比當前 deque
最右端的 值還大的話,那麼最右端的 值就沒有用了,可以直接 pop_back()
。更進一步,若新的值實在太大,還可能一路往前砍掉許多值,直到 deque
最右端的元素比他還大,或者 deque
為空。最後再把新的 值推入 deque
。
也因為這個單調性的性質,我們每次取用最大值都能用 dq.front()
在 的時間得到,且每個值只會進、出 deque
各一次,因此我們就能把複雜從 優化到 。 --- Code-2
# 斜率優化 💡
重頭戲來了。剛剛單調對列的解法只能用來處理簡單版的烏龜疊疊樂,這題原本預設的解是斜率優化,比簡單版多了要扣掉光芒這個條件,因此,我們必須先改一下轉移式:
這樣就是我們的 的算法,直接 submit 上去顯然會 TLE. 我們想想能不能用單調對列優化... 發現好像不行耶! 裡面的東西不只有 ,有 這個鬼東西,可惡,怎麼辦?
又作一些觀察,我們先展開差的平方公式 ,再把只跟 相關的值拿出 ,可得到遞迴式:
咦?這裡是不是有點特別的感覺?這有沒有看起來像一個直線方程式?若把 當作直線方程式的 ,有沒有像 ?
若寫的更清楚一點,在 中:
,因此 就是我們這次的 值。
那 deque
裡面的元素們就會是一個函數嘛,且注意到我們的 係數,他一定是遞增的喔。每加進去一條新的線,斜率又會比上一條還要更大,這是因為 只會遞增,所以造成斜率也會是 單調遞增 的。這個時候,就滿足了使用 斜率優化 的條件。
為了以下為了說明的方便,我們可以拿以下三條線作比較:
$T_1: $ 倒數第二條線 (黑)
最後一條線 (紅)
當前新的直線 (藍)
- $NOTE: $ 以下的內容可以搭配下圖當作參考。
斜率優化跟單調對列其實還滿像的,若 在 deque
最前面的直線超過了給定的範圍 ,就會老死。
但有些地方不太一樣喔,我們不能像單調對列 只有做 以下的事: 「對於新的直線的 值,若比 當前在 deque
裡面 最右邊的直線的 值還要大的話,那麼就往前砍掉比他還小的直線 」,這樣會出問題!如果新的線無法在當時取代最後一條線,那在 就失去了 在之後的 能提早取代 的機會,意即,在往後的 中, 可能已經比 強了,但若是 還沒老死,那我們的 就會選到 而不是極值 ,這個時候就會出 BUG。要處理這個問題的話,我們可以先轉換一下,有一個很好的思維,就是 「最後再來維護 deque
的最前面要是最大值」這個特性。只要在取用 值前,用一個 while
迴圈一直讓 dq[0]
跟 dq[1]
做 值比較, 若 dq[0]
小於 dq[1]
就 ,到最後就會是我們當前要的直線了。
另一點比較不一樣的是,儘管 可能在當前無法直接取代 ,然而, 若跟 聯手,是有可能把前一條線給取代掉的喔。更詳細的說,再次強調 因為斜率一定是遞增的,看到交點就意味著前一個直線必定死亡,那麼,若 與 的 座標交點,小於 與 的 座標交點,這代表著, 在把 給殺掉前, 就已經把 給殺掉了。 永遠不會有當最大值的時候, 因此可以直接把他 掉。此動作就是俗稱的 夾殺。
交點的 座標公式 可以藉由一點簡單的國中數學推導出來:
\displaylines{\large y = ax + b \\\ \large y' = cx + d \\\ \\\ \large ax + b = cx + d \\\ \large ax - cx = d - b \\\ \\\ \large x = \frac{d - b}{a - c}}交點是求出來了,但是,這樣又會出一個問題,我們若把 給夾殺掉了,這個意思是 我們 能完全確定 用不到,也就是說,我們認為 「在 和 的交點以前, 是最佳選擇,在之後的話則是 是最佳選擇」,但其實「在 和 的交點以前」有可能 已經老死了!這個時候我們就很不幸的把以後的最佳選擇給夾殺掉了。
那為了解決這個問題,我們必須多考慮 會提早死亡的可能。因此,對於一條直線的預設死期,就是就是該直線加進去的 座標 , 我們先讓他跟 與 的 座標交點取 ,再跟 與 的 座標交點比較,來決定是否該夾殺 。
** 對於每條線的死期,我們能再換另一個角度去想,若把線想成一個 線段,也許會更直觀一點 (?)
** 這題數字很大,每一個加跟乘都很容易爆 int
,因此一定要用 long long
。
# AC Code 程式碼:
# Code-1 (單調隊列版)
#include <bits/stdc++.h> | |
using namespace std; | |
typedef long long LL; | |
const int oo = 2e9; | |
const int N = 5e5+5; | |
void _debug() {cerr << '\n';} | |
template <typename A, typename... B> | |
void _debug(A a, B... b) {cerr << a << ' ', _debug(b...);} | |
#define debug(args...) cerr << '(' << (#args) << ") : ", _debug(args) | |
LL sum[N] = {}, dp[N]; | |
int main(){ | |
int n, k; cin>>n>>k; | |
for(int i = n; i > 0; i--) cin>>sum[i]; //index 1 ~ n | |
for(int i = 0; i < n; i++) sum[i+1] += sum[i]; | |
for(int i = 0; i<=n; i++) dp[i] = -oo; | |
dp[0] = 0; | |
for(int i = 1; i <= n; i++){ | |
for(int j = max(0, i-k); j < i; j++){ | |
dp[i] = max(dp[i], dp[j] + sum[j]); | |
} | |
} | |
cout<<dp[n]<<'\n'; | |
} |
# Code-2 (單調隊列版)
#include <bits/stdc++.h> | |
using namespace std; | |
typedef long long LL; | |
const int oo = 2e9; | |
const int N = 5e5+5; | |
void _debug() {cerr << '\n';} | |
template <typename A, typename... B> | |
void _debug(A a, B... b) {cerr << a << ' ', _debug(b...);} | |
#define debug(args...) cerr << '(' << (#args) << ") : ", _debug(args) | |
LL sum[N] = {}, dp[N]; | |
int main(){ | |
ios_base::sync_with_stdio(false); cin.tie(0); cout.tie(0); | |
int n, k; cin>>n>>k; | |
for(int i = n; i > 0; i--) cin>>sum[i]; //index 1 ~ n | |
for(int i = 0; i < n; i++) sum[i+1] += sum[i]; | |
for(int i = 0; i<=n; i++) dp[i] = -oo; | |
dp[0] = 0; | |
deque<pair<LL,LL>> dq; // 大到小維護,{進來的時間,強度} | |
dq.push_back({0, 0}); // 能力一定最弱的 j | |
for(int i = 1; i <= n; i++){ | |
if(dq.empty()^1 && dq.front().first < i - k) dq.pop_front(); //Out of scope, 老死的 | |
dp[i] = dq.front().second; | |
LL val = dp[i] + sum[i]; // 這次的 i,變成下一次的 j | |
while(dq.empty()^1 && dq.back().second < val){ | |
dq.pop_back(); | |
} | |
dq.push_back({i, val}); | |
} | |
cout<<dp[n]<<'\n'; | |
} |
# Code-3 (斜率優化版)
#include <bits/stdc++.h> | |
typedef long long LL; | |
using namespace std; | |
const int N = 5e5+5; | |
LL sum[N] ={}, dp[N], n, k; | |
//dp [i] -> 以 第 i 層為最底層,考慮第 i ~ n 的最佳解 | |
struct LINE{ | |
LL a,b,x; //x 就是 i. 也就是上一條線的死期 | |
LINE(){} | |
LINE(LL _a, LL _b, LL _x): a(_a), b(_b), x(_x){} | |
}; | |
int squeez(LINE l1, LINE l2, LINE l3){ | |
return min(l1.x + k, (LL)(l2.b - l1.b)/(l1.a - l2.a)) > (l3.b - l2.b) / (l2.a - l3.a); | |
} | |
int beat(LINE f, LINE s, LL x){ | |
return f.a*x + f.b < s.a*x + s.b; | |
} | |
int main(){ | |
cin>>n>>k; | |
for(int i = n; i > 0; --i) cin>>sum[i]; | |
for(int i = 0; i < n; i++) sum[i+1] += sum[i]; | |
deque<LINE> dq; | |
dq.emplace_back(0,0,0); // 推入一個斜率一定最小線 | |
for(LL i = 1; i <= n; i++){ // 由後往前,考慮前 i 隻烏龜 | |
if(dq.empty()^1 && dq.front().x < i - k) dq.pop_front(); // 老死。deque 裡最多只能有 K 隻烏龜。 | |
//pop 掉值比較小的 | |
while(dq.size()>=2 && beat(dq[0], dq[1], i)) dq.pop_front(); | |
// 計算新線 | |
LL a = dq.front().a, b = dq.front().b; | |
dp[i] = a*i + b - i*i; | |
a = 2*i; b = dp[i] + sum[i] - i*i; // 變為下次的 max. | |
LINE T3 = LINE(a,b,i); | |
// 處理夾殺 | |
while(dq.size() >= 2 && squeez(dq[dq.size()-2], dq[dq.size()-1], T3)) dq.pop_back(); | |
// 推新的線進去 deque | |
dq.push_back(LINE(a, b, i)); | |
} | |
cout<<dp[n]<<'\n'; | |
} |
<br/>
# 學習到的新技巧 or 概念~
自己在之前從來沒有寫過單調隊列與斜率優化,寫起來不是很順手,但藉由這題,感覺學到了好多 XD
- 單調對列優化初體驗
- 斜率優化初體驗
- 比較難的題目,可以先寫個較爛的複雜度的解法丟上去,這樣思考正解的時候比較不會迷失方向。