go
Veri Yapıları dersleri
Veri Yapıları/Ağaçlar

Trie (Önek Ağacı)

Önek araması, otomatik tamamlama ve Türkçe karakter desteği.

Ders 11 / 1425 dkOrta
Bu derste öğreneceklerin
  • Trie yapısı ve kullanım alanları
  • Ekleme ve arama
  • Önek ile başlama kontrolü
  • Otomatik tamamlama
  • Silme işlemi
  • map[rune] ile Unicode desteği
  • Bellek değerlendirmeleri

Bir arama kutusuna "mer" yazdığında açılan öneri listesini düşün: "merhaba", "merak", "mermer", "meridyen". Bu listeyi nasıl bu kadar hızlı üretiyor? Tüm sözlüğü tarayıp her kelimenin "mer" ile başlayıp başlamadığını kontrol etmek O(n × k) demektir; milyonlarca kelimede her tuş vuruşunda bunu yapmak mümkün değildir.

Trie (ön ek ağacı), bu problemi kelimelerin ortak ön eklerini paylaştırarak çözer. Ağaçtaki her kenar bir karakteri temsil eder; kökten bir düğüme giden yol da bir ön eki. "mer" ön ekini bulmak üç adım sürer; oradan aşağıdaki her şey zaten "mer" ile başlayan kelimelerdir. Arama süresi, sözlüğün büyüklüğünden tamamen bağımsız hâle gelir — yalnızca aranan kelimenin uzunluğuna bağlıdır.

Adı, İngilizce "retrieval" (erişim) kelimesinin ortasından gelir ve genelde "tray" gibi okunur. Otomatik tamamlama, yazım denetimi, IP yönlendirme tabloları, sözlük uygulamaları ve kelime oyunları çözücüleri hep bu yapıyı kullanır. Bu derste trie'nin yapısını, ekleme ve arama işlemlerini, ön ek sorgularını, otomatik tamamlamayı, silmenin inceliklerini, Türkçe karakter desteğini ve bellek değerlendirmelerini öğreneceksin.

Trie yapısı

Kelimeler: "kar", "kart", "kare", "kedi", "ev"

              (kök)
             ╱     ╲
            k       e
           ╱         ╲
          a           v ●     ← "ev"

        r ●                   ← "kar"
       ╱ ╲
      t ● e ●                 ← "kart", "kare"

● = burada bir kelime bitiyor (isEnd = true)

"kar", "kart" ve "kare" ilk üç harfi PAYLAŞIR.
Depolama: 3 kelime × 4 harf = 12 yerine yalnızca 7 düğüm.

Trie'nin iki tanımlayıcı özelliği vardır. Birincisi, değer düğümde değil, yolda saklanır: Bir düğümün kendisi hangi karaktere karşılık geldiğini bilmez; o bilgi, ona gelen kenardadır. İkincisi, bir düğümün "kelime sonu" olup olmadığı ayrı bir bayrakla işaretlenir — çünkü "kar" hem bir kelimedir hem de "kart" kelimesinin ön ekidir.

Aşağıdaki görselleştirmede kelime ekleyip arayarak paylaşılan ön eklerin nasıl birleştiğini izleyebilirsin:

Trie
Görselleştirme hazırlanıyor.
{}
İşlemKarmaşıklıkAçıklama
EklemeO(k)k = kelime uzunluğu
AramaO(k)Sözlük boyutundan bağımsız
Ön ek kontrolüO(k)Aynı yürüyüş
Ön ekle başlayanları listelemeO(k + m)m = sonuç sayısı
BellekO(toplam karakter)Ortak ön ekler paylaşılır
ZamanO(k)AlanO(n × k)

Ekleme ve arama

main.go
package main

import "fmt"

type TrieNode struct {
	children map[rune]*TrieNode
	isEnd    bool // burada bir kelime bitiyor mu
}

type Trie struct {
	root  *TrieNode
	size  int
	nodes int
}

func NewTrie() *Trie {
	return &Trie{root: &TrieNode{children: map[rune]*TrieNode{}}, nodes: 1}
}

// Insert: O(k)
func (t *Trie) Insert(word string) bool {
	node := t.root
	for _, r := range word {
		child, ok := node.children[r]
		if !ok {
			child = &TrieNode{children: map[rune]*TrieNode{}}
			node.children[r] = child
			t.nodes++
		}
		node = child
	}
	if node.isEnd {
		return false // zaten var
	}
	node.isEnd = true
	t.size++
	return true
}

// find: verilen ön ekin sonundaki düğümü döndürür
func (t *Trie) find(prefix string) *TrieNode {
	node := t.root
	for _, r := range prefix {
		child, ok := node.children[r]
		if !ok {
			return nil
		}
		node = child
	}
	return node
}

// Search: tam kelime araması — O(k)
func (t *Trie) Search(word string) bool {
	node := t.find(word)
	return node != nil && node.isEnd
}

// StartsWith: ön ek kontrolü — O(k)
func (t *Trie) StartsWith(prefix string) bool {
	return t.find(prefix) != nil
}

func (t *Trie) Len() int { return t.size }

