ปูพื้นฐาน
วิธีอ่านโจทย์ให้เจอสัญญาณว่า dp[i] ที่ใช้ได้มาตลอดกำลังจะพัง แล้วพาเปลี่ยน state จากจุดเป็นช่วง พร้อมเงื่อนไข "ตรงกลางต้องเกลี้ยง" ที่ทำให้ของคนละมุมมาเจอกันได้ พร้อมโจทย์ฝึก 3 ข้อที่ไล่ระดับกัน
ถ้าคุณเคยเขียน DP มาบ้าง คุณจะมีท่าประจำอยู่ท่าหนึ่ง คือตั้ง dp[i] ให้แปลว่า
"ตัดสินใจกับ i ตัวแรกครบแล้ว ได้ค่าดีที่สุดเท่าไร" แล้วไล่จากซ้ายไปขวา ท่านี้ใช้ได้กับโจทย์จำนวนมหาศาล
จนพอเจอโจทย์ที่มีคำว่า "ลบแล้วช่องปิด" ครั้งแรก คุณจะเขียนไปได้ครึ่งทางแล้วรู้สึกว่ามันไม่เข้ารูป
แต่บอกไม่ถูกว่าไม่เข้าตรงไหน บทนี้จะพาไปดูว่ามันพังตรงไหน แล้วของที่ต้องใช้แทนหน้าตาเป็นยังไง
โจทย์ประจำบท · เก็บจานบนโต๊ะยาว
บนโต๊ะมีจาน n ใบวางเรียงกันเป็นแถว ใบที่ i มีขนาด a[i] นิ้ว
คุณหยิบจาน สองใบที่อยู่ติดกัน ซ้อนกันแล้วยกเข้าตู้ได้ ถ้าขนาดต่างกันไม่เกิน 1 นิ้ว
พอยกออกไป จานที่อยู่สองข้างของช่องว่างจะเลื่อนมาชิดกัน แล้วนับเป็นคู่ติดกันได้ทันที
ทำซ้ำกี่ครั้งก็ได้ คำถามคือสุดท้ายเหลือจานบนโต๊ะน้อยที่สุดกี่ใบ
ลองกับแถวสั้น ๆ แถวหนึ่ง 1 2 3 1 3 ท่าแรกที่เกือบทุกคนลองคือกวาดจากซ้ายไปขวา
เจอคู่ที่ซ้อนได้ก็ซ้อนทันที เริ่มที่ใบ 1 กับ 2 ต่างกัน 1 นิ้วพอดี ยกเลย เหลือ 3 1 3
ซึ่งไม่มีคู่ไหนต่างกันไม่เกิน 1 อีกแล้ว จบที่ เหลือ 3 ใบ
แต่คำตอบจริงคือ เหลือ 1 ใบ ทางที่ถูกคืออย่าเพิ่งแตะคู่แรก
ยก 2 กับ 3 ตรงกลางออกก่อน โต๊ะจะเหลือ 1 1 3 ซึ่งทำให้จานใบที่ 1 กับใบที่ 4
ที่เดิมไม่เคยอยู่ติดกันเลย มาชนกันพอดี ยกออกอีกคู่ เหลือแค่ใบ 3 ใบเดียว
ทีนี้ลองถามตัวเองว่า dp[i] จะเก็บอะไร มันแปลว่า "จัดการจาน i ใบแรกเสร็จแล้ว"
ซึ่งจะใช้ได้ก็ต่อเมื่ออดีตกับอนาคตติดต่อกันผ่านรอยต่อเดียว คือขอบขวาของสิ่งที่ทำไปแล้ว
แต่ในโจทย์นี้ ของที่หายไปตรงกลางทำให้จานที่อยู่คนละฝั่งมาชนกันได้ พูดอีกอย่างคือ
อดีตกับอนาคตยื่นมือข้ามหัวเราไปจับกันเอง ต่อให้คุณเติมมิติที่สองเข้าไปว่า
"เหลือจานอะไรค้างอยู่บ้าง" คุณก็ต้องจำทั้งเซต ซึ่งบานเป็น 2ⁿ ทันที
แกะคำศัพท์
state อ่านว่า "สเตท" แปลว่า "สถานะ" ในภาษา DP มันคือข้อมูลชุดน้อยที่สุดที่ต้องจำไว้ เพื่อตัดสินใจต่อไปได้อย่างถูกต้อง โดยไม่ต้องหันกลับไปดูว่าเดินมายังไง คำนี้ยืมมาจากเครื่องจักรกล คือ "ตอนนี้เครื่องอยู่ในสถานะไหน" การออกแบบ state จึงเป็นเกมของการเลือกสิ่งที่จะลืม ไม่ใช่การเลือกสิ่งที่จะจำ ถ้าลืมน้อยไปตารางจะใหญ่จนไม่ไหว ถ้าลืมมากไปคำตอบจะผิด
ก่อนจะไปดูวิธีคิด ลองเล่นเองสักพักครับ กดจานหนึ่งใบเพื่อเลือก แล้วกดใบที่อยู่ติดกันเพื่อยกทั้งคู่เข้าตู้ ยกได้เมื่อขนาดต่างกันไม่เกิน 1 เท่านั้น พอยกออกแล้วจานสองข้างจะเลื่อนมาชิดกันเอง ตัวเลข "ดีที่สุดที่ทำได้" คือคำตอบจริงของแถวนั้น ลองไล่ให้ถึงดู
เหลือบนโต๊ะ0
ดีที่สุดที่ทำได้0
ยกไปแล้ว0
กดจานใบหนึ่งเพื่อเลือก
ลองให้หนำใจก่อนนะครับ พอเริ่มรู้สึกว่า "ต้องมองไปข้างหน้าหลายตา" แล้วค่อยอ่านต่อ
ปัญหาของ dp[i] คือมันบรรยายได้แค่ "ถึงไหนแล้ว" แต่โจทย์นี้ต้องการคำบรรยายที่บอกได้ว่า
ตอนนี้เรากำลังพูดถึงของชิ้นไหนถึงชิ้นไหน เพราะการยกของออกมันเกิดขึ้นภายในช่วง
แล้วผลของมันไม่รั่วออกนอกช่วงนั้นเลย ถ้าเรารู้ว่าช่วง [i..j] จัดการตัวเองจบแล้ว
ข้างนอกไม่ต้องรู้เลยว่ามันจบยังไง รู้แค่ว่ามันเหลืออะไรทิ้งไว้ก็พอ
นั่นแหละคือที่มาของตารางสองมิติที่ดัชนีทั้งสองตัวเป็นปลายทั้งสองข้างของช่วง ไม่ใช่ตำแหน่งกับค่าอะไรสักอย่าง
เรียกมันว่า keep[i][j] แปลว่า "ถ้ามองแค่จานในช่วง [i..j] ยกเข้าตู้ได้มากที่สุดกี่ใบ"
แกะคำศัพท์
interval อ่านว่า "อินเทอร์วัล" แปลว่า "ช่วง"
มาจากภาษาละติน inter ที่แปลว่า "ระหว่าง" บวก vallum ที่แปลว่า "กำแพงค่าย"
รวมกันแล้วแปลตรงตัวว่า "พื้นที่ระหว่างกำแพงสองด้าน" ซึ่งตรงกับที่เราจะใช้มันพอดี
คือของที่อยู่ระหว่างเสาสองต้นชื่อ i กับ j เทคนิคทั้งบทนี้จึงเรียกกันว่า
DP บนช่วง หรือ interval DP
เวลาออกแบบสูตรของ DP บนช่วง อย่าไปคิดว่า "ยกคู่ไหนก่อนดี" เพราะลำดับการยกมีเป็นล้านแบบ
ให้จับตัวใดตัวหนึ่งที่ไม่มีทางหนีเราได้ มาถามคำถามเดียว ตัวที่ง่ายที่สุดคือตัวซ้ายสุดของช่วง
คือจานใบที่ i ชะตาของมันมีแค่สองแบบเท่านั้น
[i+1..j]k สักใบที่อยู่ทางขวา ซึ่งจะเกิดขึ้นได้ต้องครบสองเงื่อนไข
คือขนาดต่างกันไม่เกิน 1 และของที่ขวางอยู่ตรงกลางต้องถูกยกออกจนเกลี้ยง สองใบนี้ถึงจะได้มาชิดกัน
เงื่อนไขข้อหลังคือหัวใจของทั้งเรื่อง และเป็นจุดที่คนพลาดบ่อยที่สุด "เกลี้ยง" ต้องแปลว่าเกลี้ยงจริง ๆ ไม่ใช่ยกออกได้เยอะ ๆ แต่ยังเหลือสักใบ เพราะถ้าเหลือแม้ใบเดียวคาอยู่ตรงกลาง จานสองใบนั้นก็ไม่มีวันได้ชิดกัน
เขียนสองทางนั้นเป็นสมการได้แบบนี้ โดยที่ k ไล่ทุกตำแหน่งทางขวาที่เข้าเงื่อนไข
อ่านสูตรนี้ให้ออกต้องยึดหลักเดียว ทุกช่องในตารางตัดสินชะตาของจานซ้ายสุดของช่วงเท่านั้น
คือใบที่ i ส่วนปลายขวา j ไม่ขยับเลยทั้งสองบรรทัด มันแค่บอกว่าตอนนี้เรามองโต๊ะแค่ถึงตรงไหน
พอตัดสินใบที่ i เสร็จ ที่เหลือก็โยนต่อให้ช่องที่สั้นกว่าไปคิดแทน
i ค้างไว้ ของที่เหลือคือช่วง [i+1..j]
ซึ่งหน้าตาเหมือนโจทย์เดิมทุกอย่าง แค่สั้นลงหนึ่งช่อง ยอดที่ยกได้จึงเท่ากับ keep[i+1][j] ตรง ๆ
ไม่มีของแถมเพิ่ม และท่านี้ใช้ได้เสมอ เพราะการไม่ทำอะไรไม่ต้องขออนุญาตใคร
i ไปคู่กับใบที่ k ทางขวา
คู่นี้จะเกิดได้ก็ต่อเมื่อของที่ขวางอยู่ตรงกลางถูกยกออกจนเกลี้ยงไปก่อน
และของตรงกลางที่หายไปนั้นนับเป็นผลงานของท่านี้ด้วย ยอดจึงเป็น k−i+1
คือทั้งช่วง [i..k] ส่วนที่ยังเหลือคือ [k+1..j] ซึ่งคิดต่อได้อิสระ
เพราะเสาสองต้นที่เพิ่งปักลงไปตัดมันขาดจากก้อนซ้ายแล้ว
ตัว k ที่ห้อยอยู่ใต้ max แปลว่าเราลองทุกคู่ที่เป็นไปได้แล้วเก็บอันที่ดีที่สุดไว้
ไม่ใช่ตัวแปรที่ใครกำหนดมาให้ และเพราะทั้งสองบรรทัดวิ่งไปหาช่วงที่สั้นกว่าตัวเองเสมอ
ตารางนี้จึงเติมได้โดยไม่มีวันวนกลับมาถามหาช่องที่ยังว่างอยู่
| ชะตาของจานใบที่ i | ค่าที่ได้ | ใช้ท่านี้ได้เมื่อ |
|---|---|---|
| ไม่ถูกยก ค้างบนโต๊ะจนจบ | keep[i+1][j] | ใช้ได้เสมอ |
| ถูกยกคู่กับใบที่ k | (k−i+1) + keep[k+1][j] | |a[i] − a[k]| ≤ 1 และช่วง [i+1..k−1] ยกออกได้เกลี้ยง |
k−i+1 ใบ ไม่ใช่แค่ 2 ใบ เพราะมันนับรวมของตรงกลางที่ถูกยกออกไปจนเกลี้ยงแล้วด้วย
ตัวเลขนี้จึงเท่ากับความยาวของช่วง [i..k] พอดี
เคล็ดลับ: "เกลี้ยงไหม" ไม่ต้องมีตารางของตัวเอง
หลายคนสร้างตารางที่สองชื่อ full[i][j] เก็บว่าช่วงนั้นยกออกได้หมดไหม ซึ่งก็ถูก
แต่ในโจทย์ที่เป้าหมายคือยกออกให้ได้มากที่สุด เราได้ของชิ้นนั้นมาฟรีอยู่แล้ว
เพราะ "ยกออกได้เกลี้ยง" ก็คือ keep[i][j] เท่ากับความยาวของช่วงพอดี
เก็บตารางเดียวจึงพอ ระวังไว้อย่างเดียวคือถ้าวันไหนเป้าหมายเปลี่ยนเป็นแต้มสูงสุด
แทนที่จะเป็นจำนวนชิ้น ทางลัดนี้จะใช้ไม่ได้ทันที เพราะทางที่ให้แต้มสูงสุดไม่จำเป็นต้องเป็นทางที่ยกออกได้เยอะสุด
(เดี๋ยวเจอของจริงในโจทย์ฝึกข้อ 3)
สังเกตว่าช่องหนึ่ง ๆ ไปขอค่าจากช่อง [i+1..j], [i+1..k−1] และ [k+1..j]
ซึ่งสั้นกว่าตัวมันเองเสมอ ถ้าเราวนตามตำแหน่ง i จากซ้ายไปขวาแบบที่ชินมือ
เราจะไปขอค่าจากช่องที่ยังว่างอยู่ ทางที่ถูกจึงเป็นการไล่ตามความยาวช่วง
ทำช่วงยาว 2 ให้ครบทุกตำแหน่งก่อน แล้วค่อยขยับไปยาว 3 ยาว 4 ไปเรื่อย ๆ ตอนถึงช่วงยาว L
ทุกช่วงที่สั้นกว่า L เสร็จหมดแล้วแน่นอน
เคล็ดลับที่ใช้ได้กับข้ออื่นด้วย
กฎข้อเดียวที่ต้องท่องคือ ลำดับการเติมตารางต้องเรียงตามสิ่งที่ทำให้ปัญหาเล็กลง ใน DP บนแถวทั่วไป สิ่งนั้นคือตำแหน่ง เราจึงวนตามตำแหน่ง ใน DP บนช่วง สิ่งนั้นคือความยาวช่วง เราจึงวนตามความยาว ถ้าเขียน DP แล้วได้คำตอบเป็น 0 หรือค่าเริ่มต้นแปลก ๆ ให้กลับมาดูจุดนี้ก่อนเสมอ เก้าในสิบครั้งคือกำลังอ่านช่องที่ยังไม่ได้เติม
เอาแถว 1 2 3 1 3 เมื่อกี้มาเติมตารางจริง แถวของตารางคือ i (ปลายซ้ายของช่วง)
คอลัมน์คือ j (ปลายขวา) ช่องที่ i มากกว่า j ไม่มีความหมาย เลยเว้นว่างไว้
กดเดินทีละขั้นได้ ตารางจะค่อย ๆ เต็มจากเส้นทแยงมุมออกไปทางขวาบน พร้อมบอกว่าช่องนั้นเลือกทางไหนและทำไม
| ช่วง | j = 0ขนาด 1 | j = 1ขนาด 2 | j = 2ขนาด 3 | j = 3ขนาด 1 | j = 4ขนาด 3 |
|---|---|---|---|---|---|
| i = 0 · ขนาด 1 | |||||
| i = 1 · ขนาด 2 | |||||
| i = 2 · ขนาด 3 | |||||
| i = 3 · ขนาด 1 | |||||
| i = 4 · ขนาด 3 |
สีทองคือช่องที่กำลังเติม สีเขียวคือช่องที่มันหยิบค่ามาใช้จริง สีจางคือช่องที่ถูกพิจารณาแต่แพ้อีกทางหนึ่ง
จุดที่น่าดูที่สุดคือตอนเติมช่อง keep[0][3] ซึ่งเป็นช่วง 1 2 3 1
ที่นั่นจานใบแรกเลือกจับคู่กับใบที่ 4 ได้ เพราะช่วงตรงกลาง [1..2] มีค่าในตารางเป็น 2
ซึ่งเท่ากับความยาวของมันพอดี แปลว่าเกลี้ยง นี่คือช่องที่เก็บ "ความรู้" ว่าตรงกลางหายหมดแล้ว
ซึ่งเป็นความรู้ที่ dp[i] ไม่มีที่เก็บ
แปลสูตรลงโค้ดได้ตรง ๆ ลูปนอกสุดคือความยาว ลูปกลางคือจุดเริ่มของช่วง ลูปในสุดคือคู่ที่จะจับ
ส่วน K กับ cleared เป็นแค่ตัวห่อไว้กันช่วงว่างเล็ดลอดออกนอกตาราง
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (scanf("%d", &n) != 1) return 0;
vector<int> a(n);
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
// keep[i][j] = ยกออกได้มากที่สุดกี่ใบ ถ้ามองเฉพาะช่วง [i..j]
vector<vector<int>> keep(n + 2, vector<int>(n + 2, 0));
auto K = [&](int i, int j) { return i > j ? 0 : keep[i][j]; };
// ช่วงที่ยกออกได้ "ครบทุกใบ" คือช่วงที่ค่าในตารางเท่ากับความยาวของมันพอดี
auto cleared = [&](int i, int j) { return i > j || keep[i][j] == j - i + 1; };
// วนตามความยาวช่วง ไม่ใช่ตามตำแหน่ง ช่วงสั้นต้องเสร็จก่อนช่วงยาวเสมอ
for (int len = 2; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
int best = K(i + 1, j); // ทิ้ง a[i] ไว้บนโต๊ะ
for (int k = i + 1; k <= j; k++) {
if (abs(a[i] - a[k]) > 1) continue; // ขนาดไม่เข้าคู่
if (!cleared(i + 1, k - 1)) continue; // ของที่ขวางต้องหายเกลี้ยงก่อน
best = max(best, (k - i + 1) + K(k + 1, j));
}
keep[i][j] = best;
}
}
printf("%d\n", n - K(0, n - 1)); // เหลือบนโต๊ะกี่ใบ
return 0;
}
นับงานดู ตารางมีช่องที่ใช้จริง n(n+1)/2 ช่อง แต่ละช่องไล่ k ได้ถึง n ครั้ง
รวมเป็น O(n³) เวลา และ O(n²) หน่วยความจำ ที่ n = 300
คือราว 27,000,000 ครั้ง ซึ่งสบายมาก แต่ที่ n = 2,000 จะพุ่งเป็น 8,000,000,000 ครั้ง
และตารางกินช่องถึง 2,001,000 ช่อง ตรงนี้แหละที่ทำให้ DP บนช่วงมีเพดานของมัน
ถ้าโจทย์ให้ n ระดับหลักพันขึ้นไป แปลว่าเขาไม่ได้ตั้งใจให้ใช้ท่านี้
ต้องมองหามุมอื่นที่ยุบมิติทิ้งได้ ซึ่งเป็นเรื่องของบทถัดไป
สามข้อนี้เรียงมาให้ตอบคนละคำถาม ข้อแรกถามว่า "ต้องใช้ DP บนช่วงจริงหรือเปล่า" ข้อสองถามว่า "ท่านี้ใช้กับอย่างอื่นที่ไม่ใช่การลบได้ไหม" ข้อสามถามว่า "ถ้าเปลี่ยนสิ่งที่วัด จะพังตรงไหน" ลองคิดเองก่อนแล้วค่อยแตะเพื่อดูเฉลยครับ
โจทย์กำหนด
แถวของเลข n ตัว ยกสองตัวที่ติดกันและมีค่าเท่ากันเป๊ะออกได้ ช่องปิดเหมือนเดิม
ทำซ้ำได้เรื่อย ๆ ถามว่าเหลือน้อยที่สุดกี่ตัว โดยที่ n ใหญ่ได้ถึง 200 000
| Input | Output |
|---|---|
| 8 4 1 1 2 3 3 2 5 | 2 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือจำนวนตัวเลข บรรทัดที่สองคือแถวจริง เอาต์พุตคือจำนวนตัวที่เหลือ หลังยกออกจนยกต่อไม่ได้แล้ว ไม่ใช่จำนวนครั้งที่ยก
คำว่า "ช่องปิด" คือหัวใจของข้อนี้ ตอนเริ่มต้นมีคู่ที่ยกได้อยู่คู่เดียวคือเลข 1 สองตัวที่ติดกัน พอยกคู่นั้นออก ตัวที่เดิมอยู่คนละฝั่งก็ขยับมาชนกัน แล้วกลายเป็นคู่ใหม่ที่ยกได้อีก เกิดเป็นลูกโซ่ 3 ครั้ง จนเหลือ 2 ตัว
คำใบ้
n ระดับสองแสนตัดทาง O(n³) และ O(n²) ทิ้งไปหมดแล้ว
แปลว่าโจทย์กำลังบอกว่าข้อนี้ไม่ต้องใช้ตารางเลย ลองถามตัวเองว่ากติกา "เท่ากันเป๊ะ" ต่างจาก "ต่างกันไม่เกิน 1" ตรงไหน
ที่มาของท่านี้ · เคสสี่ตัวที่ทำให้ผมเลิกดื้อ
ตอนเจอโจทย์แบบ "ลบแล้วช่องปิด" ครั้งแรก ผมไม่ได้กระโดดไปหาตารางช่วงทันที ผมพยายามดัน dp[i]
แบบไล่ซ้ายไปขวาให้รอดก่อน เพราะมันเขียนสั้นกว่าและเร็วกว่ามาก สิ่งที่ทำให้เลิกดื้อไม่ใช่การพิสูจน์
แต่คือการเขียนตัวไล่ทุกทางแล้วให้คอมพิวเตอร์ไปหาเคสที่เล็กที่สุดที่สองท่าตอบไม่ตรงกัน
มันคายออกมาเป็นแถวสี่ตัวคือ 1 2 3 1 ลองเดินด้วยมือดูครับ ยกคู่ 2 3 ออกก่อน
เพราะต่างกัน 1 พอช่องปิด เลข 1 สองตัวที่เดิมอยู่คนละหัวคนละท้ายก็มาติดกัน
แล้วยกออกได้อีกคู่ เหลือศูนย์ ส่วน dp[i] ตอบ 2
เหตุผลที่มันตอบ 2 ไม่ใช่เพราะเขียนโค้ดพลาด แต่เพราะสิ่งที่ตัวมันเองแปลว่าอะไร dp[i] พูดถึง "ของ i ตัวแรก" ซึ่งเป็นคำที่ใช้ได้ก็ต่อเมื่อของที่เหลือยังเรียงตามเดิม
พอช่องปิดแล้วตัวที่ 1 ไปติดกับตัวที่ 4 คำว่า "i ตัวแรก" ก็อธิบายหน้าตาของแถวไม่ได้อีกต่อไป
รัฐที่จะรอดต้องบอกได้ว่าท่อนไหนถูกจัดการไปแล้วทั้งท่อน ซึ่งก็คือช่วง
บทเรียนที่ยกไปข้ออื่นได้คือ เวลาสงสัยว่าท่าที่ถูกกว่าจะรอดไหม อย่านั่งเถียงกับตัวเอง เขียนตัวไล่ทุกทางแล้วสั่งให้มันหาเคสเล็กที่สุดที่ทำให้สองท่าต่างกัน เคสสี่ตัวที่ถือไว้ในมือได้ อธิบายได้ดีกว่าคำว่า "มันไม่เข้ารูป" ทั้งย่อหน้า
ความต่างอยู่ที่ว่า "เท่ากัน" เป็นความสัมพันธ์ที่ไม่มีทางเลือก เลข 5 จับคู่ได้กับ 5 เท่านั้น ส่วน "ต่างกันไม่เกิน 1" ทำให้เลข 5 จับคู่ได้กับทั้ง 4, 5 และ 6 ซึ่งเป็นที่มาของการต้องเลือก และเมื่อไม่มีอะไรให้เลือก ลำดับการยกก็ไม่มีผลกับผลลัพธ์สุดท้าย ไม่ว่าจะยกคู่ไหนก่อน สุดท้ายจะเหลือแถวหน้าตาเดียวกันเสมอ
พอลำดับไม่มีผล ก็กวาดครั้งเดียวจบด้วยกองซ้อน (stack) เจอตัวใหม่ที่เท่ากับตัวบนสุดของกองเมื่อไร ก็หายไปทั้งคู่ ที่เหลือค้างในกองตอนจบคือคำตอบ ทำงาน O(n)
// ฝึกข้อ 1: ลบคู่ที่ติดกันและเท่ากัน เหลือน้อยที่สุดกี่ตัว
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (scanf("%d", &n) != 1) return 0;
vector<int> st;
st.reserve(n);
for (int i = 0; i < n; i++) {
int x;
scanf("%d", &x);
if (!st.empty() && st.back() == x) st.pop_back(); // เจอฝาแฝด หายไปทั้งคู่
else st.push_back(x);
}
printf("%d\n", (int)st.size());
return 0;
} ระวัง
อย่าเอาบทเรียนนี้ไปใช้ผิดที่ กติกา "ต่างกันไม่เกิน 1" ของโจทย์ประจำบทหน้าตาคล้ายกันมาก
แต่กองซ้อนตอบผิดทันที ลองแถว 1 2 3 1 ดู กองซ้อนจะรีบยกคู่หน้าไป
แล้วเหลือ 2 ใบ ทั้งที่เก็บได้เกลี้ยง
ก่อนจะเชื่อกองซ้อน ต้องเช็คก่อนเสมอว่าการจับคู่มีทางเลือกหรือไม่
โจทย์กำหนด
ให้สตริงที่มีแต่ ( กับ ) ยาว n ตัว
คุณเติมวงเล็บแทรกตรงไหนก็ได้ ถามว่าต้องเติมอย่างน้อยกี่ตัวสตริงถึงจะถูกกติกา
คือทุกตัวเปิดมีตัวปิดของตัวเอง และไม่มีตัวปิดตัวไหนโผล่มาก่อนตัวเปิดของมัน
เช่น ())( ต้องเติม 2 ตัว
| Input | Output |
|---|---|
| ())( | 2 |
อ่านตัวอย่างนี้ยังไง
อินพุตคือสตริงบรรทัดเดียว เอาต์พุตคือจำนวนวงเล็บที่ต้องเติม ไม่ใช่สตริงที่แก้แล้ว และเติมแทรกตรงไหนก็ได้ ไม่จำเป็นต้องต่อท้าย
วิธีอ่านที่เห็นภาพที่สุดคือเดินจากซ้ายไปขวาแล้วนับว่ามีตัวเปิดกี่ตัวที่ยังรอคู่อยู่ ตัวปิดตัวไหนมาถึงตอนที่ไม่มีตัวเปิดรออยู่เลย ตัวนั้นหาคู่ไม่ได้ ต้องเติมตัวเปิดให้มัน ชุดนี้มีตัวปิดแบบนั้น 1 ตัว และเดินจบแล้วยังเหลือตัวเปิดค้างอีก 1 ตัว รวมต้องเติม 2 ตัว
คำใบ้
ข้อนี้ไม่มีการลบอะไรเลย แต่ลองถามคำถามเดิมของบทนี้ดู คือตัวซ้ายสุดของช่วงจะไปคู่กับใคร แล้วจะเห็นว่าโครงเดียวกันเป๊ะ
ให้ f[i][j] คือจำนวนตัวที่ต้องเติมน้อยที่สุด เพื่อให้ช่วง [i..j] ถูกกติกา
ช่วงยาวหนึ่งตัวต้องเติมคู่ให้มันเสมอ จึงเป็น 1 ส่วนช่วงที่ยาวกว่านั้นมีสองทาง
( และท้ายเป็น ) ให้สองตัวนี้จับคู่กันเลย เหลือปัญหาตรงกลาง f[i+1][j−1]k แล้วบวกกัน f[i][k] + f[k+1][j]
ท่าผ่าครึ่งนี้คือของที่ DP บนช่วงมีติดตัวมาตลอด และเป็นเหตุผลที่ต้องมีมันคู่กับท่าจับคู่หัวท้าย
เพราะสตริงอย่าง ()() หัวกับท้ายจับคู่กันไม่ได้ (ท้ายเป็น ) ก็จริง แต่จับคู่แล้วตรงกลางเสีย)
ต้องอาศัยการผ่าเท่านั้น ทั้งหมดเป็น O(n³)
// ฝึกข้อ 2 ท่าที่ 1: DP บนช่วง O(n^3)
// f[i][j] = ต้องเติมวงเล็บอย่างน้อยกี่ตัว ช่วง [i..j] ถึงจะถูกกติกา
#include <bits/stdc++.h>
using namespace std;
int main() {
char buf[512];
if (scanf("%s", buf) != 1) return 0;
string s = buf;
int n = (int)s.size();
vector<vector<int>> f(n + 1, vector<int>(n + 1, 0));
for (int len = 1; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
if (len == 1) { f[i][j] = 1; continue; } // ตัวเดียวโดด ๆ ต้องเติมคู่ให้เสมอ
int best = INT_MAX;
// หัวกับท้ายจับคู่กันได้พอดี ที่เหลือคือปัญหาย่อยตรงกลาง
if (s[i] == '(' && s[j] == ')') best = min(best, i + 1 <= j - 1 ? f[i + 1][j - 1] : 0);
// ไม่งั้นก็ผ่าเป็นสองท่อนที่ไม่ยุ่งกัน
for (int k = i; k < j; k++) best = min(best, f[i][k] + f[k + 1][j]);
f[i][j] = best;
}
}
printf("%d\n", n == 0 ? 0 : f[0][n - 1]);
return 0;
}
แล้วถ้า n เป็นล้านล่ะ ตารางสองมิติสร้างไม่ไหวแน่ ๆ ตรงนี้คือจุดที่ต้องกลับไปมองโจทย์ใหม่
แทนที่จะถามว่า "ช่วงนี้ต้องเติมกี่ตัว" ให้เดินจากซ้ายไปขวาแล้วถามว่าตอนนี้มีตัวเปิดค้างอยู่กี่ตัว
เจอ ( ก็บวกหนึ่ง เจอ ) ก็เอาไปปิดตัวที่ค้างอยู่ ถ้าไม่มีให้ปิดแปลว่าต้องเติมตัวเปิดข้างหน้ามัน
พอจบแถว ตัวเปิดที่ยังค้างก็ต้องเติมตัวปิดให้ทุกตัว
// ฝึกข้อ 2 ท่าที่ 2: ตัวนับตัวเดียว O(n)
// open = วงเล็บเปิดที่ยังไม่มีใครมาปิด, add = วงเล็บปิดที่หาเจ้าของไม่ได้
#include <bits/stdc++.h>
using namespace std;
int main() {
char buf[512];
if (scanf("%s", buf) != 1) return 0;
int open = 0, add = 0;
for (char *p = buf; *p; p++) {
if (*p == '(') open++;
else if (open > 0) open--; // ไปปิดตัวที่ค้างอยู่
else add++; // ไม่มีใครให้ปิด ต้องเติมตัวเปิดข้างหน้า
}
printf("%d\n", add + open);
return 0;
} ผมเอาสองท่านี้มายิงเทียบกันด้วยสตริงวงเล็บทุกแบบที่ยาวไม่เกิน 11 ตัว รวม 4 094 สตริง ตอบตรงกันหมด การยุบจากตารางสองมิติเหลือตัวนับตัวเดียวแบบนี้คือหัวใจของข้อ หยิบหนังสือ ซึ่งเป็นข้อถัดไปในคลังนี้ ถ้าอ่านบทนี้จบแล้วข้อนั้นจะอ่านสนุกขึ้นเยอะ
โจทย์กำหนด
กติกาเดิมกับโจทย์ประจำบททุกอย่าง คือยกจานสองใบที่ติดกันและขนาดต่างกันไม่เกิน 1 แต่คราวนี้คุณได้แต้มเท่ากับผลรวมขนาดของจานที่ยกออก และอยากได้แต้มมากที่สุด ไม่ได้สนใจแล้วว่าจะเหลือกี่ใบ
| Input | Output |
|---|---|
| 6 9 4 5 5 3 9 | 35 |
อ่านตัวอย่างนี้ยังไง
อินพุตคือขนาดจานเรียงจากบนลงล่าง เอาต์พุตคือแต้มรวมที่มากที่สุด ซึ่งเป็นผลรวมขนาดของจานทุกใบที่ยกออกได้ ไม่ใช่จำนวนใบ
ตรงนี้คือจุดที่โจทย์เปลี่ยนนิสัยไปจากโจทย์ประจำบท เมื่อก่อนจานทุกใบมีค่าเท่ากันหมด ยกได้มากใบก็ดีกว่าเสมอ คราวนี้จานใบใหญ่มีค่ามากกว่า การยอมยกน้อยใบแต่เป็นใบใหญ่จึงอาจดีกว่า ชุดนี้ท่าโลภที่เจอคู่ทางซ้ายสุดแล้วยกทันทีได้แค่ 9 แต้ม ขณะที่ลำดับที่ดีที่สุดได้ 35 แต้ม
คำใบ้
โครงของสูตรไม่เปลี่ยนเลย เปลี่ยนแค่ว่าช่องในตารางเก็บอะไร แต่มีทางลัดหนึ่งอันในโจทย์ประจำบทที่พังทันที ลองหาให้เจอว่าอันไหน
ทางลัดที่พังคือการอ่านว่า "ช่วงนี้เกลี้ยงไหม" จากตารางคำตอบ ในโจทย์ประจำบท เราดูได้จาก
keep[i][j] เท่ากับความยาวช่วงพอดีหรือไม่ เพราะสิ่งที่เราวัดคือจำนวนใบตรง ๆ
แต่พอเปลี่ยนมาวัดแต้ม ช่องในตารางจะเก็บแต้มสูงสุด ซึ่งอาจมาจากทางที่ยกออกไปน้อยใบกว่าก็ได้
ตารางแต้มจึงตอบคำถามเรื่อง "เกลี้ยงไหม" ไม่ได้อีกต่อไป
ทางแก้คือแยกเป็นสองตารางตรง ๆ full[i][j] เก็บว่าเกลี้ยงได้ไหมแบบไม่สนแต้ม
และ val[i][j] เก็บแต้มสูงสุด ตัวแรกใช้ตัดสินว่าคู่ไหนจับกันได้ ตัวหลังใช้สะสมคำตอบ
// ฝึกข้อ 3: กติกาเดิม แต่แต้มคือผลรวมขนาดจานที่ยกออก อยากได้แต้มมากที่สุด
// ต้องมีสองตาราง full บอกว่าช่วงนั้นยกออกได้เกลี้ยงไหม (ไม่เกี่ยวกับแต้ม)
// ส่วน val เก็บแต้มสูงสุด ถ้าเอาตารางแต้มมาตัดสินเรื่องเกลี้ยงจะผิด
// เพราะทางที่แต้มสูงสุดไม่จำเป็นต้องเป็นทางที่ยกออกได้เยอะสุด
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (scanf("%d", &n) != 1) return 0;
vector<int> a(n);
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
vector<vector<char>> full(n + 2, vector<char>(n + 2, 0));
vector<vector<long long>> val(n + 2, vector<long long>(n + 2, 0));
auto isFull = [&](int i, int j) { return i > j ? true : (bool)full[i][j]; };
auto V = [&](int i, int j) { return i > j ? 0LL : val[i][j]; };
for (int len = 2; len <= n; len++)
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
for (int k = i + 1; k <= j; k++)
if (abs(a[i] - a[k]) <= 1 && isFull(i + 1, k - 1) && isFull(k + 1, j)) {
full[i][j] = 1;
break;
}
}
for (int len = 2; len <= n; len++)
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
long long best = V(i + 1, j);
for (int k = i + 1; k <= j; k++) {
if (abs(a[i] - a[k]) > 1) continue;
if (!isFull(i + 1, k - 1)) continue;
best = max(best, a[i] + a[k] + V(i + 1, k - 1) + V(k + 1, j));
}
val[i][j] = best;
}
printf("%lld\n", V(0, n - 1));
return 0;
} ระวัง
กับดักที่เนียนกว่านั้นคือการพยายามเก็บสองอย่างไว้ในตารางเดียว เช่นเก็บคู่ (แต้ม, จำนวนใบ) แล้วเทียบแต้มก่อนค่อยเทียบจำนวนใบ ท่านี้ดูสมเหตุสมผลและผ่านการทดสอบแบบสุ่มไปได้เยอะมาก แต่มันผิดโดยหลักการ เพราะช่วงหนึ่ง ๆ อาจมีทางที่แต้มสูงแต่ไม่เกลี้ยง กับทางที่แต้มต่ำกว่าแต่เกลี้ยง ซึ่งทั้งคู่จำเป็นต่อคำตอบสุดท้ายคนละแบบ เก็บได้ค่าเดียวเมื่อไรก็แปลว่าทิ้งอีกทางไปแล้ว
DP บนช่วงที่บทนี้สอนมีต้นทุน O(n³) คือช่อง n² ช่อง ช่องละไล่จุดตัด
n จุด ซึ่งพอสำหรับ n ระดับห้าร้อยถึงพัน แต่มีโจทย์ที่ให้
n ถึงสองสามพัน แล้วทางนี้ก็ตกเวลาไปเฉย ๆ
สำหรับ DP บนช่วงรูปหนึ่งที่พบบ่อยมาก มีท่าที่ยุบ O(n³) ลงเป็น O(n²) โดยไม่เปลี่ยนสมการเลย เปลี่ยนแค่ขอบเขตของลูปในสุด ท่านั้นชื่อ
Knuth optimization (ตามชื่อ Donald Knuth)
ตรงนี้เล่าแบบย่อพอให้ใช้งานได้ ถ้าอยากได้ที่มาว่าทำไมข้ออ้างข้างล่างถึงเป็นจริง ทำไมหน้าต่างที่แคบลงนิดเดียวถึงยุบได้ทั้งอันดับ และตัวอย่างค้านที่ทำให้มันตอบผิดแบบเงียบ ๆ อยู่ในบทเร่งดีพีบนช่วงแบบคะนูธ ซึ่งมีโจทย์ฝึกให้อีกสามข้อ
เก็บเพิ่มอีกตารางหนึ่งชื่อ opt[i][j] แปลว่าจุดตัดที่ดีที่สุดของช่วง
[i..j] ข้ออ้างของ Knuth คือจุดตัดที่ดีที่สุดนั้นขยับไปทางขวาอย่างเป็นระเบียบ
เมื่อช่วงยาวขึ้น เขียนเป็นอสมการได้ว่า
opt[i][j−1] ≤ opt[i][j] ≤ opt[i+1][j]
ความหมายเชิงรูปคือ ถ้าเรารู้จุดตัดที่ดีที่สุดของช่วงที่สั้นกว่าหนึ่งช่องทั้งสองแบบแล้ว
จุดตัดที่ดีที่สุดของช่วงนี้ต้องอยู่ระหว่างสองค่านั้น ไม่มีทางหลุดออกไป
เราจึงไล่จุดตัดแค่ในช่วงแคบนั้น แทนที่จะไล่ตั้งแต่ i ถึง j-1
ที่มันกลายเป็น O(n²) ไม่ใช่เพราะแต่ละช่องเร็วขึ้นเท่า ๆ กัน
บางช่องยังไล่ยาวเหมือนเดิม แต่ผลรวมของความยาวที่ไล่ ทั้งแถวเดียวกัน มีเพดานที่ n
เพราะขอบซ้ายกับขอบขวาของช่วงที่ไล่ ต่างก็ขยับไปทางขวาอย่างเดียว
เป็นการนับต้นทุนแบบเฉลี่ยทบ เหมือนที่สองตัวชี้ในบทหน้าต่างเลื่อน ใช้
ระวัง ท่านี้ใช้ไม่ได้กับ DP บนช่วงทุกแบบ
ข้ออ้างข้างบนไม่ได้จริงฟรี ๆ มันต้องการเงื่อนไขกับฟังก์ชันต้นทุน คือต้องเป็นแบบที่บวกกันได้ตามช่วง และมีสมบัติที่เรียกว่าอสมการสี่เหลี่ยม รูปที่ปลอดภัยและพบบ่อยที่สุดคือ ต้นทุนของช่วงคือผลรวมของช่วงนั้น ซึ่งเป็นรูปของโจทย์รวมกองหิน ถ้าโจทย์มีต้นทุนหน้าตาอื่น ให้สุ่มเทียบกับ O(n³) ก่อนเชื่อทุกครั้ง เพราะเมื่อข้ออ้างไม่จริง โค้ดจะไม่พังเลย มันจะตอบค่าที่มากกว่าคำตอบจริงอย่างเงียบ ๆ เฉพาะบางอินพุต
| แนวคิด | อันดับ | งานคร่าว ๆ | เวลาที่วัดได้ |
|---|---|---|---|
| DP บนช่วง ไล่จุดตัดทุกจุด | O(n³) | 1,333,333,333 | 4.80 วินาที |
| เติม Knuth optimization | O(n²) | 4,000,000 | 0.08 วินาที |
opt เพิ่มอีกใบ
// Knuth optimization: รวมกองหินให้เหลือกองเดียว ค่าใช้จ่ายน้อยที่สุด
// dp[i][j] = ค่าน้อยสุดที่รวมช่วง i..j และ opt[i][j] = จุดตัดที่ดีที่สุดของช่วงนั้น
// ข้ออ้างที่ทำให้เร็วขึ้น: opt[i][j-1] <= opt[i][j] <= opt[i+1][j]
// จึงไล่จุดตัดแค่ช่วงแคบ ๆ แทนที่จะไล่ทั้งช่วง ทำให้ O(n^3) ยุบเป็น O(n^2)
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (scanf("%d", &n) != 1) return 0;
vector<long long> a(n + 1, 0), pre(n + 1, 0);
for (int i = 1; i <= n; i++) { scanf("%lld", &a[i]); pre[i] = pre[i - 1] + a[i]; }
auto sum = [&](int i, int j) { return pre[j] - pre[i - 1]; };
const long long INF = (long long)4e18;
vector<vector<long long>> dp(n + 2, vector<long long>(n + 2, 0));
vector<vector<int>> opt(n + 2, vector<int>(n + 2, 0));
for (int i = 1; i <= n; i++) { dp[i][i] = 0; opt[i][i] = i; }
for (int len = 2; len <= n; len++) {
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
dp[i][j] = INF;
int lo = opt[i][j - 1];
int hi = (i + 1 <= j) ? opt[i + 1][j] : j - 1;
lo = max(lo, i);
hi = min(hi, j - 1);
for (int k = lo; k <= hi; k++) {
long long cand = dp[i][k] + dp[k + 1][j] + sum(i, j);
if (cand < dp[i][j]) { dp[i][j] = cand; opt[i][j] = k; }
}
}
}
printf("%lld\n", n == 0 ? 0 : dp[1][n]);
return 0;
} พอโจทย์บอกว่าลบแล้วช่องปิด ให้เลิกถามว่า "ทำอะไรที่ตำแหน่งนี้" แล้วเปลี่ยนไปถามว่า "ช่วงนี้จัดการตัวเองจบแล้วเหลืออะไร" จับตัวซ้ายสุดของช่วงมาถามว่ามันจะไปคู่กับใคร เติมตารางไล่ตามความยาวช่วงเสมอ แล้วอย่าลืมว่าเงื่อนไข "ตรงกลางต้องเกลี้ยง" คือสิ่งเดียวที่ทำให้ของสองชิ้นคนละมุมมาเจอกันได้
DP บนช่วงไม่ได้ยากที่สูตร มันยากที่การยอมเลิกมองแถวเป็นเส้นตรงที่เดินจากซ้ายไปขวา แล้วหันมามองมันเป็นกล่องที่ซ้อนกันอยู่
ในหน้านี้