Teknik Rekursi Di Struktur Data & Algoritma
Rekursi (recursion) adalah sebuah teknik pemograman dimana sebuah function memanggil dirinya sendiri. Walaupun jarang dipakai di pemograman tingkat tinggi seperti aplikasi web, rekursi merupakan fondasi yang penting dan sering dijumpai di persoalan struktur data & algoritma (DS&A). Sebagai contoh, dynamic programming merupakan salah satu bentuk penerapan rekursi dan struktur data tree adalah struktur data yang sering ditelusuri secara rekursif.
Salah satu kendala yang saya jumpai saat mempelajari teknik rekursif adalah sulitnya melakukan visualiasi pemanggilan. Untuk itu, saya membuat Visual Recursion dengan harapan supaya saya bisa lebih memahami teknik rekursif.
Rekursi
Pada matematika, contoh klasik rekursi adalah deretan Fibonacci yang didefinisikan sebagai berikut ini:
Walaupun demikian, implementasi-nya di kode program tidak selalu harus lewat rekursi karena function di pemograman memiliki lebih banyak fitur dibandingkan dengan function matematika. Sebagai contoh, ini adalah contoh implementasi function yang mencari angka Fibonacci pada posisi tertentu melalui loop (secara iteratif):
Secara umum, kode program versi iteratif memiliki performance yang lebih baik dibandingkan dengan versi rekursif. Walaupun demikian, biasanya versi rekursif lebih mudah dipahami dibandingkan dengan versi iteratif. Contohnya adalah Ackermann function yang didefinisikan seperti berikut ini:
Function tersebut akan mudah dipahami bisa ditulis melalui pemanggilan rekursif seperti pada contoh kode program berikut ini:
Memoization
Salah satu cara untuk meningkatkan kinerja rekursi adalah dengan menggunakan memoization. Implementasinya lumayan sederhana, cukup simpan hasil kembalian function untuk argumen tertentu sehingga bila nanti ada pemanggilan dengan argumen yang sama, tidak perlu dihitung lagi. Sama seperti bentuk caching lainnya, walaupun kecepatan eksekusi meningkat, memoization akan menambah penggunaan memori.
Sebagai contoh, ini adalah versi kode program yang menghitung Ackermann function yang sudah menggunakan memoization:
Bila dilihat dari call tree yang dihasilkan, sekarang terlihat jauh lebih singkat dari versi sebelumnya. Versi tanpa memoization melibatkan 43 kali pemanggilan function. Bandingkan dengan versi memoization yang hanya melibatkan 22 kali pemanggilan function.
Karena Map
hanya mendukung sebuah key tunggal, saya menggabungkan m
dan n
dalam bentuk string. Bila ingin kinerja yang lebih baik lagi, key sebaiknya dalam bentuk sebuah angka. Saya bisa menghasilkan sebuah angka unik dari dua angka yang berbeda dengan menggunakan Cantor pairing function yang didefinisikan seperti berikut ini:
dimana .
Versi kode programnya terlihat seperti berikut ini:
Tail Recursion
Pada saat sebuah function dipanggil, sistem operasi akan menyimpan informasi seperti posisi kembali di sebuah memori sementara yang disebut stack. Bila function tersebut memanggil function lain lagi, informasi posisi saat ini akan ditambahkan ke stack, dan begitu seterusnya. Memori stack ini biasanya terbatas, misalnya area stack secara bawaan adalah 8 MB di Linux dan 1 MB di Windows.
Bila sebuah function memanggil function yang kemudian memanggil function lain dan kemudian memanggil function lain yang kemudian memanggil function lain dan seterusnya hingga area stack menjadi penuh, program akan mengalami kesalahan. Jenis kesalahan seperti ini disebut sebagai stack overflow. Rekursi sangat rentan terhadap kesalahan stack overflow karena banyaknya pemanggil function yang terjadi.
Bila function rekursif yang dipanggil merupakan kode program paling terakhir yang dikerjakan di sebuah function, ia disebut sebagai tail recursion. Salah satu kondisi unik pada tail recursion adalah sebenarnya tidak perlu menggunakan stack untuk mengingat posisi kembali terakhir karena ia bisa langsung kembali ke function sebelum-sebelumnya. Dengan demikian, tail recursion dapat dioptimalisasikan menjadi seperti iterasi tidak akan terjadi stack overflow lagi. Namun, tidak semua compiler atau interpreter mendukung fitur ini. Berdasarkan informasi di artikel Wikipedia, tidak semua browser JavaScript mendukung tail call optimization (terbatas di Safari/WebKit).
Sebagai contoh, berikut ini adalah kode program yang membalikkan string secara rekursif:
Sementara itu, berikut ini adalah versi yang menggunakan tail recursion:
Biarpun sama berakhir dengan return solve()
di baris terakhir, kode program pertama tidak menerapkan tail recursion. Hal ini karena return solve(...) + s[0]
dimana hasil kembalian pemanggilan rekursif solve(...)
perlu ditambahkan lagi dengan s[0]
. Ini menunjukkan masih ada yang perlu dilakukan setelah pemanggilan rekursif selesai! Sementara itu, pada program versi kedua, baris terakhir murni return solve(...)
tanpa tambahan apa2 sehingga ini adalah tail recursion.
Algoritma Divide and Conquer
Divice and conquer adalah jenis algoritma rekursi yang memecahkan permasalahan menjadi permasalahan kecil untuk diselesaikan, kemudian hasil-nya digabungkan kembali untuk mendapatkan solusi permasalahan tersebut. Salah satu contoh algoritma divide and conquer yang populer adalah quick sort. Berikut ini adalah contoh kode program quick sort:
Pada quick sort, terlihat bahwa array yang akan diurutkan dipecah berdasarkan pivot di posisi yang acak. Setelah mencapai ukuran paling kecil, array-array yang kecil ini digabungkan menjadi satu per satu hingga menjadi kembali seperti array awal tetapi kini dengan posisi yang sudah berurut.
Backtracking
Backtracking adalah variasi dari algoritma rekursi yang memungkinkan kode program untuk berhenti dari proses rekursi di saat kondisi sudah tidak valid lagi. Ini adalah bentuk optimalisasi dari pendekatan mencoba brute force semua kombinasi yang ada. Salah satu persoalan yang paling sering dijumpai untuk backtracking adalah N-queen puzzle. Bagaimana cara-nya meletakkan ratu di papan catur dengan ukuran dimana mereka tidak saling menyerang satu sama lain?
Pada kode program di atas, begitu isSafe()
mengembalikan false
(posisi tidak valid), maka posisi ratu saat ini akan dihapus dan proses rekursi untuk cabang ini tidak akan diteruskan.
Dynamic Programming
Dynamic programming hampir sama seperti divide and conquer yang memecahkan sebuah permasalahan menjadi masalah-masalah kecil. Perbedaannya, pada dynamic programming, permasalahan kecil ini saling berhubungan satu dengan yang lainnya, sementara pada divide and conquer, setiap permasalahan kecil masing-masing berdiri sendiri.
Salah satu contoh dynamic programming adalah longest common subsequence (LCS). LCS dipakai untuk mencari bagian dari string yang sama tetapi tidak perlu berurut. Penerapannya adalah untuk menampilkan diff
, revisi seperti di git
, dan sebagainya. Definisi LCS secara matematika adalah:
dimana string yang akan dibandingkan adalah dan . Nilai adalah 0, 1, 2 karakter pertama dari dan seterusnya. Nilai adalah 0, 1, 2 karakter pertama dari dan seterusnya. Nilai mewakili string kosong. Operator dipakai untuk menggabungkan string.
Dynamic programming bisa dilakukan secara top down (rekursif) ataupun bottom up (iteratif). Teknik bottom up biasanya memakai tabel atau array yang perlu di-isi (disebut juga tabulation). Sebagai contoh, saya bisa membuat tabel untuk rumus matematika di atas dengan kode program seperti berikut ini:
Tabel yang dihasilkan untuk "jocki"
dan "jokowi"
akan terlihat seperti berikut ini:
Tabel di atas memperlihatkan LCS untuk setiap prefix mulai dari 0 hingga keseluruhan. Sebagai contoh, jumlah LCS joc
dan jok
adalah 2
, jumlah LCS jock
dan joko
adalah 3
. Dan begitu seterusnya. Untuk keseluruhan kata, jumlah LCS jocki
dan jokowi
yang berada di pojok kanan bawah adalah 4
.
Untuk menelusuri setiap perbedaan yang ada di matrix LCS di atas, saya dapat menggunakan teknik rekursi seperti pada kode program berikut ini:
Output di atas memperlihatkan bahwa "jocki"
bisa berubah menjadi "jokowi"
bila huruf c
dihapus dan ow
ditambahkan.