ปูพื้นฐาน
คำถามเรื่องช่วงติดกันเกือบทุกแบบยุบจากกำลังสองเหลือเชิงเส้นได้ ถ้าตอบคำถามเดียวถูก คือขอบซ้ายมีทางถอยกลับไหม บทนี้ให้กฎตัดสินข้อนั้น พร้อมรูปมาตรฐานสองแบบและโจทย์ฝึกที่คนเขียนพลาดบ่อยที่สุด
โจทย์จำนวนมากถามถึงช่วงติดกันของอาเรย์หรือสตริง เช่น ช่วงที่ยาวที่สุดที่ผลรวมไม่เกินเท่านี้ ช่วงที่สั้นที่สุดที่มีของครบทุกชนิด หรือช่วงยาวคงที่ที่มีของตรงตามที่กำหนด
ท่าแรกที่ทุกคนคิดออกคือไล่ทุกคู่ของขอบซ้ายขอบขวา ซึ่งเป็น n² ช่วง แค่ n เป็นหมื่นก็เริ่มไม่ไหว
ส่วนบทนี้จะให้ท่าที่เดินขอบซ้ายกับขอบขวาไปข้างหน้าอย่างเดียว คนละรอบ
ซึ่งได้คำตอบเดียวกันในเวลาเชิงเส้น
| แนวคิด | แตะอะไรบ้าง | รวม |
|---|---|---|
| ไล่ทุกช่วง | ทุกคู่ของขอบซ้ายขวา | 500,000,500,000 |
| สองตัวชี้ | ขอบซ้ายกับขอบขวาเดินหน้าอย่างละรอบ | 2,000,000 |
หน้าต่างเลื่อนใช้ได้เมื่อการขยายขอบขวาทำให้เงื่อนไขแย่ลงเสมอ และการหดขอบซ้ายทำให้ดีขึ้นเสมอ เรียกคุณสมบัตินี้ว่าความเอกโทน (monotone)
แกะคำศัพท์
monotone อ่านว่า "โมโนโทน" แปลว่าไปทางเดียว มาจากคำกรีก monos ที่แปลว่าหนึ่งเดียว กับ tonos ที่แปลว่าเสียงหรือระดับ รวมกันแล้วหมายถึงของที่ไม่เคยเปลี่ยนทิศ มีแต่เพิ่มขึ้นตลอดหรือลดลงตลอด คำเดียวกันนี้ในภาษาพูดแปลว่าพูดเสียงเดียวราบเรียบ ก็มาจากรากเดียวกัน
ตัวอย่างที่ใช้ได้ ผลรวมของจำนวนเต็มบวก ยิ่งขยายยิ่งมาก จำนวนชนิดที่ต่างกันในช่วง ยิ่งขยายยิ่งไม่ลด ส่วนตัวอย่างที่ใช้ไม่ได้คือผลรวมของอาเรย์ที่มีเลขติดลบปนอยู่ เพราะขยายแล้วผลรวมอาจน้อยลง ขอบซ้ายที่เคยต้องเลื่อนอาจต้องถอยกลับ ซึ่งทำลายเหตุผลทั้งหมด
ระวัง
ก่อนเขียนสองตัวชี้ ให้ถามตัวเองหนึ่งคำถามเสมอ ถ้าขอบขวาขยับไปหนึ่งช่อง ขอบซ้ายมีทางถอยกลับได้ไหม ถ้าตอบว่าได้ ท่านี้ผิดตั้งแต่แรก และมันจะผิดแบบเงียบ ๆ คือตอบตัวอย่างในโจทย์ถูกแต่ตกในเทสจริง
โครงของหน้าต่างเลื่อนมีแค่สองแบบ และแทบทุกโจทย์เข้าแบบใดแบบหนึ่ง
ทั้งสองแบบมีจุดร่วมเดียวกันคือ l กับ r ไม่เคยถอยหลัง แต่ละตัวจึงเดินได้มากที่สุด
n ก้าว รวมเป็น 2n ก้าวตลอดโปรแกรม ไม่ว่าลูปข้างในจะดูน่ากลัวแค่ไหน
อาเรย์สั้น ๆ ลองลากหน้าต่างดูก่อน
หาช่วงติดกันที่ยาวที่สุด โดยผลรวมต้องไม่เกิน 8
ขยับขอบขวาเพื่อรับของเพิ่ม ขยับขอบซ้ายเพื่อทิ้งของออก
ผลรวมตอนนี้ 0 · ยาว 0 ช่อง · ยาวที่สุดที่เจอ 0 · ถอยขอบซ้ายไปแล้ว 0 ครั้ง
ตัวนับ "ถอยขอบซ้าย" มีไว้ให้สังเกตตัวเอง ไม่ได้มีไว้ทำโทษ
ถ้าเล่นจนจบได้โดยตัวนับถอยยังเป็นศูนย์ แปลว่าคุณเพิ่งค้นพบเงื่อนไขที่ทำให้ท่านี้เร็วด้วยตัวเอง
ให้อาเรย์ของจำนวนเต็มบวกยาว n กับเลข S
หาความยาวของช่วงติดกันที่ยาวที่สุดซึ่งผลรวมไม่เกิน S
อินพุต / ขอบเขต / เอาต์พุต
n และ S บรรทัดที่สองคือสมาชิก n ตัวn ≤ 1,000,000 สมาชิกทุกตัวเป็นจำนวนเต็มบวก| Input | Output |
|---|---|
| 8 10 3 1 4 1 5 9 2 6 | 4 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือความยาวกับเพดาน S บรรทัดที่สองคืออาเรย์ เอาต์พุตคือ
ความยาวของช่วง ไม่ใช่ผลรวมของช่วง และคำว่า "ช่วง" ในโจทย์นี้คือ
สมาชิกที่ติดกันเท่านั้น หยิบข้ามตัวไม่ได้
ชุดนี้ช่วงที่ยาวที่สุดที่ผลรวมไม่เกิน 10 คือตำแหน่งที่ 1 ถึง 4 ผลรวม 9 ยาว 4 ตัว ซึ่งคือเอาต์พุต ถ้ายืดขอบขวาต่ออีกหนึ่งช่อง ผลรวมจะเป็น 14 ซึ่งเกิน 10 แล้ว
ข้อสังเกตที่ทำให้ข้อนี้เร็วคือสมาชิกทุกตัวเป็นบวก ผลรวมจึงมีแต่เพิ่มเมื่อยืดขอบขวา ถ้ามีเลขติดลบปนมา ข้ออ้างนี้พังทันที
ใบ้
สมมติเรารู้แล้วว่าขอบขวาอยู่ที่ r และขอบซ้ายที่เล็กที่สุดที่ยังผ่านคือ l
พอขอบขวาขยับไปเป็น r + 1 ผลรวมมีแต่เพิ่ม แล้วขอบซ้ายที่ถูกต้องของรอบใหม่
จะอยู่ทางซ้ายของ l ได้ไหม
ที่มาของท่านี้
ก่อนจะเขียนหน้าต่างเลื่อน มีคำถามเดียวที่ต้องตอบให้ได้ก่อนเสมอ คือ ถ้าขอบขวาขยับไปหนึ่งช่อง ขอบซ้ายมีทางถอยกลับได้ไหม ถ้าตอบว่าไม่มีทางถอย ท่านี้ใช้ได้ และราคาเหลือเชิงเส้นเพราะขอบซ้ายเดินไปข้างหน้ารวมกันไม่เกิน N ก้าวตลอดทั้งโปรแกรม
ข้อนี้ตอบว่าไม่มีทางถอย เพราะเลขทุกตัวไม่ติดลบ ผลรวมของช่วงจึงโตขึ้นเสมอเมื่อขยายขอบขวา ขอบซ้ายที่เคยต้องขยับมาแล้ว ไม่มีทางกลับไปยืนที่เดิมได้อีก
และนี่คือจุดที่ท่านี้พังแบบเงียบที่สุด ถ้าอาเรย์มีเลขติดลบได้เมื่อไร คำตอบของคำถามข้างบนกลายเป็น "ถอยได้" ทันที โปรแกรมจะยังคอมไพล์ผ่าน ยังรันเร็ว ยังตอบตัวอย่างเล็ก ๆ ถูก แต่มันผิด เพราะช่วงที่ยาวกว่าอาจมีผลรวมน้อยกว่าได้
บทเรียนที่ยกไปข้ออื่นได้คือ หน้าต่างเลื่อนไม่ใช่รูปแบบโค้ดที่จำแล้วแปะได้ มันคือข้อสรุปของคำถามข้างบน ถามก่อนทุกครั้ง แล้วค่อยเขียน
ตอบไม่ได้ครับ เพราะถ้า [l, r] ผลรวมเกินไปแล้ว การเพิ่ม a[r+1] ซึ่งเป็นบวก
ยิ่งทำให้เกินหนัก ขอบซ้ายของรอบใหม่จึงอยู่ที่ l หรือขวากว่าเสมอ นั่นแปลว่า
ไม่ต้องรีเซ็ต l กลับไปที่ศูนย์ทุกรอบ
// ช่วงติดกันที่ยาวที่สุดซึ่งผลรวมไม่เกิน S (ทุกตัวเป็นจำนวนเต็มบวก)
#include <bits/stdc++.h>
using namespace std;
int main() {
int n; long long S;
scanf("%d %lld", &n, &S);
vector<long long> a(n);
for (auto& v : a) scanf("%lld", &v);
long long sum = 0; int best = 0, l = 0;
for (int r = 0; r < n; r++) {
sum += a[r];
while (sum > S) sum -= a[l++]; // ขอบซ้ายเดินหน้าอย่างเดียว ไม่มีวันถอย
best = max(best, r - l + 1);
}
printf("%d\n", best);
} | ขอบขวา | ค่าที่เข้ามา | หดซ้ายกี่ครั้ง | ขอบซ้าย | ผลรวม | ยาว | ดีที่สุด |
|---|---|---|---|---|---|---|
| 0 | 3 | 0 | 0 | 3 | 1 | 1 |
| 1 | 1 | 0 | 0 | 4 | 2 | 2 |
| 2 | 4 | 0 | 0 | 8 | 3 | 3 |
| 3 | 1 | 0 | 0 | 9 | 4 | 4 |
| 4 | 5 | 2 | 2 | 10 | 3 | 4 |
| 5 | 9 | 3 | 5 | 9 | 1 | 4 |
| 6 | 2 | 1 | 6 | 2 | 1 | 4 |
| 7 | 6 | 0 | 6 | 8 | 2 | 4 |
ผมสุ่มอาเรย์เล็ก ๆ 1,500 ชุดเทียบกับตัวไล่ทุกช่วงแบบซื่อ ๆ ตรงกันหมด
ให้อาเรย์ยาว n ที่สมาชิกเป็นเลข 1 ถึง K หาความยาวของช่วงติดกันที่สั้นที่สุด
ซึ่งมีเลขครบทั้ง K ชนิด ถ้าไม่มีให้ตอบ -1
| Input | Output |
|---|---|
| 8 3 2 1 3 1 1 2 3 2 | 3 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือความยาวกับจำนวนชนิด K บรรทัดที่สองคืออาเรย์ซึ่งมีแต่เลข 1 ถึง 3
เอาต์พุตคือความยาวของช่วงที่สั้นที่สุดที่มีครบทุกชนิด ไม่ใช่ตำแหน่งของช่วง
คำว่า "ครบทุกชนิด" นับชนิด ไม่ได้นับตัว ช่วงที่มีเลข 1 อยู่สามตัวกับเลข 2 หนึ่งตัว ยังถือว่ามีแค่สองชนิด ชุดนี้ช่วงที่สั้นที่สุดที่ครบคือตำแหน่ง 1 ถึง 3 ยาว 3
จุดที่ต่างจากข้อแรกคือจังหวะบันทึกคำตอบ ช่วงแรกสุดที่ครบคือตำแหน่ง 1 ถึง 3 ยาว 3 ซึ่งยาวกว่าคำตอบ ถ้าหยุดตอนเพิ่งครบก็จะได้เลขผิด ต้องหดขอบซ้ายต่อจนเกือบไม่ครบ
ใบ้
ข้อนี้เป็นรูปที่สอง คือสั้นที่สุดที่ผ่านแล้ว ต่างจากข้อแรกตรงจังหวะที่บันทึกคำตอบ ลองคิดว่าควรบันทึกตอนไหน ตอนเพิ่งครบ หรือตอนหดจนเกือบไม่ครบ
และการเช็กว่า "ครบทุกชนิดหรือยัง" ไม่ควรกวาดตารางนับทั้งใบทุกก้าว ควรเก็บอะไรไว้แทน
ที่มาของท่านี้ และกับดักที่ต้องระวัง
ข้อนี้ผ่านคำถามเดิมได้เหมือนกัน พอหน้าต่างมีครบทุกชนิดแล้ว การขยายขอบขวาต่อไม่มีทางทำให้มัน กลับไปไม่ครบได้ ขอบซ้ายจึงมีแต่เดินหน้า
กับดักอยู่ที่คำเดียวในโค้ด คือใช้ while หรือ if ตอนหดขอบซ้าย
ในหนึ่งก้าวของขอบขวา ขอบซ้ายอาจหดได้หลายช่องติดกัน ถ้าเขียนเป็น if
มันจะหดแค่ช่องเดียวแล้วปล่อยผ่าน คำตอบจึงยาวเกินจริง
สิ่งที่ทำให้กับดักนี้เจ็บคือมันตอบตัวอย่างง่าย ๆ ถูกได้สบาย เพราะเคสเล็กมักไม่มีจังหวะที่ต้องหดสองช่องรวดในก้าวเดียว ต้องจงใจสร้างเคสที่มีตัวซ้ำเยอะ ๆ ถึงจะเห็น
บันทึกตอนหดครับ เพราะทันทีที่ครบ เรายังไม่รู้ว่าหดได้อีกแค่ไหน จึงหดไปเรื่อย ๆ พร้อมบันทึกความยาวทุกครั้ง จนกว่าจะขาดชนิดใดชนิดหนึ่ง แล้วค่อยขยายขวาต่อ
ส่วนการเช็กความครบ ใช้ท่าเดียวกับที่ โจทย์กลิฟมายัน ใช้ คือเก็บตัวนับเพิ่มอีกตัวว่าตอนนี้มีกี่ชนิดที่โผล่แล้ว แล้วอัปเดตมันเฉพาะตอนที่ค่าในช่องข้ามศูนย์
// ช่วงติดกันที่สั้นที่สุดซึ่งมีค่าครบทั้ง K ชนิด (ค่าในอาเรย์อยู่ระหว่าง 1 ถึง K)
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, K;
scanf("%d %d", &n, &K);
vector<int> a(n);
for (auto& v : a) scanf("%d", &v);
vector<int> cnt(K + 1, 0);
int have = 0, best = INT_MAX, l = 0;
for (int r = 0; r < n; r++) {
if (cnt[a[r]]++ == 0) have++; // ชนิดนี้เพิ่งโผล่ครั้งแรกในหน้าต่าง
while (have == K) { // ครบแล้ว หดขอบซ้ายให้สั้นที่สุดเท่าที่ยังครบ
best = min(best, r - l + 1);
if (--cnt[a[l++]] == 0) have--;
}
}
printf("%d\n", best == INT_MAX ? -1 : best);
}
สังเกตว่าเงื่อนไขในลูปข้างในคือ while (have == K) ไม่ใช่ if
ถ้าเขียนเป็น if โปรแกรมจะหดได้แค่ครั้งเดียวต่อการขยับขอบขวาหนึ่งครั้ง
ซึ่งไม่พอที่จะหาช่วงที่สั้นที่สุดจริง ๆ และมันตอบตัวอย่างง่าย ๆ ถูกได้สบายมาก
ผมสุ่มอาเรย์เล็ก ๆ 1,500 ชุดเทียบกับตัวไล่ทุกช่วง ตรงกันหมด
ท่าหน้าต่างเลื่อนที่ผ่านมาทั้งบทยืนอยู่บนกติกาข้อเดียว คือตอนตัวเข้าตัวออก เราต้องอัปเดตคำตอบของหน้าต่างได้ทันที ผลรวมทำได้ เพราะบวกตัวที่เข้าและลบตัวที่ออกก็จบ แต่ถ้าโจทย์เปลี่ยนคำถามเป็น ค่ามากที่สุดของทุกหน้าต่างกว้าง k กติกานั้นพังทันที
ที่พังเพราะการลบ ตอนตัวซ้ายหลุดออกจากหน้าต่าง ถ้าตัวที่หลุดนั้นดันเป็นตัวที่มากที่สุดอยู่
เราจะไม่มีข้อมูลอะไรเหลือให้กู้คำตอบใหม่เลย รู้แค่ว่าค่ามากสุดเดิมเท่าไร
ซึ่งใช้ไม่ได้แล้ว ทางที่เห็นชัดที่สุดคือกวาดหาใหม่ทั้งหน้าต่าง แต่นั่นคือ k ครั้งต่อหนึ่งก้าว
ซึ่งพาเรากลับไปที่ปัญหาเดิมที่บทนี้เปิดเรื่องด้วย
ลองถามคำถามที่แคบกว่าเดิม แทนที่จะถามว่า "ค่ามากสุดคืออะไร" ให้ถามว่า ตำแหน่งไหนยังมีสิทธิ์เป็นคำตอบในอนาคตอยู่บ้าง
สมมติมีสองตำแหน่ง j กับ r โดย j อยู่ก่อน r
และ a[j] ≤ a[r] ตำแหน่ง j จะไม่มีวันได้เป็นคำตอบอีกเลย
เพราะทุกหน้าต่างที่ยังมี j อยู่ ก็มี r อยู่ด้วยแน่ ๆ
(r อยู่ทางขวาและหน้าต่างเลื่อนไปทางขวาเท่านั้น) และ r ก็ไม่เล็กกว่า
เก่ากว่าและไม่ใหญ่กว่า จึงไร้ประโยชน์ถาวร ทิ้งได้เลย
พอทิ้งของแบบนั้นได้ ตำแหน่งที่เหลืออยู่ในมือจะมีค่าเรียงจากมากไปน้อยโดยอัตโนมัติ เพราะทุกคู่ที่ผิดลำดับถูกทิ้งไปแล้ว และเมื่อมันเรียงอยู่แล้ว ค่ามากสุดของหน้าต่างก็คือตัวหัวแถว ไม่ต้องหาอะไรอีก
โครงสร้างที่รับของเข้าท้ายแถว ทิ้งของจากท้ายแถว และทิ้งของที่หมดอายุจากหัวแถวได้ด้วย เรียกว่าแถวสองหัว (deque ย่อจาก double-ended queue อ่านว่า "เด็ก") และเมื่อเราคุมให้ค่าในนั้น เรียงทางเดียวตลอด ก็เรียกรวมกันว่าแถวสองหัวที่เรียงทางเดียว (monotonic deque)
ในโค้ดข้างล่างมี while ซ้อนอยู่ใน for ซึ่งหน้าตาเหมือน O(n²)
แต่มันไม่ใช่ เพราะทุกตำแหน่งเข้าแถวได้ครั้งเดียวและออกจากแถวได้ครั้งเดียว
งานทั้งการเดินจึงมีเพดานที่ 2n ครั้ง ไม่ว่าลูปในจะวนยาวแค่ไหนในบางก้าว
วิธีนับต้นทุนแบบมองยอดรวมแทนการมองก้าวที่แย่ที่สุด เรียกว่าการนับแบบเฉลี่ยทบ (amortized)
ซึ่งเป็นวิธีนับเดียวกับที่ทำให้ท่าสองตัวชี้ในบทนี้ถูกตั้งแต่ต้น
| แนวคิด | งานต่อหนึ่งก้าว | รวม |
|---|---|---|
| กวาดหาค่ามากสุดใหม่ทุกหน้าต่าง | k ครั้ง | 5,000,000,000 |
| แถวสองหัวที่เรียงลด | เฉลี่ย 2 ครั้ง | 2,000,000 |
// ค่ามากที่สุดของทุกหน้าต่างกว้าง k ด้วยแถวสองหัวที่เรียงลด
// dq เก็บ "ตำแหน่ง" ไม่ใช่ค่า และค่าที่ตำแหน่งเหล่านั้นเรียงจากมากไปน้อยเสมอ
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, k; scanf("%d %d", &n, &k);
vector<int> a(n);
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
deque<int> dq;
for (int r = 0; r < n; r++) {
// ตัวใหม่ใหญ่กว่าหรือเท่าตัวท้ายแถว ตัวท้ายจึงหมดสิทธิ์เป็นคำตอบตลอดกาล
// เพราะมันทั้งเก่ากว่าและไม่ใหญ่กว่า ทิ้งได้เลยโดยไม่ต้องกลัวเสียคำตอบ
while (!dq.empty() && a[dq.back()] <= a[r]) dq.pop_back();
dq.push_back(r);
// หัวแถวหลุดออกนอกหน้าต่างไปแล้ว
if (dq.front() <= r - k) dq.pop_front();
// หน้าต่างเต็มแล้ว หัวแถวคือค่ามากสุดของหน้าต่างนี้
if (r >= k - 1) printf("%d%c", a[dq.front()], r + 1 == n ? '\n' : ' ');
}
return 0;
} ท่านี้ยกไปใช้ได้กว้างกว่าที่คิด รูปทั่วไปของมันคือ ถ้าของชิ้นหนึ่งทั้งเก่ากว่าและแย่กว่าอีกชิ้น มันไร้ประโยชน์ถาวร พอเจอโจทย์ที่ต้องรู้ค่าที่ดีที่สุดของช่วงที่เลื่อนไปทางเดียว ให้ลองถามประโยคนี้ก่อน ถ้าตอบว่าใช่ ก็เก็บของที่ยังมีสิทธิ์ไว้ในแถวเดียว แล้วคำตอบจะรออยู่ที่หัวแถวเสมอ ญาติสนิทของมันคือสแต็กที่เรียงทางเดียว (monotonic stack) ซึ่งตอบคำถามว่า "ตัวถัดไปที่ใหญ่กว่าอยู่ที่ไหน" ด้วยข้ออ้างเดียวกันเป๊ะ
ถ้าขยายขอบขวาแล้วขอบซ้ายไม่มีทางถอยกลับ ก็ไม่ต้องเริ่มนับใหม่ทุกรอบ ปล่อยให้ตัวชี้ทั้งสองเดินหน้าอย่างเดียว แล้วต้นทุนทั้งโปรแกรมจะเป็นสองรอบของอาเรย์ ไม่ว่าลูปซ้อนกันจะดูน่ากลัวแค่ไหน
ในหน้านี้