← semua materi
05:00 Arsitektur Β· 24 Oct 2026 Β· 05:02

πŸ“š Topik: Amortized Analysis β€” Kenapa append ke Dynamic Array Disebut O(1) Kategori: Algorithms Β· Level: 2 (intermediate–advanced)


🎯 Tujuan Belajar

Setelah sesi ini, keputusan berikut jadi lebih baik:

  1. Bisa membedakan amortized O(1) vs worst-case O(n) vs average-case, dan tahu kapan perbedaan itu menggigit (latency SLA, real-time, sistem dengan tail-latency budget).
  2. Tahu kapan wajib pre-alokasi (make([]T, 0, n), reserve(), new ArrayList<>(n)) dan kenapa "premature optimization" bukan alasan untuk tidak melakukannya kalau n sudah diketahui.
  3. Bisa memilih growth factor dengan alasan eksplisit (memory waste vs copy work vs allocator behavior), bukan "default library aja".
  4. Bisa mengevaluasi struktur data baru: apakah klaim O(1)-nya amortized, dan apakah amortized-nya bertahan di workload kamu (persistent/backtracking/real-time = bisa hilang).

🧠 Penjelasan Inti

Masalah yang diselesaikan

Array statis punya alokasi memori kontigu dengan indexing O(1) β€” cache-friendly, pointer-arithmetic murni. Tapi ukurannya tetap saat alokasi. Kalau butuh tumbuh, dua pilihan naif:

Pendekatan Append Memori Masalah
Alokasi tepat n+1 tiap append O(n) copy 0 waste O(nΒ²) total untuk n append
Linked list O(1) ~3x overhead (ptr + alloc header) Cache miss tiap traversal, no random access
Dynamic array (geometric growth) O(1) amortized ≀ (kβˆ’1)Γ— waste sesekali O(n) copy

Dynamic array menang karena satu trik: saat penuh, jangan alokasi n+1, alokasi kΒ·n dengan k > 1. Dengan begitu resize jadi jarang, dan biayanya "diangsur" ke operasi-operasi yang murah.

Amortized β‰  average-case

Ini distingsi yang paling sering salah:

Amortized O(1) artinya: untuk setiap urutan n operasi, total biaya ≀ cΒ·n. Bukan "biasanya cepat". Tidak ada asumsi probabilistik sama sekali. Ini lebih kuat dari average-case, lebih lemah dari worst-case per-operasi.


βš™οΈ Cara Kerja Internal

Bukti: aggregate method

Misal growth factor k = 2, kapasitas awal 1. Append ke-n dari array kosong:

Append 1:  cap 1  β†’ copy 0
Append 2:  cap 2  β†’ copy 1
Append 3:  cap 4  β†’ copy 2
Append 4:  cap 4  β†’ copy 0
Append 5:  cap 8  β†’ copy 4
...
Append n:  cap n  β†’ copy n/2

Total elemen yang di-copy sampai n append:

1 + 2 + 4 + ... + n/2  =  n βˆ’ 1  <  n

Total biaya = n (tulis elemen baru) + <n (copy) = < 2n. Bagi n β†’ ≀ 2 operasi per append. Konstan. ∎

Generalisasi growth factor

Kapasitas yang di-copy: 1 + k + kΒ² + ... + k^m β‰ˆ k^(m+1)/(kβˆ’1) β‰ˆ kΒ·n/(kβˆ’1).

Biaya amortized per append:

A(k) = 1 + k/(kβˆ’1)
k Amortized cost/append Waste memori (maks)
2.0 3 ~100% (cap bisa 2Γ— size)
1.5 4 ~50%
1.25 6 ~25%
1.1 12 ~10%

Perhatikan: angka absolutnya bergantung apakah "copy" dihitung sebagai 1 unit atau sizeof(T) unit. Yang penting: konstan, tapi konstantanya naik saat k turun. Ini trade-off memori vs CPU yang eksplisit, bukan free lunch.

