go
Veri Yapıları dersleri

Hash Tabloları

Hash fonksiyonları, çakışma çözümü, yeniden boyutlandırma ve LRU önbellek.

Ders 6 / 1435 dkOrta
Bu derste öğreneceklerin
  • Hash fonksiyonu ve kovalar (buckets)
  • Çakışmalar: ayrı zincirleme (separate chaining)
  • Açık adresleme: doğrusal yoklama (linear probing)
  • Yük faktörü ve yeniden boyutlandırma
  • Sıfırdan jenerik HashMap
  • Go map'lerinin iç yapısı (Swiss tables)
  • Uygulamalar: frekans sayma ve iki toplam
  • LRU önbellek tasarımı

Bir dizide belirli bir değeri aramak için tüm elemanları taraman gerekir: O(n). Sıralı bir dizide ikili arama yapabilirsin: O(log n). Peki hiç arama yapmadan, doğrudan doğru yere gitmek mümkün mü?

Hash tablosunun cevabı şudur: Evet — anahtarın kendisinden bir adres hesaplayabilirsen. Bir hash fonksiyonu, herhangi bir anahtarı (metin, sayı, struct) sabit boyutlu bir sayıya dönüştürür; o sayıyı tablo boyutuna göre daraltırsan elemanın hangi kovada duracağını bulmuş olursun. Arama, ekleme ve silme ortalama O(1) sürer. Bu, veri yapıları dünyasının en etkileyici sonuçlarından biridir.

Ama "ortalama" kelimesi burada ağır bir yük taşır. İki farklı anahtar aynı kovaya düşebilir — buna çakışma denir — ve gerçek uygulamalarda çakışmalar kaçınılmazdır. Bir hash tablosunu iyi yapan şey, hash fonksiyonunun kalitesi ve çakışmaları çözme stratejisidir. Bu derste hash fonksiyonlarını, iki temel çakışma çözüm yöntemini, yük faktörü ile yeniden boyutlandırmayı, sıfırdan jenerik bir hash tablosunu, Go map'lerinin iç yapısını ve klasik uygulamaları öğreneceksin.

Hash fonksiyonu ve kovalar

Temel fikir üç adımdır:

anahtar "elma"

    ▼  hash fonksiyonu
0x8f3a91c7  (sabit boyutlu sayı)

    ▼  % kova_sayısı  (8)
kova 7


┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 0  │ 1  │ 2  │ 3  │ 4  │ 5  │ 6  │ 7  │
└────┴────┴────┴────┴────┴────┴────┴────┘
                                    elma→12

İyi bir hash fonksiyonunun üç özelliği vardır:

Deterministik olmalı. Aynı anahtar her zaman aynı sayıyı üretmelidir; aksi hâlde yazdığın veriyi bir daha bulamazsın.

Hızlı olmalı. Hash hesabı, aramanın kendisinden pahalı olursa tüm avantaj kaybolur.

Dağıtımı düzgün olmalı. Anahtarlar kovalara olabildiğince eşit dağılmalıdır. Tüm anahtarların aynı kovaya düştüğü bir hash fonksiyonu, tabloyu bağlı listeye çevirir ve O(n)'e düşürür.

main.go
package main

import (
	"fmt"
	"hash/fnv"
)

// badHash: yalnızca uzunluğa bakar — felaket bir dağılım
func badHash(s string, buckets int) int {
	return len(s) % buckets
}

// simpleHash: her baytı katlayarak toplar (djb2 benzeri)
func simpleHash(s string, buckets int) int {
	h := 5381
	for i := range len(s) {
		h = h*33 + int(s[i])
	}
	if h < 0 {
		h = -h
	}
	return h % buckets
}

// fnvHash: standart kütüphanenin FNV-1a uygulaması
func fnvHash(s string, buckets int) int {
	h := fnv.New32a()
	h.Write([]byte(s))
	return int(h.Sum32()) % buckets
}

func distribution(name string, fn func(string, int) int, keys []string, buckets int) {
	counts := make([]int, buckets)
	for _, k := range keys {
		counts[fn(k, buckets)]++
	}
	empty, maxLoad := 0, 0
	for _, c := range counts {
		if c == 0 {
			empty++
		}
		maxLoad = max(maxLoad, c)
	}
	fmt.Printf("%-12s dağılım=%v boş kova=%d en dolu kova=%d\n", name, counts, empty, maxLoad)
}

func main() {
	keys := []string{
		"elma", "armut", "kiraz", "üzüm", "muz", "incir",
		"kavun", "karpuz", "erik", "şeftali", "ayva", "nar",
	}
	const buckets = 8

	distribution("kötü", badHash, keys, buckets)
	distribution("basit", simpleHash, keys, buckets)
	distribution("FNV-1a", fnvHash, keys, buckets)
}
Çıktı
kötü         dağılım=[1 0 0 2 3 4 2 0] boş kova=3 en dolu kova=4
basit        dağılım=[1 1 4 0 1 0 5 0] boş kova=3 en dolu kova=5
FNV-1a       dağılım=[2 0 3 0 1 0 5 1] boş kova=3 en dolu kova=5

badHash fonksiyonunun çıktısındaki yığılmaya dikkat et: Benzer uzunluktaki anahtarlar aynı kovada toplanıyor ve pek çok kova boş kalıyor. İyi bir hash fonksiyonu ise anahtarlardaki küçük farkları sonuçta büyük farklara dönüştürür — buna çığ etkisi (avalanche effect) denir.

