go
Veri Yapıları dersleri

Bağlı Listeler

Tek ve çift yönlü bağlı listeler ve klasik bağlı liste problemleri.

Ders 3 / 1430 dkBaşlangıç
Bu derste öğreneceklerin
  • Düğüm (node) yapısı
  • Tek yönlü bağlı liste: ekleme, silme, arama
  • Çift yönlü bağlı liste
  • Dairesel bağlı liste
  • Listeyi ters çevirme
  • Floyd'un döngü tespiti (hızlı-yavaş işaretçi)
  • container/list paketi
  • Dizi ile bağlı listenin karşılaştırılması

Dizilerin en büyük zayıflığı, elemanların bitişik durmak zorunda olmasıydı: Araya bir şey eklemek istediğinde herkesi kaydırman gerekiyordu. Bağlı liste bu kısıtı tamamen ortadan kaldırır. Elemanlar bellekte istedikleri yerde durabilir; her eleman, bir sonrakinin nerede olduğunu söyleyen bir işaretçi taşır.

Bu tasarımın sonucu çarpıcıdır: Listenin ortasına eleman eklemek yalnızca iki işaretçiyi değiştirmek demektir, kaç eleman olduğu fark etmez. Ama bedeli de ağırdır: Beşinci elemana ulaşmak için baştan başlayıp beş adım yürümen gerekir. Dizilerdeki "anında erişim, pahalı ekleme" ödünleşmesi burada tersine döner.

Bağlı listeler günlük uygulama kodunda dizilerden çok daha az kullanılır. Yine de öğrenilmeleri şart: İşaretçi düşünmeyi öğretirler, pek çok mülakat sorusunun temelini oluştururlar ve en önemlisi, ağaçlar ile graflar dâhil bundan sonraki neredeyse tüm veri yapıları aynı düğüm-işaretçi fikrinin üzerine kurulur. Bu derste düğüm yapısını, tek ve çift yönlü listeleri, dairesel listeleri, ters çevirmeyi, Floyd'un döngü tespiti algoritmasını ve Go'nun container/list paketini öğreneceksin.

Düğüm ve temel yapı

Bağlı listenin yapı taşı düğümdür (node): bir değer ve bir sonraki düğüme işaretçi.

baş


┌────┬───┐   ┌────┬───┐   ┌────┬───┐
│ 10 │ ●─┼──►│ 20 │ ●─┼──►│ 30 │ ⏚ │
└────┴───┘   └────┴───┘   └────┴───┘
  değer  next                    nil = son

Bellekte: düğümler dağınık olabilir
adres 4200: [10 | →7100]
adres 7100: [20 | →1900]
adres 1900: [30 | →nil]

Dizilerdeki adres hesabının yerini burada işaretçi takibi alır. Beşinci elemanı bulmak için formül yoktur; baştan başlayıp beş kez "bir sonrakine git" demen gerekir.

ZamanO(n) erişim, O(1) baştan eklemeAlanO(n)

Aşağıdaki görselleştirmede düğüm ekleyip silerek işaretçilerin nasıl güncellendiğini izleyebilirsin. Baştan ve ortadan ekleme arasındaki farka dikkat et:

Tek yönlü bağlı liste

Şimdi sıfırdan jenerik bir tek yönlü liste yazalım. Dikkat edeceğin nokta, her işlemin hangi işaretçileri hangi sırayla güncellediğidir:

main.go
package main

import (
	"fmt"
	"strings"
)

type Node[T any] struct {
	Value T
	Next  *Node[T]
}

type List[T comparable] struct {
	head *Node[T]
	tail *Node[T] // sona eklemeyi O(1) yapmak için
	size int
}

func (l *List[T]) Len() int { return l.size }

// PushFront: O(1)
func (l *List[T]) PushFront(v T) {
	n := &Node[T]{Value: v, Next: l.head}
	l.head = n
	if l.tail == nil {
		l.tail = n
	}
	l.size++
}

// PushBack: tail işaretçisi sayesinde O(1)
func (l *List[T]) PushBack(v T) {
	n := &Node[T]{Value: v}
	if l.tail == nil {
		l.head, l.tail = n, n
	} else {
		l.tail.Next = n
		l.tail = n
	}
	l.size++
}

// PopFront: O(1)
func (l *List[T]) PopFront() (T, bool) {
	var zero T
	if l.head == nil {
		return zero, false
	}
	n := l.head
	l.head = n.Next
	if l.head == nil {
		l.tail = nil
	}
	n.Next = nil // çöp toplayıcıya yardım et
	l.size--
	return n.Value, true
}

// Find: O(n)
func (l *List[T]) Find(v T) (*Node[T], int) {
	for n, i := l.head, 0; n != nil; n, i = n.Next, i+1 {
		if n.Value == v {
			return n, i
		}
	}
	return nil, -1
}