func main() {
	tr := NewTrie()

	words := []string{"kar", "kart", "kare", "kedi", "ev", "evet"}
	for _, w := range words {
		tr.Insert(w)
	}

	fmt.Println("kelime sayısı:", tr.Len(), "| düğüm sayısı:", tr.nodes)
	fmt.Println("tekrar ekleme kabul edildi mi:", tr.Insert("kar"))

	fmt.Println()
	for _, w := range []string{"kar", "kart", "ka", "kartal", "ev", "e"} {
		fmt.Printf("%-7q tam kelime=%-5t ön ek olarak var=%t\n",
			w, tr.Search(w), tr.StartsWith(w))
	}

	fmt.Println()
	fmt.Println("Dikkat: \"ka\" bir ön ek ama kelime değil.")
	fmt.Println("Dikkat: \"kar\" hem kelime hem ön ek.")
}
Çıktı
kelime sayısı: 6 | düğüm sayısı: 13
tekrar ekleme kabul edildi mi: false

"kar"   tam kelime=true  ön ek olarak var=true
"kart"  tam kelime=true  ön ek olarak var=true
"ka"    tam kelime=false ön ek olarak var=true
"kartal" tam kelime=false ön ek olarak var=false
"ev"    tam kelime=true  ön ek olarak var=true
"e"     tam kelime=false ön ek olarak var=true

Dikkat: "ka" bir ön ek ama kelime değil.
Dikkat: "kar" hem kelime hem ön ek.

Search ile StartsWith arasındaki fark, trie'yi anlamanın anahtarıdır. İkisi de aynı yürüyüşü yapar; tek fark, Search'ün son düğümdeki isEnd bayrağını da kontrol etmesidir. Bu bayrak olmasaydı "ka" yazdığında sistem bunu bir kelime sanardı.

Ön ekle başlayanları listeleme ve otomatik tamamlama

Trie'nin asıl parladığı yer budur. Ön ekin sonundaki düğümü bul, oradan aşağıya doğru tüm kelimeleri topla:

main.go
package main

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

type TrieNode struct {
	children map[rune]*TrieNode
	isEnd    bool
	freq     int // kelimenin kullanım sıklığı: önerileri sıralamak için
}

type Trie struct {
	root *TrieNode
}

func NewTrie() *Trie {
	return &Trie{root: &TrieNode{children: map[rune]*TrieNode{}}}
}

func (t *Trie) Insert(word string, freq int) {
	node := t.root
	for _, r := range word {
		child, ok := node.children[r]
		if !ok {
			child = &TrieNode{children: map[rune]*TrieNode{}}
			node.children[r] = child
		}
		node = child
	}
	node.isEnd = true
	node.freq = freq
}

func (t *Trie) find(prefix string) *TrieNode {
	node := t.root
	for _, r := range prefix {
		child, ok := node.children[r]
		if !ok {
			return nil
		}
		node = child
	}
	return node
}

type suggestion struct {
	word string
	freq int
}

// collect: düğümden aşağıdaki tüm kelimeleri toplar (DFS)
func collect(node *TrieNode, prefix string, out *[]suggestion) {
	if node == nil {
		return
	}
	if node.isEnd {
		*out = append(*out, suggestion{word: prefix, freq: node.freq})
	}
	// Deterministik sıra için çocukları sıralayarak gez
	keys := make([]rune, 0, len(node.children))
	for r := range node.children {
		keys = append(keys, r)
	}
	slices.Sort(keys)

	for _, r := range keys {
		collect(node.children[r], prefix+string(r), out)
	}
}

// WordsWithPrefix: O(k + m)
func (t *Trie) WordsWithPrefix(prefix string) []string {
	node := t.find(prefix)
	if node == nil {
		return nil
	}
	var found []suggestion
	collect(node, prefix, &found)

	out := make([]string, len(found))
	for i, s := range found {
		out[i] = s.word
	}
	return out
}

// Autocomplete: sıklığa göre sıralı ilk n öneri
func (t *Trie) Autocomplete(prefix string, n int) []string {
	node := t.find(prefix)
	if node == nil {
		return nil
	}
	var found []suggestion
	collect(node, prefix, &found)

	slices.SortFunc(found, func(a, b suggestion) int {
		if a.freq != b.freq {
			return b.freq - a.freq // sıklığa göre azalan
		}
		return strings.Compare(a.word, b.word) // eşitlikte alfabetik
	})

	out := make([]string, 0, n)
	for i := 0; i < len(found) && i < n; i++ {
		out = append(out, found[i].word)
	}
	return out
}

func main() {
	tr := NewTrie()

	dictionary := map[string]int{
		"merhaba":  980,
		"merak":    640,
		"mermer":   120,
		"meridyen": 15,
		"merdiven": 310,
		"masa":     500,
		"makas":    90,
	}
	for w, f := range dictionary {
		tr.Insert(w, f)
	}

	fmt.Println(`"mer" ile başlayanlar:`, tr.WordsWithPrefix("mer"))
	fmt.Println(`"ma" ile başlayanlar: `, tr.WordsWithPrefix("ma"))
	fmt.Println(`"xyz" ile başlayanlar:`, tr.WordsWithPrefix("xyz"))

	fmt.Println()
	fmt.Println("otomatik tamamlama (sıklığa göre ilk 3):")
	for _, prefix := range []string{"mer", "me", "m"} {
		fmt.Printf("  %-5q%v\n", prefix, tr.Autocomplete(prefix, 3))
	}

	fmt.Println()
	fmt.Println("tüm sözlük:", tr.WordsWithPrefix(""))
}
Çıktı
"mer" ile başlayanlar: [merak merdiven merhaba meridyen mermer]
"ma" ile başlayanlar:  [makas masa]
"xyz" ile başlayanlar: []