Çakışma çözümü: ayrı zincirleme

İki anahtar aynı kovaya düştüğünde ne olur? En yaygın çözüm, her kovada bir liste tutmaktır: ayrı zincirleme (separate chaining).

kova 3: ● ──► ["elma" 12] ──► ["nar" 7] ──► nil
                 çakışan anahtarlar aynı kovada zincirlenir

Arama: doğru kovaya git, zinciri gez, anahtarı karşılaştır.
Zincir uzunluğu kısa kaldığı sürece maliyet sabit sayılır.

Aşağıdaki görselleştirmede zincirleme stratejisini seçip anahtar ekleyerek çakışmaların nasıl biriktiğini izleyebilirsin:

main.go
package main

import (
	"fmt"
	"hash/fnv"
	"strings"
)

type chainEntry struct {
	key   string
	value int
	next  *chainEntry
}

type ChainedMap struct {
	buckets []*chainEntry
	size    int
}

func NewChainedMap(buckets int) *ChainedMap {
	return &ChainedMap{buckets: make([]*chainEntry, max(1, buckets))}
}

func (m *ChainedMap) index(key string) int {
	h := fnv.New32a()
	h.Write([]byte(key))
	return int(h.Sum32() % uint32(len(m.buckets)))
}

func (m *ChainedMap) Put(key string, value int) {
	i := m.index(key)
	for e := m.buckets[i]; e != nil; e = e.next {
		if e.key == key {
			e.value = value // var olan anahtarı güncelle
			return
		}
	}
	m.buckets[i] = &chainEntry{key: key, value: value, next: m.buckets[i]}
	m.size++
}

func (m *ChainedMap) Get(key string) (int, bool) {
	for e := m.buckets[m.index(key)]; e != nil; e = e.next {
		if e.key == key {
			return e.value, true
		}
	}
	return 0, false
}

func (m *ChainedMap) Delete(key string) bool {
	i := m.index(key)
	var prev *chainEntry
	for e := m.buckets[i]; e != nil; e = e.next {
		if e.key != key {
			prev = e
			continue
		}
		if prev == nil {
			m.buckets[i] = e.next
		} else {
			prev.next = e.next
		}
		m.size--
		return true
	}
	return false
}

// stats, zincir uzunluklarını raporlar: tablonun sağlığını gösterir
func (m *ChainedMap) stats() (longest int, empty int) {
	for _, head := range m.buckets {
		n := 0
		for e := head; e != nil; e = e.next {
			n++
		}
		if n == 0 {
			empty++
		}
		longest = max(longest, n)
	}
	return longest, empty
}

func main() {
	m := NewChainedMap(8)

	words := strings.Fields("go dilim harita ağaç graf yığın kuyruk heap trie hash")
	for i, w := range words {
		m.Put(w, i+1)
	}

	fmt.Println("eleman sayısı:", m.size)
	longest, empty := m.stats()
	fmt.Printf("en uzun zincir: %d | boş kova: %d | yük faktörü: %.2f\n",
		longest, empty, float64(m.size)/float64(len(m.buckets)))

	v, ok := m.Get("harita")
	fmt.Println("harita →", v, ok)

	m.Put("harita", 99) // güncelleme
	v, _ = m.Get("harita")
	fmt.Println("güncellendi →", v, "eleman sayısı:", m.size)

	fmt.Println("trie silindi mi:", m.Delete("trie"))
	_, ok = m.Get("trie")
	fmt.Println("trie hâlâ var mı:", ok, "| eleman sayısı:", m.size)
	fmt.Println("olmayan anahtarı silme:", m.Delete("yok"))
}
Çıktı
eleman sayısı: 10
en uzun zincir: 3 | boş kova: 1 | yük faktörü: 1.25
harita → 3 true
güncellendi → 99 eleman sayısı: 10
trie silindi mi: true
trie hâlâ var mı: false | eleman sayısı: 9
olmayan anahtarı silme: false

Zincirlemenin avantajı sadeliğidir: Yük faktörü 1'i geçse bile çalışmaya devam eder, silme işlemi basittir. Dezavantajı ise her girdi için bir düğüm ayrılması ve zincirin bellekte dağınık durmasıdır — yani önbellek dostu değildir.

Çakışma çözümü: açık adresleme

İkinci yaklaşım hiç liste kullanmaz: Kova doluysa bir sonraki boş kovaya yerleş. En basit hâline doğrusal yoklama (linear probing) denir.

"elma" → kova 3 (boş)     [ ][ ][ ]["elma"][ ][ ][ ][ ]
"nar"  → kova 3 (dolu!)   → 4'e bak (boş) → yerleş
                          [ ][ ][ ]["elma"]["nar"][ ][ ][ ]

Arama "nar": kova 3'e git, "elma" var ama eşleşmiyor →
             4'e bak, "nar" bulundu.

Arama "muz": kova 3 → 4 → 5 (BOŞ) → yok, aramayı bitir.

Bu yaklaşımdaki en ince nokta silmedir. Bir elemanı silip yerini boş bırakırsan, ondan sonraki elemanların arama zinciri kopar ve bulunamaz hâle gelirler. Çözüm, silinen yere bir mezar taşı (tombstone) işareti koymaktır: "Burası boş ama aramaya devam et."

main.go
package main

import (
	"fmt"
	"hash/fnv"
)

type slotState uint8

const (
	empty slotState = iota
	occupied
	tombstone // silinmiş: arama buradan devam etmeli
)

type slot struct {
	key   string
	value int
	state slotState
}

