Skrypt, czy Nie-SkryptProgramownie A.I. Gier

Skrypt, czy Nie-Skrypt

Ta część koncentruje się na zadziwiająco użytecznej matematycznej abstrakcji zwanej grafem. Będziesz często używać wykresów w sztucznej inteligencji gry. W rzeczywistości już je widziałeś: diagramy bąbelkowe przejścia stanu z części są rodzajem wykresu. Grafiki i ich młodsi bracia, drzewa, są stale używane przez programistów AI. Można ich używać do wielu różnych rzeczy - od umożliwienia agentom gry efektywnego podróżowania między dwoma punktami, do decydowania, co zbudować w grze strategicznej i rozwiązywania zagadek. Pierwsza część zostanie poświęcona wprowadzeniu cię do różnych rodzajów wykresów i związanej z nimi terminologii. Dowiesz się, czym są wykresy, jak można ich używać i jak je efektywnie kodować. W pozostałej części rozdziału opisano szczegółowo wiele algorytmów wyszukiwania dostępnych w celu wykorzystania pełnej mocy grafów.

Wykresy

Podczas opracowywania sztucznej inteligencji do gier jednym z najczęstszych zastosowań grafów jest reprezentowanie sieci ścieżek, którymi agent może się poruszać po swoim środowisku. Kiedy się tego nauczyłem, byłem zdezorientowany, ponieważ przez całe życie znałem wykres, który wygląda jak wykresy, których nauczyłem się rysować w szkole



Zawsze uważałem, że wykresy są przydatne do wizualizacji wzlotów i upadków niektórych włąściwości, takich jak wykresy temperatur pokazane w telewizyjnych prognozach pogody lub danych o sprzedaży, i tym podobne, więc zastanawiałem się, jak taki wykres mógłby ewentualnie może być używany do reprezentowania ścieżek tnących wokół ścian i przeszkód w środowisku gry. Jeśli nigdy nie studiowałeś teorii grafów, to prawdopodobnie tak myślisz o grafach. Myślę, że tak właśnie jeste



To ten sam wykres, ale zmieniłem etykietowanie osi, aby reprezentować współrzędne x i y przestrzeni kartezjańskiej, dodając kilka kosmetycznych ozdób, dzięki czemu teraz reprezentuje ścieżkę wijącą się w pobliżu rzeki. W rzeczywistości wygląda na coś, co przeciętny człowiek na ulicy nazwałby mapą. Rzeczywiście, cały obraz jest mapą, ale szereg punktów pośrednich i łącząca je ścieżka są reprezentowane przez bardzo prosty wykres. Teraz zdaję sobie sprawę, że niektórzy z was będą myśleć, że to nie jest wielka sprawa, ale wierzę, że dla wielu ta subtelna zmiana perspektywy może być objawieniem. To z pewnością było dla mnie. W terminologii graficznej punkty drogi nazywane są węzłami (lub czasami wektorami), a ścieżki łączące je nazywane są krawędziami (lub czasami łukami). Rysunek poniższy pokazuje kilka innych przykładów wykresów. Jak widać, mogą one przyjmować szeroki zakres konfiguracji.



W szerszym kontekście wykres jest symboliczną reprezentacją sieci i chociaż węzły i krawędzie mogą reprezentować relację przestrzenną, taką jak w omawianym wcześniej przykładzie, nie musi tak być. Wykresy mogą służyć do reprezentowania wszelkiego rodzaju sieci, od sieci telefonicznych i sieci WWW po obwody elektroniczne i sztuczne sieci neuronowe.

UWAG.A Wykresy mogą być podłączone lub niepołączone. Wykres uważa się za połączony, gdy możliwe jest prześledzenie ścieżki między poszczególnymi węzłami. Wykresy A, B, D i E na powyższym rysunku są przykładami połączonych wykresów. Wykresy C i F są przykładami niepowiązanych wykresów.

Bardziej formalny opis

Wykres G można formalnie zdefiniować jako zbiór węzłów lub wierzchołków, N, łączący się z zestawem krawędzi, E. Często można to znaleźć jako:

G = {N, E} (5.1)

Jeśli każdy węzeł na wykresie jest oznaczony liczbą całkowitą z zakresu od 0 do (N-1), krawędź może być teraz określana przez węzły, które łączy, na przykład 3-5 lub 19-7. wszelkie wykresy mają wyważone krawędzie - zawierają informacje o koszcie przejścia z jednego węzła do drugiego. Na przykład na wykresie pokazanym wcześniej na rysunku koszt przejścia krawędzi to odległość między dwoma połączonymi węzłami. Na wykresie przedstawiającym drzewo technologiczne gry RTS przypominającej Warcraft krawędzie mogą wskazywać zasoby potrzebne do ulepszenia każdej jednostki.

UWAGA. Chociaż wykres może mieć wiele połączeń z tym samym węzłem lub nawet połączenia zapętlone łączące węzeł z samym sobą, funkcje te rzadko są niezbędne do sztucznej inteligencji w grze i nie będą omawiane na następnych stronach.

Drzewa

Większość programistów zna strukturę danych drzewa. Drzewa są szeroko stosowane we wszystkich dyscyplinach programowania. Jednak możesz nie zdawać sobie sprawy, że drzewa są podzbiorem wykresów obejmujących wszystkie wykresy acykliczne (nie zawierające ścieżek cyklicznych). Wykres E na rysunku powyżej jest drzewem i prawdopodobnie jest to kształt, który znasz, ale wykres D jest również drzewem. Wykres F to las drzew.

Gęstość wykresu

Stosunek krawędzi do węzłów wskazuje, czy wykres jest rzadki czy gęsty. Rzadkie wykresy mają kilka połączeń na węzeł, a wykresy gęste wiele.



pokazuje przykład obu typów. Aby zmniejszyć złożoność i ograniczyć zużycie procesora i pamięci do minimum, powinieneś preferować rzadkie wykresy, gdy tylko jest to możliwe, na przykład podczas projektowania wykresu do wykorzystania w planowaniu ścieżki. Wiedza, czy wykres jest gęsty, czy rzadki, jest pomocna przy wyborze odpowiedniej struktury danych do kodowania struktury wykresu, ponieważ implementacja, która jest wydajna dla gęstego wykresu, prawdopodobnie nie będzie wydajna dla tego, który jest rzadki

Dwugraf

Do tej pory zakładaliśmy, że jeśli możliwe jest przejazd z węzła A do węzła B, możliwe jest również wykonanie czynności odwrotnej. Nie zawsze tak może być. Czasami może być konieczne zaimplementowanie wykresu, w którym połączenia są kierunkowe. Na przykład, twoja gra może mieć "poślizg śmierci" umieszczony nad rzeką. Agent powinien być w stanie przejść tylko w jedną stronę - od góry do dołu - dlatego musimy znaleźć sposób na reprezentację tego rodzaju połączenia. Alternatywnie może być możliwe podróżowanie między dwoma węzłami w dowolnym kierunku, ale koszt każdego przejścia może być inny. Dobrym przykładem jest to, jeśli chcesz, aby Twoi agenci brali pod uwagę pochyłości terenu. W końcu pojazd łatwo i sprawnie i szybko zjeżdża w dół, ale zużywa o wiele więcej paliwa, aby pojazd poruszał się pod górę, a jego maksymalna prędkość będzie znacznie mniejsza. Możemy odzwierciedlić te informacje za pomocą wykresu zwanego digraph lub w skrócie DAG. Digraf ma krawędzie skierowane lub w jedną stronę. Węzły, które definiują skierowaną krawędź są znane jako para uporządkowana i określa kierunek krawędzi. Na przykład, uporządkowana para 16-6 wskazuje, że możliwe jest przejście z węzła 16 do węzła 6, ale nie z węzła 6 do 16. W tym przykładzie węzeł 16 jest znany jako węzeł źródłowy, a węzeł docelowy 6.



Krawędzie są rysowane za pomocą strzałek wskazujących ich kierunek. Podczas projektowania struktury danych dla wykresów często pomocne jest myślenie o grafach bezkierunkowych jako o wykrojach z dwiema krawędziami łączącymi każdą połączoną parę węzłów. Jest to wygodne, ponieważ oba typy wykresów (skierowane i nieukierowane) mogą być następnie reprezentowane przez tę samą strukturę danych. Na przykład rzadki, nieukierowany wykres pokazany na rycinie 5.4 może być reprezentowany przez wykrój pokazany poniżej



Zanim przejdziemy do implementacji kodu dla wykresu, rzućmy okiem na niektóre z rzeczy, do których możesz użyć wykresu przy opracowywaniu sztucznej inteligencji gry, zaczynając od najpopularniejszego zastosowania: nawigacji lub wyszukiwania ścieżek.

Wykresy nawigacyjne
Wykres nawigacyjny to abstrakcja wszystkich lokalizacji w środowisku gry, które mogą odwiedzać agenci gry, oraz wszystkich połączeń między tymi punktami. W związku z tym dobrze zaprojektowany nawigator to struktura danych obejmująca wszystkie możliwe ścieżki w środowisku gry, dlatego jest bardzo przydatny, ponieważ pomaga agentom gier w podjęciu decyzji, jak dostać się z punktu A do punktu B. Każdy węzeł na wykresie przedstawia zazwyczaj pozycję kluczowy obszar lub obiekt w środowisku, a każda krawędź reprezentuje połączenia między tymi punktami. Ponadto każda krawędź będzie wiązała się z kosztami, co w najprostszym przypadku reprezentuje odległość między węzłami, które łączy. Ten typ wykresu znany jest matematykom jako wykres euklidesowy. Rysunek poniżej pokazuje wykres nawigacyjny utworzony dla środowiska o małych ścianach i wyróżnia ścieżkę przez ten wykres.



Chciałbym wyjaśnić, że agent gry nie ogranicza się do poruszania się wzdłuż krawędzi wykresu, jakby to był pociąg jadący po szynach. Agent może przejść do dowolnej niezakłóconej pozycji w środowisku gry, ale używa wykresu nawigacyjnego do negocjowania swojego środowiska - do planowania ścieżek między dwoma lub więcej punktami i przechodzenia przez nie. Na przykład, jeśli agent umieszczony w punkcie A uzna, że konieczne jest przejście do położenia B, może użyć nawigatora, aby obliczyć najlepszą trasę między węzłami znajdującymi się najbliżej tych punktów. Rysunek powyższy jest typowy dla wykresu nawigacyjnego utworzonego dla strzelanki FPS. Inne typy gier mogą uznać inny układ węzłów za bardziej skuteczny. Na przykład gry typu RTS / RPG często oparte są na siatce kafelków lub komórek, przy czym każda płytka reprezentuje inny rodzaj terenu, np. Trawę, drogę, błoto itp. Dlatego wygodnie jest utworzyć wykres za pomocą środka punkty każdej płytki i przypisywanie kosztów krawędzi na podstawie odległości między komórkami ważonymi dla typu terenu, po którym przesuwa się krawędź. Takie podejście pozwala agentom gry łatwo obliczyć ścieżki, które omijają wodę, wolą podróżować po drogach od błota i wędrować po górach. Rysunek pokazuje rodzaj układu komórek, którego można się spodziewać w RTS / RPG.



Ponieważ niektóre gry RTS / RPG mogą wykorzystywać dosłownie setki tysięcy komórek, wadą tego podejścia jest to, że wykresy mogą być bardzo duże, kosztowne do przeszukania i zajmują duże ilości pamięci. Na szczęście dla twórców AI, niektórych z tych problemów można uniknąć, stosując techniki, których nauczysz się w dalszej części

WSKAZÓWKA. Jeśli tworzysz "ukryte", takie jak gry Thief and Thief 2 firmy Looking Glass Studios / Eidos Interactive, możesz użyć nawigatora, którego krawędzie są wyważone przez to, ile dźwięku postać przemieści wzdłuż krawędzi. Krawędzie, które można pokonywać cicho, takie jak te wzdłuż dywanu, miałyby niskie ciężary wartości, a głośne krawędzie wysokie wartości. Projektowanie wykresów w ten sposób pozwala postaciom w grze znaleźć najcichszą ścieżkę między dwoma pokojami.

Wykresy zależności

