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.
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:
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 }())
}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 nextpackage 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())
}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ı.
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)
}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ş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)))
}ö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.
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)
}())
}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:
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()
}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:
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 = prevyazmadan öncenext = cur.Nextile bağlantıyı saklamazsan listenin geri kalanını kaybedersin. tailişaretçisini güncellemeyi unutmak. Son düğümü silerkentail'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 != nilkoşuluyla gezmek. Döngü asla bitmez. - Silinen düğümün
Nextalanı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
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
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)
}k=1 → 50 k=2 → 40 k=5 → 10 k=6 → geçersiz k=0 → geçersiz tek elemanlı listede k=1: 7
İ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.
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
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)))
}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ş
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.
Ç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
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)
}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]
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
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;
tailiş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.