type OpenAddressMap struct {
	slots []slot
	size  int
}

func NewOpenAddressMap(capacity int) *OpenAddressMap {
	return &OpenAddressMap{slots: make([]slot, max(2, capacity))}
}

func (m *OpenAddressMap) hash(key string) int {
	h := fnv.New32a()
	h.Write([]byte(key))
	return int(h.Sum32() % uint32(len(m.slots)))
}

// Put: doğrusal yoklama ile ilk uygun yeri bulur, yoklama sayısını döndürür
func (m *OpenAddressMap) Put(key string, value int) int {
	start := m.hash(key)
	firstTomb := -1

	for i := range len(m.slots) {
		idx := (start + i) % len(m.slots)
		switch m.slots[idx].state {
		case empty:
			if firstTomb != -1 {
				idx = firstTomb // mezar taşını yeniden kullan
			}
			m.slots[idx] = slot{key: key, value: value, state: occupied}
			m.size++
			return i + 1
		case tombstone:
			if firstTomb == -1 {
				firstTomb = idx
			}
		case occupied:
			if m.slots[idx].key == key {
				m.slots[idx].value = value
				return i + 1
			}
		}
	}
	return -1 // tablo dolu
}

func (m *OpenAddressMap) Get(key string) (int, int, bool) {
	start := m.hash(key)
	for i := range len(m.slots) {
		idx := (start + i) % len(m.slots)
		switch m.slots[idx].state {
		case empty:
			return 0, i + 1, false // boş hücre: anahtar yok
		case occupied:
			if m.slots[idx].key == key {
				return m.slots[idx].value, i + 1, true
			}
		}
		// tombstone: aramaya devam
	}
	return 0, len(m.slots), false
}

func (m *OpenAddressMap) Delete(key string) bool {
	start := m.hash(key)
	for i := range len(m.slots) {
		idx := (start + i) % len(m.slots)
		if m.slots[idx].state == empty {
			return false
		}
		if m.slots[idx].state == occupied && m.slots[idx].key == key {
			m.slots[idx] = slot{state: tombstone} // mezar taşı bırak
			m.size--
			return true
		}
	}
	return false
}

func main() {
	m := NewOpenAddressMap(8)

	for i, k := range []string{"go", "rust", "zig", "elm", "lua", "c"} {
		probes := m.Put(k, i+1)
		fmt.Printf("put %-5s yoklama=%d\n", k, probes)
	}

	fmt.Println()
	for _, k := range []string{"zig", "lua", "yok"} {
		v, probes, ok := m.Get(k)
		fmt.Printf("get %-5s → değer=%d yoklama=%d bulundu=%t\n", k, v, probes, ok)
	}

	fmt.Println()
	fmt.Println("rust silindi mi:", m.Delete("rust"))

	// Mezar taşı sayesinde diğer anahtarlar hâlâ bulunabilir
	for _, k := range []string{"zig", "lua", "c"} {
		_, _, ok := m.Get(k)
		fmt.Printf("silme sonrası %-4s bulunuyor mu: %t\n", k, ok)
	}
	fmt.Println("eleman sayısı:", m.size)
}
Çıktı
put go    yoklama=1
put rust  yoklama=1
put zig   yoklama=1
put elm   yoklama=2
put lua   yoklama=2
put c     yoklama=4

get zig   → değer=3 yoklama=1 bulundu=true
get lua   → değer=5 yoklama=2 bulundu=true
get yok   → değer=0 yoklama=1 bulundu=false

rust silindi mi: true
silme sonrası zig  bulunuyor mu: true
silme sonrası lua  bulunuyor mu: true
silme sonrası c    bulunuyor mu: true
eleman sayısı: 5

Açık adreslemenin büyük avantajı bellek yerleşimidir: Tüm veri tek bir bitişik dizide durur, işaretçi yoktur, önbellek davranışı mükemmeldir. Dezavantajı, yük faktörüne çok duyarlı olmasıdır. Tablo dolulaştıkça yoklama zincirleri hızla uzar; bu yüzden açık adresleme kullanan tablolar genelde yük faktörünü 0,7'nin altında tutar.

Ayrıca kümelenme (clustering) sorunu vardır: Dolu hücreler yan yana biriktikçe yeni gelen anahtarlar da o kümenin sonuna eklenir ve küme daha da büyür. Bunu hafifletmek için karesel yoklama veya çift hash gibi yöntemler kullanılır.

Yük faktörü ve yeniden boyutlandırma

Yük faktörü (load factor), eleman sayısının kova sayısına oranıdır. Hash tablosunun performansını belirleyen tek sayı odur.

yük faktörü α = eleman_sayısı / kova_sayısı

zincirleme:       ortalama zincir uzunluğu ≈ α
                  α = 0.75 → aramada ortalama 0.75 karşılaştırma

açık adresleme:   ortalama yoklama ≈ 1 / (1 - α)
                  α = 0.50 → 2 yoklama
                  α = 0.90 → 10 yoklama
                  α = 0.99 → 100 yoklama  ← felaket

Bu yüzden her hash tablosu bir eşik belirler ve aşıldığında yeniden boyutlandırır: Daha büyük bir dizi ayırır ve tüm elemanları yeniden yerleştirir. Elemanlar yeniden hash'lenmek zorundadır, çünkü kova sayısı değişmiştir.

main.go
package main

import (
	"fmt"
	"hash/fnv"
)

type entry[V any] struct {
	key   string
	value V
	next  *entry[V]
}