otomatik tamamlama (sıklığa göre ilk 3):
  "mer" → [merhaba merak merdiven]
  "me"  → [merhaba merak merdiven]
  "m"   → [merhaba merak masa]

tüm sözlük: [makas masa merak merdiven merhaba meridyen mermer]

Otomatik tamamlamayı gerçek sistemlerde daha da hızlandırmanın bir yolu vardır: Her düğümde, altındaki en sık kullanılan kelimeleri önceden hesaplanmış olarak saklamak. O zaman öneri üretmek için alt ağacı hiç gezmen gerekmez — tek bir yürüyüşle cevaba ulaşırsın. Bu, bellek karşılığında zaman kazanmanın bir başka örneğidir.

Silme

Silme, trie'nin en dikkat isteyen işlemidir. Bir kelimeyi silmek, düğümlerini kaldırmak anlamına gelmez — o düğümler başka kelimeler tarafından paylaşılıyor olabilir.

"kar", "kart" varken "kar" silinirse:

        k → a → r ● → t ●        →      k → a → r → t ●

                          yalnızca isEnd bayrağı kalkar,
                          düğüm silinmez ("kart" ona ihtiyaç duyuyor)

"kart" da silinirse:

        k → a → r → t ●          →      (kök)
                                 
                          artık kimse kullanmıyor: tüm zincir silinir

Kural şudur: Bir düğüm ancak çocuğu yoksa ve kendisi kelime sonu değilse silinebilir.

main.go
package main

import (
	"fmt"
	"slices"
)

type TrieNode struct {
	children map[rune]*TrieNode
	isEnd    bool
}

type Trie struct {
	root  *TrieNode
	size  int
	nodes int
}

func NewTrie() *Trie {
	return &Trie{root: &TrieNode{children: map[rune]*TrieNode{}}, nodes: 1}
}

func (t *Trie) Insert(word string) {
	node := t.root
	for _, r := range word {
		child, ok := node.children[r]
		if !ok {
			child = &TrieNode{children: map[rune]*TrieNode{}}
			node.children[r] = child
			t.nodes++
		}
		node = child
	}
	if !node.isEnd {
		node.isEnd = true
		t.size++
	}
}

func (t *Trie) Search(word string) bool {
	node := t.root
	for _, r := range word {
		child, ok := node.children[r]
		if !ok {
			return false
		}
		node = child
	}
	return node.isEnd
}

// Delete: kelimeyi siler ve artık kullanılmayan düğümleri temizler
func (t *Trie) Delete(word string) bool {
	runes := []rune(word)

	// remove, alt ağaçtan sonra bu düğümün silinip silinemeyeceğini döndürür
	var remove func(node *TrieNode, depth int) bool
	remove = func(node *TrieNode, depth int) bool {
		if depth == len(runes) {
			if !node.isEnd {
				return false // kelime yok
			}
			node.isEnd = false
			t.size--
			return len(node.children) == 0 // çocuğu yoksa silinebilir
		}

		r := runes[depth]
		child, ok := node.children[r]
		if !ok {
			return false
		}
		if remove(child, depth+1) {
			delete(node.children, r)
			t.nodes--
			// Bu düğüm de gereksizse üste haber ver
			return len(node.children) == 0 && !node.isEnd
		}
		return false
	}

	before := t.size
	remove(t.root, 0)
	return t.size < before
}

func (t *Trie) words() []string {
	var out []string
	var walk func(*TrieNode, string)
	walk = func(n *TrieNode, prefix string) {
		if n.isEnd {
			out = append(out, prefix)
		}
		keys := make([]rune, 0, len(n.children))
		for r := range n.children {
			keys = append(keys, r)
		}
		slices.Sort(keys)
		for _, r := range keys {
			walk(n.children[r], prefix+string(r))
		}
	}
	walk(t.root, "")
	return out
}

func main() {
	tr := NewTrie()
	for _, w := range []string{"kar", "kart", "kare", "ev"} {
		tr.Insert(w)
	}
	fmt.Println("başlangıç:", tr.words(), "| düğüm:", tr.nodes)

	// "kar" siliniyor ama düğümleri "kart" ve "kare" için gerekli
	fmt.Println()
	fmt.Println(`"kar" silindi mi:`, tr.Delete("kar"))
	fmt.Println("kalanlar:", tr.words(), "| düğüm:", tr.nodes, "← düğüm silinmedi")

	// "kart" siliniyor: yalnızca 't' düğümü gider
	fmt.Println()
	fmt.Println(`"kart" silindi mi:`, tr.Delete("kart"))
	fmt.Println("kalanlar:", tr.words(), "| düğüm:", tr.nodes)

	// "kare" de silinince tüm k-a-r-e zinciri temizlenir
	fmt.Println()
	fmt.Println(`"kare" silindi mi:`, tr.Delete("kare"))
	fmt.Println("kalanlar:", tr.words(), "| düğüm:", tr.nodes, "← zincir temizlendi")

	fmt.Println()
	fmt.Println("olmayan kelime silinebildi mi:", tr.Delete("yok"))
	fmt.Println("son durum:", tr.words(), "| kelime sayısı:", tr.size)
}
Çıktı
başlangıç: [ev kar kare kart] | düğüm: 8