// Remove: değeri eşleşen ilk düğümü siler, O(n)
func (l *List[T]) Remove(v T) bool {
	var prev *Node[T]
	for n := l.head; n != nil; n = n.Next {
		if n.Value != v {
			prev = n
			continue
		}
		if prev == nil {
			l.head = n.Next
		} else {
			prev.Next = n.Next
		}
		if n == l.tail {
			l.tail = prev
		}
		n.Next = nil
		l.size--
		return true
	}
	return false
}

func (l *List[T]) String() string {
	var sb strings.Builder
	sb.WriteString("[")
	for n := l.head; n != nil; n = n.Next {
		if n != l.head {
			sb.WriteString(" → ")
		}
		fmt.Fprint(&sb, n.Value)
	}
	sb.WriteString("]")
	return sb.String()
}

func main() {
	var l List[int]

	l.PushBack(20)
	l.PushBack(30)
	l.PushFront(10)
	fmt.Println("liste:", l.String(), "uzunluk:", l.Len())

	if node, idx := l.Find(20); node != nil {
		fmt.Printf("20 bulundu: indeks %d, değer %d\n", idx, node.Value)
	}
	_, notFound := l.Find(99)
	fmt.Println("99 indeksi:", notFound)

	v, _ := l.PopFront()
	fmt.Println("baştan çıkan:", v, "kalan:", l.String())

	fmt.Println("30 silindi mi:", l.Remove(30), "→", l.String(), "uzunluk:", l.Len())
	fmt.Println("boş listeden çekme:", func() bool { _, ok := (&List[int]{}).PopFront(); return ok }())
}
Çıktı
liste: [10 → 20 → 30] uzunluk: 3
20 bulundu: indeks 1, değer 20
99 indeksi: -1
baştan çıkan: 10 kalan: [20 → 30]
30 silindi mi: true → [20] uzunluk: 1
boş listeden çekme: false

tail işaretçisi küçük ama önemli bir iyileştirmedir: O olmadan sona ekleme her seferinde tüm listeyi gezmek zorunda kalır ve O(n) olur. Ancak tail tutmanın bir bedeli vardır — silme işlemlerinde onu güncellemeyi unutursan liste bozulur. Bu, bağlı liste kodunun neden hataya bu kadar açık olduğunun tipik bir örneğidir.

n.Next = nil satırları da bilinçlidir: Silinen düğüm hâlâ listenin geri kalanını işaret ediyorsa, ona bir yerden erişim kaldığında tüm zincir bellekte tutulmaya devam eder.

Çift yönlü bağlı liste

Tek yönlü listede bir düğümü silmek için öncesini bilmen gerekir; bu yüzden listeyi baştan gezersin. Çift yönlü listede her düğüm hem sonrakini hem öncekini tutar, böylece elindeki bir düğümü doğrudan O(1) sürede silebilirsin.

     ┌────┬───┬───┐   ┌────┬───┬───┐   ┌────┬───┬───┐
nil ◄┼─● │10 │ ●─┼──►│ ●  │20 │ ●─┼──►│ ●  │30 │ ●─┼─► nil
     └────┴───┴───┘   └────┴───┴───┘   └────┴───┴───┘
       prev     next
main.go
package main

import (
	"fmt"
	"strings"
)

type DNode[T any] struct {
	Value      T
	Prev, Next *DNode[T]
}

type DoublyList[T any] struct {
	head, tail *DNode[T]
	size       int
}

func (l *DoublyList[T]) Len() int { return l.size }

func (l *DoublyList[T]) PushBack(v T) *DNode[T] {
	n := &DNode[T]{Value: v, Prev: l.tail}
	if l.tail != nil {
		l.tail.Next = n
	} else {
		l.head = n
	}
	l.tail = n
	l.size++
	return n
}

func (l *DoublyList[T]) PushFront(v T) *DNode[T] {
	n := &DNode[T]{Value: v, Next: l.head}
	if l.head != nil {
		l.head.Prev = n
	} else {
		l.tail = n
	}
	l.head = n
	l.size++
	return n
}

// RemoveNode: elindeki düğümü O(1) siler — çift yönlü listenin asıl gücü
func (l *DoublyList[T]) RemoveNode(n *DNode[T]) {
	if n == nil {
		return
	}
	if n.Prev != nil {
		n.Prev.Next = n.Next
	} else {
		l.head = n.Next
	}
	if n.Next != nil {
		n.Next.Prev = n.Prev
	} else {
		l.tail = n.Prev
	}
	n.Prev, n.Next = nil, nil
	l.size--
}

func (l *DoublyList[T]) Forward() string {
	var parts []string
	for n := l.head; n != nil; n = n.Next {
		parts = append(parts, fmt.Sprint(n.Value))
	}
	return strings.Join(parts, " ⇄ ")
}