type HashMap[V any] struct {
	buckets   []*entry[V]
	size      int
	maxLoad   float64
	rehashes  int
	totalMove int
}

func NewHashMap[V any]() *HashMap[V] {
	return &HashMap[V]{buckets: make([]*entry[V], 8), maxLoad: 0.75}
}

func (m *HashMap[V]) index(key string, n int) int {
	h := fnv.New32a()
	h.Write([]byte(key))
	return int(h.Sum32() % uint32(n))
}

func (m *HashMap[V]) Load() float64 {
	return float64(m.size) / float64(len(m.buckets))
}

// grow: kova sayısını ikiye katlar ve tüm elemanları yeniden yerleştirir
func (m *HashMap[V]) grow() {
	newBuckets := make([]*entry[V], len(m.buckets)*2)
	for _, head := range m.buckets {
		for e := head; e != nil; {
			next := e.next
			i := m.index(e.key, len(newBuckets))
			e.next = newBuckets[i]
			newBuckets[i] = e
			m.totalMove++
			e = next
		}
	}
	m.buckets = newBuckets
	m.rehashes++
}

func (m *HashMap[V]) Put(key string, value V) {
	i := m.index(key, len(m.buckets))
	for e := m.buckets[i]; e != nil; e = e.next {
		if e.key == key {
			e.value = value
			return
		}
	}
	m.buckets[i] = &entry[V]{key: key, value: value, next: m.buckets[i]}
	m.size++

	if m.Load() > m.maxLoad {
		m.grow()
	}
}

func (m *HashMap[V]) Get(key string) (V, bool) {
	var zero V
	for e := m.buckets[m.index(key, len(m.buckets))]; e != nil; e = e.next {
		if e.key == key {
			return e.value, true
		}
	}
	return zero, false
}

func (m *HashMap[V]) Len() int { return m.size }

func main() {
	m := NewHashMap[int]()

	fmt.Printf("%6s %6s %6s %6s\n", "eleman", "kova", "yük", "rehash")
	for i := 1; i <= 200; i++ {
		m.Put(fmt.Sprintf("anahtar-%d", i), i)
		if i == 1 || i%50 == 0 {
			fmt.Printf("%6d %6d %6.2f %6d\n", m.Len(), len(m.buckets), m.Load(), m.rehashes)
		}
	}

	fmt.Println()
	fmt.Println("toplam taşınan eleman:", m.totalMove)
	fmt.Println("eleman başına ortalama taşıma:", float64(m.totalMove)/float64(m.Len()))

	v, ok := m.Get("anahtar-137")
	fmt.Println("anahtar-137 →", v, ok)
	_, ok = m.Get("anahtar-999")
	fmt.Println("anahtar-999 bulundu mu:", ok)
}
Çıktı
eleman   kova    yük rehash
     1      8   0.12      0
    50    128   0.39      4
   100    256   0.39      5
   150    256   0.59      5
   200    512   0.39      6

toplam taşınan eleman: 384
eleman başına ortalama taşıma: 1.92
anahtar-137 → 137 true
anahtar-999 bulundu mu: false

Toplam taşıma sayısına dikkat et: 200 eleman için yapılan toplam yeniden yerleştirme, eleman sayısıyla doğru orantılıdır. Bu, dinamik dizideki katlamalı büyümenin aynı analizidir — her yeniden boyutlandırma pahalıdır ama giderek seyrekleşir, dolayısıyla ekleme başına amortize maliyet O(1) kalır.

Go map'lerinin iç yapısı

Go'nun yerleşik map'i, uzun yıllar kovalara sekiz anahtar sığdıran bir zincirleme varyantı kullandı. Go 1.24 ile birlikte uygulama Swiss table adı verilen modern bir tasarıma geçti.

Swiss table'ın ana fikri şudur: Anahtarların hash değerinin küçük bir parçası (birkaç bit) ayrı bir kontrol baytları dizisinde tutulur. Bir arama yapılırken önce bu küçük dizi taranır ve bu tarama, işlemcinin vektör komutlarıyla aynı anda birçok hücreyi karşılaştırarak yapılır. Böylece asıl anahtar karşılaştırmasına çok daha az sayıda aday kalır.

Pratik sonuçları şunlardır: Aramalar özellikle çakışmalı durumlarda belirgin biçimde hızlandı, bellek kullanımı azaldı ve büyük map'lerde performans daha kararlı hâle geldi. Kullanıcı olarak API'de hiçbir şey değişmedi — bu, Go'nun uyumluluk sözü sayesinde alt katmanın serbestçe geliştirilebilmesinin iyi bir örneğidir.

Bilmen gereken davranışlar ise değişmedi:

  • Anahtar tipi karşılaştırılabilir olmalıdır; dilim, harita ve fonksiyon anahtar olamaz.
  • Gezinme sırası kasıtlı olarak rastgeledir.
  • nil bir map'ten okumak güvenlidir (sıfır değer döner) ama yazmak panik oluşturur.
  • Map elemanlarının adresi alınamaz; &m["a"] derlenmez, çünkü rehash sırasında elemanlar taşınır.
  • Map eşzamanlı yazmaya karşı korumasızdır; çalışma zamanı bunu tespit ederse programı sonlandırır.
main.go
package main

import (
	"fmt"
	"maps"
	"slices"
)

type Point struct{ X, Y int }

