ปูพื้นฐาน
โจทย์ "แบ่งของที่เรียงอยู่แล้วเป็น k กอง" เขียนสูตรได้ทุกคน แต่ตกรอบเพราะช้าเกินไป บทนี้ปูสะพานที่ต้นทางข้ามไป คือทำไมรู้จุดตัดที่ดีที่สุดของช่องกลางแล้วถึงเร่งได้ พร้อมเกมวางที่กั้นคอก ตัวเล่นที่เดินการเรียกซ้ำจริง ตัวอย่างค้านที่ทำให้ท่านี้ตอบผิดแบบเงียบ ๆ และโจทย์ฝึกสามข้อจากคลังอ้างอิงของต้นทาง
โจทย์ตระกูลนี้หน้าตาเหมือนกันหมด มีของ n ชิ้นเรียงกันอยู่แล้ว
ห้ามสลับที่ ให้แบ่งเป็น k กองที่ติดกัน แต่ละกองมีค่าใช้จ่ายของมันเอง
แล้วขอผลรวมที่น้อยที่สุด
เปลี่ยนคำว่า "ของ" เป็นอย่างอื่นได้เรื่อย ๆ แต่โครงไม่เปลี่ยน แบ่งม้าเข้าคอก แบ่งคิวคนขึ้นกระเช้า แบ่งข้อมูลเป็นช่วง ๆ ก่อนบีบอัด ทั้งหมดเป็นโจทย์เดียวกัน
ให้ dp[j][i] แปลว่า เอาของ i ชิ้นแรกไปจัดเป็น j กอง
ถูกที่สุดได้เท่าไร กองสุดท้ายต้องรับของตั้งแต่ชิ้นที่ m ถึงชิ้นที่ i
เราไม่รู้ว่า m ควรเป็นเท่าไร ก็ลองทุกค่า
ตรงนี้ C(m, i) คือค่าใช้จ่ายของกองที่รับของชิ้นที่ m ถึง i
ส่วน dp[j-1][m-1] คือของที่เหลือข้างหน้าซึ่งจัดไปแล้ว j-1 กอง
สูตรนี้ถูกต้องแน่นอน ปัญหาคือราคา มันมี k ชั้น ชั้นละ n ช่อง
และแต่ละช่องต้องไล่ m ได้ถึง n ค่า รวมเป็น k คูณ n
คูณ n ถ้า n เป็นแสนและ k เป็นยี่สิบ ก็คือสองแสนล้านครั้ง
ซึ่งไม่มีทางทันในเวลาที่โจทย์ให้
ชื่อเรียกของท่าที่จะแก้เรื่องนี้
ท่านี้ชื่อ Divide and Conquer DP อ่านว่า "ดิไวด์ แอนด์ คองเคอร์ ดีพี" แปลตรงตัวว่า "แบ่งแล้วพิชิต" ซึ่งเป็นชื่อเดียวกับท่าที่ใช้ในการเรียงข้อมูล คำว่า conquer มาจากภาษาละติน conquirere ที่แปลว่า "ไล่ล่ามาให้ครบ" ในที่นี้ของที่เราไล่ล่าคือ จุดตัดที่ดีที่สุด ของทุกช่องในหนึ่งชั้น ส่วนคำว่า ดีพี ย่อมาจาก dynamic programming ซึ่งบทอื่นในคลังนี้ใช้กันจนคุ้นแล้ว
ก่อนไปดูสูตรเร่งความเร็ว ลองวางที่กั้นเองก่อน กติกาคือคลิกที่ช่องว่างระหว่างม้าสองตัวเพื่อวางหรือถอนที่กั้น ต้องวางให้ครบตามจำนวนที่บอก แล้วกดตรวจ ค่าของแต่ละคอกคือ จำนวนม้าดำคูณจำนวนม้าขาว ในคอกนั้น
ม้าหกตัว สามคอก ชุดนี้คือตัวอย่างที่ติดมากับโจทย์ Timus เอง
ม้า 6 ตัว ต้องแบ่งเป็น 3 คอก จึงต้องวางที่กั้น 2 อัน
คลิกช่องว่างระหว่างม้าเพื่อวางที่กั้น
ตอนนี้ยังไม่ได้วางที่กั้น
ม้าทึบคือดำ ม้ากลวงคือขาว คอกที่มีสีเดียวล้วนมีค่าเป็นศูนย์
ชุดที่สามมีรอยต่อสีให้เลือกมากกว่าจำนวนที่กั้นที่มี ถ้าไล่ตัดตรงรอยต่อแรก ๆ จนหมดที่กั้น จะได้ 20 ขณะที่คำตอบจริงคือ 2 ที่กั้นมีจำกัด จึงต้องเก็บไว้ใช้กับรอยต่อที่คุ้มที่สุด ไม่ใช่รอยต่อที่มาถึงก่อน
พอมีคำตอบในใจแล้ว มาดูกันว่าถ้าต้องทำแบบนี้กับม้าหนึ่งแสนตัว เครื่องจะเลือกยังไง
ตั้งชื่อให้มันก่อน เรียกว่า opt(j, i)
ชื่อ opt ย่อมาจาก optimal อ่านว่า "ออปทิมัล" แปลว่า
"ดีที่สุด" มันไม่ใช่คำตอบของโจทย์ มันคือ ตำแหน่งที่ทำให้ได้คำตอบนั้น
ต่างกันเหมือนคะแนนสอบกับเลขที่นั่งของคนที่ได้คะแนนนั้น
ทีนี้ลองพิมพ์ opt ของทุกช่องออกมาดู นี่คือของจริงจากม้าแปดตัวข้างบน
| ชั้น | i=1 | i=2 | i=3 | i=4 | i=5 | i=6 | i=7 | i=8 |
|---|---|---|---|---|---|---|---|---|
| 1 คอก | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| 2 คอก | · | 2 | 3 | 3 | 3 | 3 | 4 | 5 |
| 3 คอก | · | · | 3 | 4 | 4 | 4 | 6 | 6 |
นั่นคือข้อเท็จจริงทั้งหมดที่ท่านี้ต้องการ เขียนเป็นสัญลักษณ์ได้แบบนี้
อ่านเป็นภาษาคน ถ้าของกองใหญ่ขึ้นหนึ่งชิ้น จุดตัดสุดท้ายจะไม่ถอยกลับไปทางซ้าย มันอยู่ที่เดิม หรือขยับไปทางขวา
เทียบกับของจริง
ลองนึกว่ากำลังตัดขนมปังก้อนยาวแจกคนสามคน ถ้าจู่ ๆ ขนมปังยาวขึ้นอีกนิดหนึ่งทางขวา รอยมีดสุดท้ายควรจะเลื่อนไปทางขวาหรือถอยกลับมาทางซ้าย สามัญสำนึกตอบว่าเลื่อนไปทางขวา เพราะของที่เพิ่มมาอยู่ทางขวา และคนสุดท้ายก็ควรรับภาระที่เพิ่มมานั้นบางส่วน ความรู้สึกนี้ถูกในโจทย์ส่วนใหญ่ แต่ไม่ได้ถูกเสมอ ซึ่งเป็นเรื่องที่จะเถียงกันตอนท้ายบท
นี่คือสะพานที่คนอ่านมักตกลงไป รู้ว่า opt ไม่ถอยหลัง แล้วยังไงต่อ
มันไม่ได้ช่วยตอนไล่จากซ้ายไปขวาเลย เพราะพอเดินถึงช่อง i เราก็รู้แค่ว่า
opt ของช่องนี้ไม่น้อยกว่าของช่องก่อนหน้า ซึ่งตัดงานออกได้นิดเดียว
กุญแจอยู่ที่การเลิกเดินจากซ้ายไปขวา แล้วหันไปทำช่องตรงกลางก่อน
1..8 เริ่มที่ช่องกลางคือ i = 4 ก่อนm ทั้ง 4 ค่า สมมติว่าได้ opt = 3opt(4) = 3 ช่อง 1..3 ทั้งหมดมี opt ไม่เกิน 3 และช่อง 5..8 ทั้งหมดมี opt ไม่น้อยกว่า 3 ค่าเดียวที่เพิ่งคำนวณไป ตัดพื้นที่ค้นหาของอีกเจ็ดช่องพร้อมกัน
เขียนเป็นฟังก์ชันได้หน้าตานี้ พารามิเตอร์สองตัวหลังคือหน้าต่างของจุดตัด ที่ยังต้องส่อง ไม่ใช่ช่วงของช่องที่ต้องเติม
ตารางข้างล่างคือชั้นที่ 2 ของม้าแปดตัวจริง ๆ แถวบนคือชั้นที่ 1 ซึ่งคำนวณเสร็จแล้ว แถวกลางคือช่องที่กำลังเติม แถวล่างคือจุดตัดที่เลือกได้ กดถัดไปเพื่อดูว่าหน้าต่างแคบลงยังไง
การเรียก compute() ตามลำดับจริง ในชั้นที่ 2
| แถว | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| dp ชั้นที่ 1 | ∞ | 0 | 0 | 2 | 3 | 6 | 8 | 10 | 15 |
| dp ชั้นที่ 2 | · | · | · | · | · | · | · | · | · |
| จุดตัดที่เลือก | · | · | · | · | · | · | · | · | · |
ช่องสีทองในแถวบนคือช่องที่กำลังถูกส่องในขั้นนั้น รวมทั้งชั้นแล้วส่องไป 24 ช่อง
ขณะที่ดีพีสองลูปต้องส่อง 36 ช่อง ที่ n แค่แปดยังต่างกันไม่มาก
แต่ตัวเลขนี้โตคนละแบบ ซึ่งเป็นเรื่องของหัวข้อถัดไป
การเรียกซ้ำแบ่งครึ่งไปเรื่อย ๆ จึงลึกประมาณ log n ชั้น
คำถามที่เหลือคือในหนึ่งชั้นความลึก งานรวมเป็นเท่าไร
ช่วงที่อยู่ในชั้นความลึกเดียวกันไม่ทับกัน และหน้าต่างจุดตัดของพวกมันก็เรียงต่อกันไป
โดยเหลื่อมกันได้แค่ที่ปลายข้างละหนึ่ง เพราะครึ่งซ้ายจบที่ opt พอดี
และครึ่งขวาเริ่มที่ opt พอดี ค่าเดียวกันนั้นจึงถูกนับซ้ำหนึ่งครั้ง
รวมความยาวหน้าต่างทั้งชั้นจึงไม่เกิน n บวกจำนวนช่วงในชั้นนั้น
ซึ่งชั้นที่ลึก d มีช่วงไม่เกิน 2^d ช่วง แต่ก็ไม่เกิน n อยู่ดี
งานต่อชั้นความลึกจึงเป็น O(n) คูณด้วยความลึก log n
และคูณด้วยจำนวนชั้นของดีพี k ได้เป็น O(k · n log n)
ที่คนคิดเวลาผิดบ่อย
สังเกตว่าราคานี้ไม่ได้รวมราคาของ C(m, i)
สูตรข้างบนคิดจากสมมติฐานว่าถามค่าใช้จ่ายของกองหนึ่งได้ในเวลาคงที่
ถ้า C ต้องวนลูปเองอีกรอบ ทุกอย่างต้องคูณเพิ่มเข้าไป
ซึ่งเป็นเหตุผลที่โจทย์ข้อที่สามในบทนี้ต้องออกแรงกับ C มากกว่าตัวดีพีเสียอีก
ทั้งบทเหลือโค้ดชุดเดียว เปลี่ยนแค่ C กับการอ่านอินพุต
// แม่แบบท่าแบ่งครึ่ง ใช้ได้ทุกข้อที่ dp[j][i] = min ของ dp[j-1][m-1] + C(m, i)
// เปลี่ยนแค่ฟังก์ชัน C กับการอ่านอินพุต ที่เหลือลอกทั้งก้อน
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int n, k;
vector<ll> dpBefore, dpCur;
ll C(int l, int r); // ค่าใช้จ่ายของกลุ่มที่รับตัวที่ l..r
const ll INF = LLONG_MAX / 4;
// เติม dpCur[l..r] โดยรู้ว่าจุดตัดที่ดีที่สุดของช่วงนี้อยู่ใน [optl, optr]
void compute(int l, int r, int optl, int optr) {
if (l > r) return;
int mid = (l + r) / 2;
pair<ll, int> best = {INF * 2, -1};
for (int m = optl; m <= min(mid, optr); m++)
best = min(best, {dpBefore[m - 1] + C(m, mid), m});
dpCur[mid] = best.first;
int opt = best.second;
compute(l, mid - 1, optl, opt); // ครึ่งซ้าย จุดตัดไม่เกิน opt
compute(mid + 1, r, opt, optr); // ครึ่งขวา จุดตัดไม่น้อยกว่า opt
}
ll solve() {
dpBefore.assign(n + 1, INF);
dpCur.assign(n + 1, INF);
dpBefore[0] = 0; // ชั้นที่ 0 ครอบได้แค่ของศูนย์ตัว
for (int j = 1; j <= k; j++) {
fill(dpCur.begin(), dpCur.end(), INF);
compute(1, n, 1, n);
swap(dpBefore, dpCur); // dpCur[0] ยังเป็น INF ซึ่งถูกแล้ว เพราะกลุ่มว่างไม่ได้
}
return dpBefore[n];
} สามจุดที่ต้องระวังในแม่แบบนี้
หนึ่ง ตอนเรียกซ้ำครึ่งซ้ายต้องส่ง opt เป็นขอบขวา ไม่ใช่
opt - 1 เพราะช่องซ้ายมีสิทธิ์ใช้จุดตัดเดียวกันได้ ถ้าเผลอลบหนึ่ง
คำตอบจะสูงกว่าความจริงในบางเคสโดยไม่มีอะไรฟ้อง
สอง ค่าอนันต์ต้องเลือกให้บวกกับ C แล้วไม่ล้น
ที่นี่ใช้ LLONG_MAX / 4 ไม่ใช่ LLONG_MAX
เพราะบรรทัดที่บวกไม่ได้เช็กก่อนว่าต้นทางเป็นอนันต์หรือเปล่า
สาม dpCur[0] ต้องเป็นอนันต์ ไม่ใช่ศูนย์
อันนี้เป็นบักที่ตัวโค้ดต้นแบบซึ่งเจอได้ทั่วไปในเน็ตมีติดอยู่ และมีหัวข้อของมันเองอยู่ท้ายบท
นี่คือส่วนที่ยากที่สุดจริง ๆ ของท่านี้ ไม่ใช่โค้ด เพราะโค้ดลอกได้
สิ่งที่ลอกไม่ได้คือคำตอบของคำถามว่า โจทย์ตรงหน้ามี opt ที่ไม่ถอยหลังจริงไหม
เงื่อนไขที่พิสูจน์ได้และใช้ได้กว้างที่สุดชื่อ อสมการสี่เหลี่ยม
(quadrangle inequality อ่านว่า "ควอดแรงเกิล อิเนอควอลิตี") สำหรับทุก
a ≤ b ≤ c ≤ d ต้องได้ว่า
อ่านเป็นภาษาคน ฝั่งซ้ายคือช่วงสองช่วงที่เหลื่อมกัน ฝั่งขวาคือช่วงหนึ่งกว้างมาก กับอีกช่วงแคบ ๆ ซ้อนอยู่ข้างใน อสมการนี้บอกว่า การกางออกให้กว้างมีราคาแพงกว่าเสมอ ค่าใช้จ่ายที่มีนิสัยแบบนี้คือค่าใช้จ่ายที่ลงโทษกองใหญ่ ซึ่งพอดีกับสามัญสำนึกเรื่องรอยมีดที่เลื่อนไปทางขวา
ท่าที่ผมใช้จริงเวลาแข่ง
พิสูจน์อสมการสี่เหลี่ยมกลางสนามแข่งเป็นเรื่องที่ทำไม่ทัน สิ่งที่ทำได้จริงคือ
เขียนดีพีสองลูปธรรมดาให้ถูกก่อน แล้วสั่งพิมพ์แถว opt ออกมาดู
ที่ n สัก 30 กับอินพุตสุ่มสักร้อยชุด ถ้าไม่มีแถวไหนถอยหลังเลย ก็กล้าพอที่จะส่ง
นี่ไม่ใช่การพิสูจน์ และผมพูดตรง ๆ ว่ามันคือการเดาที่มีหลักฐาน แต่มันจับกรณีที่ใช้ไม่ได้จริง ๆ
ได้เกือบทุกครั้ง เพราะโจทย์ที่ opt ถอยหลัง มักถอยตั้งแต่ n เล็ก ๆ
// เครื่องมือที่ควรเขียนก่อนเชื่อว่าใช้ท่านี้ได้: พิมพ์แถว opt ออกมาดูเลย
// ถ้าเลขในแถวไหนลดลงแม้แต่ครั้งเดียว แปลว่าท่าแบ่งครึ่งใช้กับโจทย์นี้ไม่ได้
for (int j = 1; j <= k; j++) {
printf("opt ชั้น %d:", j);
int last = 0;
for (int i = 1; i <= n; i++) {
printf(" %d", opt[j][i]);
if (opt[j][i] < last) printf("(ถอยหลัง!)");
last = opt[j][i];
}
printf("\n");
} ตัวอย่างข้างล่างเล็กมาก มีของแค่ 4 ชิ้น แบ่ง 2 กอง และค่าใช้จ่ายของทุกช่วงกำหนดมาตรง ๆ เป็นตัวเลข ไม่ได้มาจากสูตรอะไรเลย
| ช่วง | 1..1 | 1..2 | 1..3 | 1..4 | 2..2 | 2..3 | 2..4 | 3..3 | 3..4 | 4..4 |
|---|---|---|---|---|---|---|---|---|---|---|
| ค่าใช้จ่าย | 7 | 6 | 6 | 3 | 4 | 4 | 0 | 0 | 2 | 9 |
แถว opt ของชั้นที่ 2 ออกมาเป็น ·, 2, 3, 2
ซึ่งถอยหลังที่ช่องสุดท้าย ผลคือดีพีสองลูปตอบ 7
แต่ท่าแบ่งครึ่งตอบ 8
จุดที่น่ากลัวคือมันตอบสูงกว่าความจริง ไม่ใช่ตอบเป็นค่าประหลาดหรือโปรแกรมพัง คำตอบที่ได้ยังเป็นการแบ่งที่ถูกกติกาทุกอย่าง แค่ไม่ใช่การแบ่งที่ดีที่สุด ซึ่งเป็นอาการที่มองด้วยตาไม่มีทางเห็น
ฟาร์มมีม้า N ตัวยืนเรียงกันอยู่ ตัวหนึ่งเป็นดำหรือขาว
ต้องต้อนเข้าคอก K คอกโดยห้ามสลับลำดับ แต่ละคอกต้องมีม้าอย่างน้อยหนึ่งตัว
ความไม่พอใจของคอกหนึ่งคือจำนวนม้าดำคูณจำนวนม้าขาวในคอกนั้น
ขอผลรวมความไม่พอใจที่น้อยที่สุด
อินพุต / ขอบเขต / เอาต์พุต
N และ K จากนั้น N บรรทัด บรรทัดละ 1 (ดำ) หรือ 0 (ขาว)| Input | Output |
|---|---|
| 6 3 1 1 0 1 0 1 | 2 |
บรรทัดแรกบอกว่ามีม้า 6 ตัว และมีคอกให้ 3 คอก อีก 6 บรรทัดที่เหลือคือสีของม้า เรียงจากตัวซ้ายสุดไปขวาสุด ตัวเลข 1 คือดำ 0 คือขาว ชุดนี้จึงอ่านได้ว่า ดำ ดำ ขาว ดำ ขาว ดำ
สิ่งที่เราเลือกได้มีอย่างเดียวคือจะวางที่กั้นตรงไหน ม้า 6 ตัวมีช่องว่างระหว่างตัวอยู่ 5 ช่อง และต้องวางที่กั้น 2 อัน แบ่งได้ทั้งหมด 10 แบบ เอาต์พุตคือค่าที่ต่ำที่สุดในบรรดา 10 แบบนั้น ซึ่งชุดนี้คือ 2
คอกที่ 3 เป็นคอกเดียวที่ต้องจ่าย เพราะในคอกนั้นมีม้าดำ 2 ตัว และม้าขาว 1 ตัว จับคู่ข้ามสีกันได้ 2 คูณ 1 เท่ากับ 2 คู่ ส่วนอีกสองคอกมีสีเดียวล้วน จึงไม่มีคู่ต่างสีให้จับเลย ค่าเป็นศูนย์
และนี่คือทั้ง 10 แบบที่แบ่งได้ ไล่ดูแล้วจะเห็นว่า 2 คือค่าต่ำสุดจริง และมี 3 แบบที่ได้ 2 เท่ากัน โจทย์ถามแค่ตัวเลข ไม่ได้ถามว่าตัดตรงไหน ภาพข้างบนจึงเลือกมาแสดงแบบเดียว
| วางที่กั้นหลังตัวที่ | สามคอกได้ม้าตัวไหนบ้าง | ค่าของแต่ละคอก | รวม |
|---|---|---|---|
| 1 และ 2 | [1] [2] [3-6] | 1x0 + 1x0 + 2x2 | 4 |
| 1 และ 3 | [1] [2-3] [4-6] | 1x0 + 1x1 + 2x1 | 3 |
| 1 และ 4 | [1] [2-4] [5-6] | 1x0 + 2x1 + 1x1 | 3 |
| 1 และ 5 | [1] [2-5] [6] | 1x0 + 2x2 + 1x0 | 4 |
| 2 และ 3 | [1-2] [3] [4-6] | 2x0 + 0x1 + 2x1 | 2 |
| 2 และ 4 | [1-2] [3-4] [5-6] | 2x0 + 1x1 + 1x1 | 2 |
| 2 และ 5 | [1-2] [3-5] [6] | 2x0 + 1x2 + 1x0 | 2 |
| 3 และ 4 | [1-3] [4] [5-6] | 2x1 + 1x0 + 1x1 | 3 |
| 3 และ 5 | [1-3] [4-5] [6] | 2x1 + 1x1 + 1x0 | 3 |
| 4 และ 5 | [1-4] [5] [6] | 3x1 + 0x1 + 1x0 | 3 |
ใบ้
คอกที่มีม้าสีเดียวล้วนมีค่าเป็นศูนย์ ดังนั้นถ้าตัดได้พอดีตามแถบสีก็จบ แต่จำนวนคอกมักน้อยกว่าจำนวนแถบสี จึงต้องเลือกว่าจะยอมให้แถบไหนมาปนกัน คำถามที่ควรถามตอนนี้คือ ค่าใช้จ่ายของคอกหนึ่งขึ้นกับอะไรบ้าง แล้วถามซ้ำ ๆ ให้เร็วได้ไหม
ที่มาของแนวคิดนี้
ข้อนี้ไม่ได้เริ่มจากท่าแบ่งครึ่ง มันเริ่มจากการยอมรับว่าไม่มีวิธีเลือกจุดตัดแบบโลภที่ถูก ผมลองกฎ "ตัดตรงที่สีเปลี่ยนที่เจอก่อนเสมอ" กับกองม้าสิบสองตัวในมินิเกมข้างบน แล้วมันได้ 20 ขณะที่คำตอบจริงคือ 2 เพราะที่กั้นสามอันถูกใช้หมดไปกับแถบสั้น ๆ ตรงหัวแถว แล้วไม่เหลือให้แยกแถบยาวสองแถบท้ายแถวออกจากกัน
พอโลภไม่ได้ก็เหลือดีพี และดีพีของโจทย์แบ่งกองมีรูปเดียวคือ "กองสุดท้ายเริ่มตรงไหน"
เขียนแล้วได้ O(K·N²) ซึ่งที่ N เท่ากับ 500 คือ 125 ล้าน
พอผ่านอยู่แล้ว จริง ๆ ข้อนี้ไม่ต้องใช้ท่าแบ่งครึ่งเลยด้วยซ้ำ
ที่ยกมาเป็นข้อแรกเพราะค่าใช้จ่ายของมันเป็นที่ที่เห็นอสมการสี่เหลี่ยมได้ง่ายที่สุด ค่าคือดำคูณขาว ซึ่งเป็นฟังก์ชันที่โตแบบผลคูณ พอกางช่วงให้กว้างขึ้น ม้าที่เพิ่มเข้ามาจะจับคู่กับม้าคนละสีทุกตัวที่อยู่ในคอกอยู่แล้ว ราคาจึงพุ่งเร็วกว่าการกางทีละนิด
ให้ black[i] เป็นจำนวนม้าดำในตัวที่ 1 ถึง i แล้วค่าของคอกที่รับตัวที่
l ถึง r คิดได้ในเวลาคงที่ทันที
black[r] - black[l-1](r - l + 1) ลบจำนวนดำ
เมื่อ C ตอบได้ในเวลาคงที่ ก็เสียบเข้าแม่แบบได้ตรง ๆ ไม่ต้องแก้อะไรอีก
โค้ดนี้คอมไพล์ด้วย g++ 11.4 แล้วสุ่มเทียบกับดีพีสองลูป 600 รอบที่ N ไม่เกิน 9 ตรงกันทุกรอบ และตอบตัวอย่างของโจทย์ได้ 2 ที่ขอบเขตเต็ม N=500 K=250 ใช้เวลา 0.004 วินาที
มีคน n คนต่อคิวอยู่ กระเช้ามาทีละคัน รวม k คัน
คันแรกรับคนหัวคิวไป q1 คน คันต่อไปรับหัวคิวที่เหลือไป q2 คน ไปเรื่อย ๆ
ทุกคันต้องมีคนอย่างน้อยหนึ่งคน โจทย์ให้ตาราง u ขนาด n × n มาด้วย
โดย u[i][j] คือความไม่คุ้นเคยระหว่างคนที่ i กับคนที่ j
ความไม่คุ้นเคยของกระเช้าคันหนึ่งคือผลรวมของทุกคู่คนที่นั่งคันเดียวกัน
ขอผลรวมของทุกคันที่น้อยที่สุด
อินพุต / ขอบเขต / เอาต์พุต
n และ k แล้วตาราง u ขนาด n × n| Input | Output |
|---|---|
| 4 2 0 3 1 0 3 0 2 1 1 2 0 4 0 1 4 0 | 6 |
บรรทัดแรกบอกว่ามีคนต่อคิว 4 คน และมีกระเช้า 2 คัน อีก 4 บรรทัดคือตาราง
u ซึ่งอ่านแบบเดียวกับตารางระยะทางในแผนที่ ช่องแถวที่ i คอลัมน์ที่
j คือความไม่คุ้นเคยระหว่างคนคู่นั้น ตารางจึงสมมาตร (ช่องแถว 1 คอลัมน์ 2
กับช่องแถว 2 คอลัมน์ 1 เป็นเลขเดียวกัน) และแนวทแยงเป็นศูนย์ เพราะคนคนเดียวกันไม่ได้จับคู่กับตัวเอง
ที่ต้องระวังคือคิวสลับที่ไม่ได้ กระเช้าคันแรกรับคนหัวคิวติดกันไปเท่านั้น เราจึงเลือกได้แค่ว่าคันแรกจะรับไปกี่คน ที่เหลือตามมาเอง สำหรับ 4 คน 2 คัน จึงมีทางเลือกแค่ 3 แบบ
ช่องสีเขียวคือช่องที่ถูกนับจริง ช่องสีจาง ๆ ที่อยู่ใต้แนวทแยงคือเงาของช่องเดียวกัน เพราะตารางสมมาตร คู่หนึ่งคู่จึงปรากฏสองที่ และเราต้องนับมันครั้งเดียว จุดนี้คือที่มาของการหารสองในเฉลย
| คันแรกรับไปกี่คน | สองคันได้ใครบ้าง | ค่าของแต่ละคัน | รวม |
|---|---|---|---|
| 1 คน | [1] [2-4] | 0 + 7 | 7 |
| 2 คน | [1-2] [3-4] | 3 + 4 | 7 |
| 3 คน | [1-3] [4] | 6 + 0 | 6 |
ใบ้
ตัวดีพีเหมือนข้อแรกทุกอย่าง ของใหม่อยู่ที่ C เท่านั้น
ผลรวมของทุกคู่ในกลุ่มคือผลรวมของสี่เหลี่ยมจัตุรัสในตาราง
ที่มีมุมบนซ้ายและมุมล่างขวาอยู่บนแนวทแยง คำถามคือถามค่าสี่เหลี่ยมนั้นให้ได้ในเวลาคงที่ได้ไหม
และเมื่อได้แล้ว ทำไมต้องหารสอง
ที่มาของแนวคิดนี้
ตัวเลขเป็นตัวบอกเองว่าต้องใช้ท่าอะไร n เท่ากับ 4000 และ k เท่ากับ 800
ดีพีสองลูปคือ 800 คูณ 4000 คูณ 4000 ซึ่งคูณออกมาแล้วเป็นหมื่นสองพันล้าน ไม่ต้องคิดต่อ
พอเปลี่ยนเป็นท่าแบ่งครึ่ง ตัวคูณตัวสุดท้ายเหลือ log 4000 ประมาณ 12
กลายเป็นสามสิบแปดล้าน ซึ่งเป็นคนละโลกกัน
สิ่งที่ผมพลาดรอบแรกคือลืมว่าตารางสมมาตร แล้วนับทุกคู่สองครั้ง คำตอบจึงเป็นสองเท่าพอดี อาการนี้จับง่ายกว่าที่คิด เพราะพอเทียบกับตัวไล่ทุกกรณีแล้วอัตราส่วนคงที่เป๊ะทุกเคส ซึ่งเป็นสัญญาณว่าไม่ได้ผิดตรรกะ แต่ผิดตัวคูณ
บทเรียนที่หยิบไปใช้ต่อได้คือ เวลาผลต่างระหว่างของเรากับตัวตรวจเป็นอัตราส่วนคงที่ ให้มองหาการนับซ้ำก่อนเสมอ ก่อนจะไปรื้อสูตร
สร้างผลรวมสะสมสองมิติ S[i][j] เท่ากับผลรวมของสี่เหลี่ยมตั้งแต่ช่อง (1,1) ถึง (i,j)
แล้วผลรวมของสี่เหลี่ยมที่มุมอยู่ที่ l ถึง r คือ
S[r][r] - S[l-1][r] - S[r][l-1] + S[l-1][l-1]
สี่เหลี่ยมนั้นนับทุกคู่สองครั้ง เพราะทั้ง u[i][j] และ
u[j][i] อยู่ในนั้น ส่วนแนวทแยงเป็นศูนย์อยู่แล้วจึงไม่กวน หารสองก็ได้ค่าที่ต้องการ
เรื่องหน่วยความจำ ตาราง S เป็น int ขนาด 4001 คูณ 4001
คือประมาณ 64 เมกะไบต์ ค่าสูงสุดที่มันเก็บคือ 4000 คูณ 4000 คูณ 9 ซึ่งยังอยู่ในช่วงของ
int พอดี ส่วนตัวดีพีต้องเป็น long long
สุ่มเทียบกับดีพีสองลูปที่คิดค่ากลุ่มด้วยการบวกทีละคู่จริง 600 รอบ ตรงกันหมด ที่ขอบเขตเต็ม n=4000 k=800 บนเครื่องผมใช้เวลา 1.5 วินาทีรวมเวลาอ่านตัวเลข 16 ล้านตัว ซึ่งแปลว่าถ้าเวลาที่โจทย์ให้ตึง ตัวอ่านอินพุตคือสิ่งแรกที่ต้องเปลี่ยน ไม่ใช่ตัวดีพี
ให้อาเรย์ยาว n ตัดให้ติดกันเป็น k ช่วง
ค่าใช้จ่ายของช่วงหนึ่งคือจำนวนคู่ตำแหน่งในช่วงนั้นที่ค่าเท่ากัน
ขอผลรวมที่น้อยที่สุด
อินพุต / ขอบเขต / เอาต์พุต
n และ k แล้วอาเรย์ n ตัว| Input | Output |
|---|---|
| 7 3 1 1 3 3 3 2 1 | 1 |
บรรทัดแรกบอกว่าอาเรย์ยาว 7 ตัว และต้องตัดเป็น 3 ช่วง บรรทัดที่สองคือตัวเลขในอาเรย์ ตัวเลขพวกนี้ไม่ได้มีความหมายเป็นปริมาณ มันเป็นแค่ป้ายชื่อ สิ่งเดียวที่โจทย์สนใจคือ สองตำแหน่งไหนป้ายชื่อตรงกัน
ค่าใช้จ่ายนับเป็นคู่ ไม่ใช่นับตัว ถ้าช่วงหนึ่งมีเลข 3 อยู่สามตัว มันจ่ายสาม เพราะเลข 3 สามตัวจับคู่กันเองได้สามคู่ ไม่ใช่จ่ายสาม เพราะมีสามตัว ชุดนี้มีคู่ที่ค่าตรงกันทั้งหมด 6 คู่ ถ้าไม่ตัดเลยก็ต้องจ่ายครบ 6
ที่กั้นทำหน้าที่เดียวคือตัดคู่ให้ขาดจากกัน คู่ไหนมีที่กั้นคั่นอยู่ตรงกลาง คู่นั้นไม่ต้องจ่าย งานของเราคือวางที่กั้น 2 อันให้ตัดคู่ได้มากที่สุด
เส้นสีทองที่เหลืออยู่เส้นเดียวคือเลข 3 ที่ตำแหน่ง 4 กับตำแหน่ง 5 ซึ่งอยู่ในช่วงเดียวกันและแยกไม่ออก เพราะที่กั้นหมดไปก่อน จากทั้งหมด 15 แบบที่แบ่งได้ มี 3 แบบที่ได้ 1 เท่ากัน ส่วนที่เหลือถูกกว่านี้ไม่ได้ อย่างดีที่สุดก็ 2
ใบ้
ข้อนี้ผลรวมสะสมช่วยไม่ได้ เพราะจำนวนคู่ซ้ำในช่วงหนึ่งไม่ได้เป็นผลต่างของอะไรทั้งนั้น
แต่สังเกตว่าถ้ารู้ค่าของช่วง l..r แล้ว การหาค่าของ l..r+1 ราคาถูกมาก
คำถามคือ ระหว่างที่การเรียกซ้ำเดินไปเรื่อย ๆ มันขอค่าของช่วงที่ใกล้เคียงกันหรือกระโดดไปมา
ทางที่ผมลองก่อน แล้วทิ้ง
ทางแรกคือหาสูตรปิดของ C(l, r) ให้ได้ ผมนั่งอยู่พักหนึ่งกับความคิดว่าน่าจะเก็บ
"จำนวนคู่ซ้ำที่จบก่อนตำแหน่ง i" ไว้แล้วลบกัน แต่มันไม่จริง เพราะคู่ซ้ำที่ขาหนึ่งอยู่ก่อน
l และอีกขาอยู่ใน l..r ต้องถูกตัดทิ้ง ซึ่งจำนวนนั้นขึ้นกับ
ทั้งสองปลาย พร้อมกัน ไม่ใช่ปลายเดียว จึงยุบเป็นผลต่างไม่ได้
ทางที่สองคือยอมจ่ายให้ C เป็น O(r - l) ซึ่งทำให้ทั้งอัลกอริทึม
กลายเป็น O(k·n²·log n) แย่กว่าดีพีธรรมดาเสียอีก จุดที่คิดออกคือตอนที่ยอมมองว่า
C ไม่จำเป็นต้องตอบจากศูนย์ทุกครั้ง มันจำค่าของช่วงล่าสุดไว้ได้
แล้วขยับขอบทีละหนึ่ง ซึ่งเป็นท่าเดียวกับหน้าต่างเลื่อนที่บทก่อนหน้าสอนไว้
บทเรียนที่ติดตัวมาจากข้อนี้คือ เวลาเจอค่าใช้จ่ายที่ยุบเป็นผลต่างไม่ได้ อย่าเพิ่งทิ้งดีพี ให้ถามก่อนว่าลำดับการถามของอัลกอริทึมมันเรียงตัวดีพอที่จะใช้หน้าต่างเลื่อนไหม
เก็บหน้าต่างปัจจุบัน [curL, curR] กับตัวนับว่าค่าแต่ละค่าโผล่กี่ครั้งในหน้าต่างนั้น
เวลาเติมตัวใหม่เข้ามา จำนวนคู่ซ้ำเพิ่มขึ้นเท่ากับจำนวนตัวที่ค่าเดียวกันซึ่งอยู่ในหน้าต่างอยู่แล้ว
เวลาถอดออกก็ลบกลับด้วยตรรกะเดียวกัน
ลำดับการขยับสำคัญมาก ต้องขยายก่อนแล้วค่อยหด ถ้าหดก่อน หน้าต่างอาจกลายเป็นช่วงกลับด้าน กลางคัน แล้วตัวนับจะติดลบ
ทำไมถึงคุ้ม เพราะการเรียกซ้ำของท่าแบ่งครึ่งขอค่าของช่วงที่ปลายขวาเปลี่ยนช้า
ในหนึ่งกิ่งของการเรียกซ้ำ ปลายขวาคือ mid ซึ่งคงที่ตลอดลูป
และปลายซ้ายก็เดินไปข้างหน้าอย่างเดียวในลูปนั้น ระยะที่ขอบขยับรวมกันจึงเป็น
O(n log n) ต่อชั้น เท่ากับจำนวนช่องที่ส่องพอดี
สุ่มเทียบกับดีพีสองลูปที่นับคู่ซ้ำด้วยสองลูปตรง ๆ 600 รอบ ตรงกันหมด ที่ขอบเขตเต็ม n=100000 k=20 ใช้เวลา 0.27 วินาที ทั้งกรณีค่ากระจายและกรณีค่าซ้ำเยอะ
โค้ดต้นแบบที่หาเจอทั่วไปเขียนชั้นแรกด้วยมือ แล้วปล่อยให้ dp_before[0] เป็นศูนย์ค้างไว้
ทุกชั้น ความหมายของมันคือ "เอาของศูนย์ชิ้นไปจัดเป็นหลายกอง ได้ค่าศูนย์"
ซึ่งแปลว่ายอมให้กองว่าง
สำหรับสามข้อในบทนี้ มันไม่มีผลอะไรเลย เพราะค่าใช้จ่ายของทั้งสามข้อไม่เคยลดลงเมื่อเอาของมารวมกัน การใช้กองน้อยลงจึงไม่มีทางถูกกว่า ตัวเลขที่ออกมาจึงเหมือนกันเป๊ะ นี่คือเหตุผลที่บักนี้อยู่รอดมานาน
แต่พอค่าใช้จ่ายมีค่าเปิดกอง เรื่องเปลี่ยนทันที ลองให้
C(l, r) เท่ากับ 10 บวกจำนวนสมาชิก แล้วสั่งแบ่งของ 6 ชิ้นเป็น 3 กอง
| วิธีคิด | คำตอบ | เกิดอะไรขึ้น |
|---|---|---|
| ดีพีสองลูป (ถูกต้อง) | 36 | เปิดสามกองจริง จ่ายค่าเปิด 10 สามครั้ง บวกสมาชิกอีก 6 |
แบ่งครึ่งที่ปล่อย dp_before[0] = 0 | 16 | แอบใช้กองเดียวแล้วปล่อยอีกสองกองว่าง จ่ายค่าเปิดครั้งเดียว |
| แบ่งครึ่งที่แก้แล้ว | 36 | ตรงกับดีพีสองลูป |
opt ถอยหลัง
แต่มาจากบรรทัดเดียวที่ปล่อยให้กองว่างได้
ทางแก้คือให้ชั้นที่ศูนย์เป็นชั้นเดียวที่ dp[0] เป็นศูนย์ แล้ววนตั้งแต่ชั้นที่ 1
ไม่ใช่เขียนชั้นที่ 1 ด้วยมือ ซึ่งเป็นสิ่งที่แม่แบบข้างบนทำอยู่แล้ว
ทุกข้อในบทนี้ถูกตรวจด้วยโปรแกรมตัวที่สองที่ไม่รู้จักท่าแบ่งครึ่งเลย นี่คือโครงของมัน สั้นมากและเขียนได้ในสองนาที
เหตุผลที่มันมีค่าคือมันไม่ได้ใช้ข้ออ้างเดียวกับตัวจริง ถ้าเขียนตัวตรวจที่ยังเชื่อว่า
opt ไม่ถอยหลัง มันจะเห็นด้วยกับตัวจริงเสมอ แม้ตอนที่ทั้งคู่ผิด
ตัวอย่างที่โจทย์ให้มา ไม่เคยเป็นหลักฐานว่าโค้ดถูก มันถูกเขียนมาเพื่ออธิบายโจทย์ ไม่ใช่เพื่อหักโค้ด
ในหน้านี้