func (l *DoublyList[T]) Backward() string {
	var parts []string
	for n := l.tail; n != nil; n = n.Prev {
		parts = append(parts, fmt.Sprint(n.Value))
	}
	return strings.Join(parts, " ⇄ ")
}

func main() {
	var l DoublyList[string]

	l.PushBack("b")
	middle := l.PushBack("c")
	l.PushBack("d")
	l.PushFront("a")

	fmt.Println("ileri :", l.Forward())
	fmt.Println("geri  :", l.Backward())
	fmt.Println("uzunluk:", l.Len())

	l.RemoveNode(middle) // düğüm elimizde: O(1)
	fmt.Println("c silindi:", l.Forward(), "uzunluk:", l.Len())

	l.RemoveNode(l.head)
	l.RemoveNode(l.tail)
	fmt.Println("uçlar silindi:", l.Forward(), "uzunluk:", l.Len())
}
Çıktı
ileri : a ⇄ b ⇄ c ⇄ d
geri  : d ⇄ c ⇄ b ⇄ a
uzunluk: 4
c silindi: a ⇄ b ⇄ d uzunluk: 3
uçlar silindi: b uzunluk: 1

Çift yönlü listenin en bilinen kullanım alanı LRU önbelleğidir: Bir harita düğüme doğrudan erişim sağlar, çift yönlü liste de o düğümü O(1) sürede listenin başına taşır. Bu tasarımı Hash Tabloları dersinde kuracağız.

Bedeli ise düğüm başına fazladan bir işaretçidir: 64 bitlik sistemlerde eleman başına 8 bayt daha. Küçük değerler tutan listelerde bu, veri boyutunu ikiye katlayabilir.

Dairesel bağlı liste

Son düğümün nil yerine başa dönmesiyle dairesel liste elde edilir. Bu yapı, sonsuza kadar dönen sıralar için doğaldır: işlemci zamanlayıcıları, oyun sıraları, tampon halkaları.

main.go
package main

import "fmt"

type CNode struct {
	Value int
	Next  *CNode
}

// newCircular, 1..n değerlerini içeren dairesel bir liste kurar.
func newCircular(n int) *CNode {
	if n < 1 {
		return nil
	}
	head := &CNode{Value: 1}
	tail := head
	for i := 2; i <= n; i++ {
		tail.Next = &CNode{Value: i}
		tail = tail.Next
	}
	tail.Next = head // daireyi kapat
	return head
}

// josephus, her k'ıncı kişiyi eleyerek son kalanı bulur.
func josephus(n, k int) (int, []int) {
	head := newCircular(n)
	if head == nil {
		return 0, nil
	}

	// Silme için bir önceki düğüme ihtiyaç var
	prev := head
	for prev.Next != head {
		prev = prev.Next
	}

	var eliminated []int
	current := head
	for current.Next != current { // tek kişi kalana kadar
		for range k - 1 {
			prev, current = current, current.Next
		}
		eliminated = append(eliminated, current.Value)
		prev.Next = current.Next
		current = prev.Next
	}
	return current.Value, eliminated
}

func main() {
	// Daireyi iki tur gez
	head := newCircular(5)
	fmt.Print("iki tur: ")
	for n, i := head, 0; i < 10; n, i = n.Next, i+1 {
		fmt.Print(n.Value, " ")
	}
	fmt.Println()

	survivor, order := josephus(7, 3)
	fmt.Println("elenme sırası:", order)
	fmt.Println("hayatta kalan:", survivor)

	survivor2, _ := josephus(10, 2)
	fmt.Println("n=10, k=2 için hayatta kalan:", survivor2)
}
Çıktı
iki tur: 1 2 3 4 5 1 2 3 4 5
elenme sırası: [3 6 2 7 5 1]
hayatta kalan: 4
n=10, k=2 için hayatta kalan: 5

Dairesel listelerde en sık yapılan hata, döngü koşulunu yanlış yazıp sonsuz döngüye girmektir. for n := head; n != nil; n = n.Next yazımı burada asla bitmez; bunun yerine tur sayısını ya da başa dönüşü kontrol etmen gerekir.

Listeyi ters çevirme

Bu, bağlı liste sorularının klasiğidir ve işaretçi düşünmeyi öğrenmenin en iyi yoludur. Üç işaretçiyle tek geçişte yapılır:

başlangıç:  nil   1 → 2 → 3 → nil
            prev  cur

adım 1:     nil ← 1   2 → 3 → nil
                prev  cur

adım 2:     nil ← 1 ← 2   3 → nil
                     prev cur

adım 3:     nil ← 1 ← 2 ← 3
                          prev = yeni baş
main.go
package main

import (
	"fmt"
	"strings"
)

type Node struct {
	Value int
	Next  *Node
}

func build(values ...int) *Node {
	dummy := &Node{}
	tail := dummy
	for _, v := range values {
		tail.Next = &Node{Value: v}
		tail = tail.Next
	}
	return dummy.Next
}