func main() {
	// Karşılaştırılabilir her tip anahtar olabilir
	grid := map[Point]string{
		{0, 0}: "başlangıç",
		{2, 3}: "hedef",
	}
	fmt.Println("struct anahtar:", grid[Point{2, 3}])

	// Virgül-ok deyimi: yok mu, yoksa sıfır değer mi?
	scores := map[string]int{"ali": 0}
	v, ok := scores["ali"]
	_, missing := scores["veli"]
	fmt.Printf("ali=%d var=%t | veli var=%t\n", v, ok, missing)

	// nil map: okuma güvenli, yazma panik
	var nilMap map[string]int
	fmt.Println("nil map'ten okuma:", nilMap["herhangi"], "uzunluk:", len(nilMap))

	// Kapasite ipucu vermek rehash sayısını azaltır
	sized := make(map[int]int, 1000)
	for i := range 1000 {
		sized[i] = i * i
	}
	fmt.Println("önceden boyutlandırılmış map:", len(sized), "| 999²:", sized[999])

	// Deterministik gezinme
	stock := map[string]int{"kiraz": 30, "elma": 12, "armut": 7}
	for _, k := range slices.Sorted(maps.Keys(stock)) {
		fmt.Printf("%s=%d ", k, stock[k])
	}
	fmt.Println()

	// clear ve maps.Clone
	clone := maps.Clone(stock)
	clear(stock)
	fmt.Println("temizlenen:", len(stock), "| klon:", len(clone))
}
Çıktı
struct anahtar: hedef
ali=0 var=true | veli var=false
nil map'ten okuma: 0 uzunluk: 0
önceden boyutlandırılmış map: 1000 | 999²: 998001
armut=7 elma=12 kiraz=30
temizlenen: 0 | klon: 3

Uygulamalar

Hash tabloları, pek çok algoritmanın karmaşıklığını bir sınıf birden düşürür. İki klasik örnek:

main.go
package main

import (
	"fmt"
	"maps"
	"slices"
	"strings"
)

// wordFrequency: O(n) — her kelime bir kez işlenir
func wordFrequency(text string) map[string]int {
	counts := make(map[string]int)
	for word := range strings.FieldsSeq(strings.ToLower(text)) {
		word = strings.Trim(word, ".,!?;:")
		if word != "" {
			counts[word]++
		}
	}
	return counts
}

// twoSum: hedef toplamı veren iki indeksi O(n) sürede bulur.
// Kaba kuvvet çözümü O(n²) olurdu.
func twoSum(nums []int, target int) (int, int, bool) {
	seen := make(map[int]int, len(nums)) // değer → indeks
	for i, v := range nums {
		if j, ok := seen[target-v]; ok {
			return j, i, true
		}
		seen[v] = i
	}
	return 0, 0, false
}

// groupAnagrams: harfleri sıralanmış hâli anahtar olarak kullanır
func groupAnagrams(words []string) map[string][]string {
	groups := make(map[string][]string)
	for _, w := range words {
		letters := []rune(strings.ToLower(w))
		slices.Sort(letters)
		key := string(letters)
		groups[key] = append(groups[key], w)
	}
	return groups
}

// firstUnique: ilk tekrar etmeyen karakteri bulur
func firstUnique(s string) (rune, bool) {
	counts := make(map[rune]int)
	for _, r := range s {
		counts[r]++
	}
	for _, r := range s {
		if counts[r] == 1 {
			return r, true
		}
	}
	return 0, false
}

func main() {
	text := "Go basit bir dildir. Go hızlıdır, Go güçlüdür!"
	freq := wordFrequency(text)
	for _, w := range slices.Sorted(maps.Keys(freq)) {
		fmt.Printf("%s=%d ", w, freq[w])
	}
	fmt.Println()

	nums := []int{2, 7, 11, 15, 3}
	i, j, ok := twoSum(nums, 18)
	fmt.Printf("twoSum(18) → indeks %d ve %d (%d+%d) bulundu=%t\n", i, j, nums[i], nums[j], ok)
	_, _, ok = twoSum(nums, 100)
	fmt.Println("twoSum(100) bulundu mu:", ok)

	groups := groupAnagrams([]string{"kale", "kela", "elak", "masa", "sama", "kitap"})
	for _, k := range slices.Sorted(maps.Keys(groups)) {
		fmt.Printf("%s%v\n", k, groups[k])
	}

	r, found := firstUnique("aabbcdeedc f")
	fmt.Printf("ilk tekrarsız karakter: %q (bulundu=%t)\n", r, found)
}
Çıktı
basit=1 bir=1 dildir=1 go=3 güçlüdür=1 hızlıdır=1
twoSum(18) → indeks 1 ve 2 (7+11) bulundu=true
twoSum(100) bulundu mu: false
aams → [masa sama]
aekl → [kale kela elak]
aikpt → [kitap]
ilk tekrarsız karakter: ' ' (bulundu=true)

twoSum örneği, hash tablolarının algoritma tasarımındaki rolünü mükemmel gösterir: "Daha önce gördüklerimi hatırla" fikri, iç içe döngüyü tek döngüye indirir. Bu kalıbı pek çok problemde göreceksin — bellek harcayıp zaman kazanmak, algoritma tasarımının en temel takasıdır.

LRU önbellek tasarımı

