Veri Yapıları · Ders Notu

Bölüm 14: Çizgeler

Şehirler ve aralarındaki yollar, bir sosyal ağdaki kişiler ve arkadaşlıklar, bir bölümdeki dersler ve ön koşulları birbirine benzer. Hepsi aynı soyut yapıyla anlatılır: bir grup nesne ve bu nesneler arasındaki ikili ilişkiler. Bu yapıya çizge denir. Ağaçlarda her düğümün tek bir ebeveyni vardır ve döngü yoktur. Çizgede bu kısıtlar kalkar: bir düğüme pek çok yoldan ulaşılabilir, yollar dönüp aynı düğüme gelebilir. Not, çizgenin temel kavramlarıyla başlar. Ardından çizgenin bellekte nasıl saklandığını ve bu seçimin işlem maliyetlerini nasıl etkilediğini anlatır. Sonra genişlik öncelikli ve derinlik öncelikli aramayı, bunlara dayanan bileşen bulma, iki parçalılık testi, topolojik sıralama ve döngü tespitini işler. Son iki konu en kısa yol (ağırlıksız çizgede BFS, ağırlıklı çizgede Dijkstra) ve minimum kapsayan ağaçtır (Kruskal ve Prim).

Öğrenme çıktıları

Temel kavramlar
Düğüm, kenar, yön, ağırlık, derece, yol, döngü ve bağlantılılık kavramlarını açıklamak; el sıkışma lemmasını kullanarak kenar ve derece sayılarını hesaplamak.
Gösterimler
Bir çizgeyi kenar listesi, komşuluk listesi ve komşuluk matrisi ile saklamak; bu gösterimleri bellek ve işlem maliyetleriyle karşılaştırıp probleme uygun olanı seçmek.
Gezinme ve sıralama
BFS ve DFS'yi elle ve kodla uygulamak; bağlantılı bileşenleri bulmak, iki parçalılığı sınamak, yönlü döngüsüz çizgelerde (DAG) topolojik sıralama yapmak ve yönlü döngüleri bulmak.
En kısa yol ve MKA
Ağırlıksız en kısa yol için BFS'yi, ağırlıklı en kısa yol için Dijkstra'yı, minimum kapsayan ağaç için Kruskal ve Prim algoritmalarını uygulamak ve hangi problemde hangisinin kullanılacağını gerekçelendirmek.

14.1Temel kavramlar

Çizge algoritmalarını okumak için önce küçük bir sözlüğe ihtiyaç vardır: düğüm, kenar, derece, yol, döngü, bağlantılılık. Bu kavramların çoğu günlük dildeki anlamlarına yakındır. Yine de her birinin kesin bir tanımı vardır. Algoritmaların doğru çalışıp çalışmadığı da çoğu zaman bu kesin tanımlara bakarak anlaşılır.

14.1.1Çizge nedir?

Bir şehir haritasını düşünelim. Haritada kavşaklar ve kavşakları birbirine bağlayan yollar vardır. Bir yolun uzunluğu, eğimi, kaç şeritli olduğu gibi pek çok ayrıntı da vardır. “Hangi kavşaktan hangi kavşağa gidilebilir?” sorusunu yanıtlamak için bu ayrıntıların çoğu gereksizdir. Yalnız iki bilgi yeter: kavşakların listesi ve hangi iki kavşağın doğrudan bir yolla bağlı olduğu. Bu iki bilginin oluşturduğu soyut yapıya çizge (graph) denir.

Tanım 14.1

Bir çizge G = (V, E) ikilisidir. V sonlu bir düğümler (vertex, node) kümesidir. E ise kenarlar (edge) kümesidir; her kenar iki düğümü birbirine bağlar ve (u, v) biçiminde yazılır. Düğüm sayısı n = |V|, kenar sayısı m = |E| ile gösterilir.

Tanımdaki |V| gösterimi, V kümesinin eleman sayısı demektir. Not boyunca n her zaman düğüm sayısını, m her zaman kenar sayısını gösterir. Algoritmaların maliyeti de bu iki sayıyla ifade edilir; örneğin “O(n + m) zaman” ifadesi, sürenin düğüm sayısı ile kenar sayısının toplamıyla orantılı büyüdüğü anlamına gelir.

0 1 2 3 4 kenar düğüm
Şekil 14.1: Beş düğümlü ve yedi kenarlı yönsüz bir çizge. Düğümler 0'dan 4'e numaralanmıştır; daireler düğümleri, çizgiler kenarları gösterir.
Örnek 14.1

Şekil 14.1'deki çizge kümelerle şöyle yazılır:

  • V = {0, 1, 2, 3, 4}, dolayısıyla n = 5.
  • E = {(0,1), (0,4), (1,2), (1,3), (1,4), (2,3), (3,4)}, dolayısıyla m = 7.

Kenarların yönü yoktur: (0,1) ile (1,0) aynı kenardır. Bu yüzden her kenar listede bir kez yazılır. Şekil ile küme gösterimi aynı bilgiyi taşır; düğümlerin kâğıttaki yeri, çizgilerin uzunluğu ya da eğriliği çizgeye ait değildir.

Çizge, ağacın genelleştirilmiş hâli olarak da düşünülebilir. Ağaçta bir kök vardır, her düğümün (kök dışında) tek bir ebeveyni vardır ve döngü yoktur. Çizgede bu kuralların hiçbiri aranmaz:

  • Kök yoktur. Hiçbir düğüm ötekilerden ayrıcalıklı değildir. Bir algoritma bir düğümden başlayacaksa bu başlangıç düğümü ayrıca verilir.
  • Döngü olabilir. Bir düğümden çıkıp kenarlar boyunca ilerleyerek aynı düğüme geri dönmek mümkündür. Şekil 14.1'de 1 → 2 → 3 → 1 böyle bir dönüştür.
  • Bir düğüme birden çok yoldan ulaşılabilir. 0'dan 3'e hem 0 → 1 → 3 hem de 0 → 4 → 3 yoluyla gidilir.

Ağaç “bire-çok” ilişkiyi modeller: bir ebeveynin çok çocuğu olur ama her çocuğun tek ebeveyni vardır. Çizge ise “çoğa-çok” ilişkiyi modeller. Bir kişinin pek çok arkadaşı olur; bu arkadaşların her birinin de başka pek çok arkadaşı vardır. Bir ders birkaç dersin ön koşuludur; aynı dersin kendisi de birkaç ön koşula bağlıdır. Bu esneklik çizgeyi güçlü bir model yapar. Bedeli ise algoritmalarda ortaya çıkar: aynı düğüme ikinci kez gelme ihtimali her zaman vardır ve algoritma bunu fark etmek zorundadır.

Sık yapılan hata

Çizgenin çizimini çizgenin kendisi sanmak. Aynı çizge kâğıda pek çok farklı biçimde çizilebilir. Düğümlerin yeri değişince ya da iki kenar şekilde birbirini kesince yeni bir düğüm veya kenar oluşmaz. İki kenarın çizimde kesiştiği nokta bir düğüm değildir. Bir çizgeyi tanımlayan yalnız V ve E kümeleridir.

14.1.2Düğüm ve kenar

Düğüm, çizgenin temel yapı taşıdır ve modellenen nesneyi temsil eder: bir şehir, bir kişi, bir bilgisayar, bir web sayfası, bir ders. Düğüm yalnız bir numaradan ibaret olmak zorunda değildir; bir etiket, bir ad ya da haritadaki konum gibi bir değer de saklayabilir. Algoritmalar içinse düğümleri 0'dan n − 1'e numaralamak en pratik yoldur. Böylece düğüme ait bilgiler dizilerde, düğüm numarası indis olacak biçimde tutulabilir.

KARADENİZAKDENİZEGEEdirneİstanbulBursaİzmirEskişehirAnkaraAntalyaKonyaAdanaKayseriSamsunSivasGiresunTrabzonErzurumGaziantepDiyarbakırVan
Şekil 14.2: Türkiye karayolu ağının basitleştirilmiş bir çizimi. Şehirler düğüm, aralarındaki ana yollar kenardır.

Harita çizge olarak modellenince “İstanbul'dan Van'a en kısa yol hangisi?” sorusu bir çizge algoritması problemine dönüşür. Kenarlara yolların uzunluğu yazılırsa bu problemi Dijkstra algoritması çözer (14.4.4). Modelleme, problemin özünü koruyup gereksiz ayrıntıyı atmak demektir. Yolun asfalt mı stabilize mi olduğu en kısa yol için önemli değildir, ama uzunluğu önemlidir.

Kenar, iki düğüm arasındaki ilişkiyi temsil eder: iki şehir arasındaki yol, iki bilgisayar arasındaki ağ bağlantısı, iki kişi arasındaki arkadaşlık. (v, w) bir kenar ise v ile w'ye komşu ya da bitişik (adjacent) düğümler denir. Kenar da bu iki düğüme bağlıdır (incident). Şekil 14.1'de 1 ile 3 komşudur, 0 ile 2 komşu değildir.

Şekil 14.3: Bir sosyal ağ çizimi. Kişiler düğüm, arkadaşlıklar kenardır. Renkler, kendi içinde sıkı bağlı toplulukları gösterir; topluluklar arasında az sayıda kenar vardır.

Şekil 14.3 gerçek ağların tipik bir özelliğini gösterir: düğüm sayısı büyüktür ama her düğümün yalnız birkaç komşusu vardır. Bir sosyal ağda milyonlarca kişi bulunur, oysa bir kişinin arkadaş sayısı en çok birkaç yüz ya da birkaç bindir. Kenar sayısı, olabilecek en büyük kenar sayısının çok küçük bir kesridir. Böyle çizgelere seyrek çizge denir. Seyreklik, çizgenin bellekte nasıl saklanacağına karar verirken belirleyici olur (14.2.6).

14.1.3Derece ve el sıkışma lemması

Tanım 14.2

Yönsüz bir çizgede v düğümünün derecesi (degree), v'ye bağlı kenar sayısıdır ve deg(v) ile gösterilir. Hiçbir kenara bağlı olmayan, yani derecesi 0 olan düğüme yalnız düğüm (isolated vertex) denir. Bir düğümü kendisine bağlayan kenara öz döngü (self-loop) denir; öz döngü dereceye 2 katkı yapar.

Günlük hayattaki karşılığı kolaydır: bir kavşağın derecesi, o kavşağa giren yolların sayısıdır. Öz döngünün neden 2 saydığı da bu benzetmeyle görülür. Kavşaktan çıkıp yine aynı kavşağa dönen bir yol kavşağa iki ucuyla bağlanır; kavşakta duran biri o yolun iki ağzını görür.

1 3 3 2 5 2 0 öz döngü yalnız düğüm
Şekil 14.4: Her düğümün içine kendi derecesi yazılmıştır: 1, 3, 3, 2, 5, 2 ve 0. Sağ alttaki 5 dereceli düğümde bir öz döngü vardır; soldaki 0 dereceli düğüm yalnızdır.

Şekil 14.4'teki çizgede derecelerin toplamını alalım: 1 + 3 + 3 + 2 + 5 + 2 + 0 = 16. Çizgede yedi sıradan kenar ve bir öz döngü, toplam sekiz kenar vardır. Toplamın kenar sayısının tam iki katı çıkması rastlantı değildir.

Teorem 14.1 (El sıkışma lemması)

Her yönsüz çizgede düğüm derecelerinin toplamı kenar sayısının iki katıdır: Σ deg(v) = 2m.

Dereceleri toplarken her kenarı kaç kez saydığımıza bakalım. (u, v) kenarı deg(u)'ya 1, deg(v)'ye 1 katkı yapar; başka hiçbir düğümün derecesini etkilemez. Öz döngüde u = v'dir ve tanım gereği yine toplam 2 katkı yapar. Demek ki her kenar toplama tam 2 ekler; m kenar toplamı 2m yapar. □

Adı, bir toplantıdaki el sıkışmalardan gelir: herkes kaç kişiyle el sıkıştığını söylerse bu sayıların toplamı, el sıkışma sayısının iki katıdır. Çünkü her el sıkışmada iki el vardır. Lemmanın iki pratik sonucu vardır:

  • Dereceler biliniyorsa kenar sayısı hemen bulunur: m = Σ deg(v) / 2. Örneğin derecelerin toplamı 30 olan bir çizgede 15 kenar vardır.
  • Toplam her zaman çifttir. Bu yüzden tek dereceli düğümlerin sayısı da her zaman çifttir. Çift dereceler toplamı çift yapar; tek derecelerin toplamının da çift olması için bunlardan çift sayıda bulunmalıdır. Dereceleri 3, 3, 2, 2, 1 olan bir çizge kurulamaz: toplam 11 tektir.
Sık yapılan hata

Kenar sayısını bulurken dereceleri toplayıp ikiye bölmeyi unutmak. Her kenar iki ucunda birer kez sayıldığı için toplam, kenar sayısının iki katıdır. Aynı yanılgı bellek hesaplarında da görülür: yönsüz bir çizgenin komşuluk listelerinde toplam m değil, 2m kayıt bulunur.

14.1.4Yönlü çizge

Bazı ilişkiler simetriktir. A, B'nin arkadaşıysa B de A'nın arkadaşıdır; iki şehir arasındaki çift yönlü yol her iki yönde kullanılır. Bazı ilişkiler ise tek yönlüdür. Bir web sayfası başka bir sayfaya bağlantı verebilir ama o sayfa geri bağlantı vermeyebilir. Bir ders başka bir dersin ön koşuludur, tersi doğru değildir. Tek yönlü sokakta yalnız bir yönde gidilir. Bu ilişkiler yönlü çizgeyle (directed graph, kısaca digraph) modellenir.

Tanım 14.3

Yönlü çizgede her kenar sıralı bir (u, v) çiftidir ve u'dan v'ye giden bir ok olarak çizilir. u kenarın kuyruğu (başlangıcı), v ise başıdır (hedefi). Yönlü çizgede (u, v) ≠ (v, u)'dur. Bir düğümün giriş derecesi (in-degree) ona gelen okların, çıkış derecesi (out-degree) ondan çıkan okların sayısıdır.

A B C E D F
Şekil 14.5: Altı düğümlü yönlü bir çizge. Oklar A → B, B → C, C → E, E → D, D → B ve E → F'dir.
Tablo 14.1: Şekil 14.5'teki çizgenin giriş ve çıkış dereceleri.
DüğümABCDEFToplam
giriş derecesi0211116
çıkış derecesi1111206

Tablodaki değerler oklar tek tek sayılarak bulunur. B'ye iki ok gelir: A'dan ve D'den. Bu yüzden B'nin giriş derecesi 2'dir. E'den iki ok çıkar: D'ye ve F'ye. Bu yüzden E'nin çıkış derecesi 2'dir. A'ya hiç ok gelmez; F'den hiç ok çıkmaz.

Satır toplamlarının ikisi de 6'dır ve çizgedeki kenar sayısına eşittir. Bu, el sıkışma lemmasının yönlü karşılığıdır: her ok kuyruğundaki düğümün çıkış derecesine 1, başındaki düğümün giriş derecesine 1 ekler. Dolayısıyla Σ giriş derecesi = Σ çıkış derecesi = m'dir. Yönsüz çizgedeki 2m'nin yerini burada iki ayrı toplam alır.

Yönlü çizgede ulaşılabilirlik de yöne bağlıdır. Şekil 14.5'te A'dan B'ye bir ok vardır, ama B'den A'ya hiçbir yol yoktur: A'ya hiç ok gelmez. Öte yandan B'den C'ye, oradan E'ye, D'ye ve yeniden B'ye gidilebilir. Yani B, C, E, D birbirine ulaşabilir; A ve F bu gruba dışarıdan bağlıdır.

Sık yapılan hata

Yönlü çizgede komşuluğu iki yönlü sanmak. (u, v) kenarı varsa v, u'nun komşusudur; ama (v, u) kenarı yoksa u, v'nin komşusu değildir. Bu, “v'den u'ya hiç gidilemez” demek de değildir: v'den başka düğümler üzerinden u'ya dolaylı bir yol bulunabilir.

14.1.5Yol ve döngü

Tanım 14.4

Bir yol (path), ardışık her iki düğümü bir kenarla bağlı olan bir düğüm dizisidir: v0, v1, …, vk ve her i için (vi, vi+1) bir kenardır. Hiçbir düğümü tekrar etmeyen yola basit yol denir. Aynı düğümde başlayıp biten yola döngü (cycle) denir. Başlangıç düğümünün sonda tekrar gelmesi dışında hiçbir düğümü tekrar etmeyen döngü basit döngüdür.

Yolun uzunluğu, ağırlıksız çizgede üzerindeki kenar sayısıdır; k + 1 düğümlü bir yol k kenar uzunluğundadır. Yönlü çizgede yol, okların yönünde ilerlemelidir; böyle bir yola yönlü yol, yönlü bir döngüye de yönlü döngü denir.

A B C D E F G H I J K L
Şekil 14.6: On iki düğümlü bir çizgede iki basit döngü. Yeşil döngü A-B-C-D-E-F-A, kırmızı kesikli döngü G-H-I-J-K-L-G'dir. İki döngü B–G, C–G, C–L ve D–K kenarlarıyla birbirine bağlıdır; kırmızı döngünün içinde ayrıca K–I kenarı vardır.

Şekil 14.6'daki iki döngünün her biri altı kenar uzunluğundadır. Bunlar çizgedeki tek döngüler değildir. Örneğin C → G → L → C de bir döngüdür; C–G, G–L ve L–C kenarları vardır. Bir çizgedeki döngü sayısı, kenar sayısı arttıkça hızla büyür.

Ağaçta döngü yoktur. Ağaçta iki düğüm arasında tek bir basit yol bulunur. Çizgede ise iki düğüm arasında birden çok basit yol olabilir; iki farklı basit yol birleşince bir döngü oluşur. Uçuş rotalarıyla bir örnek verelim. İstanbul → Ankara → İzmir → İstanbul bir döngüdür. Uçak arada aynı şehre ikinci kez uğramadığı için bu basit bir döngüdür. Rota İstanbul → Ankara → İzmir → Ankara → İstanbul olsaydı Ankara iki kez geçerdi; döngü yine döngüdür ama basit değildir.

14.1.6İki örnek çizge

Kavramları küçük bir yönsüz ve küçük bir yönlü çizge üzerinde birlikte uygulayalım.

1 2 3 4 5
Şekil 14.7: Yönsüz örnek çizge: V = {1, 2, 3, 4, 5}, E = {(1,2), (1,3), (1,4), (2,5), (3,4), (4,5)}.
Örnek 14.2

Şekil 14.7'deki çizge için:

  1. Komşuluk. 1 ile 2 komşudur, çünkü (1,2) bir kenardır. 2 ile 3 komşu değildir.
  2. Dereceler. 1'in komşuları 2, 3, 4'tür: deg(1) = 3. 4'ün komşuları 1, 3, 5'tir: deg(4) = 3. 2, 3 ve 5'in ikişer komşusu vardır. Toplam 3 + 2 + 2 + 3 + 2 = 12 = 2 · 6; lemma doğrulanır.
  3. Yol türleri. Aşağıdaki tablo dört diziyi sınıflandırır.
Tablo 14.2: Şekil 14.7'deki çizgede dört düğüm dizisi.
DiziTürüGerekçe
1 → 2 → 5basit yol1–2 ve 2–5 kenarları var; düğüm tekrarı yok
1 → 3 → 4 → 1basit döngü1'de başlayıp 1'de biter; arada tekrar yok
1 → 3 → 4 → 1 → 2basit olmayan yolkenarların hepsi var, ama 1 iki kez geçiyor
1 → 3 → 4 → 1 → 4 → 1basit olmayan döngü1'de başlayıp 1'de biter, ama 1 ve 4 arada tekrar ediyor

Bir dizinin yol olup olmadığını anlamak için ardışık her çiftin arasında kenar olup olmadığına bakılır. Örneğin 2 → 5 → 3 bir yol değildir, çünkü 5–3 kenarı yoktur. Basitlik ise ikinci bir denetimdir: önce dizi yol olmalı, sonra düğüm tekrarına bakılır.

1 2 3 4 5
Şekil 14.8: Yönlü örnek çizge: V = {1, 2, 3, 4, 5}, E = {(1,2), (1,4), (2,5), (4,5), (3,1), (4,3)}.
Örnek 14.3

Şekil 14.8'deki yönlü çizgede:

  • 2, 1'in komşusudur, çünkü 1 → 2 oku vardır. 1 ise 2'nin komşusu değildir: 2 → 1 oku yoktur.
  • 1 → 2 → 5 bir yönlü yoldur.
  • 1 → 4 → 3 → 1 bir yönlü döngüdür: 1 → 4, 4 → 3 ve 3 → 1 okları vardır.
  • 5'ten çıkan hiçbir ok yoktur. 5'ten başlayan her yol tek düğümden ibarettir; 5'ten başka hiçbir düğüme ulaşılamaz.

14.1.7Bağlantılı çizge ve tam çizge

Tanım 14.5

Yönsüz bir çizgede her iki farklı düğüm arasında bir yol varsa çizge bağlantılıdır (connected). Birbirine yolla ulaşabilen düğümlerin en büyük grubuna bağlantılı bileşen (connected component) denir. Her iki farklı düğüm arasında doğrudan bir kenar varsa çizge tam çizgedir (complete graph); n düğümlü tam çizge Kn ile gösterilir.

