ปูพื้นฐาน
ดีพีบนช่วงรูปที่พบบ่อยที่สุดยุบจาก O(n³) เหลือ O(n²) ได้ด้วยการเปลี่ยนขอบเขตของลูปในสุดสามบรรทัด บทนี้ปูสะพานสองอันที่ตำราข้าม คือทำไมต้องเป็นเพื่อนบ้านสองช่องนั้น และทำไมหน้าต่างที่แคบลงนิดเดียวถึงยุบได้ทั้งอันดับ พร้อมเกมเลือกลำดับตัดไม้ ตัวอย่างค้านที่ทำให้ตอบผิดแบบเงียบ ๆ และโจทย์ฝึกสามข้อจากคลังอ้างอิงของต้นทาง
มีไม้ท่อนหนึ่ง ยาว 10 หน่วย บนไม้มีรอยกาไว้แล้วว่าต้องตัดตรงไหนบ้าง
ตัดหนึ่งครั้งเสียเงินเท่ากับความยาวของชิ้นที่กำลังถูกตัด ไม่ใช่ความยาวของชิ้นที่ได้ออกมา
คำถามคือ ตัดตามลำดับไหนถึงจะจ่ายน้อยที่สุด
จุดที่ทำให้โจทย์นี้ไม่ใช่โจทย์เด็ก ๆ คือ ลำดับมีผล ถ้าตัดไม้ท่อนยาวก่อน รอยที่เหลือจะอยู่ในชิ้นที่สั้นลงแล้ว จึงถูกลง กลับกัน ถ้าเก็บรอยกลางไว้ตัดทีหลัง รอยนั้นจะยังอยู่ในชิ้นยาว ๆ อยู่
เขียนสูตรได้ไม่ยาก มองไม้เป็น ช่องว่างระหว่างรอยกาที่ติดกัน ซึ่งไม้ต้นนี้มี 5 ช่อง ช่องหนึ่งคือของหนึ่งชิ้นที่ห้ามตัดอีกแล้ว การตัดหนึ่งครั้งจึงเป็นการ
ผ่าช่วงของช่องออกเป็นสองช่วง โดยจ่ายเท่าความยาวรวมของช่วงที่กำลังผ่า
ตรงนี้ C(i, j) คือค่าใช้จ่ายที่ต้องจ่ายเมื่อผ่าช่วง [i..j] หนึ่งครั้ง
ซึ่งในโจทย์ตัดไม้คือความยาวรวมของช่วงนั้น ส่วน k คือรอยที่เลือกผ่า
สูตรข้างบนมีช่องให้เติม n² ช่อง แต่ละช่องไล่ k ได้ถึง n จุด
รวมเป็น O(n³) ซึ่งไหวถ้า n อยู่ระดับไม่กี่ร้อย
พอโจทย์ให้ n เป็นหลักพัน มันก็ตกเวลาไปเฉย ๆ
| n | ไล่จุดตัดทุกจุด | ใส่หน้าต่างของ Knuth | ต่างกัน |
|---|---|---|---|
| 1,000 | 0.384 วินาที | 0.018 วินาที | 21 เท่า |
| 3,000 | 32.750 วินาที | 0.454 วินาที | 72 เท่า |
n ชื่อของท่าที่จะแก้เรื่องนี้
ท่านี้ชื่อ Knuth optimization ตั้งตามชื่อ Donald Knuth อ่านว่า "โดนัลด์ คะนูธ" นักวิทยาการคอมพิวเตอร์ที่เขียนชุดหนังสือ The Art of Computer Programming เขาใช้ท่านี้ครั้งแรกในปี 1971 กับโจทย์สร้างต้นไม้ค้นหาที่ดีที่สุด ซึ่งเป็นโจทย์ฝึกข้อสุดท้ายของบทนี้พอดี ส่วนคำว่า optimization อ่านว่า "ออปทิไมเซชัน" แปลว่า "การทำให้ดีที่สุด" ในที่นี้หมายถึงการเร่งความเร็ว ไม่ได้แปลว่าคำตอบดีขึ้น เพราะคำตอบเท่าเดิมเป๊ะ
ท่านี้เป็นญาติสนิทของบทแบ่งครึ่งเร่งดีพี ทั้งคู่ยืนอยู่บนความคิดเดียวกัน
คือจุดตัดที่ดีที่สุดไม่ได้กระโดดไปมา ต่างกันที่รูปของโจทย์ ท่าแบ่งครึ่งใช้กับดีพีที่มี
ชั้น (แบ่งของเป็น k กอง) ส่วนท่านี้ใช้กับ
ดีพีบนช่วง ที่ช่วงหนึ่งแตกเป็นสองช่วงย่อย ถ้ายังไม่ได้อ่านสองบทนั้น
อ่านบทนี้ก่อนก็ได้ แต่จะได้ประโยชน์มากขึ้นถ้าอ่านบทดีพีบนช่วงมาก่อน
ก่อนไปดูท่าเร่งความเร็ว ลองตัดเองก่อน กติกาคือคลิกที่รอยกาเพื่อตัดตรงนั้น ค่าที่ต้องจ่ายคือความยาวของชิ้นที่รอยนั้นอยู่ในขณะที่คลิก ตัดให้ครบทุกรอย แล้วดูว่ารวมแล้วได้เท่าไร
ไม้ยาว 100 มีรอยกาสามรอยที่แบ่งเท่า ๆ กัน ชุดนี้ตัดยังไงก็ได้เท่ากันหลายทาง ใช้ชินกับกติกาก่อน
ไม้ยาว 100 หน่วย มีรอยกา 3 รอย ต้องตัดให้ครบทุกรอย
คลิกที่รอยกาสีทองเพื่อตัดตรงนั้น
ยังไม่ได้ตัดเลย จ่ายไป 0
ตัวเลขบนรอยที่ตัดแล้วคือลำดับที่ตัด ตัวเลขใต้ชิ้นคือความยาวของชิ้นนั้นตอนนี้
ชุดที่สามมีรอยกาสามรอยกระจุกอยู่ทางซ้าย ถ้าไล่ตัดเรียงจากรอยซ้ายสุดไปขวาสุด จะได้ 118 ขณะที่คำตอบจริงคือ 70 เพราะทุกครั้งที่ตัดรอยทางซ้าย ชิ้นที่ถืออยู่ยังยาวเกือบเต็มไม้
พอมีคำตอบในใจแล้ว มาดูกันว่าถ้าไม้ต้นนั้นมีรอยกาสามพันรอย เครื่องจะตัดงานทิ้งได้ยังไง
ตั้งชื่อให้มันก่อน เรียกว่า opt(i, j)
ชื่อ opt ย่อมาจาก optimal อ่านว่า "ออปทิมัล" แปลว่า "ดีที่สุด" มันไม่ใช่คำตอบของช่วงนั้น มันคือตำแหน่งที่ทำให้ได้คำตอบนั้น
ต่างกันเหมือนคะแนนสอบกับเลขที่นั่งของคนที่ได้คะแนนนั้น
ทีนี้ลองพิมพ์ opt ของทุกช่องออกมาดู นี่คือของจริงจากไม้ยาว 10 ข้างบน
แถวคือขอบซ้ายของช่วง คอลัมน์คือขอบขวา
| i \ j | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 1 | 1 |
| 2 | · | 2 | 2 | 2 | 3 |
| 3 | · | · | 3 | 3 | 3 |
| 4 | · | · | · | 4 | 4 |
| 5 | · | · | · | · | 5 |
เขียนสองข้อสังเกตนั้นเป็นบรรทัดเดียวได้แบบนี้
ประโยคนี้คือจุดที่ผมอ่านตำราแล้วสะดุด เพราะมันบอกแค่ว่าอสมการเป็นจริง แต่ไม่ได้บอกว่า ทำไมสองช่องนั้น ลองอ่านมันใหม่โดยมองที่ตารางแทนที่จะมองที่ตัวอักษร
opt(i, j-1) คือช่องซ้ายมือของช่องที่กำลังจะเติม
มันคือช่วงเดียวกันแต่ตัดหางขวาออกไปหนึ่งช่อง
opt(i+1, j) คือช่องข้างล่าง
มันคือช่วงเดียวกันแต่ตัดหัวซ้ายออกไปหนึ่งช่อง
ความหมายเป็นภาษาคนคือ ต่อหางขวาให้ยาวขึ้น จุดตัดที่ดีที่สุดก็ขยับไปทางขวาหรืออยู่ที่เดิม ไม่มีวันถอยกลับ และตัดหัวซ้ายทิ้ง จุดตัดก็ขยับไปทางขวาหรืออยู่ที่เดิมเช่นกัน เอาสองอันมาประกบกัน จุดตัดของช่องนี้จึงถูกขังอยู่ระหว่างเพื่อนบ้านสองช่องที่รู้คำตอบแล้ว
ตรงนี้เป็นสะพานที่สองที่ตำรามักข้าม เพราะดูจากภาพข้างบน มันแคบลงแค่ไม่กี่ช่อง
แล้วจะยุบจาก O(n³) เป็น O(n²) ได้ยังไง
คำตอบคืออย่านับทีละช่อง ให้นับทั้งเส้นทแยงพร้อมกัน เส้นทแยงหนึ่งเส้นคือ ทุกช่วงที่ยาวเท่ากัน ในเส้นนั้น ช่องที่อยู่ถัดไปทางขวามีขอบซ้ายของหน้าต่างที่ ไม่น้อยกว่าของช่องก่อนหน้า และขอบขวาก็ไม่น้อยกว่าเช่นกัน ขอบทั้งสองข้างจึงเดินไปทางขวาอย่างเดียวตลอดทั้งเส้น
พอขอบทั้งสองข้างเดินทางเดียว ผลรวมความยาวของแถบทั้งเส้นก็มีเพดานง่าย ๆ คือ
ระยะที่ขอบขวาเดินได้ทั้งหมด บวกจำนวนแถบ เพราะแถบหนึ่งกินอย่างน้อยหนึ่งช่องเสมอ
ในภาพข้างบนคือ 5 บวก 5 เท่ากับ 10 ซึ่งมากกว่างานจริง 9 ครั้งอยู่
ทั้งเส้นทแยงจึงเป็นงาน O(n) ไม่ใช่ O(n²) และมีเส้นทแยงทั้งหมด n เส้น รวมเป็น O(n²)
วิธีนับแบบนี้มีชื่อ และเคยเจอมาแล้ว
การนับที่บอกว่า "ต่อครั้งอาจแพง แต่รวมทั้งชุดแล้วถูก" เรียกว่า amortized analysis อ่านว่า "อะมอร์ไทซ์" แปลว่า "เฉลี่ยทบ" เป็นตัวนับเดียวกับที่สองตัวชี้ใน บทหน้าต่างเลื่อน ใช้ คือขอบที่เดินทางเดียวตลอด จึงนับรวมทั้งลูปได้เลย แทนที่จะคูณจำนวนรอบด้วยงานที่แย่ที่สุดของหนึ่งรอบ
นี่คือการเติมตารางจริงของไม้ยาว 10 ต้นเดิม เดินไล่ตามความยาวช่วงจากสั้นไปยาว ช่องเขียวคือช่องที่กำลังเติม ช่องเหลืองคือเพื่อนบ้านสองช่องที่บอกขอบมาให้
เติมตาราง dp ไล่ตามความยาวช่วง
| i \ j | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 1 | 0 | · | · | · | · |
| 2 | 0 | · | · | · | |
| 3 | 0 | · | · | ||
| 4 | 0 | · | |||
| 5 | 0 |
เดินครบทั้งตารางแล้ว ท่านี้ส่องจุดตัดรวม 17 ครั้ง ส่วนการไล่ทุกจุดต้องส่อง 20 ครั้ง ประหยัดไป 3 ครั้ง ซึ่งดูน้อยเพราะไม้ต้นนี้เล็ก ความต่างจริงอยู่ในตารางวัดเวลาข้างบน
ข้ออ้างเรื่อง opt ไม่ถูกต้องฟรี ๆ มันต้องการเงื่อนไขสองข้อกับฟังก์ชันค่าใช้จ่าย C ทั้งสองข้อพูดถึงช่วงสี่ตำแหน่งที่เรียงกันคือ a ถึง b ถึง c ถึง d
ข้อแรกคือ ค่าใช้จ่ายต้องโตตามช่วง ช่วงที่อยู่ข้างในต้องไม่แพงกว่าช่วงที่ครอบมันอยู่
ข้อสองชื่อ อสมการสี่เหลี่ยม (quadrangle inequality อ่านว่า "ควอดแรงเกิล อินอิควอลิตี" แปลตรงตัวว่า "อสมการสี่เหลี่ยม" ตั้งชื่อตามการที่มันพูดถึงสี่จุดพร้อมกัน)
อสมการข้อสองนี้อ่านยากถ้าดูแค่ตัวอักษร ลองอ่านเป็นภาษาคน ฝั่งซ้ายคือช่วงคู่หนึ่งที่
เหลื่อมกัน คือ [a..c] กับ [b..d] ส่วนฝั่งขวาคือช่วงคู่หนึ่งที่
ซ้อนกัน คือ [a..d] ที่ครอบ [b..c] ไว้ทั้งอัน
ทั้งสองคู่กินตำแหน่งรวมกันเท่ากันเป๊ะ อสมการจึงบอกว่า แบบซ้อนกันแพงกว่าหรือเท่ากับแบบเหลื่อมกันเสมอ พูดอีกทางคือค่าใช้จ่ายลงโทษการมีช่วงยาว ๆ
มากกว่าลงโทษการมีช่วงกลาง ๆ สองช่วง
ท่าที่ผมใช้จริงเวลาแข่ง
พิสูจน์อสมการสี่เหลี่ยมกลางสนามแข่งเป็นเรื่องที่ทำไม่ทัน สิ่งที่ทำได้จริงคือ จำรูปที่ปลอดภัยไว้ แล้วสุ่มเทียบกับ O(n³) ก่อนส่ง
รูปที่ปลอดภัยและพบบ่อยที่สุดคือ ค่าใช้จ่ายของช่วงคือผลรวมของค่าที่ไม่ติดลบในช่วงนั้น
ซึ่งครอบทั้งโจทย์ตัดไม้ โจทย์รวมกองหิน และโจทย์ต้นไม้ค้นหา ถ้าค่าใช้จ่ายหน้าตาอื่น
ให้ถือว่ายังไม่รู้จนกว่าตัวตรวจจะบอก
ตรงนี้สำคัญกว่าที่คิด เพราะเวลาข้ออ้างไม่จริง โปรแกรมไม่พังและไม่ฟ้องอะไรเลย มันแค่ตอบค่าที่มากกว่าคำตอบจริงอย่างเงียบ ๆ เฉพาะบางอินพุต
ตัวค้านที่เล็กที่สุดที่ผมหาเจอมีแค่ 4 ช่อง และค่าใช้จ่ายเป็นศูนย์ทุกช่องยกเว้นสองช่อง คือ C(1,2) = 1 กับ C(2,4) = 1 ที่เหลือเป็นศูนย์หมด
ไล่จุดตัดทุกจุดได้คำตอบ 0 แต่ใส่หน้าต่างของ Knuth แล้วได้ 1
| i \ j | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 3 |
| 2 | · | 2 | 2 | 2 |
| 3 | · | · | 3 | 3 |
| 4 | · | · | · | 4 |
ค่าใช้จ่ายชุดนี้พังทั้งสองเงื่อนไข อสมการสี่เหลี่ยมพังที่ a=1 b=2 c=2 d=3 และเงื่อนไขโตตามช่วงพังที่ a=1 b=1 c=2 d=3
ส่วนค่าใช้จ่ายแบบผลรวมของช่วง ผมไล่ตรวจทุกสี่จุดในไม้ตัวอย่างแล้ว ผ่านทั้งสองข้อ
เรื่องที่เกิดขึ้นจริงตอนหาตัวค้านตัวนี้
รอบแรกผมปล่อยให้ตัวสุ่มใส่ค่าลงช่องแนวทแยง C(i, i) ได้ด้วย
มันหาตัวค้านเจอเร็วมาก แต่พอไปดูว่าอสมการพังที่คู่ไหน คำตอบคือคู่ที่ใช้ C(2, 2) ซึ่งเป็นช่องที่ดีพีไม่เคยอ่านเลย
เพราะช่วงยาวหนึ่งช่องไม่มีอะไรให้ตัด
ตัวค้านแบบนั้นถูกทางเทคนิค แต่เล่าไม่ได้ เพราะคนอ่านจะถามทันทีว่าช่องที่ไม่มีใครอ่าน ทำให้ผลลัพธ์เปลี่ยนได้ยังไง ผมจึงบังคับให้แนวทแยงเป็นศูนย์แล้วสุ่มใหม่ ได้ตัวค้านที่ค่าไม่เป็นศูนย์แค่สองช่อง และทั้งสองช่องเป็นช่องที่ดีพีอ่านจริง บทเรียนที่ติดมือกลับมาคือ ตัวค้านต้องหักของที่อัลกอริทึมแตะจริง ไม่ใช่แค่ทำให้เงื่อนไขบนกระดาษเป็นเท็จ
เทียบกับดีพีบนช่วงธรรมดา โค้ดนี้ต่างกันแค่สามบรรทัด คือตาราง opt กับ lo และ hi
// แม่แบบ Knuth optimization สำหรับรูป dp[i][j] = min(dp[i][k] + dp[k+1][j]) + C(i, j)
// เปลี่ยนแค่ฟังก์ชัน C กับการอ่านอินพุต ที่เหลือลอกทั้งก้อน
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int n;
ll C(int i, int j); // ค่าใช้จ่ายของช่วง i..j
const ll INF = (ll)4e18;
ll solve() {
vector<vector<ll>> dp(n + 2, vector<ll>(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 = max(opt[i][j - 1], i);
int hi = min(opt[i + 1][j], j - 1);
for (int k = lo; k <= hi; k++) {
ll cand = dp[i][k] + dp[k + 1][j] + C(i, j);
if (cand < dp[i][j]) { dp[i][j] = cand; opt[i][j] = k; }
}
}
}
return n ? dp[1][n] : 0;
} สามจุดที่ต้องระวังในแม่แบบนี้
len จากสั้นไปยาวเท่านั้น
ถ้าเขียนเป็นสองลูป i กับ j ตรง ๆ เพื่อนบ้านสองช่องจะยังไม่ถูกเติม
แล้วหน้าต่างจะอ่านค่าศูนย์ที่ยังไม่มีความหมาย
max และ min เพราะ opt[i][j-1] อาจน้อยกว่า i ไม่ได้ และ opt[i+1][j]
อาจเกิน j-1 ไม่ได้ ถ้าลืม ลูปจะอ่านช่องนอกช่วงแล้วได้คำตอบมั่ว
opt[i][i] = i ไม่ใช่ปล่อยเป็นศูนย์
เพราะช่องแนวทแยงคือที่ที่หน้าต่างเริ่มนับ ถ้าเป็นศูนย์ ขอบซ้ายของทุกช่วงยาวสองจะเริ่มผิด
แล้วอาการจะออกเป็นคำตอบมากกว่าจริงเล็กน้อย ซึ่งตัวอย่างในโจทย์มักจับไม่ได้
สามข้อนี้หยิบมาจากรายการอ้างอิงท้ายหน้า Knuth optimization ของ cp-algorithms เรียงให้ข้อแรกใช้ท่านี้ก็ได้ไม่ใช้ก็ได้ ข้อสองบังคับให้ใช้ และข้อสามเปลี่ยนหน้าตาของค่าใช้จ่าย
ช่างไม้รับไม้มาท่อนหนึ่ง ลูกค้าทำเครื่องหมายไว้ว่าต้องตัดตรงไหนบ้าง ร้านคิดเงินตามความยาวของชิ้นที่กำลังตัด ช่างจึงเลือกลำดับเองได้ ให้หาว่าจ่ายน้อยที่สุดเท่าไร
อินพุต / ขอบเขต / เอาต์พุต
L แล้วจำนวนรอยกา n แล้วตำแหน่งรอยกาเรียงจากน้อยไปมากL ไม่เกินหนึ่งพัน และ n ไม่เกินห้าสิบ| Input | Output |
|---|---|
| 100 3 25 50 75 | 200 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือความยาวไม้ทั้งท่อน คือ 100 หน่วย บรรทัดที่สองบอกว่ามีรอยกา 3 รอย บรรทัดที่สามคือตำแหน่งของรอยกาแต่ละรอย นับจากปลายซ้ายของไม้ ซึ่งคือ 25 และ 50 และ 75 เอาต์พุตเป็นเงินรวม ไม่ใช่จำนวนครั้งที่ตัดและไม่ใช่ความยาวชิ้นไหน ตรงนี้คือจุดที่คนอ่านผิดบ่อยที่สุด
ไม้ต้นนี้ถูกแบ่งเท่า ๆ กันพอดี การตัดครั้งแรกจึงต้องจ่ายเต็ม 100 เสมอไม่ว่าจะตัดรอยไหน หลังจากนั้นเหลือสองชิ้นที่ต้องตัดอีกชิ้นละครั้ง เฉพาะครั้งแรกมีจุดตัดที่ให้ค่าต่ำสุดอยู่ 1 แบบ ผลรวมจึงเป็น 100 บวก 50 บวก 50 เท่ากับ 200
ใบ้
อย่าคิดเป็น "จะตัดรอยไหนก่อน" เพราะพอตัดแล้วไม้แยกเป็นสองชิ้นที่ไม่ยุ่งกันอีกเลย
ให้คิดกลับกันว่า รอยที่ตัดเป็นครั้งแรกคือรอยไหน แล้วสองชิ้นที่ได้ก็เป็นโจทย์เดิม
ที่เล็กลง ถ้าคิดแบบนี้ได้ ขอบเขต n ไม่เกินห้าสิบบอกอะไรกับคุณ
ที่มาของแนวคิดนี้
สิ่งที่ทำให้ผมเลือกดีพีบนช่วงตั้งแต่แรก ไม่ใช่การจำรูป แต่เป็นประโยคเดียวในโจทย์คือ "ตัดแล้วไม้แยกออกจากกัน" ประโยคนั้นแปลว่าหลังตัดครั้งแรก สองชิ้นที่ได้ ไม่มีทางส่งผลถึงกันได้อีก ซึ่งเป็นเงื่อนไขเดียวกับที่ทำให้ดีพีบนช่วงใช้ได้
ทางที่ผมลองก่อนคือดีพีที่จำว่า "ตัดไปแล้วรอยไหนบ้าง" ซึ่งเป็นเซตของรอย 3 รอย
ที่ n เท่านี้ยังไหว แต่พอเอาไปคูณกับขอบเขตจริงคือห้าสิบรอย
มันกลายเป็นสองยกกำลังห้าสิบ ซึ่งไม่ต้องคิดต่อ ตัวเลขนั้นเองที่บีบให้ผมกลับไปอ่านโจทย์ใหม่
แล้วเห็นว่าสิ่งที่ต้องจำไม่ใช่ "ตัดอะไรไปแล้ว" แต่เป็น "ตอนนี้ถือชิ้นไหนอยู่"
ซึ่งบอกได้ด้วยเลขสองตัวคือปลายซ้ายกับปลายขวา
บทเรียนที่หยิบไปใช้ต่อได้คือ ถ้าสถานะที่คิดออกมามีขนาดโตแบบยกกำลัง ให้กลับไปถามว่าอนาคตต้องใช้อะไรจากอดีตจริง ๆ บ่อยครั้งคำตอบเล็กกว่าที่จำไว้มาก
แปลงรอยกาเป็นช่องว่าง ไม้ที่มี n รอยกาจะมี n+1 ช่อง
แล้วนิยาม dp[i][j] ว่าเป็นค่าน้อยสุดที่ตัดช่วงช่อง [i..j] ให้แหลกหมด
ค่าใช้จ่ายของการผ่าช่วงนี้หนึ่งครั้งคือความยาวรวมของช่วง ซึ่งเท่ากันไม่ว่าจะผ่าตรงไหน
จึงดึงออกมานอกวงเล็บได้ตามสูตรที่เขียนไว้ข้างบน
ที่ n ไม่เกินห้าสิบ การไล่จุดตัดทุกจุดก็ผ่านสบาย ๆ อยู่แล้ว
แต่ผมใส่หน้าต่างของ Knuth ลงไปด้วย เพราะโค้ดชุดเดียวกันนี้จะถูกยกไปใช้กับข้อถัดไปที่ไม่ผ่านถ้าไม่ใส่
เรื่องเดียวกันเป๊ะ เปลี่ยนจากไม้เป็นสายอักขระ ต้องหักสายที่ตำแหน่งที่กำหนดให้ครบทุกตำแหน่ง หักหนึ่งครั้งเสียเท่าความยาวของสายที่กำลังหัก จุดเดียวที่ต่างคือขอบเขต
อินพุต / ขอบเขต / เอาต์พุต
L จำนวนตำแหน่งที่ต้องหัก m แล้วตำแหน่งเหล่านั้นm ถึงหนึ่งพัน และมีหลายชุดทดสอบในไฟล์เดียว| Input | Output |
|---|---|
| 30 4 3 10 17 25 | 70 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือความยาวสายทั้งเส้น 30 หน่วย ตามด้วยจำนวนตำแหน่งที่ต้องหัก 4 ตำแหน่ง บรรทัดที่สองคือตำแหน่งเหล่านั้น คือ 3, 10, 17, 25 เอาต์พุตเป็นค่าใช้จ่ายรวม ไม่ใช่จำนวนครั้งที่หัก ซึ่งรู้อยู่แล้วว่าเท่ากับจำนวนตำแหน่งพอดี
ตัวอย่างนี้เลือกมาให้ตำแหน่งไม่สม่ำเสมอ ถ้าไล่หักเรียงจากซ้ายไปขวาจะได้ 90 ส่วนลำดับที่ถูกที่สุดได้ 70 ต่างกัน 20 หน่วย ครั้งแรกมีตำแหน่งที่ให้ค่าต่ำสุดอยู่ 1 แบบ ถ้าลำดับที่คุณคิดต่างจากในภาพแต่รวมได้เท่ากัน ก็ถูกเหมือนกัน โจทย์ถามแค่ตัวเลข
ใบ้
โค้ดของข้อที่แล้วยกมาวางได้เลยโดยไม่ต้องแก้อะไรในตัวสูตร แต่ลองคูณดูก่อน m ถึงหนึ่งพันแปลว่าช่วงมีถึง 1001 ช่อง ไล่จุดตัดทุกจุดเป็นเท่าไร
แล้วในไฟล์มีหลายชุดทดสอบด้วย ตัวเลขนั้นบอกอะไรกับคุณ
ที่มาของแนวคิดนี้
ข้อนี้ผมไม่ได้คิดอะไรใหม่เลย ผมยกโค้ดข้อที่แล้วมาแล้ววัดเวลาก่อน
เพราะขอบเขตเป็นสิ่งเดียวที่เปลี่ยน ที่ n เท่ากับหนึ่งพัน
การไล่จุดตัดทุกจุดใช้ 0.384 วินาที ต่อหนึ่งชุดทดสอบ
ตัวเลขนี้เองที่ตัดสินทุกอย่าง เพราะโจทย์บอกว่ามีหลายชุดในไฟล์เดียว
สิบชุดก็เกินสี่วินาทีแล้ว
พอใส่หน้าต่างของ Knuth ลงไป ตัวเลขเดียวกันนั้นเหลือ 0.018 วินาที
และผมยังลองที่ n สามพันเพื่อดูว่าช่องว่างถ่างขึ้นจริงไหม
ได้ 32.750 วินาทีเทียบกับ 0.454 วินาที คือต่างกัน 72 เท่า ซึ่งตรงกับที่ทฤษฎีบอก
บทเรียนคือ ขอบเขตของโจทย์คือส่วนหนึ่งของโจทย์ ไม่ใช่ของแถมท้ายหน้า ข้อนี้กับข้อที่แล้วเป็นโจทย์เดียวกันทุกตัวอักษร ต่างกันแค่ตัวเลขสองตัว และตัวเลขสองตัวนั้นเปลี่ยนคำตอบว่าจะเขียนอะไร
ค่าใช้จ่ายของข้อนี้คือผลรวมของช่วง ซึ่งเป็นรูปที่ปลอดภัยตามที่บทนี้บอกไว้แล้ว
จึงใส่หน้าต่างได้ทันทีโดยไม่ต้องพิสูจน์อะไรใหม่ สิ่งเดียวที่ต้องระวังคือ
ตารางขนาดหนึ่งพันคูณหนึ่งพันสองใบ ซึ่งเป็นหน่วยความจำราวสิบสองเมกะไบต์
ถ้าโจทย์ให้โควตาน้อยกว่านั้น ต้องเปลี่ยนตาราง opt เป็น short หรือหันไปใช้ท่าอื่น
ร้านขายของชำมีของ 5 อย่างเรียงตามตัวอักษรอยู่แล้ว แต่ละอย่างมีจำนวนครั้งที่ลูกค้าถามหาต่อวัน เจ้าของร้านอยากทำผังถามตอบเป็นต้นไม้ค้นหา คือเริ่มถามที่ของหนึ่งอย่างก่อน ถ้าของที่ลูกค้าต้องการอยู่ก่อนหน้าตามตัวอักษรก็เลี้ยวซ้าย ถ้าอยู่หลังก็เลี้ยวขวา คำถามคือจัดผังยังไงให้จำนวนครั้งที่ต้องถามรวมทั้งวันน้อยที่สุด
ของที่อยู่บนสุดถูกถามหนึ่งครั้งเสมอ ของที่อยู่ลึกลงไปหนึ่งชั้นถูกถามสองครั้ง ค่าที่ต้องทำให้น้อยที่สุดจึงเป็นผลรวมของ ความถี่ คูณ ชั้นที่ของนั้นอยู่
อินพุต / ขอบเขต / เอาต์พุต
n แล้วความถี่ของแต่ละอย่าง เรียงตามลำดับตัวอักษรอยู่แล้วn ไม่เกินสองร้อยห้าสิบ ความถี่เป็นจำนวนเต็มไม่ติดลบ| Input | Output |
|---|---|
| 5 9 8 5 6 7 | 74 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกบอกว่ามีของ 5 อย่าง บรรทัดที่สองคือความถี่ของแต่ละอย่าง ตามลำดับตัวอักษร คือ เกลือ 9, ข้าว 8, งา 5, น้ำตาล 6, พริก 7 ลำดับนี้ห้ามสลับ เพราะมันคือกติกาของต้นไม้ค้นหา เอาต์พุตเป็น จำนวนครั้งที่ต้องถามรวม ไม่ใช่ความสูงของต้นไม้และไม่ใช่ชื่อของราก
ทรงที่คนเดาก่อนเสมอคือเอาตัวที่ความถี่สูงสุดขึ้นเป็นราก แล้วทำแบบเดียวกันกับสองข้าง ทรงนั้นได้ 95 ส่วนคำตอบจริงคือ 74 ต่างกัน 21 ครั้ง เพราะรากที่ความถี่สูงสุดอาจทำให้ต้นไม้เอียงจนของอีกฝั่งลึกกันหมด
ต้นไม้ในภาพมีรากคือ ข้าว ซึ่งความถี่ 8 อยู่ชั้นที่ 1 จึงคิดเป็น 8 ครั้ง เอาทุกปมมาบวกกันแบบนี้ได้ 74
ใบ้
ถามตัวเองว่า ใครเป็นราก ของช่วง [i..j] พอเลือกรากได้ ของที่อยู่ก่อนหน้ามัน
กลายเป็นต้นไม้ย่อยฝั่งซ้าย ของที่อยู่หลังกลายเป็นฝั่งขวา และทุกปมในสองฝั่งนั้น
ลึกลงไปอีกหนึ่งชั้นพร้อมกันหมด คำถามคือ การลึกลงหนึ่งชั้นพร้อมกันหมด
ทำให้ค่ารวมเพิ่มขึ้นเท่าไร แล้วตัวเลขนั้นขึ้นกับว่าเลือกรากตัวไหนหรือเปล่า
ที่มาของแนวคิดนี้
ข้อนี้เป็นข้อที่ Knuth ใช้ท่านี้ครั้งแรกจริง ๆ ผมจึงเดาไว้ตั้งแต่ต้นว่าต้องเข้าเงื่อนไข แต่สิ่งที่ต้องคิดจริงคือหน้าตาของค่าใช้จ่าย ซึ่งไม่เหมือนสองข้อแรก
กุญแจอยู่ที่คำถามในใบ้ พอเลือกรากเป็น k ของทุกตัวในสองฝั่งลึกลงหนึ่งชั้นพร้อมกัน
ค่ารวมจึงเพิ่มขึ้นเท่ากับผลรวมความถี่ของทุกตัวในสองฝั่งนั้น
ตอนแรกผมเขียนว่ามันคือผลรวมของช่วงลบความถี่ของราก ซึ่งถูก แต่ทำให้ค่าใช้จ่ายขึ้นกับ k แล้วดึงออกนอกวงเล็บไม่ได้ ท่านี้ก็ใช้ไม่ได้ตามไปด้วย
ทางออกคือเปลี่ยนวิธีนับชั้น ให้รากนับเป็นชั้นที่ 1 แทนที่จะเป็นชั้นที่ 0
พอทำแบบนั้น รากเองก็ถูกนับเพิ่มด้วย ค่าใช้จ่ายจึงกลายเป็นผลรวมความถี่ของทั้งช่วง
ซึ่งไม่ขึ้นกับ k อีกต่อไป และกลับมาเป็นรูปที่ปลอดภัยพอดี
ถ้าโจทย์ต้องการแบบรากเป็นชั้นที่ 0 ก็เอาผลรวมความถี่ทั้งหมดไปลบทีหลังครั้งเดียว
บทเรียนคือ ก่อนจะสรุปว่าโจทย์ไม่เข้ารูป ให้ลองขยับนิยามของสิ่งที่กำลังนับก่อน บ่อยครั้งค่าใช้จ่ายที่ดูขึ้นกับจุดตัด กลายเป็นไม่ขึ้นได้ด้วยการเลื่อนจุดเริ่มนับไปหนึ่งหน่วย
นิยาม g[i][j] ว่าเป็นค่าน้อยสุดของช่วง [i..j] เมื่อรากของช่วงนั้น
นับเป็นชั้นที่ 1 จะได้สูตรนี้
ตรงนี้ S(i, j) คือผลรวมความถี่ของช่วง ต่างจากแม่แบบสองอย่างคือ k เป็นรากซึ่งถูกดึงออกจากทั้งสองข้าง จึงเป็น g[i][k-1] กับ g[k+1][j] และ k เดินได้ถึง j ไม่ใช่ j-1
เพราะรากเป็นตัวขวาสุดของช่วงก็ได้ ขอบขวาของหน้าต่างจึงต้องหนีบด้วย j ไม่ใช่ j-1 ซึ่งเป็นบรรทัดเดียวที่ต่างจากแม่แบบ
นี่คือกรณีที่ตัวตรวจสำคัญที่สุดเท่าที่มีในคลังนี้ เพราะข้ออ้างของ Knuth เป็นข้ออ้างเชิงทฤษฎีที่ ถ้าไม่จริง โค้ดจะไม่ฟ้องอะไรเลย ไม่มี segmentation fault ไม่มีลูปไม่รู้จบ มีแต่ตัวเลขที่ใหญ่กว่าคำตอบจริงนิดหน่อยในบางอินพุต
ถ้าดีพีบนช่วงของคุณมีรูป dp[i][j] = min(dp[i][k] + dp[k+1][j]) + C(i, j)
และ C เป็นผลรวมของค่าที่ไม่ติดลบในช่วง ให้เก็บตาราง opt เพิ่มอีกใบ
แล้วเปลี่ยนลูปในสุดจาก i ถึง j-1 เป็น opt[i][j-1] ถึง opt[i+1][j] เท่านี้ O(n³) ก็ยุบเป็น O(n²) โดยคำตอบเท่าเดิม
ท่านี้ไม่ได้ทำให้แต่ละช่องเร็วขึ้น มันทำให้ขอบของหน้าต่างเดินไปทางเดียวตลอดทั้งเส้นทแยง แล้วงานทั้งเส้นก็ถูกนับรวมได้ทีเดียว
opt และรายการโจทย์ฝึกท้ายหน้า cp-algorithms.com opt ขยับทางเดียว ซึ่งเป็นฐานของทั้งบท
ในหน้านี้