W poprzednim poście zobaczyliśmy, jak rozwiązać problem najkrótszej ścieżki dla wszystkich par, używając . Zbadaliśmy również kilka sposobów na poprawę wydajności algorytmu, używając paralelizmu i wektoryzacji.
Zaczynamy od przedstawienia grafu ( G
) o rozmiarze N
jako macierzy ( W
) o rozmiarze N x N
, gdzie każda komórka W[i,j]
zawiera wagę krawędzi od wierzchołka i
do wierzchołka j
(patrz Rysunek 1). W przypadkach, gdy nie ma krawędzi między wierzchołkami, komórka jest ustawiana na specjalną wartość NO_EDGE
(pokazaną jako ciemne komórki na Rysunku 1).
Od tej chwili mówimy – W[i,j]
zawiera odległość między wierzchołkami i
oraz j
.
Następnie bierzemy wierzchołek k
i przechodzimy przez wszystkie pary wierzchołków W[i,j]
sprawdzając czy odległość i ⭢ k ⭢ j
jest mniejsza niż aktualnie znana odległość między i
i j
.
Najmniejsza wartość jest przechowywana w W[i,j]
, a krok nr 3 jest powtarzany dla następnego k
aż wszystkie wierzchołki G
zostaną użyte jako k
.
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
Po wykonaniu tej operacji każda komórka W[i,j]
macierzy W
będzie zawierała odległość najkrótszej ścieżki między wierzchołkami i
i j
lub wartość NO_EDGE
, jeśli nie ma między nimi ścieżki.
Istotą algorytmu Floyda-Warshalla jest przedział o znanej odległości W[i,j]
z odległością potencjalnie nowej ścieżki z i
do j
przez wierzchołek pośredni k
.
Początkowo znamy wszystkie krawędzie grafu, co daje nam następujące ścieżki: 0 ⭢ 1 :2
, 0 ⭢ 4 :10
, 1 ⭢ 3 :1
, 2 ⭢ 4 :1
, 3 ⭢ 2 :1
i 3 ⭢ 4 :3
.
Do zapisywania ścieżek używam formatu „od” ⭢ „do” :”odległość” z poprzedniego posta .
- Uwaga autora
Wiemy również, że nie ma krawędzi prowadzących do wierzchołka 0
, więc wykonywanie algorytmu dla k = 0
nie ma sensu. Oczywiste jest również, że istnieje pojedyncza krawędź ( 0 ⭢ 1
) prowadząca od wierzchołka 0
do wierzchołka 1
, co również sprawia, że wykonywanie wszystkich i != 0
( i
jest tutaj „z”) jest całkowicie bezsensowne, a ponieważ wierzchołek 1
ma krawędzie z 2
i 4
, nie ma również sensu wykonywanie algorytmów dla dowolnego j
, które nie jest 2
lub 4
( j
jest tutaj „do”).
Dlatego naszym pierwszym krokiem będzie wykonanie algorytmu dla k = 1
, i = 0
i j = 2,4
.
Krok | Ścieżka | Komentarz |
---|---|---|
1 | 0 ⭢ 1 ⭢ 2 | Znaleziono ścieżkę. Odległość = 3 (nie było nic) |
2 | 0 ⭢ 1 ⭢ 4 | Znaleziono ścieżkę. Odległość = 8 (było 10). |
Znaleźliśmy dwie ścieżki: nową ścieżkę ( 0 ⭢ 1 ⭢ 2
) i skrót ( 0 ⭢ 1 ⭢ 4
). Obie przechodzą przez wierzchołek 1
Jeśli nie zapiszemy tej informacji (faktu, że dotarliśmy do 2
i 4
przez 1
) gdzieś teraz, zostanie ona utracona na zawsze (a to jest całkowite przeciwieństwo tego, czego chcemy).
Co więc powinniśmy zrobić? Zaktualizujemy macierz W
nowymi wartościami (patrz Rysunek 3a) i zapiszemy wartość k
( k = 1
) w komórkach R[0,2]
i R[0,4]
nowej macierzy R
o takim samym rozmiarze jak macierz W
ale zainicjowanej wartościami NO_EDGE
(patrz Rysunek 3b).
Na razie nie skupiajmy się na tym, czym dokładnie jest macierz R
Po prostu kontynuujmy i wykonajmy algorytm dla następnego k = 2
.
Tutaj wykonamy tę samą analizę (aby zrozumieć, jakie kombinacje mają sens wykonać), jaką przeprowadziliśmy dla k = 1,
ale tym razem użyjemy macierzy W
zamiast wykresu G
Przyjrzyjmy się macierzy W
, szczególnie w kolumnie nr 2 i wierszu nr 2 (Rysunek 4).
Tutaj widać, że istnieją dwie ścieżki do wierzchołka 2
z wierzchołka 0
i z wierzchołka 1
(kolumna nr 2) oraz dwie ścieżki z wierzchołka 2
do wierzchołka 3
i do wierzchołka 4
(wiersz nr 2). Wiedząc o tym, ma sens wykonanie algorytmu tylko dla kombinacji k = 2
, i = 0,1
i j = 3,4
.
Krok | Ścieżka | Komentarz |
---|---|---|
1 | 0 ⭢ 2 ⭢ 3 | Znaleziono ścieżkę. Odległość = 4 (było nic) |
2 | 0 ⭢ 2 ⭢ 4 | Znaleziono ścieżkę. Odległość = 6 (było 8) |
3 | 1 ⭢ 2 ⭢ 3 | Znaleziono ścieżkę. Odległość = 2 (nie było nic) |
4 | 1 ⭢ 2 ⭢ 4 | Znaleziono ścieżkę. Odległość = 4 (było 6) |
Jak już zrobiliśmy wcześniej, aktualizujemy komórki W[0,3]
, W[0,4]
, W[1,3]
, W[1,4]
o nowe odległości, a komórki R[0,3]
, R[0,4]
, R[1,3]
i R[1,4]
o k = 2
(patrz Rysunek 5).
Pozostało tylko k = 3
do przetworzenia (ponieważ nie ma krawędzi prowadzących z wierzchołka 4
do żadnego innego wierzchołka na grafie).
Przyjrzyjmy się ponownie macierzy W
(rysunek 6).
Zgodnie z macierzą W
istnieją trzy ścieżki do wierzchołka 3
z wierzchołków 0
, 1
i 2
(kolumna nr 3), a istnieje jedna ścieżka z wierzchołka 3
do wierzchołka 4
(wiersz nr 3). Dlatego mamy następujące ścieżki do przetworzenia:
Krok | Ścieżka | Komentarz |
---|---|---|
1 | 0 ⭢ 3 ⭢ 4 | Znaleziono ścieżkę. Odległość = 5 (było 6) |
2 | 1 ⭢ 3 ⭢ 4 | Znaleziono ścieżkę. Odległość = 3 (było 4) |
3 | 2 ⭢ 3 ⭢ 4 | Znaleziono ścieżkę. Odległość = 2 (było 3) |
Była to ostatnia iteracja algorytmu. Pozostało jedynie zaktualizować komórki W[0,4]
, W[1,4]
, W[2,4]
nowymi odległościami i ustawić komórki R[0,4]
, R[1,4]
, R[2,4]
na 3
.
Jak wiemy z poprzedniego wpisu , macierz W
zawiera teraz wszystkie pary najkrótszych ścieżek w grafie G
Ale co jest przechowywane w macierzy R
?
Za każdym razem, gdy znaleźliśmy nową najkrótszą ścieżkę, aktualizowaliśmy odpowiadającą jej komórkę macierzy R
bieżącą wartością k
. Podczas gdy na początku ta czynność może wydawać się tajemnicza, ma ona bardzo proste znaczenie – przechowywaliśmy wierzchołek, z którego dotarliśmy do wierzchołka docelowego: i ⭢ k ⭢ j
(tutaj docieramy do j
bezpośrednio z k
).
Ten moment jest kluczowy. Ponieważ znajomość wierzchołka, z którego przyszliśmy, pozwala nam na odbudowanie trasy poprzez cofanie się (jak homar) od wierzchołka j
(„do”) do wierzchołka i
(„od”).
Poniżej znajduje się opis tekstowy algorytmu przebudowującego trasę z i
do j
:
X
z
z komórki R[i,j]
.z
jest NO_EDGE
, to trasa z i
do j
została znaleziona i możemy przejść do kroku nr 7.z
na początku tablicy dynamicznej X
R[i,z]
do z
.i
do X.j
do X
X
zawiera trasę z i
do j
.
- Uwaga autora
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
Wypróbujmy to na naszym grafie G
i odbudujmy trasę od wierzchołka 0
do wierzchołka 4
(patrz rysunek 8).
Zaczynamy od odczytania wartości z R[0,4]
, co daje wynik 3
Zgodnie z algorytmem oznacza to, że trasa prowadzi do wierzchołka 4
z wierzchołka 3
(zaznaczonego na NIEBIESKO).
Ponieważ wartość R[0,4]
nie jest równa NO_EDGE
, kontynuujemy i odczytujemy wartość R[0,3]
co daje wynik 2
(zaznaczony na ZIELONO).
Ponownie, ponieważ wartość R[0,3]
nie jest NO_EDGE
, kontynuujemy i odczytujemy wartość R[0,2]
co daje wynik 1
(zaznaczony na CZERWONO).
Na koniec odczytujemy wartość R[0,1]
, co skutkuje wartością NO_EDGE
, co oznacza, że nie ma więcej wierzchołków poza 0
i 4
, które przyczyniają się do trasy. Dlatego wynikowa trasa to: 0 ⭢ 1 ⭢ 2 ⭢ 3 ⭢ 4
, co jeśli spojrzysz na graf, rzeczywiście odpowiada najkrótszej ścieżce od wierzchołka 0
do wierzchołka 4
.
Jak możemy mieć pewność, że wszystkie dane odczytane z macierzy R
należą do tej samej ścieżki?
- Czytelnik myślący
To bardzo dobre pytanie. Jesteśmy pewni, ponieważ aktualizujemy macierz R
nową wartością, gdy aktualizujemy wartość najkrótszej ścieżki w macierzy W
Tak więc ostatecznie każdy wiersz macierzy R
zawiera dane związane z najkrótszymi ścieżkami. Nic więcej, nic mniej.
W poprzednim poście , oprócz zaimplementowania oryginalnej wersji algorytmu Floyda-Warshalla, zintegrowaliśmy także różne optymalizacje: obsługę grafów rzadkich, paralelizmu i wektoryzacji, a na końcu wszystko to połączyliśmy.
Rozszerz sygnaturę funkcji, aby uwzględnić macierz R
jako oddzielny parametr – int[] routes
.
Zapisz k w routes
za każdym razem, gdy najkrótsza ścieżka zostanie zmieniona (linie: 2 i 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; } } } } }
Rozszerz sygnaturę funkcji, aby uwzględnić macierz R
jako oddzielny parametr – int[] routes
.
W każdej iteracji zainicjuj nowy wektor wartości k
(linia: 6).
Zapisz wektor wartości k
do routes
za każdym razem, gdy zmieni się najkrótsza ścieżka (wiersze: 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; } } } } }
Małe przypomnienie – operacja Vector.ConditionalSelect
zwraca nowy wektor, w którym wartości są równe mniejszej z dwóch odpowiadających sobie wartości wektorów wejściowych, tj. jeśli wartość wektora lt_vec
jest równa -1
, operacja wybiera wartość z ij_vec
, w przeciwnym razie wybiera wartość z k_vec
.
- Uwaga autora
Wszystkie eksperymenty przeprowadzono na predefiniowanym zestawie grafów użytym w poprzednim poście : 300, 600, 1200, 2400 i 4800 wierzchołków.
Kod źródłowy i wykresy eksperymentalne są dostępne w repozytorium na GitHub. Wykresy eksperymentalne można znaleźć w katalogu Data/Sparse-Graphs.zip
. Wszystkie testy porównawcze w tym poście są zaimplementowane w pliku .
Poniżej znajdują się wyniki testów porównawczych, w których metody Baseline
i BaselineWithRoutes
odpowiadają oryginalnej wersji algorytmu, a metody SpartialVectorOptimisations
i SpartialVectorOptimisationsWithRoutes
odpowiadają wektoryzowanej (z obsługą grafów rzadkich) wersji algorytmu.
Metoda | Rozmiar | Średnia (ms) | Błąd (ms) | Odchylenie standardowe (ms) |
---|---|---|---|---|
Linia bazowa | 300 | 40.233 | 0,0572 | 0,0477 |
Linia bazowa z trasami | 300 | 40.349 | 0,1284 | 0,1201 |
SpartialVectorOptimisations | 300 | 4.472 | 0,0168 | 0,0140 |
Częściowe optymalizacje wektorowe z trasami | 300 | 4.517 | 0,0218 | 0,0193 |
Linia bazowa | 600 | 324.637 | 5.6161 | 4.6897 |
Linia bazowa z trasami | 600 | 321.173 | 1,4339 | 1.2711 |
SpartialVectorOptimisations | 600 | 32.133 | 0,2151 | 0,1679 |
Częściowe optymalizacje wektorowe z trasami | 600 | 34.646 | 0,1286 | 0,1073 |
Linia bazowa | 1200 | 2,656.024 | 6.9640 | 5.8153 |
Linia bazowa z trasami | 1200 | 2,657.883 | 8.8814 | 7.4164 |
SpartialVectorOptimisations | 1200 | 361.435 | 2,5877 | 2.2940 |
Częściowe optymalizacje wektorowe z trasami | 1200 | 381.625 | 3,6975 | 3.2777 |
Linia bazowa | 2400 | 21 059,519 | 38.2291 | 33.8891 |
Linia bazowa z trasami | 2400 | 20 954,852 | 56.4719 | 50.0609 |
SpartialVectorOptimisations | 2400 | 3,029.488 | 12.1528 | 11.3677 |
Częściowe optymalizacje wektorowe z trasami | 2400 | 3,079.006 | 8.6125 | 7.1918 |
Linia bazowa | 4800 | 164,869.803 | 547.6675 | 427.5828 |
Linia bazowa z trasami | 4800 | 184,305.980 | 210.9535 | 164.6986 |
SpartialVectorOptimisations | 4800 | 21,882.379 | 200.2820 | 177.5448 |
Częściowe optymalizacje wektorowe z trasami | 4800 | 21,004.612 | 79.8752 | 70.8073 |
Wygląda to bardzo myląco (i jeśli tak jest – zdecydowanie radzę posłuchać tego z i , aby lepiej zrozumieć, jakie trudne rzeczy mogą wpływać na pomiary). Moim zdaniem „mylące” zachowanie jest spowodowane przez świetną wydajność pamięci podręcznej procesora (ponieważ wykresy nie są wystarczająco duże, aby nasycić pamięci podręczne). Częściowo teoria ta opiera się na „ pogrubionej ” próbce, gdzie możemy zobaczyć znaczną degradację na największym wykresie. Jednak jej nie zweryfikowałem.
Ogólnie rzecz biorąc, testy pokazują, że jeśli nie mamy do czynienia ze scenariuszami wymagającymi ekstremalnie wysokiej wydajności i ogromnymi wykresami, to nie ma problemu z domyślnym zintegrowaniem zapamiętywania tras z obydwoma algorytmami (należy jednak pamiętać, że spowoduje to podwojenie zużycia pamięci, ponieważ będziemy musieli przydzielić dwie macierze W
i R
zamiast jednej).
Implementacja algorytmów przebudowy tras w języku C# jest prosta i niemal całkowicie odzwierciedla wcześniej zaprezentowany pseudokod (do reprezentacji tablicy dynamicznej używamy LinkedList<T>
):
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; }
Powyższy algorytm nie jest doskonały i zdecydowanie można go ulepszyć. Najprostszym ulepszeniem, jakie możemy wprowadzić, jest wstępne przydzielenie tablicy o rozmiarze sz
i wypełnienie jej w odwrotnej kolejności:
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); }
Chociaż ta „optymalizacja” zmniejsza liczbę alokacji do jednej, niekoniecznie sprawia, że algorytm jest „szybszy” lub „alokuje mniej pamięci” niż poprzedni. Chodzi o to, że jeśli potrzebujemy trasy uporządkowanej od i
do j
, zawsze będziemy musieli „odwrócić” dane pobierane z macierzy R
i nie ma sposobu, aby tego uniknąć.
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; }
Istnieje wiele wariantów tego, jak ten kod może zostać „zmieniony” lub „ulepszony”. Ważne jest, aby pamiętać, że każda „zmiana” ma swoje wady pod względem pamięci lub cykli procesora.
Implementacje wszystkich algorytmów trasowania można znaleźć w pliku na platformie GitHub .
W tym poście zagłębiliśmy się w teorię algorytmu Floyda-Warshalla i rozszerzyliśmy ją o możliwość zapamiętywania najkrótszych ścieżek „tras”. Następnie zaktualizowaliśmy implementacje C# (oryginalne i wektorowe) z poprzedniego posta . Na koniec zaimplementowaliśmy kilka wersji algorytmu, aby przebudować „trasy”.