İki tanım arasındaki farka dikkat etmek gerekir. Bağlantılılık yol ister, tamlık kenar ister. Tam çizge her zaman bağlantılıdır, ama bağlantılı bir çizge tam olmak zorunda değildir. Bileşen tanımındaki “en büyük” sözcüğü de önemlidir: bir bileşene, ondaki herhangi bir düğüme yolla ulaşabilen her düğüm dahildir. Bileşenler çizgenin düğümlerini çakışmayan gruplara ayırır. Bağlantılı bir çizgenin tek bir bileşeni vardır.

Şekil 14.9: Soldan sağa: beş düğümün dört kenarla tek parça olduğu bağlantılı bir çizge (1 bileşen); bir kenarla bağlı iki düğüm ve iki yalnız düğümden oluşan bağlantısız bir çizge (3 bileşen); beş düğümlü tam çizge K5 (10 kenar).

Kenar sayısı için iki sınır vardır. Basit (öz döngüsüz ve çift kenarsız) yönsüz bir çizgede her düğüm çifti en çok bir kenarla bağlanır. Farklı düğüm çiftlerinin sayısı n(n − 1)/2'dir: ilk düğüm n biçimde, ikinci düğüm n − 1 biçimde seçilir ve her çift iki kez sayıldığı için 2'ye bölünür. Bu yüzden m ≤ n(n − 1)/2'dir ve eşitlik yalnız tam çizgede sağlanır. K5'te 5 · 4 / 2 = 10, 10 düğümlü tam çizgede 10 · 9 / 2 = 45 kenar vardır.

Alt sınır bağlantılılıktan gelir. Bağlantılı bir çizgede m ≥ n − 1'dir. Bunu görmek için kenarları boş bir çizgeye tek tek eklediğimizi düşünelim. Başta n bileşen vardır; her yalnız düğüm ayrı bir bileşendir. Bir kenar eklemek bileşen sayısını en çok 1 azaltır: ya iki bileşeni birleştirir ya da aynı bileşenin içine düşer. Tek bileşene inmek için en az n − 1 kenar gerekir. Tam n − 1 kenarlı bağlantılı çizge bir ağaçtır; bu gözlem kapsayan ağaçta yeniden karşımıza çıkar (14.5.1).

Bu iki sınır, seyrek ve yoğun çizge ayrımının da temelidir. Kenar sayısı n mertebesindeyse (m ≈ n) çizge seyrektir; n2 mertebesindeyse (m ≈ n2) yoğundur. Yol ağları ve sosyal ağlar seyrektir. Herkesin herkesle doğrudan bağlantılı olduğu küçük gruplar yoğundur.

14.1.8Ağırlıklı çizge

Pek çok problemde yalnız “bağlı mı?” sorusu yetmez; bağlantının bir bedeli vardır. İki şehir arasındaki yolun uzunluğu, bir ağ bağlantısının gecikmesi, bir boru hattının maliyeti ya da bir hattın kapasitesi bu türden bilgilerdir.

Tanım 14.6

Ağırlıklı çizgede (weighted graph) her kenara bir sayı atanır; bu sayıya kenarın ağırlığı (weight) denir. Bir yolun uzunluğu ya da ağırlığı, üzerindeki kenar ağırlıklarının toplamıdır. Ağırlıklı çizge yönsüz de yönlü de olabilir.

8 10 3 6 5 7 1 2 3 4 5 8 3 6 7 10 5 1 2 3 4 5
Şekil 14.10: Aynı beş düğüm üzerinde ağırlıklı yönsüz (üstte) ve ağırlıklı yönlü (altta) çizge. Yönsüz çizgede kenarlar 1–2: 8, 1–3: 10, 1–4: 3, 2–5: 6, 3–4: 5, 4–5: 7'dir. Yönlü çizgede oklar 1 → 2: 8, 1 → 4: 3, 2 → 5: 6, 4 → 5: 7, 3 → 1: 10, 4 → 3: 5'tir.
Örnek 14.4

Şekil 14.10'daki yönsüz çizgede 3 → 4 → 1 yolunun uzunluğu 5 + 3 = 8'dir. Yönlü çizgede 1 → 4 → 3 yolunun uzunluğu 3 + 5 = 8'dir.

Yönsüz çizgede 1 ile 3'ü doğrudan bağlayan bir kenar vardır ve ağırlığı 10'dur. Oysa 1 → 4 → 3 yolu yalnız 8 tutar. Doğrudan kenar, iki düğüm arasındaki en kısa yol olmak zorunda değildir. Daha çok kenardan geçen bir yol, kenarları hafifse daha kısa olabilir.

Bu gözlem, en kısa yol algoritmalarının neden gerektiğini açıklar. Ağırlıksız çizgede en kısa yol en az kenarlı yoldur. Ağırlıklı çizgede ise kenar sayısı yanıltıcıdır; ağırlıkların toplamına bakmak gerekir. Ağırlıksız çizgeyi, her kenarın ağırlığının 1 olduğu ağırlıklı çizge olarak da düşünebiliriz.

14.1.9Çizge ADT'si

Çizge de öteki veri yapıları gibi önce bir soyut veri tipi (abstract data type, ADT) olarak tanımlanabilir. ADT, hangi işlemlerin bulunduğunu ve ne yaptıklarını söyler; bu işlemlerin bellekte nasıl yürütüleceğini söylemez. Goodrich, Tamassia ve Goldwasser'in kitabındaki Graph arayüzü, aşağıdaki temel işlemleri içerir.

Tablo 14.3: Çizge ADT'sinin temel işlemleri (V: düğüm, E: kenar).
İşlemAçıklama
numVertices(), numEdges()Düğüm sayısı n, kenar sayısı m
vertices(), edges()Bütün düğümler / bütün kenarlar üzerinde gezinme
getEdge(u, v)u'dan v'ye kenar varsa onu, yoksa null döndürür
endVertices(e), opposite(v, e)Kenarın iki ucu / e kenarının v dışındaki ucu
outDegree(v), inDegree(v)Çıkış / giriş derecesi (yönsüz çizgede ikisi eşittir)
outgoingEdges(v), incomingEdges(v)v'den çıkan / v'ye gelen kenarlar
insertVertex(x), insertEdge(u, v, x)Yeni düğüm / yeni kenar ekler (x: saklanacak değer)
removeVertex(v), removeEdge(e)Düğümü bağlı kenarlarıyla birlikte / kenarı siler

Algoritmaların en çok kullandığı iki işlem öne çıkar. Birincisi getEdge(u, v), yani “u ile v komşu mu?” sorusudur. İkincisi outgoingEdges(v), yani “v'nin komşuları kimler?” sorusudur. BFS, DFS, Dijkstra ve Prim gibi algoritmalar ikinci işlemi her düğüm için bir kez çağırır. Bu yüzden bir gösterimin komşuları ne kadar hızlı verdiği, algoritmaların toplam süresini doğrudan belirler.

removeVertex(v)'nin bağlı kenarları da silmesi bir tutarlılık kuralıdır. Ucu olmayan bir kenar anlamsız olur; bu yüzden düğümle birlikte ona bağlı bütün kenarlar da çizgeden çıkar. Bu işlemlerin maliyeti çizgenin bellekte nasıl temsil edildiğine bağlıdır.

14.2Çizge gösterimleri

Bir çizgeyi bilgisayarda saklamanın tek bir doğru yolu yoktur. Aynı çizge üç farklı biçimde tutulabilir ve her biçim bazı işlemleri hızlandırırken bazılarını yavaşlatır. Bu kesimde üç temel gösterimi sırayla ele alıyor, sonra bunları bellek ve işlem maliyetleri açısından karşılaştırıyoruz. Kesimin sonunda komşuluk listesinin ve matrisinin Java ile nasıl kurulduğunu satır satır izliyoruz.

14.2.1Üç temel gösterim ve kenar listesi

Çizgeyi saklamanın üç temel yolu şunlardır:

  • Kenar listesi (edge list): bütün kenarlar tek bir listede tutulur, örneğin [(A,B), (A,E), …].
  • Komşuluk listesi (adjacency list): her düğüm için o düğümün komşularının listesi tutulur, örneğin V[i] = {j, k, l}.
  • Komşuluk matrisi (adjacency matrix): n × n boyutlu iki boyutlu bir dizi tutulur; M[i][j] hücresi i'den j'ye kenar olup olmadığını gösterir.
A B C D E
Şekil 14.11: Gösterimlerin karşılaştırılmasında kullanılan beş düğümlü yönsüz çizge. Kenarlar A–B, A–E, B–C, B–E, C–D ve D–E'dir.

Kenar listesi en basit gösterimdir. Şekil 14.11'deki çizge için liste (A, B) (A, E) (B, C) (B, E) (C, D) (D, E) biçimindedir. Kenarı olmayan düğümleri kaybetmemek için düğümler de ayrı bir listede tutulur; böylece bellek O(n + m) olur. Yeni bir kenar eklemek listenin sonuna bir öğe eklemektir ve O(1) sürer.

Kenar listesinin zayıflığı sorgulardadır. “u ile v komşu mu?” sorusunu yanıtlamak için listede (u, v) ya da (v, u) aranır. Liste hiçbir düzene göre sıralı olmadığı için, en kötü durumda bütün liste taranır: O(m). “u'nun komşuları kim?” sorusu da aynı biçimde tüm listeyi taramayı gerektirir, çünkü u'ya bağlı kenarlar listenin herhangi bir yerinde olabilir. Gezinme algoritmaları bu soruyu her düğüm için sorduğundan, kenar listesiyle bir gezinme O(n · m)'ye çıkar. Kenar listesi yine de bir algoritmada işe yarar: Kruskal algoritması kenarları ağırlığa göre sıralayıp tek tek dener ve komşu sorgusu yapmaz (14.5.3).

14.2.2Komşuluk matrisi

Tanım 14.7

Düğümleri 0'dan n − 1'e numaralanmış bir çizgenin komşuluk matrisi, n × n boyutlu bir matris dizisidir. i'den j'ye kenar varsa matris[i][j] = 1, yoksa 0'dır. Ağırlıklı çizgede hücre, kenarın ağırlığını tutar; kenar yoksa ∞ ya da bu anlama gelen özel bir değer yazılır.

Yönsüz çizgede her kenar iki yönde de geçerlidir. (i, j) kenarı hem matris[i][j] hem de matris[j][i] hücresine yazılır. Bu yüzden matris ana köşegene göre simetriktir: her i, j için matris[i][j] == matris[j][i]'dir. Yönlü çizgede bu simetri yoktur.

0 1 2 3 0 0 0 1 1 0 1 1 1 0 1 0 2 2 1 1 0 1 3 3 0 0 1 0 ← m[1][2]: tek hücre, O(1) ← 2'nin komşuları: tüm satır, O(n) simetrik: m[i][j] = m[j][i]
Şekil 14.12: Dört düğümlü bir çizge (kenarlar 0–1, 0–2, 1–2, 2–3) ve komşuluk matrisi. m[1][2] hücresine tek erişimle bakılır: O(1). 2'nin komşularını bulmak için 2. satırın tamamı okunur: O(n).

Matrisin güçlü yanı doğrudan erişimdir. Java'da iki boyutlu bir dizinin herhangi bir hücresi, indisleri bilindiği sürece sabit sürede okunur ve yazılır. Bu yüzden üç işlem O(1)'dir:

  • “i ile j arasında kenar var mı?” sorusu, matris[i][j] hücresini okumaktır.
  • Kenar eklemek, bir (yönsüzde iki) hücreye 1 yazmaktır.
  • Kenar silmek, aynı hücrelere 0 yazmaktır.

Zayıf yanı da iki tanedir. Birincisi, bir düğümün komşularını bulmak için o düğümün satırının n hücresinin hepsine bakmak gerekir. Düğümün tek bir komşusu olsa bile süre O(n)'dir. İkincisi bellektir: matris, kenar sayısından bağımsız olarak n2 hücre kaplar. Seyrek bir çizgede hücrelerin büyük çoğunluğu 0'dır; bellek, var olmayan kenarları kaydetmeye harcanır.

Örnek 14.5

Bir milyon düğümlü (n = 106) bir yol ağının komşuluk matrisi n2 = 1012 hücre ister. Hücre başına yalnız 1 bit ayrılsa bile bu yaklaşık 125 GB eder. Oysa her kavşakta ortalama üç dört yol birleşir; kenar sayısı birkaç milyon mertebesindedir. Dolu hücrelerin oranı milyonda birkaç düzeyinde kalır.

14.2.3Komşuluk matrisi: üç örnek

Aynı beş düğüm (A–E) üzerinde yönsüz, yönlü ve ağırlıklı üç çizgenin matrislerini karşılaştıralım. Satırlar ve sütunlar A, B, C, D, E sırasıyla dizilmiştir.

A B C D E A B C D E 4 3 3 2 2 5 2 A B C D E
Şekil 14.13: Soldan sağa: yönsüz çizge (Şekil 14.11'deki çizge); dokuz oklu yönlü çizge (A → B, A → E, B → C, B → D, D → A, D → C, D → E, E → B, E → D); ağırlıklı yönlü çizge (A → B: 4, B → C: 3, B → D: 3, C → A: 2, C → E: 2, E → B: 5, E → D: 2).
Tablo 14.4: Yönsüz çizgenin komşuluk matrisi.
ABCDE
A01001
B10101
C01010
D00101
E11010

Yönsüz matriste her kenar iki hücreye yazılmıştır; altı kenar 12 tane 1 verir. Matris köşegene göre simetriktir: örneğin A satırının E sütunu ile E satırının A sütunu ikisi de 1'dir. Bir satırın toplamı, o düğümün derecesidir. B satırında üç tane 1 vardır (A, C, E), dolayısıyla deg(B) = 3'tür. Köşegen 0'dır, çünkü çizgede öz döngü yoktur.

Tablo 14.5: Yönlü çizgenin komşuluk matrisi (satır = kaynak, sütun = hedef).
ABCDE
A01001
B00110
C00000
D10101
E01010

Yönlü matriste her ok yalnız bir hücreye yazılır: dokuz ok, dokuz tane 1. Satır kaynağı, sütun hedefi gösterir; bu yüzden matris simetrik değildir. A → B oku m[A][B] = 1 yapar, ama B → A oku olmadığı için m[B][A] = 0'dır. Satır toplamı çıkış derecesini, sütun toplamı giriş derecesini verir. D satırında üç tane 1 vardır: D'nin çıkış derecesi 3'tür. C satırının tamamı 0'dır: C'den hiç ok çıkmaz. C sütununda ise iki tane 1 vardır (B ve D'den): C'nin giriş derecesi 2'dir.

Tablo 14.6: Ağırlıklı yönlü çizgenin komşuluk matrisi (kenar yoksa ∞, köşegende 0).
ABCDE
A04∞∞∞
B∞033∞
C2∞0∞2
D∞∞∞0∞
E∞5∞20

Ağırlıklı matriste hücre, kenarın ağırlığını tutar. A satırının B sütunundaki 4, A → B okunun ağırlığıdır. Kenar olmayan hücrelere ∞ yazılır. Köşegende ise 0 vardır: bir düğümün kendisine uzaklığı sıfırdır. Bu gösterim en kısa yol problemlerinde kullanışlıdır, çünkü ∞ “doğrudan gidilemez” anlamına gelir ve toplamalarda kendiliğinden doğru davranır: ∞ artı herhangi bir sayı yine ∞'dur.

Sık yapılan hata

Ağırlıklı matriste kenar olmayan hücreye 0 yazmak. 0, ağırlığı 0 olan gerçek bir kenarla karışır; algoritma var olmayan bir kenarı “bedava yol” sanır. Java bir int[][] dizisini oluştururken bütün hücreleri 0 ile başlattığı için bu hata kendiliğinden ortaya çıkar. Kenarsız hücreler açıkça Integer.MAX_VALUE gibi bir değerle doldurulmalıdır. Köşegendeki 0 ise kendine uzaklık olarak anlamlıdır ve doğrudur.

14.2.4Komşuluk listesi

Tanım 14.8

Komşuluk listesi gösteriminde her düğüm için bir liste tutulur; n düğüm için n liste vardır. liste[i], i'nin komşularını (ağırlıklı çizgede ağırlıklarıyla birlikte) içerir. Listeler çoğunlukla bağlı liste ya da dinamik dizi olarak gerçeklenir.

Yönsüz çizgede bir (v, w) kenarı iki listeye birden eklenir: w, v'nin listesine; v, w'nin listesine. Yönlü çizgede ise yalnız kaynağın listesine eklenir. Listede yalnız var olan kenarlar yer aldığı için bellek n liste başı ile toplam kayıt sayısının toplamıdır. Yönsüz çizgede kayıt sayısı 2m, yönlü çizgede m'dir. Her iki durumda bellek O(n + m)'dir.

0 1 2 3 0 1 2 1 0 2 2 0 1 3 3 2 ← 2–3 var mı? listeyi tara: O(deg 2) toplam kayıt 2m = 8 → alan O(n + m)
Şekil 14.14: Şekil 14.12'deki dört düğümlü çizgenin komşuluk listesi: 0: 1, 2 · 1: 0, 2 · 2: 0, 1, 3 · 3: 2. “2–3 kenarı var mı?” sorusu için 2'nin listesi taranır: O(deg 2). Toplam kayıt 2m = 8'dir.

Komşuluk listesinin güçlü yanı gezinmedir. Bir düğümün komşularını gezmek, onun listesini baştan sona okumaktır ve O(deg(v)) sürer. Matristeki gibi komşu olmayan düğümlere boşuna bakılmaz. Kenar eklemek de listenin sonuna bir öğe eklemektir; dinamik dizide amortize O(1), bağlı listede O(1)'dir.

Zayıf yanı, “u ile v arasında kenar var mı?” sorgusudur. Listeler sıralı tutulmadığı sürece v'yi bulmak için u'nun listesi taranır: O(deg(u)). Yönsüz çizgede iki listeden kısa olanı seçilebilir ve süre O(min(deg u, deg v)) olur. Bir düğümün derecesi büyükse bu sorgu matristeki O(1)'e göre yavaştır.

14.2.5Komşuluk listesi: üç örnek

Şekil 14.13'teki üç çizgenin komşuluk listeleri aşağıdadır. Komşular alfabetik sırayla yazılmıştır.

A B E B A C E C B D D C E E A B D A B E B C D C (boş) D A C E E B D A B 4 B C 3 D 3 C A 2 E 2 D (boş) E B 5 D 2
Şekil 14.15: Soldan sağa: yönsüz çizgenin listeleri (A: B, E · B: A, C, E · C: B, D · D: C, E · E: A, B, D); yönlü çizgenin listeleri (A: B, E · B: C, D · C boş · D: A, C, E · E: B, D); ağırlıklı yönlü çizgenin listeleri (A: (B, 4) · B: (C, 3), (D, 3) · C: (A, 2), (E, 2) · D boş · E: (B, 5), (D, 2)).
  • Yönsüz. Altı kenar listelerde 12 kayıt oluşturur; her kenar iki kez yazılır. Bir listenin uzunluğu, düğümün derecesidir: B'nin listesi üç öğelidir ve deg(B) = 3'tür.
  • Yönlü. Yalnız çıkan kenarlar listelenir. Dokuz ok dokuz kayıt verir. C'den çıkan ok olmadığı için C'nin listesi boştur. Liste uzunluğu burada çıkış derecesidir. Giriş derecesini bulmak için bütün listeleri taramak gerekir; bu, listenin yönlü çizgedeki bir eksiğidir.
  • Ağırlıklı. Her kayıt (komşu, ağırlık) çifti tutar. B'nin listesi (C, 3), (D, 3)'tür. Java'da bu çift int[]{v, w} dizisiyle ya da küçük bir Kenar sınıfıyla temsil edilir.

14.2.6Gösterimlerin maliyeti

Tablo 14.7: Üç gösterimin bellek ve işlem maliyetleri (n düğüm, m kenar).
İşlemKenar listesiKomşuluk listesiKomşuluk matrisi
BellekO(n + m)O(n + m)O(n2)
u–v kenarı var mı? (getEdge)O(m)yönsüz: O(min(deg u, deg v)); yönlü u → v: O(outDeg u)O(1)
v'nin komşularını gezO(m)O(deg v)O(n)
Kenar ekleO(1)O(1)O(1)

Tablo, seçimin çizgenin yapısına ve en sık yapılan işleme göre verilmesi gerektiğini gösterir. İki tipik durum vardır:

  • Seyrek çizge (m ≈ n). Yol ağları ve sosyal ağlar bu türdendir. Matris n2 hücre ister ve hücrelerin neredeyse tamamı boş kalır. Komşuluk listesi hem belleği O(n + m)'de tutar hem de gezinmeyi hızlandırır. Bu notta incelenen bütün gezinme ve en kısa yol algoritmaları listeyle O(n + m) ya da ona yakın sürede çalışır.
  • Yoğun çizge (m ≈ n2). Kenarların çoğu varsa matrisin boş hücresi azdır ve bellek kaybı önemsizleşir. “Kenar var mı?” sorgusu sık yapılıyorsa matrisin O(1) erişimi belirleyici olur.
Örnek 14.6

n = 10.000 düğüm ve m = 30.000 kenarlı bir yol ağı saklanacak olsun. Hangi gösterim seçilmelidir?

  1. Matris. n2 = 108 hücre gerekir. Yönsüz çizgede dolu hücre sayısı 2m = 60.000'dir; oran 60.000 / 108 = %0,06'dır.
  2. Liste. n liste başı ve 2m kayıt: 10.000 + 60.000 = 70.000 öğe. Bu sayı n + m = 40.000 ile aynı mertebededir, yani bellek O(n + m)'dir.
  3. Karar. Çizge seyrektir (m = 3n). Komşuluk listesi seçilir. Gezinme de listede O(n + m), matriste O(n2) sürer.