func show(head *Node) string {
	var parts []string
	for n := head; n != nil; n = n.Next {
		parts = append(parts, fmt.Sprint(n.Value))
	}
	if len(parts) == 0 {
		return "boş"
	}
	return strings.Join(parts, " → ")
}

// reverse: iteratif, O(n) zaman, O(1) ek bellek
func reverse(head *Node) *Node {
	var prev *Node
	for cur := head; cur != nil; {
		next := cur.Next // bağlantıyı kaybetmeden önce sakla
		cur.Next = prev  // yönü çevir
		prev = cur       // ilerle
		cur = next
	}
	return prev
}

// reverseRecursive: özyinelemeli, O(n) zaman, O(n) stack belleği
func reverseRecursive(head *Node) *Node {
	if head == nil || head.Next == nil {
		return head
	}
	newHead := reverseRecursive(head.Next)
	head.Next.Next = head
	head.Next = nil
	return newHead
}

func main() {
	list := build(1, 2, 3, 4, 5)
	fmt.Println("özgün:   ", show(list))

	reversed := reverse(list)
	fmt.Println("ters:    ", show(reversed))

	again := reverseRecursive(reversed)
	fmt.Println("tekrar:  ", show(again))

	fmt.Println("tek eleman:", show(reverse(build(42))))
	fmt.Println("boş liste: ", show(reverse(nil)))
}
Çıktı
özgün:    1 → 2 → 3 → 4 → 5
ters:     5 → 4 → 3 → 2 → 1
tekrar:   1 → 2 → 3 → 4 → 5
tek eleman: 42
boş liste:  boş

İteratif sürüm O(1) ek bellek kullanırken, özyinelemeli sürüm her düğüm için bir stack çerçevesi açar; çok uzun listelerde stack taşmasına yol açabilir. Özyinelemenin maliyetini Özyineleme dersinde ayrıntısıyla ele alıyoruz.

build fonksiyonundaki dummy (kukla) düğüm de yaygın bir tekniktir: Baş düğümün özel durumunu ortadan kaldırır, böylece "liste boş mu?" kontrolünü her yere serpiştirmen gerekmez.

Floyd'un döngü tespiti

Bir bağlı listede döngü varsa, onu gezen döngü asla bitmez. Döngüyü tespit etmenin bariz yolu, gördüğün düğümleri bir haritada saklamaktır: O(n) zaman ama O(n) bellek. Floyd'un "tavşan ve kaplumbağa" algoritması aynı işi O(1) bellekle yapar.

Fikir şudur: Biri her adımda bir, diğeri iki düğüm ilerleyen iki işaretçi kullan. Döngü yoksa hızlı olan sona ulaşır. Döngü varsa hızlı olan er ya da geç yavaşı yakalar — tıpkı bir pistte hızlı koşucunun yavaşı bir tur geçmesi gibi.

main.go
package main

import "fmt"

type Node struct {
	Value int
	Next  *Node
}

func build(values ...int) *Node {
	dummy := &Node{}
	tail := dummy
	for _, v := range values {
		tail.Next = &Node{Value: v}
		tail = tail.Next
	}
	return dummy.Next
}

// nodeAt, i. düğümü döndürür (döngü kurmak için).
func nodeAt(head *Node, i int) *Node {
	for n := head; n != nil; n = n.Next {
		if i == 0 {
			return n
		}
		i--
	}
	return nil
}

// hasCycle: Floyd'un tavşan-kaplumbağa algoritması. O(n) zaman, O(1) bellek.
func hasCycle(head *Node) bool {
	slow, fast := head, head
	for fast != nil && fast.Next != nil {
		slow = slow.Next
		fast = fast.Next.Next
		if slow == fast {
			return true
		}
	}
	return false
}

// cycleStart, döngünün başladığı düğümü bulur.
func cycleStart(head *Node) *Node {
	slow, fast := head, head
	for fast != nil && fast.Next != nil {
		slow = slow.Next
		fast = fast.Next.Next
		if slow != fast {
			continue
		}
		// Buluşma noktasından ve baştan aynı hızla ilerlemek
		// tam olarak döngünün başında buluşur.
		p := head
		for p != slow {
			p = p.Next
			slow = slow.Next
		}
		return p
	}
	return nil
}

// middle, listenin orta düğümünü tek geçişte bulur.
func middle(head *Node) *Node {
	slow, fast := head, head
	for fast != nil && fast.Next != nil {
		slow = slow.Next
		fast = fast.Next.Next
	}
	return slow
}