"kar" silindi mi: true
kalanlar: [ev kare kart] | düğüm: 8 ← düğüm silinmedi

"kart" silindi mi: true
kalanlar: [ev kare] | düğüm: 7

"kare" silindi mi: true
kalanlar: [ev] | düğüm: 3 ← zincir temizlendi

olmayan kelime silinebildi mi: false
son durum: [ev] | kelime sayısı: 1

Düğüm sayısının nasıl değiştiğine dikkat et: "kar" silindiğinde hiçbir düğüm gitmiyor, çünkü hepsi hâlâ kullanımda. Ancak son kelime de silindiğinde tüm zincir temizleniyor. Bu, özyinelemenin geri dönüş yolunda karar vermenin güzel bir örneğidir.

Unicode ve Türkçe karakterler

Pek çok trie uygulaması, çocukları 26 elemanlı bir dizide ([26]*Node) tutar ve char - 'a' ile indeksler. Bu, İngilizce için hızlıdır ama Türkçe için işe yaramaz: ç, ğ, ı, İ, ö, ş, ü harfleri bu aralığa girmez.

Go'da doğru yaklaşım map[rune]*TrieNode kullanmaktır. rune bir Unicode kod noktası olduğu için her dil doğal olarak desteklenir. Ama bir incelik daha vardır: Metni gezerken bayt bayt değil rune rune ilerlemelisin.

main.go
package main

import (
	"fmt"
	"strings"
	"unicode"
)

type TrieNode struct {
	children map[rune]*TrieNode
	isEnd    bool
}

type Trie struct {
	root       *TrieNode
	normalized bool // ekleme ve aramada küçük harfe çevir
}

func NewTrie(normalized bool) *Trie {
	return &Trie{root: &TrieNode{children: map[rune]*TrieNode{}}, normalized: normalized}
}

// normalize: Türkçe kurallarına göre küçük harfe çevirir
func (t *Trie) normalize(s string) string {
	if !t.normalized {
		return s
	}
	// Türkçe özel durum: I → ı, İ → i
	replaced := strings.NewReplacer("I", "ı", "İ", "i").Replace(s)
	return strings.Map(unicode.ToLower, replaced)
}

func (t *Trie) Insert(word string) {
	node := t.root
	for _, r := range t.normalize(word) { // range string → rune rune gezer
		child, ok := node.children[r]
		if !ok {
			child = &TrieNode{children: map[rune]*TrieNode{}}
			node.children[r] = child
		}
		node = child
	}
	node.isEnd = true
}

func (t *Trie) Search(word string) bool {
	node := t.root
	for _, r := range t.normalize(word) {
		child, ok := node.children[r]
		if !ok {
			return false
		}
		node = child
	}
	return node.isEnd
}

func (t *Trie) StartsWith(prefix string) bool {
	node := t.root
	for _, r := range t.normalize(prefix) {
		child, ok := node.children[r]
		if !ok {
			return false
		}
		node = child
	}
	return true
}

func main() {
	tr := NewTrie(true)

	words := []string{"çilek", "çiçek", "ışık", "İstanbul", "şeker", "ağaç", "ördek"}
	for _, w := range words {
		tr.Insert(w)
	}

	fmt.Println("aramalar:")
	for _, w := range []string{"çilek", "ÇİLEK", "istanbul", "İSTANBUL", "ışık", "IŞIK", "çi"} {
		fmt.Printf("  %-10q kelime=%-5t ön ek=%t\n", w, tr.Search(w), tr.StartsWith(w))
	}

	fmt.Println()
	fmt.Println("bayt ve rune farkı:")
	s := "çiçek"
	fmt.Printf("  %q%d bayt, %d rune\n", s, len(s), len([]rune(s)))
	fmt.Println("  bayt bayt gezilseydi trie bozulurdu: çokbaytlı karakterler parçalanır")

	// Türkçe büyük/küçük harf tuzağı
	fmt.Println()
	fmt.Println("Türkçe I/İ kuralı:")
	fmt.Printf("  strings.ToLower(\"İSTANBUL\") = %q  ← yanlış\n", strings.ToLower("İSTANBUL"))
	fmt.Printf("  Türkçe normalize             = %q  ← doğru\n", tr.normalize("İSTANBUL"))
}
Çıktı
aramalar:
  "çilek"    kelime=true  ön ek=true
  "ÇİLEK"    kelime=true  ön ek=true
  "istanbul" kelime=true  ön ek=true
  "İSTANBUL" kelime=true  ön ek=true
  "ışık"     kelime=true  ön ek=true
  "IŞIK"     kelime=true  ön ek=true
  "çi"       kelime=false ön ek=true

bayt ve rune farkı:
  "çiçek" → 7 bayt, 5 rune
  bayt bayt gezilseydi trie bozulurdu: çokbaytlı karakterler parçalanır

