ปูพื้นฐาน

สองตัวชี้กับหน้าต่างเลื่อน: เลิกนับใหม่ทุกช่วง แล้วเหลือแค่สองรอบของอาเรย์

คำถามเรื่องช่วงติดกันเกือบทุกแบบยุบจากกำลังสองเหลือเชิงเส้นได้ ถ้าตอบคำถามเดียวถูก คือขอบซ้ายมีทางถอยกลับไหม บทนี้ให้กฎตัดสินข้อนั้น พร้อมรูปมาตรฐานสองแบบและโจทย์ฝึกที่คนเขียนพลาดบ่อยที่สุด

บทปูพื้นฐาน ★☆☆☆☆ two pointersพื้นฐาน อ่าน 13 นาที 7 กันยายน 2026

ปัญหาที่บทนี้แก้

โจทย์จำนวนมากถามถึงช่วงติดกันของอาเรย์หรือสตริง เช่น ช่วงที่ยาวที่สุดที่ผลรวมไม่เกินเท่านี้ ช่วงที่สั้นที่สุดที่มีของครบทุกชนิด หรือช่วงยาวคงที่ที่มีของตรงตามที่กำหนด

ท่าแรกที่ทุกคนคิดออกคือไล่ทุกคู่ของขอบซ้ายขอบขวา ซึ่งเป็น n² ช่วง แค่ n เป็นหมื่นก็เริ่มไม่ไหว ส่วนบทนี้จะให้ท่าที่เดินขอบซ้ายกับขอบขวาไปข้างหน้าอย่างเดียว คนละรอบ ซึ่งได้คำตอบเดียวกันในเวลาเชิงเส้น

อาเรย์ยาว 1,000,000 · นับเป็นจำนวนครั้งที่แตะข้อมูล
แนวคิดแตะอะไรบ้างรวม
ไล่ทุกช่วงทุกคู่ของขอบซ้ายขวา500,000,500,000
สองตัวชี้ขอบซ้ายกับขอบขวาเดินหน้าอย่างละรอบ2,000,000
ต่างกันประมาณ 250,000 เท่า และที่สำคัญกว่านั้นคือ แนวคิดที่สองไม่ได้แค่เร็วกว่า มันเขียนสั้นกว่าด้วย

กติกาข้อเดียวที่ทำให้ท่านี้ใช้ได้

หน้าต่างเลื่อนใช้ได้เมื่อการขยายขอบขวาทำให้เงื่อนไขแย่ลงเสมอ และการหดขอบซ้ายทำให้ดีขึ้นเสมอ เรียกคุณสมบัตินี้ว่าความเอกโทน (monotone)

แกะคำศัพท์

monotone อ่านว่า "โมโนโทน" แปลว่าไปทางเดียว มาจากคำกรีก monos ที่แปลว่าหนึ่งเดียว กับ tonos ที่แปลว่าเสียงหรือระดับ รวมกันแล้วหมายถึงของที่ไม่เคยเปลี่ยนทิศ มีแต่เพิ่มขึ้นตลอดหรือลดลงตลอด คำเดียวกันนี้ในภาษาพูดแปลว่าพูดเสียงเดียวราบเรียบ ก็มาจากรากเดียวกัน

ตัวอย่างที่ใช้ได้ ผลรวมของจำนวนเต็มบวก ยิ่งขยายยิ่งมาก จำนวนชนิดที่ต่างกันในช่วง ยิ่งขยายยิ่งไม่ลด ส่วนตัวอย่างที่ใช้ไม่ได้คือผลรวมของอาเรย์ที่มีเลขติดลบปนอยู่ เพราะขยายแล้วผลรวมอาจน้อยลง ขอบซ้ายที่เคยต้องเลื่อนอาจต้องถอยกลับ ซึ่งทำลายเหตุผลทั้งหมด

ระวัง

ก่อนเขียนสองตัวชี้ ให้ถามตัวเองหนึ่งคำถามเสมอ ถ้าขอบขวาขยับไปหนึ่งช่อง ขอบซ้ายมีทางถอยกลับได้ไหม ถ้าตอบว่าได้ ท่านี้ผิดตั้งแต่แรก และมันจะผิดแบบเงียบ ๆ คือตอบตัวอย่างในโจทย์ถูกแต่ตกในเทสจริง

รูปมาตรฐาน

โครงของหน้าต่างเลื่อนมีแค่สองแบบ และแทบทุกโจทย์เข้าแบบใดแบบหนึ่ง