func main() {
	clean := build(1, 2, 3, 4, 5, 6)
	fmt.Println("temiz listede döngü:", hasCycle(clean))
	fmt.Println("orta düğüm:", middle(clean).Value)

	looped := build(1, 2, 3, 4, 5, 6, 7)
	last := nodeAt(looped, 6)
	last.Next = nodeAt(looped, 2) // 3. değere geri bağla

	fmt.Println("döngülü listede döngü:", hasCycle(looped))
	if start := cycleStart(looped); start != nil {
		fmt.Println("döngü şu değerden başlıyor:", start.Value)
	}

	fmt.Println("tek düğümlü kendine bağlı:", func() bool {
		n := &Node{Value: 1}
		n.Next = n
		return hasCycle(n)
	}())
}
Çıktı
temiz listede döngü: false
orta düğüm: 4
döngülü listede döngü: true
döngü şu değerden başlıyor: 3
tek düğümlü kendine bağlı: true

Aynı hızlı-yavaş işaretçi fikri, orta düğümü bulmak, sondan k'ıncı düğümü bulmak ve listeyi ikiye bölmek için de kullanılır. middle fonksiyonundaki numara şudur: Hızlı işaretçi sona geldiğinde yavaş olan tam ortadadır, çünkü iki kat yavaş ilerlemiştir.

container/list ve dizi karşılaştırması

Go'nun standart kütüphanesinde container/list paketi, çift yönlü dairesel bir liste sunar. Jenerikler öncesinden kaldığı için elemanları any olarak tutar; bu yüzden modern kodda sık kullanılmaz ama LRU gibi yapılarda hâlâ karşına çıkar:

main.go
package main

import (
	"container/list"
	"fmt"
)

func main() {
	l := list.New()

	l.PushBack("orta")
	back := l.PushBack("son")
	l.PushFront("baş")
	l.InsertBefore("yeni", back) // belirli bir elemanın önüne

	fmt.Print("liste: ")
	for e := l.Front(); e != nil; e = e.Next() {
		fmt.Print(e.Value, " ")
	}
	fmt.Println("| uzunluk:", l.Len())

	// Eleman elindeyse silme O(1)
	l.Remove(back)

	fmt.Print("silmeden sonra: ")
	for e := l.Front(); e != nil; e = e.Next() {
		fmt.Print(e.Value, " ")
	}
	fmt.Println()

	// Değerler any olduğu için tip iddiası gerekir
	if s, ok := l.Front().Value.(string); ok {
		fmt.Println("ilk eleman uzunluğu:", len(s))
	}

	// Elemanı listenin başına taşımak: LRU'nun temel işlemi
	l.MoveToFront(l.Back())
	fmt.Print("taşımadan sonra: ")
	for e := l.Front(); e != nil; e = e.Next() {
		fmt.Print(e.Value, " ")
	}
	fmt.Println()
}
Çıktı
liste: baş orta yeni son | uzunluk: 4
silmeden sonra: baş orta yeni
ilk eleman uzunluğu: 4
taşımadan sonra: yeni baş orta

Peki bağlı listeyi ne zaman seçmelisin? Karşılaştırma tablosu teoriyi özetler:

İşlemDizi (dilim)Tek yönlü listeÇift yönlü liste
İndeksle erişimO(1)O(n)O(n)
Başa eklemeO(n)O(1)O(1)
Sona eklemeO(1) amortizeO(1) (tail ile)O(1)
Elimdeki düğümü silmeO(n)O(n)O(1)
AramaO(n)O(n)O(n)
Eleman başına ek bellek01 işaretçi2 işaretçi
Önbellek dostuÇokHayırHayır

Tablo bağlı listeleri cazip gösterir, ama pratikte tablo yalan söyler. Modern işlemcilerde önbellek davranışı, karmaşıklık sınıfından daha belirleyici olabilir. Dizi elemanları bitişik durduğu için tarama çok hızlıdır; bağlı listede her adım bellekte rastgele bir sıçramadır ve neredeyse her sıçrama bir önbellek ıskasıdır.

Bunun sonucu şudur: Birkaç bin elemana kadar, dizideki O(n) kaydırma bile bağlı listedeki O(1) eklemeden hızlı çıkabilir — çünkü kaydırma işlemi ardışık bellek üzerinde, işlemcinin en sevdiği biçimde yapılır. Bağlı listenin gerçekten kazandığı durumlar şunlardır: elemanların adresleri sabit kalmalıysa, elinde zaten düğüm varken silme yapıyorsan ya da listeleri kopyalamadan birleştirmen gerekiyorsa.

Bağlı listeler gerçek dünyada nerede?

Uygulama kodunda bağlı liste yazmak nadir olsa da, altyapı yazılımlarında bu yapı her yerdedir. Nerede kullanıldığını bilmek, ne zaman işe yaradığını da anlatır.

Bellek yöneticileri. Boş bellek bloklarını takip eden ayırıcılar, blokları bir serbest liste içinde tutar. Bir blok serbest bırakıldığında listeye eklenmesi sabit sürer ve blokların bellekte bitişik olması zaten mümkün değildir. Aynı fikir, nesne havuzlarında ve tampon yönetiminde de karşımıza çıkar.