Türkçe I/İ kuralı:
  strings.ToLower("İSTANBUL") = "istanbul"  ← yanlış
  Türkçe normalize             = "istanbul"  ← doğru

Türkçe'deki I/İ sorunu, yazılım dünyasının en meşhur yerelleştirme tuzaklarından biridir. Standart küçük harfe çevirme, İ harfini i ile birlikte bir birleştirme işareti üretecek şekilde dönüştürür; I harfini de i yapar — oysa Türkçe'de I'nın küçüğü ı'dır. Bu ayrıntı Stringler ve Rune'lar dersinde daha ayrıntılı ele alınıyor.

Bellek değerlendirmeleri

Trie'nin hız avantajının bir bedeli vardır: bellek. Her düğümde bir map tutmak, düğüm başına ciddi bir ek yük demektir. Küçük bir sözlükte bu önemsizdir; milyonlarca kelimede ise belirleyici hâle gelir.

Üç yaygın iyileştirme vardır ve hepsi aynı fikre dayanır: Gereksiz düğümleri ortadan kaldır.

Sıkıştırılmış trie (radix ağacı). Tek çocuklu düğüm zincirlerini tek bir düğümde birleştirir. "merhaba" kelimesi tek başınaysa, sekiz düğüm yerine tek bir düğümde "merhaba" metni saklanır. Bu, seyrek sözlüklerde bellek kullanımını dramatik biçimde düşürür ve IP yönlendirme tablolarının standart yapısıdır.

Çocuk gösterimini değiştirmek. Map yerine küçük bir dilim kullanmak, az çocuklu düğümlerde hem bellek hem hız kazandırır: On elemanlı bir dilimde doğrusal arama, map'in hash hesabından genelde daha hızlıdır. Çok çocuklu düğümlerde map'e geçen melez uygulamalar da yaygındır.

Sonekleri de paylaştırmak. Yalnızca ön ekleri değil son ekleri de birleştiren yapılar, sözlüğü bir ağaçtan bir grafa dönüştürür. Sabit sözlüklerde muazzam tasarruf sağlar ama güncelleme yapmayı zorlaştırır.

Peki trie ne zaman doğru seçimdir? Yalnızca "bu kelime var mı" sorusunu soruyorsan hash tablosu daha az bellekle daha hızlı cevap verir. Trie'yi seçmenin gerçek sebebi ön ek sorgularıdır: otomatik tamamlama, "şununla başlayan her şeyi getir", ortak ön ek bulma, sözlük sırasına göre gezinme. Bu ihtiyaçlar yoksa, trie fazladan karmaşıklıktır.

Trie gerçek dünyada nerede?

Trie, adı pek duyulmasa da kullandığın sistemlerin derinliklerinde sürekli çalışır. Nerelerde karşına çıktığını bilmek, ne zaman kendi kodunda kullanman gerektiğini de netleştirir.

Arama önerileri. Bir arama kutusuna yazmaya başladığında beliren liste, neredeyse her zaman bir ön ek yapısı tarafından üretilir. Gerçek sistemlerde bu yapı yalnızca kelimeleri değil, her ön ek için önceden hesaplanmış en popüler tamamlamaları da saklar; böylece her tuş vuruşunda alt ağacı gezmek gerekmez.

Yazım denetimi ve düzeltme. Bir kelimenin sözlükte olup olmadığını kontrol etmek kolay kısmıdır. Asıl iş, yanlış yazılmış bir kelimeye en yakın doğru kelimeleri bulmaktır. Trie burada kazandırır: Düzenleme mesafesi hesaplanırken tüm sözlük yerine ağaç üzerinde ilerlenir ve umutsuz dallar erkenden budanır.

Ağ yönlendirme. Yönlendiriciler, bir hedef adresin hangi ağa ait olduğunu "en uzun eşleşen ön ek" kuralıyla belirler. Milyonlarca kuralı barındıran tablolarda bu sorgu, her paket için mikrosaniyeler içinde yanıtlanmak zorundadır; sıkıştırılmış trie bu işin standart çözümüdür.

Metin editörlerinde ve kod tamamlamada. Bir editörün açtığı tamamlama listesi, o an tanımlı olan tüm sembolleri ön eke göre süzmek zorundadır. Sembol sayısı on binleri bulduğunda doğrusal tarama gözle görülür bir gecikme yaratır.

Kelime oyunları ve bulmaca çözücüleri. Harflerden kelime türeten algoritmalar, geçersiz ön ekleri erkenden eleyebildikleri için trie ile çok hızlanır. "Bu harf dizisiyle başlayan hiçbir kelime yok" bilgisi, arama ağacının koca bir dalını tek adımda budar.

Ortak nokta şudur: Trie, "şununla başlayan" sorusunun sık sorulduğu her yerde kazandırır. Soru "bu tam olarak var mı" ise hash tablosu yeter; soru "şuna benzeyen" ise başka yapılar gerekir. Doğru aracı seçmenin yolu, sorduğun soruyu net biçimde tanımlamaktan geçer.