Sık yapılan hata

Matristeki satır taramasının maliyetini getEdge ile karıştırmak. Matriste “kenar var mı?” tek hücre okumasıdır ve O(1)'dir; O(n) olan, bir düğümün bütün komşularını bulmak için satırı taramaktır. Listede ise durum tersinedir: komşuları gezmek ucuz, tek bir kenarı sorgulamak görece pahalıdır.

14.2.7Java ile komşuluk listesi

Düğümler 0'dan n − 1'e numaralanırsa komşuluk listesi, listelerden oluşan bir liste olarak yazılabilir. Dış listenin i. öğesi, i düğümünün komşu listesidir.

class Cizge {
    private final int n;                                  // düğümler: 0..n-1
    private final List<List<Integer>> komsu = new ArrayList<>();
    Cizge(int n) {
        this.n = n;
        for (int i = 0; i < n; i++)
            komsu.add(new ArrayList<>());
    }
    void kenarEkle(int u, int v) {
        komsu.get(u).add(v);
        komsu.get(v).add(u);                  // yönlü çizgede bu satır yok
    }
    List<Integer> komsular(int u) { return komsu.get(u); }   // O(1)
    int derece(int u) { return komsu.get(u).size(); }
}

Kodun parçaları şunlardır:

  • komsu alanı List<List<Integer>> türündedir. Dış liste düğüm başına bir iç liste tutar; iç listeler ArrayList, yani dinamik dizidir.
  • Yapıcı, n'yi saklar ve döngüyle n tane boş iç liste ekler. Bu adım O(n) zaman alır. Düğümlerin hepsi baştan bilindiği için listeler önceden açılır; böylece komsu.get(u) her u için geçerli bir liste döndürür.
  • kenarEkle(u, v) iki satırdır: v'yi u'nun listesine, u'yu v'nin listesine ekler. Yönlü çizgede ikinci satır yazılmaz. ArrayList.add amortize O(1)'dir; metot da öyledir.
  • komsular(u) listenin kendisini döndürür; kopyalama yapılmadığı için O(1)'dir. Listenin gezilmesi ise çağıranın işidir ve O(deg(u)) sürer.
  • derece(u), listenin boyutudur. ArrayList boyutunu bir alanda tuttuğu için bu da O(1)'dir.

Matris karşılığı daha kısadır: boolean[][] m = new boolean[n][n]; diziyi oluşturur, m[u][v] = m[v][u] = true; kenar ekler, m[u][v] sorgular. Ağırlıklı çizgede listenin öğesi int[]{v, w} ya da küçük bir Kenar sınıfı olur; matris ise int[][] olur ve kenarsız hücrelere Integer.MAX_VALUE yazılır.

14.2.8Kod izleme: komşuluk listesi kurma

Beş düğümlü bir çizgeyi altı kenarla kuralım. Kenarlar sırasıyla {0,1}, {0,4}, {1,2}, {1,4}, {2,3}, {3,4}'tür. İzlenen kod, sınıfın yapıcısı ile kenarEkle metodu ve bunları çağıran main parçasıdır.

Cizge(int n) {
  this.n = n;
  for (int i = 0; i < n; i++)
    komsu.add(new ArrayList<>());
}
void kenarEkle(int u, int v) {
  komsu.get(u).add(v);
  komsu.get(v).add(u);  // yönlüde yok
}
// main:
Cizge g = new Cizge(5);
int[][] E = {{0,1}, {0,4}, {1,2},
             {1,4}, {2,3}, {3,4}};
for (int[] e : E) g.kenarEkle(e[0], e[1]);
Tablo 14.8: new Cizge(5) ve altı kenarEkle çağrısından sonra komşu listeleri.
Adım01234
yapıcı (i = 0 … 4)[][][][][]
kenarEkle(0, 1)[1][0][][][]
kenarEkle(0, 4)[1, 4][0][][][0]
kenarEkle(1, 2)[1, 4][0, 2][1][][0]
kenarEkle(1, 4)[1, 4][0, 2, 4][1][][0, 1]
kenarEkle(2, 3)[1, 4][0, 2, 4][1, 3][2][0, 1]
kenarEkle(3, 4)[1, 4][0, 2, 4][1, 3][2, 4][0, 1, 3]

İzlemenin ilk aşaması yapıcıdır. new Cizge(5) çağrısında n = 5 atanır; komsu henüz boş bir dış listedir. Döngü i = 0'dan i = 4'e beş tur döner ve her turda boş bir iç liste ekler. Döngü bittiğinde her düğümün boş bir komşu listesi vardır.

İkinci aşama kenarlardır. kenarEkle(0, 1)'in ilk satırı 1'i 0'ın listesinin sonuna ekler. İkinci satır aynı kenarı ters yönde işler ve 0'ı 1'in listesine ekler. Sonraki her çağrı aynı iki adımı yapar. Listelerdeki sıra, ekleme sırasıdır: 1'in listesi [0, 2, 4] olur, çünkü 0–1, 1–2 ve 1–4 kenarları bu sırayla gelmiştir.

0 1 2 3 4 komsu 0 [ ] 1 [ ] 2 [ ] 3 [ ] 4 [ ] 0 1 2 3 4 komsu 0 1 1 0 2 [ ] 3 [ ] 4 [ ]
0 1 2 3 4 komsu 0 1 4 1 0 2 4 2 1 3 [ ] 4 0 1 0 1 2 3 4 komsu 0 1 4 1 0 2 4 2 1 3 3 2 4 4 0 1 3
Şekil 14.16: Komşuluk listesi kurulurken dört an. (a) Yapıcıdan sonra beş boş liste. (b) kenarEkle(0, 1)'den sonra 0: [1], 1: [0]. (c) kenarEkle(1, 4)'ten sonra 1: [0, 2, 4], 4: [0, 1]. (d) Son durum: 12 kayıt.

Sonuçta altı kenar, listelerde 2 · 6 = 12 kayıt oluşturur. Liste uzunlukları 2, 3, 2, 2, 3'tür ve toplamları 12'dir; bu da el sıkışma lemmasının kodda görünen biçimidir. derece(1), 1'in listesinin boyutudur ve 3 döndürür. Her kenarEkle çağrısı sabit sürelidir; bellek O(n + m)'dir.

Sık yapılan hata

Yönsüz çizgede kenarEkle'nin ikinci satırını unutmak. Bu durumda çizge sessizce yönlü hâle gelir: 0–1 kenarı 0'ın listesinde görünür ama 1'in listesinde görünmez. Gezinme 1'den başlarsa 0'a hiç ulaşamaz. Kod hata vermez; yanlış sonuç verir. Tersi de bir hatadır: yönlü bir ilişkiyi iki yönlü eklemek, olmayan geri dönüş yolları yaratır.

14.2.9Kod izleme: komşuluk matrisi kurma

Aynı altı kenarı bu kez 5 × 5 boyutlu bir boolean matrise ekleyelim.

boolean[][] m = new boolean[n][n];
void kenarEkle(int u, int v) {
  m[u][v] = true;
  m[v][u] = true;  // yönlü çizgede yok
}
// main: aynı 6 kenar (n = 5)
for (int[] e : E) g.kenarEkle(e[0], e[1]);

new boolean[n][n] 25 hücrelik bir matris ayırır. Java, boolean dizisinin bütün hücrelerini false ile başlatır; başlangıçta hiç kenar yoktur. Matrisin oluşturulması bile O(n2) sürer, çünkü 25 hücrenin her biri başlangıç değerini almak zorundadır.

Her kenarEkle(u, v) iki hücre yazar. kenarEkle(0, 1) önce m[0][1] = true yapar (satır 0, sütun 1), sonra simetrik hücreyi, m[1][0]'ı true yapar. Altı kenar sırayla şu hücre çiftlerini doldurur: (0,1)–(1,0), (0,4)–(4,0), (1,2)–(2,1), (1,4)–(4,1), (2,3)–(3,2), (3,4)–(4,3).

0 1 2 3 4 0 1 2 3 4 0 0 1 0 0 1 1 1 0 1 0 1 2 0 1 0 1 0 3 0 0 1 0 1 4 1 1 0 1 0 m[satır][sütun] (1 = true)
Şekil 14.17: Altı kenar eklendikten sonra komşuluk matrisi. 12 hücre true, 13 hücre false'tur. Matris köşegene göre simetriktir.
Tablo 14.9: Son durumdaki matris (1 = true, 0 = false).
01234
001001
110101
201010
300101
411010

Bu küçük örnekte bile iki gösterimin farkı görülür. “2–4 kenarı var mı?” sorusu m[2][4] hücresine tek erişimle yanıtlanır: false, yani kenar yok. Aynı soruyu listeyle yanıtlamak için 2'nin listesi [1, 3] taranır. Öte yandan 2'nin komşularını bulmak için matriste 2. satırın beş hücresinin hepsi okunur; listede ise yalnız iki öğe okunur. Matrisin 25 hücresinden 13'ü hiçbir bilgi taşımaz. Çizge büyüdükçe ve seyrekleştikçe bu oran hızla kötüleşir.

14.3Gezinme: BFS ve DFS

Çizge algoritmalarının çoğu, çizgeyi sistemli biçimde dolaşan bir iskelet üzerine kurulur. Bu iskeletin iki temel türü vardır: genişlik öncelikli arama ve derinlik öncelikli arama. İkisi de aynı düğüm kümesini ziyaret eder; farkları ziyaret sırasındadır. Sıranın farklı olması, her birini farklı problemlere uygun kılar. Kesimin ikinci yarısında bu iki aramanın üzerine kurulan dört uygulamayı ele alıyoruz: bağlantılı bileşenler, iki parçalılık testi, topolojik sıralama ve yönlü döngü tespiti.

14.3.1Gezinme ve ziyaret işareti

Tanım 14.9

Gezinme (traversal), bir başlangıç düğümünden ulaşılabilen bütün düğümleri, her birini bir kez olmak üzere, sistemli bir sırayla ziyaret etmektir.

İki temel gezinme yöntemi vardır:

  • Genişlik öncelikli arama (breadth-first search, BFS) önce başlangıcın bütün komşularını, sonra onların komşularını ziyaret eder. Seviye seviye ilerler. Yardımcı yapı olarak kuyruk (FIFO: ilk giren ilk çıkar) kullanır.
  • Derinlik öncelikli arama (depth-first search, DFS) bir yol boyunca gidebildiği kadar derine iner. Çıkmaza gelince geri döner ve başka bir dalı dener. Yardımcı yapı olarak yığın (LIFO: son giren ilk çıkar) ya da özyineleme kullanır.

Ağaçta gezinme ile çizgede gezinme arasında önemli bir fark vardır. Ağaçta bir düğüme kökten tek bir yol vardır; çocuklara inerek ilerleyen bir algoritma hiçbir düğüme iki kez gelmez. Çizgede ise döngü olabilir ve aynı düğüme birden çok yoldan ulaşılabilir. Bu yüzden çizge gezinmesi hangi düğümleri gördüğünü kaydetmek zorundadır. Bu kayıt çoğunlukla düğüm numarasıyla indislenen bir boolean[] ziyaret dizisidir.

Sık yapılan hata

Ağaç gezinmesini çizgeye olduğu gibi uygulamak. Ziyaret işareti olmayan bir gezinme döngüye girdiğinde aynı düğümleri tekrar tekrar dolaşır ve hiç bitmez. Döngü olmasa bile bir düğüme birden çok yoldan gelindiğinde düğüm birden çok kez işlenir ve maliyet üstel olarak büyüyebilir. Ziyaret işareti bir ayrıntı değil, algoritmanın doğruluğunun parçasıdır.

14.3.2Genişlik öncelikli arama

Suya atılan bir taşı düşünelim. Dalgalar taşın düştüğü noktadan halkalar hâlinde yayılır; önce yakın halkalar, sonra uzaktakiler oluşur. BFS de böyle çalışır. Başlangıca 1 kenar uzaklıktaki düğümleri bulur, sonra 2 kenar uzaklıktakileri, sonra 3 kenar uzaklıktakileri. Başlangıçtan k kenar uzaktaki düğümlerin oluşturduğu gruba seviye k denir.

seviye 0 seviye 1 seviye 2 s a b c d e 1 2 3 4 5 6
Şekil 14.18: BFS seviye seviye ilerler. Düğümlerin yanındaki numara ziyaret sırasıdır: s (1); a, b, c (2, 3, 4); d, e (5, 6). Kalın kenarlar BFS ağacını oluşturur; soluk kesikli b–d kenarı ağaca girmez, çünkü d'ye a üzerinden zaten ulaşılmıştır.

Bu sırayı kuyruk sağlar. Bir düğüm keşfedildiğinde kuyruğun arkasına eklenir; işlenecek düğüm her zaman kuyruğun önünden alınır. Seviye 1'deki düğümlerin hepsi, seviye 2'deki herhangi bir düğümden önce kuyruğa girer. Kuyruk ilk giren ilk çıkar ilkesiyle çalıştığı için seviye 1'in hepsi seviye 2'den önce işlenir.

BFS'nin her düğümü keşfettiği kenarlar bir ağaç oluşturur; buna BFS ağacı denir. Şekil 14.18'de d'ye hem a hem b üzerinden gidilebilir. a daha önce işlendiği için d, a–d kenarıyla keşfedilir; b–d kenarı ağaca girmez. BFS ağacında kökten bir düğüme giden yol, o düğüme giden en az kenarlı yollardan biridir. Bu özellik, ağırlıksız en kısa yol algoritmasının temelidir (14.4.1).

BFS'nin iki güçlü yanı vardır: ağırlıksız çizgede en kısa yolu bulur ve başlangıca yakın düğümleri önce keşfeder. Sosyal ağda “arkadaşlarımın arkadaşları” sorusu seviye 2'deki düğümlerdir; bir web tarayıcı robotu (crawler) bir siteyi bağlantı derinliğine göre katman katman tarar. Zayıf yanı bellektir: bir seviyenin tamamı aynı anda kuyrukta bekleyebilir. Her düğümün çok sayıda komşusu olan geniş çizgelerde kuyruk çok büyüyebilir.

14.3.3BFS algoritması ve kodu

Önce algoritmayı sözde kodla, sonra Java ile verelim.

BFS(başlangıç):
  kuyruk.ekle(başlangıç)
  başlangıç.ziyaretEdildi()
  while kuyruk boş değil:
    w = kuyruk.çıkar()
    for w'nin ziyaret edilmemiş
        her komşusu u:
      u.ziyaretEdildi()
      kuyruk.ekle(u)
void bfs(int s) {
  boolean[] ziyaret = new boolean[n];
  Queue<Integer> q = new ArrayDeque<>();
  ziyaret[s] = true;  q.add(s);
  while (!q.isEmpty()) {
    int w = q.poll();
    System.out.print(w + " ");
    for (int u : komsu.get(w))
      if (!ziyaret[u]) {
        ziyaret[u] = true;
        q.add(u);
      }
  }
}

Java kodu, 14.2.7'deki Cizge sınıfının bir metodu olarak yazılmıştır; n ve komsu alanlarını kullanır. Satır satır inceleyelim:

  • boolean[] ziyaret = new boolean[n]; her düğüm için bir işaret ayırır. Java bu diziyi false ile başlatır; başta hiçbir düğüm görülmemiştir.
  • Queue<Integer> q = new ArrayDeque<>(); boş bir kuyruk oluşturur. ArrayDeque'te add arkaya ekler, poll öndeki öğeyi çıkarıp döndürür; ikisi de O(1)'dir.
  • ziyaret[s] = true; q.add(s); başlangıç düğümünü işaretleyip kuyruğa koyar.
  • Döngü, kuyruk boşalana kadar sürer. Her turda öndeki düğüm w çıkarılır ve yazdırılır.
  • İç döngü w'nin komşu listesini gezer. Ziyaret edilmemiş her komşu u önce işaretlenir, sonra kuyruğa eklenir.

Kodun en ince noktası işaretin nerede konduğudur. Düğüm kuyruğa girerken işaretlenir, kuyruktan çıkarken değil. Böylece bir düğüm birden çok komşunun listesinde görünse bile kuyruğa yalnız bir kez girer. İşaret kuyruktan çıkarken konsaydı, aynı düğüm kuyrukta beklerken başka bir komşusu onu bir daha ekleyebilirdi.

Sık yapılan hata

Düğümü kuyruktan çıkarırken işaretlemek. Kenarları 0–1, 0–2, 1–2 olan üçgende böyle bir BFS 0'dan başlarsa çıktı 0 1 2 2 olur. 0 çıkınca 1 ve 2 eklenir. 1 çıkınca 2 henüz işaretsiz olduğu için tekrar eklenir. Sonuçta 2 iki kez çıkar ve iki kez yazdırılır. Yoğun çizgelerde kuyruk O(n) yerine O(m) boyuta büyür.

Maliyet. Her düğüm kuyruğa en çok bir kez girer ve bir kez çıkar: toplam O(n). Her düğüm çıktığında komşu listesi bir kez taranır. Bütün listelerin toplam uzunluğu yönsüz çizgede 2m'dir; tarama toplamı O(m) eder. BFS'nin toplam süresi komşuluk listesiyle O(n + m)'dir. Komşuluk matrisiyle her düğümün satırı n hücre taranacağı için süre O(n2) olur. Ek bellek ziyaret dizisi ve kuyruk için O(n)'dir.

14.3.4Kod izleme: bfs(A)

Dokuz düğümlü bir çizge üzerinde bfs(A)'yı izleyelim. Düğümler A = 0, B = 1, …, I = 8 diye numaralanmıştır. Komşu listeleri alfabetik sıradadır: A: B, F, I · B: A, C, E · C: B, D, E · D: C, G, H · E: B, C, G · F: A, G · G: D, E, F · H: D · I: A. Çizgede 9 düğüm ve 11 kenar vardır.

I A B C D H G F E q (ön → arka) (boş) ziyaret[] A F B F C F D F E F F F G F H F I F görülmedi kuyrukta işleniyor işlendi
Şekil 14.19: Gezinme örneklerinde kullanılan çizge ve başlangıç durumu. Kuyruk boştur, hiçbir düğüm ziyaret edilmemiştir. Aşağıdaki şeritte her düğümün ziyaret[] durumu görülür.
Tablo 14.10: bfs(A) izlemesi. Her satır dış döngünün bir turudur.
TurÇıkan w (yazdırılır)Taranan komşularİşaretlenip eklenenlerKuyruk (tur sonunda, önden arkaya)
başlangıç——A[A]
1AB, F, IB, F, I (seviye 1)[B, F, I]
2BA, C, EC, E (seviye 2)[F, I, C, E]
3FA, GG (seviye 2)[I, C, E, G]
4IA—[C, E, G]
5CB, D, ED (seviye 3)[E, G, D]
6EB, C, G—[G, D]
7GD, E, F—[D]
8DC, G, HH (seviye 4)[H]
9HD—[]

İzlemenin önemli anları şunlardır. Başlangıçta A işaretlenir ve kuyruğa girer. Birinci turda A çıkar ve yazdırılır; komşuları B, F, I ziyaret edilmemiştir, üçü de işaretlenip sırayla kuyruğa eklenir. Bunlar seviye 1'dir.

İkinci turda kuyruğun önündeki B çıkar. B'nin ilk komşusu A'dır ve zaten işaretlidir; atlanır. Ziyaret işareti olmasaydı A burada kuyruğa ikinci kez girerdi. C ve E işaretlenip kuyruğun arkasına eklenir. Kuyruk artık [F, I, C, E]'dir: önde hâlâ seviye 1'in düğümleri, arkada seviye 2'nin düğümleri vardır. Kuyruk, seviyelerin karışmasını kendiliğinden önler.

Üçüncü turda F çıkar, G eklenir. Dördüncü turda I çıkar; tek komşusu A işaretli olduğu için kuyruğa bir şey eklenmez. Böylece seviye 1 biter. Beşinci turda C çıkar ve D'yi seviye 3 olarak ekler. E ile G çıkınca bütün komşuları işaretli bulunur. Sekizinci turda D, H'yi seviye 4 olarak ekler. H çıkınca kuyruk boşalır ve döngü biter.

I A B C D H G F E q (ön → arka) B F I ziyaret[] A T B T C F D F E F F T G F H F I T görülmedi kuyrukta işleniyor işlendi I A B C D H G F E q (ön → arka) F I C E ziyaret[] A T B T C T D F E T F T G F H F I T görülmedi kuyrukta işleniyor işlendi
I A B C D H G F E q (ön → arka) E G D ziyaret[] A T B T C T D T E T F T G T H F I T görülmedi kuyrukta işleniyor işlendi I A B C D H G F E q (ön → arka) (boş) ziyaret[] A T B T C T D T E T F T G T H T I T görülmedi kuyrukta işleniyor işlendi
Şekil 14.20: bfs(A)'nın dört anı. (a) A işlendi; kuyruk B, F, I. (b) B işlendi; kuyruk F, I, C, E. (c) C işlendi; kuyruk E, G, D. (d) Son durum: bütün düğümler işlendi, kalın kenarlar BFS ağacını gösterir.

Yazdırılan sıra A B F I C E G D H'dir. Seviyeler bu sırada ardışık bloklar hâlinde görülür: {A}, {B, F, I}, {C, E, G}, {D}, {H}. BFS ağacının kenarları A–B, A–F, A–I, B–C, B–E, F–G, C–D ve D–H'dir. Dokuz düğümlü bir ağaçta beklendiği gibi sekiz kenar vardır. Her düğüm kuyruğa bir kez girmiş, her komşu listesi bir kez taranmıştır; taranan toplam kayıt sayısı 2m = 22'dir.