Zamanlayıcılar ve sıra yönetimi. İşletim sistemi çekirdeklerinde çalıştırılmayı bekleyen süreçler bağlı listelerde tutulur. Bir sürecin listeden çıkarılması, elde zaten o sürecin kaydı olduğu için sabit sürer; dizi kullanılsaydı her çıkarma kaydırma gerektirirdi.

Önbellek ve oturum yapıları. En az kullanılanı atan önbellekler, kayıtların sırasını çift yönlü listeyle takip eder. Bu, dersin ileri alıştırmasında kurduğun yapının ta kendisidir ve gerçek sistemlerde de aynı biçimde uygulanır.

Kalıcı ve paylaşımlı yapılar. İşlevsel programlama geleneğinden gelen değişmez listeler, yeni bir eleman eklerken var olan zinciri kopyalamaz, yalnızca önüne yeni bir düğüm ekler. Böylece eski sürüm de geçerli kalır. Diziyle bunu yapmak tüm veriyi kopyalamayı gerektirirdi.

Birleştirme gereken yerler. İki bağlı listeyi birleştirmek, son düğümün işaretçisini değiştirmekten ibarettir ve veri miktarından bağımsızdır. İki dilimi birleştirmek ise kopyalama demektir.

Ortak nokta şudur: Bağlı liste, elemanların kimliğinin ve bağlantılarının önemli olduğu, indeksin ise hiç kullanılmadığı yerlerde parlar. Uygulama kodunda genelde indeksle çalışırız; bu yüzden orada dilimler daha iyi bir seçimdir.

Sık yapılan hatalar

  • İşaretçileri yanlış sırada güncellemek. cur.Next = prev yazmadan önce next = cur.Next ile bağlantıyı saklamazsan listenin geri kalanını kaybedersin.
  • tail işaretçisini güncellemeyi unutmak. Son düğümü silerken tail'i düzeltmezsen sonraki ekleme silinmiş düğüme bağlanır.
  • Boş liste ve tek elemanlı liste durumlarını atlamak. Bağlı liste hatalarının çoğu bu iki sınırda çıkar.
  • Dairesel listede n != nil koşuluyla gezmek. Döngü asla bitmez.
  • Silinen düğümün Next alanını temizlememek. Zincirin tamamı bellekte tutulmaya devam edebilir.
  • Baş düğümün özel durumunu her yere yaymak. Kukla (dummy) düğüm kullanmak kodu belirgin biçimde sadeleştirir.
  • Bağlı listeyi varsayılan seçim yapmak. Ölçmeden karar verme; çoğu durumda dilim daha hızlıdır.

Alıştırmalar

Alıştırma·Sondan k'ıncı düğüm
Kolay

Bir bağlı listede sondan k'ıncı düğümü tek geçişte bulan bir fonksiyon yaz. Listenin uzunluğunu önceden hesaplamana izin yok. k listeden büyükse false döndür.

İpucu

İki işaretçi kullan: Önce birini k adım ilerlet, sonra ikisini birlikte yürüt. İlk işaretçi sona ulaştığında ikincisi sondan k'ıncı düğümdedir.

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

import "fmt"

type Node struct {
	Value int
	Next  *Node
}

func build(values ...int) *Node {
	dummy := &Node{}
	tail := dummy
	for _, v := range values {
		tail.Next = &Node{Value: v}
		tail = tail.Next
	}
	return dummy.Next
}

// kthFromEnd, sondan k'ıncı düğümü tek geçişte bulur (k = 1 → son düğüm).
func kthFromEnd(head *Node, k int) (int, bool) {
	if k < 1 {
		return 0, false
	}
	lead := head
	for range k {
		if lead == nil {
			return 0, false // liste k'dan kısa
		}
		lead = lead.Next
	}
	trail := head
	for lead != nil {
		lead = lead.Next
		trail = trail.Next
	}
	return trail.Value, true
}

func main() {
	list := build(10, 20, 30, 40, 50)

	for _, k := range []int{1, 2, 5, 6, 0} {
		v, ok := kthFromEnd(list, k)
		if !ok {
			fmt.Printf("k=%-2d → geçersiz\n", k)
			continue
		}
		fmt.Printf("k=%-2d%d\n", k, v)
	}

	single, _ := kthFromEnd(build(7), 1)
	fmt.Println("tek elemanlı listede k=1:", single)
}
Çıktı
k=1  → 50
k=2  → 40
k=5  → 10
k=6  → geçersiz
k=0  → geçersiz
tek elemanlı listede k=1: 7
ZamanO(n)AlanO(1)