Sık yapılan hatalar

  • Kelime sonu bayrağını unutmak. Bayrak olmadan ön ekler kelime sanılır; "ka" da sözlükte varmış gibi görünür.
  • Silmede düğümleri gereğinden erken temizlemek. Bir düğüm ancak çocuğu yoksa ve kelime sonu değilse silinebilir.
  • 26 elemanlı dizi kullanmak. Türkçe ve diğer diller için map[rune] gerekir.
  • Metni bayt bayt gezmek. Çokbaytlı karakterler parçalanır ve trie bozulur; range ile rune rune gez.
  • Standart küçük harfe çevirmeyi Türkçe metinlerde kullanmak. I/İ kuralı farklıdır.
  • Sonuçların sırasına güvenmek. map[rune] gezinme sırası rastgeledir; deterministik çıktı için anahtarları sırala.
  • Yalnızca üyelik testi için trie kullanmak. Ön ek sorgusu yoksa hash tablosu daha uygundur.

Alıştırmalar

Alıştırma·Ortak ön ek
Kolay

Bir kelime listesindeki tüm kelimelerin en uzun ortak ön ekini trie kullanarak bulan bir fonksiyon yaz. Kökten başlayıp tek çocuklu ve kelime sonu olmayan düğümler boyunca ilerle.

İpucu

Kökten aşağı in; bir düğümün birden fazla çocuğu varsa ya da kelime sonuysa dur.

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

import "fmt"

type TrieNode struct {
	children map[rune]*TrieNode
	isEnd    bool
}

type Trie struct {
	root *TrieNode
}

func NewTrie() *Trie {
	return &Trie{root: &TrieNode{children: map[rune]*TrieNode{}}}
}

func (t *Trie) Insert(word string) {
	node := t.root
	for _, r := range word {
		child, ok := node.children[r]
		if !ok {
			child = &TrieNode{children: map[rune]*TrieNode{}}
			node.children[r] = child
		}
		node = child
	}
	node.isEnd = true
}

// LongestCommonPrefix: tek çocuklu zincir boyunca in
func (t *Trie) LongestCommonPrefix() string {
	var prefix []rune
	node := t.root

	for len(node.children) == 1 && !node.isEnd {
		for r, child := range node.children { // tek eleman var
			prefix = append(prefix, r)
			node = child
		}
	}
	return string(prefix)
}

func longestCommonPrefix(words []string) string {
	if len(words) == 0 {
		return ""
	}
	tr := NewTrie()
	for _, w := range words {
		tr.Insert(w)
	}
	return tr.LongestCommonPrefix()
}

func main() {
	cases := [][]string{
		{"merhaba", "merak", "mermer"},
		{"kar", "kart", "kare"},
		{"kar", "kart", "ev"},
		{"tek"},
		{"aynı", "aynı"},
		{},
	}

	for _, c := range cases {
		fmt.Printf("%-34v%q\n", c, longestCommonPrefix(c))
	}
}
Çıktı
[merhaba                            merak                              mermer                            ] → "mer"
[kar                                kart                               kare                              ] → "kar"
[kar                                kart                               ev                                ] → ""
[tek                               ] → "tek"
[aynı                               aynı                              ] → "aynı"
[] → ""
ZamanO(toplam karakter)AlanO(toplam karakter)

Trie kurmanın maliyeti, tüm kelimelerin toplam uzunluğu kadardır. Bu problem için daha ucuz bir çözüm de var: Kelimeleri karakter karakter karşılaştırmak O(n × k) sürer ama hiç ek bellek kullanmaz. Trie'yi tercih etmen, aynı sözlük üzerinde başka ön ek sorguları da yapacaksan anlamlıdır.

isEnd kontrolüne dikkat: "kar" ve "kart" listesinde ortak ön ek "kar"dır, "kart" değil. Kelime sonuna ulaştığında durmazsan yanlış sonuç alırsın.

Alıştırma·Joker karakterli arama
Orta

Trie'de . karakterinin herhangi bir harfle eşleştiği bir arama fonksiyonu yaz. Örneğin k.r deseni "kar" ve "kir" ile eşleşmeli. Geri izleme (backtracking) kullan.

İpucu

Nokta gördüğünde tüm çocuklar için özyinelemeyi dene; biri başarılı olursa true döndür.

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

import (
	"fmt"
	"slices"
)

type TrieNode struct {
	children map[rune]*TrieNode
	isEnd    bool
}

type Trie struct {
	root *TrieNode
}

func NewTrie() *Trie {
	return &Trie{root: &TrieNode{children: map[rune]*TrieNode{}}}
}

func (t *Trie) Insert(word string) {
	node := t.root
	for _, r := range word {
		child, ok := node.children[r]
		if !ok {
			child = &TrieNode{children: map[rune]*TrieNode{}}
			node.children[r] = child
		}
		node = child
	}
	node.isEnd = true
}

// SearchPattern: '.' herhangi bir karakterle eşleşir
func (t *Trie) SearchPattern(pattern string) bool {
	runes := []rune(pattern)

	var match func(node *TrieNode, depth int) bool
	match = func(node *TrieNode, depth int) bool {
		if depth == len(runes) {
			return node.isEnd
		}
		r := runes[depth]
		if r != '.' {
			child, ok := node.children[r]
			return ok && match(child, depth+1)
		}
		// Joker: tüm çocukları dene (geri izleme)
		for _, child := range node.children {
			if match(child, depth+1) {
				return true
			}
		}
		return false
	}
	return match(t.root, 0)
}