Wykresy zależności są używane w grach typu zarządzanie zasobami w celu opisania zależności między różnymi budynkami, materiałami, jednostkami i technologiami dostępnymi dla gracza. Rysunek pokazuje część wykresu zależności utworzonego dla takiej gry. Ten rodzaj wykresu ułatwia sprawdzenie, jakie są wymagania wstępne do utworzenia każdego rodzaju zasobu.



Wykresy zależności są nieocenione przy projektowaniu sztucznej inteligencji dla tego rodzaju gatunku, ponieważ AI może ich używać do decydowania o strategiach, przewidywania przyszłego statusu przeciwnika i skutecznego przydzielania zasobów. Oto kilka przykładów opartych na wykresie pokazanym na rysunku.

1. Jeśli AI przygotowuje się do bitwy i stwierdzi, że łucznicy będą korzystni, może zbadać wykres zależności, aby stwierdzić, że zanim będzie w stanie wyprodukować łuczników, musi najpierw upewnić się, że ma koszary i technologię strzał. Wie również, że aby produkować strzały, musi mieć tartak produkujący drewno. Dlatego jeśli AI ma już tartak, może przeznaczyć zasoby na budowę koszar lub odwrotnie. Jeśli sztuczna inteligencja nie ma koszar ani tartaku, może dodatkowo sprawdzić wykres technologii, aby ustalić, że prawdopodobnie korzystne jest zbudowanie koszar przed tartakiem. Dlaczego? Ponieważ koszary są warunkiem wstępnym dla trzech różnych rodzajów jednostek bojowych, podczas gdy tartak jest warunkiem koniecznym do produkcji drewna. Sztuczna inteligencja stwierdziła już, że bitwa jest nieuchronna, więc powinna zdać sobie sprawę (oczywiście jeśli poprawnie ją zaprojektowałeś), że powinna jak najszybciej wkładać zasoby w tworzenie jednostek bojowych, ponieważ, jak wszyscy wiemy, rycerze i piechota żołnierze robią lepszych wojowników niż drewniane deski!

2. Jeśli wrogi pieszy żołnierz niosący broń dotrze na terytorium AI, AI może przejechać przez wykres do tyłu, aby stwierdzić, że:

•  Wróg musiał już zbudować kuźnię i tartak.
•  Wróg musiał opracować technologię prochu.
•  Wróg musi produkować zasoby drewna i żelaza.

Dalsze badanie wykresu wskazałoby, że wróg prawdopodobnie ma armaty lub je obecnie buduje. Paskudny! AI może wykorzystać te informacje do wyboru najlepszego planu ataku. Na przykład sztuczna inteligencja wiedziałaby, że aby nie dopuścić do dotarcia na jej terytorium kolejnych wrogów z bronią, powinna celować w kuźnię i tartak wroga. Może również wywnioskować, że wysłanie zabójcy w celu uderzenia wroga kowala znacznie osłabiłoby jego wroga i być może poświęciłby środki na stworzenie zabójcy na ten cel.

3. Często technologia lub określona jednostka jest kluczem do zwycięstwa drużyny. Jeśli koszty budowy każdego zasobu są przypisane do krawędzi wykresu zależności, wtedy AI może wykorzystać te informacje do obliczenia najbardziej wydajnej trasy do wytworzenia tego zasobu

Wykresy stanu

Wykres stanu reprezentuje każdy możliwy stan, w którym może znajdować się system, oraz przejścia między tymi stanami. Ta kolekcja potencjalnych stanów systemu jest znana jako przestrzeń stanów. Wykres tego typu można przeszukać, aby sprawdzić, czy dany stan jest możliwy, lub znaleźć najbardziej efektywną trasę do określonego stanu. Spójrzmy na prosty przykład z układanką "Towers of Hanoi"



W tej prostej wersji układanki znajdują się trzy kołki - A, B i C - i trzy pierścienie o różnych rozmiarach, które pasują na kołki. Pierścienie zaczynają się ustawiać w porządku wielkości nad kołkiem A. Celem układanki jest przesuwanie pierścieni, aż wszystkie zostaną ustawione na kołku C, również w kolejności wielkości. Jednocześnie można przenosić tylko jeden pierścień. Pierścień można umieścić na pustym kołku lub na pierścieniu większym od niego. Możemy przedstawić przestrzeń stanu tej układanki za pomocą wykresu, na którym każdy węzeł reprezentuje jeden z możliwych stanów, w których może znajdować się układanka. Krawędzie wykresu przedstawiają przejścia między stanami: Jeśli możliwe jest przejście bezpośrednio z jednego stanu do drugiego, będzie krawędź łącząca oba stany; w przeciwnym razie nie będzie połączenia. Wykres jest tworzony przez utworzenie najpierw węzła, który zawiera stan początkowy układanki. Jest to znane jako węzeł główny. Węzeł główny jest następnie rozwijany przez dodanie do wykresu wszystkich stanów możliwych do uzyskania z tego węzła, a następnie każdy z tych stanów jest rozszerzany i tak dalej, aż wszystkie możliwe stany i przejścia zostaną dodane do wykresu. Poprzedni stan każdego stanu nazywany jest stanem nadrzędnym, a nowy stan nazywany jest dzieckiem stanu nadrzędnego. Rysunek pokazuje ten proces. Strzałka łącząca dwa stany oznacza, że jeden stan można osiągnąć z drugiego, przesuwając jeden z dysków. Wykres szybko się komplikuje, więc pominąłem wiele możliwych stanów, aby ułatwić dostrzeżenie jednej ze ścieżek prowadzących do rozwiązania.



Wykres stanu można łatwo przeszukać, aby znaleźć stan celu. W tym przykładzie stanem bramki jest taki, w którym wszystkie elementy są umieszczone na kołku C we właściwej kolejności. Przeszukując przestrzeń stanów można nie tylko znaleźć jedno rozwiązanie, ale także znaleźć każde możliwe rozwiązanie lub rozwiązanie wymagające najmniejszej liczby ruchów (lub największej liczby ruchów, jeśli tego właśnie szukasz). Średnia liczba węzłów podrzędnych promieniujących z każdego węzła nadrzędnego jest znana jako czynnik rozgałęziający wykresu. W przypadku niektórych problemów, takich jak omawiany tutaj przykład łamigłówki, współczynnik rozgałęzienia jest niski - rzędu od jednej do trzech gałęzi na węzeł - co umożliwia przedstawienie za pomocą wykresu całej przestrzeni stanu w pamięci komputera. Jednak w wielu domenach współczynnik rozgałęzienia jest znacznie wyższy, a liczba potencjalnych stanów ogromnie rośnie wraz ze wzrostem odległości od węzła głównego (głębokość wykresu). W tego typu systemach niemożliwe jest przedstawienie całej przestrzeni stanów, ponieważ szybko przekroczy możliwości pamięci nawet najbardziej wydajnego komputera. Nawet jeśli taki wykres mógłby zostać zapisany, wyszukiwanie zajmie jeszcze eony. W rezultacie tego rodzaju wykresy są tworzone i przeszukiwane poprzez rozwinięcie kilku węzłów naraz, zwykle (ale nie zawsze) przy użyciu algorytmów, które kierują wyszukiwanie w kierunku celu.

Implementowanie klasy grafu

Dwie popularne struktury danych używane do reprezentowania wykresów to macierze przyległości i listy przyległości. Wykresy macierzy adiakencji wykorzystują dwuwymiarową macierz booleanów lub liczb zmiennoprzecinkowych do przechowywania informacji o połączeniu wykresu. Wartości logiczne są używane, jeśli nie ma kosztu związanego z przemierzaniem krawędzi, a zmiennoprzecinkowe są używane, gdy istnieje powiązany koszt, na przykład w przypadku wykresu nawigacyjnego, w którym każda krawędź reprezentuje odległość między dwoma węzłami. Dokładne wdrożenie zależy oczywiście od projektanta i jego potrzeb problem. Rycina. pokazuje, jak wygląda macierz przylegania dla wykreślnika na rycinie wcześniej



Każde "1" oznacza połączenie między dwoma węzłami, a każde "0" oznacza brak połączenia. Po odczytaniu wartości bezpośrednio z macierzy z rysunku 5.12 wiemy, że nie ma połączenia od węzła 2 do 6, ale istnieje krawędź łącząca 4 do 2. Macierze adiacyencji są intuicyjne, ale dla dużych rzadkich grafów ten typ reprezentacji jest nieefektywna, ponieważ większość matrycy jest używana do przechowywania niepotrzebnych wartości zerowych. Znacznie lepsza struktura danych dla rzadkich wykresów (najczęściej występujących wykresów w grze AI) to lista sąsiedztwa. Dla każdego obecnego węzła wykres listy przyległości przechowuje połączoną listę wszystkich sąsiednich krawędzi. Rysunek pokazuje, jak to działa w poprzednim przykładzie.



Listy adiakencji są skuteczne do przechowywania rzadkich wykresów, ponieważ nie marnują miejsca na przechowywanie zerowych połączeń. Ilość miejsca wymagana do przechowywania wykresu przy użyciu tego typu struktury danych jest proporcjonalna do N + E (liczba węzłów + liczba krawędzi), podczas gdy dla macierzy sąsiedniej jest proporcjonalna do N2 (liczba węzłów do kwadratu). Ponieważ większość wykresów, które można napotkać podczas tworzenia gier AI, jest niewielka, lista zgodności będzie często twoją wybraną strukturą danych. Mając to na uwadze, spójrzmy na kod źródłowy wymagany do zaimplementowania takiego wykresu.

Klasa GraphNode

GraphNode zawiera minimalną ilość informacji wymaganych przez węzeł do reprezentacji wykresu listy przyległości: unikalny numer identyfikacyjny lub indeks. Oto lista deklaracji węzła wykresu:

class GraphNode
{
protected:
// każdy węzeł ma indeks. Prawidłowy indeks to> = 0
int m_iIndex;
public:
GraphNode (): m_iIndex (invalid_node_index) {}
GraphNode (int idx): m_iIndex (idx) {}
virtual ~ GraphNode () {}
int Index () const;
void SetIndex (int NewIndex);
};

Ponieważ często konieczne jest, aby węzeł zawierał dodatkowe informacje, GraphNode jest zwykle używany jako klasa bazowa, z której można uzyskać niestandardowe węzły. Na przykład węzły wykresu nawigacyjnego muszą przechowywać informacje przestrzenne, a węzły wykresu zależności muszą zawierać informacje o reprezentowanych przez nich zasobach. Klasa węzła zaprojektowana do użycia w grafie nawigacyjnym może wyglądać mniej więcej tak:

template
klasa NavGraphNode: public GraphNode
{
protected:
// pozycja węzła
Vector2D m_vPosition;
// często będziesz potrzebować węzła navgraph, aby zawierał dodatkowe informacje.
// Na przykład węzeł może reprezentować odbiór, taki jak zbroja, w której
// case m_ExtraInfo może być wyliczoną wartością oznaczającą typ odbioru,
// umożliwiając w ten sposób algorytmowi wyszukiwania przeszukanie wykresu dla określonych elementów.
// Idąc o krok dalej, m_ExtraInfo może być wskaźnikiem do wystąpienia
// typ elementu, z którym węzeł jest powiązany. Pozwoliłoby to na algorytm wyszukiwania
// aby sprawdzić status odbioru podczas wyszukiwania.
extra_info m_ExtraInfo;
public:
/ * POMINIĘTY INTERFEJS * /
};

Pamiętaj, że chociaż wymieniona tutaj klasa węzłów używa wektora 2D do przedstawienia pozycji węzła, wykres może istnieć w dowolnej liczbie wymiarów, które ci się podobają. Jeśli tworzysz wykres nawigacyjny dla gry 3D, po prostu użyj wektorów 3D. Wszystko będzie działać tak samo.

Klasa GraphEdge

Klasa GraphEdge zawiera podstawowe informacje wymagane do oznaczenia połączenia między dwoma węzłami grafowymi. Oto kod:

