10. Menelusuri dan menghitung langkah
Dua keterampilan memisahkan peserta yang nilainya penuh dari yang nilainya setengah: bisa menelusuri algoritma dengan tangan untuk memastikan jawabannya benar, dan bisa menghitung langkahnya untuk memastikan programnya cukup cepat. Keduanya dikerjakan sebelum menekan tombol compile.
Menelusuri dengan tabel
Menelusuri berarti menjalankan algoritma di kertas, satu baris satu waktu, sambil mencatat isi setiap variabel. Buat satu kolom per variabel dan satu baris per langkah.
Algoritma yang ditelusuri:
BACA n // n = 4
jumlah <- 0
i <- 1
SELAMA i <= n
jumlah <- jumlah + i * i
i <- i + 1
CETAK jumlah
Tabelnya:
| Langkah | i |
i * i |
jumlah |
i <= n? |
|---|---|---|---|---|
| awal | 1 | — | 0 | ya |
| 1 | 2 | 1 | 1 | ya |
| 2 | 3 | 4 | 5 | ya |
| 3 | 4 | 9 | 14 | ya |
| 4 | 5 | 16 | 30 | tidak |
| cetak | — | — | 30 | — |
Perhatikan: nilai i pada kolom pertama adalah nilai
sesudah baris i <- i + 1 dijalankan.
Tetapkan aturanmu sendiri lalu pakai terus, supaya tabelnya tidak
membingungkan diri sendiri.
Empat aturan supaya tabel penelusuran benar-benar menangkap kesalahan:
- Satu baris untuk setiap kali isi perulangan dijalankan, bukan satu baris per baris kode.
- Satu kolom untuk setiap variabel yang berubah, ditambah satu kolom untuk hasil pemeriksaan syaratnya.
- Tulis nilai apa adanya, termasuk kalau terlihat salah. Godaan terbesar saat menelusuri adalah menuliskan nilai yang seharusnya, bukan yang benar-benar dihasilkan langkah itu.
- Jangan berhenti di putaran terakhir. Sebagian besar kesalahan satu langkah justru muncul pada pemeriksaan syarat yang terakhir, saat perulangan berhenti.
Buka
exercises/10/01-telusur.cpp.
Program itu mencetak isi setiap variabel pada tiap putaran. Buat dulu
tabel penelusuranmu di kertas untuk n = 4, baru jalankan
programnya dan bandingkan baris per baris.
Telusuri dengan n yang kecil — 3 atau 4 sudah cukup.
Tujuan penelusuran bukan mendapat jawaban, melainkan memastikan
langkahnya benar. Kalau benar untuk n = 4, hampir selalu
benar untuk n = 100.000.
Kasus uji dan kasus tepi
Program yang benar untuk contoh di soal belum tentu benar. Uji juga kasus tepi, yaitu masukan di ujung batas:
| Jenis kasus tepi | Contoh |
|---|---|
| data terkecil | n = 1, atau array berisi satu elemen |
| semua nilai sama | 5 5 5 5 |
| sudah terurut / terbalik | 1 2 3 4 dan 4 3 2 1 |
| nilai negatif dan nol | -3 0 7 |
| nilai terbesar sesuai batasan | semua elemen 10⁹ |
| tidak ada jawaban | target yang tidak pernah muncul |
Sebagian besar nilai yang hilang di OSN bukan karena algoritmanya salah, melainkan karena satu kasus tepi tidak pernah diuji.
Untuk setiap jenis soal, kasus tepinya bisa ditebak sebelum kodenya selesai:
| Bentuk soal | Kasus tepi yang wajib dicoba |
|---|---|
| menelusuri array | n = 1; semua elemen sama |
| mencari nilai terbesar/terkecil | semua elemen negatif |
| mencari sesuatu | yang dicari tidak ada; ada di posisi pertama dan terakhir |
| pembagian | pembaginya nol |
| menjumlahkan | n terbesar dan nilai terbesar
sekaligus |
perulangan while |
masukan yang membuat perulangan tidak berjalan sama sekali |
| banyak kasus uji | dua kasus berturut-turut; kasus kedua berbeda jauh dari yang pertama |
Cara cepat membangkitkan kasus tepi: ambil batasan di soal, lalu coba
nilai tepat di batas dan satu di dalam
batas. Untuk 1 ≤ n ≤ 200.000, itu berarti
n = 1, n = 2, dan
n = 200.000.
Buka
exercises/10/02-kasus-tepi.cpp.
Program itu mencari selisih terbesar antara dua elemen dan berjalan
benar untuk contoh biasa, tetapi salah untuk n = 1 dan
untuk data yang semuanya negatif. Jalankan kedua kasus itu, lalu
perbaiki.
Menghitung jumlah langkah
Hitung berapa kali bagian terdalam sebuah program dijalankan, sebagai
fungsi dari n.
for (int i = 0; i < n; i++) // n kali
cout << a[i];
for (int i = 0; i < n; i++) // n * n kali
for (int j = 0; j < n; j++)
...
for (int i = 0; i < n; i++) // n * (n-1) / 2 kali
for (int j = i + 1; j < n; j++)
...
for (int i = 1; i <= n; i *= 2) // log2(n) kali
...Yang ditulis biasanya hanya bentuk pertumbuhannya, dengan mengabaikan angka pengali dan suku yang lebih kecil:
| Jumlah langkah sebenarnya | Ditulis | Nama |
|---|---|---|
| 5 | O(1) | tetap |
| log₂ n | O(log n) | logaritmik |
| 3n + 7 | O(n) | linear |
| n log₂ n | O(n log n) | seperti sort |
| n(n − 1)/2 | O(n²) | kuadratik |
| 2ⁿ | O(2ⁿ) | eksponensial |
Alasan angka pengali diabaikan: ketika n membesar,
bentuk pertumbuhannya yang menentukan. Program O(n²) dengan pengali
kecil tetap kalah jauh dari program O(n log n) begitu n
mencapai ratusan ribu.
Seberapa jauh, bisa dilihat dari tabel berikut. Angkanya jumlah langkah, bukan detik:
n |
O(log n) | O(n) | O(n log n) | O(n²) | O(2ⁿ) |
|---|---|---|---|---|---|
| 10 | 3 | 10 | 33 | 100 | 1.024 |
| 1.000 | 10 | 1.000 | 9.966 | 10⁶ | tak terhingga untuk kita |
| 100.000 | 17 | 10⁵ | 1,7 × 10⁶ | 10¹⁰ | — |
| 1.000.000 | 20 | 10⁶ | 2,0 × 10⁷ | 10¹² | — |
Perhatikan kolom O(n²): dari n = 1.000 ke
n = 100.000, datanya menjadi 100 kali lipat tetapi
pekerjaannya menjadi 10.000 kali lipat. Inilah sebabnya
penyelesaian yang "cukup cepat" pada contoh kasus bisa sama sekali tidak
selesai pada data sesungguhnya.
Cara cepat mengenali bentuk pertumbuhan tanpa menghitung: lihat apa
yang terjadi kalau n digandakan.
Kalau n digandakan, waktunya ... |
Bentuknya |
|---|---|
| hampir tidak berubah | O(1) atau O(log n) |
| menjadi dua kali | O(n) |
| menjadi sedikit lebih dari dua kali | O(n log n) |
| menjadi empat kali | O(n²) |
| menjadi delapan kali | O(n³) |
menjadi dua kali lipat per satu tambahan n |
O(2ⁿ) |
Jumlah langkah beberapa operasi yang sudah kamu pakai:
| Operasi | Jumlah langkah |
|---|---|
a[i] — mengambil satu elemen array |
O(1) |
v.push_back(x) |
O(1) rata-rata |
| menelusuri seluruh array | O(n) |
sort(...) |
O(n log n) |
| mencari satu nilai dengan penelusuran biasa | O(n) |
| memeriksa semua pasangan | O(n²) |
saringan Eratosthenes sampai n |
O(n log log n) |
pangkat_mod(a, b, m) (Bab 14) |
O(log b) |
Dari batasan ke pilihan algoritma
Komputer penilai mengerjakan kira-kira 10⁸ operasi sederhana
per detik. Batas waktu soal biasanya 1 atau 2 detik. Dari sana,
batasan n di soal langsung memberi tahu algoritma seperti
apa yang dicari:
Batasan n |
Jumlah langkah yang aman | Algoritma yang cocok |
|---|---|---|
| n ≤ 10 | O(n!) atau O(2ⁿ) | coba semua susunan (Bab 11) |
| n ≤ 25 | O(2ⁿ) | coba semua himpunan bagian |
| n ≤ 500 | O(n³) | tiga perulangan bersarang |
| n ≤ 5.000 | O(n²) | dua perulangan bersarang, DP tabel |
| n ≤ 200.000 | O(n log n) | sort, greedy, pencarian biner |
| n ≤ 10.000.000 | O(n) | satu penelusuran |
| n ≤ 10¹⁸ | O(log n) atau O(1) | rumus, pangkat cepat (Bab 14) |
Angka 10⁸ per detik adalah perkiraan yang sengaja dibuat aman. Perulangan yang sangat sederhana bisa berjalan jauh lebih cepat dari itu, sedangkan langkah yang menyentuh memori secara melompat-lompat bisa jauh lebih lambat. Untuk memilih algoritma, perkiraan aman itulah yang dipakai.
Tabel ini adalah alat kerja, bukan hiasan. Sebelum menulis kode, baca
batasannya, lihat barisnya, dan tanyakan: apakah rencana saya
masuk? Menulis penyelesaian O(n²) untuk soal dengan
n = 200.000 berarti nol walaupun logikanya benar
sempurna.
Contoh membacanya: soal dengan n ≤ 200.000 hampir pasti
tidak menginginkan perulangan bersarang. Kalau rencanamu berbentuk
"periksa semua pasangan", berarti ada pengamatan yang belum ditemukan —
misalnya "urutkan dulu", seperti pada contoh Bab 08.
Mengukur waktu sungguhan
Perkiraan di atas tetap perlu diperiksa. Cara termudah di komputer sendiri:
#include <ctime>
clock_t t0 = clock();
// ... bagian yang diukur ...
double detik = (double)(clock() - t0) / CLOCKS_PER_SEC;
cerr << detik << " detik\n";Perhatikan cerr, bukan cout: keluaran
cerr tidak ikut dinilai oleh penilai daring, jadi aman
dipakai untuk pesan bantuan. Meski begitu, biasakan menghapusnya sebelum
mengirim jawaban.
Ketika angka pengali ikut menentukan
Bentuk pertumbuhan menentukan pilihan algoritma, tetapi ada satu hal
yang sering membuat penyelesaian O(n) yang benar tetap kehabisan waktu:
kecepatan membaca masukan. Secara bawaan
cin disetel agar bisa dipakai bersama scanf,
dan penyesuaian itu memakan waktu. Dua baris di awal main
mematikannya:
ios::sync_with_stdio(false);
cin.tie(nullptr);Pada pengukuran membaca satu juta bilangan bulat, waktunya turun dari 0,21 detik menjadi 0,04 detik — sekitar lima kali lebih cepat, tanpa mengubah satu pun baris algoritma. Untuk soal dengan masukan ratusan ribu angka, biasakan menulis kedua baris itu.
Sesudah cin.tie(nullptr), jangan lagi mencampur
cin/cout dengan
scanf/printf dalam satu program: urutan
keluarannya bisa tertukar. Pilih salah satu dan pakai terus.
Buka
exercises/10/03-hitung-langkah.cpp.
Program itu mencacah berapa kali bagian terdalam dijalankan untuk tiga
bentuk perulangan. Jalankan untuk n = 1000 dan
n = 2000, lalu perhatikan: yang O(n) menjadi dua kali
lipat, yang O(n²) menjadi empat kali lipat.
Menguji dengan pembanding
Kalau sebuah penyelesaian cepat terasa meragukan, uji dengan penyelesaian lambat yang jelas benar:
- Tulis versi lambat yang pasti benar (misalnya memeriksa semua pasangan).
- Buat data acak berukuran kecil.
- Jalankan keduanya, bandingkan keluarannya.
- Ulangi ratusan kali. Begitu ada yang berbeda, kamu punya contoh kasus kecil yang bisa ditelusuri dengan tangan.
Kuncinya ada pada langkah 2: datanya harus kecil. Perbedaan yang ditemukan pada array berisi empat angka bisa ditelusuri dengan tangan dalam satu menit; perbedaan pada array berisi seratus ribu angka tidak memberi tahu apa pun.
for (int uji = 0; uji < 1000; uji++) {
int n = rand() % 6 + 1; // 1 sampai 6 elemen
for (int i = 0; i < n; i++) a[i] = rand() % 21 - 10; // -10 sampai 10
if (cepat(n) != lambat(n)) {
cerr << "beda pada n=" << n << ":";
for (int i = 0; i < n; i++) cerr << " " << a[i];
cerr << "\n cepat=" << cepat(n) << " lambat=" << lambat(n) << "\n";
break;
}
}Cara ini menemukan bug yang tidak terlihat dengan membaca kode, dan
ia juga satu-satunya cara praktis untuk menguji dugaan greedy di Bab 12.
Contohnya ada di
exercises/10/04-uji-banding.cpp.
Pekerjaan rumah
Soal 1 dan 2 dikerjakan di kertas.
Soal 1 — Tabel penelusuran
Telusuri algoritma berikut untuk n = 5 dan buat
tabelnya. Tulis nilai setiap variabel pada tiap putaran, lalu tulis
keluarannya.
BACA n
a <- 0
b <- 1
UNTUK i DARI 1 SAMPAI n
c <- a + b
a <- b
b <- c
CETAK a
Soal 2 — Pilih algoritma
Untuk setiap batasan berikut, sebutkan bentuk kompleksitas terbesar yang masih aman, dan satu contoh algoritma yang cocok:
a. n ≤ 18 b. n ≤ 2.000 c.
n ≤ 300.000 d. n ≤ 10¹²
Soal 3 — Perbaiki kasus tepi
Buka
exercises/10/homework/03-kasus-tepi.cpp.
Program itu seharusnya mencetak rata-rata bilangan
positif dari sebuah array, tetapi gagal untuk tiga
kasus tepi: tidak ada bilangan positif sama sekali, n = 1,
dan data yang jumlahnya melebihi batas int. Temukan
ketiganya, tulis sebagai komentar, lalu perbaiki.
Soal 4 — Hitung langkahnya
Buka
exercises/10/homework/04-hitung-langkah.cpp.
Ada empat potongan perulangan di dalam komentar. Untuk masing-masing,
tulis jumlah langkahnya dalam notasi O, lalu lengkapi program agar
mencacah langkah sebenarnya untuk n = 100 dan
n = 200 — dan periksa apakah perbandingannya sesuai
dugaanmu.
Tantangan — Penguji pembanding
Buka
exercises/10/homework/05-uji-banding.cpp.
Soalnya: cetak selisih terbesar antara dua elemen array. Di
dalam file sudah ada dua fungsi — satu versi lambat yang benar, satu
versi cepat yang mengandung satu bug. Tulis perulangan
pengujinya: bangkitkan array acak kecil (n antara 1 dan 6,
nilai −10 sampai 10), bandingkan kedua fungsi, dan cetak contoh kasus
pertama yang membuat keduanya berbeda. Lalu perbaiki bug-nya.
Tips OSN
Batasan dibaca sebagai perintah, bukan keterangan. Sebelum memutuskan cara, pasangkan batasan terbesar pada soal dengan tabel ini. Angkanya memakai patokan 10⁸ operasi sederhana per detik.
Batasan N |
Jumlah langkah yang muat | Bentuk penyelesaiannya |
|---|---|---|
| ≤ 20 | 2^N | coba semua kemungkinan |
| ≤ 500 | N³ | tiga perulangan bersarang |
| ≤ 5.000 | N² | dua perulangan bersarang |
| ≤ 200.000 | N log N | urutkan, atau satu perulangan + pencarian biner |
| ≤ 1.000.000 | N | satu perulangan saja |
| ≤ 10¹⁸ | log N | rumus, atau perulangan atas digit |
Batasan N ≤ 200.000 yang muncul berulang-ulang di soal
OSN adalah isyarat paling jelas: N² = 4 × 10¹⁰ tidak
mungkin, jadi yang dicari penyelesaian satu kali baca atau satu kali
urut.
Nyatakan biayanya sebelum mengetik. Tulis satu baris di kertas coretan: "N ≤ 2 × 10⁵, cara saya N², berarti 4 × 10¹⁰ — tidak muat". Kalimat sependek itu mencegah setengah jam mengetik jawaban yang sudah pasti kena Time Limit Exceeded.
Jawaban lambat tetap layak dikirim. Kalau soal
dibagi menjadi subsoal, jawaban N² biasanya menyelesaikan
subsoal berbatas kecil. Kirim dulu, baru perbaiki. Yang tidak boleh
adalah kehabisan waktu tanpa mengirim apa pun.
Jawaban lambat juga alat penguji. Cara
membandingkannya otomatis dibahas di bab ini: jalankan versi lambat dan
versi cepat pada masukan acak yang sama, dan bandingkan keluarannya
dengan diff. Kalau berbeda, kamu punya contoh kecil yang
salah — jauh lebih berguna daripada vonis Wrong Answer tanpa
keterangan.
Masukan besar perlu pembacaan cepat. Untuk
N ratusan ribu ke atas, dua baris ini pantas menjadi
kebiasaan di awal main:
ios::sync_with_stdio(false);
cin.tie(nullptr);Setelah menulisnya, jangan mencampur cin dengan
scanf dalam satu program.
Ukur, jangan menebak. clock() dari
<ctime> cukup untuk mengukur bagian yang dicurigai
lambat. Menebak bagian mana yang lambat hampir selalu meleset, dan
mengoptimalkan bagian yang bukan penyebabnya tidak mengubah apa-apa.
Perulangan yang memanggil cout di setiap putaran untuk
200.000 baris bisa menjadi bagian paling lambat dari seluruh program
kalau memakai endl. Ganti dengan "\n" sebelum
mencurigai algoritmanya.
Ringkasan
- Menelusuri dengan tabel memeriksa kebenaran; menghitung langkah memeriksa kecepatan. Keduanya dikerjakan sebelum compile.
- Tabel penelusuran: satu baris per putaran, satu kolom per variabel, tulis nilai apa adanya, dan jangan berhenti sebelum pemeriksaan terakhir.
- Kasus tepi yang wajib:
n = 1, semua nilai sama, semua negatif, tidak ada jawaban, dan nilai terbesar sesuai batasan. - Notasi O menyembunyikan angka pengali dan menyisakan bentuk
pertumbuhannya. Cara cepat mengenalinya: lihat apa yang terjadi kalau
ndigandakan. - Perkiraan aman: 10⁸ operasi sederhana per detik. Pakai tabel batasan untuk memilih algoritma sebelum menulis kode.
ios::sync_with_stdio(false); cin.tie(nullptr);mempercepat pembacaan masukan besar tanpa mengubah algoritma.- Penguji pembanding dengan data acak kecil menemukan bug yang tidak terlihat saat membaca kode.
Latihan bergaya OSN
Ketiga soal berikut punya jawaban langsung yang mudah ditulis dan pasti terlalu lambat. Sebelum mengetik, hitung dulu jumlah langkah jawaban langsung itu, lalu cari bentuk yang muat.
Kerangka ketiganya ada di
exercises/10/osn/.
Soal 1 — Subarray Maksimum
Diberikan barisan N bilangan bulat. Pilih sepotong
bagian yang letaknya berurutan dan tidak kosong, sehingga jumlah isinya
sebesar mungkin. Tentukan jumlah itu.
Format masukan. Baris pertama berisi bilangan bulat
N. Baris kedua berisi N bilangan bulat
A₁ A₂ … A_N.
Format keluaran. Satu baris berisi jumlah terbesar yang bisa dicapai.
Batasan.
- 1 ≤
N≤ 200.000 - −10⁹ ≤
Aᵢ≤ 10⁹
Contoh masukan
8
-2 1 -3 4 -1 2 1 -5
Contoh keluaran
6
Penjelasan. Potongan 4 -1 2 1 berjumlah
6, dan tidak ada potongan lain yang lebih besar. Perhatikan bahwa
potongannya tidak boleh kosong: kalau semua bilangannya negatif,
jawabannya adalah bilangan negatif yang terbesar, bukan 0.
Kerangkanya:
exercises/10/osn/01-subarray-maksimum.cpp
Soal 2 — Pasangan Berjumlah K
Diberikan N bilangan bulat dan sebuah bilangan
K. Hitung ada berapa pasangan posisi (i, j)
dengan i < j sehingga Aᵢ + A_j = K.
Format masukan. Baris pertama berisi dua bilangan
bulat N dan K. Baris kedua berisi
N bilangan bulat.
Format keluaran. Satu baris berisi banyaknya pasangan.
Batasan.
- 2 ≤
N≤ 200.000 - 1 ≤
Aᵢ≤ 10⁶ - 2 ≤
K≤ 2 × 10⁶
Contoh masukan
5 7
1 6 3 4 2
Contoh keluaran
2
Penjelasan. Pasangannya adalah (1, 6) dan (3, 4).
Angka 2 tidak punya pasangan karena 5 tidak ada dalam daftar. Yang
dihitung adalah pasangan posisi: kalau masukannya
2 2 2 2 dengan K = 4, jawabannya 6, karena ada
enam cara memilih dua posisi berbeda.
Kerangkanya:
exercises/10/osn/02-pasangan-berjumlah.cpp
Soal 3 — Jumlah Rentang
Diberikan barisan N bilangan bulat dan Q
pertanyaan. Setiap pertanyaan berisi dua nomor posisi L dan
R; jawabannya adalah jumlah A_L sampai
A_R, termasuk kedua ujungnya.
Format masukan. Baris pertama berisi dua bilangan
bulat N dan Q. Baris kedua berisi
N bilangan bulat. Q baris berikutnya
masing-masing berisi dua bilangan bulat L dan
R.
Format keluaran. Q baris, satu jawaban
per pertanyaan.
Batasan.
- 1 ≤
N,Q≤ 200.000 - −10⁹ ≤
Aᵢ≤ 10⁹ - 1 ≤
L≤R≤N
Contoh masukan
5 3
1 2 3 4 5
1 5
2 3
4 4
Contoh keluaran
15
5
4
Penjelasan. Pertanyaan pertama menjumlahkan seluruh
barisan, yang kedua menjumlahkan 2 + 3, dan yang ketiga hanya satu
angka. Menjumlahkan ulang setiap pertanyaan memerlukan
N × Q = 4 × 10¹⁰ penjumlahan — hitung sendiri sebelum
memutuskan.
Kerangkanya:
exercises/10/osn/03-jumlah-rentang.cpp
Bingung atau sudah selesai? Buka halaman pembahasan PR.