programming.in.th · ข้อ 2003
ท่าที่ฝึกคือการอัดสถานะ ซึ่งใช้ได้ทุกครั้งที่โจทย์มีคำว่า "สามชิ้นล่าสุด" หรือหน้าต่างที่เลื่อนไปเรื่อย ๆ ทุกอย่างเริ่มจากคำถามว่าอดีตส่วนไหนยังมีผลกับก้าวถัดไปจริง ๆ และวิธีนับว่ามันมีได้กี่แบบ พร้อมกองห้าชิ้นที่หักวิธีโลภ
มีเหมืองถ่านหินอยู่สองแห่ง แต่ละแห่งมีคนงานประจำอยู่ของตัวเอง คนงานจะขุดถ่านก็ต่อเมื่อมีอาหารส่งไปถึง
และอาหารมีสามชนิดคือ M (meat เนื้อ), F (fish ปลา) และ B (bread ขนมปัง)
ที่ทำให้ข้อนี้สนุกคือคนงานเบื่ออาหารซ้ำ ทุกครั้งที่ของมาถึงเหมืองไหน คนงานเหมืองนั้นจะมองย้อนไปที่ ของชิ้นที่เพิ่งมาถึงรวมกับสองชิ้นก่อนหน้า (ถ้ายังมีไม่ครบสามชิ้นก็ดูเท่าที่มี) แล้วนับว่าในนั้นมีอาหารกี่ชนิดที่ไม่ซ้ำกัน ได้หนึ่งชนิดขุดได้ 1 ก้อน สองชนิดได้ 2 ก้อน สามชนิดได้ 3 ก้อน
ของแต่ละชิ้นแบ่งครึ่งไม่ได้ ต้องส่งเข้าเหมืองใดเหมืองหนึ่งทั้งชิ้น และของมาถึงเรียงกันมาเป็นลำดับที่กำหนดไว้แล้ว เปลี่ยนลำดับไม่ได้ หน้าที่เรามีอย่างเดียวคือชี้ว่าชิ้นนี้ไปเหมือง 1 หรือเหมือง 2 เพื่อให้ถ่านรวมของทั้งสองเหมืองมากที่สุด
อินพุต / ขอบเขต / เอาต์พุต
N จำนวนชิ้นของอาหาร บรรทัดที่สองคือสายอักขระยาว N ตัว ประกอบด้วย M, F, B เรียงตามลำดับที่ของมาถึง1 ≤ N ≤ 100 000 เวลา 1.5 วินาที หน่วยความจำ 16 เมกะไบต์| Input | Output |
|---|---|
| 6 MBMFFB | 12 |
| 16 MMBMBBBBMMMMMBMB | 29 |
ท่าแรกที่ทุกคนคิดถึงคือโลภ ของชิ้นนี้ลองทาบดูทั้งสองเหมือง เหมืองไหนให้ถ่านมากกว่าก็ส่งไปเหมืองนั้น เขียนสิบบรรทัดจบ และมันได้คำตอบถูกกับตัวอย่างทั้งสองกองในโจทย์ด้วย
ปัญหาคือถ่านที่ได้ตอนนี้ไม่ใช่ทั้งหมดของสิ่งที่เราจ่ายไป การส่งของเข้าเหมืองหนึ่งเปลี่ยนความจำของเหมืองนั้น
ไปอีกสองตาข้างหน้า ท่าที่ได้ถ่านเท่ากันวันนี้จึงทิ้งเหมืองไว้คนละสภาพ กองสั้น ๆ แค่ห้าชิ้นอย่าง MMFFF ก็หักวิธีโลภได้แล้ว
| ชิ้น | ส่งไป | คู่ล่าสุดของเหมืองนั้น | ได้ถ่าน | รวม |
|---|---|---|---|---|
1. M | เหมือง 1 | · · | 1 | 1 |
2. M | เหมือง 1 | · M | 1 | 2 |
3. F | เหมือง 1 | M M | 2 | 4 |
4. F | เหมือง 1 | M F | 2 | 6 |
5. F | เหมือง 1 | F F | 1 | 7 |
M เหมือนกัน วิธีโลภมองว่ายังไงก็ได้ 1 เท่ากันทั้งคู่ จึงกองไว้ที่เหมืองเดิม
พอ F ตามมา เหมือง 2 ที่ยังว่างเปล่าให้ได้แค่ 1 มันจึงไม่เคยถูกใช้เลยทั้งกอง
แต่ถ้ายอมทิ้งของชิ้นที่สองไว้อีกเหมืองหนึ่งตั้งแต่แรก ทั้งที่ตอนนั้นได้ถ่านเท่ากันเป๊ะ ผลรวมปลายทางจะขยับขึ้นเป็น 8
| ชิ้น | ส่งไป | คู่ล่าสุดของเหมืองนั้น | ได้ถ่าน | รวม |
|---|---|---|---|---|
1. M | เหมือง 2 | · · | 1 | 1 |
2. M | เหมือง 1 | · · | 1 | 2 |
3. F | เหมือง 2 | · M | 2 | 4 |
4. F | เหมือง 1 | · M | 2 | 6 |
5. F | เหมือง 1 | M F | 2 | 8 |
ใบ้
ลองถามตัวเองว่า ถ้าจะตัดสินใจชิ้นถัดไปให้ถูก เราจำเป็นต้องรู้อะไรบ้างเกี่ยวกับอดีตทั้งหมดที่ผ่านมา ของทั้งกองที่แจกไปแล้ว ลำดับที่แจก จำนวนถ่านที่เก็บได้ อันไหนบ้างที่มีผลกับกำไรของชิ้นถัดไปจริง ๆ แล้วสิ่งที่จำเป็นต้องจำจริง ๆ นั้น มันมีได้ทั้งหมดกี่แบบ?
กดปุ่มส่งของทีละชิ้นเข้าเหมืองที่คุณเลือก ตัวเลขถ่านจะขึ้นให้เห็นทันที ลองกองกับดักก่อน แล้วค่อยไปลองกองยาวจากตัวอย่างที่สอง เป้าหมายคือทำให้เท่าคะแนนเต็มที่บอกไว้บนหัวการ์ด
เหมือง 1
เหมือง 2
ลองเล่นดูก่อนนะครับ พอมีคำตอบในใจแล้ว ค่อยไปดูเฉลย
ที่มาของแนวคิดนี้
ท่าโลภตายไปแล้วตั้งแต่หัวข้อก่อน แต่สิ่งที่มันทิ้งไว้คือคำถามที่ถูก คือถ้าการส่งของวันนี้ไปมีผลกับวันหน้า แล้วผลนั้นอยู่ได้นานแค่ไหน ผมไม่ได้ตอบด้วยการนึกเอา ผมกลับไปอ่านกติกาการนับถ่าน มันนับจากของสามชิ้นล่าสุดของเหมืองนั้น ไม่มีคำไหนในกติกาพูดถึงชิ้นที่สี่เลย
พออ่านเจอแบบนั้น คำตอบก็บังคับตัวเองว่าของที่ต้องจำคือสองชิ้นล่าสุดต่อเหมือง เพราะชิ้นที่สามของหน้าต่างถัดไป คือของชิ้นใหม่ที่เรากำลังจะแจก ส่วนชิ้นที่เก่ากว่านั้นไม่มีทางกลับเข้ามาในหน้าต่างได้อีก สองเหมืองรวมกันจึงเป็นสี่ช่อง ช่องละสี่ค่า เท่ากับ 256 สถานะ ไม่ว่ากองจะยาวเป็นแสนชิ้นก็ตาม
การยุบจากแสนเหลือ 256 เป็นเรื่องที่ควรสงสัยตัวเองหน่อย ผมเลยเขียนตัวไล่แจกทุกวิธีที่เป็นไปได้จริง ๆ ซึ่งเป็นสองยกกำลังจำนวนชิ้น แล้วเทียบกับตารางสี่ช่องนี้ สุ่มกองยาวไม่เกินเก้าชิ้น 400 กอง ตรงกันทุกกอง ตัวไล่ตัวนั้นไม่ได้รู้จักคำว่าสถานะเลย มันแค่ลองทุกทางแล้วนับถ่านตรง ๆ ซึ่งเป็นเหตุผลเดียวที่ทำให้ผมกล้าเชื่อว่าการลืมอดีตตรงนี้ปลอดภัย
บทเรียนที่ยกไปข้ออื่นได้คือ ขอบเขตของสิ่งที่ต้องจำ ไม่ได้มาจากความรู้สึกว่าจำเท่าไรถึงจะพอ มันเขียนอยู่ในกติกาการให้คะแนนอยู่แล้ว ให้ไปนับว่ากติกามองย้อนหลังไปกี่ชิ้น แล้วจำเท่านั้นพอ
คำถามในใบ้มีคำตอบสั้นมาก ของชิ้นถัดไปจะให้ถ่านเท่าไร ขึ้นกับสองชิ้นล่าสุดของเหมืองที่มันไปลงเท่านั้น ชิ้นที่สามนับจากท้ายไม่มีสิทธิ์โผล่ในหน้าต่างของชิ้นถัดไปอีกแล้ว และของที่อยู่ในอีกเหมืองหนึ่งก็ไม่เกี่ยวกัน เพราะคนงานสองเหมืองไม่ได้กินข้าวร่วมกัน
แปลว่าอดีตทั้งกอง ไม่ว่าจะยาวเป็นแสนชิ้น ยุบลงเหลือของที่ต้องจำแค่สี่ช่อง คือคู่ล่าสุดของเหมือง 1
กับคู่ล่าสุดของเหมือง 2 เรียกมันว่า (x₁, y₁) และ (x₂, y₂) โดย y คือชิ้นล่าสุด
แต่ละช่องเป็นได้ 4 ค่า คือ M, F, B หรือ "ยังว่าง" สำหรับตอนที่เหมืองนั้นเพิ่งเริ่ม
พอนับได้ว่า 256 แบบ ข้อนี้ก็จบไปครึ่งหนึ่งแล้ว เพราะไม่ว่ากองจะยาวแค่ไหน จำนวนสถานะที่ต้องแบกไปข้างหน้า ก็ไม่โตตามความยาวกอง มันตันอยู่ที่ 256 ตลอดกาล
ให้ dp[s] คือถ่านรวมที่มากที่สุด ที่ทำได้เมื่อแจกของไปแล้ว i ชิ้น แล้วลงเอยที่สถานะ s พอดี
ถ้าสถานะไหนไปไม่ถึงก็ปล่อยว่างไว้ ส่วนกำไรของการวางของหนึ่งชิ้นเขียนเป็นฟังก์ชันสั้น ๆ ได้แบบนี้
อ่านว่า นับจำนวนสมาชิกที่ไม่ซ้ำกันของ x, y, c โดยตัดช่องว่างทิ้ง
ถ้าเหมืองยังไม่เคยได้ของเลย ทั้ง x และ y เป็นช่องว่าง เหลือแค่ c ตัวเดียว ได้ถ่าน 1
ซึ่งตรงกับที่โจทย์บอกว่า "ถ้ายังมีไม่ครบสามชิ้นก็ดูเท่าที่มี"
ทีนี้ของชิ้นถัดไปคือ c ยืนอยู่ที่สถานะเดิมหนึ่งตัว มันมีทางไปแค่สองทาง สมการจึงเขียนแบบผลัก
คือยืนที่ต้นทางแล้วส่งค่าออกไปยังปลายทาง (ใช้ dp' แทนตารางของรอบถัดไป และ ⇐ แปลว่าเก็บค่าที่มากกว่าไว้)
สองบรรทัดนี้อ่านเป็นภาษาคนได้ทีละบรรทัด โดยมีหลักปักหมุดคือ y ของเหมืองไหนก็ตาม แปลว่า "ชิ้นล่าสุดของเหมืองนั้น"
เสมอ ดังนั้นเหมืองที่เพิ่งรับของ ชิ้นล่าสุดของมันย่อมกลายเป็น c ทันที
y₁ ที่เคยเป็นชิ้นล่าสุด
ถอยไปเป็นชิ้นก่อนหน้า และ c เข้ามาเป็นชิ้นล่าสุด กลายเป็น (y₁, c) ส่วน x₁ ตัวเก่า
หลุดออกจากความจำถาวร เพราะมันอยู่ห่างจากชิ้นล่าสุดสามช่องแล้ว ถ่านที่ได้คิดจากคู่ก่อนเลื่อน
คือ g(x₁, y₁, c) เพราะหน้าต่างที่คนงานมองคือของใหม่บวกสองชิ้นก่อนหน้าจริง ๆ ส่วนคู่ของเหมือง 2 คัดลอกมาทั้งดุ้น ไม่มีอะไรเกิดขึ้นกับมัน
(y₂, c)
ถ่านคิดจาก g(x₂, y₂, c) ส่วนคู่ของเหมือง 1 คัดลอกมาเฉย ๆ
ผลที่ตามมาคือไม่มีเงื่อนไขให้ตัดทิ้งเลยสักบรรทัด ต่างจากตาราง DP ทั่วไปที่มักมีขอบตารางคอยกันไม่ให้ดัชนีติดลบ ที่นี่ทุกสถานะส่งของเข้าเหมืองไหนก็ได้เสมอ ของสองบรรทัดจึงใช้ได้ครบทุกช่องตลอดกอง เหลือแค่ต้องระวังว่า ปลายทางหนึ่งช่องอาจมีหลายต้นทางยิงเข้ามา จึงต้องเก็บตัวที่มากที่สุดไว้
| ทางเลือกของชิ้นที่ i | สถานะปลายทาง | ถ่านที่บวกเพิ่ม |
|---|---|---|
| ส่งเข้าเหมือง 1 | (y₁, c), (x₂, y₂) | g(x₁, y₁, c) |
| ส่งเข้าเหมือง 2 | (x₁, y₁), (y₂, c) | g(x₂, y₂, c) |
คำตอบคือค่ามากที่สุดในตารางแถวสุดท้าย ไม่ต้องเจาะจงสถานะไหน เพราะจบกองแล้วสภาพของเหมืองไม่มีความหมายอีกต่อไป
เอากองกับดัก MMFFF มาเดินจริง ตารางข้างล่างคือสถานะทั้งหมดที่ไปถึงได้หลังแจกของครบ i ชิ้น
เรียงจากถ่านมากไปน้อย กดถัดไปทีละขั้นเพื่อดูมันงอกและยุบ แถวสีเขียวคือสถานะที่จะพาไปจบที่คำตอบ 8
| เหมือง 1 (x₁ y₁) | เหมือง 2 (x₂ y₂) | ถ่านรวม |
|---|---|---|
จำนวนสถานะโตขึ้นได้เร็วก็จริง แต่มันชนเพดาน 256 แล้วหยุด ต่อให้กองยาวเป็นแสนชิ้น ตารางนี้ก็สูงไม่เกิน 256 แถวเสมอ
แทนที่จะเก็บสถานะเป็นสตริงหรือ map เราอัด (x₁, y₁, x₂, y₂) เป็นเลขฐานสี่สี่หลัก
ได้ตัวเลข 0 ถึง 255 พอดี ตารางจึงเป็นอาเรย์ธรรมดายาว 256 ช่อง เข้าถึงด้วยดัชนีตรง ๆ ไม่ต้องแฮชอะไรเลย
// หัวใจของทั้งข้อ: หนึ่งสถานะ แตกได้สองทาง เท่านั้น
int t1 = (y1 << 6) | (f << 4) | (x2 << 2) | y2; // ของชิ้นนี้เข้าเหมือง 1
int v1 = cur[st] + coal(x1, y1, f);
if (v1 > nxt[t1]) nxt[t1] = v1;
int t2 = (x1 << 6) | (y1 << 4) | (y2 << 2) | f; // ของชิ้นนี้เข้าเหมือง 2
int v2 = cur[st] + coal(x2, y2, f);
if (v2 > nxt[t2]) nxt[t2] = v2;
ค่า -1 ใช้แทน "สถานะนี้ยังไปไม่ถึง" เพราะ 0 เป็นถ่านที่ถูกต้องได้ (ตอนยังไม่แจกอะไรเลย)
และต่างจากข้อ หยิบหนังสือ ตรงที่ตรงนี้ต้องล้างแถวปลายทางก่อนทุกรอบ
เพราะเราเขียนแบบผลัก ช่องปลายทางบางช่องอาจไม่มีใครยิงเข้ามาเลยในรอบนี้ ถ้าไม่ล้างก็จะเหลือค่าเก่าจากรอบก่อนค้างอยู่
งานต่อของหนึ่งชิ้นคือวน 256 สถานะ สถานะละสองทาง ทั้งกองจึงเป็น O(256n) ที่ n = 100 000
คือราวห้าสิบล้านครั้งของงานเบา ๆ เครื่องผมวิ่งจบใน 56 มิลลิวินาที จากลิมิต 1.5 วินาที
ส่วนหน่วยความจำใช้อาเรย์ int สองชุด ชุดละ 256 ช่อง รวมสองกิโลไบต์ ห่างจากลิมิต 16 เมกะไบต์อยู่มาก
ตัวอย่างแรก MBMFFB มีจุดที่ชวนสะดุด เพราะแผนที่ดีที่สุดของมันคือเทของลงเหมืองเดียวทั้งกอง
อีกเหมืองไม่ได้แตะเลยแม้แต่ชิ้นเดียว
| ชิ้น | ส่งไป | คู่ล่าสุดของเหมืองนั้น | ได้ถ่าน | รวม |
|---|---|---|---|---|
1. M | เหมือง 1 | · · | 1 | 1 |
2. B | เหมือง 1 | · M | 2 | 3 |
3. M | เหมือง 1 | M B | 2 | 5 |
4. F | เหมือง 1 | B M | 3 | 8 |
5. F | เหมือง 1 | M F | 2 | 10 |
6. B | เหมือง 1 | F F | 2 | 12 |
นี่คือเหตุผลที่วิธีโลภตอบตัวอย่างในโจทย์ถูกทั้งสองข้อ กองที่สลับชนิดมาดีอยู่แล้วไม่ต้องการการวางแผนอะไรเลย กองที่หักวิธีโลภคือกองที่มีของซ้ำกันติดกัน ซึ่งเป็นตอนที่การแยกไปอีกเหมืองมีราคาเป็นศูนย์
ท่าที่ข้อนี้สอนมีชื่อว่าการบีบสถานะ (state compression) คือเราไม่เก็บอดีตทั้งกอง เก็บเฉพาะส่วนของอดีตที่ยังมีสิทธิ์เปลี่ยนอนาคต ในข้อนี้คืออาหารสองมื้อล่าสุดของแต่ละเหมือง ที่เหลือทิ้งได้หมดเพราะไม่มีสูตรคะแนนข้อไหนมองย้อนไปไกลกว่านั้น
ถ้าอยากเห็นท่านี้แบบปูพื้นตั้งแต่ต้น มีบทเรื่อง DP ที่จดสภาพลงในสถานะ อยู่ในคลังนี้ ซึ่งอธิบายทั้งวิธีเลือกว่าอะไรควรอยู่ในสถานะ และวิธีนับว่าสถานะมีได้กี่แบบ คำถามที่ยกไปใช้กับข้ออื่นได้คือ ถ้าฉันลืมข้อมูลชิ้นนี้ไป จะมีการตัดสินใจไหนในอนาคตที่ตอบไม่ได้บ้าง อะไรที่ลืมแล้วไม่มีใครเดือดร้อน ไม่ต้องอยู่ในสถานะ
ความยาวของกองไม่ใช่ขนาดของปัญหา อดีตทั้งกองยุบเหลือคู่ล่าสุดของสองเหมืองคือ 256 แบบ จบกอง เดินไปข้างหน้าทีละชิ้น ชิ้นละสองทาง แล้วเก็บค่าที่มากที่สุดของแต่ละสถานะไว้
ในหน้านี้