class GraphEdge
{
protected:
// Krawędź łączy dwa węzły. Prawidłowe wskaźniki węzłów są zawsze dodatnie.
int m_iFrom;
int m_iTo;
// koszt przejścia krawędzi
double m_dCost;
public:
GraphEdge (int from, int to, double cost): m_dCost (cost),
m_iFrom (od),
m_iTo (do)
{}
GraphEdge (int from, int to): m_dCost (1.0),
m_iFrom (od),
m_iTo (do)
{} GraphEdge (): m_dCost (1.0),
m_iFrom (invalid_node_index),
m_iTo (invaid_node_index)
{}

Czasami przydaje się możliwość utworzenia GraphEdge z jednym lub obydwoma wskaźnikami ustawionymi na "nieprawidłową" (ujemną) wartość. Wyliczona wartość invaid_node_index w pliku NodeTypeEnumerations.h jest tutaj używana , aby zainicjować From i To w domyślnym konstruktorze.

virtual ~ GraphEdge () {}
int From () const;
void SetFrom (int NewIndex);
int To () const;
void SetTo (int NewIndex);
double Cost () const; void SetCost (double NewCost);
};

Jeśli pracujesz na platformie, na której wykorzystanie pamięci jest znacznie większym problemem niż szybkość wyszukiwania wykresu, możesz uzyskać dobre oszczędności na wykresach opartych na komórkach (lub wykresach o równej lub większej gęstości), nie zapisując jawnie kosztu każdego krawędź. Zamiast tego można zaoszczędzić pamięć, pomijając pole kosztu z klasy GraphEdge i obliczyć koszt "w locie" za pomocą funkcji atrybutów jego dwóch sąsiednich węzłów. Na przykład, jeśli koszt krawędzi jest równy odległości między dwoma węzłami, funkcją będzie odległość euklidesowa. Coś jak:

//cost from A to B
cost = Distance(NodeA.Position, NodeB.Position)

Ponieważ na tym wykresie jest zwykle osiem razy więcej krawędzi niż wierzchołków, oszczędność pamięci może być znaczna, gdy zaangażowana jest duża liczba węzłów

Klasa SparseGraph

Węzły i krawędzie wykresu są zgrupowane razem w klasie SparseGraph. Jest to zaimplementowane jako szablon klasy, dzięki czemu ten typ wykresu może wykorzystywać dowolne odpowiednie typy węzłów i krawędzi. Algorytmy działające na wykresach powinny mieć szybki dostęp do węzła i danych brzegowych. Mając to na uwadze, klasa SparseGraph została zaprojektowana w taki sposób, aby klucze numeryczne indeksu każdego węzła bezpośrednio w wektorze węzłów grafowych (m_Nodes) i wektorze list krawędzi przyległości (m_Edges), co daje czas wyszukiwania O (1). Stwarza to jednak problem, gdy węzeł jest usuwany z wykresu, ponieważ gdyby miał zostać również usunięty z m_Nodes, całe indeksowanie dla węzłów o wyższym indeksowaniu byłoby unieważnione. Dlatego zamiast usuwać węzeł z wektora, jego indeks jest ustawiony na wyliczoną wartość invalid_node_index i wszystkie metody SparseGraph traktują tę wartość tak, jakby nie było węzła. Oto lista deklaracji SparseGraph.

template
class SparseGraph
{
public:
// możliwia łatwy dostęp klienta do typów krawędzi i węzłów używanych na wykresie
typedef edge_type EdgeType;
typedef node_type NodeType;
// kilka innych przydatnych typedefs
typedef std::vector NodeVector;
typedef std::list EdgeList;
typedef std::vector EdgeListVector;
private:
// węzły, które składają się na ten wykres
NodeVector m_Nodes;
// wektor list krawędzi przyległości. (każdy klucz indeksu węzła do
// listy krawędzi powiązanych z tym węzłem)
EdgeListVector m_Edges;
// czy to jest ukierunkowany wykres?
bool m_bDigraph;
// indeks następnego węzła, który ma zostać dodany
int m_iNextNodeIndex;
/ * DODATKOWE SZCZEGÓŁY POMINOWANE * /
public:
//ctor
SparseGraph(bool digraph): m_iNextNodeIndex(0), m_bDigraph(digraph){}
// zwraca węzeł o podanym indeksie
const NodeType& GetNode(int idx)const;
// wersja non-const
NodeType& GetNode(int idx);
// metoda const uzyskiwania odniesienia do krawędzi
const EdgeType& GetEdge(int from, int to)const;
// wersja non const
EdgeType& GetEdge(int from, int to);
// pobiera następny wolny indeks węzła
int GetNextFreeNodeIndex()const;
// dodaje węzeł do wykresu i zwraca jego indeks
int AddNode(NodeType node);
// usuwa węzeł, ustawiając jego indeks na invalid_node_index
void RemoveNode(int node);
// metody dodawania i usuwania krawędzi
void AddEdge(EdgeType edge);
void RemoveEdge(int from, int to);

Zwróć uwagę, w jaki sposób klasa ma metody usuwania węzłów i krawędzi. Jest to niezbędna funkcja, jeśli wykres jest dynamiczny i może się zmieniać w miarę postępu gry. Na przykład łatwo jest przedstawić zakłócenie wywołane trzęsieniem ziemi, usuwając (a czasem dodając) krawędzie z wykresu nawigacyjnego. Alternatywnie, rozgrywka podobna do tej z Command & Conquer może dodawać i usuwać krawędzie, gdy gracze budują lub niszczą mosty i ściany.

// zwraca liczbę aktywnych + nieaktywnych węzłów obecnych na wykresie
int NumNodes () const;
// zwraca liczbę aktywnych węzłów obecnych na wykresie
int NumActiveNodes () const;
// zwraca liczbę krawędzi obecnych na wykresie
int NumEdges () const;
// zwraca true, jeśli wykres jest skierowany
bool isDigraph () const;
// zwraca true, jeśli wykres nie zawiera żadnych węzłów
bool isEmpty () const;
// zwraca true, jeśli węzeł z danym indeksem jest obecny na wykresie
bool isPresent (int nd) const;
// metody ładowania i zapisywania wykresów z otwartego strumienia plików lub z
// nazwa pliku
bool Save (const char * FileName) const;
bool Save (std :: ofstream & stream) const;
bool Load (const char * nazwa_pliku);
bool Load (std :: ifstream & stream);
// czyści wykres gotowy do wstawienia nowego węzła
void Clear ();
// klienci iteratorów mogą uzyskiwać dostęp do węzłów i krawędzi
class ConstEdgeIterator;
class EdgeIterator;
class NodeIterator;
class ConstNodeIterator;
};

Z informacji w tej sekcji dowiedziałeś się, że wykresy to potężne narzędzie, które masz do dyspozycji. Jednak sama struktura danych wykresu ma niewiele zastosowań. Duża część mocy grafów jest realizowana tylko wtedy, gdy są one obsługiwane przez algorytmy zaprojektowane do ich eksploracji, albo w celu znalezienia określonego węzła lub znalezienia ścieżki między węzłami. Pozostała część jest poświęcona jest badaniu kilku z tych algorytmów.

Algorytmy wyszukiwania wykresów

Teoria grafów jest popularnym obszarem badań matematyków i opracowano wiele algorytmów do wyszukiwania i eksploracji topologii wykresu. Korzystając z algorytmów wyszukiwania, można między innymi:

•  Odwiedź każdy węzeł na wykresie, skutecznie mapując topologiaę wykresu
•  Znajdź dowolną ścieżkę między dwoma węzłami. Jest to przydatne, jeśli chcesz znaleźć węzeł, ale tak naprawdę nie zależy ci na tym, jak tam dotrzeć. Na przykład tego rodzaju wyszukiwania można użyć do znalezienia jednego (lub więcej) rozwiązania puzzli Wieże Hanoi.
•  Znajdź najlepszą ścieżkę między dwoma węzłami. To, co jest "najlepsze", zależy od problemu. Jeśli przeszukiwany wykres jest grafem nawigacyjnym, najlepszą ścieżką może być najkrótsza ścieżka między dwoma węzłami, ścieżka, która zabiera agenta między dwoma punktami w najszybszym czasie, ścieżka omijająca linię wzroku wroga lub najcichsza ścieżka (? la Thief). Jeśli wykres jest wykresem stanu, takim jak układanka z Wież Hanoi, to najlepsza ścieżka będzie tą, która dojdzie do rozwiązania w jak najmniejszej liczbie kroków

Zanim przejdę do drobiazgów, chciałbym wyjaśnić, że wielu z was na początku będzie miało trudności z zrozumieniem niektórych z tych algorytmów. W rzeczywistości uważam, że algorytmy wyszukiwania grafów powinny zawierać ostrzeżenie zdrowotne. Właściwe byłoby coś takiego:

OSTRZEŻENIE!

Strzeż się! Algorytmy wyszukiwania mają zdolność tworzenia w przeciętnym ludzkim mózgu strasznych frustracji i dezorientacji, co prowadzi do bólów głowy, nudności i braku snu. Spontaniczne i nadmierne wycie nie jest niczym niezwykłym. Należy pamiętać, że te objawy są powszechne na wczesnych etapach krzywej uczenia się i nie są generalnie powodem do niepokoju. Zwykła usługa jest zwykle wznawiana w rozsądnym czasie. (Jeśli objawy utrzymują się, trzymaj się z dala od ruchliwych dróg, żyletek i załadowanej broni. Zasięgnij porady medycznej przy najbliższej okazji.)

Poważnie, jednak dla wielu osób te rzeczy mogą być trudne do zrozumienia. Z tego powodu poświęcę czas na wyjaśnienie każdego algorytmu. Bardzo ważne jest, aby zrozumieć teorię i nie używać tych technik w sposób "wycinaj i wklej", ponieważ często możesz chcieć zmodyfikować algorytm zgodnie z własnymi wymaganiami. Bez zrozumienia, jak działają te wyszukiwania, wszelkie modyfikacje będą prawie niemożliwe, a ty będziesz drapał się po głowie z frustracją.

Niedoinformowane wyszukiwania grafów

Niedoinformowane wyszukiwania grafów lub ślepe wyszukiwania, jak to czasem bywa znane, przeszukuje wykres bez względu na związane z nim koszty krawędzi. Mogą jednak rozróżniać poszczególne węzły i krawędzie, umożliwiając im identyfikację węzła docelowego lub rozpoznanie wcześniej odwiedzonych węzłów lub krawędzi. Jest to jedyna informacja wymagana do pełnego zbadania wykresu (do odwiedzenia każdego węzła) lub znalezienia ścieżki między dwoma węzłami.

Głębokie pierwsze wyszukiwanie

Poznaj małego Billy′ego. Billy stoi przy wejściu do typowego parku rozrywki: zlepek przejażdżek i innych atrakcji oraz ścieżki wijące się przez park, który je łączy. Billy nie ma mapy, ale chętnie odkrywa, jakie przejażdżki i inną rozrywkę ma do zaoferowania park. Na szczęście Billy wie o teorii grafów i szybko dostrzega podobieństwo między układem parku rozrywki a wykresem. Widzi, że każdą atrakcję można przedstawić za pomocą węzła, a ścieżki między nimi za pomocą krawędzi. Wiedząc o tym, Billy może zapewnić, że odwiedzi każdą atrakcję i zejdzie każdą ścieżką, używając algorytmu wyszukiwania zwanego pierwszym wyszukiwaniem głębokim lub w skrócie DFS. Pierwsze wyszukiwanie głębokie jest tak nazwane, ponieważ wyszukuje, przesuwając jak najgłębiej na wykresie. Kiedy uderza w ślepy zaułek, wraca do płytszego węzła, gdzie może kontynuować eksplorację. Na przykładzie parku tematycznego działa ten algorytm:

Od wejścia do parku (węzeł źródłowy) Billy odnotowuje opis i ścieżki, które rozciągają się na zewnątrz (krawędzie) na kartce papieru. Następnie wybiera jedną ze ścieżek do zejścia. Nie ma znaczenia, może wybrać jeden losowo, pod warunkiem, że jest to ścieżka, której nie zbadał. Za każdym razem, gdy ścieżka prowadzi Billy′ego do nowej atrakcji, notuje jej nazwę i ścieżki z nią związane. Ilustracje oznaczone od A do D na rycinie przedstawiają kilka pierwszych kroków tego procesu.



Cienkie czarne linie reprezentują niezbadane ścieżki a podświetlone linie pokazują ścieżki, które Billy postanowił zejść. Kiedy osiąga pozycję pokazaną w D, Billy zauważa, że ze statku pirackiego nie prowadzą żadne nowe ścieżki (w mowie graficznej ten węzeł jest znany jako węzeł końcowy). Dlatego, aby kontynuować wyszukiwanie, cofa się do kina 3D, gdzie istnieją dalsze niezbadane ścieżki do przejścia. Patrz rysunek E.



