DP Optimization
บทนี้สรุปจากการสอนค่าย สสวท. (IOI Training Camp) เดือนมีนาคม 2025 ต่อจากบท Dynamic Programming ขั้นสูง ซึ่งเน้นการ ออกแบบ state และ transition ที่นี่สมมติว่าออกแบบ DP ได้แล้วแต่ช้าเกินไป เช่น O\left( n^{2} \right) หรือ O\left( kn^{2} \right) แล้วใช้โครงสร้างของ transition เพื่อลดเวลา
Convex Hull Trick และ Li Chao Tree
หลายโจทย์มี transition รูป
\text{ dp}\lbrack i\rbrack = \min\limits_{j < i}\left( m_{j} \cdot x_{i} + c_{j} \right)
โดย m_{j} และ c_{j} ขึ้นกับ j เท่านั้น และ x_{i} ขึ้นกับ i เท่านั้น มองแต่ละ j เป็น เส้นตรง y = m_{j}x + c_{j} คำถามจะกลายเป็น “ที่ x = x_{i} เส้นไหนต่ำสุด”
ตัวอย่าง Frog 3: กบกระโดดจากหิน j ไปหิน i เสีย \left( h_{i} - h_{j} \right)^{2} + C เมื่อกระจายกำลังสอง
\text{ dp}\lbrack i\rbrack = h_{i}^{2} + C + \min\limits_{j < i}\left( - 2h_{j} \cdot h_{i} + \text{ dp}\lbrack j\rbrack + h_{j}^{2} \right)
ได้เส้นตรง m_{j} = - 2h_{j} และ c_{j} = \text{ dp}\lbrack j\rbrack + h_{j}^{2} ซึ่ง query ที่ x = h_{i} เส้นที่ต่ำสุดในแต่ละจุดรวมกันเป็น lower envelope ซึ่งเป็นรูป convex
Li Chao tree คือ segment tree บนแกน x ที่แต่ละ node เก็บเส้นเดียว คือเส้นที่ดีที่สุด ณ จุดกึ่งกลางของช่วงนั้น เมื่อเพิ่มเส้นใหม่ เส้นที่แพ้ตรงกลางจะชนะได้แค่ครึ่งเดียว (ซ้ายหรือขวา) จึงส่งลงไปแค่ฝั่งเดียว ทั้ง insert และ query ใช้ O\left( \log X \right) เมื่อ X คือขนาดของแกน x
struct Line {
long long m, c;
long long operator()(long long x) const { return m * x + c; }
};
Line t[4 * X]; // ทุก node เริ่มเป็น {0, INF}, เก็บค่าต่ำสุด, x อยู่ในช่วง [0, X)
void insert(int v, int l, int r, Line nw) { // ช่วง [l, r)
int mid = (l + r) / 2;
bool winLeft = nw(l) < t[v](l);
bool winMid = nw(mid) < t[v](mid);
if (winMid) swap(t[v], nw); // node เก็บเส้นที่ชนะตรงกลาง
if (r - l == 1) return;
if (winLeft != winMid) insert(2 * v, l, mid, nw); // เส้นที่แพ้อาจชนะทางซ้าย
else insert(2 * v + 1, mid, r, nw); // หรือทางขวา
}
long long query(int v, int l, int r, int x) {
long long res = t[v](x);
if (r - l == 1) return res;
int mid = (l + r) / 2;
if (x < mid) return min(res, query(2 * v, l, mid, x));
return min(res, query(2 * v + 1, mid, r, x));
}
ถ้าเส้นเข้ามาเรียงตาม slope และ query เรียงตาม x ด้วย ใช้ convex hull trick แบบ deque ได้ O(1) ต่อครั้ง แต่ Li Chao tree เขียนง่ายกว่าและไม่ต้องการเงื่อนไขเรื่องลำดับ
Slope Trick
Slope trick ใช้กับ DP ที่ \text{dp}_{i}(x) เป็นฟังก์ชันของค่า x ที่
ต่อเนื่อง และเป็นเส้นตรงเป็นท่อน ๆ (piecewise linear)
slope เป็นจำนวนเต็ม
เป็น convex
แทนที่จะเก็บค่าทุก x ให้เก็บแค่ จุดหักเห (breakpoint) ที่ slope เปลี่ยนทีละ 1 ไว้ใน heap หรือ multiset การบวกฟังก์ชันอย่าง |x - a| จึงเป็นแค่การเพิ่ม breakpoint
ตัวอย่าง Increasing Array II: เพิ่มหรือลดสมาชิกทีละ 1 ให้น้อยครั้งที่สุดจนได้ array ไม่ลดลง
ให้ f_{i}(x) คือค่าใช้จ่ายน้อยสุดของ i ตัวแรกเมื่อตัวที่ i มีค่า ไม่เกิน x แล้ว f_{i}(x) = \min\limits_{y \leq x}\left( f_{i - 1}(y) + |y - a_{i}| \right) ฟังก์ชันนี้เป็น convex และไม่เพิ่มขึ้น จึงเก็บเฉพาะ breakpoint ทางซ้ายใน max-heap
บวก |x - a_{i}|: เพิ่ม breakpoint ที่ a_{i}
ถ้า breakpoint ขวาสุด (
top) มากกว่า a_{i} ค่าต่ำสุดเพิ่มขึ้นtop - a_iและการตัดส่วนที่ slope เป็นบวกทิ้ง (ขั้น \min บน y \leq x) คือการ poptopออก
priority_queue<long long> pq;
long long ans = 0;
for (long long a : arr) {
pq.push(a);
if (pq.top() > a) {
ans += pq.top() - a;
pq.pop();
pq.push(a);
}
}
ถ้าโจทย์ต้องการ เพิ่มขึ้นแท้ (strictly increasing) ให้ใช้ a_{i} - i แทน a_{i} ซึ่งเป็นโจทย์ CF 713C Sonya and Problem Wihtout a Legend
Broken Profile DP (Plug DP)
ใช้กับโจทย์ปูกระเบื้องหรือเติมตารางที่มีมิติหนึ่งเล็ก (ประมาณ n \leq 10) แทนที่จะเติมทีละ คอลัมน์ ทั้งคอลัมน์ ให้เติมทีละ ช่อง แล้วเก็บ mask n บิตเป็น “รอยต่อ” (plug หรือ profile) ระหว่างส่วนที่เติมแล้วกับส่วนที่ยังไม่เติม
ตัวอย่าง Counting Tilings: นับวิธีปูตาราง n \times m ด้วยโดมิโน 1 \times 2
เติมทีละคอลัมน์ ในคอลัมน์เติมจากบนลงล่าง ตอนอยู่ที่ช่อง (i,j) บิตที่ r < i บอกว่าช่อง (r,j + 1) ถูกจองแล้วหรือยัง และบิตที่ r \geq i บอกว่าช่อง (r,j) ถูกจองแล้วหรือยัง
vector<long long> dp(1 << n, 0), nd(1 << n);
dp[0] = 1;
for (int j = 0; j < m; j++) {
for (int i = 0; i < n; i++) {
fill(nd.begin(), nd.end(), 0);
for (int mask = 0; mask < (1 << n); mask++) {
if (dp[mask] == 0) continue;
if (mask >> i & 1) { // ช่องนี้ถูกจองแล้ว ช่องถัดไปทางขวายังว่าง
nd[mask ^ (1 << i)] = (nd[mask ^ (1 << i)] + dp[mask]) % MOD;
continue;
}
// วางแนวนอน: จองช่อง (i, j + 1)
nd[mask | (1 << i)] = (nd[mask | (1 << i)] + dp[mask]) % MOD;
// วางแนวตั้ง: จองช่อง (i + 1, j)
if (i + 1 < n && !(mask >> (i + 1) & 1))
nd[mask | (1 << (i + 1))] = (nd[mask | (1 << (i + 1))] + dp[mask]) % MOD;
}
swap(dp, nd);
}
}
// คำตอบ dp[0]: ไม่มีโดมิโนยื่นออกนอกตาราง
Time O\left( nm2^{n} \right) ถ้าเติมทีละคอลัมน์ทั้งคอลัมน์จะต้องลองทุกคู่ mask ได้ O\left( m4^{n} \right)
Divide and Conquer Optimization
ใช้กับ DP แบ่ง array เป็น k กลุ่ม
\text{ dp}_{k}\lbrack i\rbrack = \min\limits_{j < i}\left( \text{dp}_{k - 1}\lbrack j\rbrack + C(j,i) \right)
โดย C(j,i) คือค่าใช้จ่ายของกลุ่มสมาชิก j + 1 ถึง i ถ้าทำตรง ๆ ใช้ O\left( kn^{2} \right)
ให้ \text{opt}(i) คือ j ที่ดีที่สุดของ i
ถ้า \text{opt}(i) \leq \text{ opt}(i +
1) เสมอ (monotone ซึ่งเป็นจริงเมื่อ C
ทำตาม quadrangle inequality) เราคำนวณ dp[mid] ก่อน
แล้วรู้ว่าครึ่งซ้ายต้องหา j ไม่เกิน
opt(mid) และครึ่งขวาหาจาก opt(mid) ขึ้นไป
แต่ละชั้นของ recursion ตรวจรวม O(n) ตัว
จึงได้ O\left( n\log n \right) ต่อ layer
และ O\left( kn\log n \right) ทั้งหมด
const long long INF = 1e18; // ไม่ใช้ LLONG_MAX เพราะ INF + C จะ overflow
vector<long long> dp_prev(n + 1, INF), dp_cur(n + 1, INF);
void compute(int l, int r, int optl, int optr) {
if (l > r) return;
int mid = (l + r) / 2;
pair<long long, int> best = {LLONG_MAX, -1};
for (int j = optl; j <= min(mid - 1, optr); j++)
best = min(best, {dp_prev[j] + C(j, mid), j});
dp_cur[mid] = best.first;
compute(l, mid - 1, optl, best.second);
compute(mid + 1, r, best.second, optr);
}
// dp_prev[0] = 0;
// for (int g = 1; g <= k; g++) { compute(1, n, 0, n - 1); swap(dp_prev, dp_cur); }
// คำตอบ dp_prev[n]
เทคนิคใกล้เคียงกันคือ Knuth optimization สำหรับ interval DP ที่ \text{opt}\lbrack l\rbrack\lbrack r - 1\rbrack \leq \text{ opt}\lbrack l\rbrack\lbrack r\rbrack \leq \text{ opt}\lbrack l + 1\rbrack\lbrack r\rbrack ลดจาก O\left( n^{3} \right) เหลือ O\left( n^{2} \right)
WQS Binary Search (Aliens Trick)
ถ้าโจทย์บังคับให้ใช้ พอดี k กลุ่ม (หรือเลือกพอดี k ชิ้น) state ต้องมีมิติ k เพิ่มขึ้นมา แนวคิด WQS คือเอาเงื่อนไขนั้นออก แล้ว ปรับ \lambda ทุกครั้งที่เปิดกลุ่มใหม่แทน
\lambda มาก: DP จะเลือกใช้กลุ่มน้อย
\lambda น้อย: DP จะเลือกใช้กลุ่มมาก
binary search \lambda จนได้จำนวนกลุ่มพอดี k แล้วคืนค่าปรับ \lambda k
ใช้ได้เมื่อคำตอบ g(k) (ค่าที่ดีที่สุดเมื่อใช้พอดี k กลุ่ม) เป็น convex ใน k ซึ่งมักต้องเชื่อจากการทดลองด้วย brute force เล็ก ๆ มากกว่าพิสูจน์ในห้องสอบ
// solve(lambda): DP ที่ไม่มีมิติ k แต่บวก lambda ทุกครั้งที่เปิดกลุ่มใหม่
// คืน {ค่าต่ำสุด, จำนวนกลุ่มน้อยสุดที่ได้ค่านั้น} (ถ้าเสมอให้เลือกกลุ่มน้อย)
long long lo = 0, hi = MAX_SLOPE; // ขอบบนของ |g(k) - g(k + 1)|
while (lo < hi) {
long long mid = (lo + hi) / 2;
if (solve(mid).cnt <= k) hi = mid;
else lo = mid + 1;
}
long long answer = solve(lo).val - lo * k;
การ tie-break ให้นับกลุ่มน้อยสุดสำคัญมาก: บาง \lambda ให้คำตอบเท่ากันหลายจำนวนกลุ่ม และ k อาจไม่ใช่ค่าที่ DP คืนมาพอดี แต่สูตร \text{val } - \lambda k ยังถูกต้อง
Time O\left( T\log(\text{MAX\_SLOPE}) \right) เมื่อ T คือเวลาของ DP ที่ไม่มีมิติ k
โจทย์ฝึกฝน (Practice Problems)
Convex hull trick และ Li Chao tree
CF 932F Escape Through Leaf (Li Chao tree + small-to-large)
เทคนิคอื่น
Increasing Array II และ CF 713C (slope trick)
Counting Tilings (broken profile)
CF 321E Ciel and Gondolas และ CF 868F Yet Another Minimization Problem (D&C optimization)
CF 739E Gosha is hunting (WQS)
อ่านเพิ่ม
Li Chao tree: A Simple Introduction to Li-Chao Segment Tree, Li Chao Tree Extended, Convex Hull Trick Visualisation, Convex Hull Optimization
Slope trick: Slope trick explained, Tutorial Slope Trick, Slope Trick Visualised
Broken profile: An Introduction to Plug DP, DP on Broken Profile (USACO Guide)
D&C และ Knuth: Divide and Conquer DP (CP-Algorithms), Divide and Conquer Optimization, Knuth Optimization
WQS: WQS Binary Search Optimization, DP optimization from N^3 to N log^2 N, Intuition behind Aliens trick, The Trick From Aliens