Complexity & Searching

โจทย์เดียวกันเขียนได้หลายวิธี แต่ละวิธีใช้จำนวน step ไม่เท่ากัน Complexity analysis คือการวัดว่าเมื่อข้อมูล n โตขึ้น จำนวน step โตเร็วแค่ไหน

อัลกอริทึม 2 แบบ ผลเท่ากันแต่คนละราคา

โจทย์: บวกเลข 1 ถึง n

Algor1 — วนลูป

Algor1(n){
    sum = 0
    for(i = 1 to n){
        sum = sum + i
    }
    return sum
}

ทำงาน n ครั้ง

Algor2 — ใช้สูตร

1,  2,  3,  … ,  n-2,  n-1,  n

จับคู่หัวท้าย: 1+n = n+1, 2+(n-1) = n+1, …
ได้ n+1 ทั้งหมด n/2 คู่  →  ผลรวม = (n+1)(n/2)
Algor2(n){
    return (n+1)*(n/2)
}

ทำงาน 1 ครั้ง ไม่ว่า n จะใหญ่แค่ไหน

ผลลัพธ์เท่ากันเป๊ะ แต่ Algor1 เป็น O(n) ส่วน Algor2 เป็น O(1) — นี่คือเหตุผลที่ต้องวิเคราะห์ complexity

นับจำนวน step ยังไง

for (i = 0 to n)
{
    a = i+1
    print(a)
}

ข้างในลูปนับเป็น 1 step ลูปวน i = 0 ถึง n จึงได้

Σi=0..n 1 = n + 1        →  O(n+1) = O(n)
Σi=0..n c = n + c        →  O(n+c) = O(n)

วิธีวิเคราะห์ 2 แบบ

วิธีทำอย่างไรข้อจำกัด
Method 1: วัดจริงแล้วพล็อตกราฟจับเวลาด้วย System.nanoTime() ก่อน–หลังรัน แล้วพล็อต n เทียบเวลาขึ้นกับเครื่อง ภาษา และคอมไพเลอร์
Method 2: วิเคราะห์โปรแกรมนับจำนวน step จากโค้ดตรง ๆต้องอ่านโค้ดเป็น แต่ได้คำตอบที่ไม่ขึ้นกับเครื่อง
long start = System.nanoTime();
// code algorithm → searching X in array A size n
long stop  = System.nanoTime();

เทียบอัตราการโตด้วย limit

อยากรู้ว่า f(n) กับ g(n) ตัวไหนโตเร็วกว่า ให้หาลิมิตของอัตราส่วน

lim (n→∞) f(n)/g(n) =
    ∞   → f(n) โตเร็วกว่า g(n)
    0   → g(n) โตเร็วกว่า f(n)
    C   → โตเท่ากัน

ตัวอย่าง: log(n) เทียบกับ n²

lim log(n)/n²
  = lim ln(n) / (ln10 · n²)
  = (1/ln10) · lim (1/n) / (2n)      // L'Hôpital: หาอนุพันธ์ทั้งเศษและส่วน
  = (1/ln10) · lim 1/(2n²)
  = 0

ได้ 0 แปลว่า n² โตเร็วกว่า log(n)

ลำดับการโตที่ต้องจำ:

O(1) << O(log n) << O(n) << O(n²) << O(2ⁿ)
ขนาดข้อมูล n จำนวน step 2ⁿ n log n n log n 1 ยิ่งเส้นชันเร็ว = ยิ่งแย่เมื่อ n โต

O(1) << O(log n) << O(n) << O(n log n) << O(n²) << O(2ⁿ) — เส้น 2ⁿ พุ่งชนเพดานตั้งแต่ n ยังน้อย

Big-O และเพื่อน ๆ

สัญลักษณ์ความหมายใช้พูดถึง
o(f(n))โตน้อยกว่า f(n)ขอบบนแบบเข้ม
O(f(n))โตน้อยกว่าหรือเท่ากับ f(n)worst case / ขอบบน
Θ(f(n))โตเท่ากับ f(n)best = worst
Ω(f(n))โตมากกว่าหรือเท่ากับ f(n)best case / ขอบล่าง
ω(f(n))โตมากกว่า f(n)ขอบล่างแบบเข้ม

กฎการตัดทอน

O(n+1) = O(n) + O(1)     // O(1) โตช้ากว่า O(n)
O(n+1) = O(n)            // เก็บเฉพาะพจน์ที่โตเร็วสุด
O(n+c) = O(n)
O((n²-n)/2) = O(n²)      // ตัดค่าคงที่คูณทิ้ง