Kiedy dotrze do Lodowego Blastera, są cztery niezbadane ścieżki do wypróbowania. Pierwsze dwie, po których nawiguje, prowadzą z powrotem do wcześniej odwiedzonych miejsc - Wejścia i Funhouse - więc każdą ścieżkę oznaczy jako zbadaną przed powrotem do Ice Blaster, aby wypróbować inną trasę. W końcu znajduje ścieżkę prowadzącą do automatów. Patrz rysunek F, G i H. Ten proces przesuwania się do przodu przez wykres tak daleko, jak to możliwe, zanim nastąpi powrót do wcześniej niezbadanych ścieżek, trwa do momentu zmapowania całego parku rozrywki. Kroki od I do L na rysunku przedstawiają kilka kolejnych etapów procesu. Rycina poniższy pokazuje gotowy wykres po tym, jak



UWAGA Przy danym węźle źródłowym pierwsze wyszukiwanie głębokości może jedynie zagwarantować, że wszystkie węzły i krawędzie zostaną odwiedzone na połączonym wykresie. Pamiętaj, że połączony wykres to taki, na którym można dotrzeć do dowolnego węzła z dowolnego innego. Jeśli przeszukujesz niepowiązany wykres, taki jak C na rysunku wcześniej, algorytm należy rozwinąć, aby zawierał węzeł źródłowy dla każdego pod-wykresu.

Implementacja algorytmu

DFS jest zaimplementowany jako szablon klasy i może być używany z dowolną implementacją grafu (np. Gęsty wykres) przy użyciu tego samego interfejsu, co pokazana wcześniej klasa SparseGraph. Najpierw przejrzyjmy deklarację klasy, a następnie opiszę sam algorytm wyszukiwania.

template
class Graph_SearchDFS
{
private:
// w celu zwiększenia czytelności
enum {visited, unvisited, no_parent_assigned};
// utwórz typy typef dla typów krawędzi i węzłów używanych przez wykres
typedef typename graph_type::EdgeType Edge;
typedef typename graph_type::NodeType Node;
private:
// odniesienie do wykresu do przeszukania
const graph_type & m_Graph;
// rejestruje indeksy wszystkich odwiedzanych węzłów jeśli
// wyszukiwanie trwa
std::vector m_Visited;

m_Visited zawiera tę samą liczbę elementów, co węzły na wykresie. Każdy element jest początkowo ustawiony jako niezwiedzony. W miarę postępu wyszukiwania za każdym razem, gdy odwiedzany jest węzeł, odpowiadający mu element w m_Visited zostanie ustawiony na odwiedzony.

// to utrzymuje trasę wybraną do celu
std::vector m_Route;

m_Route zawiera również tę samą liczbę elementów, co na węźle na wykresie. Każdy element jest początkowo ustawiony na no_parent_assigned. W miarę przeszukiwania wykresu ten wektor przechowuje trasę do węzła docelowego, rejestrując elementy nadrzędne każdego węzła pod odpowiednim indeksem. Na przykład, jeśli ścieżka do celu podąża za węzłami 3 - 8 - 27, to m_Route [8] będzie zawierać 3, a m_Route będzie zawierać 8.

/ indeksy węzła źródłowego i docelowego
int m_iSource,
m_iTarget;

Podczas eksploracji wykresu najczęściej szukasz określonego celu (lub celów). Aby ponownie użyć analogii do parku rozrywki, wygląda to tak, jakbyś szukał konkretnej jazdy, na przykład kolejki górskiej. Mając to na uwadze, algorytmy wyszukiwania zwykle wykorzystują warunek zakończenia, zwykle w postaci indeksu węzła docelowego.

//true, jeśli ścieżka do celu została znaleziona
bool m_bFound;
//ta metoda wykonuje wyszukiwanie w DFS
bool Search();

Ta metoda jest kodem, który implementuje algorytm pierwszego wyszukiwania głębokości. Za chwilę zanurzymy się w jej wnętrzności.

Graph_SearchDFS(const graph_type& graph,
int source,
int target = -1 ):
m_Graph(graph),
m_iSource(source),
m_iTarget(target),
m_bFound(false),
m_Visited(m_Graph.NumNodes(), unvisited),
m_Route(m_Graph.NumNodes(), no_parent_assigned)
{
m_bFound = Search();
}
//zwraca wartość true, jeśli węzeł docelowy został zlokalizowany
bool Found()const{return m_bFound;}
//zwraca wektor indeksów węzłów, które zawierają najkrótszą ścieżkę
//od źródła do celu
std::list GetPathToTarget()const;
};

Algorytm wyszukiwania DFS jest implementowany przy użyciu wskaźniki do krawędzi std :: stack const zawierających wykres, który wyszukuje. Stos jest strukturą danych ostatnia, pierwsza wyszła (zwykle w skrócie LIFO). Stos jest używany w podobny sposób, jak arkusz papieru, którego nasz przyjaciel Billy używał do eksploracji parku tematycznego: Krawędzie są popychane na nim w trakcie wyszukiwania, tak jak Billy zapisywał ścieżki podczas eksploracji. Rzuć okiem na kod metody wyszukiwania, a następnie przedstawię ci przykład, aby upewnić się, że rozumiesz, jak działa jego magia.

template
bool Graph_SearchDFS::Search()
{
//utwórz standardowy stos wskaźników do krawędzi
std::stack stack;
// stwórz atrapę i umieść na stosie
Edge Dummy(m_iSource, m_iSource, 0);
stack.push(&Dummy);
// gdy na stosie są krawędzie, szukaj dalej
while (!stack.empty())
{
//złap następną krawędź
const Edge* Next = stack.top();
//usuń krawędź ze stosu
stack.pop();
//zanotuj element nadrzędny węzła, na który wskazuje ta krawędź
m_Route[Next->To] = Next->From();
//i oznacz jako odwiedzone
m_Visited[Next->To()] = visited;
//jeśli cel został znaleziony, metoda może zwrócić sukces
if (Next->To() == m_iTarget)
{
return true;
}
// popchnij krawędzie prowadzące od węzła, na który wskazuje ta krawędź
// stos (pod warunkiem, że krawędź nie wskazuje wcześniej
// odwiedzony węzeł)
graph_type::ConstEdgeIterator ConstEdgeItr(m_Graph, Next->To());
for (const Edge* pE=ConstEdgeItr.begin();
!ConstEdgeItr.end();
pE=ConstEdgeItr.next())
{
if (m_Visited[pE->To()] == unvisited)
{
stack.push(pE);
}
}
}//podczas
// braku ścieżki do celu> return false;
}

Aby pomóc Ci zrozumieć, przeanalizujmy prosty przykład. Używając niekierowanego wykresu pokazany na poniższym rysunku, powiedzmy, że chcemy wyszukać węzeł 3 (węzeł docelowy), rozpoczynając wyszukiwanie od węzła 5 (węzeł źródłowy).



Wyszukiwanie rozpoczyna się od utworzenia sztucznej krawędzi - prowadzącej od węzła źródłowego z powrotem do węzła źródłowego - i umieszczenia go na stosie.



Podświetlenie wskazuje, że krawędź znajduje się na stosie. Wyszukiwanie odbywa się po wprowadzeniu pętli while. Podczas gdy są jeszcze niezbadane krawędzie stosu, algorytm iteruje następujące kroki. Komentarze w nawiasach opisują sytuację dla pierwszej iteracji w pętli.

1. Usuń najwyższą krawędź ze stosu. (Atrapa krawędzi [5-5].)
2. Zanotuj element nadrzędny węzła docelowego krawędzi, wstawiając indeks elementu nadrzędnego do wektora m_Routes, w elemencie, do którego odwołuje się indeks węzła docelowego. (Ponieważ krawędź fikcyjna jest używana do uruchomienia algorytmu, rodzicem węzła 5 jest również węzeł 5. Dlatego m_Routes [5] jest ustawiony na 5.)
3. Zaznacz węzeł docelowy krawędzi jako odwiedzony, przypisując odwiedzone wyliczenie do odpowiedniego indeksu w wektorze m_Visited

(m_Visited [5] = visited).
4. Test na zakończenie. Jeśli węzeł docelowy krawędzi jest węzłem docelowym, wyszukiwanie może zwrócić sukces. (5 nie jest węzłem docelowym, więc wyszukiwanie jest kontynuowane).
5. Jeśli węzeł docelowy krawędzi nie jest celem, to pod warunkiem, że węzeł, na który wskazuje bieżąca krawędź, nie został jeszcze odwiedzony, wszystkie sąsiednie krawędzie zostaną wypchnięte na stos. (Krawędzie [5-2], [5-4] i [5-6] są wpychane w stos.)

Rysunek poniższy pokazuje stan gry algorytmu po jednej iteracji pętli while. Szary kolor węzła źródłowego wskazuje, że został on oznaczony jako odwiedzony.



W tym momencie algorytm rozgałęzia się z powrotem na początek pętli while, zsuwa następną krawędź ze szczytu stosu (krawędź [5-2]), zaznacza węzeł docelowy jako odwiedzony (węzeł 2) i notuje elementu nadrzędnego węzła docelowego (węzeł 5 jest elementem nadrzędnym węzła 2). Następnie algorytm bierze pod uwagę, które krawędzie powinny zostać zepchnięte na stos. Węzeł 2 (do którego krawędzi [5-2] wskazuje) ma dwie sąsiednie krawędzie: [2-1] i [2-5]. Węzeł 5 został oznaczony jako odwiedzony, więc krawędź [2-5] nie zostaje dodana do stosu. Ponieważ węzeł 1 nie został odwiedzony, prowadząca do niego krawędź [2-1] jest wypychana na stos. Zobacz rysunek 5.20. Gruba czarna linia z [5-2] wskazuje, że konkretna krawędź nie będzie dalej rozpatrywana.



Po raz kolejny algorytm rozgałęzia się z powrotem na początek pętli while i zrzuca następną krawędź ze stosu (krawędź [2 -1]), zaznacza węzeł docelowy jako odwiedzony (węzeł 1) i zapisuje informacje o jego rodzicu (węźle 2 jest rodzicem węzła 1). Węzeł 1 ma krawędzie prowadzące do węzła 3 i węzła 2. Węzeł 2 został już odwiedzony, więc tylko krawędź [1-3] jest wypychana na stos.



Tym razem, gdy algorytm wyskakuje ze stosu następną krawędzią [1-3], po zwykłej procedurze zaznaczania węzła docelowego i odnotowywania elementu nadrzędnego, algorytm stwierdza, że zlokalizował cel, w którym to momencie algorytm kończy działanie. Na tym etapie status jest pokazany na rysunku



Podczas wyszukiwania ścieżka do węzła docelowego jest przechowywana w wektorze m_Route (przedstawionym na poprzednich rysunkach tabelą używaną do przechowywania rodziców każdego węzła). Metoda GetPathToTarget wyodrębnia te informacje i zwraca je jako wektor liczb całkowitych reprezentujących indeksy węzłów, których agent musi przestrzegać, aby przejść od źródła do celu. Oto jak wygląda kod źródłowy:

template
std::list Graph_SearchDFS::GetPathToTarget()const
{
std::list path;
// po prostu zwróć pustą ścieżkę, jeśli nie znaleziono ścieżki do celu lub jeśli
// nie określono celu
if (!m_bFound || m_iTarget<0) return path;
int nd = m_iTarget;
path.push_back(nd);
while (nd != m_iSource)
{
nd = m_Route[nd];
path.push_back(nd);
}
return path;
}

To bardzo prosta metoda. Zaczyna się od przetestowania, aby zobaczyć, jaki jest element nadrzędny węzła docelowego, a następnie element nadrzędny tego węzła i tak dalej, aż do osiągnięcia węzła źródłowego. W przykładzie, który podążałeś na kilku ostatnich stronach, ścieżka, którą zwraca ta metoda, to 5 - 2 - 1 - 3.

