19.4 binary search บนจำนวนจริง
บางโจทย์คำตอบเป็นทศนิยม เช่น ระดับน้ำ เวลาที่ใช้ ความเร็ว ค่าเหล่านี้ไม่มีคำว่า "ค่าถัดไป" แบบจำนวนเต็ม ช่วงที่ค้นจึงไม่มีวันหดจนเหลือค่าเดียวพอดี หน้านี้ใช้ความคิดเดิมจากหน้า 19.3 กับคำตอบที่เป็น double แล้วเปลี่ยนวิธีหยุดลูปเป็นวนจำนวนรอบที่กำหนดไว้ ซึ่งปลอดภัยกว่าการรอให้ช่วงแคบลงเอง
ลองกับรากที่สอง
รากที่สองของ x คือค่า m ที่ m × m = x ใช้คำถามใช่ไม่ใช่ว่า m * m >= x ไหม ยิ่ง m มากยิ่งตอบใช่ จึงเป็นทางเดียว คำตอบอยู่ระหว่าง 0 ถึง x ถ้า x ไม่น้อยกว่า 1 แต่ถ้า x น้อยกว่า 1 เช่น 0.25 รากที่สองคือ 0.5 ซึ่งมากกว่า x จึงให้ hi เป็นค่าที่มากกว่าระหว่าง 1 กับ x
ต่างจากจำนวนเต็มสองจุด ไม่มีบวกหนึ่งหรือลบหนึ่ง เพราะไม่มีค่าถัดไปให้ข้าม ตั้ง lo = mid หรือ hi = mid ตรงๆ และไม่ใช้ while (lo < hi) แต่วนให้ครบ 100 รอบ
#include <bits/stdc++.h>
using namespace std;
int main()
{
double x;
cin >> x;
double lo = 0, hi = max(1.0, x);
for (int step = 0; step < 100; step++) { // (1)!
double mid = (lo + hi) / 2;
if (mid * mid >= x) {
hi = mid;
} else {
lo = mid;
}
}
cout << fixed << setprecision(9); // (2)!
cout << "binary search: " << hi << '\n';
cout << "sqrt: " << sqrt(x) << '\n';
return 0;
}
- วนครบ 100 รอบเสมอ ไม่ว่าช่วงจะแคบแค่ไหนแล้ว เหตุผลอยู่ในหัวข้อถัดไป
fixedกับsetprecision(9)คือทศนิยม 9 ตำแหน่ง แบบเดียวกับprintf("%.9f")ในบทที่ 2 ตั้งครั้งเดียวมีผลกับทุกค่าที่พิมพ์ต่อจากนั้น
2
binary search: 1.414213562
sqrt: 1.414213562
ตรงกับ sqrt ทั้ง 9 ตำแหน่ง ลองป้อน x อื่นก็ได้ตรงกัน เช่น 10 ได้ 3.162277660 ทั้งคู่ 0.5 ได้ 0.707106781 ทั้งคู่ และ 123456789 ได้ 11111.111060556 ทั้งคู่ แน่นอนว่าโจทย์จริงไม่ต้องหารากที่สองเอง ตัวอย่างนี้มีไว้ให้เห็นว่าลูปแบบนี้ได้ความละเอียดเท่าที่ double เก็บได้
ทำไมต้อง 100 รอบ
ทุกรอบช่วงแคบลงครึ่งหนึ่ง ถ้าช่วงเริ่มกว้าง 109 ความกว้างหลังวน k รอบคือ 109 หารด้วย 2k
| จำนวนรอบ | ความกว้างของช่วงที่เหลือ |
|---|---|
| 30 | ราว 0.93 |
| 60 | ราว 8.7 × 10-10 |
| 100 | ราว 7.9 × 10-22 ในทางทฤษฎี |
double แม่นราว 15 ถึง 16 หลักตามหน้า 1.4 ค่าใกล้ 109 จึงแม่นได้ถึงราวทศนิยมตำแหน่งที่ 6 หรือ 7 เท่านั้น พอวนไปราว 60 รอบ ช่วงก็แคบจนติดขีดจำกัดของ double แล้ว รอบที่เหลือไม่ได้ทำให้แม่นขึ้น แต่ก็ไม่ทำให้เสียหาย 100 รอบจึงเผื่อไว้สบายๆ สำหรับช่วงกว้างแค่ไหนก็ได้ เวลาทั้งหมดคือ 100 คูณเวลาตรวจหนึ่งครั้ง ถ้าตรวจหนึ่งครั้งเป็น O(N) ที่ N เป็นแสน ก็ราว 107 ขั้น สบายมาก
ระดับน้ำ
แท็งก์น้ำแบ่งเป็น N ช่องเรียงกัน ช่องละกว้าง 1 เมตร ผนังกั้นสูงมาก พื้นของแต่ละช่องสูงไม่เท่ากัน ช่องที่ i สูง hi เมตร ทุกช่องมีท่อเล็กๆ เชื่อมถึงกันใต้พื้น เทน้ำลงไป V ตารางเมตร (มองเป็นภาพตัดขวางสองมิติ และไม่คิดน้ำที่ค้างในท่อ) น้ำจะไหลถึงกันจนผิวน้ำของทุกช่องที่มีน้ำอยู่ระดับเดียวกัน ถามว่าผิวน้ำอยู่สูงเท่าไร ตอบทศนิยม 4 ตำแหน่ง
ถ้าผิวน้ำอยู่ที่ระดับ L ช่องที่พื้นต่ำกว่า L จะมีน้ำสูง L - hi ช่องที่พื้นสูงกว่าไม่มีน้ำ น้ำทั้งหมดใต้ระดับ L จึงคำนวณได้ในลูปเดียว คำถามใช่ไม่ใช่คือ "ใต้ระดับ L จุน้ำได้อย่างน้อย V ไหม" ยกระดับขึ้น ทุกช่องจุได้เท่าเดิมหรือมากขึ้น จึงเป็นทางเดียว และคำตอบคือ L ที่น้อยที่สุดที่ตอบใช่
#include <bits/stdc++.h>
using namespace std;
double water_below(const vector<int> &floor_height, double level)
{
double total = 0;
for (int i = 0; i < (int)floor_height.size(); i++) {
if (level > floor_height[i]) {
total += level - floor_height[i];
}
}
return total;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
double volume;
cin >> n >> volume;
vector<int> floor_height(n);
int highest = 0;
for (int i = 0; i < n; i++) {
cin >> floor_height[i];
highest = max(highest, floor_height[i]);
}
double lo = 0, hi = highest + volume; // (1)!
for (int step = 0; step < 100; step++) {
double mid = (lo + hi) / 2;
if (water_below(floor_height, mid) >= volume) {
hi = mid;
} else {
lo = mid;
}
}
cout << fixed << setprecision(4) << hi << '\n';
return 0;
}
- ที่ระดับ
highest + volumeช่องไหนก็ตามมีน้ำสูงอย่างน้อยvolumeแล้ว จึงตอบใช่แน่นอน ส่วนระดับ 0 ไม่มีน้ำเลย
5 6
3 1 4 1 5
3.6667
ตรวจด้วยมือ ระดับต้องอยู่ระหว่าง 3 ถึง 4 เพราะที่ระดับ 3 จุได้แค่ 0 + 2 + 0 + 2 + 0 = 4 ตารางเมตร ช่วงนี้มีน้ำสามช่องคือช่องที่พื้นสูง 3 1 1 จึงได้ 3L - 5 = 6 ได้ L = 11/3 = 3.6667 ตรงกัน ถ้าจะคิดด้วยสูตรแบบนี้ ต้องรู้ก่อนว่าระดับอยู่ช่วงไหน ซึ่งต้องเรียงความสูงแล้วไล่หา ส่วน binary search ไม่ต้องรู้อะไรเลยนอกจากวิธีคำนวณน้ำใต้ระดับหนึ่งๆ
ระวังชุดทดสอบที่ปัดเศษตรงครึ่ง
คำตอบที่ได้จาก binary search คลาดจากค่าจริงนิดเดียว ถ้าค่าจริงตกตรงกลางระหว่างสองค่าที่ปัดได้พอดี เช่น 2.00005 กับทศนิยม 4 ตำแหน่ง ความคลาดนิดเดียวนั้นอาจทำให้ปัดไปคนละทาง โจทย์ที่ดีจึงตรวจคำตอบแบบยอมให้คลาดได้เล็กน้อย หรือเลือกชุดทดสอบที่ไม่ตกตรงครึ่ง ตัวตรวจของเว็บนี้เทียบทุกตัวอักษร ชุดทดสอบในแบบฝึกหัดจึงกรองกรณีแบบนั้นออกแล้ว
เร็วขึ้นแค่ไหน
วิธีตรงไปตรงมาคือไล่ระดับขึ้นทีละ 0.0001 เมตรจาก 0 จนกว่าน้ำใต้ระดับจะถึง V จำนวนรอบเท่ากับคำตอบคูณหนึ่งหมื่น วัดบนเครื่องเดียวกับหน้า 19.1 แท็งก์ N = 1000 ช่อง พื้นสูงสุ่มตั้งแต่ 0 ถึง 100 เมตร เทน้ำ V = 50000 ผิวน้ำอยู่ที่ราว 99 เมตร วิธีไล่จึงต้องตรวจราวหนึ่งล้านครั้ง ครั้งละ 1000 ช่อง รันแต่ละแบบ 5 รอบ
| วิธี | Code::Blocks (-Wall) | -O2 | คำตอบ |
|---|---|---|---|
| ไล่ทีละ 0.0001 | 5.0 วินาที | 0.74 วินาที | 99.0152 |
| binary search 100 รอบ | 0.04 วินาที | 0.04 วินาที | 99.0151 |
วิธีไล่ไม่ได้แค่ช้า แต่ยังได้คำตอบผิดในตำแหน่งสุดท้าย ค่าจริงคือ 99.015136 กว่าๆ วิธีไล่หยุดที่ขั้นแรกที่น้ำพอ คือ 99.0152 ซึ่งเกินค่าจริงไปแล้ว ถ้าจะให้ปัดถูกต้องไล่ละเอียดกว่านี้อีกสิบเท่า ก็ช้าลงอีกสิบเท่า ส่วน binary search ที่ N = 100000 ช่องและน้ำห้าล้านตารางเมตร ยังใช้แค่ 0.10 วินาทีทั้งสองแบบ
ลองทำให้พัง
แบบที่ 1 หยุดเมื่อช่วงแคบกว่า 10-9
หลายคนหยุดลูปเมื่อช่วงแคบพอ แทนการวนจำนวนรอบที่กำหนด แบบนี้
while (hi - lo > 1e-9) {
double mid = (lo + hi) / 2;
if (water_below(floor_height, mid) >= volume) {
hi = mid;
} else {
lo = mid;
}
}
ข้อมูลเข้าชุดเดิมได้ 3.6667 ถูกต้อง แต่ลองแท็งก์สามช่องที่พื้นสูงระดับพันล้าน 3 7 กับ 1000000000 999999999 1000000000 โปรแกรมค้าง ใส่บรรทัดพิมพ์ lo กับ hi ทุก 20 รอบด้วย setprecision(17) จะเห็นว่า
step 20 lo=999999053.32567692 hi=1000000007
step 40 lo=1000000001.999598 hi=1000000002.0005076
step 60 lo=1000000001.9999999 hi=1000000002
step 80 lo=1000000001.9999999 hi=1000000002
step 100 lo=1000000001.9999999 hi=1000000002
step 120 lo=1000000001.9999999 hi=1000000002
step 140 lo=1000000001.9999999 hi=1000000002
step 160 lo=1000000001.9999999 hi=1000000002
ตั้งแต่รอบที่ 60 lo กับ hi ไม่ขยับอีกเลย double ที่อยู่ใกล้ 109 เก็บค่าได้ห่างกันทีละราว 1.2 × 10-7 ไม่มีค่าไหนอยู่ระหว่าง lo กับ hi คู่นี้แล้ว จุดกลางที่คำนวณได้จึงปัดกลับไปเท่ากับ lo หรือ hi เดิม ช่วงไม่มีทางแคบกว่า 10-9 ได้ ลูปจึงไม่จบ การวนจำนวนรอบที่กำหนดไม่มีปัญหานี้ เพราะไม่ได้รอให้ช่วงแคบ
แบบที่ 2 พิมพ์ double ด้วย cout เฉยๆ
ในโปรแกรม water-level ลบ fixed << setprecision(4) << ออก เหลือ cout << hi << '\n';
#include <bits/stdc++.h>
using namespace std;
double water_below(const vector<int> &floor_height, double level)
{
double total = 0;
for (int i = 0; i < (int)floor_height.size(); i++) {
if (level > floor_height[i]) {
total += level - floor_height[i];
}
}
return total;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
double volume;
cin >> n >> volume;
vector<int> floor_height(n);
int highest = 0;
for (int i = 0; i < n; i++) {
cin >> floor_height[i];
highest = max(highest, floor_height[i]);
}
double lo = 0, hi = highest + volume;
for (int step = 0; step < 100; step++) {
double mid = (lo + hi) / 2;
if (water_below(floor_height, mid) >= volume) {
hi = mid;
} else {
lo = mid;
}
}
cout << hi << '\n';
return 0;
}
3 7
1000000000 999999999 1000000000
1e+09
คำตอบที่ถูกคือ 1000000002.0000 แต่ cout ที่ไม่ได้ตั้งค่าอะไร พิมพ์ทศนิยมแค่ 6 หลักสำคัญ ค่าที่ใหญ่จึงกลายเป็นรูปวิทยาศาสตร์ 1e+09 ส่วนข้อมูลเข้าชุดแรกพิมพ์ออกมาเป็น 3.66667 ซึ่งก็ผิดรูปแบบที่โจทย์ต้องการอยู่ดี โจทย์ที่ตอบเป็นทศนิยมต้องใส่ fixed กับ setprecision ทุกครั้ง หรือใช้ printf("%.4f\n", hi)
สรุป
- คำตอบเป็นทศนิยมก็ใช้ binary search ได้ ถ้าคำถามใช่ไม่ใช่เป็นทางเดียว
- ตั้ง
lo = midหรือhi = midไม่มีบวกลบหนึ่ง และวนจำนวนรอบที่กำหนด เช่น 100 รอบ - อย่าหยุดด้วย
hi - lo > epsเพราะเมื่อค่าใหญ่doubleแคบได้ไม่ถึง eps ลูปจะไม่จบ - เวลาทั้งหมดคือ 100 คูณเวลาตรวจหนึ่งครั้ง
- พิมพ์คำตอบด้วย
fixedกับsetprecisionหรือprintfตามจำนวนตำแหน่งที่โจทย์กำหนด