ทั้งสองแบบมีจุดร่วมเดียวกันคือ l กับ r ไม่เคยถอยหลัง แต่ละตัวจึงเดินได้มากที่สุด n ก้าว รวมเป็น 2n ก้าวตลอดโปรแกรม ไม่ว่าลูปข้างในจะดูน่ากลัวแค่ไหน

ก้าวที่ชนะ · ยาว 4 ช่อง ผลรวม 9 3 0 1 1 4 2 1 3 5 4 9 5 2 6 6 7 l=0 r=3 ก้าวที่ล้นเพดาน · ทิ้งซ้ายออก 2 ช่อง เหลือผลรวม 10 3 1 4 1 5 9 2 6 l=2 r=4 ช่องเส้นประคือช่องที่หลุดออกไป
ตัวอย่าง 3, 1, 4, 1, 5, 9, 2, 6 กับเพดานผลรวม 10 แถวบนคือก้าวที่หน้าต่างยาวที่สุด คือช่อง 0 ถึง 3 ผลรวม 9 ยาว 4 ช่อง แถวล่างคือก้าวแรกที่ผลรวมล้นเพดาน ตัวชี้ขวาขยับมาถึงช่อง 4 แล้วผลรวมเกิน จึงต้องทิ้งช่องทางซ้ายออก 2 ช่อง จนผลรวมเหลือ 10 สังเกตว่าตัวชี้ซ้ายเดินไปทางขวาเท่านั้น ไม่เคยถอยกลับ ทั้งการเดินจึงเสียแรงรวมกันไม่เกินสองรอบอาเรย์ (วาดประกอบโดยผู้เขียน)

ลองเอง · ลากหน้าต่างหาช่วงที่ยาวที่สุด

อาเรย์สั้น ๆ ลองลากหน้าต่างดูก่อน

หาช่วงติดกันที่ยาวที่สุด โดยผลรวมต้องไม่เกิน 8

ขยับขอบขวาเพื่อรับของเพิ่ม ขยับขอบซ้ายเพื่อทิ้งของออก

ผลรวมตอนนี้ 0 · ยาว 0 ช่อง · ยาวที่สุดที่เจอ 0 · ถอยขอบซ้ายไปแล้ว 0 ครั้ง

ตัวนับ "ถอยขอบซ้าย" มีไว้ให้สังเกตตัวเอง ไม่ได้มีไว้ทำโทษ

ถ้าเล่นจนจบได้โดยตัวนับถอยยังเป็นศูนย์ แปลว่าคุณเพิ่งค้นพบเงื่อนไขที่ทำให้ท่านี้เร็วด้วยตัวเอง

โจทย์ฝึกข้อที่ 1 · ช่วงที่ยาวที่สุดที่ผลรวมไม่เกิน S

ให้อาเรย์ของจำนวนเต็มบวกยาว n กับเลข S หาความยาวของช่วงติดกันที่ยาวที่สุดซึ่งผลรวมไม่เกิน S

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
8 10
3 1 4 1 5 9 2 6
4

อ่านตัวอย่างนี้ยังไง

บรรทัดแรกคือความยาวกับเพดาน S บรรทัดที่สองคืออาเรย์ เอาต์พุตคือ ความยาวของช่วง ไม่ใช่ผลรวมของช่วง และคำว่า "ช่วง" ในโจทย์นี้คือ สมาชิกที่ติดกันเท่านั้น หยิบข้ามตัวไม่ได้

ชุดนี้ช่วงที่ยาวที่สุดที่ผลรวมไม่เกิน 10 คือตำแหน่งที่ 1 ถึง 4 ผลรวม 9 ยาว 4 ตัว ซึ่งคือเอาต์พุต ถ้ายืดขอบขวาต่ออีกหนึ่งช่อง ผลรวมจะเป็น 14 ซึ่งเกิน 10 แล้ว

ข้อสังเกตที่ทำให้ข้อนี้เร็วคือสมาชิกทุกตัวเป็นบวก ผลรวมจึงมีแต่เพิ่มเมื่อยืดขอบขวา ถ้ามีเลขติดลบปนมา ข้ออ้างนี้พังทันที