UWAGA W celu zapewnienia szybkości i wydajności implementacje algorytmów wyszukiwania opisanych w tym rozdziale są zaprojektowane do działania na wykresach utworzonych przed wyszukiwaniem. Jednak w przypadku niektórych problemów nie będzie można tego zrobić, ponieważ oczekiwany rozmiar wykresu jest albo zbyt duży, aby można go było zapisać w pamięci, albo dlatego, że trzeba oszczędzać pamięć, tworząc tylko te węzły i krawędzie, które są niezbędne do wyszukiwania. Na przykład, jeśli chcesz przeszukać przestrzeń stanu gry, takiej jak szachy, nie można zbudować wykresu stanu przed wyszukiwaniem ze względu na ogromną liczbę możliwych stanów. Zamiast tego węzły i krawędzie muszą być tworzone w miarę wyszukiwania.

DFS w akcji

Aby przekazać użyteczne informacje wizualne, stworzyłem program demonstracyjny, który pokazuje różne wyszukiwania wykresów na wykresie nawigacyjnym układu siatki. Ten typ układu węzłów jest powszechnie spotykany w grach opartych na kafelkach, w których węzeł jest umieszczony na środku każdej płytki, a krawędzie łączą się z najbliższymi ośmioma sąsiadami. Linie pionowe i poziome reprezentują granice kafelków, kropki są węzłami wykresu, a cienkie linie wychodzące z węzłów są krawędziami łączącymi. Niestety zrzut ekranu jest drukowany w skali szarości, więc może nie być łatwo zobaczyć wszystko wyraźnie. Jeśli jesteś blisko komputera, polecam uruchomić plik wykonywalny PathFinder.exe i nacisnąć klawisz "G", aby wyświetlić wykres.

UWAGA. Chociaż w prawdziwym świecie połączenia diagonalne w demonstracji PathFinder są dłuższe niż połączenia pionowe i poziome, pierwsze wyszukiwanie głębokości nie ma wiedzy na temat żadnego związanego z tym kosztu krawędzi i dlatego traktuje wszystkie krawędzie jako równe.

W wersji demo użyłem układu węzłów opartego na siatce, ponieważ ułatwia tworzenie eksperymentalnych wykresów poprzez blokowanie kafelków w celu reprezentowania przeszkód lub zróżnicowanego terenu. Nie oznacza to jednak, że Twoja gra musi korzystać z wykresu opartego na siatce. Podkreślam ten punkt, ponieważ często widzę, że nowicjusze mają trudności ze zrozumieniem, w jaki sposób wykres może mieć dowolny kształt inny niż siatka. Mówią na przykład: "Wiem, jak działa algorytm wyszukiwania XYZ dla opartych na kafelkach gier RTS, ale czy można go użyć w moim FPS?" Często spotykam się z tego rodzaju pytaniami i przypuszczam, że dzieje się tak, ponieważ większość prezentacji, samouczków i artykułów na temat wyszukiwania ścieżek pokazuje przykłady przy użyciu układu węzła oparty na siatce (z powodów już wymienionych) i myślę, że ludzie zakładają, że tak właśnie musi być. Proszę … Błagam, nie popełniaj tego samego błędu! Wykresy mogą mieć dowolny kształt (i dowolną liczbę wymiarów). W każdym razie wróćmy do DFS. Zrzut ekranu pokazuje prostą mapę, którą utworzyłem, blokując niektóre kafelki jako przeszkody.



Ponieważ jest wydrukowany w skali szarości, sprawiłem, że węzły źródłowy i docelowy są lepiej widoczne, zaznaczając je odpowiednio kwadratem i krzyżem. Liczby w prawym dolnym rogu pokazują liczbę węzłów i krawędzi na podstawowym wykresie

Ulepszenia systemu plików DFS

Niektóre wykresy mogą być bardzo głębokie, a DFS można łatwo opóźnić, przechodząc zbyt daleko złą ścieżką. W najgorszym scenariuszu system plików DFS może nie być w stanie wyprowadzić się z niewłaściwego wyboru na początku wyszukiwania, powodując trwałe zablokowanie. Jako przykład załóżmy, że chcesz znaleźć rozwiązanie losowo pomieszanego Kostki Rubika. Cała przestrzeń stanu dla tego problemu jest ogromna i zabrania generowania pełnego wykresu stanu przed jakimkolwiek wyszukiwaniem, dlatego węzły są tworzone w locie, ponieważ każdy stan jest rozwijany, zaczynając od węzła głównego. W pewnym momencie wyszukiwania algorytm DFS może wybrać krawędź prowadzącą do pod grafu przestrzeni stanu, który nie zawiera stanu rozwiązania, ale jest zbyt duży, aby można go było w pełni rozwinąć, biorąc pod uwagę dostępną moc obliczeniową. Powoduje to, że rozwiązanie nigdy nie jest zwracane przez DFS, a komputer skutecznie zawiesić. Na szczęście można temu zapobiec, ograniczając liczbę głębokości wyszukiwania algorytmu DFS przed rozpoczęciem cofania. Nazywa się to wyszukiwaniem głębokości. Korzystając z wyszukiwania z ograniczoną głębokością, pod warunkiem, że głębokość jest tą, którą algorytm może przeszukiwać, biorąc pod uwagę dostępną moc obliczeniową, DFS zawsze zwróci rozwiązanie, jeśli takie istnieje na tej głębokości. Jednak wyszukiwanie z ograniczoną głębokością ma poważną wadę. Skąd wiesz, jaki limit ustawić dla maksymalnej głębokości wyszukiwania? W przypadku większości problematycznych domen nie można ocenić, jaki powinien być ten limit. Korzystając z Kostki Rubika jako przykładu, rozwiązaniem może być trzy kroki do przodu lub piętnaście. Jeśli maksymalna głębokość wyszukiwania jest ustawiona na dziesięć, może, ale nie musi znaleźć rozwiązania. Jeśli jest ustawiony zbyt wysoko, liczba możliwych stanów może spowodować zawieszenie wyszukiwania. Na szczęście istnieje sposób na obejście tego problemu: iteracyjne pogłębianie DFS. Iteracyjne wyszukiwanie pogłębienia polega na użyciu DFS do przeszukiwania wykresu z limitem głębokości równym jeden, następnie głębokość druga, potem trzy itd., Aż do zakończenia wyszukiwania. Chociaż na pierwszy rzut oka może się to wydawać marnotrawstwem zasobów, ponieważ węzły na płytszych głębokościach są przeszukiwane wiele razy, w rzeczywistości większość węzłów zawsze znajduje się na granicy poszukiwań. Jest to szczególnie prawdziwe w przypadku przeszukiwania wykresów o wysokim współczynniku rozgałęzienia. Biorąc pod uwagę współczynnik rozgałęzienia wynoszący osiem, podobnie jak wykres na demie PathFinder, liczba węzłów na skraju jest pokazana w tabeli



Wyobrażam sobie, że niektórzy z was mogą myśleć coś w stylu "Ale jeśli podczas normalnego wyszukiwania DFS głębokość n zawieszę komputer, to na pewno, gdy iteracyjne pogłębianie DFS osiągnie tę samą głębokość, zawiesi także komputer!" Odpowiedź brzmi: tak, jeśli IDDFS może wejść tak głęboko, pojawią się problemy, ale sekretem stosowania iteracyjnego pogłębiania jest narzucenie granicy, zwykle definiowanej jako limit czasowy. Po upływie czasu przeznaczonego na wyszukiwanie algorytm kończy się bez względu na osiągnięty poziom. Następstwem tego metodycznego podejścia jest to, że biorąc pod uwagę wystarczającą ilość czasu i prawidłowy cel, iteracyjne pogłębianie DFS nie tylko znajdzie cel, ale znajdzie cel w jak najmniejszej liczbie kroków.

Linia na poniższym zrzucie ekranu ilustruje ścieżkę, którą podjął DFS w poszukiwaniu celu. Jak widać, meandruje wokół prawie całego obszaru mapy, zanim natknie się na węzeł docelowy, wyraźnie pokazując, że chociaż DFS znajduje cel, nie gwarantuje znalezienia najlepszej trasy do celu.



Należy również zauważyć w tym przykładzie, że DFS nie zbadał żadnych krawędzi poza ścieżką do węzła docelowego. Gdy węzeł docelowy można osiągnąć wieloma ścieżkami, jest to wspólna cecha systemu plików DFS, co czyni go szybkim algorytmem do zastosowania, gdy długość ścieżki jest nieistotna (tj. Przy przeszukiwaniu przestrzeni stanów w celu sprawdzenia, czy istnieje konkretny stan w przeciwieństwie do określenie najszybszej trasy do tego stanu).

Szerokość Pierwszego Wyszukiwania

Chociaż zwykły system plików DFS gwarantuje znalezienie węzła docelowego na połączonym wykresie, nie gwarantuje znalezienia najlepszej ścieżki do tego węzła - ścieżki zawierającej najmniej krawędzi. W poprzednim przykładzie DFS dało ścieżkę obejmującą trzy krawędzie, mimo że najlepsza ścieżka obejmuje dwie: 5-4-3. Algorytm BFS wysuwa się z węzła źródłowego i bada każdy z węzłów, do których prowadzą jego krawędzie, przed rozwinięciem z tych węzłów i sprawdzanie wszystkich krawędzi, z którymi się łączą itd. Możesz myśleć o wyszukiwaniu jako eksploracji wszystkich węzłów, które są jedną krawędzią od węzła źródłowego, następnie wszystkie węzły dwie krawędzie dalej, a następnie trzy krawędzie i tak dalej, aż do znalezienia węzła docelowego. Dlatego też, gdy tylko cel zostanie zlokalizowany, ścieżka do niego prowadząca ma gwarancję jak najmniejszej liczby krawędzi. (Mogą istnieć inne ścieżki o równej długości, ale nie będzie krótszej ścieżki).

Implementacja algorytmu

Algorytm dla BFS jest prawie dokładnie taki sam, jak dla DFS, z tym wyjątkiem, że używa stosu kolejki pierwsze wejście, pierwsze wyjście (FIFO) zamiast stosu. W związku z tym krawędzie czasu są pobierane z kolejki w tej samej kolejności, w jakiej są umieszczane w kolejce. Spójrz na kod źródłowy metody wyszukiwania BFS.

template
bool Graph_SearchBFS< graph_type>::Search()
{
// stwórz standardową kolejkę krawędzi wskaźnika
std::queue Q;
// utwórz atrapę i umieść w kolejce
const Edge Dummy(m_iSource, m_iSource, 0);
Q.push(&Dummy);
// zaznacz węzeł źródłowy jako odwiedzony
m_Visited[m_iSource] = visited;
// gdy w kolejce są krawędzie, szukaj dalej
while (!Q.empty())
{
// złap następną krawędź
const Edge* Next = Q.front();
Q.pop();
// zaznacz element nadrzędny tego węzła
m_Route[Next->To()] = Next->From();
// wyjście, jeśli cel został znaleziony
if (Next->To() == m_iTarget)
{
return true;
}
// pchnij krawędzie prowadzące od węzła na końcu tej krawędzi
// do kolejki
graph_type::ConstEdgeIterator ConstEdgeItr(m_Graph, Next->To());
for (const Edge* pE=ConstEdgeItr.begin();
!ConstEdgeItr.end();

pE=ConstEdgeItr.next())
{
// jeśli węzeł jeszcze nie był odwiedzany, możemy go przesłać na
// krawędź do kolejki
if (m_Visited[pE->To()] == unvisited)
{
Q.push(pE);
// węzeł jest tutaj oznaczony jako odwiedzony, ZANIM zostanie sprawdzony, ponieważ
// zapewnia, że w kolejce będzie zawsze umieszczonych maksymalnie N krawędzi,
// zamiast krawędzi E.
m_Visited[pE->To()] = visited;
}
}
}
// brak ścieżki do celu
return false;
}

Aby wyjaśnić ci algorytm, przejdźmy do tego samego przykładu, co poprzednio. Rysunek poniźszy odświeży twoją pamięć.



BFS zaczyna się jak DFS. Najpierw tworzona jest sztuczna krawędź [5-5] i wpychana do kolejki. Następnie węzeł źródłowy jest oznaczony jako odwiedzony.