İki işaretçi arasındaki mesafeyi k'da sabit tutmak, listeyi iki kez gezmeye gerek bırakmaz. Aynı fikir, akış hâlinde gelen ve uzunluğu önceden bilinmeyen verilerde de çalışır — bu yüzden gerçek sistemlerde de kullanılır.

Alıştırma·İki sıralı listeyi birleştirme
Orta

Sıralı iki bağlı listeyi, yeni düğüm oluşturmadan tek bir sıralı listede birleştiren bir fonksiyon yaz. Var olan düğümlerin işaretçilerini yeniden bağla. Sonucun gerçekten sıralı olduğunu doğrula.

İpucu

Kukla bir baş düğüm kullan; böylece ilk elemanı seçerken özel duruma girmezsin. Her adımda iki listenin başlarından küçük olanı al.

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

import (
	"fmt"
	"strings"
)

type Node struct {
	Value int
	Next  *Node
}

func build(values ...int) *Node {
	dummy := &Node{}
	tail := dummy
	for _, v := range values {
		tail.Next = &Node{Value: v}
		tail = tail.Next
	}
	return dummy.Next
}

func show(head *Node) string {
	var parts []string
	for n := head; n != nil; n = n.Next {
		parts = append(parts, fmt.Sprint(n.Value))
	}
	if len(parts) == 0 {
		return "boş"
	}
	return strings.Join(parts, " → ")
}

func isSorted(head *Node) bool {
	for n := head; n != nil && n.Next != nil; n = n.Next {
		if n.Value > n.Next.Value {
			return false
		}
	}
	return true
}

// mergeSorted, iki sıralı listeyi yeni düğüm ayırmadan birleştirir.
func mergeSorted(a, b *Node) *Node {
	dummy := &Node{}
	tail := dummy

	for a != nil && b != nil {
		if a.Value <= b.Value {
			tail.Next = a
			a = a.Next
		} else {
			tail.Next = b
			b = b.Next
		}
		tail = tail.Next
	}

	// Kalanı doğrudan ekle
	if a != nil {
		tail.Next = a
	} else {
		tail.Next = b
	}
	return dummy.Next
}

func main() {
	a := build(1, 4, 7, 9)
	b := build(2, 3, 8, 10, 11)

	fmt.Println("a:", show(a))
	fmt.Println("b:", show(b))

	merged := mergeSorted(a, b)
	fmt.Println("birleşik:", show(merged))
	fmt.Println("sıralı mı:", isSorted(merged))

	fmt.Println("biri boş:", show(mergeSorted(build(1, 2), nil)))
	fmt.Println("ikisi de boş:", show(mergeSorted(nil, nil)))
}
Çıktı
a: 1 → 4 → 7 → 9
b: 2 → 3 → 8 → 10 → 11
birleşik: 1 → 2 → 3 → 4 → 7 → 8 → 9 → 10 → 11
sıralı mı: true
biri boş: 1 → 2
ikisi de boş: boş
ZamanO(n + m)AlanO(1)

Kukla düğüm burada kodu gözle görülür biçimde sadeleştiriyor: Onsuz, sonucun baş düğümünü belirlemek için ayrı bir if bloğu ve head ile tail arasında ek bir eşitleme gerekirdi. Bu birleştirme adımı, Böl ve Fethet dersinde göreceğin birleştirme sıralamasının (merge sort) bağlı liste sürümünün kalbidir.

Alıştırma·LRU önbellek iskeleti
Zor

Çift yönlü liste ve harita kullanarak sabit kapasiteli bir LRU (en az kullanılan önce atılır) önbellek yaz. Get ve Put işlemleri O(1) olsun. Kapasite dolduğunda en uzun süredir kullanılmayan anahtarı at ve hangi anahtarın atıldığını raporla.

İpucu

Harita anahtardan düğüme eşlesin. Her erişimde düğümü listenin başına taşı; kapasite aşıldığında listenin sonundaki düğümü sil.

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

import (
	"fmt"
	"strings"
)

type entry struct {
	key   string
	value int
}

type lruNode struct {
	data       entry
	prev, next *lruNode
}

type LRUCache struct {
	capacity   int
	items      map[string]*lruNode
	head, tail *lruNode // head = en yeni, tail = en eski
}

func NewLRU(capacity int) *LRUCache {
	return &LRUCache{capacity: max(1, capacity), items: make(map[string]*lruNode)}
}

func (c *LRUCache) unlink(n *lruNode) {
	if n.prev != nil {
		n.prev.next = n.next
	} else {
		c.head = n.next
	}
	if n.next != nil {
		n.next.prev = n.prev
	} else {
		c.tail = n.prev
	}
	n.prev, n.next = nil, nil
}

func (c *LRUCache) pushFront(n *lruNode) {
	n.next = c.head
	if c.head != nil {
		c.head.prev = n
	}
	c.head = n
	if c.tail == nil {
		c.tail = n
	}
}