Hash tablosunun en bilinen bileşik kullanımı, en az kullanılanı atan (LRU) önbellektir. Tek bir yapı yeterli değildir: Harita anahtardan değere anında erişim verir ama "en eski hangisiydi?" sorusunu yanıtlayamaz; çift yönlü liste sırayı bilir ama arama yapamaz. İkisini birleştirirsen her iki soruyu da O(1) sürede yanıtlarsın.

     harita: anahtar → düğüm işaretçisi
     ┌──────────────────────────────┐
     │ "a"→●  "b"→●  "c"→●          │
     └───┬──────┬──────┬────────────┘
         │      │      │
   baş ◄─┴──────┴──────┴─► son
   (en yeni)          (en eski = atılacak)

Get(k):  haritadan düğümü bul → listede başa taşı
Put(k):  yeni düğüm başa; kapasite aşıldıysa sondakini at

Bu yapının tam uygulamasını Bağlı Listeler dersinin ileri alıştırmasında kurduk. Buradaki asıl ders, veri yapılarının birleştirilerek tek başlarına veremeyecekleri garantileri sağlayabilmesidir. Gerçek sistemlerde bu kombinasyonlara sık rastlarsın: hash + heap (öncelikli önbellek), hash + trie (otomatik tamamlama), hash + sayaç dizisi (yaklaşık frekans sayımı).

Hash tablosunu ne zaman seçmeli?

Anahtarla arama yapıyorsan. Sözlük semantiği gereken her yerde — önbellek, indeks, arama tablosu, sayaç — ilk akla gelmesi gereken yapı budur.

Üyelik testi yapıyorsan. "Bu eleman kümede var mı?" sorusu için map[T]struct{} ideal bir kümedir; değer için bellek harcamaz.

Tekrarları ayıklıyorsan. Görülen elemanları bir haritada işaretlemek, iç içe döngüleri tek geçişe indirir.

Gruplama yapıyorsan. Bir ölçüte göre gruplamak, hesaplanan anahtarı map'e yazmakla biter.

Buna karşılık hash tablosunun veremediği şeyler de vardır ve bunları bilmek seçim yapmanın diğer yarısıdır. Sıralı gezinme yoktur: Anahtarları sıraya koymak istiyorsan ya her seferinde sıralarsın ya da dengeli bir arama ağacına geçersin. Aralık sorguları yoktur: "10 ile 50 arasındaki anahtarlar" sorusunu yanıtlayamaz. En kötü durum garantisi zayıftır: Kötü niyetli girdiler çakışma saldırısı üretebilir; bu yüzden Go map'leri her çalıştırmada rastgele bir tohum kullanır. Son olarak bellek kullanımı, boş kovalar nedeniyle veri boyutundan belirgin biçimde fazladır.

Sıralı erişim ve aralık sorguları gerekiyorsa İkili Arama Ağaçları, ön ek sorguları gerekiyorsa Trie doğru araçtır.

Sık yapılan hatalar

  • Kötü bir hash fonksiyonu yazmak. Dağılım bozuksa tablo bağlı listeye dönüşür ve O(n)'e düşer.
  • Açık adreslemede silineni boş bırakmak. Arama zinciri kopar; mezar taşı kullan.
  • Yük faktörünü izlememek. Açık adreslemede 0,9 üzeri yük, yoklama sayısını felakete çevirir.
  • nil map'e yazmak. Panik oluşturur; make ile oluştur.
  • Map gezinme sırasına güvenmek. Sıra kasten rastgeledir; deterministik çıktı için anahtarları sırala.
  • Map elemanının adresini almaya çalışmak. Rehash sırasında elemanlar taşındığı için buna izin verilmez.
  • Eşzamanlı yazmayı korumamak. Çalışma zamanı tespit ederse programı sonlandırır; kilit ya da kanal kullan.
  • Kapasite ipucu vermemek. Kaç eleman ekleyeceğini biliyorsan make(map[K]V, n) yazmak rehash sayısını düşürür.

Alıştırmalar

Alıştırma·Küme işlemleri
Kolay

map[T]struct{} kullanarak jenerik bir küme tipi yaz ve kesişim, birleşim, fark işlemlerini ekle. Sonuçları deterministik biçimde sıralı yazdır.

İpucu

Kesişimde küçük kümeyi gezmek daha verimlidir. Sıralı çıktı için slices.Sorted(maps.Keys(...)) kullan.

Çözümü göster
main.go
package main

import (
	"cmp"
	"fmt"
	"maps"
	"slices"
)

type Set[T comparable] map[T]struct{}

func NewSet[T comparable](values ...T) Set[T] {
	s := make(Set[T], len(values))
	for _, v := range values {
		s[v] = struct{}{}
	}
	return s
}

func (s Set[T]) Add(v T)      { s[v] = struct{}{} }
func (s Set[T]) Has(v T) bool { _, ok := s[v]; return ok }
func (s Set[T]) Len() int     { return len(s) }

func Union[T comparable](a, b Set[T]) Set[T] {
	out := make(Set[T], len(a)+len(b))
	maps.Copy(out, a)
	maps.Copy(out, b)
	return out
}

func Intersection[T comparable](a, b Set[T]) Set[T] {
	if len(b) < len(a) {
		a, b = b, a // küçük olanı gez
	}
	out := make(Set[T])
	for v := range a {
		if b.Has(v) {
			out.Add(v)
		}
	}
	return out
}

func Difference[T comparable](a, b Set[T]) Set[T] {
	out := make(Set[T])
	for v := range a {
		if !b.Has(v) {
			out.Add(v)
		}
	}
	return out
}

func sorted[T cmp.Ordered](s Set[T]) []T {
	return slices.Sorted(maps.Keys(s))
}