Następnie algorytm odnotowuje rodzica 5. Tak jak poprzednio, ponieważ pierwszą krawędzią, którą należy wziąć pod uwagę, jest krawędź fikcyjna, węzeł 5 jest ustawiony jako macierzysty. Ta krawędź jest następnie usuwana z przodu kolejki i dodawane są wszystkie sąsiednie krawędzie węzła 5 (te skierowane do niezapowiedzianych węzłów).



Do tej pory wszystko wyglądało bardzo podobnie do DFS, ale tutaj algorytm zaczyna się różnić. Następnie krawędź [5-6] jest usuwana z kolejki. Zauważono, że węzeł 5 jest rodzicem węzła 6. Ponieważ obie sąsiednie krawędzie węzła 6 wskazują na wcześniej odwiedzane węzły, nie są dodawane do kolejki.



Dalej w kolejce jest krawędź [5-4]. Zauważono, że węzeł 5 jest rodzicem węzła 4. Węzeł 4 ma trzy sąsiednie krawędzie, ale tylko krawędź [4-3] wskazuje na nieoznaczony węzeł, więc jest to jedyny umieszczony w kolejce.



Dalej jest krawędź [5-2]. Zauważono, że węzeł 5 jest rodzicem węzła 2, a krawędź [2-1] jest umieszczona w kolejce.



Krawędź [4-3] jest następna. Węzeł 4 jest uważany za element nadrzędny węzła 3. Ponieważ węzeł 3 jest również węzłem docelowym, w tym momencie algorytm wychodzi.



Używając wektora m_Routes do przejścia przez rodziców z węzła docelowego do źródła, otrzymujemy ścieżkę 3-4-5. Jest to ścieżka między dwoma zawierającymi najmniej krawędzi… najlepsza ścieżka.

WSKAZÓWKA. Możesz przyspieszyć BFS (i wiele innych algorytmów wyszukiwania wykresów), uruchamiając jednocześnie dwa wyszukiwania, jedno rozpoczęte w węźle źródłowym, a drugie w węźle docelowym i zatrzymując wyszukiwanie, gdy się spotkają. Nazywa się to wyszukiwaniem dwukierunkowym.

BFS w akcji

Uruchommy ponownie program PathFinder, aby zobaczyć, jak BFS działa w prawdziwym świecie. Przede wszystkim myślę, że przydałby ci się prosty przykład pokazany na zrzucie ekranu 5.4. (Jeśli korzystasz z programu PathFinder, załaduj plik no_obstacles_source_target_close.map i kliknij przycisk BF na pasku narzędzi.)



W tym przypadku nie ma żadnych przeszkód. Węzeł źródłowy jest umieszczony blisko węzła docelowego, a tylko kilka innych węzłów (kafelków) oddziela je. Ponownie gruba linia pokazuje ścieżkę znalezioną przez algorytm BFS. Cienkie linie reprezentują wszystkie krawędzie, które algorytm odwiedził w drodze do celu. To wyraźnie pokazuje, jak wentylatory BFS wychodzą z węzła źródłowego, aż do znalezienia węzła docelowego. Odwiedzane krawędzie tworzą kwadratowy kształt, ponieważ BFS, podobnie jak DFS, traktuje wszystkie krawędzie tak, jakby były równej długości. Ścieżka skręca w lewo, a następnie w prawo zamiast z tego samego powodu bezpośrednio w kierunku węzła docelowego. Oba wykonują tę samą liczbę kroków, ale kształt ścieżki jest całkowicie zależny od kolejności, w której eksplorowane są krawędzie każdego węzła. Zrzut ekranu 5.5 pokazuje działanie BFS na tej samej mapie, którą widzieliśmy wcześniej. Poprawa długości ścieżki jest wyraźna, chociaż należy się tego spodziewać, ponieważ zwykły system plików DFS nie nadaje się do wyszukiwania najkrótszych ścieżek. Jeszcze raz zwróć uwagę, jak każda krawędź znajduje się na tej samej głębokości z dala od węzła źródłowego, ponieważ cel został odwiedzony.



Niestety, ponieważ BFS jest tak systematyczny w swojej eksploracji, może okazać się niewygodny w użyciu na czymkolwiek innym niż małe przestrzenie wyszukiwania. Jeśli oznaczymy współczynnik rozgałęzienia jako b, a liczbę krawędzi, na której węzeł docelowy jest oddalony od źródła jako d (głębokość), wówczas liczbę zbadanych węzłów podaje równanie



Jeśli badany wykres jest bardzo duży i ma wysoki współczynnik rozgałęzienia, BFS zaprzepaści dużo pamięci i będzie słabo działać. Co gorsza, jeśli przestrzeń stanu ma tak wysoki współczynnik rozgałęzienia, że uniemożliwia utworzenie pełnego wykresu przed wyszukiwaniem, wymagając od BFS rozszerzenia węzłów podczas eksploracji, wyszukiwanie może potrwać dosłownie lata. W swojej książce Artificial Intelligence: A Modern Approach Russell i Norvig podają przykład układanki o współczynniku rozgałęzienia równym 10; zakładając, że rozwinięcie każdego węzła zajmuje jedną milisekundę, BFS zajmie 3500 lat, aby osiągnąć głębokość 14! Komputery stały się znacznie szybszymi bestiami od czasu wydania pierwszego wydania tej książki, ale mimo to nadal byłbyś starszym człowiekiem w ilości czasu, jaką BFS zajmuje do tej głębokości.

Wyszukiwania oparte na kosztach

W przypadku wielu domen problemowych powiązany wykres będzie kosztował (czasami określany jako ciężar) związany z przechodzeniem przez krawędź. Na przykład wykresy nawigacyjne zwykle mają krawędzie o koszcie proporcjonalnym do odległości między połączonymi węzłami. Aby znaleźć najkrótsze ścieżki na wykresie, należy wziąć pod uwagę te koszty. Po prostu nie wystarczy - tak jak poprzednio z BFS - znaleźć ścieżkę zawierającą najmniejszą liczbę krawędzi, ponieważ przy powiązanym koszcie podróżowanie w dół wielu krótszych krawędzi może być znacznie tańsze niż dwóch długich.

Chociaż można użyć BFS lub DFS do wyczerpującego wyszukiwania przez wszystkie trasy do węzła docelowego, sumując koszty każdej ścieżki, a następnie wybierając ścieżkę o najniższych kosztach, jest to oczywiście bardzo nieefektywne rozwiązanie. Na szczęście mamy do dyspozycji znacznie lepsze metody.

Edge Relaxation

Algorytmy wyszukiwania omówione w pozostałej części oparte są na technice zwanej relaksacją krawędzi. W trakcie działania algorytm zbiera informacje o najlepszej znalezionej do tej pory ścieżce (BPSF) od węzła źródłowego do dowolnego innego węzła w drodze do celu. Informacje te są aktualizowane w miarę sprawdzania nowych krawędzi. Jeśli nowo zbadana krawędź wnioskuje, że ścieżka do węzła może zostać skrócona poprzez użycie jej zamiast istniejącej najlepszej ścieżki, wówczas ta krawędź jest dodawana i ścieżka jest odpowiednio aktualizowana. Ten proces relaksacji, podobnie jak w przypadku wszystkich operacji graficznych, jest znacznie łatwiejszy do zrozumienia, obserwując schemat. Spójrz na rysunek



Na wykresie pokazanym w A najlepsza ścieżka od 1 do 4 przez 3 nie jest poprawiona przez zbadanie krawędzi [5-4]. Dlatego relaks nie jest konieczny. Jednak w przypadku wykresu B krawędź [5-4] może zostać wykorzystana do stworzenia krótszej ścieżki do 4; w rezultacie BPSF musi zostać odpowiednio zaktualizowany poprzez zmianę elementu nadrzędnego węzła 4 z 3 na 5 (podając ścieżkę 1 - 2 - 5 - 4). Proces ten nazywa się rozluźnieniem krawędzi, ponieważ naśladuje sposób, w jaki kawałek sprężysty rozciągnięty wzdłuż krawędzi BPSF rozluźniłby się (stałby się mniej napięty), gdy zostanie znaleziona krawędź, która ułatwia krótszą ścieżkę. Każdy algorytm utrzymuje zmienną std :: wektor liczb zmiennoprzecinkowych (indeksowanych według węzłów) reprezentujących najlepszy całkowity koszt dla każdego węzła znaleziony do tej pory przez algorytm. Biorąc pod uwagę ogólny przypadek pokazany na rysunku 5.32, pseudokod do rozluźnienia ścieżki wyglądałby mniej więcej tak:

if (TotalCostToThisNode [t]> TotalCostToThisNode [n] + EdgeCost (n-to-t))
{
TotalCostToThisNode [t] = TotalCostToThisNode [n] + EdgeCost (n-to-t));
Parent (t) = n;
}



Drzewa najkrótszej ścieżki

Biorąc pod uwagę wykres, G i węzeł źródłowy, drzewo najkrótszej ścieżki (SPT) jest poddrzewa G, które reprezentuje najkrótszą ścieżkę od dowolnego węzła na SPT do węzła źródłowego. Ponownie, zdjęcie jest warte tysiąca słów, więc spójrz na Rysunek 5.33. Pokazuje SPT z korzeniem ustawionym w węźle 1.



Poniższe algorytmy znajdują najkrótsze ścieżki na wykresach ważonych przez "wyhodowanie" najkrótszego drzewa ścieżek na zewnątrz od węzła źródłowego.

Algorytm Dijkstry

Profesor Edsger Wybe Dijkstra wniósł do informatyki wiele cennych wkładów, ale jednym z najbardziej znanych jest jego algorytm znajdowania najkrótszych ścieżek na wykresach ważonych. Algorytm Dijkstry buduje najkrótsze drzewo ścieżek po jednej krawędzi, najpierw dodając węzeł źródłowy do SPT, a następnie dodając krawędź, która daje najkrótszą ścieżkę od źródła do węzła, który nie jest jeszcze w SPT. W wyniku tego procesu SPT zawiera najkrótszą ścieżkę od każdego węzła na wykresie do węzła źródłowego. Jeśli algorytm zostanie dostarczony z węzłem docelowym, proces zostanie zakończony, gdy tylko zostanie znaleziony. W momencie zakończenia algorytmu wygenerowany SPT będzie zawierał najkrótszą ścieżkę do węzła źródłowego od węzła docelowego i od każdego węzła odwiedzonego podczas wyszukiwania.

UWAGA HISTORYCZNA, Dijkstra jest również znany z tego, że zaprojektowała i zakodował kompilator Algol 60 oraz żarliwie potępia użycie instrukcji goto w programowaniu. Lubię też jego powiedzenie, że "pytanie, czy komputery mogą myśleć, jest jak pytanie, czy łodzie podwodne potrafią pływać". Niestety Dijkstra zmarł w 2002 roku na raka. Przejdźmy przez przykład z wykorzystaniem tego samego wykresu, który widziałeś na powyższym rysunku, ale w tym przypadku węzłem źródłowym będzie węzeł 5. Najpierw węzeł 5 jest dodawany do SPT, a krawędzie, które go opuszczają, są umieszczane na granicy wyszukiwania.



Algorytm następnie sprawdza docelowe węzły krawędzi na granicy - 6 i 2 - i dodaje ten najbliższy źródłu (węzeł 2 w odległości 1.9) do SPT. Następnie wszelkie krawędzie opuszczające węzeł 2 są dodawane do granicy.



Algorytm ponownie sprawdza docelowe węzły krawędzi na granicy. Koszt dotarcia do węzła 3 ze źródła wynosi 5,0, a koszt dotarcia do węzła 6 to 3,0. Węzeł 6 jest zatem następnym węzłem, który zostanie dodany do SPT, a wszystkie krawędzie, które go opuszczą, zostaną dodane do granicy.



Proces powtarza się jeszcze raz. Ponieważ koszt węzła 4 jest mniejszy niż koszt węzła 3, jest on dodawany do SPT. Tym razem jednak jedyna krawędź z węzła 4 prowadzi do węzła 3 - węzła, który jest już węzłem docelowym krawędzi na granicy. Tutaj zaczyna się relaks krawędzi. Analizując obie możliwe ścieżki do 3, algorytm widzi, że ścieżka 5 - 2 - 3 ma koszt 5,0, a ścieżka 5 - 6 - 4 - 3 wyższy koszt 7,8. Dlatego krawędź [2-3] pozostaje na SPT, a krawędź [4-3] zostaje usunięta z dalszych rozważań.



