12. Greedy
Greedy (serakah) berarti mengambil pilihan yang
paling menguntungkan saat ini, lalu tidak pernah mundur. Algoritmanya
biasanya pendek dan cepat — sering hanya sort diikuti satu
penelusuran. Bahayanya juga di situ: greedy yang salah terlihat sama
meyakinkannya dengan greedy yang benar.
Bentuk umum
- Urutkan data menurut suatu ukuran.
- Telusuri sekali dari depan.
- Di setiap langkah, ambil kalau memungkinkan.
Jumlah langkahnya O(n log n) karena sort, jadi greedy
cocok untuk soal berbatasan n ≤ 200.000 atau lebih.
Seluruh kesulitan greedy ada di langkah 1: diurutkan menurut apa?
Ukuran pengurutan yang sering dicoba, dan bentuk soal yang biasanya cocok dengannya:
| Diurutkan menurut | Bentuk soal yang biasanya cocok |
|---|---|
| nilai terbesar dulu | "ambil sebanyak mungkin yang bernilai tinggi" |
| nilai terkecil dulu | "buang yang paling murah", "bayar sesedikit mungkin" |
| waktu selesai paling awal | penjadwalan kegiatan |
| tenggat paling awal | "kerjakan sebelum tenggatnya" |
| rasio nilai per berat | soal muatan yang boleh dipotong (bukan knapsack 0/1) |
| dua ujung sekaligus | memasangkan yang ringan dengan yang berat |
Daftar ini adalah daftar dugaan, bukan daftar jawaban. Setiap dugaan tetap harus diuji, dan bagian akhir bab ini membahas caranya.
Contoh yang berhasil: uang kembalian
Diberikan pecahan uang Indonesia 100.000, 50.000, 20.000, 10.000, 5.000, 2.000, dan 1.000. Berapa lembar paling sedikit untuk membayar
krupiah?
Greedy: ambil sebanyak mungkin dari pecahan terbesar, lalu turun.
int pecahan[] = {100000, 50000, 20000, 10000, 5000, 2000, 1000};
int lembar = 0;
for (int i = 0; i < 7; i++) {
lembar += k / pecahan[i];
k %= pecahan[i];
}Untuk k = 87.000: 87.000 → satu 50.000, sisa 37.000 →
satu 20.000, sisa 17.000 → satu 10.000, sisa 7.000 → satu 5.000, sisa
2.000 → satu 2.000. Lima lembar, dan tidak ada cara dengan empat
lembar.
Kenapa greedy benar di sini: setiap pecahan uang Indonesia selalu habis dibagi atau "cukup rapat" terhadap pecahan di bawahnya, sehingga mengambil satu lembar besar tidak pernah bisa dikalahkan oleh beberapa lembar kecil. Sifat itu milik daftar pecahannya, bukan milik soal "cari lembar paling sedikit" — dan itulah yang membuat contoh berikutnya gagal.
Buka
exercises/12/01-kembalian.cpp.
Ganti daftar pecahannya menjadi {4000, 3000, 1000} dan
i < 7 menjadi i < 3, lalu jalankan untuk
k = 6000. Greedy menjawab 3 lembar (4000 + 1000 + 1000),
padahal 2 lembar (3000 + 3000) cukup. Kembalikan daftar pecahannya
sesudah itu.
Contoh yang gagal: pecahan buatan
Ganti pecahannya menjadi 1, 3, dan 4, lalu bayar 6.
- Greedy: ambil 4, sisa 2; ambil 1, sisa 1; ambil 1. Tiga koin.
- Jawaban terbaik: 3 + 3. Dua koin.
Greedy salah. Tidak ada yang bisa diperbaiki dengan menambah aturan kecil; soal seperti ini membutuhkan dynamic programming (Bab 13).
Kalau seluruh nilai 1 sampai 10 diperiksa, kegagalannya ternyata jarang — dan justru itu bahayanya:
k |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| greedy | 1 | 2 | 1 | 1 | 2 | 3 | 2 | 2 | 3 | 4 |
| terbaik | 1 | 2 | 1 | 1 | 2 | 2 | 2 | 2 | 3 | 3 |
Delapan dari sepuluh nilai cocok. Kalau kamu hanya mencoba
k = 1 sampai k = 5, greedy terlihat benar
sempurna. Inilah sebabnya greedy tidak boleh "diuji sekilas": yang
diperlukan adalah pemeriksaan menyeluruh atas data kecil, bukan dua tiga
contoh yang kebetulan terpikir.
Greedy yang benar untuk pecahan uang sungguhan tidak berarti greedy benar untuk semua pecahan. Kebenaran greedy bergantung pada datanya, bukan pada bentuk soalnya. Selalu uji dengan pembanding sebelum percaya.
Buka
exercises/12/02-koin-gagal.cpp.
Program itu membandingkan greedy dengan pencarian menyeluruh untuk
pecahan {1, 3, 4}. Jalankan, lalu ubah pecahannya menjadi
{1, 5, 10, 25} (koin Amerika) dan lihat greedy kembali
benar untuk semua nilai yang diuji.
Contoh klasik: penjadwalan kegiatan
Ada
nkegiatan, masing-masing dengan waktu mulais[i]dan waktu selesaif[i]. Kamu hanya bisa mengikuti satu kegiatan pada satu waktu. Berapa kegiatan terbanyak yang bisa diikuti?
Tiga ukuran pengurutan yang terlihat masuk akal:
| Diurutkan menurut | Benar? |
|---|---|
| waktu mulai paling awal | tidak — satu kegiatan panjang bisa memakan semuanya |
| durasi terpendek | tidak — kegiatan pendek bisa menabrak dua kegiatan lain |
| waktu selesai paling awal | ya |
Alasan yang benar: kegiatan yang selesai paling awal menyisakan waktu terbanyak bagi kegiatan berikutnya. Setiap penyelesaian lain bisa ditukar kegiatan pertamanya dengan kegiatan yang selesai paling awal tanpa membuat jawabannya lebih buruk.
Kedua ukuran yang salah punya contoh penyangkal yang sangat kecil:
- Waktu mulai paling awal. Kegiatan (0–10), (1–2), (3–4). Greedy mengambil (0–10) dan berhenti di situ: 1 kegiatan. Jawaban terbaik (1–2) dan (3–4): 2 kegiatan.
- Durasi terpendek. Kegiatan (1–5), (4–6), (5–9). Greedy mengambil (4–6) yang berdurasi 2, dan keduanya yang lain jadi bertabrakan: 1 kegiatan. Jawaban terbaik (1–5) dan (5–9): 2 kegiatan.
Menelusuri greedy yang benar pada enam kegiatan (1–4), (3–5), (0–6), (5–7), (3–8), (8–10) — sudah terurut menurut waktu selesai:
| Kegiatan | Mulai | Selesai | waktu sebelum |
Diambil? | waktu sesudah |
|---|---|---|---|---|---|
| 1 | 1 | 4 | −∞ | ya | 4 |
| 2 | 3 | 5 | 4 | tidak | 4 |
| 3 | 0 | 6 | 4 | tidak | 4 |
| 4 | 5 | 7 | 4 | ya | 7 |
| 5 | 3 | 8 | 7 | tidak | 7 |
| 6 | 8 | 10 | 7 | ya | 10 |
Jawabannya 3 kegiatan. Perhatikan kegiatan ke-3 yang paling panjang (0–6): meskipun ia mulai paling awal, mengambilnya justru akan menghalangi dua kegiatan lain.
Buka
exercises/12/03-jadwal.cpp.
Ubah aturan pengurutannya menjadi a.mulai < b.mulai,
lalu jalankan dengan data (0,10), (1,2), (3,4). Jawabannya berubah dari
2 menjadi 1.
sort(keg, keg + n, [](Kegiatan a, Kegiatan b) {
return a.selesai < b.selesai;
});
int banyak = 0;
int waktu = -1000000000; // kapan kegiatan terakhir selesai
for (int i = 0; i < n; i++) {
if (keg[i].mulai >= waktu) {
banyak++;
waktu = keg[i].selesai;
}
}Bentuk [](...) { return ...; } adalah lambda:
fungsi kecil tanpa nama yang diberikan kepada sort sebagai
aturan pengurutan. Untuk buku ini, cukup kenali polanya — kembalikan
true kalau a harus berada sebelum
b.
Struktur Kegiatan dibuat dengan struct:
struct Kegiatan {
int mulai, selesai;
};
Kegiatan keg[100005];struct menggabungkan beberapa nilai menjadi satu,
sehingga saat diurutkan, mulai dan selesai
ikut berpindah bersama. Tanpa itu, mengurutkan dua array terpisah akan
merusak pasangannya.
Contoh: muatan perahu
norang menunggu menyeberang. Berat orang ke-iadalahb[i]. Setiap perahu memuat paling banyak dua orang dan total berat paling banyakk. Berapa perahu paling sedikit?
Greedy yang benar: urutkan berat, lalu pasangkan yang paling ringan dengan yang paling berat. Kalau keduanya muat, berangkatkan berdua; kalau tidak, yang terberat berangkat sendiri.
sort(b, b + n);
int kiri = 0, kanan = n - 1, perahu = 0;
while (kiri <= kanan) {
if (b[kiri] + b[kanan] <= k) kiri++; // yang ringan ikut
kanan--; // yang berat selalu berangkat
perahu++;
}Menelusuri untuk berat 1 2 3 4 5 dan
k = 6:
kiri |
kanan |
b[kiri] + b[kanan] |
Muat? | Perahu |
|---|---|---|---|---|
| 0 (1) | 4 (5) | 6 | ya | 1 |
| 1 (2) | 3 (4) | 6 | ya | 2 |
| 2 (3) | 2 (3) | — | sendirian | 3 |
Tiga perahu: {1, 5}, {2, 4}, dan {3} sendiri.
Alasan greedy ini benar: orang terberat pasti berangkat, itu tidak bisa dihindari. Pertanyaannya hanya apakah ia bisa ditemani. Kalau yang paling ringan pun tidak muat bersamanya, tidak ada seorang pun yang muat, jadi ia berangkat sendiri. Kalau yang paling ringan muat, memasangkannya tidak pernah merugikan — orang ringan itu paling mudah dipasangkan dengan siapa pun, jadi "menyimpannya" untuk nanti tidak memberi keuntungan.
Pola "dua penunjuk dari ujung berlawanan" ini muncul di banyak soal greedy.
Buka
exercises/12/04-perahu.cpp
dan hapus baris sort(b, b + n);, lalu jalankan contoh
masukan 5 100 dengan berat 90 20 30 70 50.
Jawabannya berubah dari 3 menjadi 5 perahu: tanpa pengurutan, "dua
penunjuk dari ujung berlawanan" tidak lagi bertemu orang teringan dan
terberat.
Cara meyakinkan diri bahwa greedy benar
Tiga cara, dari yang paling cepat sampai yang paling meyakinkan:
- Cari contoh penyangkal. Coba data kecil yang aneh: dua elemen sama, satu elemen sangat besar, semua elemen sama. Satu contoh yang gagal sudah cukup untuk membuang rencana itu.
- Uji dengan pembanding. Tulis brute force yang
mencoba semua kemungkinan untuk
n ≤ 8, lalu bandingkan pada ribuan data acak (Bab 10). Ini cara paling praktis saat lomba. - Argumen penukaran. Tunjukkan bahwa jawaban terbaik mana pun bisa diubah sedikit demi sedikit menjadi jawaban greedy tanpa memperburuknya. Inilah pembuktian sungguhan, dan itulah yang dilakukan pada penjadwalan kegiatan di atas.
Di lomba, urutan yang benar adalah: duga greedy-nya, uji dengan pembanding, baru tulis versi cepatnya. Mengirim greedy yang belum diuji adalah cara tercepat kehilangan seluruh nilai satu soal.
Pekerjaan rumah
Soal 1 — Uang kembalian
Buka
exercises/12/homework/01-kembalian.cpp.
Baca k (kelipatan 1.000, sampai 10⁹), cetak jumlah lembar
uang paling sedikit memakai pecahan 100.000, 50.000, 20.000, 10.000,
5.000, 2.000, dan 1.000.
Soal 2 — Penjadwalan kegiatan
Buka
exercises/12/homework/02-jadwal.cpp.
Baca n (≤ 200.000), lalu n pasang
mulai dan selesai. Cetak jumlah kegiatan
terbanyak yang bisa diikuti. Kegiatan yang mulai tepat saat kegiatan
lain selesai boleh diambil.
Soal 3 — Muatan perahu
Buka
exercises/12/homework/03-perahu.cpp.
Baca n dan k, lalu n berat. Cetak
jumlah perahu paling sedikit dengan aturan di bab ini (maksimal dua
orang, total berat maksimal k). Dijamin setiap orang muat
sendirian.
Soal 4 — Greedy yang gagal
Buka
exercises/12/homework/04-greedy-gagal.cpp.
Pecahan koinnya {1, 3, 4}. Tulis dua fungsi:
greedy(k) yang mengambil koin terbesar lebih dulu, dan
terbaik(k) yang mencoba semua kemungkinan secara rekursif.
Cetak semua k dari 1 sampai 30 yang hasil keduanya
berbeda.
Tantangan — Ukuran pengurutan yang salah
Buka
exercises/12/homework/05-uji-greedy.cpp.
Untuk soal penjadwalan kegiatan, tulis tiga fungsi: greedy menurut
waktu mulai paling awal, greedy menurut durasi
terpendek, dan brute force yang mencoba semua himpunan bagian
(n ≤ 8). Cari, dengan data acak, contoh kasus terkecil yang
membuat masing-masing greedy salah. Tulis contoh kasus itu sebagai
komentar.
Kesalahan yang paling sering
| Kesalahan | Akibatnya |
|---|---|
| greedy dikirim tanpa diuji pembanding | nol untuk seluruh soal |
| diuji hanya dengan contoh kasus di soal | contoh kasus hampir selalu lolos |
| dua array terpisah diurutkan sendiri-sendiri | pasangan datanya rusak; pakai struct |
| mengurutkan menurut ukuran yang salah | benar untuk sebagian data saja |
| lupa mengurutkan sama sekali | jawaban acak, tetapi tidak selalu terlihat salah |
batas awal waktu = 0 padahal data bisa negatif |
kegiatan pertama terlewat |
Tips OSN
Greedy hampir selalu berarti "urutkan dulu". Pertanyaan sesungguhnya bukan "apakah ini greedy", melainkan "diurutkan menurut apa". Empat urutan di bawah menutupi sebagian besar soal greedy di tingkat awal:
| Kalimat pada soal | Urutkan menurut |
|---|---|
| sebanyak mungkin kegiatan tanpa bertabrakan | waktu selesai, naik |
| total waktu tunggu sekecil mungkin | lama layanan, naik |
| beban terberat sekecil mungkin | berat, lalu pasangkan ujung ke ujung |
| titik/penanda sesedikit mungkin untuk semua selang | ujung kanan, naik |
Yakinkan diri dengan argumen penukaran. Bentuknya selalu sama: andaikan ada jawaban terbaik yang urutannya berbeda dari pilihan greedy; tukar dua bagian yang berdekatan; tunjukkan hasilnya tidak menjadi lebih buruk. Kalau penukaran itu selalu bisa dilakukan, pilihan greedy pasti termasuk salah satu jawaban terbaik. Argumen sependek itu sudah cukup untuk membedakan greedy yang benar dari greedy yang kebetulan lolos contoh.
Uji greedy dengan brute force sebelum mengirim.
Untuk N ≤ 10, jawaban brute force pasti benar. Jalankan
keduanya pada ratusan masukan acak kecil dan bandingkan dengan
diff. Greedy yang salah biasanya gugur pada masukan acak
kelima atau keenam — jauh sebelum kamu kehilangan waktu di ruang
lomba.
Kalau greedy gagal, biasanya soalnya DP. Ciri yang sering muncul: pilihan sekarang mempengaruhi pilihan yang tersedia nanti dengan cara yang tidak bisa diringkas menjadi satu angka. Bab 13 membahas penggantinya.
Perhatikan perbedaan "sebanyak mungkin" dan "sebesar mungkin". Memilih kegiatan terbanyak diurutkan menurut waktu selesai; memilih kegiatan dengan total nilai terbesar tidak bisa greedy dan memang soal DP. Satu kata pada soal mengubah seluruh pendekatannya.
Jangan mengurutkan data yang urutannya bagian dari
soal. Kalau jawaban menuntut nomor urut aslinya, simpan nomor
itu bersama datanya sebelum mengurutkan — misalnya sebagai
pair. Kehilangan nomor asli adalah kesalahan yang baru
ketahuan setelah seluruh kode selesai.
Greedy yang lolos contoh soal sama sekali bukan bukti. Contoh pada soal dipilih untuk menjelaskan aturan, bukan untuk menjebak. Soal OSN yang memuat jebakan greedy hampir selalu menyimpannya di berkas uji, bukan di contoh.
Ringkasan
- Greedy mengambil pilihan terbaik saat ini dan tidak pernah mundur;
bentuk bakunya
sortlalu satu penelusuran, O(n log n). - Seluruh kesulitannya ada pada satu pertanyaan: diurutkan menurut apa.
- Greedy yang benar untuk satu kumpulan data bisa salah untuk kumpulan data lain — kebenarannya milik datanya, bukan bentuk soalnya.
- Tiga cara memeriksanya: cari contoh penyangkal kecil, uji dengan pembanding brute force pada ribuan data acak, dan argumen penukaran.
- Contoh baku yang perlu dikenali: uang kembalian, penjadwalan kegiatan (urut menurut waktu selesai), dan muatan perahu (dua penunjuk dari ujung berlawanan).
- Kalau greedy terbukti salah dan tidak ada ukuran pengurutan lain yang bertahan, yang dibutuhkan adalah dynamic programming di Bab 13.
Latihan bergaya OSN
Ketiganya diselesaikan dengan satu kali pengurutan disusul satu kali baca. Yang menentukan benar-salahnya adalah apa yang dijadikan kunci urut, jadi tuliskan dulu argumen penukarannya sebelum mengetik.
Kerangka ketiganya ada di
exercises/12/osn/.
Soal 1 — Antrian Layanan
Sebuah loket hanya bisa melayani satu orang pada satu waktu. Pagi itu
N pelanggan sudah hadir semua sejak loket dibuka, dan
pelanggan ke-i memerlukan Tᵢ menit untuk
dilayani. Petugas boleh memanggil pelanggan dalam urutan mana pun.
Waktu tunggu seorang pelanggan adalah lamanya ia menunggu sejak loket dibuka sampai giliran layanannya dimulai — tidak termasuk lama layanannya sendiri. Tentukan total waktu tunggu terkecil yang bisa dicapai.
Format masukan. Baris pertama berisi bilangan bulat
N. Baris kedua berisi N bilangan bulat
T₁ T₂ … T_N.
Format keluaran. Satu baris berisi total waktu tunggu terkecil.
Batasan.
- 1 ≤
N≤ 200.000 - 1 ≤
Tᵢ≤ 10.000
Contoh masukan
3
5 1 3
Contoh keluaran
5
Penjelasan. Urutan terbaik adalah 1, 3, 5. Pelanggan pertama menunggu 0 menit, pelanggan kedua 1 menit, pelanggan ketiga 1 + 3 = 4 menit. Totalnya 5. Urutan 5, 3, 1 memberi total 0 + 5 + 8 = 13.
Kerangkanya:
exercises/12/osn/01-antrian-layanan.cpp
Soal 2 — Titik Pemeriksaan
Sepanjang sebuah jalan lurus terdapat N ruas yang harus
diawasi. Ruas ke-i membentang dari kilometer
Lᵢ sampai kilometer Rᵢ, termasuk kedua
ujungnya.
Panitia akan mendirikan pos pemeriksaan pada titik-titik kilometer bulat. Sebuah ruas dianggap terawasi kalau ada sedikitnya satu pos yang berdiri di dalam ruas itu. Tentukan banyaknya pos paling sedikit yang diperlukan agar semua ruas terawasi.
Format masukan. Baris pertama berisi bilangan bulat
N. N baris berikutnya masing-masing berisi dua
bilangan bulat Lᵢ dan Rᵢ.
Format keluaran. Satu baris berisi banyaknya pos paling sedikit.
Batasan.
- 1 ≤
N≤ 200.000 - 1 ≤
Lᵢ≤Rᵢ≤ 10⁹
Contoh masukan
4
1 3
2 5
4 7
6 9
Contoh keluaran
2
Penjelasan. Satu pos di kilometer 3 mengawasi ruas pertama dan kedua. Satu pos lagi di kilometer 7 mengawasi ruas ketiga dan keempat. Dua pos tidak bisa dikurangi lagi, karena ruas pertama dan ruas keempat tidak punya kilometer yang sama.
Kerangkanya:
exercises/12/osn/02-titik-pemeriksaan.cpp
Soal 3 — Pasangan Beban
Ada 2N barang yang harus diangkut dengan N
gerobak. Setiap gerobak memuat tepat dua barang. Beban sebuah gerobak
adalah jumlah berat kedua barang di dalamnya.
Panitia ingin gerobak yang paling berat sekecil mungkin. Tentukan beban gerobak terberat pada susunan terbaik.
Format masukan. Baris pertama berisi bilangan bulat
N. Baris kedua berisi 2N bilangan bulat, berat
setiap barang.
Format keluaran. Satu baris berisi beban gerobak terberat yang paling kecil.
Batasan.
- 1 ≤
N≤ 100.000 - 1 ≤ berat ≤ 10⁹
Contoh masukan
3
3 5 2 8 1 4
Contoh keluaran
9
Penjelasan. Susunan terbaik memasangkan 1 dengan 8 (beban 9), 2 dengan 5 (beban 7), dan 3 dengan 4 (beban 7). Gerobak terberatnya 9. Susunan lain seperti (8, 5), (4, 3), (2, 1) menghasilkan gerobak terberat 13.
Kerangkanya:
exercises/12/osn/03-pasangan-beban.cpp
Bingung atau sudah selesai? Buka halaman pembahasan PR.