Analisis kode

Membaca perulangan, perulangan bersarang, dan kombinasi sekuensial versus multiplikatif untuk menaksir kompleksitas.

Daftar isi

Menaksir kompleksitas dari kode berarti membaca struktur perulangannya, menjalankan sedikit aritmetika, lalu menutupnya dengan notasi Big-O. Topik ini melatih keterampilan tersebut pada pola yang paling sering muncul, yaitu kode berurutan, perulangan tunggal, perulangan bersarang, dan perulangan yang batasnya menggandakan diri. Setiap pola dianalisis langkah demi langkah sampai rumusnya, dan seluruh taksiran dibuktikan dengan penghitung operasi di bagian akhir.

Kaedah penjumlahan untuk kode berurutan#

Kaedah pertama berlaku untuk blok yang dijalankan satu demi satu, yaitu jumlahkan operasinya lalu pertahankan suku yang tumbuh paling cepat. Dua perulangan tunggal yang ditulis berurutan mengerjakan nn langkah lalu nn langkah lagi. Totalnya n+n=2nn + n = 2n, dan konstanta 2 dibuang sehingga hasilnya O(n)O(n). Urutan penulisan blok tidak mengubah hasil akhirnya, karena penjumlahan tidak memedulikan urutan sukunya.

python Dua perulangan sekuensial
n = 1000
langkah = 0
for i in range(n):       # blok pertama: n langkah
    langkah += 1
for j in range(n):       # blok kedua: n langkah lagi
    langkah += 1
print(langkah)
text Keluaran
2000

Penghitung mencetak 2000 untuk n=1000n = 1000, sesuai taksiran 2n2n. Ketika salah satu blok jauh lebih berat, blok yang lain praktis tak terlihat. Satu perulangan tunggal di depan sepasang perulangan bersarang menyumbang n+n2n + n^2 langkah, suku n2n^2 yang menang, sehingga keseluruhannya tetap O(n2)O(n^2). Kaedah penjumlahan selalu berakhir pada suku dominannya.

Kaedah ini bekerja untuk berbagai bentuk blok, bukan hanya perulangan yang tampak jelas. Memanggil fungsi berbiaya O(log⁡n)O(\log n) sekali lalu menyusuri daftar nn elemen menyumbang O(log⁡n)+O(n)O(\log n) + O(n) yang berakhir di O(n)O(n). Membaca berkas berbiaya O(n)O(n) lalu mengurutkan isinya berbiaya O(nlog⁡n)O(n \log n) berakhir di O(nlog⁡n)O(n \log n). Suku dengan kurva paling curam selalu mewakili seluruh deret blok yang dijalankan berurutan.

Kaedah perkalian untuk perulangan bersarang#

Kaedah kedua berlaku ketika perulangan berada di dalam perulangan lain, yaitu kalikan banyaknya langkah keduanya. Perulangan luar mengerjakan nn putaran, dan setiap putaran membiarkan perulangan dalam mengerjakan nn putaran penuh. Totalnya n×n=n2n \times n = n^2, sehingga strukturnya berada di kelas O(n2)O(n^2).

python Perulangan bersarang penuh
n = 1000
langkah = 0
for i in range(n):       # setiap putaran luar
    for j in range(n):   # membiarkan n putaran dalam
        langkah += 1
print(langkah)
text Keluaran
1000000

Batas dalam yang menyusut juga termasuk pola ini. Perulangan dalam yang hanya mengerjakan ii putaran pada putaran ke-ii dari luar menghasilkan jumlah segitiga berikut.

∑i=1n−1i=n(n−1)2 \sum_{i=1}^{n-1} i = \frac{n(n-1)}{2}
python Perulangan bersarang segitiga
n = 1000
langkah = 0
for i in range(n):       # putaran ke-i
    for j in range(i):   # hanya berjalan i kali
        langkah += 1
print(langkah)
text Keluaran
499500

Untuk n=1000n = 1000, rumus segitiga memberi 499.500, sama persis dengan penghitung. Pembagi dua adalah konstanta, sehingga kelasnya tetap O(n2)O(n^2) meskipun jumlah langkah aslinya separuh dari pola persegi. Dua fragmen di atas terlihat berbeda di layar, tetapi keduanya berdiri di kelas yang sama.

Perkalian juga berlaku ketika yang dikalikan bukan perulangan, melainkan biaya satu langkah. Perulangan tunggal yang di dalamnya memanggil pencarian biner mengerjakan nn putaran berbiaya log⁡n\log n, sehingga totalnya O(nlog⁡n)O(n \log n). Pola ini mudah terlewat karena biaya baris dalam tersembunyi di balik pemanggilan fungsi. Karena itu biaya setiap fungsi yang dipanggil perlu diketahui lebih dahulu sebelum kode pemanggilnya dianalisis.

Batas yang menggandakan diri#

