25.2 LIS ลำดับย่อยที่เพิ่มขึ้นยาวที่สุด
น้องสอบย่อยวิชาคณิตศาสตร์ 8 ครั้ง ได้คะแนน 5 2 8 6 3 6 9 7 อยากเลือกบางครั้งมาเรียงตามเวลาให้เห็นว่าคะแนน สูงขึ้นทุกครั้ง ได้ยาวที่สุดกี่ครั้ง คำถามนี้ชื่อ LIS ย่อมาจาก longest increasing subsequence แปลว่าลำดับย่อยที่เพิ่มขึ้นยาวที่สุด เจอบ่อยในค่าย 2 ทั้งแบบตรงๆ และแบบซ่อน เช่น ซ้อนกล่อง เรียงคนตามความสูง หรือหาจำนวนครั้งน้อยสุดที่ต้องย้ายของให้เรียง หน้านี้เริ่มจาก DP แบบ O(N2) ที่คิดตามบทที่ 24 ตรงๆ แล้วเร่งเป็น O(N log N) ด้วย lower_bound จากบทที่ 19 พร้อมพิมพ์ลำดับจริงออกมา
ลำดับย่อยไม่ใช่ช่วงติดกัน
ลำดับย่อย (subsequence) คือเลือกบางตัวโดยคงลำดับเดิมไว้ ข้ามตัวไหนก็ได้ ต่างจาก ช่วง (subarray) ในโจทย์ Kadane ของบทที่ 24 ที่ต้องติดกัน จากคะแนนข้างบน 2 3 6 9 เป็นลำดับย่อยที่เพิ่มขึ้น เพราะ 2 อยู่ครั้งที่ 2 ส่วน 3 6 9 อยู่ครั้งที่ 5 6 7 ตามลำดับ แต่ 2 3 6 8 ไม่ใช่ เพราะ 8 อยู่ครั้งที่ 3 ก่อน 3 กับ 6 ส่วน "เพิ่มขึ้น" ในหน้านี้หมายถึง มากกว่าตัวก่อนหน้าจริงๆ คะแนนเท่ากันไม่นับ
ลำดับยาว N มีลำดับย่อย 2N แบบ ลองทุกแบบไม่ไหว
DP แบบ O(N2)
ให้ length[i] คือความยาวของลำดับย่อยที่เพิ่มขึ้นที่ยาวที่สุดที่ จบที่ตัวที่ i พอดี การบังคับให้จบที่ i ทำให้รู้ว่าตัวสุดท้ายคือ a[i] ตัวก่อนหน้ามันจึงเป็น a[j] ตัวไหนก็ได้ที่ j < i และ a[j] < a[i] เลือกตัวที่ length[j] มากที่สุดแล้วต่อท้ายด้วย a[i] ถ้าไม่มี j แบบนั้นเลย ลำดับมีแค่ a[i] ตัวเดียว ยาว 1 คำตอบคือ length[i] ที่มากที่สุดในทุก i
จดว่าแต่ละตัวต่อมาจากตัวไหนไว้ใน previous[i] แบบการเก็บเส้นทางย้อนกลับในบทที่ 24 จะพิมพ์ลำดับจริงได้
#include <bits/stdc++.h>
using namespace std;
int main()
{
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}
vector<int> length(n, 1), previous(n, -1);
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (a[j] < a[i] && length[j] + 1 > length[i]) { // (1)!
length[i] = length[j] + 1;
previous[i] = j;
}
}
}
int last = 0;
for (int i = 0; i < n; i++) {
cout << "i=" << i << " a=" << a[i] << " length=" << length[i] << " previous=" << previous[i] << '\n';
if (length[i] > length[last]) {
last = i;
}
}
vector<int> sequence;
for (int i = last; i != -1; i = previous[i]) {
sequence.push_back(a[i]);
}
reverse(sequence.begin(), sequence.end());
cout << "LIS length = " << sequence.size() << '\n';
for (int k = 0; k < (int)sequence.size(); k++) {
cout << sequence[k] << (k + 1 == (int)sequence.size() ? '\n' : ' ');
}
return 0;
}
- เงื่อนไข
>ทำให้previous[i]เก็บ j ตัวแรกที่ให้ความยาวมากสุด ถ้าเปลี่ยนเป็น>=ความยาวยังเท่าเดิม แต่ลำดับที่พิมพ์อาจเปลี่ยนไป
8
5 2 8 6 3 6 9 7
i=0 a=5 length=1 previous=-1
i=1 a=2 length=1 previous=-1
i=2 a=8 length=2 previous=0
i=3 a=6 length=2 previous=0
i=4 a=3 length=2 previous=1
i=5 a=6 length=3 previous=4
i=6 a=9 length=4 previous=5
i=7 a=7 length=4 previous=5
LIS length = 4
2 3 6 9
โปรแกรมนับตำแหน่งจาก 0 ดูบรรทัด i=6 คือคะแนน 9 ตัวก่อนหน้าน้อยกว่า 9 ทุกตัว ตัวที่ยาวที่สุดคือ i=5 (คะแนน 6) ยาว 3 จึงได้ 4 ย้อนจาก i=6 ไป i=5 i=4 และ i=1 ได้ 9 6 3 2 กลับลำดับเป็น 2 3 6 9 บรรทัด i=7 (คะแนน 7) ก็ยาว 4 เหมือนกัน แปลว่า 2 3 6 7 ก็เป็นคำตอบได้ LIS มักมีหลายแบบ โจทย์ที่ให้พิมพ์ลำดับจึงต้องบอกว่าเอาแบบไหน หรือยอมรับแบบไหนก็ได้
ทุก i ไล่ j ทุกตัวก่อนหน้า รวม N(N - 1)/2 คู่ เป็น O(N2) N เป็นหมื่นยังไหว แต่ N เป็นแสนไม่ทัน ซึ่งจะเห็นในตารางเวลาท้ายหน้า
เก็บหางที่เล็กที่สุดของแต่ละความยาว
เปลี่ยนมุมมอง ไล่ตัวเลขจากซ้ายไปขวา แล้วถามว่า ลำดับย่อยที่เพิ่มขึ้นยาว k ที่เจอมาแล้ว ตัวสุดท้ายเล็กได้แค่ไหน เก็บคำตอบไว้ใน tails[k - 1] เหตุผลคือ ถ้ามีลำดับยาวเท่ากันสองแบบ แบบที่จบด้วยตัวเล็กกว่าดีกว่าเสมอ เพราะตัวเลขที่จะมาต่อท้ายได้ในอนาคต ต่อแบบที่หางใหญ่ได้ ก็ต่อแบบที่หางเล็กได้แน่นอน
tails เรียงจากน้อยไปมากเสมอ (ลำดับยาว k + 1 มีส่วนต้นยาว k ที่จบด้วยตัวที่น้อยกว่าหางของมัน) จึงใช้ binary search ได้ พอเจอตัวเลข x ใหม่
- หาช่องแรกใน
tailsที่>= xด้วยlower_boundสมมติว่าเป็นช่องpos - ทุกช่องก่อน
posมีหางน้อยกว่า x จึงต่อ x ท้ายลำดับยาวposได้ กลายเป็นลำดับยาวpos + 1ที่หางคือ x - ถ้า
posเลยท้ายtailsแปลว่าได้ลำดับที่ยาวกว่าที่เคยมี ต่อท้ายtailsถ้าไม่ ให้ทับtails[pos]ด้วย x ซึ่งเล็กกว่าหรือเท่าของเดิม
#include <bits/stdc++.h>
using namespace std;
int main()
{
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}
vector<int> tails;
for (int i = 0; i < n; i++) {
int pos = lower_bound(tails.begin(), tails.end(), a[i]) - tails.begin();
if (pos == (int)tails.size()) {
tails.push_back(a[i]);
} else {
tails[pos] = a[i];
}
cout << "a=" << a[i] << " pos=" << pos << " tails:";
for (int k = 0; k < (int)tails.size(); k++) {
cout << ' ' << tails[k];
}
cout << '\n';
}
cout << "LIS length = " << tails.size() << '\n';
return 0;
}
8
5 2 8 6 3 6 9 7
a=5 pos=0 tails: 5
a=2 pos=0 tails: 2
a=8 pos=1 tails: 2 8
a=6 pos=1 tails: 2 6
a=3 pos=1 tails: 2 3
a=6 pos=2 tails: 2 3 6
a=9 pos=3 tails: 2 3 6 9
a=7 pos=3 tails: 2 3 6 7
LIS length = 4
ตอนเจอ 3 tails คือ 2 6 ช่องแรกที่ไม่น้อยกว่า 3 คือ 6 ที่ช่อง 1 แปลว่าลำดับยาว 2 ตอนนี้จบด้วย 3 ได้แล้ว ซึ่งดีกว่าจบด้วย 6 เลยทับ 6 ด้วย 3 พอ 6 ตัวที่สองมา ต่อจาก 3 ได้ยาว 3 ทั้งที่ถ้ายังเก็บ 6 ไว้จะต่อไม่ได้ เพราะต้องมากกว่าจริงๆ ตัวเลขแต่ละตัวทำ lower_bound หนึ่งครั้ง O(log N) ทั้งหมดจึงเป็น O(N log N) ความยาวของ tails ตอนจบคือคำตอบ
tails ไม่ใช่ LIS
ป้อน 4 กับ 3 4 5 1 ให้โปรแกรม lis-tails บรรทัดสุดท้ายของการไล่จะเป็น a=1 pos=0 tails: 1 4 5 ความยาว 3 ถูก แต่ 1 4 5 ไม่ใช่ลำดับย่อย เพราะ 1 อยู่หลัง 4 กับ 5 tails แต่ละช่องมาจากลำดับคนละชุดกัน ช่อง 0 คือลำดับ 1 ช่อง 2 คือลำดับ 3 4 5 ถ้าโจทย์ให้พิมพ์ลำดับ ห้ามพิมพ์ tails
พิมพ์ลำดับจริง
เก็บเพิ่มสองอย่าง tail_index[k] คือ ตำแหน่ง ของตัวที่เป็นหางใน tails[k] และตอนวาง a[i] ลงช่อง pos ให้จดว่ามันต่อจากหางของช่อง pos - 1 ณ ตอนนั้น คือ previous[i] = tail_index[pos - 1] พอจบ เริ่มจากหางของช่องสุดท้ายแล้วตาม previous ย้อนกลับ
#include <bits/stdc++.h>
using namespace std;
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}
vector<int> tails, tail_index, previous(n, -1);
for (int i = 0; i < n; i++) {
int pos = lower_bound(tails.begin(), tails.end(), a[i]) - tails.begin(); // (1)!
if (pos > 0) {
previous[i] = tail_index[pos - 1]; // (2)!
}
if (pos == (int)tails.size()) {
tails.push_back(a[i]);
tail_index.push_back(i);
} else {
tails[pos] = a[i];
tail_index[pos] = i;
}
}
vector<int> sequence;
for (int i = tail_index.back(); i != -1; i = previous[i]) { // (3)!
sequence.push_back(a[i]);
}
reverse(sequence.begin(), sequence.end());
cout << sequence.size() << '\n';
for (int k = 0; k < (int)sequence.size(); k++) {
cout << sequence[k] << (k + 1 == (int)sequence.size() ? '\n' : ' ');
}
return 0;
}
- ส่วนนี้เหมือนโปรแกรม lis-tails ต่างกันแค่ไม่พิมพ์การไล่ และอ่านเร็วตามบทที่ 15 เพราะจะใช้กับข้อมูลล้านตัว
- ต้องจดตอนนี้ เพราะ
tail_index[pos - 1]อาจถูกทับภายหลังด้วยตัวที่อยู่ทางขวาของ i ซึ่งต่อหน้าa[i]ไม่ได้แล้ว - ได้ตัวเลขจากท้ายไปหน้า จึงต้อง
reverseก่อนพิมพ์
8
5 2 8 6 3 6 9 7
4
2 3 6 7
ได้ 2 3 6 7 ไม่ใช่ 2 3 6 9 แบบโปรแกรม O(N2) ทั้งคู่ถูก วิธีนี้ให้ลำดับที่หางของแต่ละความยาวเล็กที่สุดตอนจบ ถ้าโจทย์ระบุว่าต้องการแบบไหน ต้องคิดเงื่อนไขการเลือกเพิ่ม และทดสอบกับวิธี O(N2) ด้วยข้อมูลเล็กเสมอ
ไม่ลดลง ใช้ upper_bound
ถ้าโจทย์ยอมให้ค่าเท่ากันได้ เช่น "ลำดับย่อยที่ไม่ลดลงยาวที่สุด" ตัว x ต้องต่อท้ายลำดับที่หาง ไม่เกิน x ได้ด้วย จึงต้องหาช่องแรกที่ > x แทน คือเปลี่ยน lower_bound เป็น upper_bound คำเดียว ตามความหมายของสองฟังก์ชันในหน้า 19.2
| โจทย์ต้องการ | ตัวถัดไปต้อง | ใช้ |
|---|---|---|
| เพิ่มขึ้น (strictly increasing) | มากกว่าตัวก่อน | lower_bound |
| ไม่ลดลง (non-decreasing) | มากกว่าหรือเท่ากับตัวก่อน | upper_bound |
ถ้าโจทย์ถามลำดับที่ ลดลง ไม่ต้องเขียนใหม่ ให้กลับเครื่องหมายทุกตัวเป็นค่าติดลบก่อน ลำดับที่ลดลงของค่าเดิมคือลำดับที่เพิ่มขึ้นของค่าติดลบ
เร็วขึ้นแค่ไหน
วัดบนเครื่องเดียวกับหน้า 19.1 ตัวเลขสุ่มตั้งแต่ 1 ถึง 109 วิธี O(N2) คือโปรแกรม lis-quadratic ที่ตัดส่วนพิมพ์และส่วนย้อนรอยออก เหลือแค่หาความยาว และอ่านเร็วแบบเดียวกัน วิธี O(N log N) คือโปรแกรม lis-print ทั้งโปรแกรม รวมการพิมพ์ลำดับ รันแต่ละแบบ 5 รอบ
| N | วิธี | Code::Blocks (-Wall) | -O2 | คำตอบ |
|---|---|---|---|---|
| 20000 | O(N2) | 2.2 วินาที | 0.44 วินาที | 270 |
| O(N log N) | 0.03 วินาที | 0.03 วินาที | ||
| 100000 | O(N2) | 44 วินาที (5 รอบอยู่ระหว่าง 37 ถึง 50) | 14.5 วินาที | 629 |
| O(N log N) | 0.04 วินาที | 0.04 วินาที | ||
| 1000000 | O(N log N) | 0.30 วินาที | 0.17 วินาที | 1974 |
N เพิ่มห้าเท่าจากสองหมื่นเป็นแสน วิธี O(N2) ช้าลงราว 20 ถึง 33 เท่า ใกล้กับ 52 = 25 ที่คาดไว้ ข้อมูลแสนตัวเป็นขนาดปกติของโจทย์ TOI ถ้าเห็น N ถึงแสนแล้วโจทย์ถาม LIS ให้ใช้วิธี tails ทันที
ลองทำให้พัง
แบบที่ 1 ใช้ upper_bound กับโจทย์ที่ต้องเพิ่มขึ้นจริงๆ
นักเรียน 10 คนยืนเรียงแถว แต่ละคนมีคะแนนเต็ม 10 ครูจะเลือกคนออกมาโดยคงลำดับเดิม ให้ คนหลังได้คะแนนมากกว่าคนหน้าทุกคู่ เลือกได้มากสุดกี่คน ในโปรแกรม lis-print เปลี่ยน lower_bound เป็น upper_bound
#include <bits/stdc++.h>
using namespace std;
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}
vector<int> tails, tail_index, previous(n, -1);
for (int i = 0; i < n; i++) {
int pos = upper_bound(tails.begin(), tails.end(), a[i]) - tails.begin();
if (pos > 0) {
previous[i] = tail_index[pos - 1];
}
if (pos == (int)tails.size()) {
tails.push_back(a[i]);
tail_index.push_back(i);
} else {
tails[pos] = a[i];
tail_index[pos] = i;
}
}
vector<int> sequence;
for (int i = tail_index.back(); i != -1; i = previous[i]) {
sequence.push_back(a[i]);
}
reverse(sequence.begin(), sequence.end());
cout << sequence.size() << '\n';
for (int k = 0; k < (int)sequence.size(); k++) {
cout << sequence[k] << (k + 1 == (int)sequence.size() ? '\n' : ' ');
}
return 0;
}
10
4 4 7 2 7 7 9 3 9 10
8
4 4 7 7 7 9 9 10
ได้ 8 คน แต่ลำดับที่พิมพ์มี 4 กับ 4 ติดกัน และ 7 สามครั้ง ซึ่งผิดเงื่อนไขว่าต้องมากกว่า คำตอบที่ถูกคือ 4 ใช้ lower_bound แล้วได้ 2 3 9 10 ความผิดนี้จับยากเพราะข้อมูลที่ไม่มีค่าซ้ำเลยจะได้คำตอบเท่ากันทั้งสองแบบ ตัวอย่างในโจทย์มักไม่มีค่าซ้ำ แต่ชุดทดสอบที่ซ่อนไว้มีแน่ ทดสอบ LIS ทุกครั้งด้วยข้อมูลที่ทุกตัวเท่ากัน คำตอบแบบเพิ่มขึ้นต้องเป็น 1 แบบไม่ลดลงต้องเป็น N
แบบที่ 2 พิมพ์ tails เป็นคำตอบ
ความผิดนี้เห็นในกล่องเตือนข้างบนแล้ว ความยาวของ tails ถูกเสมอ จึงผ่านโจทย์ที่ถามแค่ความยาว แต่ถ้าโจทย์ให้พิมพ์ลำดับ แล้วพิมพ์ tails จะได้ตัวเลขที่เรียงสวยแต่ไม่ใช่ลำดับย่อยจริง กับข้อมูล 3 4 5 1 ได้ 1 4 5 ต้องเก็บ tail_index กับ previous แบบโปรแกรม lis-print
สรุป
length[i]คือ LIS ที่จบที่ตัวที่ i ได้จากlength[j] + 1ที่มากสุด เมื่อ j < i และa[j] < a[i]เป็น O(N2)tails[k]เก็บหางที่เล็กที่สุดของลำดับยาว k + 1 เรียงอยู่เสมอ ใช้lower_boundหาช่องแล้วทับหรือต่อท้าย เป็น O(N log N)tailsไม่ใช่ LIS ถ้าต้องพิมพ์ ให้เก็บtail_indexกับpreviousแล้วย้อนกลับ- เพิ่มขึ้นใช้
lower_boundไม่ลดลงใช้upper_boundลดลงให้กลับเครื่องหมายก่อน - N ถึงแสนต้องใช้ O(N log N) วิธี O(N2) ใช้ไม่ทันแม้คอมไพล์
-O2