// Get: O(1)
func (c *LRUCache) Get(key string) (int, bool) {
	n, ok := c.items[key]
	if !ok {
		return 0, false
	}
	c.unlink(n)
	c.pushFront(n) // en yeni hâline getir
	return n.data.value, true
}

// Put: O(1), kapasite aşılırsa en eskiyi atar
func (c *LRUCache) Put(key string, value int) (evicted string, didEvict bool) {
	if n, ok := c.items[key]; ok {
		n.data.value = value
		c.unlink(n)
		c.pushFront(n)
		return "", false
	}

	if len(c.items) == c.capacity {
		oldest := c.tail
		c.unlink(oldest)
		delete(c.items, oldest.data.key)
		evicted, didEvict = oldest.data.key, true
	}

	n := &lruNode{data: entry{key: key, value: value}}
	c.pushFront(n)
	c.items[key] = n
	return evicted, didEvict
}

func (c *LRUCache) String() string {
	var parts []string
	for n := c.head; n != nil; n = n.next {
		parts = append(parts, fmt.Sprintf("%s=%d", n.data.key, n.data.value))
	}
	return "[" + strings.Join(parts, " ") + "]"
}

func main() {
	cache := NewLRU(3)

	for _, kv := range []entry{{"a", 1}, {"b", 2}, {"c", 3}} {
		cache.Put(kv.key, kv.value)
	}
	fmt.Println("başlangıç:", cache)

	v, ok := cache.Get("a") // a en yeniye taşınır
	fmt.Println("a okundu:", v, ok, "→", cache)

	evicted, did := cache.Put("d", 4) // kapasite doldu: en eski olan b atılır
	fmt.Printf("d eklendi, atılan: %q (%t) → %v\n", evicted, did, cache)

	_, ok = cache.Get("b")
	fmt.Println("b hâlâ var mı:", ok)

	cache.Put("c", 33) // var olan anahtarın güncellenmesi atma yapmaz
	fmt.Println("c güncellendi:", cache)
}
Çıktı
başlangıç: [c=3 b=2 a=1]
a okundu: 1 true → [a=1 c=3 b=2]
d eklendi, atılan: "b" (true) → [d=4 a=1 c=3]
b hâlâ var mı: false
c güncellendi: [c=33 d=4 a=1]
ZamanO(1)AlanO(n)

Bu tasarım, iki veri yapısının güçlü yanlarını birleştirmenin ders kitabı örneğidir: Harita anahtardan düğüme anında erişim verir, çift yönlü liste ise elindeki düğümü anında taşımayı ve silmeyi mümkün kılar. İkisinden birini çıkarırsan O(1) garantisi kaybolur — yalnızca liste olsaydı anahtarı aramak O(n), yalnızca harita olsaydı en eskiyi bulmak O(n) olurdu.

Gerçek uygulamalarda bu yapıya eşzamanlı erişim koruması (bkz. Senkronizasyon) ve genelde bir süre sınırı (TTL) da eklenir.

Kısa sınav

Kısa sınav

Bağlı listede indeksle erişim neden O(n)'dir?

Tek yönlü listede elindeki bir düğümü silmek neden O(n)'dir?

Floyd'un tavşan-kaplumbağa algoritması neyi sağlar?

Listeyi ters çevirirken next := cur.Next satırı neden cur.Next = prev satırından önce gelmelidir?

Kukla (dummy) baş düğüm kullanmanın amacı nedir?

Pratikte küçük veri kümelerinde dizi, bağlı listeden neden sık sık daha hızlıdır?

Özet

  • Bağlı liste, elemanları işaretçilerle bağlar; bitişik bellek gerektirmez ama indeksle erişimi O(n) yapar.
  • Baştan ekleme ve çıkarma O(1)'dir; tail işaretçisiyle sona ekleme de O(1) olur.
  • Çift yönlü listede her düğüm öncekini de tutar; elindeki düğümü O(1) sürede silebilirsin.
  • Dairesel listede son düğüm başa bağlanır; gezerken bitiş koşulunu tur sayısıyla kontrol et.
  • Ters çevirme üç işaretçiyle tek geçişte, O(1) ek bellekle yapılır; bağlantıyı kaybetmemek için sırayı koru.
  • Floyd'un hızlı-yavaş işaretçi tekniği döngüyü, orta düğümü ve sondan k'ıncı düğümü O(1) bellekle bulur.
  • Kukla düğüm, baş düğümün özel durumunu ortadan kaldırarak kodu sadeleştirir.
  • container/list çift yönlü dairesel bir liste sunar ama jenerik değildir.
  • Karmaşıklık tablosu bağlı listeyi cazip gösterse de önbellek davranışı nedeniyle diziler pratikte çoğu zaman daha hızlıdır; ölçmeden karar verme.
Bu dersi bitirdin mi?
İlerlemen bu tarayıcıda saklanır.