Pola ketiga menyembunyikan O(log⁡n)O(\log n) di balik bentuk yang tidak tampak seperti perulangan biasa. Ketika batas perulangan menggandakan nilai variabelnya, jumlah langkahnya sama dengan berapa kali variabel itu harus digandakan agar melewati nn. Rumusnya 2k≥n2^k \ge n dengan kk banyaknya langkah, sehingga k=⌈log⁡2n⌉k = \lceil \log_2 n \rceil.

python Penggandaan nilai batas
n = 1000000
i = 1
langkah = 0
while i < n:             # batas naik dua kali lipat setiap putaran
    i *= 2
    langkah += 1
print(langkah, i)
text Keluaran
20 1048576

Penghitung melaporkan 20 langkah untuk n=1000000n = 1000000, dan nilai i berhenti di 1048576, yaitu 2202^{20}. Pola inilah yang menggerakkan pencarian biner, yang membuang separuh kandidat pada setiap perbandingan sehingga sejuta kandidat habis dalam dua puluh langkah. Kapan pun sebuah variabel digandakan atau dibelah dua di setiap putaran, dugaan pertama yang wajar adalah O(log⁡n)O(\log n).

Arahnya bebas bolak-balik. Variabel yang membelah diri, misalnya i //= 2 pada perulangan yang berhenti saat nilainya mencapai satu, menempuh jumlah langkah yang sama dengan variabel yang menggandakan diri. Yang menentukan bukan operasi gandakan atau belah, melainkan bahwa ruang kerjanya berlipat atau menyusut dengan faktor tetap di setiap putaran. Setiap faktor tetap yang lebih dari satu menghasilkan O(log⁡n)O(\log n), dengan pangkal berapa pun.

Membuktikan taksiran dengan penghitung#

Seluruh kaedah di atas menghasilkan taksiran, dan taksiran layak diuji. Uji yang paling ringkas adalah doubling test, yaitu menjalankan algoritma yang sama pada nn yang berlipat dua lalu membandingkan jumlah langkahnya. Bila langkahnya berlipat empat, bentuk kurvanya kuadrat, karena masukan berlipat dua dan 22=42^2 = 4. Berkas berikut menerapkannya pada algoritma pencarian pasangan angka yang memeriksa setiap pasangan. Target yang dipakai adalah angka yang tidak pernah ada di daftar, sehingga fungsi selalu terseret ke kasus terburuknya dan menyusuri seluruh pasangan.

python uji-taksiran.py
# Taksiran O(n^2) dibandingkan dengan penghitung operasi sesungguhnya.
# Fungsi memeriksa setiap pasangan angka, mencari dua nilai yang
# berjumlah sama dengan target.

def cari_pasangan(angka, target):
    langkah = 0
    n = len(angka)
    for i in range(n):
        for j in range(i + 1, n):
            langkah += 1
            if angka[i] + angka[j] == target:
                return langkah, (i, j)
    return langkah, None

data = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4, 6, 2, 6, 4, 3, 3, 8, 3, 2, 7, 9, 5]
print(f"{'n':>5} {'langkah':>8} {'n*(n-1)/2':>10} {'rasio':>6}")
sebelumnya = None
for ukuran in (8, 16, 32):
    langkah, hasil = cari_pasangan(data[:ukuran], 100)
    taksiran = ukuran * (ukuran - 1) // 2
    rasio = f"{langkah / sebelumnya:.1f}x" if sebelumnya else "-"
    print(f"{ukuran:>5} {langkah:>8} {taksiran:>10} {rasio:>6}")
    sebelumnya = langkah
text Keluaran
    n  langkah  n*(n-1)/2  rasio
    8       28         28      -
   16      120        120   4.3x
   32      496        496   4.1x

Tiga baris tabelnya mengikuti rumus n(n−1)2\frac{n(n-1)}{2} dengan tepat, dan rasio antar barisnya menghampiri empat setiap nn berlipat dua. Rasio 4,3 dan 4,1 belum persis empat karena suku (n−1)(n-1) pada rumus itu masih terasa pada nn kecil, dan angka empat baru tepat ketika nn jauh lebih besar. Meski begitu, bukti empirisnya sudah cukup untuk menempatkan fungsi tersebut di kelas O(n2)O(n^2), tanpa perlu membaca prosesor maupun jam. Bila nanti bertemu fungsi yang kelasnya tidak pasti, pengujian penggandaan seperti ini adalah langkah pertama yang murah untuk dilakukan.

Ringkasan#

  • Kode berurutan dijumlahkan lalu diringkas ke suku dominannya, sehingga n+n2n + n^2 tetap O(n2)O(n^2).
  • Perulangan bersarang dikalikan, baik bentuk penuh n×n=n2n \times n = n^2 maupun bentuk segitiga n(n−1)2\frac{n(n-1)}{2} yang kelasnya tetap sama.
  • Batas yang menggandakan diri menghasilkan O(log⁡n)O(\log n), karena rumusnya 2k≥n2^k \ge n.
  • Pengujian penggandaan membuktikan taksiran secara empiris, dengan rasio langkah empat untuk kurva kuadrat ketika nn berlipat dua.

Referensi#