Bilgisayar belleği, numaralandırılmış hücrelerden oluşan devasa bir şerittir. Bir veri yapısının hızını belirleyen en temel şey, veriyi bu şeride nasıl yerleştirdiğidir. Dizi, mümkün olan en sade yerleşimi seçer: Tüm elemanlar, art arda, hiç boşluk bırakmadan durur. Bu basit karar, dizilere hem olağanüstü bir güç hem de belirgin bir zayıflık kazandırır.
Gücü şudur: Elemanların nerede olduğunu bilmek için arama yapmaya gerek yoktur. Başlangıç adresini ve eleman boyutunu biliyorsan, üç yüz bininci elemanın adresini tek bir çarpma ve toplama ile hesaplarsın. Zayıflığı ise şudur: Ortaya bir eleman eklemek, ondan sonraki her şeyi bir hücre kaydırmayı gerektirir. Bu ikilik, bilgisayar biliminin en temel ödünleşmelerinden biridir ve bundan sonraki tüm veri yapılarını anlamanın anahtarıdır.
Bu derste bitişik belleğin neden bu kadar hızlı olduğunu, indeks erişiminin gerçekte nasıl çalıştığını, ekleme ve silmenin maliyetini, sabit boyutlu dizilerden dinamik dizilere nasıl geçildiğini, büyüme faktörünün neden ikiye katlama olduğunu ve amortize analizi öğreneceksin. Sonra bunların hepsini birleştirip sıfırdan jenerik bir dinamik dizi yazacak ve Go'nun dilimleriyle karşılaştıracaksın.
Bitişik bellek ve rastgele erişim
Bir dizi bellekte şöyle durur:
dizi: [10][20][30][40][50] eleman boyutu: 8 bayt (int64)
adres: 1000 1008 1016 1024 1032
indeks: 0 1 2 3 4
3. elemanın adresi = başlangıç + indeks × eleman_boyutu
= 1000 + 3 × 8
= 1024Bu formül, dizinin en önemli özelliğini verir: rastgele erişim. Hangi elemanı istersen iste, maliyet aynıdır — bir çarpma, bir toplama, bir bellek okuması.
Ama hikâyenin görünmeyen bir yarısı daha vardır. Modern işlemciler belleği tek tek bayt olarak okumaz; önbellek satırı (cache line) denen, genelde 64 baytlık bloklar hâlinde okur. Bir dizinin ilk elemanına eriştiğinde, onunla birlikte komşu yedi eleman da (int64 için) ücretsiz olarak önbelleğe gelir. Diziyi baştan sona gezen bir döngü, bu yüzden beklediğinden çok daha hızlı çalışır: Sekiz erişimden yalnızca biri gerçek bellek okuması yapar.
Aşağıdaki görselleştirmede bir dilimin bellekteki yerleşimini, uzunluk ve kapasite kavramlarını inceleyebilirsin. Eleman ekleyip kapasitenin ne zaman ikiye katlandığını izlemeye çalış:
Bu "önbellek dostu olma" özelliği, teoride aynı karmaşıklığa sahip veri yapıları arasında pratikte on kata varan farklar yaratır. Bağlı listeler teorik olarak bazı işlemlerde daha iyidir ama elemanları bellekte dağınık durduğu için her erişim bir önbellek ıskası (cache miss) demektir. Bunu Bağlı Listeler dersinde ölçerek göreceksin.
package main
import "fmt"
func main() {
// Go'da sabit boyutlu dizi: boyut tipin parçasıdır
var arr [5]int
arr[0], arr[1], arr[2], arr[3], arr[4] = 10, 20, 30, 40, 50
fmt.Println("dizi:", arr, "uzunluk:", len(arr))
fmt.Println("3. eleman:", arr[3]) // O(1)
// [5]int ve [6]int FARKLI tiplerdir
other := [6]int{}
fmt.Printf("tipler: %T ve %T\n", arr, other)
// Diziler değer tipidir: atama KOPYALAR
copied := arr
copied[0] = 999
fmt.Println("özgün:", arr[0], "kopya:", copied[0])
// Diziler karşılaştırılabilir
a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
fmt.Println("eşit mi:", a == b)
// Çok boyutlu dizi: tek parça bitişik bellek
var grid [3][4]int
for i := range grid {
for j := range grid[i] {
grid[i][j] = i*4 + j
}
}
fmt.Println("ızgara:", grid)
}dizi: [10 20 30 40 50] uzunluk: 5 3. eleman: 40 tipler: [5]int ve [6]int özgün: 10 kopya: 999 eşit mi: true ızgara: [[0 1 2 3] [4 5 6 7] [8 9 10 11]]
Go'da sabit boyutlu diziler günlük kodda nadiren doğrudan kullanılır; boyutun tipin parçası olması onları katı kılar. Ama dilimlerin altında her zaman bir dizi vardır — dilimler, dizilere açılan pencerelerdir.
Ekleme ve silmenin gerçek maliyeti
Bitişikliğin bedeli burada ortaya çıkar. Diziye ortadan bir eleman eklemek istersen, yer açman gerekir:
[10][20][30][40][50] 2. indekse 99 ekle
adım 1: sondan başlayarak kaydır
[10][20][30][40][50]
└───┴───┘ sağa bir kaydır
[10][20][ ][30][40][50]
adım 2: boşluğa yaz
[10][20][99][30][40][50]
kaydırılan eleman sayısı: n - indeksBaşa eklemek en pahalı durumdur: Tüm elemanlar kayar, yani O(n). Sona eklemek ise (yer varsa) hiçbir şeyi kaydırmaz: O(1).
package main
import "fmt"
// insertAt, diziye O(n) maliyetle araya eleman ekler ve kaydırma sayısını sayar.
func insertAt(arr []int, index, value int) ([]int, int) {
arr = append(arr, 0) // yer aç
shifts := copy(arr[index+1:], arr[index:len(arr)-1]) // sağa kaydır
arr[index] = value
return arr, shifts
}
// removeAt, elemanı siler ve boşluğu kapatır.
func removeAt(arr []int, index int) ([]int, int) {
shifts := copy(arr[index:], arr[index+1:])
return arr[:len(arr)-1], shifts
}
// removeUnordered, sıra önemsizse O(1) siler: son elemanı boşluğa taşır.
func removeUnordered(arr []int, index int) []int {
arr[index] = arr[len(arr)-1]
return arr[:len(arr)-1]
}
func main() {
data := []int{10, 20, 30, 40, 50}
withMiddle, shifts := insertAt(data, 2, 99)
fmt.Println("ortaya ekleme:", withMiddle, "kaydırma:", shifts)
atFront, shifts := insertAt(data, 0, 5)
fmt.Println("başa ekleme: ", atFront, "kaydırma:", shifts)
removed, shifts := removeAt(withMiddle, 2)
fmt.Println("silme: ", removed, "kaydırma:", shifts)
unordered := removeUnordered([]int{10, 20, 30, 40, 50}, 1)
fmt.Println("sırasız silme:", unordered, "kaydırma: 0")
}ortaya ekleme: [10 20 99 30 40 50] kaydırma: 3 başa ekleme: [5 10 20 30 40 50] kaydırma: 5 silme: [10 20 30 40 50] kaydırma: 3 sırasız silme: [10 50 30 40] kaydırma: 0
Son fonksiyon önemli bir püf noktasıdır: Elemanların sırası senin için önemli değilse, silinecek elemanın üzerine son elemanı yazıp diziyi bir kısaltırsın. Böylece O(n) olan silme O(1)'e düşer. Bu numara oyun motorlarında ve benzetimlerde çok kullanılır.
Dinamik dizi: kapasite ve büyüme
Sabit boyutlu dizinin sorunu açıktır: Kaç eleman geleceğini önceden bilmen gerekir. Dinamik dizi bu sorunu, gerçekte ayrılan yeri (kapasite) kullanılan yerden (uzunluk) ayırarak çözer.
uzunluk = 3, kapasite = 8
[10][20][30][ ][ ][ ][ ][ ]
└─ kullanılan ─┘└─ boş yer ─┘
append: uzunluk < kapasite ise yalnızca yaz → O(1)
uzunluk = 8, kapasite = 8 → dolu!
append: yeni ve daha büyük bir dizi ayır, hepsini kopyala → O(n)
[10][20][30][40][50][60][70][80] eski (kap. 8)
↓ kopyala
[10][20][30][40][50][60][70][80][ ][ ]... (kap. 16)Kritik soru şudur: Dizi dolduğunda ne kadar büyütmeli? Cevap, tüm dinamik dizi uygulamalarının kalbindeki karardır.
Sabit miktarda büyütmek (her seferinde +1 veya +10) felakettir. n eleman eklemek için toplam kopyalama sayısı 1 + 2 + 3 + ... ≈ n²/2 olur; yani her ekleme ortalama O(n) maliyetli hâle gelir.
Katlayarak büyütmek (×2) ise sihirli biçimde işe yarar. Kapasite her dolduğunda ikiye katlanırsa, n eleman eklerken yapılan toplam kopyalama sayısı şudur:
1 + 2 + 4 + 8 + ... + n/2 + n < 2n
toplam iş: O(n)
ekleme başına ortalama iş: O(n)/n = O(1)Buna amortize O(1) denir: Tek tek bakıldığında bazı eklemeler pahalıdır (O(n)), ama uzun vadede ekleme başına düşen maliyet sabittir. Amortize analizin ayrıntısını Karmaşıklık Analizi dersinde bulabilirsin.
package main
import "fmt"
func main() {
// Go'nun büyüme davranışını izleyelim
var s []int
prevCap := 0
fmt.Printf("%5s %5s %5s\n", "n", "len", "cap")
for i := 1; i <= 1024; i++ {
s = append(s, i)
if cap(s) != prevCap {
fmt.Printf("%5d %5d %5d\n", i, len(s), cap(s))
prevCap = cap(s)
}
}
// Kapasiteyi önceden ayırmak tüm kopyalamayı ortadan kaldırır
preallocated := make([]int, 0, 1000)
startCap := cap(preallocated)
for i := range 1000 {
preallocated = append(preallocated, i)
}
fmt.Println("önceden ayrılmış kapasite hiç değişti mi:", cap(preallocated) != startCap)
}n len cap
1 1 4
5 5 8
9 9 16
17 17 32
33 33 64
65 65 128
129 129 256
257 257 512
513 513 848
849 849 1280
önceden ayrılmış kapasite hiç değişti mi: falseKüçük dilimlerde Go kapasiteyi ikiye katlar; dilim büyüdükçe büyüme faktörü kademeli olarak 1,25'e doğru düşer. Bunun nedeni bellek israfını sınırlamaktır: Yüz megabaytlık bir dilimi ikiye katlamak, yüz megabayt daha ister.
Pratik sonuç: Kaç eleman ekleyeceğini biliyorsan kapasiteyi önceden ayır. make([]int, 0, n) yazmak, hem tüm kopyalamaları hem de çöp toplayıcı üzerindeki baskıyı ortadan kaldırır.
Sıfırdan jenerik dinamik dizi
Şimdi teoriyi koda dökelim. Aşağıdaki DynamicArray[T], Go'nun dilimlerinin altında ne olduğunu göstermek için sıfırdan yazılmıştır:
package main
import (
"errors"
"fmt"
)
var ErrOutOfRange = errors.New("indeks aralık dışında")
type DynamicArray[T any] struct {
data []T // altta yatan depolama (kapasite buradadır)
length int // kullanılan eleman sayısı
grows int // kaç kez büyüdü (öğretici amaçlı)
}
func NewDynamicArray[T any](capacity int) *DynamicArray[T] {
if capacity < 1 {
capacity = 1
}
return &DynamicArray[T]{data: make([]T, capacity)}
}
func (a *DynamicArray[T]) Len() int { return a.length }
func (a *DynamicArray[T]) Cap() int { return len(a.data) }
// grow, kapasiteyi ikiye katlar: amortize O(1)'in kaynağı
func (a *DynamicArray[T]) grow() {
newData := make([]T, len(a.data)*2)
copy(newData, a.data)
a.data = newData
a.grows++
}
// Append: amortize O(1)
func (a *DynamicArray[T]) Append(v T) {
if a.length == len(a.data) {
a.grow()
}
a.data[a.length] = v
a.length++
}
// Get: O(1)
func (a *DynamicArray[T]) Get(i int) (T, error) {
var zero T
if i < 0 || i >= a.length {
return zero, ErrOutOfRange
}
return a.data[i], nil
}
// Insert: O(n) — kaydırma gerekir
func (a *DynamicArray[T]) Insert(i int, v T) error {
if i < 0 || i > a.length {
return ErrOutOfRange
}
if a.length == len(a.data) {
a.grow()
}
copy(a.data[i+1:a.length+1], a.data[i:a.length])
a.data[i] = v
a.length++
return nil
}
// Remove: O(n) — boşluk kapatılır
func (a *DynamicArray[T]) Remove(i int) error {
if i < 0 || i >= a.length {
return ErrOutOfRange
}
copy(a.data[i:], a.data[i+1:a.length])
var zero T
a.data[a.length-1] = zero // sızıntıyı önle: işaretçi tutma
a.length--
return nil
}
func (a *DynamicArray[T]) Slice() []T { return a.data[:a.length] }
func main() {
arr := NewDynamicArray[string](2)
for _, w := range []string{"go", "dilim", "dizi", "harita", "ağaç"} {
arr.Append(w)
}
fmt.Println("içerik:", arr.Slice())
fmt.Printf("uzunluk=%d kapasite=%d büyüme=%d\n", arr.Len(), arr.Cap(), arr.grows)
_ = arr.Insert(1, "YENİ")
fmt.Println("araya ekleme:", arr.Slice())
_ = arr.Remove(0)
fmt.Println("baştan silme:", arr.Slice())
v, err := arr.Get(2)
fmt.Println("2. eleman:", v, err)
_, err = arr.Get(99)
fmt.Println("aralık dışı:", err)
}içerik: [go dilim dizi harita ağaç] uzunluk=5 kapasite=8 büyüme=2 araya ekleme: [go YENİ dilim dizi harita ağaç] baştan silme: [YENİ dilim dizi harita ağaç] 2. eleman: dizi <nil> aralık dışı: indeks aralık dışında
Remove içindeki a.data[a.length-1] = zero satırına dikkat et. Bu satır olmasaydı, silinen eleman hâlâ altta yatan dizide durur ve işaretçi içeriyorsa çöp toplayıcının o belleği geri almasını engellerdi. Go'nun kendi slices.Delete fonksiyonu da tam olarak bu temizliği yapar.
Go dilimleri ile karşılaştırma
Yazdığın DynamicArray, Go'nun dilimlerinin yaptığı işin neredeyse aynısını yapar. Fark, dilimlerin dile gömülü olması ve üç alanlı küçük bir yapıdan (başlık) oluşmasıdır: altta yatan diziye bir işaretçi, uzunluk ve kapasite.
Dilimlerin paylaşım davranışı hem güçlü hem tehlikelidir: s[2:5] ifadesi yeni bir dizi oluşturmaz, aynı diziye bakan yeni bir pencere üretir. Bu yüzden alt dilimi değiştirmek özgün dilimi de değiştirir. Ayrıntıları Diziler ve Dilimler dersinde inceledik.
package main
import (
"fmt"
"slices"
)
func main() {
base := []int{1, 2, 3, 4, 5, 6}
window := base[1:4] // paylaşımlı pencere
fmt.Println("pencere:", window, "len:", len(window), "cap:", cap(window))
window[0] = 99
fmt.Println("pencereyi değiştirince taban:", base)
// Bağımsız kopya
independent := slices.Clone(base)
independent[0] = -1
fmt.Println("klon:", independent, "taban:", base)
// slices paketinin hazır işlemleri: hepsi aynı maliyet analizine tabidir
inserted := slices.Insert(slices.Clone(base), 2, 100, 200) // O(n)
fmt.Println("araya ekleme:", inserted)
deleted := slices.Delete(slices.Clone(base), 1, 3) // O(n)
fmt.Println("aralık silme:", deleted)
fmt.Println("ters:", func() []int { c := slices.Clone(base); slices.Reverse(c); return c }())
fmt.Println("arama (sıralı):", must(slices.BinarySearch(base, 5)))
}
func must(i int, ok bool) string {
if !ok {
return "bulunamadı"
}
return fmt.Sprintf("indeks %d", i)
}pencere: [2 3 4] len: 3 cap: 5 pencereyi değiştirince taban: [1 99 3 4 5 6] klon: [-1 99 3 4 5 6] taban: [1 99 3 4 5 6] araya ekleme: [1 99 100 200 3 4 5 6] aralık silme: [1 4 5 6] ters: [6 5 4 3 99 1] arama (sıralı): indeks 4
Diziyi ne zaman seçmeli?
Veri yapısı seçimi, yapacağın işlemlerin dağılımına bağlıdır. Dizi (ve dinamik dizi) şu durumlarda neredeyse her zaman doğru seçimdir.
Elemanlara indeksle erişiyorsan. Hiçbir yapı, rastgele erişimde diziyi geçemez. Bir öncelik sırası, bir tablo, bir tampon — indeksle çalışıyorsan başka bir şey arama.
Baştan sona geziyorsan. Bitişik bellek, tarama işlemlerinde ölçülebilir bir hız avantajı sağlar. Veri kümesi ne kadar büyükse fark o kadar açılır.
Eklemelerin çoğu sondaysa. Amortize sabit maliyetli ekleme, dinamik dizinin en güçlü yanıdır. Günlük biriktirme, sonuç toplama, tampon doldurma gibi işler tam olarak bu desene uyar.
Bellek kullanımı önemliyse. Dizi, eleman başına ek yük taşımaz. Bağlı liste her düğüm için en az bir işaretçi (8 bayt) daha tutar; ağaçlar ve hash tabloları çok daha fazlasını.
Buna karşılık, eklemelerin ve silmelerin çoğu ortada ya da baştaysa dizi kötü bir seçimdir. Her işlem ortalama n/2 eleman kaydırır ve bu, veri büyüdükçe hızla dayanılmaz hâle gelir. Böyle bir erişim deseni varsa bağlı liste, deque ya da farklı bir yapı düşünmelisin.
Pratikte yaygın bir yanılgı da şudur: "Ortaya çok ekleme yapıyorum, o hâlde bağlı liste kullanmalıyım." Küçük ve orta boyutlu veri kümelerinde (birkaç bin eleman), dizideki kaydırma işlemi önbellek dostu olduğu için bağlı listedeki işaretçi takibinden hâlâ hızlı olabilir. Karar vermeden önce ölç; sezgi bu konuda sık sık yanılır.
Sık yapılan hatalar
- Kapasiteyi önceden ayırmamak. Kaç eleman geleceğini biliyorsan
make([]T, 0, n)yaz; gereksiz kopyalamayı tamamen ortadan kaldırır. - Alt dilimin bağımsız olduğunu sanmak.
s[2:5]aynı diziyi paylaşır; bağımsız kopya içinslices.Clonegerekir. - Silerken kalan işaretçileri temizlememek. Silinen elemanlar altta yatan dizide kalırsa bellek serbest bırakılmaz.
- Sırası önemsiz veride O(n) silme kullanmak. Son elemanı boşluğa taşımak O(1) çözüm verir.
- Döngü içinde diziyi değiştirirken indeksleri kaydırmak. Silme yaparken sondan başa doğru gezmek ya da yeni bir dilim üretmek daha güvenlidir.
- Sabit boyutlu diziyi fonksiyona geçirip değişmesini beklemek. Diziler değer tipidir; kopyalanır. Dilim geçirmelisin.
- Büyük dilimlerin küçük bir parçasını uzun süre tutmak. Küçük bir pencere, devasa bir diziyi hafızada tutmaya devam eder; gerekirse klonla.
Alıştırmalar
Sıralı bir tamsayı diliminden tekrar eden elemanları, yeni bir dilim ayırmadan (yerinde) temizleyen bir fonksiyon yaz. Sonuç dilimini ve kaç eleman kaldığını döndür. Boş dilim ve tek elemanlı dilim durumlarını da ele al.
İpucu
İki işaretçi kullan: biri yazma konumunu, diğeri okuma konumunu takip etsin. Yeni bir değer gördüğünde yazma konumuna koy ve ilerlet.
Çözümü göster
package main
import "fmt"
// dedup, sıralı bir dilimdeki tekrarları yerinde temizler.
func dedup(nums []int) []int {
if len(nums) < 2 {
return nums
}
write := 1
for read := 1; read < len(nums); read++ {
if nums[read] != nums[write-1] {
nums[write] = nums[read]
write++
}
}
return nums[:write]
}
func main() {
cases := [][]int{
{1, 1, 2, 3, 3, 3, 4, 5, 5},
{7, 7, 7, 7},
{1, 2, 3},
{42},
{},
}
for _, c := range cases {
original := fmt.Sprint(c)
result := dedup(c)
fmt.Printf("%-24s → %v (%d eleman)\n", original, result, len(result))
}
}[1 1 2 3 3 3 4 5 5] → [1 2 3 4 5] (5 eleman) [7 7 7 7] → [7] (1 eleman) [1 2 3] → [1 2 3] (3 eleman) [42] → [42] (1 eleman) [] → [] (0 eleman)
Tek geçişte, ek bellek ayırmadan çalışır. write işaretçisi her zaman "bir sonraki benzersiz elemanın yazılacağı yeri" gösterir; bu iki işaretçi deseni pek çok dizi probleminde karşına çıkacak. Deseni İki İşaretçi ve Kayan Pencere dersinde derinlemesine inceliyoruz.
Bir diziyi sağa k adım döndüren bir fonksiyon yaz. Önce ek dizi kullanan basit çözümü, sonra üç kez ters çevirme numarasıyla O(1) ek bellek kullanan çözümü uygula ve ikisinin aynı sonucu verdiğini göster.
İpucu
Üç ters çevirme: önce tüm diziyi, sonra ilk k elemanı, sonra kalanları ters çevir. k'yi k % n ile normalleştirmeyi unutma.
Çözümü göster
package main
import (
"fmt"
"slices"
)
// rotateExtra: anlaşılır ama O(n) ek bellek kullanır.
func rotateExtra(nums []int, k int) []int {
n := len(nums)
if n == 0 {
return nums
}
k = ((k % n) + n) % n // negatif k için de doğru çalışır
out := make([]int, n)
for i, v := range nums {
out[(i+k)%n] = v
}
return out
}
// rotateInPlace: üç ters çevirme, O(1) ek bellek.
func rotateInPlace(nums []int, k int) []int {
n := len(nums)
if n == 0 {
return nums
}
k = ((k % n) + n) % n
slices.Reverse(nums)
slices.Reverse(nums[:k])
slices.Reverse(nums[k:])
return nums
}
func main() {
base := []int{1, 2, 3, 4, 5, 6, 7}
for _, k := range []int{0, 1, 3, 7, 10, -2} {
a := rotateExtra(slices.Clone(base), k)
b := rotateInPlace(slices.Clone(base), k)
fmt.Printf("k=%-3d ek bellek: %v | yerinde: %v | aynı mı: %t\n",
k, a, b, slices.Equal(a, b))
}
}k=0 ek bellek: [1 2 3 4 5 6 7] | yerinde: [1 2 3 4 5 6 7] | aynı mı: true k=1 ek bellek: [7 1 2 3 4 5 6] | yerinde: [7 1 2 3 4 5 6] | aynı mı: true k=3 ek bellek: [5 6 7 1 2 3 4] | yerinde: [5 6 7 1 2 3 4] | aynı mı: true k=7 ek bellek: [1 2 3 4 5 6 7] | yerinde: [1 2 3 4 5 6 7] | aynı mı: true k=10 ek bellek: [5 6 7 1 2 3 4] | yerinde: [5 6 7 1 2 3 4] | aynı mı: true k=-2 ek bellek: [3 4 5 6 7 1 2] | yerinde: [3 4 5 6 7 1 2] | aynı mı: true
Üç ters çevirme numarası ilk bakışta sihir gibi görünür ama mantığı basittir: Tüm diziyi ters çevirmek son k elemanı başa taşır, ancak ters sırada; iki parçayı ayrı ayrı ters çevirmek bu bozulmayı düzeltir. Toplam iş yine O(n)'dir fakat ek dizi ayrılmaz — bu, büyük veri kümelerinde bellek baskısını belirgin biçimde azaltır.
İki dinamik dizi uygulaması yaz: biri kapasiteyi her dolduğunda bir artırsın, diğeri ikiye katlasın. Her iki uygulamayla 20.000 eleman ekleyip toplam kopyalanan eleman sayısını karşılaştır. Sonuçların teorik beklentiyle uyuştuğunu göster.
İpucu
Her grow çağrısında kopyalanan eleman sayısını bir sayaçta topla. Sabit artışta toplamın ≈ n²/2, katlamada ≈ 2n olmasını bekle.
Çözümü göster
package main
import "fmt"
type growthCounter struct {
data []int
length int
copied int // toplam kopyalanan eleman
growths int
double bool
}
func (g *growthCounter) grow() {
newCap := len(g.data) + 1
if g.double {
newCap = max(1, len(g.data)*2)
}
newData := make([]int, newCap)
g.copied += copy(newData, g.data[:g.length])
g.data = newData
g.growths++
}
func (g *growthCounter) Append(v int) {
if g.length == len(g.data) {
g.grow()
}
g.data[g.length] = v
g.length++
}
func run(n int, double bool) (copied, growths int) {
g := &growthCounter{double: double}
for i := range n {
g.Append(i)
}
return g.copied, g.growths
}
func main() {
const n = 20_000
linearCopied, linearGrowths := run(n, false)
doubleCopied, doubleGrowths := run(n, true)
fmt.Printf("%-22s %12s %10s %14s\n", "strateji", "kopyalama", "büyüme", "ekleme başına")
fmt.Printf("%-22s %12d %10d %14.2f\n", "kapasite + 1", linearCopied, linearGrowths,
float64(linearCopied)/float64(n))
fmt.Printf("%-22s %12d %10d %14.2f\n", "kapasite × 2", doubleCopied, doubleGrowths,
float64(doubleCopied)/float64(n))
fmt.Println()
fmt.Println("teorik n²/2 =", n*n/2, "| gerçek:", linearCopied)
fmt.Println("teorik 2n =", 2*n, "| gerçek:", doubleCopied)
fmt.Printf("katlama kaç kat daha az kopyalama yaptı: %.0f\n",
float64(linearCopied)/float64(doubleCopied))
}strateji kopyalama büyüme ekleme başına kapasite + 1 199990000 20000 9999.50 kapasite × 2 32767 16 1.64 teorik n²/2 = 200000000 | gerçek: 199990000 teorik 2n = 40000 | gerçek: 32767 katlama kaç kat daha az kopyalama yaptı: 6103
Sayılar teoriyi doğruluyor: Sabit artış stratejisinde ekleme başına ortalama iş, eleman sayısıyla doğru orantılı olarak büyüyor; katlamada ise sabit kalıyor ve ikiyi geçmiyor. Bu, amortize analizin neden önemli olduğunun en net örneğidir — tek bir işlemin en kötü durumuna bakmak yanıltıcı olurdu, çünkü pahalı işlemler giderek seyrekleşir.
Büyüme faktörünü daha küçük seçmek (örneğin 1,5) bellek israfını azaltır ama kopyalama sayısını artırır. Gerçek uygulamalar bu iki uç arasında bir denge seçer; Go'nun dilimleri de büyüdükçe faktörü kademeli olarak düşürür.
Kısa sınav
Bir dizide indeksle erişimin O(1) olmasının sebebi nedir?
Dinamik dizide kapasite dolduğunda kapasiteyi ikiye katlamak neden tercih edilir?
Sırası önemsiz bir dilimden ortadaki bir elemanı O(1) sürede nasıl silersin?
s := []int{1,2,3,4,5} iken w := s[1:3] yapıp w[0] = 99 dersen ne olur?
Bitişik bellek, aynı karmaşıklığa sahip yapılara göre pratikte neden daha hızlıdır?
Aşağıdaki işlemlerden hangisi dizide O(n) maliyetlidir?
Özet
- Dizi, elemanları bitişik bellekte tutar; adres hesabı sayesinde indeksle erişim O(1)'dir.
- Bitişiklik önbellek dostudur: Tarama işlemlerinde teorik eşitlere göre belirgin hız avantajı sağlar.
- Ortaya veya başa ekleme/silme O(n)'dir; sona ekleme ve sondan silme O(1).
- Sıra önemsizse, son elemanı boşluğa taşıyarak O(1) silme yapılabilir.
- Dinamik dizi uzunluk ile kapasiteyi ayırır; kapasite dolunca daha büyük bir dizi ayrılıp içerik kopyalanır.
- Katlayarak büyüme, toplam kopyalamayı O(n)'de tutar ve ekleme başına amortize O(1) verir; sabit artış O(n)'e çıkar.
- Go dilimleri aynı fikrin dile gömülmüş hâlidir; ek olarak alt dilimler altta yatan diziyi paylaşır.
- Kaç eleman ekleyeceğini biliyorsan kapasiteyi önceden ayır; sildiğin elemanların işaretçilerini temizle.