// MatchAll: desene uyan tüm kelimeleri döndürür
func (t *Trie) MatchAll(pattern string) []string {
	runes := []rune(pattern)
	var out []string

	var walk func(node *TrieNode, depth int, acc []rune)
	walk = func(node *TrieNode, depth int, acc []rune) {
		if depth == len(runes) {
			if node.isEnd {
				out = append(out, string(acc))
			}
			return
		}
		r := runes[depth]
		if r != '.' {
			if child, ok := node.children[r]; ok {
				walk(child, depth+1, append(acc, r))
			}
			return
		}
		keys := make([]rune, 0, len(node.children))
		for k := range node.children {
			keys = append(keys, k)
		}
		slices.Sort(keys) // deterministik sıra
		for _, k := range keys {
			walk(node.children[k], depth+1, append(acc, k))
		}
	}
	walk(t.root, 0, nil)
	return out
}

func main() {
	tr := NewTrie()
	for _, w := range []string{"kar", "kir", "kur", "kara", "top", "tip"} {
		tr.Insert(w)
	}

	fmt.Println("desen eşleşmeleri:")
	for _, p := range []string{"kar", "k.r", "...", "k..a", "t.p", "z.r", "...."} {
		fmt.Printf("  %-7q eşleşti=%-5t sonuçlar=%v\n", p, tr.SearchPattern(p), tr.MatchAll(p))
	}
}
Çıktı
desen eşleşmeleri:
  "kar"   eşleşti=true  sonuçlar=[kar]
  "k.r"   eşleşti=true  sonuçlar=[kar kir kur]
  "..."   eşleşti=true  sonuçlar=[kar kir kur tip top]
  "k..a"  eşleşti=true  sonuçlar=[kara]
  "t.p"   eşleşti=true  sonuçlar=[tip top]
  "z.r"   eşleşti=false sonuçlar=[]
  "...."  eşleşti=true  sonuçlar=[kara]
ZamanO(26^j × k) en kötüAlanO(k)

Karmaşıklıktaki j, desendeki joker sayısıdır: Her joker, tüm çocuk dallarını denemeyi gerektirir. Desen tamamen jokerden oluşuyorsa arama, o uzunluktaki tüm kelimeleri gezmeye dönüşür.

append(acc, r) kullanımında bir tuzak vardır: Aynı altta yatan dizi paylaşıldığı için bazı durumlarda sonuçlar birbirini bozabilir. Burada güvenlidir çünkü string(acc) dönüşümü anında kopya üretir; ama diliği sonuç listesine doğrudan eklediğin durumlarda slices.Clone kullanman gerekir.

Alıştırma·Sıkıştırılmış trie
Zor

Tek çocuklu düğüm zincirlerini birleştiren sıkıştırılmış bir trie (radix ağacı) yaz. Ekleme ve arama işlemlerini destekle, ve sıradan trie ile düğüm sayısını karşılaştır.

İpucu

Her kenarda tek karakter yerine bir metin parçası tut. Ekleme sırasında mevcut kenarla girdinin ortak ön ekini bul; ayrıldıkları yerde kenarı ikiye böl.

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

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

type RadixNode struct {
	children map[string]*RadixNode // kenar metni → çocuk
	isEnd    bool
}

type RadixTree struct {
	root  *RadixNode
	nodes int
}

func NewRadixTree() *RadixTree {
	return &RadixTree{root: &RadixNode{children: map[string]*RadixNode{}}, nodes: 1}
}

// commonPrefixLen: iki metnin ortak ön ek uzunluğu (rune cinsinden)
func commonPrefixLen(a, b string) int {
	ra, rb := []rune(a), []rune(b)
	n := 0
	for n < len(ra) && n < len(rb) && ra[n] == rb[n] {
		n++
	}
	return n
}

func (t *RadixTree) Insert(word string) {
	node := t.root

	for word != "" {
		var matchedEdge string
		var matchLen int

		for edge := range node.children {
			if n := commonPrefixLen(edge, word); n > 0 {
				matchedEdge, matchLen = edge, n
				break
			}
		}

		if matchLen == 0 { // hiç eşleşme yok: yeni kenar
			node.children[word] = &RadixNode{children: map[string]*RadixNode{}, isEnd: true}
			t.nodes++
			return
		}

		edgeRunes := []rune(matchedEdge)
		wordRunes := []rune(word)

		if matchLen == len(edgeRunes) { // kenarın tamamı eşleşti: aşağı in
			node = node.children[matchedEdge]
			word = string(wordRunes[matchLen:])
			if word == "" {
				node.isEnd = true
			}
			continue
		}

		// Kenarı böl: ortak kısım üstte, kalanlar altta
		child := node.children[matchedEdge]
		delete(node.children, matchedEdge)

		split := &RadixNode{children: map[string]*RadixNode{}}
		t.nodes++
		node.children[string(edgeRunes[:matchLen])] = split
		split.children[string(edgeRunes[matchLen:])] = child

		if matchLen == len(wordRunes) {
			split.isEnd = true
		} else {
			split.children[string(wordRunes[matchLen:])] =
				&RadixNode{children: map[string]*RadixNode{}, isEnd: true}
			t.nodes++
		}
		return
	}
	node.isEnd = true
}