func main() {
	a := NewSet(1, 2, 3, 4, 5)
	b := NewSet(4, 5, 6, 7)

	fmt.Println("a:", sorted(a))
	fmt.Println("b:", sorted(b))
	fmt.Println("birleşim: ", sorted(Union(a, b)))
	fmt.Println("kesişim:  ", sorted(Intersection(a, b)))
	fmt.Println("a - b:    ", sorted(Difference(a, b)))
	fmt.Println("b - a:    ", sorted(Difference(b, a)))

	words := NewSet("go", "dilim", "go", "harita")
	fmt.Println("tekrarlar ayıklandı:", sorted(words), "uzunluk:", words.Len())
	fmt.Println("go var mı:", words.Has("go"), "| rust var mı:", words.Has("rust"))
}
Çıktı
a: [1 2 3 4 5]
b: [4 5 6 7]
birleşim:  [1 2 3 4 5 6 7]
kesişim:   [4 5]
a - b:     [1 2 3]
b - a:     [6 7]
tekrarlar ayıklandı: [dilim go harita] uzunluk: 3
go var mı: true | rust var mı: false
ZamanO(n + m)AlanO(n + m)

struct{} sıfır bayt yer kaplar, bu yüzden map[T]bool yerine map[T]struct{} kullanmak büyük kümelerde ölçülebilir bellek kazandırır. Kesişimde küçük kümeyi gezmek de küçük ama bedava bir iyileştirmedir: Karmaşıklık iki kümenin küçüğüne bağlı hâle gelir.

Alıştırma·Çakışma sayacı
Orta

Verilen bir hash fonksiyonu ve kova sayısı için, bir anahtar kümesinin kaç çakışma ürettiğini ölçen bir araç yaz. Üç farklı kova sayısıyla (asal ve ikinin kuvveti) karşılaştır ve en uzun zincir uzunluğunu raporla.

İpucu

Çakışma sayısı = eleman sayısı − dolu kova sayısı. Kova sayısını değiştirdiğinde dağılımın nasıl etkilendiğine bak.

Çözümü göster
main.go
package main

import (
	"fmt"
	"hash/fnv"
)

func hashIndex(key string, buckets int) int {
	h := fnv.New32a()
	h.Write([]byte(key))
	return int(h.Sum32() % uint32(buckets))
}

type report struct {
	buckets    int
	collisions int
	longest    int
	used       int
}

func analyze(keys []string, buckets int) report {
	counts := make([]int, buckets)
	for _, k := range keys {
		counts[hashIndex(k, buckets)]++
	}

	r := report{buckets: buckets}
	for _, c := range counts {
		if c > 0 {
			r.used++
		}
		r.longest = max(r.longest, c)
	}
	r.collisions = len(keys) - r.used
	return r
}

func main() {
	// 60 anahtar üret
	keys := make([]string, 0, 60)
	for i := range 60 {
		keys = append(keys, fmt.Sprintf("kullanıcı-%d", i*7))
	}

	fmt.Printf("%d anahtar\n\n", len(keys))
	fmt.Printf("%8s %12s %12s %10s %8s\n", "kova", "kullanılan", "çakışma", "en uzun", "yük")

	for _, b := range []int{16, 31, 64, 97, 128} {
		r := analyze(keys, b)
		fmt.Printf("%8d %12d %12d %10d %8.2f\n",
			r.buckets, r.used, r.collisions, r.longest, float64(len(keys))/float64(r.buckets))
	}

	fmt.Println()
	fmt.Println("Gözlem: kova sayısı arttıkça çakışma azalır, ama boş kova oranı artar.")
}
Çıktı
60 anahtar

    kova   kullanılan      çakışma    en uzun      yük
      16           16           44          8     3.75
      31           28           32          5     1.94
      64           38           22          4     0.94
      97           42           18          3     0.62
     128           48           12          3     0.47

Gözlem: kova sayısı arttıkça çakışma azalır, ama boş kova oranı artar.

Sonuçlar iki şeyi gösterir. Birincisi, yük faktörü düştükçe çakışma sayısı da düşer — ama bunun bedeli boşa giden bellektir. İkincisi, iyi bir hash fonksiyonu kullanıldığında kova sayısının asal olup olmaması büyük fark yaratmaz; bu, zayıf hash fonksiyonlarıyla çalışan eski uygulamalarda önemliydi, çünkü asal modül, düzenli desenlerin yarattığı yığılmayı dağıtır.

Alıştırma·Ekleme sırasını koruyan harita
Zor

Anahtarların eklenme sırasını koruyan bir harita yaz: Set, Get, Delete ve sıralı gezinme sağlayan Keys metotları olsun. Tüm işlemler ortalama O(1) olmalı (gezinme hariç).

İpucu

Bir harita (anahtar → indeks) ve bir dilim (sıra) tut. Silme sırasında diliği kaydırmak O(n) olur; bunun yerine "silinmiş" işareti koyup eleman sayısı belirli bir eşiği geçince sıkıştır.

Çözümü göster
main.go
package main

import "fmt"

type OrderedMap[V any] struct {
	index   map[string]int // anahtar → order dilimindeki konum
	order   []string       // ekleme sırası ("" = silinmiş)
	values  map[string]V
	deleted int
}

func NewOrderedMap[V any]() *OrderedMap[V] {
	return &OrderedMap[V]{index: map[string]int{}, values: map[string]V{}}
}

func (m *OrderedMap[V]) Set(key string, value V) {
	if _, exists := m.values[key]; !exists {
		m.index[key] = len(m.order)
		m.order = append(m.order, key)
	}
	m.values[key] = value // var olan anahtarın sırası korunur
}