14.3.5Derinlik öncelikli arama

Bir labirentte çıkış arayan biri genellikle şöyle davranır: bir koridora girer, sonuna kadar yürür. Çıkmaza girerse son yol ayrımına döner ve henüz denemediği bir koridora girer. Bu davranış DFS'nin ta kendisidir. DFS gidebildiği kadar derine iner. Ziyaret edilmemiş komşusu kalmayan bir düğüme gelince geri döner (backtracking) ve başka bir dalı dener.

s a b c d e 1 2 3 4 5 6
Şekil 14.21: DFS bir yol boyunca derine iner. Numaralar ziyaret sırasıdır: s (1), a (2), d (3), b (4); b'de çıkmaz vardır, geri dönülür; c (5), e (6). Kalın kenarlar DFS ağacıdır. s–b kenarı ağaca girmez, çünkü b'ye d üzerinden ulaşılmıştır.

Şekil 14.21'i Şekil 14.18 ile karşılaştıralım. Çizge aynıdır, ziyaret sıraları farklıdır. BFS s'den sonra a, b, c'yi, yani bütün komşuları ziyaret eder. DFS ise s'den a'ya, a'dan d'ye, d'den b'ye iner. b'nin bütün komşuları görülmüştür; DFS geri dönmek zorunda kalır. s'ye kadar geri çıkıp c'ye, oradan e'ye iner.

DFS'nin artıları şunlardır: bellekte yalnız o anki yol tutulur; geniş ama sığ çizgelerde BFS'den az bellek kullanır. Bir yolun sonuna kadar gitmek gereken problemlerde (labirent, oyun ağacı) doğal bir yöntemdir. Eksisi de açıktır: bulduğu yol en kısa yol olmak zorunda değildir. Şekil 14.21'de DFS b'ye s → a → d → b yoluyla, üç kenarla ulaşmıştır; oysa s–b kenarı doğrudan vardır. DFS'nin kullanıldığı başlıca yerler labirent çözme, döngü tespiti, bağlantılı bileşenleri bulma ve yönlü döngüsüz çizgelerde topolojik sıralamadır.

14.3.6DFS kodu: özyinelemeli ve açık yığınla

DFS'nin en kısa yazılışı özyinelemelidir.

void dfs(int v, boolean[] ziyaret) {
  ziyaret[v] = true;
  System.out.print(v + " ");
  for (int u : komsu.get(v))
    if (!ziyaret[u])
      dfs(u, ziyaret);
}

Metot önce v'yi işaretler ve yazdırır. Sonra v'nin komşularını sırayla dener; ziyaret edilmemiş ilk komşu için kendini çağırır. Bu çağrı dönmeden döngü devam etmez. Yani DFS, ilk komşunun altındaki her şeyi bitirmeden ikinci komşuya geçmez; “derine önce” davranışı buradan gelir. Komşuların hepsi denendiğinde metot döner ve çağıran düğüm kaldığı yerden devam eder. Bu dönüş, geri dönmenin kodda görünen biçimidir.

Özyineleme yığını bizim yerimize çağrı yığını ile tutar. Her etkin dfs çağrısı çağrı yığınında bir çerçevedir; çerçevelerin sırası, başlangıçtan o anki düğüme giden yoldur. Aynı algoritma açık bir yığınla da yazılabilir:

void dfsYigin(int s) {
  boolean[] ziyaret = new boolean[n];
  Deque<Integer> yigin = new ArrayDeque<>();
  yigin.push(s);  ziyaret[s] = true;
  while (!yigin.isEmpty()) {
    int v = yigin.peek();
    Integer yeni = null;
    for (int u : komsu.get(v))
      if (!ziyaret[u]) { yeni = u; break; }
    if (yeni == null) yigin.pop(); // geri
    else { ziyaret[yeni] = true;
           yigin.push(yeni); }
  }
}

Açık yığınlı sürüm özyinelemeyi adım adım taklit eder. Yığının üstü (peek) o anda bulunduğumuz düğümdür. Her turda üstteki düğümün ziyaret edilmemiş ilk komşusu aranır. Bulunursa işaretlenir ve yığına itilir; bu, özyinelemeli bir çağrıya karşılık gelir. Bulunamazsa (yeni == null) üstteki düğüm yığından çıkarılır; bu, bir çağrının dönmesine karşılık gelir. break önemlidir: ilk uygun komşu bulununca arama durur ve hemen o komşuya inilir.

Bu sürüm, derin çizgelerde özyinelemenin StackOverflowError vermesini önler. Java'nın çağrı yığını sınırlıdır; yüz binlerce düğümlü bir yol çizgesinde özyinelemeli DFS yığını taşırabilir. Açık yığın ise yığın belleğinde (heap) tutulur ve çok daha büyüyebilir.

Maliyet. Özyinelemeli sürümde her düğüm için dfs bir kez çağrılır ve o düğümün listesi bir kez gezilir: O(n + m). Açık yığınlı sürümde ise her peek turunda komşu listesi baştan taranır. Bir düğüm yığının üstüne birkaç kez geri geldiğinde listesinin başındaki işaretli komşular yeniden atlanır. Her düğüm yığının üstüne en çok derecesi kadar artı bir kez gelir; bu yüzden en kötü durumda tarama toplamı Σ deg(v)2 mertebesine çıkabilir. Her liste için “nerede kaldım” bilgisi bir indis dizisinde tutulursa açık yığınlı sürüm de O(n + m) olur. Sunumdaki kod sadeliği öne çıkarır; küçük derecelerde bu fark önemsizdir.

14.3.7Kod izleme: özyinelemeli dfs(A)

Şekil 14.19'daki çizgede dfs(A, ziyaret) çağrısını izleyelim. Komşular yine alfabetik sıradadır.

Tablo 14.11: Özyinelemeli dfs(A) izlemesi. Çağrı yığını alttan üste yazılmıştır.
AdımOlayÇağrı yığınıYazdırılan (toplam)
1dfs(A): A işaretlenirAA
2A'nın komşusu B yeni → dfs(B)A BA B
3B: A işaretli, atla; C yeni → dfs(C)A B CA B C
4C: B atla; D yeni → dfs(D)A B C DA B C D
5D: C atla; G yeni → dfs(G)A B C D GA B C D G
6G: D atla; E yeni → dfs(E)A B C D G EA B C D G E
7E: B, C, G işaretli → dfs(E) dönerA B C D G—
8G: F yeni → dfs(F)A B C D G FA B C D G E F
9F: A, G işaretli → döner; G'nin komşusu kalmadı → dönerA B C D—
10D: H yeni → dfs(H)A B C D HA B C D G E F H
11H: D atla → döner; D biter → dönerA B C—
12C: E işaretli → döner; B: E işaretli → dönerA—
13A: F işaretli, atla; I yeni → dfs(I)A IA B C D G E F H I
14I: A atla → döner; A biter → döner(boş)—

Tablo, DFS'nin iki evresini açıkça gösterir. 1.–6. adımlarda algoritma hep ileri gider ve çağrı yığını her adımda bir çerçeve büyür: A → B → C → D → G → E. E'de bütün komşular işaretlidir; ilk geri dönüş burada olur. Geri dönen algoritma G'ye gelir ve G'nin listesinde kaldığı yerden devam eder: D ve E denenmiştir, sırada F vardır.

Dikkat edilecek bir nokta da şudur: bir düğüm döndükten sonra çağıran düğüm döngüsünü baştan değil, kaldığı yerden sürdürür. Örneğin 12. adımda C'ye dönülür; C'nin listesi B, D, E'dir ve sırada E kalmıştır. E ise G'nin altında zaten işaretlenmiştir. Her düğümün listesi tam bir kez gezilir.

I A B C D H G F E ziyaret[] A T B T C T D T E T F F G T H F I F görülmedi yığında üstte çıktı I A B C D H G F E ziyaret[] A T B T C T D T E T F T G T H F I F görülmedi yığında üstte çıktı
I A B C D H G F E ziyaret[] A T B T C T D T E T F T G T H T I F görülmedi yığında üstte çıktı I A B C D H G F E ziyaret[] A T B T C T D T E T F T G T H T I T görülmedi yığında üstte çıktı
Şekil 14.22: Özyinelemeli dfs(A)'nın dört anı ve sağdaki çağrı yığını. (a) En derin nokta: yığın A B C D G E. (b) E döndü, G'nin altında F'ye inildi. (c) G bitti, D'nin altında H'ye inildi. (d) Son adım: dfs(I) döndü, dfs(A) da bitmek üzere; bütün düğümler ziyaret edildi.

Ziyaret sırası A B C D G E F H I'dır. DFS ağacının kenarları A–B, B–C, C–D, D–G, G–E, G–F, D–H ve A–I'dır. Çağrı yığınının en derin hâli altı çerçevedir. BFS ağacıyla karşılaştırıldığında DFS ağacı daha uzun ve daha ince bir yapıdadır: kökten E'ye BFS ağacında iki kenar, DFS ağacında beş kenar vardır.

14.3.8Kod izleme: dfsYigin(A)

Aynı çizgede açık yığınlı sürümü izleyelim. Her turda yığının üstüne bakılır, ziyaret edilmemiş ilk komşu aranır ve ya push ya da pop yapılır.

Tablo 14.12: dfsYigin(A) izlemesi. Yığın alttan üste yazılmıştır.
Turpeekİlk ziyaret edilmemiş komşuİşlemYığın (tur sonunda)
başlangıç——push AA
1ABpush BA B
2BCpush CA B C
3CDpush DA B C D
4DGpush GA B C D G
5GEpush EA B C D G E
6EyokpopA B C D G
7GFpush FA B C D G F
8FyokpopA B C D G
9GyokpopA B C D
10DHpush HA B C D H
11HyokpopA B C D
12DyokpopA B C
13CyokpopA B
14ByokpopA
15AIpush IA I
16IyokpopA
17Ayokpop(boş)

Döngü 17 tur döner: 8 push (A dışındaki sekiz düğüm) ve 9 pop (bütün düğümler). Push turları yeni bir düğümün ziyaret edildiği anlardır ve sıra A B C D G E F H I'dır; özyinelemeli sürümle tıpatıp aynıdır. Yığının her andaki içeriği de Tablo 14.11'deki çağrı yığınıyla aynıdır. İki kod aynı algoritmadır; yalnız yığını tutan taraf değişmiştir.

Sık yapılan hata

Açık yığınlı DFS'yi BFS kodundaki kuyruğu yığınla değiştirerek yazmak: bir düğümü çıkarıp bütün komşularını birden yığına itmek. Bu sürüm de her düğümü bir kez ziyaret eder, ama ziyaret sırası özyinelemeli DFS'nin sırası olmaz. Komşular ters sırada çıkar ve bir düğüm, derine inilmeden önce kardeşleriyle birlikte işaretlenmiş olur. Sunumdaki peek ve “ilk komşu, break” düzeni bu yüzden seçilmiştir.

14.3.9BFS ile DFS'nin karşılaştırması

Tablo 14.13: BFS ve DFS'nin karşılaştırması.
BFSDFS
Yardımcı yapıkuyruk (FIFO)yığın (LIFO) ya da özyineleme
Zaman (komşuluk listesi)O(n + m)O(n + m)
Ek bellekO(n): en geniş seviye kuyruktaO(n): en uzun yol yığında
En kısa yolu verir mi?evet (ağırlıksız çizgede)hayır
Tipik kullanımen az adım, seviye bulmadöngü tespiti, bileşenler, labirent, topolojik sıralama

İkisi de O(n + m) zaman alır, çünkü her düğüm bir kez işlenir ve bütün komşuluk kayıtları bir kez taranır. Yönsüz çizgede her kenar iki listede göründüğü için taranan kayıt sayısı 2m'dir; sabit çarpan büyük-O gösteriminde düşer ve sonuç yine O(m)'dir.

Ek bellek ikisinde de en kötü durumda O(n)'dir, ama kötü durumlar farklıdır. BFS'yi zorlayan çizge geniştir: bir düğümün çok sayıda komşusu varsa hepsi aynı anda kuyruğa girer. DFS'yi zorlayan çizge derindir: uzun bir zincirde yığın zincirin boyu kadar büyür.

Tablodaki sıralar, komşuların alfabetik sırayla gezildiği varsayımıyla elde edilmiştir. Komşu listelerinin sırası değişirse ziyaret sırası da değişir; ama ziyaret edilen düğüm kümesi ve maliyet değişmez. Örneğin aynı çizgede H'den başlanırsa BFS H D C G B E F A I, DFS H D C B A F G E I sırasını verir. Bir başka gözlem de şudur: açık yığınlı DFS'deki yığın bir kuyrukla değiştirilirse ve komşuların hepsi birden eklenirse algoritma BFS'ye dönüşür. İki arama arasındaki fark, yardımcı yapının hangi ucundan okunduğudur.

14.3.10Bağlantılı bileşenler

Bağlantısız bir çizgede bir düğümden başlayan gezinme yalnız o düğümün bileşenini ziyaret eder. Bütün bileşenleri bulmak için gezinmeyi, henüz hiçbir bileşene atanmamış her düğümden yeniden başlatmak yeterlidir. Her yeni başlangıç yeni bir bileşen demektir.

int bilesenler(int[] bil) {
  Arrays.fill(bil, -1);
  int say = 0;
  for (int s = 0; s < n; s++)
    if (bil[s] == -1) dfs(s, say++, bil);
  return say;
}
void dfs(int v, int c, int[] bil) {
  bil[v] = c;
  for (int u : komsu.get(v))
    if (bil[u] == -1) dfs(u, c, bil);
}

bil dizisi her düğümün bileşen numarasını tutar. Başta bütün değerler −1'dir: “henüz atanmadı”. Bu dizi aynı zamanda ziyaret işareti görevi görür; ayrı bir boolean diziye gerek kalmaz. Dış döngü düğümleri 0'dan n − 1'e tarar. −1 değerli bir düğüm bulunca dfs(s, say++, bil) çağrılır. say++ sonradan artırma olduğu için çağrıya say'ın eski değeri gider; ilk bileşen 0 numarasını alır. dfs, ulaştığı her düğüme bileşen numarasını yazar.

0 1 2 3 4 5 v bil 0 0 0 1 0 2 1 3 1 4 2 5
Şekil 14.23: Altı düğümlü çizge (kenarlar 0–1, 0–2, 3–4) ve bilesenler'in sonucu. Üç bileşen üç renkle gösterilmiştir: bil = [0, 0, 0, 1, 1, 2].
Tablo 14.14: Şekil 14.23'teki çizgede bilesenler izlemesi.
sbil[s]Yapılansaybil (sonra)
——Arrays.fill0[−1, −1, −1, −1, −1, −1]
0−1dfs(0, 0): 0, 1, 2 boyanır1[0, 0, 0, −1, −1, −1]
10atla1aynı
20atla1aynı
3−1dfs(3, 1): 3, 4 boyanır2[0, 0, 0, 1, 1, −1]
41atla2aynı
5−1dfs(5, 2): yalnız 53[0, 0, 0, 1, 1, 2]

dfs(0, 0)'ın içi şöyle yürür: 0 boyanır; 0'ın ilk komşusu 1 boyanır ve 1'in tek komşusu 0 zaten boyalı olduğu için 1'den dönülür. Sonra 0'ın ikinci komşusu 2 boyanır; onun da tek komşusu 0'dır. Metot sonunda 3 döndürür: çizgede üç bileşen vardır, {0, 1, 2}, {3, 4} ve {5}.

Maliyet O(n + m)'dir. Dış döngü n tur döner. Her düğüm tam bir kez boyanır ve listesi bir kez gezilir; her kenara iki ucundan birer kez bakılır. Bir kez hesaplanan bil dizisiyle “u ile v aynı bileşende mi?” sorusu bil[u] == bil[v] karşılaştırmasıyla O(1)'de yanıtlanır. Aynı soru her seferinde yeni bir gezinmeyle yanıtlansaydı her sorgu O(n + m) sürerdi.

14.3.11İki parçalı çizge testi

Tanım 14.10

Düğümleri iki gruba ayrılabilen ve her kenarı iki grup arasında bulunan çizgeye iki parçalı çizge (bipartite graph) denir. Böyle bir çizgede hiçbir kenar aynı grubun iki düğümünü bağlamaz.

İki parçalı çizgeler eşleştirme problemlerinde doğal olarak ortaya çıkar. Öğrenciler bir grup, projeler öbür grup olsun; kenar “bu öğrenci bu projeyi istiyor” anlamına gelsin. Hiçbir kenar iki öğrenciyi ya da iki projeyi bağlamaz. İki vardiyalı çizelgeleme ve çakışmasız gruplama da aynı yapıdadır.

Grupları iki renk olarak düşünelim. Çizge iki parçalıysa her düğüme, komşularının hepsinin rengi kendisininkinden farklı olacak biçimde iki renkten biri verilebilir. Bunu BFS ile sınamak kolaydır: başlangıca 0 rengi verilir; her yeni keşfedilen düğüm, onu keşfeden düğümün zıt rengini alır. Daha önce boyanmış bir komşunun rengi şimdiki düğümün rengiyle aynıysa çizge iki parçalı değildir.

// renk[] = -1 ile başlar; s'den BFS
renk[s] = 0;  q.add(s);
while (!q.isEmpty()) {
  int w = q.poll();
  for (int u : komsu.get(w))
    if (renk[u] == -1) {
      renk[u] = 1 - renk[w];  q.add(u);
    } else if (renk[u] == renk[w]) return false;
}
return true;

renk dizisi −1 ile başlar; −1 “boyanmadı”, 0 ve 1 iki renktir. 1 - renk[w] ifadesi zıt rengi verir: 0'ın zıttı 1, 1'in zıttı 0'dır. Kod BFS'nin aynısıdır; tek fark, boyalı bir komşuda rengin denetlenmesidir.

0 1 2 3 4 5 0 1 2 iki parçalı ✓ (çift döngüler) üçgen ✗: 1 ve 2 aynı renk
Şekil 14.24: Solda iki renkle boyanabilen altı düğümlü çizge: 0–1–2–3–4–5–0 döngüsü ve 1–4 kenarı. Sağda üçgen: 0'dan BFS ile boyanınca 1 ve 2 aynı rengi alır, aralarındaki kenar çakışmayı gösterir.
Örnek 14.7

Soldaki çizge. 0 rengi 0 alır. Komşuları 1 ve 5 rengi 1 alır. 1 işlenince 2 ve 4 rengi 0 alır. 5 işlenince komşuları 4 (renk 0) ve 0 (renk 0) zıt renklidir; sorun yok. 2 işlenince 3 rengi 1 alır. Kalan denetimlerin hiçbiri çakışma vermez. Sonuç renk = [0, 1, 0, 1, 0, 1]: çift numaralılar bir grupta, tek numaralılar öbür gruptadır. Çizgedeki iki döngü (0–1–4–5–0 ve 1–2–3–4–1) dörder kenarlıdır.

Üçgen. 0 rengi 0 alır, komşuları 1 ve 2 rengi 1 alır. 1 işlenirken komşusu 2 zaten boyalıdır ve rengi 1'in rengiyle aynıdır. Metot false döndürür. Üçgen iki parçalı değildir: üç düğümü iki renge, komşular farklı olacak biçimde dağıtmak imkânsızdır.

Bu iki örnek genel bir kuralı yansıtır: bir çizgenin iki parçalı olması, tek uzunlukta döngü içermemesiyle eşdeğerdir. Bir döngü boyunca renkler 0, 1, 0, 1 diye değişir. Döngünün başladığı düğüme geri dönüldüğünde renk başlangıçtakiyle aynı olmalıdır; bu ancak döngünün kenar sayısı çiftse mümkündür. Örneğin 5 düğümlü döngü C5 iki parçalı değildir, 6 düğümlü döngü C6 ise iki parçalıdır. Testin maliyeti BFS'ninkiyle aynıdır: O(n + m). Çizge bağlantısızsa test, bileşenlerdeki gibi her boyanmamış düğümden yeniden başlatılır.

14.3.12Topolojik sıralama ve Kahn algoritması

Bir öğrencinin alacağı dersleri düşünelim. Bazı dersler başka derslerin ön koşuludur. Öğrenci dersleri öyle bir sırayla almalıdır ki her ders, ön koşullarının hepsinden sonra gelsin. Bu ilişki yönlü bir çizgedir: “u, v'nin ön koşulu” ise u → v oku vardır. Aranan sıraya topolojik sıra denir.

Tanım 14.11

Yönlü bir çizgenin topolojik sıralaması (topological sort), düğümlerin her u → v okunda u önce gelecek biçimde dizilmesidir. Yönlü döngü içermeyen yönlü çizgeye yönlü döngüsüz çizge (directed acyclic graph, DAG) denir. Topolojik sıra yalnız DAG'lerde vardır.

Döngülü bir çizgede neden topolojik sıra olamayacağı kolayca görülür. a → b → c → a döngüsü olsun. Sırada a, b'den; b, c'den; c de a'dan önce gelmelidir. Bu, a'nın kendisinden önce gelmesi demektir. Ders örneğinde her ders, döngüdeki bir başka dersin önce alınmasını bekler ve hiçbiri başlayamaz.

Topolojik sıra tek olmak zorunda değildir. Aynı anda hazır olan, yani ön koşulu kalmamış birden çok ders varsa bunlar herhangi bir sırayla alınabilir. Örneğin 0 → 1, 0 → 2, 1 → 3, 2 → 3 DAG'ında hem 0 1 2 3 hem de 0 2 1 3 geçerli sıralardır. Topolojik sıralama ders planlarında, görev bağımlılıklarında, derleme sırasının belirlenmesinde ve iş akışı planlamada kullanılır.

