Big O Notation
ทำไมต้องวิเคราะห์ความเร็ว
ในการแข่งขัน โปรแกรมต้องตอบถูก และ ทำงานเสร็จภายในเวลาที่กำหนด (มักเป็น 1 วินาที) การออกแบบวิธีที่ถูกแต่ช้ามักง่าย ความท้าทายอยู่ที่การหาวิธีที่เร็วพอ
Time complexity คือการบอกว่าจำนวนคำสั่งที่โปรแกรมทำ โตขึ้นอย่างไร เมื่อขนาด input n โตขึ้น โดยเขียนด้วยสัญลักษณ์ O(\cdots) ทำให้รู้ว่าวิธีไหนผ่านเวลาได้ ก่อน ลงมือเขียนโค้ด
Motivation problem
โค้ดสองชุดนี้ ชุดไหนเร็วกว่ากันเมื่อ n = 10^{5}
// แบบ A
for (int i = 1; i <= n; i++)
for (int j = i + 1; j <= n; j++)
work();
// แบบ B
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j += i)
work();
ทั้งคู่เป็น loop สองชั้น แต่จำนวนรอบต่างกันมาก
แบบ A: รอบในทำ n - i ครั้ง รวม \frac{n(n - 1)}{2} ครั้ง เป็น O\left( n^{2} \right)
แบบ B: รอบในทำประมาณ \frac{n}{i} ครั้ง รวม n\left( 1 + \frac{1}{2} + \frac{1}{3} + \cdots + \frac{1}{n} \right) \approx n\ln n เป็น O\left( n\log n \right)
| n | แบบ A | แบบ B |
|---|---|---|
| 10^{3} | 499,500 | 8,053 |
| 10^{4} | 49,995,000 | 103,643 |
| 10^{5} | 4,999,950,000 | 1,266,714 |
บทเรียน: อย่านับแค่จำนวนชั้นของ loop ให้ดูว่าแต่ละชั้นวนกี่รอบจริง
กฎการคำนวณ
Loop
Loop ที่วน n รอบเป็น O(n) และ loop ซ้อน k ชั้นที่แต่ละชั้นวน n รอบเป็น O\left( n^{k} \right)
for (int i = 1; i <= n; i++) // O(n)
for (int j = 1; j <= n; j++) // O(n) ต่อรอบนอก
work(); // รวม O(n^2)
ไม่สนค่าคงที่และพจน์ที่เล็กกว่า
Big O สนใจแค่ อันดับการเติบโต (order of magnitude) loop ที่วน 3n, n + 5 หรือ \frac{n}{2} รอบ เป็น O(n) ทั้งหมด และ n^{2} + 100n เป็น O\left( n^{2} \right) เพราะเมื่อ n ใหญ่ n^{2} ครอบงำทั้งหมด
หลาย phase ต่อกัน
ถ้าทำงานหลายช่วงต่อกัน complexity รวมคือของช่วงที่ช้าที่สุด (bottleneck) เช่น O(n) + O\left( n^{2} \right) + O(n) = O\left( n^{2} \right)
หลายตัวแปร
ถ้าขนาด input มีหลายค่า ให้เขียนด้วยหลายตัวแปร เช่น loop n รอบซ้อน loop m รอบเป็น O(nm) และ BFS บนกราฟเป็น O(n + m)
Loop ที่หารครึ่ง
for (int i = n; i > 0; i /= 2) work(); // O(log n)
n ถูกหารครึ่งได้ประมาณ \log_{2}n ครั้งก่อนเหลือ 0 เช่น \log_{2}10^{6} \approx 20 นี่คือที่มาของความเร็วของ binary search
Recursion
เวลาของ recursion = (จำนวนครั้งที่ฟังก์ชันถูกเรียก) \times (งานในแต่ละครั้ง)
void f(int n) { // เรียก n ครั้ง ครั้งละ O(1): O(n)
if (n == 1) return;
f(n - 1);
}
void g(int n) { // เรียก 1 + 2 + 4 + ... + 2^(n-1) = 2^n - 1 ครั้ง: O(2^n)
if (n == 1) return;
g(n - 1);
g(n - 1);
}
ดูภาพต้นไม้การเรียกฟังก์ชันได้ใน widget ของบท Recursion
Complexity classes ที่พบบ่อย
| Complexity | ชื่อ | ตัวอย่าง |
|---|---|---|
| O(1) | constant | สูตรคณิตศาสตร์, เข้าถึง array |
| O\left( \log n \right) | logarithmic | binary search, set/map หนึ่งครั้ง |
| O\left( \sqrt{n} \right) | square root | หาตัวประกอบของ n |
| O(n) | linear | วนอ่าน array หนึ่งรอบ |
| O\left( n\log n \right) | - | sort, ทำ O\left( \log n
\right) ไป n ครั้ง |
| O\left( n^{2} \right) | quadratic | พิจารณาทุกคู่ |
| O\left( n^{3} \right) | cubic | loop สามชั้น, Floyd-Warshall |
| O\left( 2^{n} \right) | exponential | ลองทุก subset |
| O(n!) | factorial | ลองทุก permutation |
ประเมินว่าผ่านเวลาหรือไม่
แทน n สูงสุดลงใน complexity แล้วเทียบกับประมาณ 10^{8} คำสั่งต่อวินาที เช่น n = 10^{5} กับ O\left( n^{2} \right) ได้ 10^{10} คำสั่ง ใช้ราว 100 วินาที ไม่ผ่านแน่นอน
ลองเปลี่ยนค่า n แล้วดูว่า complexity ไหนผ่านเวลา เปิด “ให้ฉันทายก่อน” แล้วคลิกแถวที่คิดว่าผ่าน ก่อนกดตรวจคำตอบ
ในทางกลับกัน ขนาด input ก็บอกใบ้วิธีแก้ได้: ถ้า n \leq 20 โจทย์อาจต้องการ O\left( 2^{n} \right) แต่ถ้า n \leq 2 \times 10^{5} ต้องคิดวิธี O\left( n\log n \right) หรือเร็วกว่า (ดูตารางในบทการเขียนโปรแกรมเชิงแข่งขัน)
ค่าคงที่ก็มีผล
Big O ไม่บอกค่าคงที่ วิธี O(n) ที่ทำ 50n คำสั่งอาจช้ากว่าวิธี O\left( n\log n \right) ที่เขียนเรียบง่าย ถ้า
complexity อยู่ใกล้ขอบ (เช่น 10^{8} ถึง
10^{9}) ให้ระวังค่าคงที่ เช่น ใช้
map ซึ่งช้ากว่า array หลายเท่า
ต้นทุนที่ซ่อนอยู่
บางคำสั่งดูเหมือนบรรทัดเดียวแต่ไม่ใช่ O(1)
| คำสั่ง | เวลา |
|---|---|
s = s + c (สร้าง string ใหม่) |
O\left( |s| \right) ใช้
s += c แทนซึ่งเฉลี่ย O(1) |
v.erase(v.begin()) หรือ
v.insert(v.begin(), x) |
O(n) เพราะต้องเลื่อนทุกตัว |
ส่ง vector เข้าฟังก์ชันแบบ by value |
O(n) ทุกครั้งที่เรียก |
set/map: insert, find, erase |
O\left( \log n \right) |
sort |
O\left( n\log n \right) |
v.push_back(x) |
เฉลี่ย O(1) (amortized ดูบท Dynamic Array) |
Space complexity
วิเคราะห์หน่วยความจำได้ด้วย Big O เช่นกัน และโจทย์มักจำกัดหน่วยความจำ (เช่น 256 MB)
intใช้ 4 byte ดังนั้นint a[10000000](10^{7} ตัว) ใช้ 40 MBlong longใช้ 8 bytearray 2 มิติขนาด 10^{4} \times 10^{4} ของ
intใช้ 400 MB เกินแน่นอน
ลองคิด
for (int i = 1; i * i <= n; i++)วนกี่รอบถ้า n \leq 10^{6} วิธี O\left( n\sqrt{n} \right) ผ่านเวลา 1 วินาทีหรือไม่
โค้ดที่ sort แล้ว binary search q ครั้งบน array ขนาด n มี complexity เท่าไร
เฉลย
ประมาณ \sqrt{n} รอบ: O\left( \sqrt{n} \right)
10^{6} \times 10^{3} = 10^{9} คำสั่ง เสี่ยงเกินเวลามาก
O\left( n\log n + q\log n \right)
โจทย์ฝึกฝน (Practice Problems)
ลองทำโจทย์เหล่านี้จาก CSES Problem Set เพื่อฝึกใช้ทักษะจากบทนี้ โดยเริ่มจากโจทย์ที่ง่ายที่สุดก่อน
Counting Divisors (O\left( \sqrt{n} \right) ต่อคำถาม หรือ sieve)
โจทย์เพิ่มเติม: CSES Problem Set และ programming.in.th