Category: Struktur Data

  • Cara Kerja Slice Go: Append, Kapasitas, dan Memori

    Cara Kerja Slice Go: Append, Kapasitas, dan Memori

    Cara kerja slice Golang sebenarnya sederhana: slice bukan array, melainkan struct kecil berisi tiga hal, yaitu pointer ke array di belakangnya, length (jumlah elemen terpakai), dan capacity (ukuran array yang dialokasikan). Saat kamu memanggil append, Go menulis elemen baru ke array itu selama kapasitas masih cukup. Begitu penuh, Go mengalokasikan array baru yang lebih besar, menyalin semua elemen lama, lalu mengembalikan slice yang menunjuk ke array baru tersebut.

    Di artikel ini kita bongkar mekanisme itu satu per satu dengan kode yang bisa kamu jalankan sendiri: melihat isi slice header, mengamati kapan alokasi baru terjadi, membuktikan lewat benchmark kenapa pre-alokasi penting, sampai membuat dynamic array versi sendiri. Artikel ini bagian kelima dari seri Belajar Struktur Data dari Nol. Kalau kamu belum paham beda array dan slice di level pemakaian, baca dulu bagian sebelumnya: Array dan Slice di Go: Struktur Data Paling Dasar.

    Semua kode di artikel ini dites dengan Go 1.23.1 di Linux dan berperilaku sama di Go 1.24. Jalankan go version untuk memastikan versimu minimal Go 1.20, karena kita memakai fungsi unsafe.SliceData yang baru ada sejak versi itu.

    Anatomi Slice Header: Pointer, Length, dan Capacity

    Di dalam runtime Go, slice direpresentasikan seperti ini:

    type sliceHeader struct {
        Data uintptr // alamat array di belakang slice
        Len  int     // jumlah elemen yang sedang dipakai
        Cap  int     // total slot yang tersedia di array
    }

    Struct ini ringan, hanya 24 byte di mesin 64-bit. Itu sebabnya slice murah untuk dioper antar fungsi: yang disalin cuma header-nya, bukan seluruh data. Kita bisa mengintip ketiga komponen ini dengan len(), cap(), dan unsafe.SliceData yang mengembalikan pointer ke elemen pertama array:

    package main
    
    import (
    	"fmt"
    	"unsafe"
    )
    
    func main() {
    	s := make([]int, 3, 8)
    	fmt.Println("len:", len(s), "cap:", cap(s))
    	fmt.Printf("alamat array s: %p\n", unsafe.SliceData(s))
    
    	t := s[1:3]
    	fmt.Println("len t:", len(t), "cap t:", cap(t))
    	fmt.Printf("alamat array t: %p\n", unsafe.SliceData(t))
    }

    Output di mesin saya (alamat pasti berbeda di mesinmu):

    len: 3 cap: 8
    alamat array s: 0xc00001e1c0
    len t: 2 cap t: 7
    alamat array t: 0xc00001e1c8

    Perhatikan dua hal. Pertama, alamat t hanya bergeser 8 byte dari s. Artinya t tidak punya data sendiri, ia menunjuk ke array yang sama, mulai dari elemen index 1. Ukuran int di mesin 64-bit memang 8 byte. Kedua, cap(t) jadi 7, bukan 8, karena capacity dihitung dari posisi awal slice sampai ujung array. Fakta bahwa dua slice bisa berbagi satu array inilah sumber dari hampir semua bug slice yang akan kita bahas nanti.

    Kapan append Memicu Alokasi Baru di Go 1.24

    Sekarang kita amati langsung kapan Go mengalokasikan array baru. Caranya gampang: append terus menerus, dan cetak setiap kali nilai cap berubah. Perubahan cap menandakan array lama sudah diganti array baru yang lebih besar.

    package main
    
    import "fmt"
    
    func main() {
    	var s []int
    	lastCap := -1
    	for i := 0; i < 600; i++ {
    		s = append(s, i)
    		if cap(s) != lastCap {
    			fmt.Printf("len=%-4d cap=%d\n", len(s), cap(s))
    			lastCap = cap(s)
    		}
    	}
    }

    Outputnya:

    len=1    cap=1
    len=2    cap=2
    len=3    cap=4
    len=5    cap=8
    len=9    cap=16
    len=17   cap=32
    len=33   cap=64
    len=65   cap=128
    len=129  cap=256
    len=257  cap=512
    len=513  cap=848

    Polanya terbaca jelas. Selama kapasitas masih di bawah 256, Go menggandakan kapasitas setiap kali penuh: 1, 2, 4, 8, dan seterusnya. Setelah melewati 256 elemen, pertumbuhan melambat ke kisaran 1,25 sampai 1,6 kali, makanya dari 512 lompat ke 848 dan bukan 1024. Angka 848 juga bukan hasil perkalian bulat karena runtime membulatkan ukuran alokasi ke kelas ukuran (size class) milik allocator Go supaya memori tidak terbuang. Detail angka ini bisa sedikit bergeser antar versi Go, tapi pola besarnya sama di Go 1.23 dan 1.24: gandakan saat kecil, tumbuh lebih hemat saat besar.

    Yang penting dipahami: di baris len=513 cap=848, Go baru saja menyalin 512 elemen ke array baru. Kalau slice-mu berisi jutaan elemen, satu kali append yang kebetulan memicu pertumbuhan berarti menyalin jutaan elemen juga.

    Dynamic Array dan Amortized O(1): Kenapa append Rata-Rata Murah

    Slice Go adalah implementasi dari struktur data klasik bernama dynamic array: array yang bisa tumbuh dengan cara realokasi dan salin. Kalau sesekali append harus menyalin semua elemen, kenapa append tetap disebut O(1)?

    Jawabannya ada di analisis amortized. Misalkan kita append 8 elemen mulai dari slice kosong dengan strategi gandakan kapasitas:

    Append ke- Kapasitas sebelum Perlu salin? Elemen disalin
    1 0 ya 0
    2 1 ya 1
    3 2 ya 2
    4 4 tidak 0
    5 4 ya 4
    6 sampai 8 8 tidak 0

    Total operasi salin untuk 8 append adalah 7. Kalau diteruskan ke n append, total salinnya selalu di bawah 2n. Dibagi rata ke semua operasi, tiap append menanggung biaya konstan, kurang dari 2 salinan per operasi. Itulah maksud amortized O(1): sesekali ada operasi mahal O(n), tapi rata-ratanya tetap konstan karena operasi mahal itu makin jarang terjadi seiring kapasitas membesar. Konsep menghitung biaya seperti ini sudah kita kenal dari bagian ketiga seri tentang notasi Big O.

    Benchmark: append Tanpa make vs Dengan make

    Amortized O(1) bukan berarti gratis. Realokasi tetap memakan waktu dan menyampah ke garbage collector. Kalau kamu sudah tahu jumlah elemen dari awal, pre-alokasi dengan make([]int, 0, n) menghapus semua realokasi itu. Kita buktikan dengan benchmark resmi Go. Simpan sebagai append_test.go di folder yang sudah punya go.mod:

    package main
    
    import "testing"
    
    const n = 10000
    
    func BenchmarkAppendTanpaMake(b *testing.B) {
    	for i := 0; i < b.N; i++ {
    		var s []int
    		for j := 0; j < n; j++ {
    			s = append(s, j)
    		}
    	}
    }
    
    func BenchmarkAppendDenganMake(b *testing.B) {
    	for i := 0; i < b.N; i++ {
    		s := make([]int, 0, n)
    		for j := 0; j < n; j++ {
    			s = append(s, j)
    		}
    	}
    }

    Jalankan dengan:

    go test -bench=. -benchmem

    Hasil di laptop saya (Intel i5-10310U, Go 1.23.1):

    BenchmarkAppendTanpaMake-8     10000    102632 ns/op    357625 B/op    19 allocs/op
    BenchmarkAppendDenganMake-8    81643     13500 ns/op     81920 B/op     1 allocs/op

    Bacaannya begini. Tanpa make, mengisi 10 ribu elemen butuh 19 kali alokasi dan total 357 ribu byte, karena setiap kali tumbuh Go membuat array baru dan array lama jadi sampah. Dengan pre-alokasi, cuma ada 1 alokasi sebesar 81920 byte (10 ribu kali 8 byte, dibulatkan ke size class) dan waktunya sekitar 7 kali lebih cepat. Angkamu pasti beda, tapi rasionya akan mirip.

    Di backend Go untuk sistem aplikasi klien yang tim Arrazy kerjakan, pola ini paling sering muncul saat mengubah hasil query database menjadi slice DTO. Jumlah baris sudah diketahui, jadi pre-alokasi jadi kebiasaan standar, murah ditulis dan efeknya nyata di endpoint yang dipanggil ribuan kali.

    Dua Kesalahan Umum Seputar Slice yang Sering Jadi Bug

    Memory leak karena slicing potongan kecil dari slice besar

    Ingat, slice hasil re-slicing berbagi array dengan slice asalnya. Kode seperti ini terlihat tidak berdosa:

    func ambilHeader(fileBesar []byte) []byte {
    	return fileBesar[:100] // BUG: menahan seluruh file di memori
    }

    Slice yang dikembalikan memang cuma 100 byte panjangnya, tapi pointer-nya masih menunjuk ke array asli yang mungkin puluhan megabyte. Selama slice kecil itu hidup, garbage collector tidak bisa membebaskan seluruh array. Kalau fungsi ini dipanggil untuk banyak file, memori server naik terus tanpa kelihatan sebabnya di kode. Solusinya salin datanya supaya array besar bisa dilepas:

    func ambilHeader(fileBesar []byte) []byte {
    	header := make([]byte, 100)
    	copy(header, fileBesar[:100])
    	return header
    }

    Sejak Go 1.21 kamu juga bisa memakai slices.Clone(fileBesar[:100]) dari package slices, hasilnya sama.

    append di dalam fungsi tidak mengubah slice pemanggil

    Karena yang dioper ke fungsi adalah salinan slice header, append di dalam fungsi hanya mengubah salinan itu:

    package main
    
    import "fmt"
    
    func tambahSalah(s []int) {
    	s = append(s, 99)
    }
    
    func tambahBenar(s []int) []int {
    	return append(s, 99)
    }
    
    func main() {
    	data := make([]int, 0, 4)
    	data = append(data, 1, 2)
    
    	tambahSalah(data)
    	fmt.Println("setelah tambahSalah:", data)
    
    	data = tambahBenar(data)
    	fmt.Println("setelah tambahBenar:", data)
    }

    Output:

    setelah tambahSalah: [1 2]
    setelah tambahBenar: [1 2 99]

    Di tambahSalah, elemen 99 sebenarnya sempat tertulis ke array (kapasitasnya masih cukup), tapi length milik pemanggil tetap 2, jadi elemen itu tidak pernah terlihat. Aturannya sederhana: fungsi yang melakukan append harus mengembalikan slice hasilnya, persis seperti append bawaan Go sendiri. Alternatifnya terima parameter *[]int, tapi gaya return lebih umum dan lebih enak dibaca.

    Latihan: Membuat Dynamic Array Sendiri di Go

    Cara terbaik memahami apa yang Go lakukan di balik layar adalah menirunya. Kita buat dynamic array sederhana: struct dengan array mentah, length manual, dan fungsi grow yang menggandakan kapasitas persis seperti perilaku slice kecil.

    package main
    
    import "fmt"
    
    type DynamicArray struct {
    	data []int // dipakai sebagai array mentah, tidak pernah di-append
    	len  int
    }
    
    func NewDynamicArray() *DynamicArray {
    	return &DynamicArray{data: make([]int, 1)}
    }
    
    func (d *DynamicArray) grow() {
    	newData := make([]int, len(d.data)*2)
    	copy(newData, d.data)
    	d.data = newData
    }
    
    func (d *DynamicArray) Push(v int) {
    	if d.len == len(d.data) {
    		d.grow()
    	}
    	d.data[d.len] = v
    	d.len++
    }
    
    func (d *DynamicArray) Get(i int) int {
    	if i < 0 || i >= d.len {
    		panic("index di luar batas")
    	}
    	return d.data[i]
    }
    
    func (d *DynamicArray) Len() int { return d.len }
    func (d *DynamicArray) Cap() int { return len(d.data) }
    
    func main() {
    	arr := NewDynamicArray()
    	for i := 1; i <= 10; i++ {
    		arr.Push(i * 10)
    		fmt.Printf("push ke-%d, len=%d cap=%d\n", i, arr.Len(), arr.Cap())
    	}
    	fmt.Println("elemen index 7:", arr.Get(7))
    }

    Output:

    push ke-1, len=1 cap=1
    push ke-2, len=2 cap=2
    push ke-3, len=3 cap=4
    push ke-4, len=4 cap=4
    push ke-5, len=5 cap=8
    push ke-6, len=6 cap=8
    push ke-7, len=7 cap=8
    push ke-8, len=8 cap=8
    push ke-9, len=9 cap=16
    push ke-10, len=10 cap=16
    elemen index 7: 80

    Bandingkan dengan output eksperimen append di atas: pola pertumbuhannya identik untuk ukuran kecil. Tiga komponen yang kamu kelola manual di sini, yaitu data, len, dan kapasitas, persis tiga field slice header. Sebagai tantangan tambahan, coba tambahkan method Pop(), Set(i, v), dan aturan pertumbuhan 1,25 kali setelah kapasitas 256 supaya makin mirip runtime Go.

    Troubleshooting: Error yang Sering Dialami Pemula

    panic: runtime error: slice bounds out of range

    Muncul saat re-slicing melebihi capacity, misalnya s[:10] padahal cap(s) hanya 8. Ingat batas re-slicing adalah capacity, bukan length, jadi s[:5] pada slice dengan len 3 dan cap 8 itu sah. Cek dulu dengan cap(s) sebelum memperlebar slice, atau tangani panjang data secara eksplisit.

    undefined: unsafe.SliceData

    Fungsi ini baru ada di Go 1.20. Kalau muncul error ini, versi Go-mu terlalu lama, cek dengan go version lalu upgrade lewat go.dev/doc/install. Pastikan juga baris go di go.mod tidak memaksa versi bahasa lama.

    go test -bench tidak menjalankan apa pun atau "no test files"

    Dua penyebab paling umum: nama file tidak diakhiri _test.go, atau nama fungsi tidak diawali Benchmark dengan huruf besar setelahnya. Pastikan juga kamu menjalankan perintah di folder yang berisi file test dan sudah ada go.mod (kalau belum, jalankan go mod init namamodul sekali).

    Dua variabel slice saling menimpa data

    Gejalanya: kamu append ke slice B, tiba-tiba isi slice A ikut berubah. Ini terjadi karena B hasil re-slicing dari A dan keduanya masih berbagi array, lalu append B menulis ke slot yang masih dalam jangkauan A. Solusinya pisahkan kepemilikan data dengan slices.Clone, atau pakai full slice expression a[low:high:max] untuk membatasi capacity sehingga append berikutnya dipaksa mengalokasikan array baru.

    Rangkuman dan Lanjutan Seri

    Sekarang kamu tahu isi perut slice: header kecil berisi pointer, length, dan capacity yang menunjuk ke satu array bersama. Append murah secara amortized, tapi pre-alokasi dengan make tetap layak dibiasakan saat ukuran data diketahui, dan dua jebakan klasiknya, yaitu array bersama dan salinan header, sekarang bisa kamu kenali sebelum jadi bug di production.

    Dynamic array punya satu kelemahan bawaan: sisip atau hapus elemen di tengah tetap O(n) karena semua elemen setelahnya harus digeser. Struktur data berikutnya menjawab masalah itu dengan pendekatan yang sama sekali berbeda. Bagian keenam, Linked List: Konsep dan Implementasi Singly Linked List di Go, terbit menyusul dan bisa kamu pantau di halaman hub Belajar Struktur Data dari Nol.

    Referensi

  • Array dan Slice di Go: Struktur Data Paling Dasar

    Array dan Slice di Go: Struktur Data Paling Dasar

    Perbedaan array dan slice Golang sebenarnya sederhana. Array punya ukuran tetap yang ditentukan saat deklarasi dan bersifat value type, artinya setiap assignment menyalin seluruh isinya. Slice ukurannya dinamis dan hanya berisi referensi ke sebuah array di belakangnya, yang biasa disebut backing array. Karena sifat referensi ini, dua slice bisa menunjuk ke data yang sama, dan di situlah banyak bug diam-diam lahir.

    Artikel ini bagian keempat dari seri Belajar Struktur Data dari Nol. Kita akan bedah kenapa akses indeks array itu O(1) dilihat dari layout memorinya, praktik operasi dasar beserta analisis kompleksitasnya, sampai latihan reverse dan rotate slice tanpa alokasi baru. Semua contoh dites di Go 1.22, tapi berjalan sama di Go 1.18 ke atas.

    Prasyaratnya satu: kamu sudah paham cara membaca kompleksitas algoritma. Kalau notasi O(1) dan O(n) masih terasa asing, baca dulu bagian sebelumnya tentang Big O Notation: Cara Mengukur Kompleksitas Algoritma, karena artikel ini memakai notasi itu terus-menerus.

    Array Adalah Blok Memori Berurutan, Karena Itu Aksesnya O(1)

    Bayangkan array sebagai deretan loker yang menempel rapat di dinding. Saat kamu menulis var a [5]int64, Go memesan satu blok memori utuh sebesar 40 byte: 5 elemen dikali 8 byte per int64. Elemen-elemennya duduk berdampingan tanpa celah.

    Karena elemennya rapat dan ukurannya seragam, alamat elemen ke-i bisa dihitung langsung dengan satu rumus:

    alamat elemen i = alamat awal + (i × ukuran elemen)

    Mau ambil a[0] atau a[4999], prosesor cuma melakukan satu perkalian dan satu penjumlahan, lalu langsung lompat ke alamat itu. Tidak ada proses menyusuri elemen satu per satu. Inilah alasan akses indeks pada array selalu O(1), tidak peduli arraynya berisi 10 atau 10 juta elemen. Bandingkan dengan linked list yang akan kita bahas di bagian 6 nanti, di mana mencari elemen ke-i harus jalan kaki dari kepala list.

    Kita bisa mengintip alamat memorinya langsung:

    package main
    
    import "fmt"
    
    func main() {
    	var a [5]int64
    	for i := range a {
    		fmt.Printf("a[%d] di alamat %p\n", i, &a[i])
    	}
    }

    Output di mesin saya (alamat awal pasti berbeda di mesinmu, tapi polanya sama):

    a[0] di alamat 0xc000012330
    a[1] di alamat 0xc000012338
    a[2] di alamat 0xc000012340
    a[3] di alamat 0xc000012348
    a[4] di alamat 0xc000012350

    Perhatikan selisih tiap alamat persis 8 byte. Blok memorinya benar-benar berurutan. Efek sampingnya juga bagus untuk performa: CPU cache senang dengan data yang berdekatan, sehingga iterasi array atau slice hampir selalu lebih cepat daripada struktur data berbasis pointer.

    Perbedaan Array dan Slice di Go: Ukuran, Semantik, dan Isi Sebenarnya

    Di Go, array dan slice adalah dua tipe yang berbeda, bukan sekadar dua nama untuk hal yang sama seperti di beberapa bahasa lain.

    Aspek Array Slice
    Deklarasi var a [5]int s := []int{1, 2, 3}
    Ukuran Tetap, bagian dari tipe Dinamis, bisa tumbuh lewat append
    Semantik assignment Value type, seluruh isi disalin Header disalin, data tetap dibagi
    Isi di memori Blok data itu sendiri Pointer, length, capacity
    Pemakaian umum Jarang dipakai langsung Default untuk kumpulan data

    [5]int dan [6]int bahkan dianggap dua tipe berbeda oleh compiler. Fungsi yang menerima [5]int tidak bisa diberi [6]int. Ini yang membuat array mentah jarang muncul di kode Go sehari-hari.

    Slice sendiri sebenarnya struct kecil berisi tiga hal: pointer ke backing array, length, dan capacity. Saat kamu mengoper slice ke fungsi, yang disalin cuma struct kecil ini, bukan datanya. Demo berikut memperlihatkan bedanya:

    package main
    
    import "fmt"
    
    func main() {
    	// Array: assignment menyalin seluruh isi
    	arr1 := [3]int{1, 2, 3}
    	arr2 := arr1
    	arr2[0] = 99
    	fmt.Println("arr1:", arr1) // tidak ikut berubah
    	fmt.Println("arr2:", arr2)
    
    	// Slice: assignment hanya menyalin header
    	sl1 := []int{1, 2, 3}
    	sl2 := sl1
    	sl2[0] = 99
    	fmt.Println("sl1:", sl1) // ikut berubah
    	fmt.Println("sl2:", sl2)
    }

    Output:

    arr1: [1 2 3]
    arr2: [99 2 3]
    sl1: [99 2 3]
    sl2: [99 2 3]

    arr1 aman karena arr2 adalah salinan penuh. sl1 ikut berubah karena sl1 dan sl2 menunjuk backing array yang sama. Pahami baris ini baik-baik, karena pola yang sama akan muncul lagi di bagian bug nanti.

    Operasi Dasar pada Slice dan Analisis Big O Tiap Operasi

    Sekarang kita praktikkan empat operasi paling dasar: akses, update, insert di tengah, dan delete di tengah. Ini kombinasi operasi yang di proyek nyata muncul terus, misalnya saat tim Arrazy mengelola daftar item di backend Go untuk sistem aplikasi klien, mayoritas manipulasi datanya berujung pada operasi slice semacam ini.

    Akses dan Update: O(1)

    s := []string{"nasi", "ayam", "sambal"}
    fmt.Println(s[1]) // akses: ayam
    s[1] = "bebek"    // update
    fmt.Println(s[1]) // bebek

    Keduanya O(1). Alasannya sama dengan penjelasan layout memori tadi: alamat elemen dihitung langsung dari rumus, tidak ada penelusuran.

    Insert di Tengah: O(n)

    Slice tidak punya operasi insert bawaan, kita rakit sendiri dari append dan copy:

    package main
    
    import "fmt"
    
    func insertAt(s []int, i int, v int) []int {
    	s = append(s, 0)      // tambah satu ruang di ujung
    	copy(s[i+1:], s[i:])  // geser elemen i sampai akhir, satu langkah ke kanan
    	s[i] = v              // isi lubangnya
    	return s
    }
    
    func main() {
    	s := []int{10, 20, 40, 50}
    	s = insertAt(s, 2, 30)
    	fmt.Println(s)
    }

    Output:

    [10 20 30 40 50]

    Delete di Tengah: O(n)

    func deleteAt(s []int, i int) []int {
    	copy(s[i:], s[i+1:])  // geser elemen setelah i, satu langkah ke kiri
    	return s[:len(s)-1]   // potong ekor yang tersisa
    }

    Panggil deleteAt(s, 2) pada [10 20 30 40 50] dan hasilnya kembali [10 20 40 50]. Sejak Go 1.21 kamu juga bisa memakai slices.Insert dan slices.Delete dari package standar slices, tapi di dalamnya mereka melakukan pergeseran yang sama.

    Ringkasan Kompleksitas

    Operasi Kompleksitas Alasan
    Akses indeks O(1) Alamat dihitung langsung
    Update indeks O(1) Sama, tulis ke alamat hasil hitungan
    Insert di tengah O(n) Semua elemen setelahnya harus digeser
    Delete di tengah O(n) Sama, geser untuk menutup lubang
    Append di ujung O(1) amortized Dibahas tuntas di bagian 5 seri ini

    Kenapa Insert dan Delete di Tengah Itu O(n): Demo Pergeseran Elemen

    Bagian ini penting, jadi kita perjelas dengan gambar teks. Ingat, elemen array duduk rapat di memori. Tidak ada ruang kosong di antara mereka. Kalau mau menyisipkan 30 di posisi indeks 2, satu-satunya cara adalah menggeser semua penghuni dari indeks 2 ke kanan dulu:

    Sebelum:  [10] [20] [40] [50] [ _ ]   <- append menambah ruang di ujung
    
    Geser:    [10] [20] [40] [40] [50]    <- copy(s[3:], s[2:])
                        ^^^^ masih duplikat
    
    Isi:      [10] [20] [30] [40] [50]    <- s[2] = 30

    Dalam kasus terburuk, insert di indeks 0 berarti seluruh n elemen ikut bergeser. Itulah O(n). Delete kebalikannya: elemen di kanan posisi yang dihapus digeser ke kiri untuk menutup lubang.

    Konsekuensi praktisnya: kalau programmu sering insert dan delete di posisi acak pada data yang besar, slice mungkin bukan struktur data yang tepat. Kandidat penggantinya, linked list, akan kita bangun sendiri di bagian 6. Tapi jangan buru-buru juga, untuk data berukuran kecil sampai menengah slice sering tetap menang karena ramah CPU cache.

    Dua Bug Paling Sering: Index Out of Range dan Backing Array yang Dibagi

    Panic Index Out of Range

    Ini panic pertama yang hampir semua pemula Go temui:

    s := []int{1, 2, 3}
    fmt.Println(s[3]) // indeks valid hanya 0, 1, 2
    panic: runtime error: index out of range [3] with length 3

    Indeks valid selalu 0 sampai len(s)-1. Sumber klasiknya: loop dengan kondisi i <= len(s), mengakses s[len(s)] saat mau ambil elemen terakhir, atau mengakses slice kosong hasil query yang tidak dicek dulu.

    Slice yang Berubah Diam-Diam

    Bug kedua lebih halus dan lebih berbahaya karena tidak menghasilkan panic. Operasi slicing seperti s[1:3] tidak menyalin data, ia membuat jendela baru ke backing array yang sama:

    package main
    
    import "fmt"
    
    func main() {
    	harga := []int{1000, 2000, 3000, 4000}
    	promo := harga[1:3] // jendela ke elemen indeks 1 dan 2
    
    	promo[0] = 500 // niatnya cuma ubah data promo
    
    	fmt.Println("promo:", promo)
    	fmt.Println("harga:", harga) // ikut berubah tanpa disadari
    }

    Output:

    promo: [500 3000]
    harga: [1000 500 3000 4000]

    Data harga rusak padahal tidak pernah disentuh langsung. Di aplikasi nyata, bug seperti ini bisa hidup berbulan-bulan sebelum ketahuan. Solusinya: kalau butuh salinan yang benar-benar lepas, salin eksplisit:

    promo := make([]int, 2)
    copy(promo, harga[1:3])
    // atau sejak Go 1.21: promo := slices.Clone(harga[1:3])

    Latihan: Reverse dan Rotate Slice In-Place Tanpa Alokasi Baru

    Dua fungsi ini soal wawancara kerja yang sangat umum, dan keduanya melatih intuisi manipulasi indeks. Syaratnya: in-place, alias tidak boleh membuat slice baru.

    Reverse memakai teknik dua pointer, tukar ujung kiri dan kanan lalu bergerak ke tengah. Rotate memakai trik elegan tiga kali reverse:

    package main
    
    import "fmt"
    
    // reverse membalik slice di tempat, O(n) waktu, O(1) memori tambahan
    func reverse(s []int) {
    	for kiri, kanan := 0, len(s)-1; kiri < kanan; kiri, kanan = kiri+1, kanan-1 {
    		s[kiri], s[kanan] = s[kanan], s[kiri]
    	}
    }
    
    // rotate menggeser semua elemen k posisi ke kanan, in-place
    func rotate(s []int, k int) {
    	if len(s) == 0 {
    		return
    	}
    	k = k % len(s)
    	reverse(s)      // [5 4 3 2 1]
    	reverse(s[:k])  // [4 5 3 2 1]
    	reverse(s[k:])  // [4 5 1 2 3]
    }
    
    func main() {
    	a := []int{1, 2, 3, 4, 5}
    	reverse(a)
    	fmt.Println("reverse:", a)
    
    	b := []int{1, 2, 3, 4, 5}
    	rotate(b, 2)
    	fmt.Println("rotate 2:", b)
    }

    Output:

    reverse: [5 4 3 2 1]
    rotate 2: [4 5 1 2 3]

    Coba telusuri sendiri kenapa tiga reverse menghasilkan rotasi. Tulis kondisi slice setelah tiap langkah di kertas. Kalau kamu bisa menjelaskannya ke orang lain, konsep jendela slice dan manipulasi indeks sudah benar-benar nempel. Perhatikan juga baris k = k % len(s): tanpa itu, rotate(b, 7) pada slice berisi 5 elemen akan panic saat reverse(s[:k]) dipanggil.

    Troubleshooting: Error yang Sering Dialami Pemula

    panic: runtime error: index out of range [n] with length n

    Penyebab: mengakses indeks di luar batas, paling sering s[len(s)]. Solusi: ingat indeks terakhir adalah len(s)-1, dan cek len(s) > 0 sebelum mengakses elemen dari slice yang mungkin kosong. Untuk loop, pakai for i := range s supaya batasnya tidak mungkin salah.

    cannot use arr (variable of type [5]int) as []int value

    Penyebab: mengoper array ke fungsi yang parameternya slice. Array dan slice adalah tipe berbeda di Go. Solusi: ubah array jadi slice dengan slicing kosong, namaFungsi(arr[:]), atau sejak awal deklarasikan sebagai slice kalau memang tidak butuh ukuran tetap.

    Data berubah sendiri padahal tidak pernah diubah langsung

    Penyebab: dua slice berbagi backing array yang sama, biasanya hasil operasi slicing atau assignment slice. Perubahan lewat satu slice terlihat di slice lainnya. Solusi: salin eksplisit dengan copy ke slice baru hasil make, atau pakai slices.Clone di Go 1.21 ke atas, sebelum data dimodifikasi.

    first argument to append must be a slice; have arr (variable of type [5]int)

    Penyebab: memanggil append pada array. Array ukurannya tetap, tidak bisa tumbuh, jadi append hanya menerima slice. Solusi: deklarasikan variabelnya sebagai slice, misalnya s := []int{} alih-alih var s [5]int.

    Rangkuman dan Lanjut ke Bagian Berikutnya

    Tiga hal untuk dibawa pulang. Pertama, array adalah blok memori berurutan, itulah kenapa akses indeksnya O(1) dan insert di tengahnya O(n). Kedua, perbedaan array dan slice Golang ada di ukuran dan semantik: array tetap dan disalin penuh, slice dinamis dan hanya membawa referensi ke backing array. Ketiga, sifat referensi itu pisau bermata dua, hemat memori tapi rawan bug data berubah diam-diam kalau kamu lupa siapa saja yang memegang backing array yang sama.

    Masih ada satu pertanyaan besar yang sengaja belum dijawab: apa yang sebenarnya terjadi saat append dipanggil dan kapasitas slice habis? Kenapa kadang slice hasil append masih terhubung ke slice lama, kadang tidak? Itu jatah bagian kelima, Cara Kerja Slice Go: Append, Kapasitas, dan Memori, yang terbit menyusul. Pantau halaman hub Belajar Struktur Data dari Nol supaya tidak ketinggalan.

    Referensi

  • Big O Notation: Cara Mengukur Kompleksitas Algoritma

    Big O Notation: Cara Mengukur Kompleksitas Algoritma

    Big O notation adalah cara menulis batas atas pertumbuhan jumlah operasi sebuah algoritma ketika ukuran inputnya membesar. Jadi O(n) tidak berarti “algoritma ini butuh n detik”. Artinya, kalau inputnya dikali dua, jumlah operasinya paling banyak ikut naik kira-kira dua kali. Big O mengukur pola pertumbuhan, bukan waktu eksekusi nyata.

    Ini bagian ketiga dari seri Belajar Struktur Data dari Nol. Di Kompleksitas Algoritma: Kenapa Struktur Data Menentukan Performa kita sudah merasakan sendiri bedanya program cepat dan lambat. Sekarang kita kasih nama resmi untuk pola-pola itu, lalu buktikan lewat benchmark Go dengan input 1.000 versus 1.000.000 elemen. Kode di sini dites dengan Go 1.24, tapi jalan di Go 1.22 ke atas.

    Enam Kelas Kompleksitas yang Paling Sering Muncul

    Hampir semua algoritma di seri ini masuk ke salah satu dari enam kelas berikut, urut dari yang paling lambat tumbuh:

    • O(1), konstan. Jumlah operasi tidak peduli ukuran input. Contoh: akses elemen slice lewat indeks.
    • O(log n), logaritmik. Setiap langkah membuang setengah data. Contoh: binary search.
    • O(n), linear. Sentuh setiap elemen sekali. Contoh: menjumlahkan isi slice.
    • O(n log n), linearitmik. Kelas terbaik untuk sorting berbasis perbandingan. Contoh: merge sort, quick sort rata-rata.
    • O(n²), kuadratik. Setiap elemen dibandingkan dengan setiap elemen lain. Contoh: nested loop, bubble sort.
    • O(2ⁿ), eksponensial. Jumlah operasi berlipat dua setiap input bertambah satu. Contoh: rekursi Fibonacci naif.

    Kalau digambar, kurvanya kira-kira begini. Semakin curam, semakin cepat algoritma jadi tidak terpakai untuk data besar:

    operasi
    |            O(2^n)  O(n^2)
    |              |      /     O(n log n)
    |              |     /     /      O(n)
    |             /    /     /    ___/
    |           _/  _/    __/____/     O(log n)
    |      ____/__/______/------------ O(1)
    +---------------------------------> n
    

    Angka membuat bedanya lebih terasa. Perkiraan jumlah operasi untuk tiga ukuran input:

    Kelas n = 10 n = 1.000 n = 1.000.000
    O(1) 1 1 1
    O(log n) 3 10 20
    O(n) 10 1.000 1.000.000
    O(n log n) 33 10.000 20.000.000
    O(n²) 100 1.000.000 1.000.000.000.000
    O(2ⁿ) 1.024 lebih dari jumlah atom di alam semesta tidak masuk akal

    Perhatikan baris O(n²). Di n = 1.000 masih sejuta operasi, selesai dalam hitungan milidetik. Di n = 1.000.000 sudah satu triliun. Itulah kenapa program yang lancar saat demo bisa mati saat data produksi masuk.

    Praktik: Satu Fungsi Go untuk Tiap Kelas Kompleksitas

    Buat folder baru, lalu simpan kode berikut sebagai main.go:

    package main
    
    import "fmt"
    
    // O(1): berapa pun panjang slice, cuma satu operasi
    func Pertama(data []int) int {
    	return data[0]
    }
    
    // O(log n): setiap iterasi membuang setengah data (data harus terurut)
    func BinarySearch(data []int, target int) int {
    	low, high := 0, len(data)-1
    	for low <= high {
    		mid := (low + high) / 2
    		if data[mid] == target {
    			return mid
    		}
    		if data[mid] < target {
    			low = mid + 1
    		} else {
    			high = mid - 1
    		}
    	}
    	return -1
    }
    
    // O(n): sentuh setiap elemen tepat sekali
    func Jumlah(data []int) int {
    	total := 0
    	for _, v := range data {
    		total += v
    	}
    	return total
    }
    
    // O(n^2): nested loop, setiap elemen dibandingkan dengan elemen lain
    func HitungPasanganNol(data []int) int {
    	count := 0
    	for i := 0; i < len(data); i++ {
    		for j := i + 1; j < len(data); j++ {
    			if data[i]+data[j] == 0 {
    				count++
    			}
    		}
    	}
    	return count
    }
    
    // O(2^n): setiap pemanggilan memanggil dirinya dua kali
    func FibNaif(n int) int {
    	if n < 2 {
    		return n
    	}
    	return FibNaif(n-1) + FibNaif(n-2)
    }
    
    func main() {
    	data := []int{-3, -1, 0, 1, 3, 5, 8}
    	fmt.Println(Pertama(data))            // -3
    	fmt.Println(BinarySearch(data, 5))    // 5
    	fmt.Println(Jumlah(data))             // 13
    	fmt.Println(HitungPasanganNol(data))  // 2
    	fmt.Println(FibNaif(10))              // 55
    }
    

    Untuk O(n log n) kita pakai sort.Ints dari pustaka standar, yang implementasinya ada di kelas itu. Merge sort dan quick sort buatan sendiri menyusul di bagian 20 dan 21 seri ini.

    Jalankan dulu untuk memastikan semuanya benar:

    go mod init bigo
    go run main.go
    

    Output yang diharapkan:

    -3
    5
    13
    2
    55
    

    Benchmark Go: Input 1.000 vs 1.000.000

    Sekarang bagian serunya. Kita ukur pola pertumbuhannya pakai fitur benchmark bawaan Go. Simpan sebagai main_test.go di folder yang sama:

    package main
    
    import (
    	"fmt"
    	"math/rand"
    	"sort"
    	"testing"
    )
    
    var hasil int
    
    func buatData(n int) []int {
    	data := make([]int, n)
    	for i := range data {
    		data[i] = rand.Intn(n)
    	}
    	return data
    }
    
    func BenchmarkPertama(b *testing.B) {
    	for _, n := range []int{1000, 1000000} {
    		data := buatData(n)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = Pertama(data)
    			}
    		})
    	}
    }
    
    func BenchmarkBinarySearch(b *testing.B) {
    	for _, n := range []int{1000, 1000000} {
    		data := buatData(n)
    		sort.Ints(data)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = BinarySearch(data, -1)
    			}
    		})
    	}
    }
    
    func BenchmarkJumlah(b *testing.B) {
    	for _, n := range []int{1000, 1000000} {
    		data := buatData(n)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = Jumlah(data)
    			}
    		})
    	}
    }
    
    // Sengaja hanya sampai 10.000. Baca penjelasan di bawah.
    func BenchmarkHitungPasanganNol(b *testing.B) {
    	for _, n := range []int{1000, 10000} {
    		data := buatData(n)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = HitungPasanganNol(data)
    			}
    		})
    	}
    }
    

    Variabel hasil di level package itu penting. Tanpa itu, compiler Go bisa membuang pemanggilan fungsi yang hasilnya tidak dipakai, dan benchmark kamu mengukur ruang kosong.

    Jalankan:

    go test -bench=. -benchtime=1s
    

    Angkanya pasti beda di tiap mesin, tapi polanya akan mirip seperti ini:

    BenchmarkPertama/n=1000-8            1000000000    0.25 ns/op
    BenchmarkPertama/n=1000000-8         1000000000    0.25 ns/op
    BenchmarkBinarySearch/n=1000-8       300000000     4.1 ns/op
    BenchmarkBinarySearch/n=1000000-8    150000000     8.3 ns/op
    BenchmarkJumlah/n=1000-8             4000000       310 ns/op
    BenchmarkJumlah/n=1000000-8          3800          315000 ns/op
    BenchmarkHitungPasanganNol/n=1000-8  3000          410000 ns/op
    BenchmarkHitungPasanganNol/n=10000-8 28            41000000 ns/op
    

    Baca polanya, bukan angkanya. Pertama tidak berubah walau input naik seribu kali lipat, itu wajah O(1). BinarySearch cuma naik sekitar dua kali karena log₂ 1.000 itu 10 dan log₂ 1.000.000 itu 20. Jumlah naik seribu kali, sebanding dengan inputnya. HitungPasanganNol naik seratus kali padahal inputnya cuma naik sepuluh kali, karena 10² = 100.

    Kenapa pasangan nol tidak dites di 1.000.000? Karena itu satu triliun perbandingan per iterasi, benchmark-nya tidak akan selesai dalam waktu masuk akal. FibNaif lebih parah lagi, di n = 50 saja sudah tidak praktis dijalankan.

    Cara Membaca Kode dan Menentukan Big O-nya

    Loop tunggal berarti O(n)

    Satu loop yang menyentuh setiap elemen sekali adalah O(n). Ada dua loop terpisah yang berurutan? Itu O(n + n) = O(2n), yang nanti kita sederhanakan jadi O(n) juga.

    Nested loop berarti kalikan

    Loop di dalam loop berarti jumlah iterasinya dikalikan. Dua loop yang sama-sama jalan n kali menghasilkan O(n × n) = O(n²). Tiga tingkat berarti O(n³). Hati-hati, ini sering menyelinap lewat pemanggilan fungsi: loop yang di dalamnya memanggil fungsi O(n) juga menghasilkan O(n²), walau di kode kelihatannya cuma satu loop.

    Loop yang membagi dua berarti O(log n)

    Kalau variabel loop dikali dua atau dibagi dua setiap iterasi, seperti for i := 1; i < n; i *= 2, jumlah iterasinya log₂ n. Sama dengan pola binary search di atas: ruang pencarian dipangkas setengah setiap putaran.

    Rekursi: hitung cabangnya

    Fungsi rekursif yang memanggil dirinya sekali biasanya O(n). Yang memanggil dirinya dua kali, seperti FibNaif, meledak jadi O(2ⁿ) karena tiap tingkat menggandakan jumlah pemanggilan.

    Aturan Penyederhanaan: Buang Konstanta, Ambil Suku Dominan

    Big O hanya peduli pola pertumbuhan untuk n yang besar, jadi ada dua aturan penulisan:

    1. Buang konstanta. O(2n + 10) ditulis O(n). O(n/2) juga O(n). Loop yang jalan dua kali tetap linear, cuma linearnya dua kali lebih tebal.
    2. Ambil suku yang paling dominan. O(n² + n + 100) ditulis O(n²). Saat n = 1.000.000, suku n² menyumbang satu triliun operasi sementara suku n cuma sejuta. Sisanya jadi tidak relevan.

    Satu pengecualian: dua variabel input yang berbeda jangan digabung. Algoritma yang memproses dua koleksi berbeda ukurannya O(n + m), bukan O(n). Ini muncul lagi nanti di graph, yang kompleksitasnya ditulis O(V + E).

    Kesalahan Umum Saat Memakai Big O

    Mengira O(1) selalu lebih cepat dari O(n). Salah untuk input kecil. Lookup map itu O(1) tapi ada biaya hashing di tiap akses, jadi scan linear di slice berisi 10 elemen sering lebih cepat karena datanya berdampingan di memori. Big O baru menang bicara saat n membesar.

    Menganggap Big O sama dengan waktu eksekusi nyata. Dua algoritma sama-sama O(n) bisa beda kecepatan lima kali lipat karena konstanta yang dibuang tadi. Big O menjawab “bagaimana perilakunya saat data tumbuh”, benchmark menjawab “berapa cepat di mesin ini dengan data ini”. Kamu butuh keduanya. Di proyek klien, tim Arrazy biasanya memakai Big O saat memilih struktur data di tahap desain, lalu memvalidasi titik-titik panas dengan benchmark seperti di atas sebelum sistem naik ke produksi.

    Lupa bahwa Big O adalah kasus terburuk yang umum dikutip. Quick sort rata-rata O(n log n) tapi terburuknya O(n²). Map Go rata-rata O(1) tapi terburuknya O(n). Saat membaca “Big O suatu algoritma”, pastikan kamu tahu itu angka rata-rata atau terburuk.

    Cheat Sheet Big O untuk Seri Ini

    Simpan tabel ini. Semua struktur data dan algoritma di bawah akan kita bedah satu per satu di bagian berikutnya, dan angka-angka ini jadi rujukan kita terus.

    Struktur / Algoritma Akses Cari Sisip Hapus
    Array / Slice O(1) O(n) O(n) O(n)
    Singly / Doubly Linked List O(n) O(n) O(1) di ujung O(1) jika node diketahui
    Stack O(n) O(n) O(1) push O(1) pop
    Queue O(n) O(n) O(1) enqueue O(1) dequeue
    Hash Table / map O(1) rata-rata, O(n) terburuk O(1) rata-rata O(1) rata-rata
    Binary Search Tree (seimbang) O(log n) O(log n) O(log n) O(log n)
    Heap / Priority Queue O(1) puncak O(n) O(log n) O(log n)
    Algoritma Terbaik Rata-rata Terburuk
    Bubble Sort / Insertion Sort O(n) O(n²) O(n²)
    Merge Sort O(n log n) O(n log n) O(n log n)
    Quick Sort O(n log n) O(n log n) O(n²)
    Binary Search O(1) O(log n) O(log n)
    BFS / DFS pada graph O(V + E)

    Troubleshooting: Masalah yang Sering Muncul Saat Benchmark

    go test bilang “no test files”

    Penyebabnya hampir selalu nama file. File benchmark wajib berakhiran _test.go, misalnya main_test.go. Nama seperti benchmark.go atau test_main.go tidak akan dikenali. Pastikan juga kamu menjalankan go test -bench=. di folder yang sama dengan file tersebut, dan go mod init sudah dijalankan.

    Hasil benchmark 0.25 ns/op padahal fungsinya berat

    Compiler membuang pemanggilan fungsi karena hasilnya tidak pernah dipakai. Ini yang disebut dead code elimination. Solusinya seperti di kode kita: tampung hasil ke variabel level package seperti var hasil int. Untuk O(1) sungguhan seperti Pertama, angka di bawah 1 ns memang normal.

    Benchmark tidak pernah selesai atau laptop menggantung

    Kamu mungkin memberi input besar ke fungsi O(n²) atau O(2ⁿ). HitungPasanganNol dengan sejuta elemen berarti sekitar satu triliun operasi per iterasi, dan Go akan mencoba mengulanginya berkali-kali. Tekan Ctrl+C, kecilkan inputnya, atau batasi dengan -benchtime=10x agar hanya jalan 10 iterasi.

    Fungsi benchmark tidak dijalankan padahal tidak ada error

    Nama fungsi harus diawali Benchmark dengan huruf besar dan menerima b *testing.B. Selain itu flag -bench wajib diberi pola, dan titik berarti semua. Tanpa flag itu, go test hanya menjalankan unit test biasa.

    Lanjut ke Bagian Berikutnya

    Kamu sekarang punya kosakata untuk menilai algoritma: enam kelas kompleksitas, cara membacanya dari kode, aturan penyederhanaannya, plus cheat sheet yang akan terus kita pakai. Di bagian 4, “Array dan Slice di Go: Struktur Data Paling Dasar”, kita buktikan kenapa akses indeks itu O(1) tapi sisip di tengah O(n). Artikelnya terbit menyusul, pantau di halaman hub seri Belajar Struktur Data.

    Referensi

  • Kompleksitas Algoritma: Kenapa Struktur Data Menentukan Performa

    Kompleksitas Algoritma: Kenapa Struktur Data Menentukan Performa

    Kompleksitas algoritma adalah ukuran seberapa banyak operasi yang dibutuhkan sebuah kode ketika ukuran datanya bertambah. Bukan seberapa cepat CPU kamu, bukan seberapa mahal servernya. Kode yang butuh 1 juta operasi akan selalu kalah dari kode yang butuh 1 operasi, mau dijalankan di laptop kentang atau server sultan. Dan yang menentukan jumlah operasi itu, sebagian besar, adalah pilihan struktur data.

    Ini bagian kedua dari seri Belajar Struktur Data dari Nol. Di artikel ini kita buktikan klaim di atas lewat eksperimen langsung: mencari satu item di antara 1 juta data dengan dua cara berbeda, lalu mengukur selisihnya. Setelah itu kita belajar cara mengukur yang benar pakai micro-benchmark bawaan Go.

    Prasyarat Sebelum Mulai

    Kamu butuh Go yang sudah terpasang dan paham cara menjalankan file Go sederhana. Kalau belum, ikuti dulu bagian pertama: Belajar Struktur Data dari Nol: Pengenalan dan Persiapan Go. Semua kode di artikel ini saya uji dengan Go 1.23.1 di Linux, tapi jalan normal di Go 1.21 ke atas. Cek versi kamu dengan go version.

    Eksperimen: Mencari 1 Item di Antara 1 Juta Data

    Kita mulai dari eksperimen, bukan teori. Buat folder baru, lalu jalankan dua perintah ini:

    mkdir kompleksitas && cd kompleksitas
    go mod init kompleksitas

    Buat file main.go berisi kode berikut. Kode ini menyiapkan 1 juta ID user, lalu mencari satu ID yang sama dengan dua cara: loop satu per satu di slice, dan akses langsung di map.

    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    func main() {
    	const n = 1_000_000
    	target := "user-999999"
    
    	// Siapkan data: 1 juta ID di slice dan di map
    	daftar := make([]string, 0, n)
    	indeks := make(map[string]bool, n)
    	for i := 0; i < n; i++ {
    		id := fmt.Sprintf("user-%d", i)
    		daftar = append(daftar, id)
    		indeks[id] = true
    	}
    
    	// Cara 1: loop seluruh slice
    	mulai := time.Now()
    	ketemu := false
    	for _, id := range daftar {
    		if id == target {
    			ketemu = true
    			break
    		}
    	}
    	fmt.Println("slice:", ketemu, "butuh", time.Since(mulai))
    
    	// Cara 2: akses langsung ke map
    	mulai = time.Now()
    	_, ada := indeks[target]
    	fmt.Println("map  :", ada, "butuh", time.Since(mulai))
    }

    Jalankan dengan go run main.go. Di laptop yang saya pakai, hasilnya kira-kira seperti ini:

    slice: true butuh 13.003519ms
    map  : true butuh 819ns

    Angka di mesin kamu pasti berbeda, tapi polanya akan sama: map menang telak. Di contoh ini selisihnya ribuan kali lipat. Padahal dua-duanya menjawab pertanyaan yang sama persis: apakah user-999999 ada di dalam data.

    Kenapa Selisihnya Sejauh Itu: Jumlah Operasi, Bukan Kecepatan CPU

    Loop di slice memeriksa item satu per satu dari depan. Target kita ada di posisi terakhir, jadi Go harus melakukan sekitar 1 juta perbandingan string sebelum ketemu. Map bekerja beda: dia menghitung hash dari kata kunci, lalu langsung melompat ke lokasi penyimpanannya. Berapa pun jumlah datanya, operasinya tetap segelintir.

    Di sinilah inti kompleksitas algoritma. Yang kita hitung bukan detik, tapi jumlah operasi sebagai fungsi dari ukuran data. Perhatikan tabel ini:

    Jumlah data Perkiraan operasi loop slice (kasus terburuk) Perkiraan operasi akses map
    1.000 1.000 sekitar 1
    1.000.000 1.000.000 sekitar 1
    1.000.000.000 1.000.000.000 sekitar 1

    CPU yang dua kali lebih cepat cuma memotong waktu jadi setengah. Tapi mengganti slice ke map memotong 1 miliar operasi jadi satu. Tidak ada upgrade hardware yang bisa menyaingi itu. Makanya programmer berpengalaman lebih dulu bertanya “struktur datanya apa” sebelum bertanya “servernya apa”.

    Time Complexity vs Space Complexity

    Kompleksitas algoritma punya dua sisi yang selalu tarik-menarik:

    • Time complexity: berapa banyak operasi yang dibutuhkan seiring data membesar. Ini yang barusan kita lihat di eksperimen.
    • Space complexity: berapa banyak memori tambahan yang dipakai seiring data membesar.

    Eksperimen kita sebenarnya contoh klasik dari tarik-menarik ini. Map kita cepat karena dia menyimpan struktur tambahan di memori: bucket, hash, dan metadata lain. Kita membayar memori ekstra untuk membeli kecepatan. Loop slice hemat memori karena tidak butuh struktur tambahan apa pun, tapi bayarannya waktu.

    Tidak ada jawaban yang selalu benar. Kalau datanya cuma 20 item dan dicari sekali, loop slice sudah lebih dari cukup. Kalau datanya jutaan dan dicari ribuan kali per detik, struktur tambahan seperti map jadi wajib. Kemampuan menimbang ini yang akan terus kita asah sepanjang seri.

    Praktik: Menulis Micro-Benchmark dengan testing.B

    Mengukur pakai time.Since cocok untuk demo, tapi kurang bisa diandalkan untuk keputusan serius. Sekali jalan bisa kena gangguan: sistem operasi lagi sibuk, cache CPU masih dingin, atau garbage collector kebetulan aktif. Go menyediakan alat resmi untuk ini: benchmark di package testing.

    Buat file cari.go berisi dua fungsi yang mau kita adu:

    package main
    
    func CariDiSlice(data []string, target string) bool {
    	for _, v := range data {
    		if v == target {
    			return true
    		}
    	}
    	return false
    }
    
    func CariDiMap(data map[string]bool, target string) bool {
    	return data[target]
    }

    Lalu buat file cari_test.go. Nama file wajib berakhiran _test.go dan nama fungsi wajib diawali Benchmark:

    package main
    
    import (
    	"fmt"
    	"testing"
    )
    
    const jumlahData = 100_000
    
    // Variabel global sebagai penampung hasil,
    // supaya compiler tidak menghapus kode yang diukur.
    var hasil bool
    
    func siapkanSlice() []string {
    	data := make([]string, 0, jumlahData)
    	for i := 0; i < jumlahData; i++ {
    		data = append(data, fmt.Sprintf("user-%d", i))
    	}
    	return data
    }
    
    func siapkanMap() map[string]bool {
    	data := make(map[string]bool, jumlahData)
    	for i := 0; i < jumlahData; i++ {
    		data[fmt.Sprintf("user-%d", i)] = true
    	}
    	return data
    }
    
    func BenchmarkCariDiSlice(b *testing.B) {
    	data := siapkanSlice()
    	target := "user-99999"
    	b.ResetTimer()
    	for i := 0; i < b.N; i++ {
    		hasil = CariDiSlice(data, target)
    	}
    }
    
    func BenchmarkCariDiMap(b *testing.B) {
    	data := siapkanMap()
    	target := "user-99999"
    	b.ResetTimer()
    	for i := 0; i < b.N; i++ {
    		hasil = CariDiMap(data, target)
    	}
    }

    Dua detail penting di kode ini. Pertama, b.ResetTimer() dipanggil setelah data disiapkan, supaya waktu menyiapkan 100 ribu item tidak ikut terhitung. Kedua, hasil fungsi disimpan ke variabel global hasil, alasannya kita bahas di bagian troubleshooting. Jalankan dengan:

    go test -bench=. -benchmem

    Contoh keluaran di mesin saya:

    goos: linux
    goarch: amd64
    pkg: kompleksitas
    cpu: Intel(R) Core(TM) i5-10310U CPU @ 1.70GHz
    BenchmarkCariDiSlice-8        2326    537460 ns/op       0 B/op       0 allocs/op
    BenchmarkCariDiMap-8      86166330     22.97 ns/op       0 B/op       0 allocs/op
    PASS
    ok      kompleksitas    4.353s

    Cara Membaca Hasil Benchmark Go

    • Angka setelah nama fungsi (3021 dan 29847216) adalah b.N, yaitu berapa kali Go mengulang pengukuran. Go sendiri yang menentukan jumlah ini sampai hasilnya stabil secara statistik. Inilah bedanya dengan time.Since yang cuma mengukur sekali.
    • ns/op adalah rata-rata nanodetik per operasi. Di sini pencarian slice makan sekitar 537 ribu nanodetik, pencarian map sekitar 23 nanodetik.
    • B/op dan allocs/op muncul karena flag -benchmem, menunjukkan alokasi memori per operasi. Ini sisi space complexity dalam angka nyata.

    Kasus Nyata di Backend: Full Scan vs Lookup Terindeks

    Pola slice vs map ini bukan cuma soal latihan. Ini persis pola yang muncul di database. Query yang menyaring kolom tanpa indeks memaksa database membaca seluruh tabel baris per baris, istilahnya full table scan. Sama seperti loop slice kita. Query di kolom yang terindeks bisa melompat langsung ke baris yang dicari, sama seperti akses map.

    Tim Arrazy beberapa kali menemukan kasus ini saat membangun sistem aplikasi untuk klien: endpoint yang terasa cepat saat data masih ratusan mendadak lambat setelah data tumbuh ke ratusan ribu. Kodenya tidak berubah sama sekali. Yang berubah cuma ukuran datanya, dan kompleksitas yang tadinya tidak terasa mulai menagih. Solusinya hampir selalu sama polanya: ganti cara akses dari scan menyeluruh ke lookup terindeks, entah lewat indeks database, map di memori, atau cache.

    Pelajaran praktisnya: kode yang lolos testing dengan data kecil belum tentu selamat di produksi. Kompleksitas baru kelihatan giginya saat data membesar.

    Troubleshooting: Error yang Sering Dialami Pemula Saat Benchmark

    Benchmark terlihat mustahil cepat, misalnya 0.25 ns/op

    Penyebab: hasil fungsi tidak dipakai, jadi compiler Go menganggap kodenya percuma dan menghapusnya. Yang kamu ukur akhirnya loop kosong. Ini disebut dead code elimination, dan hasilnya benchmark bohong. Solusi: simpan hasil ke variabel global seperti hasil di contoh kita, atau pakai b.Loop() di Go 1.24 ke atas yang otomatis mencegah optimasi ini. Kalau kamu melihat angka di bawah 1 ns/op untuk operasi yang jelas berat, curigai masalah ini.

    go test tidak menjalankan benchmark sama sekali

    Penyebab paling umum ada dua. Pertama, menjalankan go test tanpa flag -bench, karena secara default Go hanya menjalankan fungsi Test, bukan Benchmark. Kedua, salah tulis pola, misalnya -bench tanpa nilai akan error flag needs an argument. Solusi: selalu sertakan pola, minimal -bench=. yang artinya jalankan semua benchmark.

    Muncul pesan “no test files”

    Penyebab: nama file tidak berakhiran _test.go, atau file test beda package dengan kode yang diuji. Solusi: pastikan nama file persis berpola nama_test.go dan baris package di file test sama dengan file utamanya.

    Angka hasil benchmark berubah-ubah tiap kali dijalankan

    Penyebab: mesin kamu mengerjakan hal lain saat benchmark jalan, misalnya browser dengan banyak tab atau proses build lain. Variasi 5 sampai 10 persen itu normal. Solusi: tutup aplikasi berat, lalu jalankan beberapa putaran dengan go test -bench=. -count=5 dan bandingkan hasilnya. Keputusan diambil dari pola beberapa putaran, bukan dari satu angka keramat.

    Latihan: Prediksi Dulu, Baru Buktikan

    Kebiasaan paling berharga dari bagian ini: sebelum menjalankan benchmark, tulis dulu prediksimu. Cocokkan setelahnya. Coba tiga latihan ini:

    1. Ubah target di benchmark menjadi "user-0", item paling depan. Prediksi dulu: apakah BenchmarkCariDiSlice jadi jauh lebih cepat, sama saja, atau lebih lambat? Bagaimana dengan map? Jalankan dan cocokkan.
    2. Ubah jumlahData dari 100.000 menjadi 1.000.000. Prediksi: waktu slice naik sekitar 10 kali lipat atau tetap? Waktu map bagaimana? Buktikan.
    3. Cari target yang tidak ada di data, misalnya "tidak-ada". Prediksi mana yang paling terpengaruh, lalu jalankan.

    Kalau prediksimu meleset, justru bagus. Di situ letak belajarnya: cari tahu kenapa angka nyata tidak sesuai bayanganmu. Latihan nomor 1 misalnya, sering mengejutkan pemula karena slice mendadak menang.

    Lanjut ke Bagian Berikutnya

    Kamu sekarang sudah merasakan sendiri bahwa jumlah operasi menentukan performa, dan sudah bisa mengukurnya dengan benar pakai benchmark. Yang belum kita punya adalah bahasa standar untuk menyebut perbedaan itu. Menyebut “sekitar 1 juta operasi” tiap kali jelas tidak praktis.

    Bahasa standar itu namanya notasi Big O, dan itu bahasan bagian ketiga: Big O Notation: Cara Mengukur Kompleksitas Algoritma. Artikelnya terbit menyusul, pantau daftar lengkapnya di halaman hub Belajar Struktur Data dari Nol.

    Referensi

  • Belajar Struktur Data dari Nol: Pengenalan dan Persiapan Go

    Belajar Struktur Data dari Nol: Pengenalan dan Persiapan Go

    Struktur data adalah cara menyimpan dan mengatur data di memori supaya cepat diakses dan diubah. Algoritma adalah urutan langkah untuk memproses data itu. Dua hal ini yang membedakan aplikasi yang tetap responsif saat datanya jutaan baris dengan aplikasi yang mulai lemot padahal datanya baru ribuan. Artikel ini bagian pertama dari seri Belajar Struktur Data dari Nol. Di bagian ini kita bereskan fondasinya dulu: paham apa itu struktur data, kenapa serinya pakai Go, lalu siapkan Go 1.24 di komputer sampai program pertama benar-benar jalan.

    Prasyaratnya ringan. Kamu cukup paham sintaks dasar Go: variabel, fungsi, if, dan for. Kalau belum, selesaikan dulu bagian-bagian awal seri Belajar Golang dari Nol, karena dasar bahasanya sengaja tidak diulang di sini. Yang penting kamu bisa membaca kode Go sederhana tanpa bingung.

    Apa Itu Struktur Data dan Algoritma

    Bayangkan dua gudang dengan isi barang yang sama persis. Gudang pertama pakai rak berlabel: lorong A untuk sparepart, lorong B untuk kemasan, tiap rak ada nomornya. Gudang kedua tidak pakai rak sama sekali, semua barang ditumpuk begitu saja di lantai. Saat ada pesanan masuk, petugas gudang pertama langsung jalan ke lorong yang benar dan ambil barangnya dalam hitungan detik. Petugas gudang kedua harus membongkar tumpukan satu per satu, dan makin banyak barangnya makin lama carinya.

    Struktur data adalah sistem raknya, yaitu cara data disusun. Algoritma adalah cara kerja petugasnya, yaitu langkah-langkah mencari, menambah, atau mengeluarkan barang. Keduanya tidak bisa dipisah. Rak serapi apa pun percuma kalau petugasnya tetap memeriksa semua lorong satu per satu. Sebaliknya, petugas secerdas apa pun tidak bisa berbuat banyak kalau barangnya cuma ditumpuk. Itulah kenapa di kampus keduanya diajarkan sebagai satu mata kuliah: pilihan struktur data menentukan algoritma apa saja yang bisa jalan efisien di atasnya.

    Contoh nyatanya ada di HP kamu. Daftar kontak tersimpan terurut berdasarkan nama, jadi pencarian nama bisa langsung melompat ke huruf yang benar. Kalau kontak disimpan acak sesuai urutan input, satu-satunya cara mencari adalah memeriksa semua kontak dari awal. Data sama, struktur beda, kecepatan beda jauh.

    Kenapa Seri Ini Pakai Go 1.24

    Semua contoh kode di 26 bagian seri ini ditulis dengan Go 1.24. Alasannya praktis, bukan sekadar selera:

    • Sintaksnya kecil. Go tidak punya banyak fitur ajaib yang menyembunyikan apa yang terjadi di memori. Saat belajar linked list atau hash table, kamu ingin melihat pointer dan alokasi secara eksplisit, dan Go menampilkannya apa adanya.
    • Tooling bawaan lengkap. Ada go run untuk eksekusi cepat, gofmt untuk format otomatis, dan package testing bawaan. Tidak perlu instal build tool tambahan.
    • Generics sudah matang. Sejak Go 1.18 kita bisa menulis struktur data generik seperti Stack[T], dan di Go 1.24 dukungannya sudah stabil termasuk untuk type alias generik. Ini penting karena kita akan membuat struktur data yang bisa dipakai ulang.
    • Dipakai di industri. Go umum dipakai untuk backend dan tooling infrastruktur. Tim Arrazy sendiri memakai Go di backend sistem aplikasi yang kami bangun untuk klien, jadi materi seri ini diambil dari pola yang memang kami pakai sehari-hari.

    Kamu tidak wajib memakai persis versi 1.24. Versi 1.22 ke atas masih bisa mengikuti hampir semua materi. Tapi supaya output di artikel sama dengan di layar kamu, sebaiknya samakan versinya.

    Install Go 1.24 dan Verifikasi dengan go version

    Cara paling aman adalah unduh langsung dari situs resmi Go, bukan dari package manager sistem. Di Linux (Ubuntu, Debian, dan turunannya) jalankan perintah berikut di terminal:

    wget https://go.dev/dl/go1.24.5.linux-amd64.tar.gz
    sudo rm -rf /usr/local/go
    sudo tar -C /usr/local -xzf go1.24.5.linux-amd64.tar.gz

    Lalu tambahkan Go ke PATH. Buka ~/.bashrc (atau ~/.zshrc kalau pakai zsh), tambahkan baris ini di paling bawah, lalu buka terminal baru:

    export PATH=$PATH:/usr/local/go/bin

    Untuk Windows, unduh installer .msi dari halaman go.dev/dl, klik dua kali, dan ikuti wizard-nya. PATH diatur otomatis. Untuk macOS, unduh installer .pkg dari halaman yang sama.

    Apa pun sistem operasinya, verifikasi dengan perintah ini:

    go version

    Output yang diharapkan (arsitektur bisa berbeda sesuai mesin kamu):

    go version go1.24.5 linux/amd64

    Kalau yang muncul justru error atau versi lama, jangan lanjut dulu. Lihat bagian error umum di bawah, dua masalah paling sering terjadi persis di langkah ini.

    Proyek Pertama: go mod init dan go run

    Semua kode seri ini akan hidup dalam satu proyek bernama struktur-data. Buat foldernya dan inisialisasi Go module:

    mkdir struktur-data
    cd struktur-data
    go mod init struktur-data

    Output yang diharapkan:

    go: creating new go.mod: module struktur-data

    Perintah itu membuat file go.mod, penanda bahwa folder ini adalah sebuah Go module. Tanpa file ini, banyak perintah Go modern akan menolak jalan. Sekarang buat file main.go berisi kode berikut:

    package main
    
    import "fmt"
    
    func main() {
    	angka := []int{7, 3, 9}
    	fmt.Println("Isi slice:", angka)
    	fmt.Println("Jumlah elemen:", len(angka))
    }

    Jalankan:

    go run main.go

    Output yang diharapkan:

    Isi slice: [7 3 9]
    Jumlah elemen: 3

    Kalau output kamu sama, environment sudah siap. Slice yang barusan kamu cetak itu sebenarnya struktur data pertama di seri ini, dan cara kerjanya di dalam memori akan kita bedah di bagian 4 dan 5.

    Setup VS Code dan Struktur Folder untuk Mengikuti Seri

    Editor yang dipakai di seri ini adalah VS Code karena dukungan Go-nya paling mulus. Langkah setupnya:

    1. Buka VS Code, masuk ke tab Extensions, cari Go yang dipublikasikan oleh Go Team at Google (id: golang.go), lalu install.
    2. Buka folder struktur-data lewat File, Open Folder.
    3. Tekan Ctrl+Shift+P, ketik Go: Install/Update Tools, centang semua tool (termasuk gopls dan dlv), lalu OK. Tool ini yang membuat autocomplete dan deteksi error jalan.

    Supaya kode otomatis dirapikan gofmt setiap kali disimpan, buka Settings JSON (Ctrl+Shift+P, lalu Preferences: Open User Settings (JSON)) dan tambahkan:

    {
      "[go]": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "golang.go"
      }
    }

    Terakhir, sepakati struktur folder. Setiap bagian seri dapat satu folder sendiri di dalam module yang sama, jadi kode lama tidak tertimpa kode baru:

    struktur-data/
    ├── go.mod
    ├── bab-01-pengenalan/
    │   └── main.go
    ├── bab-02-kompleksitas/
    │   └── main.go
    └── bab-03-big-o/
        └── main.go

    Pindahkan main.go yang tadi ke bab-01-pengenalan/. Untuk menjalankan kode bab tertentu dari root proyek, cukup sebut foldernya:

    go run ./bab-01-pengenalan

    Error Umum Saat Setup Go dan Cara Mengatasinya

    go: command not found

    Muncul saat mengetik go version padahal Go sudah diekstrak ke /usr/local/go. Penyebabnya hampir selalu PATH: shell tidak tahu harus mencari binary go di mana. Pastikan baris export PATH=$PATH:/usr/local/go/bin benar-benar tersimpan di ~/.bashrc atau ~/.zshrc, lalu buka terminal baru atau jalankan source ~/.bashrc. Cek juga file mana yang dibaca shell kamu dengan echo $SHELL, karena user zsh sering salah menaruh export di .bashrc.

    Versi Go lama padahal baru install

    go version menampilkan misalnya go1.18 padahal kamu baru mengekstrak 1.24. Biasanya karena dulu pernah install Go lewat apt, dan binary lama di /usr/bin/go ditemukan lebih dulu daripada yang baru. Cek dengan which go. Kalau hasilnya /usr/bin/go, hapus versi lama dengan sudo apt remove golang-go, lalu buka terminal baru. Repository apt memang sering tertinggal beberapa versi di belakang rilis resmi, jadi untuk Go selalu unduh dari go.dev/dl.

    go.mod file not found in current directory

    Error lengkapnya biasanya go: cannot find main module atau go.mod file not found. Ini muncul saat menjalankan perintah Go di folder yang belum diinisialisasi sebagai module. Zaman dulu Go mewajibkan semua kode ada di dalam satu folder global bernama GOPATH. Sistem itu sudah ditinggalkan dan diganti Go modules, tapi banyak tutorial lama di internet masih mengajarkan GOPATH sehingga pemula tercampur. Aturannya sekarang sederhana: satu proyek satu folder, jalankan go mod init nama-proyek sekali di root folder itu, selesai. Kamu tidak perlu menyentuh variabel GOPATH sama sekali.

    Autocomplete VS Code tidak jalan

    Kode bisa di-run tapi tidak ada saran kode dan error tidak digarisbawahi. Biasanya gopls belum terinstal atau VS Code dibuka bukan di root proyek. Jalankan lagi Go: Install/Update Tools, pastikan folder yang dibuka adalah folder yang berisi go.mod, lalu restart VS Code.

    Peta Lengkap 26 Bagian Seri Belajar Struktur Data

    Supaya kamu tahu arah perjalanannya, ini peta seri dari awal sampai akhir:

    Bagian Topik Yang kamu kuasai
    1-3 Fondasi: setup, kompleksitas, Big O Mengukur cepat lambatnya algoritma secara objektif
    4-5 Array dan slice Paham perilaku slice Go sampai level memori dan kapasitas
    6-9 Linked list, stack, queue Membangun struktur linear sendiri dengan pointer
    10-11 Hash table dan map Tahu cara kerja map Go dari dalam, termasuk hashing dan collision
    12-15 Rekursi, binary tree, BST, heap Struktur hierarkis dan priority queue
    16-18 Graph, BFS, DFS Memodelkan relasi dan menelusurinya, termasuk deteksi siklus
    19-22 Sorting dan binary search Bubble, insertion, merge, quick sort, plus pencarian cepat di data terurut
    23-24 Greedy dan dynamic programming Strategi memecahkan soal optimasi
    25-26 Latihan interview dan proyek LRU cache Menggabungkan semua materi jadi solusi soal nyata

    Di akhir seri kamu bukan cuma hafal nama-nama struktur data. Kamu sudah pernah mengimplementasikan semuanya sendiri di Go, tahu kapan memakai yang mana, dan siap menghadapi soal coding interview yang menanyakan hal-hal ini.

    Langkah Berikutnya

    Environment sudah siap, program pertama sudah jalan, dan kamu sudah pegang peta serinya. Pertanyaan berikutnya yang wajar muncul: kalau dua program sama-sama benar, bagaimana cara membuktikan yang satu lebih cepat dari yang lain tanpa menebak-nebak. Itu materi bagian kedua, “Kompleksitas Algoritma: Kenapa Struktur Data Menentukan Performa”, yang terbit menyusul dan bisa kamu pantau di halaman hub seri Belajar Struktur Data dari Nol. Sampai artikelnya terbit, pastikan setup kamu beres: go version menampilkan 1.24 dan go run ./bab-01-pengenalan mencetak output tanpa error.

    Referensi