Kahn algoritmasının fikri basittir. Giriş derecesi 0 olan bir düğümün hiçbir ön koşulu yoktur; sırada ilk o gelebilir. Bu düğüm sıraya yazılınca ondan çıkan oklar “silinmiş” sayılır. Silinen her ok, hedefinin giriş derecesini bir azaltır. Giriş derecesi böylece 0'a düşen düğümler de hazır hâle gelir. Hazır düğümler bir kuyrukta bekletilir.

Kahn(G):
  q = girişDerecesi 0 olan düğümler
  sonuc = []
  while q boş değil:
    u = q'dan çıkar
    sonuc'a u ekle
    for u'dan çıkan her (u, v):
      girişDerecesi[v]--
      if girişDerecesi[v] == 0:
        q'ya v ekle
  if sonuc.size < n:
    döngü var
  return sonuc
List<Integer> kahn(List<List<Integer>> adj) {
  int n = adj.size();
  int[] giris = new int[n];
  for (List<Integer> l : adj)
    for (int v : l) giris[v]++;
  Queue<Integer> q = new ArrayDeque<>();
  for (int v = 0; v < n; v++)
    if (giris[v] == 0) q.add(v);
  List<Integer> sonuc = new ArrayList<>();
  while (!q.isEmpty()) {
    int u = q.poll();
    sonuc.add(u);
    for (int v : adj.get(u))
      if (--giris[v] == 0) q.add(v);
  }
  return sonuc;  // boyut < n → döngü
}

Java kodu üç evreden oluşur:

  1. Sayma. İç içe iki döngü bütün listeleri gezer ve her okun hedefinin giris değerini bir artırır. Sonunda giris[v], v'nin giriş derecesidir. Bu evre O(n + m) sürer.
  2. Başlatma. Giriş derecesi 0 olan bütün düğümler kuyruğa konur.
  3. Ana döngü. Kuyruktan bir düğüm u çıkar ve sonuca eklenir. u'nun her okunun hedefi için --giris[v] yapılır. Ön ek azaltma önce azaltır, sonra yeni değeri verir; değer 0'a düşmüşse v kuyruğa eklenir.

Kuyruk boşaldığında sonuç listesinde n düğüm varsa bulunan sıra bir topolojik sıradır. n'den az düğüm varsa çizgede yönlü bir döngü vardır. Döngüdeki düğümlerin giriş dereceleri hiçbir zaman 0'a inmez, çünkü her biri döngüdeki bir önceki düğümü bekler. Algoritma bu düğümleri hiç kuyruğa koymaz.

14.3.13Kod izleme: kahn()

Altı dersli bir ön koşul çizgesi alalım. Oklar 0 → 3, 1 → 3, 1 → 4, 2 → 0, 2 → 4, 3 → 5, 4 → 5'tir. Komşu listeleri 0: [3] · 1: [3, 4] · 2: [0, 4] · 3: [5] · 4: [5] · 5: [] biçimindedir.

1 2 0 3 4 5 giris[] (giriş derecesi) 0 1 2 3 4 5 giris 1 0 0 2 2 2 q 1 2 sonuc [ ]
Şekil 14.25: Ders ön koşul çizgesi; sayma evresinden sonraki durum. Giriş dereceleri giris = [1, 0, 0, 2, 2, 2]'dir; ön koşulu olmayan 1 ve 2 kuyruktadır.

Sayma evresi. 0'ın listesi giris[3]'ü 1 yapar. 1'in listesi giris[3]'ü 2'ye, giris[4]'ü 1'e çıkarır. 2'nin listesi giris[0]'ı 1'e, giris[4]'ü 2'ye çıkarır. 3 ve 4'ün listeleri giris[5]'i 2'ye çıkarır. 5'in listesi boştur ve bir şey değiştirmez. Sonuç [1, 0, 0, 2, 2, 2]'dir. Giriş derecesi 0 olan 1 ve 2 kuyruğa girer.

Tablo 14.15: kahn() ana döngüsünün izlemesi.
Çıkan uSilinen oklar ve yeni giriş dereceleriKuyruğa girenlergiris (sonra)Kuyruksonuc
——1, 2[1, 0, 0, 2, 2, 2][1, 2][]
11 → 3: giris[3] = 1; 1 → 4: giris[4] = 1—[1, 0, 0, 1, 1, 2][2][1]
22 → 0: giris[0] = 0; 2 → 4: giris[4] = 00, 4[0, 0, 0, 1, 0, 2][0, 4][1, 2]
00 → 3: giris[3] = 03[0, 0, 0, 0, 0, 2][4, 3][1, 2, 0]
44 → 5: giris[5] = 1—[0, 0, 0, 0, 0, 1][3][1, 2, 0, 4]
33 → 5: giris[5] = 05[0, 0, 0, 0, 0, 0][5][1, 2, 0, 4, 3]
5(liste boş)—[0, 0, 0, 0, 0, 0][][1, 2, 0, 4, 3, 5]

İzlemede şu anlara dikkat etmek gerekir. 1 çıkınca 3 ve 4'ün giriş dereceleri azalır ama 0'a inmez: ikisi de hâlâ başka bir ön koşul bekler. 3, 0'ı; 4 ise 2'yi bekler. 2 çıkınca hem 0 hem 4 hazır olur. 0 çıkınca 3'ün son ön koşulu da tamamlanır. 5'in iki ön koşulu vardır (3 ve 4); 4 çıkınca giris[5] 1'e iner, 3 çıkınca 0 olur.

1 2 0 3 4 5 giris[] (giriş derecesi) 0 1 2 3 4 5 giris 1 0 0 1 1 2 q (boş) sonuc 1 2 1 2 0 3 4 5 giris[] (giriş derecesi) 0 1 2 3 4 5 giris 0 0 0 1 0 2 q 0 4 sonuc 1 2
1 2 0 3 4 5 giris[] (giriş derecesi) 0 1 2 3 4 5 giris 0 0 0 0 0 1 q 3 sonuc 1 2 0 4 1 2 0 3 4 5 giris[] (giriş derecesi) 0 1 2 3 4 5 giris 0 0 0 0 0 0 q (boş) sonuc 1 2 0 4 3 5
Şekil 14.26: kahn()'ın dört anı. (a) 2 kuyruktan çıktı ve sonuca eklendi; okları henüz işlenmedi, kuyruk boş. (b) 2 → 4 de silindi; kuyruk 0, 4. (c) 4 → 5 silindi; 5 hâlâ 3'ü bekler. (d) Son: sonuç 1 2 0 4 3 5.

Sonuç 1 2 0 4 3 5'tir ve boyutu 6 = n'dir; çizgede döngü yoktur. Sıra gerçekten geçerlidir: her ok için kaynak hedeften önce gelir. Örneğin 2 → 0 okunda 2 ikinci, 0 üçüncü sıradadır. Her düğüm bir kez kuyruğa girmiş, her ok bir kez azaltılmıştır: maliyet O(n + m)'dir.

Örnek 14.8

Oklar 0 → 1, 1 → 2, 2 → 0, 2 → 3 olsun. Giriş dereceleri [1, 1, 1, 1]'dir; hiçbir düğümün giriş derecesi 0 değildir. Kuyruk baştan boştur ve ana döngü hiç dönmez. Sonuç boş listedir; boyutu 0 < 4 olduğu için döngü vardır. 3 döngünün parçası değildir, ama döngüdeki 2'ye bağlı olduğu için o da sıraya giremez.

Topolojik sıralamanın bir başka yolu DFS'dir. DFS'de bir düğümün çağrısı, ondan ulaşılabilen her düğümün çağrısı bittikten sonra biter. Düğümler bitiş zamanlarına göre ters sırada dizilirse topolojik sıra elde edilir. Bu yöntemde döngü, bir sonraki kesimdeki geri kenar denetimiyle bulunur.

Sık yapılan hata

Kahn algoritmasının dönüş değerini denetlememek. Döngülü bir çizgede algoritma hata vermez; yalnız eksik bir liste döndürür. Sonuç boyutu n ile karşılaştırılmazsa eksik liste geçerli bir sıra sanılır ve döngüdeki dersler plandan sessizce düşer.

14.3.14Yönlü çizgede döngü tespiti

Yönsüz çizgede döngü bulmak görece kolaydır: DFS sırasında, ebeveyn dışında işaretli bir komşuya rastlamak döngü demektir. Yönlü çizgede ise işaretli bir düğüme ok gitmesi döngü için yeterli değildir. İşaretli düğümün o anki DFS yolunun üzerinde olup olmadığı önemlidir. Bu ayrımı yapmak için her düğüm üç renkten biriyle işaretlenir:

  • beyaz (0): henüz ziyaret edilmedi;
  • gri (1): ziyaret edildi ve şu anki DFS yolunun üzerinde; çağrısı henüz bitmedi;
  • siyah (2): ziyaret edildi ve çağrısı bitti; ondan ulaşılabilen her şey incelendi.
// renk: 0 beyaz, 1 gri (yolda), 2 siyah
boolean donguVar(int v, int[] renk) {
  renk[v] = 1;
  for (int u : komsu.get(v)) {
    if (renk[u] == 1) return true;  // geri kenar
    if (renk[u] == 0 && donguVar(u, renk))
      return true;
  }
  renk[v] = 2;
  return false;
}

Bir okun hedefi gri ise, hedef düğümden şu anki düğüme bir yol vardır (DFS yolu boyunca). Bu yola okun kendisi eklenince döngü kapanır. Böyle bir oka geri kenar denir. Hedef siyah ise ondan şu anki düğüme dönen bir yol yoktur; olsaydı o düğümün çağrısı sırasında şu anki düğüm de ziyaret edilmiş olurdu. Siyah düğüme giden ok döngü kurmaz.

0 1 2 3 4 v renk 1 0 2 1 2 2 1 3 1 4 0 beyaz · 1 gri · 2 siyah geri kenar → DÖNGÜ
Şekil 14.27: Oklar 0 → 1, 1 → 2, 0 → 3, 3 → 2, 3 → 4, 4 → 0. 4 → 0 oku incelenirken 0 gridir, yani şu anki yolun üzerindedir: döngü 0 → 3 → 4 → 0 bulunur. 1 ve 2 siyahtır.
Tablo 14.16: donguVar(0, renk) izlemesi.
AdımOlayrenk (0…4)
10 gri olur[1, 0, 0, 0, 0]
20 → 1: 1 beyaz, çağrı; 1 gri olur[1, 1, 0, 0, 0]
31 → 2: 2 beyaz, çağrı; 2 gri olur[1, 1, 1, 0, 0]
42'nin oku yok: 2 siyah, false[1, 1, 2, 0, 0]
51'in okları bitti: 1 siyah, false[1, 2, 2, 0, 0]
60 → 3: 3 beyaz, çağrı; 3 gri olur[1, 2, 2, 1, 0]
73 → 2: 2 siyah, döngü değil, devam[1, 2, 2, 1, 0]
83 → 4: 4 beyaz, çağrı; 4 gri olur[1, 2, 2, 1, 1]
94 → 0: 0 gri → geri kenar, true[1, 2, 2, 1, 1]
10true 3'e, oradan 0'a geri iletilir[1, 2, 2, 1, 1]

7. adım algoritmanın inceliğini gösterir. 3 → 2 okunda 2 daha önce ziyaret edilmiştir. Yalnız “ziyaret edildi mi?” diye bakan bir algoritma burada yanlışlıkla döngü ilan ederdi. Oysa 2'den 3'e dönen bir yol yoktur; 2 siyahtır, işi bitmiştir. 9. adımda ise 0 gridir: 0 → 3 → 4 yolu hâlâ çağrı yığınındadır ve 4 → 0 oku bu yolu kapatır. Bulunan döngü 0 → 3 → 4 → 0'dır. true değeri her düzeyde hemen döndürülür; geri kalan oklara bakılmaz.

Sık yapılan hata

renk[v] = 2 satırını unutmak. Bu durumda işi biten düğümler gri kalır. Okları 0 → 1, 0 → 2, 1 → 2 olan DAG'da 0, 1, 2 sırayla gri olur; 2 ve 1 döner ama gri kalır. 0'a dönülüp 0 → 2 oku incelenince renk[2] == 1 görülür ve döngü olmadığı hâlde true döner. İki renk (ziyaret edildi / edilmedi) yönlü çizgede yetmez; gri ile siyahın ayrımı şarttır.

Döngü tespitinin maliyeti DFS'ninkiyle aynıdır: her düğüm bir kez gri, bir kez siyah olur ve her ok bir kez incelenir. Çizge bağlantısızsa ya da 0'dan her düğüme ulaşılamıyorsa, donguVar beyaz kalan her düğümden yeniden çağrılır; toplam yine O(n + m)'dir.

14.4En kısa yol

Bir harita uygulaması iki nokta arasındaki en kısa güzergâhı, bir ağ yönlendiricisi bir paketin en hızlı yolunu, bir oyun karakteri hedefe giden en kısa rotayı arar. Bu soruların hepsi aynı çizge problemidir. Problemin iki sürümü vardır. Ağırlıksız çizgede yolun uzunluğu kenar sayısıdır ve problemi BFS çözer. Ağırlıklı çizgede uzunluk kenar ağırlıklarının toplamıdır ve problemi Dijkstra algoritması çözer.

14.4.1Ağırlıksız en kısa yol problemi

Tanım 14.12

Tek kaynaklı en kısa yol probleminde bir s kaynak düğümü verilir ve s'den çizgedeki her düğüme olan en kısa yolun uzunluğu aranır. Bu uzunluğa s'den o düğüme uzaklık denir. s'den ulaşılamayan bir düğümün uzaklığı ∞ kabul edilir.

Ağırlıksız çizgede her kenarın ağırlığı 1 sayılır; yolun uzunluğu kenar sayısıdır. Bu durumda çözüm BFS'dir. BFS düğümleri seviye seviye keşfeder ve seviye k'deki bir düğümün uzaklığı tam olarak k'dir.

Teorem 14.2 (BFS ve en kısa yol)

Ağırlıksız bir çizgede s'den başlayan BFS, her düğümü s'ye uzaklık sırasıyla keşfeder. Bir düğüme ilk kez w düğümünden ulaşıldığında o düğümün uzaklığı uzaklık(w) + 1'dir.

Kuyruğun her an şu biçimde olduğunu gösterelim: önde uzaklığı k olan düğümler, arkada uzaklığı k + 1 olan düğümler vardır. Başta kuyrukta yalnız s vardır ve uzaklığı 0'dır. Uzaklığı k olan bir w çıktığında, keşfedilmemiş komşuları kuyruğun arkasına k + 1 ile eklenir; düzen bozulmaz. Şimdi uzaklığı k + 1 olan bir u düşünelim. s'den u'ya k + 1 kenarlı bir yol vardır; bu yolun sondan bir önceki düğümü, uzaklığı k olan bir w''dür. Uzaklığı k'den küçük hiçbir düğüm u'ya komşu değildir; komşu olsaydı u'nun uzaklığı k'den küçük olurdu. Uzaklığı k olanlar ise uzaklığı k + 1 olanların hepsinden önce kuyruktan çıkar. Bu yüzden u, uzaklığı k olan bir düğümden keşfedilir ve ona yazılan değer k + 1'dir. □

1 2 5 3 8 4 6 7 0 1 2 1 2 1 2 3
Şekil 14.28: Sekiz düğümlü ağırlıksız çizge; kaynak 1. Kenarlar 1–2, 1–3, 1–4, 2–5, 3–8, 4–6, 5–8, 6–7, 7–8'dir. Düğümlerin yanındaki sayı 1'e uzaklıktır: 2, 3, 4 için 1; 5, 6, 8 için 2; 7 için 3.

Şekil 14.28'de 7'ye iki en kısa yol vardır: 1 → 4 → 6 → 7 ve 1 → 3 → 8 → 7. İkisi de üç kenarlıdır. BFS bunlardan yalnız birini bulur; hangisini bulacağı komşu listelerinin sırasına bağlıdır. Uzunluk ise her durumda 3'tür. En kısa yol tek olmak zorunda değildir, ama en kısa uzunluk tektir.

14.4.2Algoritma ve kod

mesafe[tüm v] = ∞
mesafe[s] = 0
kuyruk.ekle(s)
while kuyruk boş değil:
  w = kuyruk.çıkar()
  for w'nin her komşusu u:
    if mesafe[u] == ∞:
      mesafe[u] = mesafe[w] + 1
      kuyruk.ekle(u)
return mesafe

Sözde kod BFS'nin küçük bir değişikliğidir. Ziyaret dizisinin yerini bir mesafe dizisi alır. “mesafe = ∞” aynı zamanda “ziyaret edilmedi” anlamına gelir; ayrı bir işaret dizisine gerek kalmaz. Bir düğüm ilk kez keşfedildiğinde mesafesi, onu keşfeden düğümün mesafesinin bir fazlası olarak yazılır. Teorem 14.2 bu ilk değerin doğru olduğunu söyler; değer bir daha değiştirilmez.

Map<Integer, Integer> enKisaYol(int s) {
  final int INF = Integer.MAX_VALUE;  // ∞
  Map<Integer, Integer> mesafe =
      new HashMap<>();
  for (int v : cizge.keySet())
    mesafe.put(v, INF);
  mesafe.put(s, 0);
  Queue<Integer> q = new ArrayDeque<>();
  q.offer(s);
  while (!q.isEmpty()) {
    int w = q.poll();
    for (int u : cizge.get(w))
      if (mesafe.get(u) == INF) {
        mesafe.put(u, mesafe.get(w) + 1);
        q.offer(u);
      }
  }
  return mesafe;
}

Java sürümünde çizge cizge adlı bir Map<Integer, List<Integer>> içinde tutulur: anahtar düğüm, değer onun komşu listesidir. Bu örnekte düğümler 1'den başlayarak numaralandığı için dizi yerine eşleme kullanılmıştır. Kod satır satır şunları yapar:

  • INF, Integer.MAX_VALUE değeriyle ∞'u temsil eder.
  • İlk döngü her düğümün mesafesini INF yapar; sonra kaynağın mesafesi 0 yapılır ve kaynak kuyruğa konur. offer, add ile aynı işi yapar; kuyruğun arkasına ekler.
  • Ana döngü BFS'dir. Mesafesi hâlâ INF olan her komşu için mesafe mesafe.get(w) + 1 yapılır ve komşu kuyruğa eklenir.

Maliyet BFS'ninki gibidir: O(n + m). HashMap işlemleri ortalama O(1) kabul edilir.

Sık yapılan hata

∞ değerine 1 eklemek. Integer.MAX_VALUE + 1 Java'da taşma yapar ve en küçük negatif sayıya, −2.147.483.648'e döner. Koddaki koşul bu yüzden önemlidir: mesafeye 1 eklenen w her zaman kuyruktan çıkmış, yani mesafesi sonlu bir düğümdür. Ağırlıklı çizgede bu tehlike daha büyüktür; Dijkstra'nın Java sürümü mesafeleri bu yüzden long türünde tutar ve yalnız kesinleşmiş düğümlerden gevşetme yapar.

14.4.3Kod izleme: enKisaYol(1)

Şekil 14.28'deki çizgede kaynak 1 olsun. Komşu listeleri küçükten büyüğe sıralıdır: 1: [2, 3, 4] · 2: [1, 5] · 3: [1, 8] · 4: [1, 6] · 5: [2, 8] · 6: [4, 7] · 7: [6, 8] · 8: [3, 5, 7].

Tablo 14.17: enKisaYol(1) izlemesi. Mesafe sütunu 1…8 düğümlerinin sırasıyla mesafeleridir (∞ = INF).
Çıkan wKomşularGüncellenenmesafe (1…8)Kuyruk
——mesafe[1] = 00 ∞ ∞ ∞ ∞ ∞ ∞ ∞[1]
12, 3, 42, 3, 4 ← 0 + 1 = 10 1 1 1 ∞ ∞ ∞ ∞[2, 3, 4]
21, 55 ← 1 + 1 = 20 1 1 1 2 ∞ ∞ ∞[3, 4, 5]
31, 88 ← 1 + 1 = 20 1 1 1 2 ∞ ∞ 2[4, 5, 8]
41, 66 ← 1 + 1 = 20 1 1 1 2 2 ∞ 2[5, 8, 6]
52, 8— (ikisi de belli)0 1 1 1 2 2 ∞ 2[8, 6]
83, 5, 77 ← 2 + 1 = 30 1 1 1 2 2 3 2[6, 7]
64, 7— (ikisi de belli)0 1 1 1 2 2 3 2[7]
76, 8—0 1 1 1 2 2 3 2[]

İzlemenin üç noktası öğreticidir. Birincisi, 2 çıktığında komşusu 1'in mesafesi zaten 0'dır ve değişmez. BFS'de bir düğümün ilk bulunan mesafesi en kısadır; sonradan gelen hiçbir yol onu kısaltamaz. İkincisi, 5 çıktığında komşusu 8'in mesafesi 2'dir. 5 üzerinden 8'e gitmek 2 + 1 = 3 verirdi; bu daha uzundur ve kod ona bakmaz bile, çünkü 8'in mesafesi artık INF değildir.

Üçüncüsü, 7'nin mesafesini 8 yazmıştır. 8, kuyruğa 6'dan önce girmişti; bu yüzden 7'ye 6'dan önce o ulaşır. Bulunan yol 1 → 3 → 8 → 7'dir. Komşu sırası farklı olsaydı 1 → 4 → 6 → 7 bulunabilirdi; mesafe yine 3 olurdu. Son tabloda her düğümün mesafesi Şekil 14.28'deki değerlerle aynıdır.