func (m *OrderedMap[V]) Get(key string) (V, bool) {
	v, ok := m.values[key]
	return v, ok
}

func (m *OrderedMap[V]) Delete(key string) bool {
	i, ok := m.index[key]
	if !ok {
		return false
	}
	m.order[i] = "" // mezar taşı
	delete(m.index, key)
	delete(m.values, key)
	m.deleted++

	if m.deleted > len(m.order)/2 { // yarıdan fazlası çöpse sıkıştır
		m.compact()
	}
	return true
}

func (m *OrderedMap[V]) compact() {
	kept := m.order[:0]
	for _, k := range m.order {
		if k == "" {
			continue
		}
		m.index[k] = len(kept)
		kept = append(kept, k)
	}
	m.order = kept
	m.deleted = 0
}

func (m *OrderedMap[V]) Keys() []string {
	out := make([]string, 0, len(m.values))
	for _, k := range m.order {
		if k != "" {
			out = append(out, k)
		}
	}
	return out
}

func (m *OrderedMap[V]) Len() int { return len(m.values) }

func main() {
	m := NewOrderedMap[int]()

	for i, k := range []string{"zeta", "alfa", "beta", "gama", "delta"} {
		m.Set(k, i+1)
	}
	fmt.Println("ekleme sırası:", m.Keys())

	m.Set("alfa", 99) // güncelleme sırayı değiştirmez
	v, _ := m.Get("alfa")
	fmt.Println("alfa güncellendi:", v, "→", m.Keys())

	fmt.Println("beta silindi mi:", m.Delete("beta"))
	fmt.Println("silme sonrası:", m.Keys(), "uzunluk:", m.Len())

	fmt.Println("gama silindi mi:", m.Delete("gama"))
	fmt.Println("zeta silindi mi:", m.Delete("zeta"))
	fmt.Println("sıkıştırma sonrası:", m.Keys(), "| iç dilim uzunluğu:", len(m.order))

	m.Set("yeni", 7)
	fmt.Println("yeni eklendi:", m.Keys())

	_, ok := m.Get("beta")
	fmt.Println("beta hâlâ var mı:", ok)
	fmt.Println("olmayanı silme:", m.Delete("yok"))
}
Çıktı
ekleme sırası: [zeta alfa beta gama delta]
alfa güncellendi: 99 → [zeta alfa beta gama delta]
beta silindi mi: true
silme sonrası: [zeta alfa gama delta] uzunluk: 4
gama silindi mi: true
zeta silindi mi: true
sıkıştırma sonrası: [alfa delta] | iç dilim uzunluğu: 2
yeni eklendi: [alfa delta yeni]
beta hâlâ var mı: false
olmayanı silme: false
ZamanO(1) amortizeAlanO(n)

Bu yapı, Go'nun map'inin vermediği tek şeyi — sıra garantisini — ekler ve bunu hash tablosunun hızından ödün vermeden yapar. Sıkıştırma stratejisi, kuyruk dersinde gördüğün yaklaşımın aynısıdır: Her silmede kaydırmak O(n) olurdu; çöp oranı belirli bir eşiği geçtiğinde tek seferde temizlemek işi amortize O(1)'de tutar.

Aynı fikri, JSON nesnelerinde alan sırasını korumak, yapılandırma dosyalarını yeniden yazarken düzeni bozmamak ve ekleme sırasına duyarlı önbellekler kurmak için kullanabilirsin.

Kısa sınav

Kısa sınav

İyi bir hash fonksiyonunun en kritik özelliği hangisidir?

Açık adreslemede bir eleman silinirken yerine neden mezar taşı konur?

Açık adreslemede yük faktörü 0,9 olduğunda ortalama yoklama sayısı yaklaşık kaçtır?

Hash tablosunda yeniden boyutlandırma neden tüm elemanların yeniden hash'lenmesini gerektirir?

Go map`lerinde hangisi DOĞRU değildir?

Hash tablosunun sağlayamadığı şey nedir?

Özet

  • Hash tablosu, anahtardan doğrudan adres hesaplayarak ortalama O(1) arama, ekleme ve silme sağlar.
  • İyi bir hash fonksiyonu deterministik, hızlı ve düzgün dağıtan bir fonksiyondur.
  • Ayrı zincirleme her kovada liste tutar: basit, silmesi kolay, yüksek yük faktörüne dayanıklı ama önbellek dostu değil.
  • Açık adresleme veriyi tek dizide tutar: önbellek dostu ve hızlı, ancak yük faktörüne çok duyarlı ve silme için mezar taşı gerektirir.
  • Yük faktörü performansı belirler; eşik aşılınca tablo büyütülür ve tüm elemanlar yeniden hash'lenir — amortize maliyet yine O(1)'dir.
  • Go map'i 1.24'ten beri Swiss table tasarımını kullanır; API değişmedi, aramalar hızlandı ve bellek kullanımı azaldı.
  • Map anahtarları karşılaştırılabilir olmalıdır; gezinme sırası rastgeledir, eleman adresi alınamaz, eşzamanlı yazma korunmalıdır.
  • Frekans sayma, üyelik testi, gruplama ve "gördüklerimi hatırla" kalıbı hash tablolarının klasik uygulamalarıdır.
  • Sıralı gezinme ve aralık sorguları gerekiyorsa hash tablosu yetmez; arama ağaçlarına geçilir.
Bu dersi bitirdin mi?
İlerlemen bu tarayıcıda saklanır.