go
Veri Yapıları dersleri

Diziler ve Dinamik Diziler

Bitişik bellek, rastgele erişim ve sıfırdan dinamik dizi yazmak.

Ders 2 / 1425 dkBaşlangıç
Bu derste öğreneceklerin
  • Bitişik bellek ve önbellek dostu erişim
  • İndeks ile O(1) erişim
  • Araya ekleme ve silmenin maliyeti
  • Dinamik dizi: kapasite ve büyüme faktörü
  • Amortize O(1) ekleme
  • Sıfırdan jenerik DynamicArray[T]
  • Go slice'ları ile karşılaştırma

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
                   = 1024

Bu 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ı.

ZamanO(1)AlanO(n)

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.

main.go
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)
}
Çıktı
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 - indeks

Başa eklemek en pahalı durumdur: Tüm elemanlar kayar, yani O(n). Sona eklemek ise (yer varsa) hiçbir şeyi kaydırmaz: O(1).

İşlemKarmaşıklıkNeden
İndeksle okuma / yazmaO(1)Adres hesabı
Sona ekleme (yer varsa)O(1)Kaydırma yok
Başa eklemeO(n)Tüm elemanlar kayar
Ortaya eklemeO(n)Ortalama n/2 eleman kayar
Sondan silmeO(1)Kaydırma yok
Baştan/ortadan silmeO(n)Boşluk kapatılır
Sıralı dizide aramaO(log n)İkili arama
Sırasız dizide aramaO(n)Doğrusal tarama
main.go
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")
}
Çıktı
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.

main.go
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)
}
Çıktı
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: false

Küçü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:

main.go
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)
}
Çıktı
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.

ÖzellikDynamicArray[T]Go dilimi
BüyümeSabit ×2×2'den başlayıp büyük dilimlerde azalır
PaylaşımYokDilimler altta yatan diziyi paylaşır
Alt dilim almaYoks[2:5] — kopyalama yapmaz
Sıfır değernil işaretçinil dilim, kullanıma hazır
Büyüme kontrolüSen yazarsınappend yönetir, make ile ipucu verilir

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.

main.go
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)
}
Çıktı
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çin slices.Clone gerekir.
  • 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

Alıştırma·Yerinde tekrarları ayıklama
Kolay

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
main.go
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))
	}
}
Çıktı
[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)
ZamanO(n)AlanO(1)

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.

Alıştırma·Diziyi k adım döndürme
Orta

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
main.go
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))
	}
}
Çıktı
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
ZamanO(n)AlanO(1)

Üç 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.

Alıştırma·Büyüme stratejilerini ölçme
Zor

İ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
main.go
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))
}
Çıktı
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
ZamanO(1) amortize (katlama), O(n) amortize (sabit artış)AlanO(n)

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

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.
Bu dersi bitirdin mi?
İlerlemen bu tarayıcıda saklanır.