1 2 5 3 8 4 6 7 1 2 3 4 5 6 7 8 mesafe 0 1 1 1 ∞ ∞ ∞ ∞ q 2 3 4 1 2 5 3 8 4 6 7 1 2 3 4 5 6 7 8 mesafe 0 1 1 1 2 2 3 2 q 6 7
Şekil 14.29: enKisaYol(1)'in iki anı. (a) 1 işlendi; 2, 3, 4'ün mesafesi 1, kuyruk 2, 3, 4. (b) 8 işlendi; 7'nin mesafesi 3 yazıldı, kuyruk 6, 7.

Kod yalnız uzaklıkları verir, yolların kendisini vermez. Yolu da istemek için her düğümü ilk keşfeden düğüm bir onceki dizisinde saklanır. Dijkstra'da aynı yöntem kullanılır ve yolun hedeften geriye doğru nasıl kurulduğu 14.4.8'de ayrıntılı olarak izlenir.

14.4.4Ağırlıklı en kısa yol problemi

Ağırlıklı çizgede problem aynıdır: kaynaktan her düğüme en kısa yol aranır. Bu kez yolun uzunluğu kenar ağırlıklarının toplamıdır. Ağırlık uzaklık, süre ya da maliyet olabilir.

4 2 5 10 3 4 11 A B D C E F
Şekil 14.30: Ağırlıklı yönlü çizge. Oklar A → B: 4, A → C: 2, B → C: 5, B → D: 10, C → E: 3, E → D: 4, D → F: 11'dir. A'dan F'ye en kısa yol A → C → E → D → F'dir: 2 + 3 + 4 + 11 = 20.
Örnek 14.9

Şekil 14.30'daki çizgede A'dan F'ye en az kenarlı yol A → B → D → F'dir; üç kenarlıdır. BFS bu yolu bulur. Ağırlıklı uzunluğu ise 4 + 10 + 11 = 25'tir. Dört kenarlı A → C → E → D → F yolu yalnız 20 tutar. Kenar sayısını en aza indiren yol, ağırlık toplamını en aza indiren yol değildir; ağırlıklı çizgede BFS yetmez.

Çözüm, Edsger W. Dijkstra'nın 1956'da bulduğu ve 1959'da yayımladığı algoritmadır. Algoritmanın tek koşulu vardır: kenar ağırlıkları negatif olmamalıdır. Bu koşulun neden gerektiği algoritmanın doğruluk gerekçesinde görülür (14.4.9).

14.4.5Dijkstra algoritması: fikir ve sözde kod

Dijkstra algoritması, her düğüm için bir geçici mesafe tutar. Bu değer, o ana kadar bulunan en kısa yolun uzunluğudur ve başta kaynak için 0, öbür düğümler için ∞'dur. Algoritma her adımda şunları yapar:

  1. Henüz kesinleşmemiş düğümler arasından geçici mesafesi en küçük olanı seçer. Bu düğümün mesafesi artık kesindir.
  2. Seçilen düğümün komşularının mesafelerini gevşetme ile günceller.
Tanım 14.13

(u, v) kenarını gevşetmek (relaxation), u üzerinden v'ye giden yolun bilinen yoldan kısa olup olmadığını denemektir: alt = mesafe[u] + ağırlık(u, v) hesaplanır; alt < mesafe[v] ise mesafe[v] = alt ve önceki[v] = u yapılır.

“Gevşetme” adı, mesafe üst sınırının bir yay gibi gevşeyip küçülmesinden gelir. Geçici mesafe hiçbir zaman gerçek uzaklığın altına inmez; her gevşetme onu ya olduğu gibi bırakır ya da gerçek değere biraz daha yaklaştırır.

Dijkstra(G, s):
  for her düğüm v:
    mesafe[v] = ∞
    önceki[v] = tanımsız
  mesafe[s] = 0
  Q = tüm düğümler  // öncelikli kuyruk
  while Q boş değil:
    u = Q.çıkar()   // en küçük mesafe[u]
    for u'nun Q'daki her komşusu v:
      alt = mesafe[u] + ağırlık(u, v)
      if alt < mesafe[v]:
        mesafe[v] = alt
        önceki[v] = u
        // Q'da v'nin önceliğini azalt

Sözde kodda Q, kesinleşmemiş düğümlerin öncelikli kuyruğudur; öncelik, düğümün geçici mesafesidir. Q.çıkar() mesafesi en küçük düğümü verir. Bir düğümün mesafesi gevşetmeyle küçülünce kuyruktaki önceliği de küçültülmelidir. önceki[] dizisi her düğüm için en kısa yolda kendisinden önce gelen düğümü tutar; yol bu diziden geriye doğru okunarak kurulur.

Sık yapılan hata

Öncelikli kuyrukta önceliği düğüm numarasına göre vermek. Kuyruk, mesafesi en küçük düğümü vermelidir; numarası en küçük düğümü değil. Java'da PriorityQueue<long[]> kullanılırken karşılaştırıcı bu yüzden kaydın ikinci öğesini, yani mesafeyi karşılaştırır.

14.4.6Dijkstra: Java

long[] dijkstra(List<List<int[]>> adj, int s, int[] onceki) { // ağırlıklar ≥ 0
    long[] d = new long[adj.size()];
    Arrays.fill(d, Long.MAX_VALUE);  d[s] = 0;  Arrays.fill(onceki, -1);
    PriorityQueue<long[]> pq =                     // {düğüm, mesafe}
        new PriorityQueue<>((a, b) -> Long.compare(a[1], b[1]));
    pq.add(new long[]{s, 0});
    while (!pq.isEmpty()) {
        long[] e = pq.poll();  int u = (int) e[0];
        if (e[1] > d[u]) continue;                   // bayat kayıt: atla
        for (int[] k : adj.get(u)) {
            int v = k[0];  long alt = d[u] + k[1];
            if (alt < d[v]) { d[v] = alt; onceki[v] = u; pq.add(new long[]{v, alt}); }
        }
    }
    return d;
}

Kodu parça parça inceleyelim:

  • Girdi. adj.get(u), u'nun komşularını int[]{v, w} çiftleri olarak verir: komşu ve kenar ağırlığı. onceki dizisini çağıran metot ayırır; dijkstra onu doldurur.
  • Başlangıç. d dizisi long türündedir ve Long.MAX_VALUE ile, yani ∞ ile doldurulur. d[s] = 0 yapılır, onceki −1 ile doldurulur; −1 “önceki yok” anlamındadır.
  • Kuyruk. Kuyruğa {düğüm, mesafe} çiftleri girer. Karşılaştırıcı Long.compare(a[1], b[1]) mesafeye göre sıralar; poll en küçük mesafeli kaydı verir.
  • Bayat kayıt denetimi. if (e[1] > d[u]) continue; satırı, kayıttaki mesafe düğümün güncel mesafesinden büyükse kaydı atlar.
  • Gevşetme. Her komşu için alt = d[u] + k[1] hesaplanır. alt < d[v] ise mesafe ve onceki güncellenir, kuyruğa yeni bir {v, alt} kaydı eklenir.

Java'nın PriorityQueue sınıfında “önceliği azalt” işlemi yoktur. Sözde koddaki “Q'da v'nin önceliğini azalt” adımının yerine kod, v için yeni bir kayıt ekler. Eski kayıt kuyrukta kalır. Eski kayıt sonunda kuyruktan çıktığında, taşıdığı mesafe d[v]'den büyüktür, çünkü d[v] o arada küçülmüştür. Bu kayda bayat kayıt denir ve continue ile atlanır. Bu yönteme “tembel silme” de denir: eski kaydı hemen silmek yerine, çıktığında görmezden gelmek.

Sık yapılan hata

Bayat kayıt satırını doğruluk için gerekli sanmak ya da tam tersine gereksiz sayıp silmek. Ağırlıklar negatif değilse bu satır silindiğinde mesafeler yine doğru çıkar. Bayat kayıt çıktığında d[u] zaten daha küçüktür ve komşular bu küçük değerle önceden gevşetilmiştir; alt < d[v] bir daha sağlanmaz. Satırın görevi verimdir: bayat kayıtların komşularını boşuna yeniden taramayı önler. Yoğun çizgelerde bu tarama zamanı belirgin biçimde artırır.

14.4.7Kod izleme: dijkstra(adj, A)

Gezinme örneklerindeki dokuz düğümlü çizgeye ağırlık verelim: A–B: 4, A–F: 7, A–I: 3, B–C: 1, B–E: 2, C–D: 7, C–E: 3, D–G: 3, D–H: 5, E–G: 2, F–G: 2. Çizge yönsüzdür; her kenar iki komşu listesinde de vardır. Kaynak A'dır.

3 4 7 1 2 3 7 2 2 3 5 I A B C D H G F E A B C D E F G H I d[] 0 ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ onceki – – – – – – – – – pq (mesafeye göre sıralı gösterim; soluk = bayat) A,0
Şekil 14.31: Dijkstra izlemesinin başlangıcı. d[A] = 0, öbür mesafeler ∞; onceki tümüyle boş; kuyrukta yalnız {A, 0} vardır.

Aşağıdaki tabloda her satır bir poll işlemidir. Kuyruk sütunu, o satırdaki gevşetmelerden sonra kuyrukta kalan kayıtları mesafeye göre sıralı gösterir.

Tablo 14.18: dijkstra(adj, A) izlemesi.
Çıkan kayıtDurumGevşetmelerKuyruk (sonra)
{A, 0}A kesinleşir (0)B: 0 + 4 = 4 < ∞ ✓; F: 0 + 7 = 7 ✓; I: 0 + 3 = 3 ✓{I,3} {B,4} {F,7}
{I, 3}I kesinleşir (3)A: 6 ≥ 0{B,4} {F,7}
{B, 4}B kesinleşir (4)A: 8 ≥ 0; C: 4 + 1 = 5 ✓; E: 4 + 2 = 6 ✓{C,5} {E,6} {F,7}
{C, 5}C kesinleşir (5)B: 6 ≥ 4; D: 5 + 7 = 12 ✓; E: 8 ≥ 6{E,6} {F,7} {D,12}
{E, 6}E kesinleşir (6)B: 8 ≥ 4; C: 9 ≥ 5; G: 6 + 2 = 8 ✓{F,7} {G,8} {D,12}
{F, 7}F kesinleşir (7)A: 14 ≥ 0; G: 9 ≥ 8{G,8} {D,12}
{G, 8}G kesinleşir (8)D: 8 + 3 = 11 < 12 ✓; E: 10 ≥ 6; F: 10 ≥ 7{D,11} {D,12}
{D, 11}D kesinleşir (11)C: 18 ≥ 5; G: 14 ≥ 8; H: 11 + 5 = 16 ✓{D,12} {H,16}
{D, 12}bayat: 12 > d[D] = 11atlanır{H,16}
{H, 16}H kesinleşir (16)D: 21 ≥ 11(boş)

İzlemenin kritik anı G'nin işlendiği satırdır. D'nin geçici mesafesi o ana kadar 12'dir; C üzerinden bulunmuştur (A → B → C → D: 4 + 1 + 7). G kesinleşince D–G kenarı gevşetilir: 8 + 3 = 11 < 12. D'nin mesafesi 11'e iner, onceki[D] = G olur ve kuyruğa {D, 11} eklenir. Eski {D, 12} kaydı kuyrukta kalır. Önce {D, 11} çıkar ve D kesinleşir. Hemen ardından {D, 12} çıkar; 12 > 11 olduğu için bayattır ve atlanır.

Bir başka gözlem: düğümler mesafe sırasıyla kesinleşir. Sıra A (0), I (3), B (4), C (5), E (6), F (7), G (8), D (11), H (16)'dır. Bu sıra Dijkstra'nın temel özelliğidir ve doğruluğunun da dayanağıdır. Toplam 10 poll, 10 ekleme (1 başlangıç ve 9 başarılı gevşetme) ve 22 gevşetme denemesi yapılmıştır; deneme sayısı 2m = 22'ye eşittir.

3 4 7 1 2 3 7 2 2 3 5 I A B C D H G F E A B C D E F G H I d[] 0 4 5 12 6 7 ∞ ∞ 3 onceki – A B C B A – – A pq (mesafeye göre sıralı gösterim; soluk = bayat) E,6 F,7 D,12 3 4 7 1 2 3 7 2 2 3 5 I A B C D H G F E A B C D E F G H I d[] 0 4 5 11 6 7 8 ∞ 3 onceki – A B G B A E – A pq (mesafeye göre sıralı gösterim; soluk = bayat) D,11 D,12
3 4 7 1 2 3 7 2 2 3 5 I A B C D H G F E A B C D E F G H I d[] 0 4 5 11 6 7 8 16 3 onceki – A B G B A E D A pq (mesafeye göre sıralı gösterim; soluk = bayat) H,16 3 4 7 1 2 3 7 2 2 3 5 I A B C D H G F E A B C D E F G H I d[] 0 4 5 11 6 7 8 16 3 onceki – A B G B A E D A pq (mesafeye göre sıralı gösterim; soluk = bayat) (boş)
Şekil 14.32: Dijkstra izlemesinin dört anı. (a) C kesinleşti; d[D] = 12 (C üzerinden). (b) G üzerinden daha kısa yol bulundu: d[D] = 11, onceki[D] = G; eski {D, 12} kaydı bayat. (c) Bayat kayıt çıktı ve atlandı. (d) Son durum: kalın kenarlar en kısa yol ağacıdır.
Tablo 14.19: Dijkstra'nın sonucu (kaynak A).
ABCDEFGHI
d04511678163
onceki–ABGBAEDA

onceki dizisi en kısa yol ağacını tutar. H'den geriye doğru okunursa H ← D ← G ← E ← B ← A elde edilir. A'dan H'ye en kısa yol A → B → E → G → D → H'dir ve uzunluğu 4 + 2 + 2 + 3 + 5 = 16'dır. Bu yol beş kenarlıdır. A'dan H'ye en az kenarlı yollardan biri ise dört kenarlı A → F → G → D → H'dir; ama bu yolun uzunluğu 7 + 2 + 3 + 5 = 17'dir.

14.4.8Yolu geri kurma

Dijkstra'nın bıraktığı onceki dizisinden s'den t'ye yolu çıkarmak için t'den başlanır ve onceki zinciri −1'e kadar izlenir. Zincir hedeften kaynağa doğru ilerler; yol ise kaynaktan hedefe yazılmalıdır. Bu yüzden her düğüm listenin başına eklenir.

List<Integer> yol(int[] onceki, int s, int t) {
  LinkedList<Integer> y = new LinkedList<>();
  for (int v = t; v != -1; v = onceki[v])
    y.addFirst(v);
  if (y.getFirst() != s) return List.of();
  return y;                 // s ... t
}

Döngü v = t ile başlar, her turda v'yi listenin başına ekler ve v = onceki[v] ile bir adım geri gider. Kaynağın onceki değeri −1 olduğu için kaynak eklendikten sonra döngü biter. LinkedList.addFirst sabit sürelidir. Döngü, yoldaki düğüm sayısı kadar döner; maliyet O(yol uzunluğu)'dur ve en çok O(n)'dir.

Son satırdaki denetim ulaşılamayan hedefi yakalar. t'ye s'den ulaşılamıyorsa onceki[t] = −1 kalmıştır; zincir hemen biter ve listenin ilk öğesi t'nin kendisi olur. İlk öğe s değilse metot boş liste döndürür.

Örnek 14.10

Şekil 14.30'daki çizgede Dijkstra A'dan çalıştırılınca onceki şöyle olur: B ← A, C ← A, D ← E, E ← C, F ← D. yol(onceki, A, F) çağrısı:

  1. v = F: liste [F]; v = onceki[F] = D.
  2. v = D: liste [D, F]; v = onceki[D] = E.
  3. v = E: liste [E, D, F]; v = onceki[E] = C.
  4. v = C: liste [C, E, D, F]; v = onceki[C] = A.
  5. v = A: liste [A, C, E, D, F]; v = onceki[A] = −1, döngü biter.

Beş tur dönülmüştür; yolda beş düğüm vardır. İlk öğe A'dır, yani F'ye ulaşılabilir. Sonuç A → C → E → D → F'dir ve uzunluğu d[F] = 2 + 3 + 4 + 11 = 20'dir.

4 2 5 10 3 4 11 A B D C E F v onceki – A A B A C E D C E D F y A C E D F addFirst → hedeften kaynağa doğru, başa ekle
Şekil 14.33: Yolu geri kurmanın sonu. onceki zinciri F'den A'ya izlenmiş, her düğüm listenin başına eklenmiştir: y = [A, C, E, D, F].
Sık yapılan hata

onceki dizisini başka bir kaynak için kullanmak. Dizi, Dijkstra hangi kaynaktan çalıştırıldıysa o kaynağın en kısa yol ağacıdır. onceki = [−1, 0, 0, 1, 3] dizisi 0'dan kurulmuştur; 0'dan 4'e yol 0 → 1 → 3 → 4'tür. Aynı diziyle 2'den 4'e yol aranırsa zincir 4, 3, 1, 0 diye gider ve 2'ye hiç uğramaz; metot boş liste döndürür. 2'den yol bulmak için Dijkstra 2'den yeniden çalıştırılmalıdır.

14.4.9Dijkstra'nın analizi ve doğruluğu

Zaman. İki gerçekleme yaygındır:

Tablo 14.20: Dijkstra algoritmasının iki gerçeklemesi.
GerçeklemeKarmaşıklıkNeden?
Öncelikli kuyruk (ikili heap)O((n + m) log n)n kez en küçüğü çıkarma, en çok m kez ekleme ya da güncelleme; her biri O(log n)
Kuyruksuz (dizi taraması)O(n2 + m) = O(n2)Her adımda en küçük mesafeli düğümü bulmak için n düğüm taranır; komşu denetimleri toplam m

Heap'li sürümde her başarılı gevşetme kuyruğa bir kayıt ekler. Kuyrukta en çok m + 1 kayıt bulunur; her ekleme ve çıkarma O(log m)'dir. m ≤ n2 olduğu için log m ≤ 2 log n'dir ve O(log m) = O(log n) yazılabilir. Toplam O((n + m) log n) eder.

Hangisinin seçileceği çizgeye bağlıdır. Seyrek çizgede (m ≈ n) heap'li sürüm O(n log n)'dir ve dizi taramasının O(n2)'sinden çok daha hızlıdır. Çok yoğun çizgede (m ≈ n2) heap'li sürüm O(n2 log n)'ye çıkar; dizi taraması O(n2) ile daha iyi olabilir.