Accounting (banker's) method

Bayangkan tiap append "menabung" kredit. Append biasa biaya 1, tapi kita charge 3 (atau A(k)): 1 untuk kerja nyata, sisanya ditabung di elemen itu. Saat resize, biaya copy c dibayar dari tabungan c elemen yang sudah ada β€” tiap elemen punya saldo β‰₯ 2 (atau k/(kβˆ’1)βˆ’1). Tabungan tidak pernah negatif β†’ prepaid, tidak ada hutang. Itu definisi amortized yang valid.

Potential method (paling rigorous, dipakai di paper)

Definisikan fungsi potensial Ξ¦ = "energi tersimpan" di struktur data:

Ξ¦(h) = 2Β·num(h) βˆ’ capacity(h)      // untuk k = 2

Amortized cost = actual + ΔΦ.

Konstan di kedua kasus. Ξ¦ β‰₯ 0 selalu (karena capacity β‰₯ num... perlu 2Β·num βˆ’ cap β‰₯ 0 yang berlaku saat cap ≀ 2Β·num, benar untuk k=2). Syarat formal: Ξ¦(hβ‚€) = 0 dan Ξ¦(hα΅’) β‰₯ 0 untuk semua i. Terpenuhi. ∎


πŸ—οΈ Diagram / Pseudocode

append(x):
    if size == capacity:
        new_cap = grow(capacity)          # kΒ·capacity, dibatasi min threshold
        new_buf = alloc(new_cap)          # ← biaya O(new_cap), bisa page fault
        memcpy(new_buf, buf, size)        # ← biaya O(size), memory bandwidth bound
        free(buf)                         # ← kadang tertunda (GC / refcount)
        buf = new_buf
        capacity = new_cap
    buf[size] = x
    size += 1

Timeline biaya per-append (k=2, n=16):

cost
 16 |                    β–ˆ
  8 |              β–ˆ     Β·
  4 |        β–ˆ     Β·     Β·
  2 |    β–ˆ   Β·     Β·     Β·
  1 |β–ˆ β–ˆ Β· β–ˆ Β· β–ˆ Β· β–ˆ Β· β–ˆ Β· β–ˆ Β· β–ˆ Β· β–ˆ
    +─────────────────────────────────→ append #
      spike di 1,2,3,5,9,17  β€”  sisanya flat

Pola spike lalu datar inilah yang bikin amortized O(1) tapi worst-case per-op tetap O(n). Kalau kamu punya SLA p99, kamu hidup di spike itu.

Implementasi nyata (terverifikasi, baca langsung dari source)

Go β€” runtime/slice.go, fungsi growslice:

const threshold = 256
if oldCap < threshold {
    return doublecap          // 2x untuk slice kecil
}
for {
    // Transition from growing 2x for small slices
    // to growing 1.25x for large slices
    newcap += (newcap + 3*threshold) / 4
    if newcap >= newLen { break }
}

Threshold 256, bukan 1024. Formula halus ini masuk sejak Go 1.18 (sebelumnya: 2x di bawah 1024, 1.25x di atas). Sumber: go.dev/src/runtime/slice.go (dicek 2026). Konsekuensi: slice besar di Go punya amortized cost mendekati 5–6 unit per append β€” lebih mahal dari Java/C++ 2x, ditukar dengan waste memori lebih kecil.

Java β€” ArrayList.grow() (JDK 7+):

int newCapacity = oldCapacity + (oldCapacity >> 1);   // 1.5x

Amortized ~3 unit/append, waste maks 50%.

C++ β€” std::vector: standar tidak menspesifikasikan growth factor, hanya menjamin push_back amortized O(1). libstdc++ (GCC/Clang) pakai 2x; MSVC pakai 1.5x. Ini implementation detail β€” jangan diandalkan portabel.

Python β€” list pakai ~1.125x (newsize + newsize >> 3 + (newsize < 9 ? 3 : 6)), agresif hemat memori karena tiap elemen adalah pointer 8 byte ke objek heap.


βš–οΈ Trade-off & Kapan JANGAN Dipakai

Ini bagian inti.

1. Amortized O(1) adalah kebohongan di sistem latency-sensitive

Kalau kamu punya p99 latency budget 10ms dan satu request bisa memicu resize 10 juta elemen (copy ~80MB di []int64), itu 80MB memcpy β‰ˆ 5–15ms di satu request. Amortized-nya tetap O(1). p99-mu tetap hancur. Amortized analysis merata-ratakan latency spike ke request lain yang tidak mengalaminya β€” secara matematis benar, secara operasional menyesatkan.

Kapan ini fatal: game loop (frame budget 16ms), audio processing, real-time control, HFT, database dengan latency SLO. Di domain itu, pre-allocate atau pakai struktur lain (ring buffer, chunked/deque).

2. Growth factor kecil (1.25x) = lebih banyak copy

Go memilih 1.25x untuk slice besar. Kalau workload kamu append 10 juta elemen, total copy ~50 juta elemen vs ~20 juta dengan 2x. Untuk tipe besar (struct 200 byte), itu 10GB vs 4GB memory bandwidth. Kalau n diketahui, growth factor jadi tidak relevan β€” langsung pre-alokasi.

3. Growth factor 2x dan interaksi dengan allocator

Argumen klasik (dari diskusi libstdc++ 2004, masih relevan sebagai reasoning, bukan hasil benchmark yang saya ukur): dengan 2x, blok baru selalu lebih besar dari total semua blok lama yang pernah dibebaskan (c + c/2 + c/4 + ... < 2c). Akibatnya allocator tidak bisa memakai ulang memori bebas β€” selalu mmap halaman baru β†’ page fault + RSS naik. Dengan 1.5x, total blok bebas (3c) melebihi kebutuhan (1.5c), jadi realloc in-place mungkin.

Status: DIINFERENSI. Ini argumen analitis yang masuk akal dan menjelaskan kenapa MSVC pilih 1.5x, tapi besarnya efek sangat bergantung allocator (glibc malloc, jemalloc, tcmalloc beda perilaku). Jangan kutip sebagai angka benchmark.

4. Shrinking: sumber thrashing yang sering dilupakan

pop sampai size turun jauh tidak mengecilkan kapasitas di banyak implementasi (Java ArrayList tidak shrink otomatis). Kalau kamu shrink agresif tiap kali size < cap/2, pola append/pop bergantian di sekitar batas memicu resize bolak-balik tiap operasi β†’ O(n) per op, amortized hancur.

Fix standar: shrink hanya saat size < cap/4 (hysteresis), supaya butuh cap/4 operasi append untuk memicu grow lagi. Buffer gap 4x mencegah osilasi.

5. Kapan JANGAN pakai dynamic array sama sekali

Situasi Pakai ini
Butuh worst-case O(1) append (real-time) Ring buffer fixed-size, atau pre-allocated + chunk list
Butuh O(1) prepend juga Deque (circular buffer)
Ukuran tidak diketahui & sangat besar, butuh pointer stabil Chunked list / Vec<Box<[T]>> / unrolled linked list β€” resize tidak invalidasi pointer lama
Persistent/immutable, butuh versi lama RRB-tree / HAMT. Amortized analysis gugur di sini (lihat miskonsepsi 3)
Akses dominan random, write jarang Array statis, alokasi sekali

6. Pre-alokasi: kapan "premature optimization" bukan alasan

Kalau n diketahui dari input (misal len(rows) dari query DB), make([]T, 0, n) adalah koreksi kompleksitas, bukan micro-optimization. Bedanya: kamu mengubah O(n) copy tersembunyi jadi nol. Kalau n hanya estimasi longgar, over-allocate bisa lebih buruk (RSS naik, cache pressure) β€” estimasi 2x dari aktual masih masuk akal, 100x tidak.


🏭 Contoh Produksi & Failure Mode

1. Aliasing bug di Go β€” append mengembalikan slice baru, bukan memutasi

a := make([]int, 3, 10)     // len 3, cap 10
b := append(a, 99)          // TIDAK resize (cap cukup) β†’ b.array == a.array
b[0] = 42
// a[0] == 42  ← kejutan. a dan b share backing array.

Kalau cap(a) == len(a), append akan alokasi baru dan a tidak terpengaruh. Perilaku berubah tergantung kapasitas β€” bug yang muncul hanya di beberapa code path, sulit direproduksi. Ini penyebab umum data corruption di parser/encoder yang mengembalikan sub-slice. Mitigasi: slices.Clone() sebelum append kalau ownership ambigu.

2. Latency spike dari realloc di jalur request

Pola umum: cache in-process berupa []Entry yang di-append terus tanpa bound. Setelah berjalan 6 jam, append ke-1.000.001 memicu copy 1M entry di tengah request handling β†’ p99 naik 50ms sesekali, tidak terlihat di average. Fixed dengan pre-allocated fixed-size ring buffer + eviction.

3. Peak memory 2.5x saat resize (Java)

ArrayList grow dari cap c ke 1.5c: array lama masih hidup selama Arrays.copyOf berjalan. Peak = c + 1.5c = 2.5c. Di container dengan memory limit ketat, ini bisa OOM padahal data akhirnya cuma butuh 1.5c. Gejala: OOMKilled yang tidak konsisten, hanya saat resize besar. Mitigasi: pre-size dari awal, atau naikkan limit dengan margin 2.5x.

4. Go: append di dalam loop tanpa pre-alokasi pada slice besar

var out []Item
for _, r := range rows { out = append(out, convert(r)) }   // ~5 unit/element untuk slice besar

vs

out := make([]Item, 0, len(rows))                          // 1 unit/element

Untuk 1 juta Item 100 byte: beda ~400MB memory traffic. Terukur, bukan teori.

5. Amortized hilang saat persistence/backtracking

Struktur immutable (misal persistent vector) yang mengandalkan lazy evaluation + memoization: kalau kamu akses versi lama berulang kali, kerja yang tadinya "dibayar sekali" jadi dibayar ulang. Amortized bound tidak berlaku karena state global (cache/memo) di-share antar versi. Ini alasan paper Okasaki soal amortization in persistent data structures pakai teknik khusus (banker's queue dengan scheduling eksplisit), bukan amortized naif.


⚠️ Miskonsepsi Umum

1. "Append itu O(1)." Salah tanpa kualifikasi. Amortized O(1). Worst-case per operasi O(n). Kalau kamu menulis dokumentasi API atau SLO, tulis "amortized O(1), worst-case O(n)".

2. "Amortized O(1) berarti cepat." Tidak. Amortized cost konstantanya bisa 2, 3, atau 6 unit kerja. k=1.25 berarti ~5x lebih banyak memory traffic per append dibanding k=2. Konstanta itu nyata dan terukur.

3. "Amortized analysis berlaku di mana saja." Gugur untuk: (a) struktur persistent/immutable dengan state di-share, (b) struktur dengan operasi yang bisa di-interleave secara adversarial (attacker memicu resize tiap request = DoS amplification), (c) concurrent tanpa sinkronisasi β€” dua goroutine append bersamaan ke slice yang sama = data race, bukan soal kompleksitas.

4. "Growth factor 2x selalu lebih baik karena amortized cost-nya paling rendah." 2x punya amortized cost terendah (3 vs 4 vs 6) tapi waste memori tertinggi (100%). Kalau kamu simpan 10GB data, waste 100% = 20GB RSS. Itu bukan trade-off sepele. Tidak ada "best practice" universal β€” ini fungsi dari rasio memori-harga vs CPU-harga di deployment kamu.

5. "Karena amortized O(1), tidak perlu pre-alokasi." Pre-alokasi saat n diketahui menghilangkan semua resize dan semua copy β€” dari O(n) total jadi 0. Itu bukan optimasi mikro, itu menghilangkan pekerjaan.

6. "Resize = alokasi + copy." Sering ada komponen ketiga yang dilupakan: free/deallocation. Di runtime GC (Go, Java), array lama baru benar-benar dibebaskan setelah GC berikutnya β€” peak memory bisa lebih tinggi dari perkiraan, dan biaya GC proportional ke volume alokasi (yang naik karena resize).


βœ… Cek Pemahaman

1. Sebuah dynamic array pakai growth factor k = 1.5, kapasitas awal 1, kamu append 3⁢ = 729 elemen. Hitung total elemen yang di-copy dan verifikasi amortized cost β‰ˆ 3 unit/append. Lalu: berapa total copy kalau growth factor-nya 2.0? Bandingkan dan jelaskan implikasi untuk memilih k.

2. Kamu punya endpoint API dengan SLO p99 < 20ms. Di dalamnya ada loop yang meng-append ke slice yang tidak di-pre-alokasi, dan panjangnya bervariasi 0–5 juta elemen. Apakah "append itu O(1) amortized" cukup untuk membenarkan desain ini? Kalau tidak, sebutkan dua perbaikan konkret dan trade-off masing-masing.

3. Jelaskan kenapa potential function Ξ¦ = 2Β·num βˆ’ capacity tidak valid kalau growth factor-nya 1.25 (hint: periksa syarat Ξ¦ β‰₯ 0 saat num mendekati capacity). Konstruksi Ξ¦ yang valid untuk k = 1.5 dan buktikan amortized cost-nya konstan.


πŸ”‘ Poin Kunci


➑️ Eksplorasi Lanjut


Catatan verifikasi: Klaim source code Go (threshold = 256, formula newcap += (newcap + 3*threshold)/4) dan Java (oldCapacity + (oldCapacity >> 1)) diverifikasi langsung dari source/diskusi implementasi, dicek 2026. Growth factor libstdc++/MSVC adalah implementation detail yang dilaporkan komunitas, bukan spesifikasi standar C++ β€” DIINFERENSI, jangan diandalkan portabel. Angka "amortized cost 3/4/6" adalah hasil derivasi matematis dari model biaya copy-per-elemen, bukan benchmark terukur; biaya riil bergantung sizeof(T), memory bandwidth, dan perilaku allocator.