เพดานคือ 10 3 1 1 2 4 3 1 4 5 5 9 6 2 7 6 8 ผลรวม 9 ยาว 4 เอาต์พุตคือ 4
ช่องสีเขียวคือช่วงที่ยาวที่สุดที่ผลรวมยังไม่เกินเพดาน ช่องสีทองคือช่องถัดไปที่ทำให้เกิน ซึ่งเป็นเหตุผลที่ช่วงนี้ยืดต่อไม่ได้

ใบ้

สมมติเรารู้แล้วว่าขอบขวาอยู่ที่ r และขอบซ้ายที่เล็กที่สุดที่ยังผ่านคือ l พอขอบขวาขยับไปเป็น r + 1 ผลรวมมีแต่เพิ่ม แล้วขอบซ้ายที่ถูกต้องของรอบใหม่ จะอยู่ทางซ้ายของ l ได้ไหม

เฉลยข้อที่ 1

ที่มาของท่านี้

ก่อนจะเขียนหน้าต่างเลื่อน มีคำถามเดียวที่ต้องตอบให้ได้ก่อนเสมอ คือ ถ้าขอบขวาขยับไปหนึ่งช่อง ขอบซ้ายมีทางถอยกลับได้ไหม ถ้าตอบว่าไม่มีทางถอย ท่านี้ใช้ได้ และราคาเหลือเชิงเส้นเพราะขอบซ้ายเดินไปข้างหน้ารวมกันไม่เกิน N ก้าวตลอดทั้งโปรแกรม

ข้อนี้ตอบว่าไม่มีทางถอย เพราะเลขทุกตัวไม่ติดลบ ผลรวมของช่วงจึงโตขึ้นเสมอเมื่อขยายขอบขวา ขอบซ้ายที่เคยต้องขยับมาแล้ว ไม่มีทางกลับไปยืนที่เดิมได้อีก

และนี่คือจุดที่ท่านี้พังแบบเงียบที่สุด ถ้าอาเรย์มีเลขติดลบได้เมื่อไร คำตอบของคำถามข้างบนกลายเป็น "ถอยได้" ทันที โปรแกรมจะยังคอมไพล์ผ่าน ยังรันเร็ว ยังตอบตัวอย่างเล็ก ๆ ถูก แต่มันผิด เพราะช่วงที่ยาวกว่าอาจมีผลรวมน้อยกว่าได้

บทเรียนที่ยกไปข้ออื่นได้คือ หน้าต่างเลื่อนไม่ใช่รูปแบบโค้ดที่จำแล้วแปะได้ มันคือข้อสรุปของคำถามข้างบน ถามก่อนทุกครั้ง แล้วค่อยเขียน

ตอบไม่ได้ครับ เพราะถ้า [l, r] ผลรวมเกินไปแล้ว การเพิ่ม a[r+1] ซึ่งเป็นบวก ยิ่งทำให้เกินหนัก ขอบซ้ายของรอบใหม่จึงอยู่ที่ l หรือขวากว่าเสมอ นั่นแปลว่า ไม่ต้องรีเซ็ต l กลับไปที่ศูนย์ทุกรอบ

sliding_sum.cpp
// ช่วงติดกันที่ยาวที่สุดซึ่งผลรวมไม่เกิน 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);
}
ไล่ทุกก้าวบนตัวอย่าง (S = 10)
ขอบขวาค่าที่เข้ามาหดซ้ายกี่ครั้ง ขอบซ้ายผลรวมยาวดีที่สุด
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
คอลัมน์หดซ้ายรวมกันตลอดตารางได้ 6 ครั้ง ซึ่งไม่เกินความยาวอาเรย์คือ 8 นี่คือหลักฐานว่าลูปข้างในไม่ได้ทำให้ต้นทุนกลายเป็นกำลังสอง

ผมสุ่มอาเรย์เล็ก ๆ 1,500 ชุดเทียบกับตัวไล่ทุกช่วงแบบซื่อ ๆ ตรงกันหมด

โจทย์ฝึกข้อที่ 2 · ช่วงที่สั้นที่สุดที่มีครบทุกชนิด

ให้อาเรย์ยาว n ที่สมาชิกเป็นเลข 1 ถึง K หาความยาวของช่วงติดกันที่สั้นที่สุด ซึ่งมีเลขครบทั้ง K ชนิด ถ้าไม่มีให้ตอบ -1

EXAMPLE
InputOutput
8 3
2 1 3 1 1 2 3 2
3

อ่านตัวอย่างนี้ยังไง

