Tek Yönlü Bağlı Listeler - Singly Linked List
Elemanları pointer'larla birbirine bağlayan dinamik veri yapısı
Bağlı Liste Nedir?
Bağlı liste (linked list), elemanların bellekte ardışık olmak zorunda olmadığı, her elemanın bir sonraki elemanın adresini tutarak birbirine bağlandığı dinamik bir veri yapısıdır. Dizilerde elemanlar yan yana dururken, bağlı listede her eleman belleğin herhangi bir yerinde olabilir; önemli olan birbirlerini tanımalarıdır.
Bunu bir kağıt oyununa benzetebilirsiniz: her kağıtta bir bilgi ve bir sonraki kağıdın nerede olduğunu gösteren bir ipucu yazılıdır. İlk kağıdı (head) bildiğiniz sürece, ipuçlarını takip ederek tüm kağıtlara sırayla ulaşabilirsiniz. Son kağıttaki ipucu "yok" (NULL) der ve zincir biter.
Temel Kavramlar
Düğüm (Node) Nedir?
Bağlı listenin her bir elemanına düğüm (node) denir. Tek yönlü bağlı listede her düğüm iki parçadan oluşur: saklanan veri (data) ve bir sonraki düğümü gösteren pointer (next). Dizideki bir eleman sadece veri tutarken, bağlı listedeki bir düğüm hem veriyi hem de "sonraki eleman nerede" bilgisini taşır. Bu ekstra pointer, bağlı listenin esnekliğinin bedelidir.
Head (Baş) Pointer
Head, listenin ilk düğümünü gösteren pointer'dır. Listeye erişmenin tek yolu head pointer'ıdır. Head'i kaybederseniz listenin tamamını kaybedersiniz, çünkü düğümlere ulaşmanın başka bir yolu yoktur. Boş bir listede head NULL'dur.
NULL (Son İşareti)
Listenin son düğümünün next pointer'ı NULL değerini tutar. Bu, "benden sonra kimse yok, liste burada bitiyor" anlamına gelir. Traverse işleminde NULL'a ulaşmak döngünün durma koşuludur.
Dizi ile Temel Fark
Dizi (Array)
Bellekte ardışık, sabit boyut, indeks ile O(1) erişim, ekleme/silme yavaş (O(n)), tanımda boyut gerekli
Bağlı Liste (Linked List)
Bellekte dağınık, dinamik boyut, erişim yavaş (O(n)), ekleme/silme hızlı (O(1)), boyut gerekmez
Bellekte Dizi vs Linked List
Farkı somutlaştırmak için ikisinin bellekteki görünümünü karşılaştıralım. Dizi elemanları yan yana otururken, linked list düğümleri belleğin tamamen farklı bölgelerinde olabilir. Birbirlerini pointer'lar aracılığıyla tanırlar.
Neden Bağlı Liste Kullanırız?
Diziler harika bir veri yapısıdır, peki o zaman neden linked list'e ihtiyaç duyarız? Çünkü bazı senaryolarda dizinin sınırlamaları ciddi sorun oluşturur:
Bilinmeyen veri miktarı: Bir metin editöründe kullanıcı kaç satır yazacağını önceden bilemezsiniz. Dizi ile ya çok büyük alan ayırıp israf edersiniz ya da sık sık realloc ile boyut değiştirirsiniz. Linked list'te her yeni satır için basitçe bir düğüm eklersiniz.
Sık ekleme ve silme: Bir müzik çalma listesinde şarkı eklemek, silmek ve sıra değiştirmek çok yaygın işlemlerdir. Dizide araya eleman eklemek O(n) kaydırma gerektirirken, linked list'te sadece birkaç pointer değiştirmek yeterlidir.
Bellek fragmentasyonu: Büyük bir ardışık bellek bloğu ayırmak bazen mümkün olmayabilir. Linked list düğümleri küçük parçalar halinde farklı yerlere dağılabildiği için bu sorundan etkilenmez.
Diğer veri yapılarının temeli: Stack, queue, hash table'ın chaining mekanizması, graf'ların komşuluk listeleri, işletim sistemlerinin process queue'ları — bunların hepsi arka planda linked list kullanır. Linked list'i anlamadan bu yapıları gerçek anlamda kavramak mümkün değildir.
Node Yapısı ve Liste Oluşturma
C dilinde bağlı liste düğümü bir struct ile tanımlanır. Struct içinde veri alanı ve bir sonraki düğümü gösteren pointer bulunur. Bu yapı kendi kendini referans alan (self-referential) bir yapıdır çünkü struct'ın içinde yine aynı struct türünden bir pointer vardır.
Şimdi bu düğümleri birbirine bağlayarak bir liste oluşturalım:
typedef ile Temiz Kod
Her yerde struct Node yazmak zahmetlidir. typedef kullanarak daha temiz bir sözdizimi elde edebilirsiniz. Bu yazım biçimi özellikle büyük projelerde kodun okunabilirliğini artırır ve hata yapma olasılığını azaltır.
Self-Referential Struct
Node struct'ının kendine referans vermesi (self-referential) ilk başta kafa karıştırıcı olabilir. struct Node içinde struct Node* yazmak, "bu struct türünden bir pointer tut" demektir. Struct'ın kendisini tutmak değil, sadece adresini tutmak söz konusudur. Bu fark çok önemlidir çünkü bir pointer her zaman sabit boyuttadır (64-bit sistemde 8 byte), ancak struct'ın kendisi değişken boyutta olabilir.
Her Düğümün Bellek Maliyeti
Bir int dizisinde her eleman sadece 4 byte yer kaplar. Ancak linked list'te her düğüm hem veriyi hem de bir pointer'ı taşır. 64-bit bir sistemde bu hesap şöyle olur:
| Alan | Boyut | Açıklama |
|---|---|---|
data (int) | 4 byte | Saklanan veri |
padding | 4 byte | Hizalama (alignment) için boşluk |
next (pointer) | 8 byte | 64-bit sistemde pointer boyutu |
| Toplam | 16 byte | Dizide aynı veri 4 byte tutardı |
Traverse İşlemi (Dolaşma)
Bağlı listede indeks yoktur. Bir elemana ulaşmak için head'den başlayıp next pointer'larını takip ederek ilerlemek zorundasınız. Bu işleme traverse denir. Geçici bir pointer (genellikle temp veya current) oluşturulur, head'den başlatılır ve her adımda temp = temp->next ile bir sonraki düğüme geçilir. NULL'a ulaşınca liste bitmiştir.
Belirli Pozisyondaki Elemana Erişim
Dizide arr[3] yazmak O(1) sürede doğrudan 4. elemana erişir. Linked list'te böyle bir lüks yoktur. 4. elemana ulaşmak için head'den başlayıp 3 kez next'i takip etmeniz gerekir. Bu, bağlı listenin en büyük dezavantajıdır.
Başa Eleman Ekleme
Başa ekleme, bağlı listenin en hızlı ekleme operasyonudur: O(1). Yeni düğüm oluşturulur, next pointer'ı mevcut head'e yönlendirilir ve head güncellenir. Hiçbir eleman kaydırılmaz, sadece iki pointer işlemi yeterlidir. Dizideki O(n) maliyetiyle karşılaştırıldığında devasa bir fark.
Mevcut liste:
Yeni düğüm oluştur, next'ini head'e bağla:
head = yeni düğüm → Tamamlandı!
Sona Eleman Ekleme
Sona ekleme için listenin sonuna kadar gitmek gerekir çünkü tek yönlü bağlı listede son düğüme doğrudan erişim yoktur. Bu yüzden sona ekleme O(n) karmaşıklığındadır. Son düğüm bulunur, next pointer'ı yeni düğüme yönlendirilir.
Son düğümü bul (next == NULL olan):
Son düğümün next'ini yeni düğüme bağla:
Tail Pointer ile O(1) Sona Ekleme
Sona ekleme her seferinde listenin sonuna kadar yürümek zorunda olması can sıkıcıdır. Ama basit bir optimizasyon ile bu O(n) maliyeti O(1)'e düşürebilirsiniz: listenin son düğümünü ayrı bir pointer'da (tail) tutmak. Her sona ekleme işleminde tail güncellenir ve bir sonraki ekleme doğrudan tail üzerinden yapılır.
Araya Eleman Ekleme
Belirli bir pozisyona eleman eklemek için o pozisyonun bir önceki düğümüne ulaşmak gerekir. Önceki düğümün next pointer'ı yeni düğüme, yeni düğümün next pointer'ı ise eski sonraki düğüme yönlendirilir. Bu işlem pointer seviyesinde O(1)'dir ama pozisyona ulaşmak O(n) sürer.
Pozisyon 1'deki düğümü bul (önceki):
Yeni düğümü araya bağla:
Sıralı Listeye Ekleme (Sorted Insert)
Eğer liste sıralı tutuluyorsa, yeni elemanı doğru pozisyona yerleştirmek gerekir. Bu işlem, kendisinden büyük ilk elemanın önüne ekleme yapmak demektir. Sıralı ekleme birçok algoritmada (insertion sort, priority queue) temel bir operasyondur.
Baştan Silme
Baştan silme, başa ekleme gibi O(1) karmaşıklığındadır. Head pointer bir sonraki düğüme kaydırılır ve eski head'in belleği serbest bırakılır.
Silinecek düğümü kaydet:
head = head->next, eski düğümü free():
Sondan Silme
Sondan silme için son düğümün bir önceki düğümüne ulaşmak gerekir. Önceki düğümün next pointer'ı NULL yapılır ve son düğüm serbest bırakılır. Tek yönlü bağlı listede geriye gidemediğimiz için baştan ilerlememiz gerekir, bu da O(n) karmaşıklığı demektir.
Sondan bir önceki düğümü bul:
Öncekinin next'ini NULL yap, son düğümü free():
Belirli Bir Elemanı Silme
Belirli bir değere sahip düğümü silmek için, o düğümün bir öncesini bulmak gerekir. Önceki düğümün next pointer'ı, silinecek düğümün next'ine yönlendirilir ve silinecek düğüm free() ile serbest bırakılır.
Silinecek düğümü ve bir öncesini bul:
Öncekini sonrakine bağla, silineni free():
Eleman Arama
Bağlı listede eleman aramak, dizideki doğrusal aramaya benzer: head'den başlayıp her düğümün verisini kontrol edersiniz. İndeks ile doğrudan erişim mümkün olmadığı için en kötü durumda tüm listeyi dolaşmanız gerekir. Karmaşıklık O(n)'dir.
Liste Uzunluğu Bulma
Dizilerin aksine bağlı listelerde eleman sayısını doğrudan bilmenin bir yolu yoktur. Listenin uzunluğunu bulmak için head'den başlayıp NULL'a kadar ilerleyerek her düğümde bir sayaç artırılır.
Ortadaki Elemanı Bulma (Two Pointer Tekniği)
Listenin ortadaki elemanını bulmak klasik bir mülakat sorusudur. Naive yaklaşım: önce uzunluğu bul, sonra n/2'ye git (iki geçiş). Ancak "slow-fast pointer" tekniği ile tek geçişte bulabilirsiniz. Yavaş pointer (slow) her adımda bir düğüm ilerlerken, hızlı pointer (fast) iki düğüm ilerler. Fast listenin sonuna ulaştığında, slow tam ortada olur.
Listeyi Tersine Çevirme
Listeyi tersine çevirmek, her düğümün next pointer'ını bir önceki düğüme yönlendirmek demektir. Bu klasik bir mülakat sorusudur ve iki farklı yaklaşımla çözülebilir: iterative (döngüsel) ve recursive (özyinelemeli).
Yöntem 1: Iterative (Döngüsel)
Üç pointer kullanılır: prev (önceki), current (mevcut) ve next (sonraki). Her adımda mevcut düğümün next pointer'ı önceki düğüme çevrilir, ardından üç pointer bir adım ileri kayar.
Başlangıç: prev=NULL, curr=head
10'un yönünü çevir: next→ yerine ←prev
20'nin yönünü çevir:
30'un yönünü çevir, curr = NULL → DUR
Yöntem 2: Recursive (Özyinelemeli)
Recursive yaklaşım, listenin sonuna kadar gider ve geri dönerken her düğümün yönünü çevirir. Bu yöntem kodun daha zarif ve kısa olmasını sağlar ancak her fonksiyon çağrısı stack'te yer kapladığı için alan karmaşıklığı O(n)'dir.
Mantık şöyledir: önce listenin geri kalanını ters çevir, sonra mevcut düğümü ters çevrilmiş listenin sonuna ekle. Base case: tek elemanlı veya boş liste zaten tersine çevrilmiştir.
İleri gidiş (recursive çağrılar):
ters(10→20→30→NULL)
ters(20→30→NULL)
ters(30→NULL) ← base case, 30'u döndür
Geri dönüş (pointer çevirme):
30→NULL → head=30
20->next->next=20 → 30→20, 20->next=NULL
10->next->next=10 → 30→20→10, 10->next=NULL
Iterative vs Recursive Karşılaştırma
| Özellik | Iterative | Recursive |
|---|---|---|
| Zaman karmaşıklığı | O(n) | O(n) |
| Alan karmaşıklığı | O(1) — sadece 3 pointer | O(n) — call stack |
| Kod okunabilirliği | Orta — 3 pointer yönetimi | Yüksek — kısa ve zarif |
| Uzun listelerde risk | Yok | Stack overflow riski |
| Debug kolaylığı | Kolay — adım adım takip | Zor — çağrı yığını karmaşık |
| Tavsiye edilen durum | Üretim kodu | Öğrenme / mülakat |
Sık Yapılan Hatalar
1. Head Güncellemesini Unutmak
Başa ekleme veya baştan silme işlemlerinde head pointer'ını güncellemeyi unutmak en yaygın hatadır. Fonksiyon içinde head'i değiştirmek için çift pointer (Node**) geçmek zorunludur, aksi halde değişiklik fonksiyon dışına yansımaz.
2. free() Sonrası Pointer Kullanımı (Dangling Pointer)
Bir düğümü free() ile sildikten sonra, o düğüme ait pointer hâlâ eski adresini tutar. Bu pointer'ı kullanmaya devam etmek tanımsız davranışa yol açar. Silme işleminden sonra pointer'ı NULL'a eşitlemek iyi bir alışkanlıktır.
3. Link Kaybı (Lost Node)
Araya ekleme veya silme işlemlerinde pointer'ları yanlış sırada değiştirmek, düğümler arasındaki bağlantının kopmasına yol açar. Kopan bağlantıdan sonraki düğümlere bir daha ulaşılamaz ve bu düğümlerin belleği de serbest bırakılamaz (memory leak).
4. malloc Başarısızlığını Kontrol Etmemek
Sistem belleği dolduğunda malloc() NULL döndürür. Bu durumu kontrol etmeden devam etmek, NULL pointer üzerinde erişim denemesine ve programın çökmesine yol açar. Her malloc çağrısından sonra NULL kontrolü yapılmalıdır.
5. Boş Liste Kontrolü Yapmamak
Neredeyse her linked list fonksiyonunun başında "liste boş mu?" kontrolü yapılmalıdır. Boş listede (head == NULL) traverse, silme veya erişim yapmaya çalışmak çökmeye neden olur. Tek elemanlı liste de özel bir durumdur çünkü hem head hem son düğümdür.
Bağlı Listeler Nerelerde Kullanılır?
İşletim Sistemleri
İşletim sistemleri process (süreç) yönetimi için bağlı listeler kullanır. Çalışan süreçler bir listede tutulur ve zamanlayıcı (scheduler) bu listeden sırayla süreç seçer. Yeni bir süreç başlatıldığında listeye eklenir, sonlandığında listeden çıkarılır. Bellek yönetiminde boş bellek blokları da linked list ile takip edilir.
Web Tarayıcıları
Tarayıcıdaki ileri-geri navigasyon geçmişi aslında bir bağlı liste yapısıdır. Her sayfa bir düğümdür ve "geri" butonu önceki düğüme, "ileri" butonu sonraki düğüme geçer. Bu özellik çift yönlü bağlı liste ile daha verimli gerçeklenir ancak mantık tek yönlü listelerden başlar.
Müzik ve Medya Oynatıcılar
Çalma listeleri bağlı liste ile yönetilir. Şarkı ekleme, silme, sıra değiştirme, tekrarla (repeat) gibi operasyonlar linked list'in güçlü olduğu alanlardır. Döngüsel (circular) linked list kullanıldığında son şarkıdan otomatik olarak ilk şarkıya geçiş sağlanır.
Metin Editörleri
Bazı metin editörleri her satırı bir düğüm olarak bağlı listede saklar. Satır ekleme, silme ve araya satır sokmak böylece çok hızlı yapılır. Undo/redo mekanizması da genellikle bir linked list veya stack (linked list tabanlı) ile gerçeklenir.
Hash Table — Chaining
Hash tablolarında çarpışma (collision) durumunda aynı indekse düşen elemanlar linked list ile zincirlenir. Her hash bucket'ı aslında bir linked list'in head pointer'ıdır. Bu konuyu müfredatın hash table bölümünde detaylı göreceksiniz.
Bellek Yönetimi: free() Kullanımı
C dilinde malloc() ile ayırdığınız her bellek bloğunu işiniz bittiğinde free() ile serbest bırakmanız gerekir. Aksi takdirde bellek sızıntısı (memory leak) oluşur: program çalıştıkça bellek tüketimi artar ve sonunda sistem yavaşlar veya çöker. Listenin tamamını silmek için her düğümü tek tek free() etmelisiniz.
Zaman Karmaşıklığı Özeti
| İşlem | Linked List | Array | Kazanan |
|---|---|---|---|
| Başa ekleme | O(1) | O(n) | Linked List |
| Sona ekleme | O(n) | O(1) | Array |
| Araya ekleme | O(n)* | O(n) | Linked List* |
| Baştan silme | O(1) | O(n) | Linked List |
| Sondan silme | O(n) | O(1) | Array |
| İndeks ile erişim | O(n) | O(1) | Array |
| Eleman arama | O(n) | O(n) | Berabere |
| Tersine çevirme | O(n) | O(n) | Berabere |
Sonuç
Tek yönlü bağlı listeler, dizilerin sabit boyut ve yavaş ekleme/silme gibi sınırlamalarını çözmek için tasarlanmış temel bir veri yapısıdır. Dinamik bellek yönetimi, pointer manipülasyonu ve recursive düşünme gibi kavramları uygulamalı olarak öğretir. Bu konulara hakim olmak, sadece bağlı listeler için değil, ileride karşılaşacağınız ağaçlar, graflar ve daha karmaşık veri yapıları için de sağlam bir temel oluşturur.
Ancak tek yönlü bağlı listelerin de sınırlamaları vardır: geriye doğru gidemezsiniz, son düğümü silmek O(n) sürer ve her düğüm ekstra bir pointer alanı kaplar. Bu sınırlamaları aşmak için bir sonraki konumuzda çift yönlü bağlı listeleri (doubly linked list) inceleyeceğiz. Çift yönlü listelerde her düğüm hem ileri hem geri yönde bağlantıya sahiptir ve bu sayede birçok operasyon çok daha verimli hale gelir.
Bağlı Liste Ailesi
Tek yönlü bağlı liste, bağlı liste ailesinin en temel üyesidir. Müfredatın ilerleyen bölümlerinde karşılaşacağınız diğer üyeler şunlardır:
| Tür | Özellik | Avantajı |
|---|---|---|
| Tek Yönlü (Singly) | Her düğüm sadece ileriyi görür | Basit, az bellek kullanır |
| Çift Yönlü (Doubly) | Her düğüm hem ileri hem geri bağlantıya sahip | Sondan silme O(1), geri traverse |
| Dairesel (Circular) | Son düğüm head'e geri bağlanır | Döngüsel yapılar, round-robin |
| Çift Yönlü Dairesel | Hem çift yönlü hem dairesel | En esnek, her yöne O(1) hareket |