Önceki yazıda , kullanarak tüm çiftler için en kısa yol probleminin nasıl çözüleceğini gördük. Ayrıca, paralellik ve vektörleştirme kullanarak algoritmanın performansını iyileştirmenin çeşitli yollarını da inceledik.
Boyut N
olan bir grafiği ( G
), her hücre W[i,j]
i
j
köşeye kadar olan bir kenarın ağırlığını içeren N x N
boyutunda bir matris ( W
) olarak temsil ederek başlıyoruz (bkz. Resim 1). Köşeler arasında kenar olmadığı durumlarda, hücre özel bir NO_EDGE
değerine ayarlanır (Resim 1'de koyu hücreler olarak gösterilmiştir).
Şu andan itibaren şunu diyeceğiz: W[i,j]
i
ve j
köşeleri arasında bir mesafe içerir.
Daha sonra, k
köşesini alırız ve tüm W[i,j]
köşe çiftlerini dolaşarak i ⭢ k ⭢ j
mesafesinin, i
ve j
arasındaki bilinen mesafeden daha küçük olup olmadığını kontrol ederiz.
En küçük değer W[i,j]
içinde saklanır ve 3. adım, G
tüm köşeleri k
olarak kullanılana kadar bir sonraki k
için tekrarlanır.
algorithm FloydWarshall(W) do for k = 0 to N - 1 do for i = 0 to N - 1 do for j = 0 to N - 1 do W[i,j] = min(W[i,j], W[i,k] + W[k,j]) end for end for end for end algorithm
İşlem tamamlandığında, W
matrisinin her hücresi W[i,j]
i
ve j
köşeleri arasındaki en kısa yolun mesafesini veya aralarında bir yol yoksa NO_EDGE
değerini içerir.
Floyd-Warshall algoritmasının özü, i
j
ara köşe k
geçen potansiyel olarak yeni bir yol mesafesine sahip, bilinen mesafe W[i,j]
olan bir bölmedir.
Başlangıçta tüm grafik kenarlarını biliyoruz ve bu da bize şu yolları veriyor: 0 ⭢ 1 :2
, 0 ⭢ 4 :10
, 1 ⭢ 3 :1
, 2 ⭢ 4 :1
, 3 ⭢ 2 :1
ve 3 ⭢ 4 :3
.
Yolları yazarken önceki yazıda kullandığım “from” ⭢ “to” :”distance” formatını kullanıyorum.
- Yazarın notu
Ayrıca, köşe 0
giden hiçbir kenar olmadığını da biliyoruz, bu nedenle k = 0
için bir algoritma yürütmek anlamsızdır. Ayrıca, köşe 0
köşe 1
giden tek bir kenar ( 0 ⭢ 1
) olduğu da açıktır, bu da tüm i != 0
( i
burada "from"dur) yürütülmesini oldukça anlamsız hale getirir ve köşe 1
2
ve 4
ile kenarları olduğundan, 2
veya 4
olmayan herhangi bir j
için algoritma yürütmek de anlamsızdır ( j
burada "to"dur).
Bu nedenle ilk adımımız k = 1
, i = 0
ve j = 2,4
için bir algoritma yürütmek olacak.
Adım | Yol | Yorum |
---|---|---|
1 | 0 ⭢ 1 ⭢ 2 | Yol bulundu. Mesafe = 3 (hiçbir şey değildi) |
2 | 0 ⭢ 1 ⭢ 4 | Yol bulundu. Mesafe = 8 (önceden 10 idi). |
İki yol bulduk: yeni bir yol ( 0 ⭢ 1 ⭢ 2
) ve bir kısayol ( 0 ⭢ 1 ⭢ 4
). Her ikisi de 1
tepe noktasından geçer. Bu bilgiyi ( 1
2
ve 4
ulaştığımız gerçeğini) hemen bir yerde saklamazsak, sonsuza dek kaybolacaktır (ve bu istediğimizin tam tersidir).
Peki ne yapmalıyız? Matris W
yeni değerlerle güncelleyeceğiz (bkz. Resim 3a) ve ayrıca k
değerini ( k = 1
olan) matris W
ile aynı boyutta ancak NO_EDGE
değerleriyle başlatılmış yeni bir matris R
R[0,2]
ve R[0,4]
hücrelerine depolayacağız (bkz. Resim 3b).
Şimdilik, R
matrisinin tam olarak ne olduğuna odaklanmayın. Devam edelim ve algoritmayı bir sonraki k = 2
için çalıştıralım.
Burada, k = 1,
ancak bu sefer G
grafiği yerine W
matrisini kullanacağız. Özellikle 2. sütun ve 2. satırdaki W
matrisine bakın (Resim 4).
Burada, köşe 2
ve köşe 1
(sütun #2) köşe 2'ye giden iki yol ve köşe 2
köşe 3
ve köşe 4
(satır #2) 0
iki yol olduğunu görebilirsiniz. Bunu bilerek, algoritmayı yalnızca k = 2
, i = 0,1
ve j = 3,4
kombinasyonları için yürütmek mantıklıdır.
Adım | Yol | Yorum |
---|---|---|
1 | 0 ⭢ 2 ⭢ 3 | Yol bulundu. Mesafe = 4 (hiçbir şey değildi) |
2 | 0 ⭢ 2 ⭢ 4 | Yol bulundu. Mesafe = 6 (8 idi) |
3 | 1 ⭢ 2 ⭢ 3 | Yol bulundu. Mesafe = 2 (hiçbir şey değildi) |
4 | 1 ⭢ 2 ⭢ 4 | Yol bulundu. Mesafe = 4 (6 idi) |
Daha önce yaptığımız gibi, W[0,3]
, W[0,4]
, W[1,3]
, W[1,4]
hücrelerini yeni mesafelerle ve R[0,3]
, R[0,4]
, R[1,3]
ve R[1,4]
hücrelerini k = 2
ile güncelliyoruz (bkz. Resim 5).
İşlenecek sadece k = 3
kaldı (çünkü grafikte 4
köşeden başka herhangi bir köşeye giden kenar yok).
Tekrar W
matrisine bakalım (Resim 6).
W
matrisine göre, 0
, 1
ve 2
nolu köşelerden 3
köşeye giden üç yol vardır (sütun #3) ve 3
nolu köşeden 4
nolu köşeye giden bir yol vardır (satır #3). Bu nedenle, işlememiz gereken şu yollara sahibiz:
Adım | Yol | Yorum |
---|---|---|
1 | 0 ⭢ 3 ⭢ 4 | Yol bulundu. Mesafe = 5 (6 idi) |
2 | 1 ⭢ 3 ⭢ 4 | Yol bulundu. Mesafe = 3 (4 idi) |
3 | 2 ⭢ 3 ⭢ 4 | Yol bulundu. Mesafe = 2 (3 idi) |
Bu, algoritmanın son yinelemesiydi. Geriye kalan tek şey, W[0,4]
, W[1,4]
, W[2,4]
hücrelerini yeni mesafelerle güncellemek ve R[0,4]
, R[1,4]
, R[2,4]
hücrelerini 3
olarak ayarlamak.
Önceki yazıdan bildiğimiz gibi, W
matrisi artık G
grafiğindeki tüm en kısa yol çiftlerini içeriyor. Peki R
matrisinin içinde ne saklanıyor?
Her seferinde yeni bir en kısa yol bulduğumuzda, matris R
karşılık gelen hücresini k
geçerli değeriyle güncelliyorduk. İlk başta, bu eylem gizemli görünebilir ancak çok basit bir anlamı vardır: hedef tepe noktasına ulaştığımız tepe noktasını saklıyorduk: i ⭢ k ⭢ j
(burada j
doğrudan k
ulaşıyoruz).
Bu an kritiktir. Çünkü geldiğimiz bir tepe noktasını bilmek, tepe noktası j
("nereye") tepe noktası i
("nereye") doğru geriye doğru (bir ıstakoz gibi) hareket ederek rotayı yeniden inşa etmemize olanak tanır.
İşte i
j
giden rotayı yeniden oluşturmak için algoritmanın metinsel açıklaması:
X
hazırlayın.R[i,j]
hücresinden z
değerini okuyarak başlayalım.z
NO_EDGE
ise, i
j
giden rota bulunmuştur ve 7. adıma geçmeliyiz.X
önüne z
ekleyin.R[i,z]
hücresinin değerini z
oku.i
ekleyin.j
X
ekle.X
i
j
giden yolu içeriyor.
- Yazarın notu
algorithm RebuildRoute(i, j, R) x = array() z = R[i,j] while (z ne NO_EDGE) do x.prepend(z) z = R[i,z] end while x.prepend(i) x.append(j) return x end algorithm
Bunu G
grafiğimizde deneyelim ve 0
köşeden 4
köşeye bir rota yeniden oluşturalım (bkz. Resim 8).
R[0,4]
değerini okuyarak başlıyoruz, bu da 3
sonucunu veriyor. Algoritmaya göre bu, rotanın 3
köşeden (MAVİ ile gösterilmiştir) 4
köşeye gittiği anlamına geliyor.
R[0,4]
değeri NO_EDGE
olmadığından, devam edip R[0,3]
değerini okuyoruz ve 2
sonucunu elde ediyoruz (YEŞİL renkle gösterilmiştir).
Yine, R[0,3]
değeri NO_EDGE
olmadığından, devam edip R[0,2]
değerini okuyoruz ve 1
sonucunu elde ediyoruz (KIRMIZI ile gösterilmiştir).
Son olarak, NO_EDGE
değeriyle sonuçlanan R[0,1]
değerini okuruz, yani rotaya katkıda bulunan 0
ve 4
dışında başka tepe noktası yoktur. Bu nedenle, ortaya çıkan rota şudur: 0 ⭢ 1 ⭢ 2 ⭢ 3 ⭢ 4
ki grafiğe bakarsanız, gerçekten de 0
tepe noktasından 4
tepe noktasına en kısa yola karşılık gelir.
R
matrisinden okuduğumuz tüm verilerin aynı yola ait olduğundan nasıl emin olabiliriz?
- Düşünceli okuyucu
Çok iyi bir soru. Eminiz çünkü W
matrisindeki en kısa yol değerini güncellediğimizde R
matrisini yeni bir değerle güncelliyoruz. Yani sonunda R
matrisinin her satırı en kısa yollarla ilgili veri içeriyor. Ne daha fazla, ne daha az.
Önceki yazımızda Floyd-Warshall algoritmasının orijinal versiyonunu uygulamanın yanı sıra çeşitli optimizasyonları da entegre ettik: seyrek grafiklere destek, paralellik ve vektörizasyon ve sonunda bunların hepsini birleştirdik.
Fonksiyon imzasını R
matrisini ayrı bir parametre olarak içerecek şekilde genişletin – int[] routes
.
En kısa yol her değiştiğinde routes
k kaydedin (satırlar: 2 ve 14).
public void BaselineWithRoutes( int[] matrix, int[] routes, int sz) { for (var k = 0; k < sz; ++k) { for (var i = 0; i < sz; ++i) { for (var j = 0; j < sz; ++j) { var distance = matrix[i * sz + k] + matrix[k * sz + j]; if (matrix[i * sz + j] > distance) { matrix[i * sz + j] = distance; routes[i * sz + j] = k; } } } } }
Fonksiyon imzasını, R
matrisini ayrı bir parametre olarak içerecek şekilde genişletin – int[] routes
.
Her yinelemede, k
değerinden oluşan yeni bir vektör başlatın (satır: 6).
En kısa yol her değiştiğinde routes
k
değerli vektör kaydedin (satırlar: 31-32).
public void SpartialVectorOptimisationsWithRoutes( int[] matrix, int[] routes, int sz) { for (var k = 0; k < sz; ++k) { var k_vec = new Vector<int>(k); for (var i = 0; i < sz; ++i) { if (matrix[i * sz + k] == Constants.NO_EDGE) { continue; } var ik_vec = new Vector<int>(matrix[i * sz + k]); var j = 0; for (; j < sz - Vector<int>.Count; j += Vector<int>.Count) { var ij_vec = new Vector<int>(matrix, i * sz + j); var ikj_vec = new Vector<int>(matrix, k * sz + j) + ik_vec; var lt_vec = Vector.LessThan(ij_vec, ikj_vec); if (lt_vec == new Vector<int>(-1)) { continue; } var r_vec = Vector.ConditionalSelect(lt_vec, ij_vec, ikj_vec); r_vec.CopyTo(matrix, i * sz + j); var ro_vec = new Vector<int>(routes, i * sz + j); var rk_vec = Vector.ConditionalSelect(lt_vec, ro_vec, k_vec); rk_vec.CopyTo(routes, i * sz + j); } for (; j < sz; ++j) { var distance = matrix[i * sz + k] + matrix[k * sz + j]; if (matrix[i * sz + j] > distance) { matrix[i * sz + j] = distance; routes[i * sz + j] = k; } } } } }
Küçük bir hatırlatma – Vector.ConditionalSelect
işlemi, değerlerin giriş vektörlerinin iki karşılık gelen değerinden küçük olanına eşit olduğu yeni bir vektör döndürür, yani, lt_vec
vektörünün değeri -1
eşitse, işlem ij_vec
bir değer seçer, aksi takdirde k_vec
bir değer seçer.
- Yazarın notu
Tüm deneyler, önceki yazıda kullanılan önceden tanımlanmış grafik kümesi üzerinde gerçekleştirildi: 300, 600, 1200, 2400 ve 4800 tepe noktası.
Kaynak kodu ve deneysel grafikler GitHub'daki depoda mevcuttur. Deneysel grafikler Data/Sparse-Graphs.zip
dizininde bulunabilir. Bu gönderideki tüm kıyaslamalar dosyasında uygulanmıştır.
Aşağıda, Baseline
ve BaselineWithRoutes
yöntemlerinin algoritmanın orijinal versiyonuna, SpartialVectorOptimisations
ve SpartialVectorOptimisationsWithRoutes
yöntemlerinin ise algoritmanın vektörleştirilmiş (seyrek grafikler için destek sağlayan) versiyonuna karşılık geldiği kıyaslama sonuçları yer almaktadır.
Yöntem | Boyut | Ortalama (ms) | Hata (ms) | Standart Sapma (ms) |
---|---|---|---|---|
Temel çizgi | 300 | 40.233 | 0,0572 | 0,0477 |
Rotalarla Temel Hat | 300 | 40.349 | 0,1284 | 0,1201 |
SpartialVektörOptimizasyonları | 300 | 4.472 | 0,0168 | 0,0140 |
SpartialVectorOptimizationsWithRoutes | 300 | 4.517 | 0,0218 | 0,0193 |
Temel çizgi | 600 | 324.637 | 5.6161 | 4.6897 |
Rotalarla Temel Hat | 600 | 321.173 | 1.4339 | 1.2711 |
SpartialVektörOptimizasyonları | 600 | 32.133 | 0.2151 | 0,1679 |
SpartialVectorOptimizationsWithRoutes | 600 | 34.646 | 0,1286 | 0,1073 |
Temel çizgi | 1200 | 2.656.024 | 6.9640 | 5.8153 |
Rotalarla Temel Hat | 1200 | 2.657.883 | 8.8814 | 7.4164 |
SpartialVektörOptimizasyonları | 1200 | 361.435 | 2,5877 | 2.2940 |
SpartialVectorOptimizationsWithRoutes | 1200 | 381.625 | 3.6975 | 3.2777 |
Temel çizgi | 2400 | 21.059,519 | 38.2291 | 33.8891 |
Rotalarla Temel Hat | 2400 | 20.954.852 | 56.4719 | 50.0609 |
SpartialVektörOptimizasyonları | 2400 | 3.029.488 | 12.1528 | 11.3677 |
SpartialVectorOptimizationsWithRoutes | 2400 | 3.079.006 | 8.6125 | 7.1918 |
Temel çizgi | 4800 | 164.869,803 | 547.6675 | 427.5828 |
Rotalarla Temel Hat | 4800 | 184.305.980 | 210.9535 | 164.6986 |
SpartialVektörOptimizasyonları | 4800 | 21.882,379 | 200.2820 | 177.5448 |
SpartialVectorOptimizationsWithRoutes | 4800 | 21.004.612 | 79.8752 | 70.8073 |
Bu çok kafa karıştırıcı görünüyor (ve eğer öyleyse – ölçümleri etkileyebilecek zor şeylerin ne olduğunu daha iyi anlamak için ve ile bu dinlemenizi şiddetle tavsiye ederim). Buradaki en iyi çıkarımım, "kafa karıştırıcı" davranışın harika CPU önbellek performansından kaynaklandığıdır (çünkü grafikler önbellekleri doyurmak için yeterince büyük değildir). Kısmen, bu teori en büyük grafikte önemli bir bozulma görebildiğimiz " kalın " örneğe dayanmaktadır. Ancak, bunu doğrulamadım.
Genel olarak, kıyaslama, aşırı yüksek performans senaryolarından ve büyük grafiklerden bahsetmiyorsak, varsayılan olarak rota ezberlemeyi her iki algoritmaya entegre etmenin sorun olmadığını gösteriyor (ancak unutmayın, bir yerine iki matris W
ve R
ayırmamız gerektiğinden bellek tüketimini iki katına çıkaracaktır).
C# dilinde rota yeniden oluşturma algoritmalarının uygulanması basittir ve daha önce gösterilen sözde kodu neredeyse tamamen yansıtır (dinamik diziyi temsil etmek için LinkedList<T>
kullanırız):
public static IEnumerable<int> RebuildWithLinkedList( int[] routes, int sz, int i, int j) { var x = new LinkedList<int>(); var z = routes[i * sz + j]; while (z != Constants.NO_EDGE) { x.AddFirst(z); z = routes[i * sz + z]; } x.AddFirst(i); x.AddLast(j); return x; }
Yukarıdaki algoritma mükemmel değil ve kesinlikle geliştirilebilir. Yapabileceğimiz en basit geliştirme, sz
boyutunda bir diziyi önceden tahsis etmek ve ters sırada doldurmaktır:
public static IEnumerable<int> RebuildWithArray( int[] routes, int sz, int i, int j) { var x = new int[sz]; var y = sz - 1; // Fill array from the end x[y--] = j; var z = routes[i * sz + j]; while (z != Constants.NO_EDGE) { x[y--] = z; z = routes[i * sz + z]; } x[y] = i; // Create an array segment from 'y' to the end of the array // // This is required because 'sz' (and therefore length of the array x) // equals to the number of vertices in the graph // // So, in cases when route doesn't span through // all vertices - we need return a segment of the array return new ArraySegment<int>(x, y, sz - y); }
Bu "optimizasyon" tahsis sayısını bire düşürse de, algoritmayı bir öncekinden "daha hızlı" yapmaz veya "daha az bellek tahsis etmez". Buradaki nokta, i
j
sıralanmış bir rotaya ihtiyacımız varsa, R
matrisinden aldığımız verileri her zaman "tersine çevirmemiz" gerekeceğidir ve bundan kaçmanın bir yolu yoktur.
public static IEnumerable<int> RebuildWithReverseYield( int[] routes, int sz, int i, int j) { yield return j; var z = routes[i * sz + j]; while (z != Constants.NO_EDGE) { yield return z; z = routes[i * sz + z]; } yield return i; }
Bu kodun nasıl "değiştirilebileceği" veya "geliştirilebileceği" konusunda çok daha fazla varyant olabilir. Buradaki önemli an, şunu hatırlamaktır: Herhangi bir "değişikliğin" bellek veya CPU döngüleri açısından dezavantajları vardır.
Tüm rota algoritmalarının uygulamalarını GitHub'daki dosyasında bulabilirsiniz.
Bu yazıda, Floyd-Warshall algoritmasının ardındaki teoriye derinlemesine bir dalış yaptık ve bunu en kısa yollar olan "rotaları" ezberleme olasılığıyla genişlettik. Ardından, önceki yazıdan C# uygulamalarını (orijinal ve vektörleştirilmiş) güncelledik. Sonunda, "rotaları" yeniden oluşturmak için algoritmanın birkaç sürümünü uyguladık.