บรรทัดแรกคือความยาวกับจำนวนชนิด K บรรทัดที่สองคืออาเรย์ซึ่งมีแต่เลข 1 ถึง 3 เอาต์พุตคือความยาวของช่วงที่สั้นที่สุดที่มีครบทุกชนิด ไม่ใช่ตำแหน่งของช่วง

คำว่า "ครบทุกชนิด" นับชนิด ไม่ได้นับตัว ช่วงที่มีเลข 1 อยู่สามตัวกับเลข 2 หนึ่งตัว ยังถือว่ามีแค่สองชนิด ชุดนี้ช่วงที่สั้นที่สุดที่ครบคือตำแหน่ง 1 ถึง 3 ยาว 3

จุดที่ต่างจากข้อแรกคือจังหวะบันทึกคำตอบ ช่วงแรกสุดที่ครบคือตำแหน่ง 1 ถึง 3 ยาว 3 ซึ่งยาวกว่าคำตอบ ถ้าหยุดตอนเพิ่งครบก็จะได้เลขผิด ต้องหดขอบซ้ายต่อจนเกือบไม่ครบ

ต้องมีครบ 3 ชนิด 2 1 1 2 3 3 1 4 1 5 2 6 3 7 2 8 ช่วงแรกที่ครบ ยาว 3 สั้นที่สุด ยาว 3 ซึ่งคือเอาต์พุต
แถบเขียวคือช่วงที่สั้นที่สุดที่มีครบทุกชนิด แถบเทาคือช่วงแรกสุดที่ครบ ซึ่งยาวกว่า ความต่างของสองแถบนี้คือเหตุผลที่ต้องหดขอบซ้ายต่อ ไม่ใช่หยุดตอนเพิ่งครบ

ใบ้

ข้อนี้เป็นรูปที่สอง คือสั้นที่สุดที่ผ่านแล้ว ต่างจากข้อแรกตรงจังหวะที่บันทึกคำตอบ ลองคิดว่าควรบันทึกตอนไหน ตอนเพิ่งครบ หรือตอนหดจนเกือบไม่ครบ

และการเช็กว่า "ครบทุกชนิดหรือยัง" ไม่ควรกวาดตารางนับทั้งใบทุกก้าว ควรเก็บอะไรไว้แทน

เฉลยข้อที่ 2

ที่มาของท่านี้ และกับดักที่ต้องระวัง

ข้อนี้ผ่านคำถามเดิมได้เหมือนกัน พอหน้าต่างมีครบทุกชนิดแล้ว การขยายขอบขวาต่อไม่มีทางทำให้มัน กลับไปไม่ครบได้ ขอบซ้ายจึงมีแต่เดินหน้า

กับดักอยู่ที่คำเดียวในโค้ด คือใช้ while หรือ if ตอนหดขอบซ้าย ในหนึ่งก้าวของขอบขวา ขอบซ้ายอาจหดได้หลายช่องติดกัน ถ้าเขียนเป็น if มันจะหดแค่ช่องเดียวแล้วปล่อยผ่าน คำตอบจึงยาวเกินจริง

สิ่งที่ทำให้กับดักนี้เจ็บคือมันตอบตัวอย่างง่าย ๆ ถูกได้สบาย เพราะเคสเล็กมักไม่มีจังหวะที่ต้องหดสองช่องรวดในก้าวเดียว ต้องจงใจสร้างเคสที่มีตัวซ้ำเยอะ ๆ ถึงจะเห็น

บันทึกตอนหดครับ เพราะทันทีที่ครบ เรายังไม่รู้ว่าหดได้อีกแค่ไหน จึงหดไปเรื่อย ๆ พร้อมบันทึกความยาวทุกครั้ง จนกว่าจะขาดชนิดใดชนิดหนึ่ง แล้วค่อยขยายขวาต่อ

ส่วนการเช็กความครบ ใช้ท่าเดียวกับที่ โจทย์กลิฟมายัน ใช้ คือเก็บตัวนับเพิ่มอีกตัวว่าตอนนี้มีกี่ชนิดที่โผล่แล้ว แล้วอัปเดตมันเฉพาะตอนที่ค่าในช่องข้ามศูนย์

shortest_cover.cpp
// ช่วงติดกันที่สั้นที่สุดซึ่งมีค่าครบทั้ง 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) ซึ่งเป็นวิธีนับเดียวกับที่ทำให้ท่าสองตัวชี้ในบทนี้ถูกตั้งแต่ต้น

