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.
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)
}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:
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"))
}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."
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)
}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 ← felaketBu 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.
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)
}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: falseToplam 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.
nilbir 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.
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))
}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:
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)
}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 atBu 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.
nilmap'e yazmak. Panik oluşturur;makeile 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
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
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"))
}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
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.
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
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.")
}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.
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
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"))
}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
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
İ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.