Teorem 14.3 (Dijkstra'nın doğruluğu)

Kenar ağırlıkları negatif değilse, Dijkstra algoritmasında bir düğüm kesinleştiği anda geçici mesafesi o düğümün kaynağa gerçek uzaklığına eşittir.

u, kesinleşmemiş düğümler arasında geçici mesafesi en küçük olan düğüm olsun. s'den u'ya herhangi bir yolu düşünelim. Bu yol s'de, yani kesinleşmiş bölgede başlar ve bir yerde bölgeden ilk kez çıkar; ilk dışarıdaki düğüm y, ondan önceki düğüm x olsun. x kesinleşirken (x, y) kenarı gevşetilmiştir; bu yüzden y'nin geçici mesafesi en çok yolun s'den y'ye kadarki kısmının uzunluğudur. u en küçük geçici mesafeli düğüm olduğu için mesafe[u] ≤ mesafe[y]'dir. Yolun y'den u'ya kalan kısmı negatif olmayan kenarlardan oluşur; uzunluğu ≥ 0'dır. Demek ki yolun tamamı en az mesafe[y] ≥ mesafe[u] uzunluğundadır. Hiçbir yol mesafe[u]'dan kısa olamaz. □

Kanıtın tek bir yerinde ağırlıkların negatif olmaması kullanılmıştır: “kalan kısmın uzunluğu ≥ 0”. Negatif bir kenar varsa, kesinleşmiş bir düğümün mesafesi sonradan negatif kenar üzerinden kısalabilir. Sözde koddaki Dijkstra bu durumda yanlış sonuç verir. Bayat kayıtlı Java sürümü bir düğümü yeniden işleyerek bazen doğru sonucu bulabilir, ama süre garantisi kaybolur; negatif ağırlıklı bir döngü varsa algoritma hiç bitmez. Negatif ağırlıklı çizgelerde Bellman-Ford algoritması kullanılır.

Örnek 14.11

Oklar s → a: 2, s → b: 3, b → a: −2, a → c: 1 olsun. Dijkstra önce s'yi (0), sonra a'yı (2) kesinleştirir; a → c gevşetilir ve c'nin mesafesi 3 olur. Sonra b (3) kesinleşir. b → a üzerinden 3 + (−2) = 1 elde edilir; a'nın gerçek uzaklığı 1'dir. Oysa a, 2 değeriyle çoktan kesinleşmiştir ve sözde koddaki algoritma kesinleşmiş düğümü bir daha işlemez. c de 3 değeriyle kalır; gerçek uzaklığı ise 1 + 1 = 2'dir. Bayat kayıtlı Java sürümü bu küçük örnekte a'yı 1 değeriyle kuyruğa yeniden ekler. a ikinci kez işlenir ve c 2'ye düzelir. Sonuç bu kez doğrudur, ama bir düğüm birden çok kez işlenmiştir.

14.5Minimum kapsayan ağaç

Bir belediye, ilçedeki köylerin hepsine su götürmek istiyor. Her iki köy arasına boru döşemenin bir maliyeti var. Her köyün her köye doğrudan bağlanması gerekmez; suyun her köye bir yolla ulaşması yeterlidir. Toplam maliyeti en düşük boru ağı hangisidir? Bu soru, minimum kapsayan ağaç problemidir. Aynı problem elektrik ve iletişim ağlarının tasarımında, kümeleme yöntemlerinde ve başka algoritmaların alt adımı olarak karşımıza çıkar.

14.5.1Kapsayan ağaç ve MKA

Tanım 14.14

Bağlantılı bir çizgenin kapsayan ağacı (spanning tree), çizgenin bütün düğümlerini içeren ve döngü içermeyen bağlantılı bir alt çizgesidir; n düğüm için tam n − 1 kenarı vardır. Ağırlıklı bir çizgenin minimum kapsayan ağacı (MKA; minimum spanning tree, MST), toplam kenar ağırlığı en düşük olan kapsayan ağaçtır.

Kapsayan ağaçta neden döngü istemiyoruz? Ağırlıklar pozitifse döngüdeki herhangi bir kenarı çıkarmak toplamı azaltır ve bağlantılılığı bozmaz: döngünün öbür kenarları iki ucu hâlâ birbirine bağlar. Bu yüzden en ucuz bağlantılı alt çizge her zaman döngüsüzdür, yani bir ağaçtır. n − 1 sayısı da 14.1.7'deki alt sınırdan gelir: n düğümü bağlamak için en az n − 1 kenar gerekir ve döngüsüz bağlantılı çizgede tam bu kadar kenar vardır.

0 1 1 1 1 2 2 2 2 4 4 4 4 5 5 6 7 11 A B C D E F G H I J
Şekil 14.34: On düğüm ve on sekiz ağırlıklı kenardan oluşan yönsüz çizge. Kenarlar ağırlığa göre: I–J 0; A–E, C–I, E–F, G–H 1; B–D, C–J, D–E, D–H 2; A–D, B–C, C–H, G–I 4; A–B, D–F 5; H–I 6; F–G 7; D–G 11.

Bu çizgenin kenar ağırlıklarının toplamı 62'dir. MKA ise yalnız dokuz kenar seçer ve toplamı 14'tür. Olası kapsayan ağaçların sayısı çok büyüktür; hepsini tek tek denemek pratik değildir. İki klasik algoritma, Kruskal ve Prim, MKA'yı açgözlü (greedy) bir yöntemle bulur: her adımda o an en iyi görünen seçimi yapar ve bu seçimden hiç geri dönmez. Açgözlü yöntemin bu problemde doğru sonuç vermesinin nedeni aşağıdaki özelliktir.

Teorem 14.4 (Kesim özelliği)

Bir çizgenin düğümleri iki boş olmayan gruba ayrılsın. İki grubu birbirine bağlayan kenarlar arasında ağırlığı en küçük olan kenar, bir minimum kapsayan ağaçta bulunur.

En hafif kesişen kenar e = (u, v) olsun ve bir MKA T, e'yi içermesin. T bağlantılı olduğu için içinde u'dan v'ye bir yol vardır. u ile v farklı gruplarda olduğundan bu yol iki grup arasında en az bir kez geçer; geçtiği kenar f olsun. T'ye e'yi ekleyip f'yi çıkaralım. Sonuç yine bir kapsayan ağaçtır: e eklenince oluşan tek döngü f çıkınca kırılır, bağlantı bozulmaz. e kesişen kenarların en hafifi olduğu için ağırlık(e) ≤ ağırlık(f)'dir. Yeni ağacın toplamı T'ninkinden büyük olamaz; o da bir MKA'dır ve e'yi içerir. □

Kruskal ve Prim bu teoremin iki farklı kullanımıdır. Prim'de gruplardan biri o ana kadar kurulan ağaçtır. Kruskal'da ise seçilen kenarın iki ucunun ait olduğu ağaçlar ayrı gruplar olarak düşünülür.

14.5.2Ayrık kümeler (Union-Find)

Kruskal algoritması kenarları hafiften ağıra dener ve her kenar için aynı soruyu sorar: “Bu kenar, şimdiye kadar seçilenlerle bir döngü kurar mı?” Kenarın iki ucu seçilen kenarlarla zaten birbirine bağlıysa yeni kenar bir döngü kapatır. Bu soruyu hızlı yanıtlayan yapı ayrık kümeler (disjoint sets, union-find) yapısıdır.

Yapı, çakışmayan kümelerden oluşan bir aileyi iki işlemle yönetir:

  • find(x): x'in kümesinin temsilcisini döndürür. İki öğe aynı kümedeyse find ikisi için aynı temsilciyi verir.
  • union(u, v): u ile v farklı kümelerdeyse iki kümeyi birleştirir ve true döndürür. Zaten aynı kümedeyseler hiçbir şey yapmaz ve false döndürür.

Sunum yalnız bu iki işlemin davranışını kullanır; yapının iç ayrıntısını vermez. Aşağıdaki sınıf, sunumdaki izlemenin ebeveyn oklarını aynen üreten bir gerçeklemedir.

class UnionFind {
    private final int[] ebeveyn, boyut;
    UnionFind(int n) {                       // her düğüm kendi kümesi
        ebeveyn = new int[n];  boyut = new int[n];
        for (int i = 0; i < n; i++) { ebeveyn[i] = i; boyut[i] = 1; }
    }
    int find(int x) {                        // kökü bul, yolu kısalt
        if (ebeveyn[x] != x) ebeveyn[x] = find(ebeveyn[x]);
        return ebeveyn[x];
    }
    boolean union(int u, int v) {
        int a = find(u), b = find(v);
        if (a == b) return false;            // aynı küme: döngü kurar
        if (boyut[a] > boyut[b]) { int t = a; a = b; b = t; }
        ebeveyn[a] = b;  boyut[b] += boyut[a];  // küçük ağaç büyüğün altına
        return true;
    }
}

Her küme bir ağaç olarak tutulur. ebeveyn[x], x'in ağaçtaki ebeveynidir; kökün ebeveyni kendisidir ve kök, kümenin temsilcisidir. Başta her düğüm kendi kümesidir: n tane tek düğümlü ağaç vardır. İki iyileştirme yapının hızını belirler:

  • Boyuta göre birleştirme. union, küçük ağacın kökünü büyük ağacın köküne bağlar. Böylece ağaçların yüksekliği en çok log n olur. Boyutlar eşitse u'nun kökü v'nin köküne bağlanır.
  • Yol kısaltma (path compression). find kökü bulurken yol üzerindeki her düğümü doğrudan köke bağlar. Aynı düğüm bir daha sorulduğunda kök tek adımda bulunur.

İki iyileştirme birlikte kullanılınca bir dizi işlemin işlem başına maliyeti O(α(n)) olur. Buradaki α, ters Ackermann işlevidir ve son derece yavaş büyür. Evrendeki atom sayısı kadar büyük bir n için bile α(n) ≤ 4'tür. Pratikte find ve union sabit süreli kabul edilebilir.

14.5.3Kruskal algoritması

Kruskal(G):
  T = ∅          // seçilen kenarlar
  for her düğüm v: makeSet(v)
  kenarları ağırlığa göre sırala
  for her (u, v), küçükten büyüğe:
    if find(u) != find(v): // döngü yok
      T.ekle((u, v))
      union(u, v)
    if |T| == n − 1: break
  return T  // MST (bağlı değilse: orman)

Kruskal'ın kuralı tek cümledir: döngü oluşturmayan en hafif kenarı seç. Algoritma başta n ayrık küme ile başlar; her düğüm tek başına bir ağaçtır. Kenarlar ağırlığa göre küçükten büyüğe sıralanır ve sırayla denenir. Kenarın iki ucu farklı kümelerdeyse kenar T'ye eklenir ve iki küme birleşir. Aynı kümedeyse kenar bir döngü kapatır ve atlanır. n − 1 kenar seçilince ağaç tamamlanmıştır; kalan kenarlara bakılmaz.

Ara durumda seçilen kenarlar tek bir ağaç değil, birden çok ağaçtan oluşan bir ormandır. Ağaçlar algoritma ilerledikçe birleşir ve sonunda tek ağaç kalır. Çizge bağlantısızsa n − 1 kenara hiç ulaşılamaz. Bu durumda algoritma bütün kenarları dener ve her bağlantılı bileşen için bir ağaçtan oluşan minimum kapsayan ormanı döndürür.

Tablo 14.21: Kruskal algoritmasının maliyeti.
AşamaMaliyet
Kenarları sıralamaO(m log m)
2m find + (n − 1) unionO(m · α(n))
ToplamO(m log n)

Baskın terim sıralamadır. m ≤ n2 olduğu için log m ≤ 2 log n'dir; bu yüzden O(m log m) = O(m log n) yazılır. Union-Find işlemleri neredeyse doğrusaldır ve toplamı değiştirmez. Döngü denetimi Union-Find yerine her kenar için bir DFS ile yapılsaydı, her denetim O(n) sürer ve toplam O(m · n)'ye çıkardı.

14.5.4Kruskal adım adım

Kruskal'ı Şekil 14.34'teki çizgede çalıştıralım. Eşit ağırlıklı kenarlar Şekil 14.34'ün altyazısındaki sırayla denenir.

Tablo 14.22: Kruskal'ın denediği kenarlar ve kararları.
KenarAğırlıkKararBirleşen kümeler / gerekçe|T|Toplam
I–J0kabul{I} + {J}10
A–E1kabul{A} + {E}21
C–I1kabul{C} + {I, J}32
E–F1kabul{A, E} + {F}43
G–H1kabul{G} + {H}54
B–D2kabul{B} + {D}66
C–J2retC ve J zaten {C, I, J} kümesinde66
D–E2kabul{B, D} + {A, E, F}78
D–H2kabul{A, B, D, E, F} + {G, H}810
A–D4retA ve D aynı kümede810
B–C4kabulbüyük küme + {C, I, J}914

C–J kenarının neden reddedildiğine bakalım. Daha önce C–I ve I–J kenarları seçilmiştir; C ile J, C–I–J yoluyla zaten bağlıdır. C–J eklenseydi C–I–J–C döngüsü oluşurdu. A–D de aynı nedenle reddedilir: A ile D, A–E–D yoluyla bağlıdır. B–C kabul edilince |T| = 9 = n − 1 olur ve algoritma durur. Toplam ağırlık 0 + 1 + 1 + 1 + 1 + 2 + 2 + 2 + 4 = 14'tür. C–H, G–I, A–B, D–F, H–I, F–G ve D–G kenarlarına, yani kalan yedi kenara hiç bakılmaz.

I–J = 0 ✓ A–E = 1 ✓ C–I = 1 ✓ E–F = 1 ✓ G–H = 1 ✓ B–D = 2 ✓ C–J = 2 ✗ D–E = 2 D–H = 2 A–D = 4 B–C = 4 C–H = 4 G–I = 4 A–B = 5 D–F = 5 H–I = 6 F–G = 7 D–G = 11 0 1 1 1 1 2 2 2 2 4 4 4 4 5 5 6 7 11 A B C D E F G H I J Seçilen kenar 6 / 9 Toplam ağırlık 6
I–J = 0 ✓ A–E = 1 ✓ C–I = 1 ✓ E–F = 1 ✓ G–H = 1 ✓ B–D = 2 ✓ C–J = 2 ✗ D–E = 2 ✓ D–H = 2 ✓ A–D = 4 ✗ B–C = 4 ✓ C–H = 4 G–I = 4 A–B = 5 D–F = 5 H–I = 6 F–G = 7 D–G = 11 0 1 1 1 1 2 2 2 2 4 4 4 4 5 5 6 7 11 A B C D E F G H I J Seçilen kenar 9 / 9 Toplam ağırlık 14
Şekil 14.35: Kruskal'ın iki anı. Üstte C–J kenarı reddediliyor: C ve J aynı ağaçtadır. Altta son durum: seçilen dokuz kenar A–E, E–F, D–E, B–D, D–H, G–H, B–C, C–I, I–J'dir ve toplam ağırlık 14'tür. Soldaki listede kabul edilen kenarlar işaretli, reddedilenler üstü çizilidir.
Sık yapılan hata

Bir kenarın döngü kurup kurmadığına yalnız iki ucunun daha önce seçilmiş bir kenara dokunup dokunmadığına bakarak karar vermek. D–E denendiğinde D de E de daha önce seçilmiş kenarlara bağlıdır (B–D ve A–E), ama farklı ağaçlardadır; kenar kabul edilir. Belirleyici olan uçların aynı kümede olup olmadığıdır, seçilmiş olup olmadıkları değil.

14.5.5Kod izleme: kruskal() ve Union-Find

List<int[]> kruskal(int n, List<int[]> E) {
  E.sort((a, b) ->          // {u, v, w}
      Integer.compare(a[2], b[2]));
  UnionFind uf = new UnionFind(n);
  List<int[]> T = new ArrayList<>();
  for (int[] e : E) {
    if (uf.union(e[0], e[1]))  // döngüsüz
      T.add(e);
    if (T.size() == n - 1) break;
  }
  return T;
}

Kod, sözde kodun doğrudan karşılığıdır. Kenarlar {u, v, w} dizileri olarak verilir. E.sort kenarları üçüncü öğeye, yani ağırlığa göre sıralar. Java'nın nesne listelerini sıralama yöntemi kararlıdır: eşit ağırlıklı kenarlar girdideki sıralarını korur. uf.union(e[0], e[1]) hem döngü denetimini hem birleştirmeyi tek çağrıda yapar: true dönerse kenar eklenir. |T| = n − 1 olunca döngü break ile kesilir.

Union-Find'ın içini izlemek için düğümleri A = 0, …, J = 9 diye numaralayalım. Aşağıdaki tabloda “bağlanan” sütunu, union içinde hangi kökün hangi köke bağlandığını gösterir.

Tablo 14.23: kruskal() sırasında Union-Find ormanı.
Kenarfind kökleriunionBağlananDeğişen ebeveynler
I–J (0)I, JtrueI → J (boyutlar eşit)I → J
A–E (1)A, EtrueA → E (eşit)A → E
C–I (1)C, JtrueC → J (1 < 2)C → J
E–F (1)E, FtrueF → E (1 < 2)F → E
G–H (1)G, HtrueG → H (eşit)G → H
B–D (2)B, DtrueB → D (eşit)B → D
C–J (2)J, Jfalse——
D–E (2)D, EtrueD → E (2 < 3)D → E
D–H (2)E, HtrueH → E (2 < 5)H → E
A–D (4)E, Efalse——
B–C (4)E, JtrueJ → E (3 < 7)B → E (yol kısaltma), J → E

Tablodaki iki an özellikle öğreticidir. C–J denendiğinde find(C), C → J okunu izleyip J'yi; find(J) doğrudan J'yi verir. Kökler aynıdır, union false döndürür ve kenar atlanır. Son adımda find(B), B → D → E yolunu izler ve yol kısaltma B'yi doğrudan E'ye bağlar. Sonra J'nin ağacı (3 düğüm) E'nin ağacının (7 düğüm) altına girer. Son ormanda tek kök E'dir; I ve C, J üzerinden, G ise H üzerinden E'ye ulaşır.

A B C D E F G H I J kenar (sıralı) I–J 0 ✓ A–E 1 ✓ C–I 1 ✓ E–F 1 ✓ G–H 1 ✓ B–D 2 ✓ C–J 2 ✗ D–E 2 ✓ D–H 2 A–D 4 B–C 4 C–H 4 G–I 4 A–B 5 D–F 5 H–I 6 F–G 7 D–G 11 uf ormanı (ok → ebeveyn) A B C D E F G H I J |T|=7, ağırlık=8 A B C D E F G H I J kenar (sıralı) I–J 0 ✓ A–E 1 ✓ C–I 1 ✓ E–F 1 ✓ G–H 1 ✓ B–D 2 ✓ C–J 2 ✗ D–E 2 ✓ D–H 2 ✓ A–D 4 ✗ B–C 4 ✓ C–H 4 G–I 4 A–B 5 D–F 5 H–I 6 F–G 7 D–G 11 uf ormanı (ok → ebeveyn) A B C D E F G H I J |T|=9, ağırlık=14
Şekil 14.36: Union-Find ormanının iki anı (oklar ebeveyni gösterir). (a) D–E kabul edildi: D'nin ağacı E'nin altına girdi. (b) Son durum: tek ağaç, kök E. B'nin oku yol kısaltmayla doğrudan E'yi gösterir.

return T dokuz kenar döndürür; toplam ağırlık 14'tür. On sekiz kenardan on biri denenmiş, yedisine hiç bakılmamıştır. Sıralama yine de on sekiz kenarın hepsini kapsar; algoritmanın baskın maliyeti budur.

14.5.6İkinci örnek: havalimanları

Goodrich, Tamassia ve Goldwasser'in kitabındaki örnekte dokuz ABD havalimanı ve aralarındaki 19 uçuş hattı vardır; ağırlıklar mil cinsinden uzaklıklardır. Bütün havalimanlarını en kısa toplam hat uzunluğuyla birbirine bağlayan ağ aranıyor.

2704 867 849 740 621 1846 802 144 187 184 1258 337 1464 1235 1391 946 1090 1121 2342 BOS PVD ORD JFK BWI SFO LAX DFW MIA
Şekil 14.37: Dokuz havalimanı ve 19 hat. Kalın kenarlar Kruskal'ın bulduğu MKA'dır: JFK–PVD 144, JFK–BWI 184, JFK–BOS 187, SFO–LAX 337, ORD–BWI 621, ORD–DFW 802, BWI–MIA 946, LAX–DFW 1235. Kesikli kenarlar seçilmez.
Tablo 14.24: Havalimanı örneğinde Kruskal'ın kararları.
Hat (mil)KararGerekçe
JFK–PVD 144, JFK–BWI 184, JFK–BOS 187kabulJFK çevresinde dört havalimanı birleşir
SFO–LAX 337kabulbatıda ayrı bir ağaç
ORD–BWI 621kabulORD doğu ağacına katılır
ORD–JFK 740retORD ile JFK, BWI üzerinden zaten bağlı (döngü)
ORD–DFW 802kabulDFW doğu ağacına katılır
ORD–PVD 849, ORD–BOS 867retiki uç da doğu ağacında
BWI–MIA 946kabulMIA doğu ağacına katılır
JFK–MIA 1090, DFW–MIA 1121retiki uç da doğu ağacında
LAX–DFW 1235kabulbatı ve doğu ağaçları birleşir; 8 = n − 1 kenar

Toplam 144 + 184 + 187 + 337 + 621 + 802 + 946 + 1235 = 4456 mildir. On üç hat denenmiş, en uzun altı hat (1258, 1391, 1464, 1846, 2342, 2704) hiç denenmemiştir. Örnek, algoritmanın davranışını iyi gösterir: kısa hatlar önce birbirine yakın havalimanlarını gruplar; uzun hatlar yalnız gruplar arasında köprü gerektiğinde seçilir. LAX–DFW, batı grubu ile doğu grubu arasındaki en kısa köprüdür; kesim özelliğine göre MKA'da bulunmak zorundadır.

14.5.7Prim algoritması

Prim(G, s):
  ağaç = {s}, T = ∅
  s'nin kenarlarını kuyruğa ekle
  while |T| < n − 1:
    (u, v) = en hafif kenar  // u ağaçta
    if v ağaçta: atla        // döngü
    v'yi ağaca, (u, v)'yi T'ye ekle
    v'nin dışarı giden kenarlarını
      kuyruğa ekle
  return T

Prim algoritması Kruskal'dan farklı bir yol izler. Ağaç tek bir düğümle başlar ve her adımda bir düğüm büyür. Her adımda, ağaçtaki bir düğümü ağaç dışındaki bir düğüme bağlayan kenarların en hafifi seçilir. Kesim özelliği bu seçimin güvenli olduğunu söyler: ağaç ile dışarısı iki gruptur; ağaç dışarıya bir yolla bağlanmak zorundadır ve seçilen kenar bu bağlantıların en ucuzudur.

Prim'in Dijkstra'ya benzerliği dikkat çekicidir. İkisi de bir düğümden başlar, bir öncelikli kuyruk kullanır ve her adımda kuyruktan en küçük öğeyi alır. Fark kuyruğun anahtarındadır. Dijkstra'da anahtar düğümün kaynağa toplam uzaklığıdır. Prim'de anahtar, düğümü ağaca bağlayan tek kenarın ağırlığıdır. Kruskal'dan farkı ise ara durumdadır: Prim'de her an tek bir ağaç vardır. Bir kenarın döngü kurup kurmadığını anlamak için “hedef düğüm zaten ağaçta mı?” sorusu yeterlidir; Union-Find gerekmez.

14.5.8Prim: Java

Sunumdaki Java sürümü öncelikli kuyrukla yazılmış tembel sürümdür.

List<int[]> prim(List<List<int[]>> adj, int s) {
  int n = adj.size();                 // adj.get(u): {v, w}
  boolean[] agacta = new boolean[n];
  PriorityQueue<int[]> pq = new PriorityQueue<>(
      (a, b) -> Integer.compare(a[2], b[2]));  // {u, v, w}
  List<int[]> T = new ArrayList<>();
  agacta[s] = true;
  for (int[] e : adj.get(s)) pq.add(new int[]{s, e[0], e[1]});
  while (!pq.isEmpty() && T.size() < n - 1) {
    int[] e = pq.poll();               // en hafif aday
    if (agacta[e[1]]) continue;       // bayat: döngü kurar
    agacta[e[1]] = true;
    T.add(e);
    for (int[] f : adj.get(e[1]))
      if (!agacta[f[0]]) pq.add(new int[]{e[1], f[0], f[1]});
  }
  return T;
}

Kuyrukta {u, v, w} kayıtları bulunur: u ağaçtaki uç, v aday düğüm, w kenarın ağırlığıdır. Karşılaştırıcı yalnız ağırlığa bakar. agacta dizisi hangi düğümlerin ağaca girdiğini tutar. Kod şu adımları izler:

  1. Başlangıç düğümü s ağaca konur; s'nin bütün kenarları kuyruğa eklenir.
  2. Döngü, kuyruk boşalana ya da n − 1 kenar seçilene kadar sürer. Her turda en hafif kayıt alınır.
  3. Kaydın aday ucu e[1] zaten ağaçtaysa kayıt bayattır: bu kenar iki ağaç düğümünü bağlar ve döngü kurar. continue ile atlanır.
  4. Değilse aday ağaca alınır, kenar T'ye eklenir. Yeni düğümün ağaç dışına giden bütün kenarları kuyruğa eklenir.

“Tembel” sözcüğü bayat kayıtların ele alınış biçimini anlatır. v ağaca girdiğinde, v'ye giden ve kuyrukta bekleyen eski kayıtlar silinmez; kuyruktan çıktıklarında atlanırlar. Dijkstra'daki bayat kayıt yöntemiyle aynı fikirdir.

Maliyet. Her kenar en çok iki kez kuyruğa girer, her ucundan bir kez. Kuyrukta en çok 2m ekleme ve 2m çıkarma yapılır; her biri O(log m)'dir. Toplam O(m log m) = O(m log n), ek bellek ise kuyruk için O(m)'dir. Komşuluk matrisiyle ve kuyruksuz, dizi taramalı bir sürümle Prim O(n2)'de çalışır; çok yoğun çizgelerde bu sürüm daha iyidir.

Sık yapılan hata

Bayat kayıt denetimini (if (agacta[e[1]]) continue;) gereksiz sanıp silmek. Dijkstra'da benzer satır yalnız verim içindi; Prim'de ise doğruluk için gereklidir. Satır silinirse, v ağaca girdikten sonra kuyrukta kalan ve v'ye giden bir kayıt çıktığında iki ucu da ağaçta olan bir kenar T'ye eklenir. Ağaçta döngü oluşur ve sonuç kapsayan ağaç olmaktan çıkar.

14.5.9Prim adım adım

Prim'i Şekil 14.34'teki çizgede A'dan başlatalım. Elle çalıştırırken eşit ağırlıklı kayıtlar alfabetik sırayla alınsın: önce ağaçtaki uç, sonra aday uç alfabeye göre karşılaştırılır.

Tablo 14.25: Prim'in A'dan izlemesi (eşit ağırlıklarda alfabetik sıra).
AdımSeçilen kenarAğaca girenKuyruğa yeni eklenen adaylarToplam
0—AA–E 1, A–D 4, A–B 50
1A–E (1)EE–D 2, E–F 11
2E–F (1)FF–D 5, F–G 72
3E–D (2)DD–B 2, D–G 11, D–H 24
4D–B (2)BB–C 46
5D–H (2)HH–C 4, H–G 1, H–I 68
6H–G (1)GG–I 49
7B–C (4) (önce bayat A–D 4 atlanır)CC–I 1, C–J 213
8C–I (1)II–J 014
9I–J (0)J—14

3. adımda D ağaca girer. Bu anda kuyruktaki A–D 4 ve F–D 5 kayıtları bayatlamıştır: ikisinin de iki ucu ağaçtadır. Kuyruktan hemen silinmezler. 7. adımda en hafif kayıtlar dörder ağırlıklıdır ve alfabetik sırada önce A–D gelir; A–D bayat olduğu için atlanır, ardından B–C seçilir. 9. adımda J ağaca girer, n − 1 = 9 kenar seçilmiştir ve algoritma durur. Kuyrukta hâlâ sekiz kayıt vardır; bunlara bakılmaz.

öncelikli kuyruk D–B = 2 D–H = 2 A–D = 4 A–B = 5 F–D = 5 F–G = 7 D–G = 11 0 1 1 1 1 2 2 2 2 4 4 4 4 5 5 6 7 11 A B C D E F G H I J Ağaçtaki düğüm 4 / 10 Toplam ağırlık 4
öncelikli kuyruk C–J = 2 H–C = 4 A–D = 4 G–I = 4 A–B = 5 F–D = 5 H–I = 6 F–G = 7 D–G = 11 0 1 1 1 1 2 2 2 2 4 4 4 4 5 5 6 7 11 A B C D E F G H I J Ağaçtaki düğüm 10 / 10 Toplam ağırlık 14
Şekil 14.38: Prim'in iki anı. Üstte E–D seçildi ve D ağaca girdi; üstü çizili kayıtlar bayatlamıştır. Altta son durum: ağaçta on düğüm, toplam ağırlık 14.

Prim ağacı A–E, E–F, E–D, D–B, D–H, H–G, B–C, C–I, I–J'dir. Kenar kümesi Kruskal'ınkiyle aynıdır, yalnız kenarlar farklı sırada seçilmiştir. Kruskal işe ağırlığı 0 olan I–J ile başlamıştı; Prim bu kenarı ancak en son, I ağaca girdikten sonra görebilir.

Alfabetik sıra elle izleme için seçilmiş bir kuraldır. Sunumdaki Java kodu eşit ağırlıklı kayıtları PriorityQueue'nun iç düzenine göre çıkarır ve bu düzen hiçbir sıra garantisi vermez. Kodun eşit ağırlıklarda hangi kaydı önce çıkaracağı, kayıtların kuyruğa hangi sırayla girdiğine, dolayısıyla komşu listelerinin sırasına bağlıdır. Kod bu çizgede çalıştırıldığında komşu listeleri alfabetik sıralıysa 7. adımda B–C yerine G–I seçilir, ardından I–J ve I–C gelir; ağaç A–E, E–F, E–D, D–B, D–H, H–G, G–I, I–J, I–C olur. Kenarlar listelere Şekil 14.34'ün altyazısındaki sırayla eklenmişse 7. adımda H–C seçilir. Bu ağaçların her biri bir MKA'dır ve toplamları yine 14'tür. Eşit ağırlıklı kenarlar varsa birden çok MKA olabilir; toplam ağırlık ise her zaman aynıdır.

Örnek 14.12

Prim aynı çizgede J'den başlatılsın (eşitlikte alfabetik sıra).

  1. J'nin kenarları kuyruğa girer: J–I 0, J–C 2. En hafifi J–I 0'dır; I ağaca girer. Yeni adaylar I–C 1, I–G 4, I–H 6.
  2. En hafif kayıt I–C 1'dir; C ağaca girer. Yeni adaylar C–B 4, C–H 4.
  3. Sıradaki en hafif kayıt J–C 2'dir, ama C artık ağaçtadır: kayıt bayattır ve atlanır. Kuyrukta dörder ağırlıklı C–B, C–H ve I–G vardır; alfabetik sırada C–B önce gelir ve B ağaca girer.

İlk üç seçilen kenar J–I (0), I–C (1) ve C–B (4)'tür. Algoritma B–D, D–E, E–A, E–F, D–H, H–G ile devam eder ve toplam yine 14 olur. Başlangıç düğümü ağacın kuruluş sırasını değiştirir, MKA'nın toplam ağırlığını değiştirmez.

14.5.10Kruskal ile Prim'in karşılaştırması

Tablo 14.26: Kruskal ve Prim algoritmalarının karşılaştırması.
KruskalPrim
Fikirkenarları küçükten büyüğe dener, ağaçları birleştirirtek ağacı bir düğümden başlayıp büyütür
Ara durumorman (birden çok ağaç)tek ağaç
Veri yapısısıralı kenar listesi ve Union-Findöncelikli kuyruk, işaret dizisi, komşuluk listesi
Döngü denetimifind(u) == find(v)v zaten ağaçta mı?
MaliyetO(m log n) (sıralama baskın)O(m log n) heap ile; matrisle O(n2)
Ne zaman?seyrek çizge, kenarlar liste olarak hazırkomşuluk listesi hazır; çok yoğun çizgede O(n2) sürüm

İki algoritma da açgözlüdür ve kesim özelliğine dayanır. Aynı çizgede aynı toplam ağırlığı bulurlar; örneğimizde ikisi de 14'tür. Eşit ağırlıklı kenarlar varsa buldukları ağaçlar farklı olabilir. Seçim, verinin elde hangi biçimde olduğuna da bağlıdır. Kenarlar bir dosyadan liste olarak okunuyorsa Kruskal doğaldır. Çizge zaten komşuluk listesiyle tutuluyorsa ve bir düğümden başlamak anlamlıysa Prim doğaldır.

Sık yapılan hata

MKA ile en kısa yol ağacını karıştırmak. Dijkstra'nın onceki dizisi de bir ağaç kurar, ama bu ağaç kaynaktan her düğüme olan yol uzunluklarını en aza indirir. MKA ise kenarların toplamını en aza indirir. Kenarları A–B 2, B–C 2, A–C 3 olan üçgende MKA, A–B ve B–C kenarlarından oluşur (toplam 4). Bu ağaçta A'dan C'ye yol 4 uzunluğundadır; oysa A'dan C'ye en kısa yol doğrudan A–C kenarıdır ve 3 uzunluğundadır. İki problemin cevabı genel olarak farklıdır.

14.6Özet

  • Çizge G = (V, E), düğümler ve onları bağlayan kenarlardan oluşur; n düğüm, m kenar sayısıdır. Ağaçtan farklı olarak kökü yoktur, döngü içerebilir ve bir düğüme birden çok yoldan ulaşılabilir. Kenarlar yönlü ya da yönsüz, ağırlıklı ya da ağırlıksız olabilir.
  • Derece bir düğüme bağlı kenar sayısıdır. El sıkışma lemmasına göre Σ deg(v) = 2m'dir; yönlü çizgede giriş ve çıkış derecelerinin toplamlarının ikisi de m'dir. Basit yönsüz çizgede m ≤ n(n − 1)/2, bağlantılı çizgede m ≥ n − 1'dir.
  • Yol kenarlarla bağlı düğüm dizisi, döngü aynı düğümde başlayıp biten yoldur. Her iki düğümü arasında yol olan çizge bağlantılıdır; birbirine ulaşabilen düğümlerin en büyük grubu bir bağlantılı bileşendir.
  • Komşuluk matrisi O(n2) bellek kullanır; kenar sorgusu O(1), komşuları gezmek O(n)'dir. Komşuluk listesi O(n + m) bellek kullanır; komşuları gezmek O(deg v)'dir. Seyrek çizgede liste, yoğun çizgede ve sık kenar sorgusunda matris seçilir.
  • BFS kuyrukla seviye seviye, DFS yığınla ya da özyinelemeyle derine doğru ilerler. İkisi de komşuluk listesiyle O(n + m)'dir. Düğüm kuyruğa ya da yığına girerken işaretlenir; işaret olmadan gezinme döngülerde sonsuza kadar döner.
  • Üç uygulama bu gezinmelerin üzerine kurulur. Bağlantılı bileşenler, boyanmamış her düğümden yeni bir gezinme başlatılarak bulunur. İki parçalılık testi BFS ile 2-renklemedir; tek uzunlukta döngü yoksa çizge iki parçalıdır. Yönlü döngü tespiti üç renkli DFS'dir; gri düğüme giden ok bir geri kenardır.
  • Topolojik sıralama yalnız DAG'lerde vardır. Kahn algoritması giriş derecesi 0 olan düğümleri kuyruktan sırayla çıkarır ve oklarını siler; sonuçta n'den az düğüm varsa döngü vardır. Maliyet O(n + m)'dir.
  • Ağırlıksız en kısa yolu BFS bulur: ilk bulunan mesafe en kısadır. Ağırlıklı en kısa yolu, ağırlıklar negatif değilse Dijkstra bulur: mesafesi en küçük kesinleşmemiş düğümü seçer ve komşularını gevşetir. Heap ile O((n + m) log n), dizi taramasıyla O(n2)'dir. Yol, onceki dizisinden hedeften geriye okunarak kurulur.
  • Minimum kapsayan ağaç, bütün düğümleri en düşük toplam ağırlıkla bağlayan n − 1 kenarlı ağaçtır. Kruskal kenarları sıralayıp döngü kurmayanları Union-Find ile seçer; Prim tek ağacı her adımda en hafif dış kenarla büyütür. İkisi de O(m log n)'dir ve kesim özelliğine dayanır.
Tablo 14.27: Bölümdeki algoritmalar.
AlgoritmaYardımcı yapıZaman (komşuluk listesi ile)Ne verir?
BFSkuyrukO(n + m)seviye sırası, ağırlıksız en kısa yol
DFSyığın / özyinelemeO(n + m)derinlik öncelikli gezinme, döngü, bileşenler
Topolojik sıralama (Kahn)kuyruk / DFSO(n + m)DAG için bağımlılık sırası
Dijkstraöncelikli kuyrukO((n + m) log n)ağırlıklı en kısa yol (ağırlık ≥ 0)
Kruskalsıralama ve Union-FindO(m log n)minimum kapsayan ağaç
Primöncelikli kuyrukO(m log n)minimum kapsayan ağaç

Tablo, bölümün ortak dersini de özetler: veri yapısı algoritmanın davranışını belirler. Aynı gezinme iskeleti kuyrukla BFS, yığınla DFS, mesafeye göre sıralı öncelikli kuyrukla Dijkstra, kenar ağırlığına göre sıralı öncelikli kuyrukla Prim olur.

14.7Kavram listesi

Terimlerin İngilizce karşılıkları ve öteki bölümlerdeki kullanımları terim sözlüğünde bulunur.

14.8Kendini Dene

  1. 8 düğümlü basit, yönsüz ve bağlantılı bir çizgede en az ve en çok kaç kenar bulunabilir?

    Cevap

    En az n − 1 = 7 kenar (bu durumda çizge bir ağaçtır), en çok n(n − 1)/2 = 8 · 7 / 2 = 28 kenar (tam çizge K8).

  2. Okları 0 → 1, 0 → 2, 2 → 1, 3 → 0 olan yönlü çizgenin komşuluk matrisinde kaç hücre 1'dir? 0'ın çıkış derecesi ve 1'in giriş derecesi matristen nasıl okunur?

    Cevap

    Her ok bir hücre doldurur: 4 hücre 1'dir. 0'ın çıkış derecesi 0. satırın toplamıdır: m[0][1] + m[0][2] = 2. 1'in giriş derecesi 1. sütunun toplamıdır: m[0][1] + m[2][1] = 2.

  3. Gezinme örneklerindeki dokuz düğümlü çizgede (komşular alfabetik) bfs(E) hangi sırayı yazdırır? A, E'den kaç kenar uzaktadır?

    Cevap

    Sıra E B C G A D F I H'dir. E'nin komşuları B, C, G seviye 1'dir; B'den A, C'den D, G'den F seviye 2'ye girer; A'dan I ve D'den H seviye 3'tür. A seviye 2'dedir: E'den 2 kenar uzaktadır (E → B → A).

  4. Aynı çizgede özyinelemeli dfs(F) hangi sırayı yazdırır?

    Cevap

    F A B C D G E H I. F'nin ilk komşusu A, A'nın ilk ziyaret edilmemiş komşusu B, sonra C, D, G'ye inilir. G'nin komşusu E ziyaret edilir. E'den ve G'den geri dönülür; D'nin altında H ziyaret edilir. En sonda A'ya dönülünce A'nın son komşusu I ziyaret edilir.

  5. Komşu listeleri 0: [1, 2] · 1: [4] · 2: [4] · 3: [1] · 4: [] olan DAG için kahn() hangi sırayı döndürür?

    Cevap

    Giriş dereceleri [0, 2, 1, 0, 2]'dir; kuyruk 0, 3 ile başlar. 0 çıkar: giris[1] = 1, giris[2] = 0 ve 2 kuyruğa girer. 3 çıkar: giris[1] = 0 ve 1 girer. 2 çıkar: giris[4] = 1. 1 çıkar: giris[4] = 0 ve 4 girer. Sonuç 0 3 2 1 4'tür; boyut 5 = n, döngü yok.

  6. Okları 0 → 1, 1 → 2, 2 → 3, 3 → 1 olan çizgede donguVar(0, renk) hangi oku incelerken true döndürür? O anda renk dizisi nedir?

    Cevap

    0, 1, 2, 3 sırayla gri olur: renk = [1, 1, 1, 1]. 3 → 1 oku incelenirken 1 gridir, yani şu anki yolun üzerindedir. Bu bir geri kenardır; bulunan döngü 1 → 2 → 3 → 1'dir.

  7. Yönsüz kenarları A–B 1, A–C 4, B–C 2, B–D 6, C–D 3 olan çizgede Java Dijkstra A'dan çalıştırılırsa son d ve onceki değerleri ne olur? Kuyruktan kaç bayat kayıt çıkar?

    Cevap

    d = [0, 1, 3, 6] (A, B, C, D) ve onceki = [–, A, B, C]. A işlenince C 4, B 1 olur. B işlenince C 3'e iner ({C, 3} eklenir) ve D 7 olur. C işlenince D 6'ya iner ({D, 6} eklenir). Eski {C, 4} ve {D, 7} kayıtları bayat olarak çıkar ve atlanır: 2 bayat kayıt.

  8. Kenarları A–B 3, A–C 1, B–C 3, B–D 2, C–D 4, C–E 5, D–E 6 olan çizgede Kruskal'ın seçtiği kenarlar ve toplam ağırlık nedir? Eşit ağırlıklı A–B ile B–C'nin deneme sırası değişirse ne olur?

    Cevap

    Sıra A–C 1 (kabul), B–D 2 (kabul), A–B 3 (kabul: {A, C} ile {B, D} birleşir), B–C 3 (ret), C–D 4 (ret), C–E 5 (kabul). Dört kenar seçilmiştir, D–E'ye bakılmaz. Toplam 1 + 2 + 3 + 5 = 11'dir. B–C önce denenirse B–C kabul, A–B ret olur. Ağaç farklıdır (A–C, B–D, B–C, C–E), toplam yine 11'dir.

  9. Bir önceki sorudaki çizgede tembel Prim A'dan başlatılırsa (eşitlikte alfabetik sıra) kenarlar hangi sırayla seçilir ve kaç bayat kayıt atlanır?

    Cevap

    A–C 1, A–B 3, B–D 2, C–E 5. A–B seçildikten sonra kuyrukta C–B 3 kalır; B ağaçta olduğu için bu kayıt bayattır. D girince C–D 4 de bayatlar. C–E'den önce bu iki kayıt çıkar ve atlanır: 2 bayat kayıt. Toplam yine 11'dir.

  10. Bütün kenarlarının ağırlığı 5 olan bir çizgede tek kaynaklı en kısa yollar için Dijkstra yerine hangi algoritma kullanılabilir? Neden?

    Cevap

    BFS. Bütün ağırlıklar eşit olduğunda bir yolun uzunluğu 5 × kenar sayısı'dır; en az kenarlı yol aynı zamanda en kısa yoldur. BFS'nin bulduğu mesafeler 5 ile çarpılır. BFS O(n + m)'dir; heap'li Dijkstra'nın log n çarpanından kurtulunur.

14.9İleri okuma

  • M. T. Goodrich, R. Tamassia ve M. H. Goldwasser, Data Structures and Algorithms in Java (6. baskı, 2014), Bölüm 14: çizge ADT'sini, gösterimleri, DFS ve BFS'yi, topolojik sıralamayı, Dijkstra'yı, Kruskal ve Prim'i Java koduyla anlatır; havalimanı örneği bu bölümdendir.
  • R. Sedgewick ve K. Wayne, Algorithms (4. baskı, 2011), Bölüm 4: yönsüz ve yönlü çizgeleri, MKA'yı ve en kısa yolları ayrı kesimlerde işler; tembel ve istekli Prim sürümlerini karşılaştırır.
  • T. H. Cormen, C. E. Leiserson, R. L. Rivest ve C. Stein, Introduction to Algorithms (4. baskı, 2022), Bölüm 20–22: BFS ve DFS'nin doğruluk kanıtlarını, kesim özelliğini ve Bellman-Ford algoritmasını biçimsel bir dille verir; ayrık kümeler yapısının analizi Bölüm 19'dadır.

14.10Kaynaklar

  1. T. H. Cormen, C. E. Leiserson, R. L. Rivest, C. Stein, Introduction to Algorithms, 4. baskı, MIT Press, 2022.
  2. E. W. Dijkstra, “A note on two problems in connexion with graphs”, Numerische Mathematik, 1, 269–271, 1959.
  3. M. T. Goodrich, R. Tamassia, M. H. Goldwasser, Data Structures and Algorithms in Java, 6. baskı, Wiley, 2014.
  4. A. B. Kahn, “Topological sorting of large networks”, Communications of the ACM, 5(11), 558–562, 1962.
  5. J. B. Kruskal, “On the shortest spanning subtree of a graph and the traveling salesman problem”, Proceedings of the American Mathematical Society, 7(1), 48–50, 1956.
  6. R. C. Prim, “Shortest connection networks and some generalizations”, Bell System Technical Journal, 36(6), 1389–1401, 1957.
  7. R. Sedgewick, K. Wayne, Algorithms, 4. baskı, Addison-Wesley, 2011.

İlgili sayfalar: Bölüm 14 sunumu · Çalışma soruları · Terim sözlüğü · © Sercan Külcü. Tüm hakları saklıdır.