Wreszcie węzeł 3 jest dodawany do SPT. Zobacz rysunek 5.38. Zwróć uwagę, że krawędź [3-5] nie została dodana do granicy. Wynika to z faktu, że węzeł 5 jest już w SPT i nie wymaga dalszego rozpatrywania. Dodatkowo zauważ, że węzeł 1 nie został dodany do SPT. Ponieważ istnieją tylko krawędzie prowadzące od węzła 1, jest on skutecznie izolowany od innych węzłów na wykresie.



Implementowanie algorytmu Dijkstry

Wdrożenie algorytmu najkrótszej ścieżki Dijkstry może być początkowo bardzo zrozumiałe i przyznaję, że nie mogłem się doczekać napisania tej części, ponieważ uważam, że nie będzie to łatwiejsze do wyjaśnienia! Myślę, że oboje musimy wziąć głęboki oddech, zanim przejdziemy dalej. To jest o wiele lepsze. Dobra, zacznę od pokazania deklaracji klasy. Komentarze w kodzie zawierają wyjaśnienia każdej ze zmiennych składowych, z których większość powinna brzmieć znajomo.

template
class Graph_SearchDijkstra
{
private:
// utwórz typy typef dla typów węzłów i krawędzi używanych przez wykres
typedef typename graph_type::EdgeType Edge;
typedef typename graph_type::NodeType Node;
private:
const graph_type & m_Graph;
// ten wektor zawiera krawędzie, które składają się na najkrótsze drzewo ścieżek -
// ukierunkowane poddrzewo wykresu, które zawiera najlepsze ścieżki
// każdy węzeł SPT do węzła źródłowego.
std::vector m_ShortestPathTree;
// jest to indeksowane według indeksu węzłów i zawiera całkowity koszt najlepszych
// ścieżka znaleziona do danego węzła. Na przykład m_CostToThisNode [5]
// utrzyma całkowity koszt wszystkich krawędzi, które tworzą najlepszą ścieżkę
// do węzła 5 znalezionego do tej pory w wyszukiwaniu (jeśli węzeł 5 jest obecny i ma
// odwiedzono oczywiście).
std::vector m_CostToThisNode;
// jest to indeksowany (według węzłów) wektor "macierzystych" krawędzi prowadzących do węzłów
// podłączony do SPT, ale jeszcze go nie dodano.
std::vector m_SearchFrontier;
int m_iSource;
int m_iTarget;
void Search();
public:
Graph_SearchDijkstra(const graph_type& graph,
int source,
int target = -1):m_Graph(graph),
m_ShortestPathTree(graph.NumNodes()),
m_SearchFrontier(graph.NumNodes()),
m_CostToThisNode(graph.NumNodes()),
m_iSource(source),
m_iTarget(target)
{
Search();
}
// zwraca wektor krawędzi definiujących SPT. Jeśli podano cel
// w konstruktorze, będzie to SPT zawierający wszystkie węzły
// sprawdzane przed znalezieniem celu, w przeciwnym razie będzie on zawierał wszystkie węzły
// na wykresie.
std::vector GetAllPaths()const;
// zwraca wektor indeksów węzłów obejmujących najkrótszą ścieżkę // od źródła do celu. Oblicza ścieżkę, pracując // wstecz przez SPT z węzła docelowego std::list GetPathToTarget()const;
//zwraca całkowity koszt do celu
double GetCostToTarget()const;
};

Ten algorytm wyszukiwania jest implementowany przy użyciu indeksowanej kolejki priorytetowej. Kolejka priorytetowa, w skrócie PQ, to kolejka, która porządkuje elementy według priorytetu (wtedy nie ma niespodzianek). Ten typ struktury danych można wykorzystać do przechowywania docelowych węzłów krawędzi na granicy wyszukiwania, w kolejności rosnącej odległości (kosztu) od węzła źródłowego. Ta metoda gwarantuje, że węzeł z przodu PQ będzie węzłem, który nie jest jeszcze w SPT, który jest najbliżej węzła źródłowego. Czy do tej pory mam sens? Jeśli nie, proszę o zniesławienie mnie przed ponownym przeczytaniem tego akapitu. PQ musi być w stanie utrzymać przechowywane w nim elementy w uporządkowanej kolejności. Oznacza to, że każdy węzeł wykresu musi mieć dodatkową zmienną składową, aby przechowywać koszty skumulowane w tym węźle, aby operatorzy "lub" mogli zostać przeciążeni, aby zapewnić prawidłowe zachowanie. Chociaż użycie dodatkowej zmiennej składowej jest z pewnością poprawnym rozwiązaniem, wolałbym nie zmieniać istniejącego węzła grafu, a poza tym może to być problematyczne, gdy uruchomionych jest wiele wyszukiwań jednocześnie, ponieważ każde wyszukiwanie będzie wykorzystywać ten sam rekord danych . Można temu zaradzić, tworząc kopie węzłów, ale wówczas cenna pamięć i szybkość zostają utracone. Alternatywą jest użycie indeksowanej kolejki priorytetowej (w skrócie iPQ). Ten typ PQ indeksuje się do wektora kluczy. W tym przykładzie klucze to skumulowane koszty dla każdego węzła przechowywane w wektorze m_CostToThisNode. Węzeł jest dodawany do kolejki przez wstawienie jego indeksu. Podobnie, gdy węzeł jest pobierany z iPQ, zwracany jest jego indeks, a nie sam węzeł (lub wskaźnik do węzła). Tego indeksu można następnie użyć do uzyskania dostępu do węzła i jego danych za pośrednictwem m_Graph :: GetNode. Czas pokazać ci kod źródłowy. Upewnij się, że nie spiesz się i rozumiesz każdą linię tego algorytmu; na dłuższą metę przyniesie ci dywidendy. Napisałem obszerne komentarze w źródle, aby pomóc ci zrozumieć, ale jeśli jesteś zwykłym śmiertelnikiem, wątpię, aby same komentarze wystarczyły, by "kliknąć" przy pierwszym czytaniu. (Jeśli po kilku odczytach nadal masz problemy z algorytmem, zdecydowanie zalecamy przejrzenie kodu na kartce papieru na prostym przykładzie).

template
void Graph_SearchDijkstra::Search()
{
// utwórz indeksowaną kolejkę priorytetową, która będzie sortowana od najmniejszej do największej
//(od przodu do tyłu). Zauważ, że maksymalna liczba elementów iPQ
// może zawierać jest NumNodes (). Wynika to z faktu, że żaden węzeł nie może być reprezentowany
// w kolejce więcej niż jeden raz.
IndexedPriorityQLow pq(m_CostToThisNode, m_Graph.NumNodes());
// umieść węzeł źródłowy w kolejce
pq.insert(m_iSource);
// gdy kolejka nie jest pusta
while(!pq.empty())
{
// pobierz węzeł o najniższym koszcie z kolejki. Nie zapomnij o wartości zwracanej
// jest * indeksem węzła *, a nie samym węzłem. Ten węzeł nie jest jeszcze węzłem
// w SPT, który jest najbliżej węzła źródłowego
int NextClosestNode = pq.Pop();
// przenieś tę krawędź z granicy wyszukiwania do drzewa najkrótszej ścieżki
m_ShortestPathTree[NextClosestNode] = m_SearchFrontier[NextClosestNode];
// jeśli cel został znaleziony, wyjdź
if (NextClosestNode == m_iTarget) return;
// teraz, aby rozluźnić krawędzie. Dla każdej krawędzi podłączonej do następnego najbliższego węzła
graph_type::ConstEdgeIterator ConstEdgeItr(m_Graph, NextClosestNode);
for (const Edge* pE=ConstEdgeItr.begin();
!ConstEdgeItr.end();
pE=ConstEdgeItr.next())
{
// całkowity koszt dla węzła, na który wskazuje ta krawędź, to koszt dla
// aktualny węzeł plus koszt połączenia krawędzi.
double NewCost = m_CostToThisNode[NextClosestNode] + pE->Cost();
// jeśli ta krawędź nigdy nie była na granicy, zanotuj koszt
// aby dotrzeć do węzła, na który wskazuje, a następnie dodaj krawędź do granicy
// i węzeł docelowy do PQ.
if (m_SearchFrontier[pE->To()] == 0)
{
m_CostToThisNode[pE->To()] = NewCost;
pq.insert(pE->To());
m_SearchFrontier[pE->To()] = pE;
}
// else przetestuj, czy koszt dotarcia do węzła docelowego za pośrednictwem
// aktualny węzeł jest tańszy niż najtańszy dotychczas znaleziony koszt. Gdyby
// ta ścieżka jest tańsza, przypisujemy nowy koszt do miejsca docelowego
// węzeł, zaktualizuj jego wpis w PQ, aby odzwierciedlić zmianę, i dodaj
// krawędź do granicy
else if ( (NewCost < m_CostToThisNode[pE->To()]) &&
(m_ShortestPathTree[pE->To()] == 0) )
{
m_CostToThisNode[pE->To()] = NewCost;
// ponieważ koszt jest mniejszy niż poprzednio, PQ musi być
// uciekł się do tego.pq.ChangePriority(pE->To());
m_SearchFrontier[pE->To()] = pE;
}
}
}
}

WSKAZÓWKA. Implementacja indeksowanej kolejki priorytetowej wykorzystuje stertę dwukierunkową do przechowywania elementów. W przypadku rzadkich wykresów, jeśli każda zbadana krawędź powoduje poprawę kosztów (wymaganie, aby IndexedPriorityQLow :: ChangePriority nył wywołany), algorytm podaje najgorszy czas działania Elog2N, chociaż w praktyce czas działania będzie znacznie krótszy.

Dalsze ulepszenia prędkości można uzyskać, stosując stertę d-way, gdzie d jest funkcją gęstości wykresu. Tym razem najgorszym czasem działania będzie ElogdN. Gdy wszystko jest już powiedziane i zrobione, algorytm najkrótszej ścieżki Dijkstry jest ładny dobry wykonawca i gwarantuje znalezienie najkrótszej ścieżki między dwoma węzłami, jeśli taki istnieje.

Algorytm Dijkstry w działaniu

Uruchommy ponownie program PathFinder i sprawdźmy, jak działa algorytm Dijkstry na przykładach, które widzieliśmy wcześniej. Zrzut ekranu ilustruje algorytm działający na prosty problem



Wynik jest podobny do szerokości pierwszego wyszukiwania, chociaż teraz drzewo zawierające badane krawędzie ma okrągły kształt. Wynika to z faktu, że algorytm Dijkstry działa z rzeczywistymi kosztami krawędzi, dlatego tym razem krawędzie ukośne kosztują więcej, niż poziomy lub pionowy. Mając to na uwadze, możesz zobaczyć, jak algorytm szukał podobnej odległości we wszystkich kierunkach przed osiągnięciem celu. Zrzut ekranu pokazuje algorytm Dijkstry działający na bardziej złożonej mapie.



Podobnie jak BFS, algorytm Dijkstry analizuje bardzo dużo krawędzi. Czy nie byłoby wspaniale, gdyby algorytm mógł otrzymywać wskazówki w miarę przesuwania wyszukiwania we właściwym kierunku? Na szczęście dla nas jest to możliwe. Panie i panowie! Proszę, złóżcie oklaski i powitajcie A *!

Dijkstra z niespodzianką: A*

Algorytm Dijkstry szuka, minimalizując dotychczasowy koszt ścieżki. Można go znacznie poprawić, biorąc pod uwagę, umieszczając węzły na granicy, szacowany koszt do celu z każdego rozważanego węzła. Oszacowanie to jest zwykle określane jako heurystyczne, a nazwa nadana algorytmowi wykorzystującemu tę metodę heurystycznie kierowanego wyszukiwania to A * (wymawiana ay-star). I to jest naprawdę dobre! Jeśli heurystyka stosowana przez algorytm określa dolną granicę rzeczywistego kosztu (nie docenia kosztu) z dowolnego węzła do celu, wówczas A* gwarantuje optymalne ścieżki. W przypadku wykresów zawierających informacje przestrzenne, takich jak wykresy nawigacyjne, można użyć kilku funkcji heurystycznych, z których najprostszą jest odległość w linii prostej między danymi węzłami. Jest to czasami określane jako odległość euklidesowa. A* przebiega prawie identycznie jak algorytm wyszukiwania Dijkstry. Jedyna różnica polega na obliczeniu kosztów węzłów na granicy wyszukiwania. Skorygowany koszt F dla węzła umieszczonego w kolejce priorytetowej (granicy wyszukiwania) oblicza się jako:

F = G + H (5.3)

gdzie G jest łącznym kosztem dotarcia do węzła, a H jest heurystycznym oszacowaniem odległości do celu. W przypadku krawędzi E, która właśnie zeszła z granicy i została dodana do SPT, pseudokod do obliczenia kosztu do węzła docelowego wygląda następująco:

Koszt = Koszt skumulowany (E.From) + E.Koszt + KosztTo (docelowy)

Wykorzystując w ten sposób heurystykę, zmodyfikowane koszty kierują wyszukiwanie w kierunku węzła docelowego zamiast promieniować równo na zewnątrz we wszystkich kierunkach. Powoduje to konieczność zbadania mniejszej liczby krawędzi, co przyspiesza wyszukiwanie i jest podstawową różnicą między algorytmem A * i algorytmem Dijkstry.

UWAGA. Jeśli ustawisz koszt heurystyczny na zero w A *, wynikowe wyszukiwanie zachowa się dokładnie tak samo jak algorytm Dijkstry. Rzućmy okiem na to, jak A * działa na wykresach problemów używanych w programie PathFinder

A* w akcji

Zrzut ekranu pokazuje wynik działania A* na prostym przykładzie przykładowym źródło-cel. Jak widać, nie uwzględniono żadnych obcych krawędzi, a ścieżka prowadzi bezpośrednio do celu. Zastosowaną funkcją heurystyczną jest odległość w linii prostej między dwoma węzłami.



Zrzut ekranu jest równie imponujący. Zauważ, jak mało krawędzi algorytm A * musiał wziąć pod uwagę przed znalezieniem celu. W rezultacie czas potrzebny na wyszukiwanie jest znacznie krótszy niż w przypadku innych wyszukiwań (mimo że do obliczenia kosztu heurystycznego wymagany jest zły pierwiastek kwadratowy).



UWAGA. Wykazano, że A* jest optymalnie wydajny. Innymi słowy, żaden inny algorytm wyszukiwania nie rozszerzy mniejszej liczby węzłów w poszukiwaniu ścieżki o najniższych kosztach między źródłem a celem.

Wdrożenie A*

Klasa A* jest bardzo podobna do Graph_SearchDijkstra. Implementacja wyszukiwania wymaga utrzymania dwóch standardowych wektorów kosztów: jeden dla kosztu F dla każdego węzła, który jest indeksowany przez kolejkę priorytetową, i jeden dla kosztu G dla każdego węzła. Ponadto podczas tworzenia instancji tej klasy należy określić jako parametr szablonu heurystykę, która ma zostać użyta. Ta konstrukcja ułatwia stosowanie niestandardowej heurystyki z klasą, podobnie jak heurystyka na odległość na Manhattanie wspomniana pod koniec tego rozdziału. Oto deklaracja klasowa, którą możesz przeczytać:

template
class Graph_SearchAStar
{
private:
// utwórz typedef dla typu krawędzi używanego przez wykres
typedef typename graph_type::EdgeType Edge;
private:
const graph_type& m_Graph;
// indeksowane według węzła. Zawiera "rzeczywisty" skumulowany koszt dla tego węzła
std::vector m_GCosts;
// indeksowane według węzła. Zawiera koszt dodania m_GCosts [n] do
// heurystycznego kosztu od n do węzła docelowego. To jest wektor
// iPQ indeksuje do.
std::vector m_FCosts;
std::vector m_ShortestPathTree;
std::vector m_SearchFrontier;
int m_iSource;
int m_iTarget

// algorytm wyszukiwania A*
void Search();
public:
Graph_SearchAStar(graph_type& graph,
int source,
int target):m_Graph(graph),
m_ShortestPathTree(graph.NumNodes()),
m_SearchFrontier(graph.NumNodes()),
m_GCosts(graph.NumNodes(), 0.0),
m_FCosts(graph.NumNodes(), 0.0),
m_iSource(source),
m_iTarget(target)
{
Search();
}
// zwraca wektor krawędzi zbadanych przez algorytm
std::vector GetSPT()const;
// zwraca wektor indeksów węzłów, które zawierają najkrótszą ścieżkę
// od źródła do celu
std::list GetPathToTarget()const;
/ zwraca całkowity koszt do celu
double GetCostToTarget()const;
};

Strategie heurystyczne do użytku z tą klasą muszą zapewniać statyczną metodę obliczania z następującym podpisem:

//oblicz koszt heurystyczny od węzła nd1 do węzła nd2 static double Calculate(const graph_type& G, int nd1, int nd2); Ponieważ wykres używany przez demo PathFinder przedstawia informacje przestrzenne, koszt heurystyczny jest obliczany jako odległość w linii prostej (znana również jako odległość euklidesowa) do węzła docelowego z każdego rozważanego węzła. Poniższy kod pokazuje, w jaki sposób taka heurystyka jest implementowana jako klasa, której można użyć jako parametru szablonu dla Graph_SearchAStar.

class Heuristic_Euclid
{
public:
Heuristic_Euclid(){}
// obliczyć odległość w linii prostej od węzła nd1 do węzła nd2
template
static double Calculate(const graph_type& G, int nd1, int nd2)
{
return Vec2DDistance(G.GetNode(nd1).Position, G.GetNode(nd2).Position);
}
}

Typ heurystyczny jest przekazywany jako parametr szablonu podczas tworzenia instancji klasy wyszukiwania A*. Oto jak program demonstracyjny PathFinder tworzy instancję wyszukiwania A * przy użyciu heurystyki euklidesowej:

//utwórz kilka czcionek typedef, aby kod wygodnie znajdował się na stronie
typedef SparseGraph NavGraph;
typedef Graph_SearchAStar AStarSearch; //utwórz kilka czcionek typedef, aby kod wygodnie znajdował się na stronie
AStarSearch AStar(*m_pGraph, m_iSourceCell, m_iTargetCell);

Implementacja metody wyszukiwania A* jest prawie identyczna jak w przypadku algorytmu najkrótszej ścieżki Dijkstry. Jedynym wyjątkiem jest to, że koszt dotarcia do określonego węzła przed umieszczeniem go na granicy jest teraz obliczany jako G + H (zamiast tylko G). Wartość H określa się, wywołując metodę statyczną heurystycznej klasy strategii.

template
void Graph_SearchAStar::Search()
{
// utwórz indeksowaną kolejkę priorytetową węzłów. Kolejka da pierwszeństwo
// do węzłów o niskich kosztach F. (F = G + H)
IndexedPriorityQLow pq(m_FCosts, m_Graph.NumNodes());
// umieść węzeł źródłowy w kolejce
pq.insert(m_iSource);
// gdy kolejka nie jest pusta
while(!pq.empty())
{
// pobierz węzeł o najniższym koszcie z kolejki
int NextClosestNode = pq.Pop();
// przenieś ten węzeł z granicy do drzewa opinającego
m_ShortestPathTree[NextClosestNode] = m_SearchFrontier[NextClosestNode];
// jeśli cel został znaleziony, wyjdź
if (NextClosestNode == m_iTarget) return;
// teraz, aby przetestować wszystkie krawędzie dołączone do tego węzła
graph_type::ConstEdgeIterator ConstEdgeItr(m_Graph, NextClosestNode);
for (const Edge* pE=ConstEdgeItr.begin();
!ConstEdgeItr.end();
pE=ConstEdgeItr.next())
{
// oblicz koszt heurystyczny od tego węzła do celu (H)
double HCost = heuristic::Calculate(m_Graph, m_iTarget, pE->To());
// obliczyć "rzeczywisty" koszt dla tego węzła ze źródła (G)
double GCost = m_GCosts[NextClosestNode] + pE->Cost();
// jeśli węzeł nie został dodany do granicy, dodaj go i zaktualizuj
// koszty G i F.
if (m_SearchFrontier[pE->To()] == NULL)
{
m_FCosts[pE->T()] = GCost + HCost;
m_GCosts[pE->To()] = GCost;
pq.insert(pE->To());
m_SearchFrontier[pE->To()] = pE;
}
// jeśli ten węzeł znajduje się już na granicy, ale koszt dotarcia tutaj
// droga jest tańsza niż wcześniej znaleziona, zaktualizuj koszty węzła
// i odpowiednio granica
else if ((GCost < m_GCosts[pE->To()]) &&
(m_ShortestPathTree[pE->To()]==NULL))
{
m_FCosts[pE->To()] = GCost + HCost;
m_GCosts[pE->To()] = GCost;
pq.ChangePriority(pE->To());
m_SearchFrontier[pE->To()] = pE;
}
}
}
}

WSKAZÓWKA. Jeśli pracujesz ze ścisłymi wymaganiami dotyczącymi pamięci, możesz ograniczyć ilość pamięci używanej przez A * lub wyszukiwanie Dijkstry, ograniczając liczbę węzłów umieszczonych w kolejce priorytetowej. Innymi słowy, tylko n najlepszych węzłów jest przechowywanych w kolejce. To stało się znane jako wyszukiwanie wiązki.

Heurystyka na dystansie na Manhattanie

Widziałeś, jak można użyć klasy algorytmu wyszukiwania A* w heurystyce euklidesowej (odległość w linii prostej). Inną funkcją heurystyczną popularną wśród programistów gier, które mają tabele graficzne z topologią podobną do siatki, takich jak gry wojenne oparte na kafelkach, jest odległość Manhattanu między dwoma węzłami: suma przesunięcia w kafelkach w pionie i poziomie. Na przykład odległość Manhattanu między węzłami na rysunku wynosi 10 (6 + 4).



Odległość Manhattanu daje wzrost prędkości w stosunku do heurystyki euklidesowej, ponieważ do obliczeń nie jest wymagany pierwiastek kwadratowy

Podsumowanie<

Powinieneś teraz dobrze rozumieć wykresy i algorytmy, których możesz użyć do ich przeszukiwania. Podobnie jak w przypadku większości technik sztucznej inteligencji, twoje zrozumienie ogromnie wzrośnie dzięki praktycznemu doświadczeniu, dlatego zachęcam do podjęcia przynajmniej niektórych z poniższych problemów.

Praktyka czyni mistrza

1. Za pomocą ołówka i papieru prześledź algorytm DFS, BFS i Dijkstry dla poniższego wykresu. Dla każdego wyszukiwania użyj innego węzła początkowego i końcowego. Dodatkowe punkty zostaną przyznane za użycie stylu i koloru.



2. Utwórz heurystyczną klasę polityki odległości Manhattan, aby oszacować odległość między węzłem a celem na wykresie nawigacyjnym. Spróbuj heurystyki z różnymi wykresami. Czy jest lepsza czy gorsza niż heurystyka euklidesowa dla grafów opartych na siatce?

3. Euklidesowa heurystyka oblicza odległość w linii prostej między węzłami. Obliczenia te wymagają użycia pierwiastka kwadratowego. Utwórz heurystykę, która działa w przestrzeni z kwadratem odległości i zwróć uwagę na kształt tworzonych ścieżek.

4. Utwórz program, aby znaleźć najlepsze rozwiązanie dla wież typu n-disk. Układanka Hanoi, w której n może być dowolną liczbą całkowitą dodatnią. Aby to zrobić, musisz przepisz algorytm BFS, aby węzły i krawędzie zostały dodane do pliku wykres stanu w trakcie wyszukiwania. To doskonały sposób na przetestowanie twoje zrozumienie materiału przedstawionego w tej części

5. Teraz zmodyfikuj algorytm utworzony w 4, aby wyszukać najlepsze rozwiązanie za pomocą iteracyjnego pogłębiania DFS. Jak to się porównuje?

6. Użyj algorytmu A*, aby rozwiązać potasowaną Kostkę Rubika. Dużo uwagi poświęć zaprojektowaniu funkcji heurystycznej. Jest to trudny problem, szukając potencjalnie ogromnej przestrzeni wyszukiwania, więc najpierw wypróbuj swój algorytm na sześcianie oddalonej tylko o jeden obrót od rozwiązania, a następnie o dwa itd.