Linear Search

for (i = 0 to n)
{
    if(x[i] == c)
    {
        return i
    }
}

ไล่ดูทีละช่องจากซ้ายไปขวา

กรณีจำนวนครั้งสัญลักษณ์
Best — เจอที่ช่องแรก1Ω(1)
Worst — เจอช่องสุดท้าย หรือไม่เจอเลยnO(n)
ถ้าโค้ดไม่มี return (วนจนจบทุกกรณี) จะกลายเป็น Θ(n) เพราะ best = worst = n

ตัวอย่างจากสไลด์

array [1, 6, 3, 4, 5] ค้นหา 5 → ใช้ 5 ครั้ง / ค้นหา 1 → ใช้ 1 ครั้ง / ค้นหา 0 (ไม่มี) → ใช้ n ครั้ง = worst case

Binary Search

เงื่อนไขสำคัญ: ข้อมูลต้องเรียงแล้ว ถ้าไม่เรียง binary search ใช้ไม่ได้

ตัวอย่าง: array [1, 6, 7, 9, 10, 12, 15, 16, 17] ค้นหา 16

รอบช่วงที่ค้นค่ากลางตัดสินใจ
1ทั้งหมด1016 > 10 → ไปครึ่งขวา
2ครึ่งขวา1516 > 15 → ไปขวาอีก
3ที่เหลือ16เจอ

Linear search ใช้ 8 ครั้ง ส่วน binary search ใช้ 3 ครั้ง

ค้นหา 16 ใน [1, 6, 7, 9, 10, 12, 15, 16, 17] รอบ 1 1 6 7 9 10 12 15 16 17 mid = 10 → 16 > 10 ไปครึ่งขวา รอบ 2 ตัดทิ้ง 12 15 16 17 mid = 15 → 16 > 15 ไปขวาอีก รอบ 3 ตัดทิ้ง 16 17 เจอที่รอบ 3 linear search ใช้ 8 ครั้ง · binary search ใช้ 3 ครั้ง

ทุกรอบข้อมูลเหลือครึ่งเดียว จำนวนรอบจึงเป็น log₂n

search(a, x, left, right)
{
    // เงื่อนไขหยุด: เหลือช่องเดียว
    if(left == right){
        if(a[left] == x) print("found at " + x);
        return;
    }
    // เงื่อนไขหยุด: เหลือ 2 ช่องสุดท้าย
    if(left == right-1){
        if(a[left]  == x) { print("found at " + x); return; }
        if(a[right] == x) { print("found at " + x); return; }
    }

    mid = left + ((right - left)/2)

    if(a[mid] == x){
        print("found at " + x);
        return;
    }
    if(x > a[mid])  search(a, x, mid+1, right);
    if(x < a[mid])  search(a, x, left, mid);
}
ต้องมีเงื่อนไขหยุด ไม่งั้น recursion จะเรียกตัวเองไม่จบ

แก้ recurrence ของ Binary Search

แต่ละรอบข้อมูลลดลงครึ่งหนึ่ง และเสียงานคงที่ c ต่อรอบ

T(n)   = T(n/2¹) + c
T(n/2) = T(n/2²) + c
T(n/4) = T(n/2³) + c
    …
T(n/2^(k-1)) = T(n/2^k) + c
T(n/2^k) = b            // รอบสุดท้าย เหลือ 1 ตัว

รอบสุดท้ายเหลือข้อมูล 1 ตัว จึงได้

n/2^k = 1
n     = 2^k
k     = log₂ n          // จำนวนรอบ

โปรแกรมทำงาน k รอบ = log n รอบ → O(log n)

n = 8 4 4 2 2 2 2 1 1 1 1 step 0 step 1 step 2 step 3

8 = 2³ จึงแบ่งได้ 3 ชั้น → k = log₂n

ดูจากต้นไม้การแบ่ง

n = 8  →  8 → 4 → 2 → 1   ลึก 3 ชั้น
8 = 2³ ดังนั้นชั้น = 3 = log₂ 8

สรุปเทียบ 2 อัลกอริทึม

Linear SearchBinary Search
ข้อมูลต้องเรียงก่อนไหมไม่ต้องต้องเรียง
Best caseΩ(1)Ω(1)
Worst caseO(n)O(log n)
เขียนแบบไหนลูปธรรมดาrecursive หรือ ลูป while