func (t *RadixTree) Search(word string) bool {
	node := t.root
	for word != "" {
		found := false
		for edge, child := range node.children {
			if strings.HasPrefix(word, edge) {
				word = word[len(edge):]
				node = child
				found = true
				break
			}
		}
		if !found {
			return false
		}
	}
	return node.isEnd
}

func (t *RadixTree) Words() []string {
	var out []string
	var walk func(*RadixNode, string)
	walk = func(n *RadixNode, prefix string) {
		if n.isEnd {
			out = append(out, prefix)
		}
		edges := make([]string, 0, len(n.children))
		for e := range n.children {
			edges = append(edges, e)
		}
		slices.Sort(edges)
		for _, e := range edges {
			walk(n.children[e], prefix+e)
		}
	}
	walk(t.root, "")
	return out
}

// countPlainTrieNodes: aynı kelimeler için sıradan trie kaç düğüm kullanırdı
func countPlainTrieNodes(words []string) int {
	type node struct{ children map[rune]*node }
	root := &node{children: map[rune]*node{}}
	count := 1
	for _, w := range words {
		cur := root
		for _, r := range w {
			child, ok := cur.children[r]
			if !ok {
				child = &node{children: map[rune]*node{}}
				cur.children[r] = child
				count++
			}
			cur = child
		}
	}
	return count
}

func main() {
	words := []string{"merhaba", "merak", "mermer", "meridyen", "masa", "makas"}

	rt := NewRadixTree()
	for _, w := range words {
		rt.Insert(w)
	}

	fmt.Println("kelimeler:", rt.Words())
	fmt.Println()

	fmt.Println("aramalar:")
	for _, w := range []string{"merhaba", "merak", "mer", "masal", "makas"} {
		fmt.Printf("  %-10q%t\n", w, rt.Search(w))
	}

	plain := countPlainTrieNodes(words)
	fmt.Println()
	fmt.Println("sıradan trie düğüm sayısı:  ", plain)
	fmt.Println("sıkıştırılmış düğüm sayısı:", rt.nodes)
	fmt.Printf("tasarruf: %%%.0f\n", 100*(1-float64(rt.nodes)/float64(plain)))
}
Çıktı
kelimeler: [makas masa merak merhaba meridyen mermer]

aramalar:
  "merhaba"  → true
  "merak"    → true
  "mer"      → false
  "masal"    → false
  "makas"    → true

sıradan trie düğüm sayısı:   24
sıkıştırılmış düğüm sayısı: 10
tasarruf: %58
ZamanO(k)AlanO(toplam benzersiz karakter)

Tasarruf oranı sözlüğün yapısına bağlıdır. Kelimeler birbirine benzemiyorsa (ortak ön ek azsa) sıkıştırma çok kazandırır, çünkü her kelime neredeyse tek bir düğüme iner. Yoğun biçimde ortak ön ek paylaşan sözlüklerde ise fark azalır.

Sıkıştırılmış trie'nin gerçek dünyadaki en bilinen kullanımı IP yönlendirme tablolarıdır: Yönlendiriciler, bir adresin hangi ağa ait olduğunu "en uzun eşleşen ön ek" kuralıyla bulur ve bu yapı tam olarak o sorguyu hızlandırmak için tasarlanmıştır. Aynı fikir, bazı anahtar-değer depolarında ve dosya sistemi yollarını indekslemede de kullanılır.

Kısa sınav

Kısa sınav

Trie'de bir kelimeyi aramanın karmaşıklığı nedir?

Trie düğümlerinde isEnd bayrağı neden gereklidir?

Trie'den bir kelime silinirken düğümler ne zaman kaldırılabilir?

Türkçe kelimeler için trie yazarken çocuklar nasıl tutulmalıdır?

Sıkıştırılmış trie (radix ağacı) neyi farklı yapar?

Trie yerine hash tablosu ne zaman tercih edilmelidir?

Özet

  • Trie, kelimeleri ortak ön eklerini paylaştırarak saklayan bir ağaçtır; karakter bilgisi düğümde değil kenardadır.
  • Arama, ekleme ve ön ek kontrolü O(k) sürer ve sözlüğün büyüklüğünden bağımsızdır.
  • isEnd bayrağı, bir yolun tam kelime mi yoksa yalnızca ön ek mi olduğunu ayırt eder.
  • Ön ekle başlayan kelimeleri listelemek, ön ek düğümünden aşağıya DFS yapmakla bulunur: O(k + m).
  • Otomatik tamamlamada sonuçlar genelde sıklığa göre sıralanır; düğümlerde önceden hesaplanmış öneriler tutmak sorguyu hızlandırır.
  • Silmede düğüm ancak çocuğu yoksa ve kelime sonu değilse kaldırılabilir.
  • Türkçe ve diğer diller için map[rune]*Node kullan; metni bayt bayt değil rune rune gez ve I/İ kuralına dikkat et.
  • Sıkıştırılmış trie, tek çocuklu zincirleri birleştirerek belleği belirgin biçimde azaltır.
  • Ön ek sorgusu yoksa hash tablosu daha uygun bir seçimdir.
Bu dersi bitirdin mi?
İlerlemen bu tarayıcıda saklanır.