ต้นทุน · ที่ n = 1,000,000 และ k = 5,000
แนวคิดงานต่อหนึ่งก้าวรวม
กวาดหาค่ามากสุดใหม่ทุกหน้าต่าง k ครั้ง 5,000,000,000
แถวสองหัวที่เรียงลด เฉลี่ย 2 ครั้ง 2,000,000
ต่างกัน 2,500 เท่า ผมคอมไพล์โค้ดข้างล่างด้วย g++ แล้วรันที่ขอบเขตนี้จริง ใช้เวลา 0.15 วินาที และหน่วยความจำราว 12 เมกะไบต์ ส่วนทางที่กวาดใหม่ทุกหน้าต่างคือห้าพันล้านครั้ง ซึ่งไม่จบในเวลาที่โจทย์แบบนี้ให้
window_max.cpp
// ค่ามากที่สุดของทุกหน้าต่างกว้าง 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;
}
ตัวตรวจที่ทำให้กล้าเชื่อโค้ดข้างบน

ข้ออ้างทั้งหมดของท่านี้อยู่บนประโยคเดียว คือ "เก่ากว่าและไม่ใหญ่กว่า จึงทิ้งได้" ตัวตรวจจึงต้องไม่รู้จักประโยคนั้นเลย ไม่งั้นมันจะทดสอบแค่ว่าผมพิมพ์ถูกไหม ตัวนี้กวาดทุกหน้าต่างแบบซื่อ ๆ ซึ่งช้ากว่ามากแต่ไม่มีอะไรให้คิดผิด

brute_window_max.cpp
// ตัวตรวจอิสระ กวาดทุกหน้าต่างแล้วหาค่ามากสุดตรง ๆ
// มันไม่รู้จักข้ออ้างเรื่อง "เก่ากว่าและเล็กกว่า" เลย จึงไม่ได้ทดสอบแค่การพิมพ์
#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]);
    for (int l = 0; l + k <= n; l++) {
        int best = a[l];
        for (int j = l; j < l + k; j++) best = max(best, a[j]);
        printf("%d%c", best, l + k == n ? '\n' : ' ');
    }
    return 0;
}

ผมสุ่มอาเรย์เล็ก ๆ พร้อม k สุ่ม แล้วเทียบเอาต์พุตสองฝั่ง 600 รอบ ตรงกันทุกรอบ ชุดสุ่มจงใจให้มีค่าซ้ำกันเยอะ (สุ่มจาก -9 ถึง 9) เพราะจุดที่พลาดง่ายที่สุดของท่านี้คือเครื่องหมาย ตอนเทียบค่าเท่ากัน ถ้าเขียน < แทน ≤ ตำแหน่งที่ค่าเท่ากันจะค้างอยู่ในแถว แล้วโค้ดยังตอบถูกอยู่ดีในหลายกรณี ผิดเฉพาะบางรูป ซึ่งเป็นบั๊กประเภทที่ตัวอย่างในโจทย์จับไม่ได้

ท่านี้ยกไปใช้ได้กว้างกว่าที่คิด รูปทั่วไปของมันคือ ถ้าของชิ้นหนึ่งทั้งเก่ากว่าและแย่กว่าอีกชิ้น มันไร้ประโยชน์ถาวร พอเจอโจทย์ที่ต้องรู้ค่าที่ดีที่สุดของช่วงที่เลื่อนไปทางเดียว ให้ลองถามประโยคนี้ก่อน ถ้าตอบว่าใช่ ก็เก็บของที่ยังมีสิทธิ์ไว้ในแถวเดียว แล้วคำตอบจะรออยู่ที่หัวแถวเสมอ ญาติสนิทของมันคือสแต็กที่เรียงทางเดียว (monotonic stack) ซึ่งตอบคำถามว่า "ตัวถัดไปที่ใหญ่กว่าอยู่ที่ไหน" ด้วยข้ออ้างเดียวกันเป๊ะ

เอาไปใช้ที่ไหนในคลังนี้

สรุปบรรทัดเดียว

ถ้าขยายขอบขวาแล้วขอบซ้ายไม่มีทางถอยกลับ ก็ไม่ต้องเริ่มนับใหม่ทุกรอบ ปล่อยให้ตัวชี้ทั้งสองเดินหน้าอย่างเดียว แล้วต้นทุนทั้งโปรแกรมจะเป็นสองรอบของอาเรย์ ไม่ว่าลูปซ้อนกันจะดูน่ากลัวแค่ไหน