A Vida Secreta dos Grafos Este capítulo focará em um conceito abstrato matemático muito útil chamado grafo. Você usará muitos grafos na IA de jogos. Na verdade você já os viu: Os diagramas de transição de estado do Capítulo 2 são um tipo de grafo. Os grafos e suas irmãs caçulas, as árvores, são usadas pelos programadores de IA para jogos o tempo todo. Eles podem ser usados para uma grande variedade de coisas – de permitir que agentes de jogo viajem entre dois pontos eficientemente, decidir o que construir a seguir em jogos de estratégia e resolver puzzles. A primeira parte deste capítulo será gasta introduzindo você aos diferentes tipos de grafos e a terminologia associada com eles. Você aprenderá o que os grafos realmente são, como eles podem ser usados e como codificá-los eficientemente. O resto do capítulo descreverá com muitos detalhes, várias das técnicas de algoritmos de procura disponíveis para fazer bom uso de todo o poder dos grafos. 1. Grafos Quando desenvolvendo IA para jogos, um dos usos dos grafos mais comum é para representar redes de caminhos que um agente pode usar para navegar pelo ambiente. Quando eu aprendi isto, eu fiquei confuso, pois em toda minha vida em sabia que um grafo parecia como que eu desenhava na escola – algo como a forma familiar mostrada na Figura 5.1, por exemplo. Figura 5.1: Um típico grafo Eu sempre achava que grafos eram úteis para visualizar as subidas e descidas de alguma propriedade, como a temperatura mostrada pelos repórteres meteorológicos na TV ou representações de vendas, coisas como isso, e assim eu fiquei maravilhado com como este tipo de grafo pode ser usado para representar caminhos ao redor de muros e obstáculos em um ambiente de jogo. Se você nunca estudou a teoria dos grafos, então esta possivelmente pode ser a maneira como você os vê também. Eu achava que eles eram úteis apenas para uma coisa. Entretanto, deixe-me mostrar a você algo interessante. Veja a figura 5.2. Figura 5.2 Este é o mesmo grafo, mas eu mudei as marcas dos eixos para representar as coordenadas x e y de um espaço Cartesiano, adicionando alguns embelezamentos cosméticos para que agora ele represente um caminho próximo a um rio. De fato, muitas pessoas na rua chamariam ele de mapa. Certamente, a imagem inteira é um mapa, mas a série de pontos e o percurso os conectando é representado por um grafo muito simples. Eu acho que alguns de vocês devem estar pensando que isto não é uma grande coisa, mas eu acredito que para muitos esta mudança súbita de perspectiva possa ser reveladora. Ela certamente foi para mim. Na terminologia dos grafos, os pontos são chamados de nodos (ou algumas vezes vetores) e os percursos conectando-os são chamados de arestas (ou algumas vezes arcos). A Figura 5.3 mostra alguns exemplos de grafos. Como você pode ver, eles podem assumir uma grande variedade de configurações. Figura 5.3: Exemplos de grafos Em um contexto mais amplo, um grafo é uma representação simbólica de uma rede, e algumas vezes as arestas podem representar um relacionamento espacial, como no exemplo discutido antes, mas nem sempre isto é o caso. Os grafos são usados para representar redes de qualquer espécie, redes de telefones, a World Wide Web, circuitos eletrônicos e redes neurais artificiais. Nota Os grafos podem ser conectados ou desconectados. Um grafo é considerado conectado quando é possível de se traçar um percurso de cada nodo a todos os outros. Os grafos A, B, D e E na Figura 5.3 são exemplos de grafos conectados. Os grafos C e F são exemplos de grafos desconectados. 1.1 Uma descrição mais formal Um grafo G, pode ser formalmente como um conjunto de nodos ou vértices N, conectados por um conjunto de arestas E. Você verá frequêntemente isto escrito como: (5.1) Se cada nodo em um grafo é etiquetado com um inteiro de 0 até (N-1), uma aresta pode agora ser referenciada pelos nodos que conecta, por exemplo, 3-5 ou 19-7. Muitos grafos possuem arestas com pesos – elas contêm informação sobre o custo de se mover de um nodo ao outro. Por exemplo, no grafo mostrado na Figura 5.2, o custo de se atravessar uma aresta é a distância entre os dois nodos que ela conecta. Em um gráfico que representa uma árvore de tecnologias em jogos RTS como Warcraft, as arestas podem indicar os recursos necessários para se melhorar cada unidade. Nota Embora um grafo possa ter múltiplas conexões entre dois nodos ou conexões em laço em um único nodo, essas funções são raramente necessárias em IA para jogos e não serão consideradas nas próximas páginas. 1.2 Árvores A maioria dos programadores são conhecedores da estrutura de dados em árvore. As árvores são profundamente utilizadas por todas as disciplinas de programação. Entretanto, você pode não entender que as árvores são um subconjunto dos grafos compreendendo todos os grafos acíclicos (os que não contém nenhum caminho cíclico). O grafo E na Figura 5.3 é uma árvore, e provavelmente é a forma com que você está familiarizado, mas o grafo D também é uma árvore. O grafo F é uma floresta de árvores. 1.3 Densidade dos grafos A relação de arestas para nodos em um grafo é esparsa ou densa. Grafos esparsos possuem poucas conexões por nodo e os grafos densos possuem muitas. A Figura 5.4 mostra um exemplo de ambos os tipos. Em ordem de reduzir a complexidade e manter os usos de CPU e memória no mínimo, você deve preferir gráficos esparsos sempre que possível, por exemplo, quando projetar um grafo para usar no planejamento de percurso (veja o Capítulo 8). Figura 5.4: Exemplos de grafos denso e esparso Saber quando um grafo é denso ou esparso é útil quando se seleciona uma estrutura de dados apropriada para codificar a estrutura do grafo, já que uma implementação que é eficiente para grafos densos, provavelmente não será eficiente para um que é esparso. 1.4 Digrafos Em geral quando assumimos que é possível viajar do nodo A para o nodo B, então também é possível se fazer o inverso. Mas isso nem sempre será o caso. Algumas vezes você precisará implementar grafos onde as conexões são direcionais. Por exemplo, seu jogo pode ter um “tobogã” posicionado entre as margens de um rio. Um agente pode ser capaz de atravessar ele só de uma maneira – de cima para baixo – então você terá que encontrar uma maneira de representar este tipo de conexão. Alternativamente, pode ser possível se viajar entre dois nodos em ambas as direção, mas o custo de cada percurso pode ser diferente. Um bom exemplo disso é se você deseja que seus agentes levem em consideração as inclinações do terreno. Afinal, é fácil para um veículo viajar eficientemente e rapidamente ladeira abaixo, mas ele gasta muito mais combustível para subir a ladeira, além de sua velocidade máxima ficar muito mais lenta. Nós podemos refletir esta informação usando um gráfico chamado digrafo, ou DAG para abreviar. Um digrafo possui arestas que são direcionadas, ou caminhos. Os nodos que definem uma aresta direcionada são conhecidos como um par ordenado e especificam a direção da aresta. Por exemplo, o par ordenado 16-6 indica que é possível se viajar do nodo 16 para o nodo 6, mas não do nodo 6 para o 16. Neste exemplo, o nodo 16 é conhecido como o nodo fonte e o nodo 6 o nodo destino. A Figura 5.5 mostra um pequeno digrafo. As arestas são desenhadas usando setas para indicar suas direções. Figura 5.5: Um simples digrafo. Note que no mundo dos digrafos, grafos não-direcionados são mais frequêntes que os direcionados. Quando projetar uma estrutura de dados para grafos é frequêntemente útil pensar em grafos nãodirecionados como digrafos com duas arestas conectando cada par conectado de nodos. Isto é conveniente, pois ambos os tipos de grafos (direcionados e não-direcionados) podem ser representados pela mesma estrutura de dados. Por exemplo, o grafo não-direcionado esparso mostrado na Figura 5.4 pode ser representado pelo digrafo mostrado na Figura 5.6. Figura 5.6: Representação de um grafo não-direcionado como um dígrafo 1.5 Grafos em IA de Jogos Antes de vermos a implementação do código para um grafo, vamos dar uma olhada nas coisas que você pode usar grafos no desenvolvimento da IA de um jogo, começando com o uso mais popular: a navegação ou pathfinding. 1.5.1 Grafos de Navegação Um grafo de navegação ou navgraph, é uma abstração de todas as localizações em um ambiente de jogo que os agentes do jogo podem visitar e de todas as conexões entre estes pontos. Consequentemente, um navgraph bem-projetado é uma estrutura de dados que incorpora todos os percursos possíveis através do ambiente do jogo e é, portanto, extremamente útil para ajudar os seus agentes de jogo a decidirem como ir de A até B. Cada nodo em um navgraph usualmente representa a posição de uma área chave ou objeto dentro do ambiente e cada aresta representa as conexões entre esses pontos. Além disso, cada aresta terá a ela associado um custo, que no caso mais simples representa a distância entre os nodos que ela conecta. Este tipo de grafo é conhecido pelos matemáticos como grafo Euclidiano. A Figura 5.7 mostra um grafo de navegação criado para um pequeno ambiente com paredes e um caminho realçado através desse grafo. Figura 5.7: Um grafo de navegação simples. A coleção de todas as arestas e nodos constitui o grafo. As arestas realçadas representam um caminho possível através do grafo. Eu gostaria de deixar claro que um agente de jogo não é restringido a se mover apenas nas arestas do grafo, como um trem viajando ao longo dos trilhos. Um agente pode se mover para qualquer posição desobstruída dentro do ambiente do jogo, mas ele usa o grafo de navegação para negociar seu ambiente para planejar os caminhos entre dois ou mais pontos e para atravessar eles. Por exemplo, se um agente posicionado em ponto A acha necessário se mover para a posição B, ele pode usar o navgraph para calcular a melhor rota entre os nodos mais próximos para estes pontos. A Figura 5.7 é um típico grafo de navegação criado para um jogo de tiro em primeira pessoa. Outros tipos de jogos podem achar uma configuração de nodos diferente mais efetiva. Os jogos do tipo RTS/RPG, por exemplo, são frequentemente baseados sobre uma grade de tiles ou células, onde cada tile representa um tipo diferente de terreno, tal como grama, estrada, lama, etc. Portanto, é conveniente se criar um grafo usando o ponto central de cada tile e designando custos de arestas baseados na distância entre as células e o peso do tipo de terreno em que a aresta atravessará. Esta abordagem permite agentes de jogo facilmente calcularem caminhos que evitem água, prefiram viajar em estradas invés de lama e circularem montanhas. A Figura 5.8 mostra a espécie de configuração de células que você pode ver em um RTS/RPG. Figura 5.8: Um típico ambiente baseado em células. Embora não mostrados por claridade, os nodos do grafo são posicionados nos centros das células, com as arestas conectando os nodos adjacentes. A demonstração PathFinder deste capítulo usa este tipo de navgraph. Por causa de alguns jogos de RTS/RPG poderem usar literalmente centenas de milhares de células, a desvantagem desta abordagem é que os grafos podem ficar extremamente dispendiosos, demorados de pesquisar e tomarem grandes quantias de memória. Felizmente para os desenvolvedores de IA, alguns destes problemas podem ser evitados se usando técnicas que você aprenderá mais tarde neste livro. Dica Se você está criando um jogo "mantenha-se nas sombras", como Thief e Thief 2 da Looking Glass Studios/Eidos Interactive, você pode usar um navgraph que tem suas arestas com pesos baseados em quanto som um personagem pode fazer ao atravessá-la. As arestas que são silenciosas de serem atravessadas como as ao longo de um tapete podem ter pesos baixos, e as arestas barulhentas podem ter valores altos. Projetar seus grafos desta maneira permite que os personagens de seu jogo encontrem o caminho mais silencioso entre duas salas. 1.5.2 Grafos de Dependências Os grafos de dependência são utilizados em tipos de jogos de administração de recursos para descrever as dependências entre as várias construções, materiais, unidades e tecnologias disponíveis para um jogador. A Figura 5.9 mostra a parte de um grafo de dependência criado para um jogo desses. Esta espécie de grafo torna fácil ver quais pré-requisitos são necessários para a criação de cada tipo de recurso. Os grafos de dependência são inestimáveis quando se projeta uma IA para este tipo de gênero, pois a IA pode usar eles para decidir estratégias, prever a condição futura de um adversário e administrar os recursos efetivamente. Aqui estão alguns exemplos baseados no grafo mostrado na figura. Figura 5.9: Um simples grafo de dependências 1. Se a IA está se preparando para uma batalha e conclui que arqueiros darão vantagem, ela pode examinar o grafo de dependência para concluir que antes dela poder produzir arqueiros, ela deve primeiro garantir que tem um quartel e a tecnologia de flechas. Com isso ela também sabe que para produzir flechas ela deve ter uma serralheria produzindo madeira. Portanto, se a IA já tem uma serralheria, ela pode administrar seus recursos para construir um quartel ou vice-versa. Se a IA não tem nem o quartel, nem uma serralheria, ela pode inspecionar o grafo de tecnologia para determinar se é vantajoso construir o quartel antes da serralheria. Por quê? Porque o quartel é um pré-requisito para três espécies diferentes de unidade de batalha, ao passo que uma serralheria é unicamente um pré-requisito para produzir madeira. A IA já determinou que uma batalha é iminente, assim ele deve compreender (se você a projetou corretamente, é claro) que deve gastar seus recursos na fabricação de unidades de batalha tão logo quanto possível, pois como todos nós sabemos, cavaleiros e soldados são guerreiros melhores que pilhas de madeira! 2. Se um soldado inimigo carregando uma arma entra no território da IA, a IA pode trabalhar para trás no grafo para concluir que: • O inimigo já deve ter construído uma forja e uma serralheria; • O inimigo já deve ter desenvolvido a tecnologia de pólvora; • O inimigo já deve estar produzindo recursos de madeira e ferro. Além disso, o exame do grafo pode indicar que o inimigo provavelmente já tem um ou outro canhão ou está os construindo. Droga! A IA pode usar esta informação para decidir o melhor plano de ataque. Por exemplo, a IA pode saber que para prevenir quaisquer inimigos carregadores de armas de alcançar seu território, ela deve focar a forja e a serralheria do inimigo. Ela também pode enviar um assassino para matar o ferreiro do inimigo, e assim o enfraquecer consideravelmente, e talvez dedique seus recursos para criar um assassino para este propósito. 3. Frequentemente, uma tecnologia ou unidade específica é a chave para a vitória da equipe em um jogo. Se os custos de construção de cada recurso são designados pelas arestas do grafo de dependências, então a IA pode usar esta informação para calcular a rota mais eficiente para produzir esse recurso. 1.5.3 Grafos de Estados Um grafo de estados é uma representação de todos os estado possíveis que um sistema pode ter e as transições entre esses estados. Esta coleção de estados em potencial de um sistema é conhecida como o espaço dos estados. Um grafo deste tipo pode ser pesquisado para ver se um estado em particular é possível ou para encontrar a rota mais eficiente para um estado específico. Vamos ver um simples exemplo usando o puzzle "Torres de Hanoi". Figura 5.10: O Torres de Hanoi Nesta versão simples do puzzle há três pinos - A, B e C - e três argolas de tamanhos diferentes que se encaixam em cima dos pinos. As argolas começam posicionadas em ordem de tamanho em cima do pino A. O objetivo do puzzle é mover as argolas até que todas elas fiquem posicionadas no pino C, também em ordem de tamanho. Apenas uma argola pode ser movida de cada vez. Uma argola pode ser colocada tanto em outro pino vazio ou em cima de outra argola que é maior que a mesma. Nós podemos representar o espaço dos estados deste puzzle usando um grafo onde cada nodo representa um dos estados possíveis que o puzzle pode ocupar. As arestas do grafo representam as transições entre os estados: Se é possível se mover diretamente de um estado para outro, então haverá uma aresta conectando os dois estados; se não, não haverá nenhuma conexão. O grafo é construído primeiro pela criação de um nodo que contém o estado inicial do puzzle. Este é conhecido como o nodo raiz. O nodo raiz é então expandido pela adição de todos os estados obteníveis a partir dele no grafo, logo então, cada um desses estados é expandido, e assim em diante, até que todos os estados e transições possíveis tenham sido adicionados no grafo. Cada estado anterior a outro, é chamado de estado pai, o novo estado é chamado de filho do estado pai. A Figura 5.11 mostra este processo. Uma seta conectando dois estados significa que um estado pode ser alcançado a partir do outro ao se mover as argolas. O grafo fica complicado rapidamente, assim eu omiti muitos dos estados possíveis para tornar mais fácil de ver um dos caminhos que conduz para uma solução. Figura 5.11: Expansão dos estados para o puzzle das Torres de Hanoi. As caixas pontilhadas indicam os estados que não foram expandidos. Um grafo de estados pode ser facilmente pesquisado para se encontrar um estado final. Neste exemplo, o estado final é um onde todas as peças estão posicionadas no pino C na ordem correta. Ao se pesquisar o espaço dos estados é possível não apenas se encontrar uma única solução, mas se encontrar todas as soluções possíveis ou a solução que requer menos movimentos (ou a que requer mais movimentos se é o que você está procurando). O número médio de nodos filhos irradiando de cada nodo pai é conhecido como fator de expansão (branching factor) de um grafo. Para alguns problemas, tal como o exemplo de puzzle que discutimos aqui, o fator de expansão é baixo - na ordem de um a três galhos por nodo – tornando possível de se representar o espaço dos estados inteiro do grafo em uma memória de computador. Porém em outros casos, o fator de expansão é muito mais alto e o número de estados em potencial cresce enormemente, assim como a distância do nodo raiz (a profundidade do gráfico) aumenta. Com esses tipos de sistemas é impossível se representar o espaço dos estados inteiramente, pois este rapidamente excederá as capacidades de memória até mesmo do computador mais poderoso. Mesmo que o grafo possa ser armazenado, este poderia tomar ainda eternidades para se fazer uma pesquisa. Consequentemente, esses tipos de grafos são criados e pesquisados ao se expandir só alguns nodos de cada vez, tipicamente (mas nem sempre) usando algoritmos que direcionam a pesquisa ao estado final. 2. Criação de uma Classe para Grafos As duas estruturas de dados mais populares utilizadas para representar os grafos são as matrizes de adjacência e as listas de adjacência. Os grafos de matriz de adjacência usam uma matriz bidimensional de Booleanos ou floats para armazenar a informação da conectividade de um grafo. Os Booleanos são utilizados se não há custo associado ao se atravessar uma aresta, já os floats são utilizadas quando há um custo associado, tal como em um gráfico de navegação onde cada aresta representa a distância entre dois nodos. A implementação exata, certamente, depende do projetista e das necessidades de seu problema. A Figura 5.12 mostra o que parece a matriz de adjacência do dígrafo na Figura 5.6. Figura 5.12: Uma matriz de adjacência Cada "1" representa uma conexão entre dois nodos, cada "0" representa a falta de uma conexão. Pela leitura dos valores diretamente da matriz da Figura 5.12, nós sabemos que não há conexão do nodo 2 com o 6, mas há uma aresta conectando 4 com 2. As matrizes de adjacência são intuitivas, mas para grandes grafos esparsos este tipo de representação é ineficiente, pois a maior parte da matriz é utilizada para armazenar valores desnecessários zero. Uma estrutura de dados muito melhor para grafos esparsos (a mais utilizada em grafos de IA de jogos) é a lista de adjacência. Para cada nodo presente, um grafo de lista de adjacência armazena uma lista encadeada com todas as arestas adjacentes. A Figura 5.13 mostra como isto funciona no exemplo prévio. Figura 5.13: Uma lista de adjacência representando o digrafo da Figura 5.6 As listas de adjacência são eficientes para armazenar grafos esparsos, pois elas não desperdiçam espaço armazenando conexões nulas. A quantia de espaço requerido para armazenar um grafo usando este tipo de estrutura de dados é proporcional a N + A (número de nodos + número de arestas), ao passo que para uma matriz de adjacência isto é proporcional a N2 (número de nodos ao quadrado). Como a maioria dos grafos que você encontrará no desenvolvimento da IA de jogos são esparsos, uma lista de adjacência frequentemente será a estrutura de dados da sua escolha. Com isto em mente, vamos ver o código fonte necessário para implementar um grafo. 2.1 A Classe GraphNode A GraphNode encapsula a informação mínima que um nodo requer para uma representação de grafo com lista de adjacência: um único número de identificação ou índice. Aqui está a declaração da classe dos nodos do grafo: class GraphNode { protected: //every node has an index. A valid index is >= 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); }; Como frequentemente você precisará que um nodo contenha informações adicionais, a GraphNode será tipicamente utilizada como uma classe base da qual se derivarão nodos personalizados. Por exemplo, os nodos de um grafo de navegação devem armazenar informação do espacial, já nodos de um grafo de dependência devem conter informações sobre o que eles representam. Uma classe de nodos projetada para ser usada dentro de um grafo de navegação pode parecer algo como esta: template < class extra_info = void*> class NavGraphNode : public GraphNode { protected: //the node's position Vector2D m_vPosition; //often you will require a navgraph node to contain additional information. //For example a node might represent a pickup such as armor in which //case m_ExtraInfo could be an enumerated value denoting the pickup type, //thereby enabling a search algorithm to search a graph for specific items. //Going one step further, m_ExtraInfo could be a pointer to the instance of //the item type the node is twinned with. This would allow a search algorithm //info. extra_info m_ExtraInfo; public: /*INTERFACE OMITTED */ }; Por favor, note que embora a classe de nodos aqui use um vetor 2D para representar a posição de um nodo, um grafo pode existir em qualquer número de dimensões que você quiser. Se você está criando um grafo de navegação para um jogo 3D, simplesmente use vetores 3D. Tudo funcionará do mesmo jeito. 2.2 A Classe GraphEdge A classe GraphEdge encapsula a informação básica requerida para denotar uma conexão entre dois nodos de um grafo. Aqui o código: class GraphEdge { protected: //An edge connects two nodes. Valid node indices are always positive. int m_iFrom; int m_iTo; //the cost of traversing the edge double m_dCost; public: //Constructors GraphEdge(int from, int to, double cost):m_dCost(cost), m_iFrom(from), m_iTo(to) {} GraphEdge(int from, int to):m_dCost(1.0), m_iFrom(from), m_iTo(to) {} GraphEdge():m_dCost(1.0), m_iFrom(invalid_node_index), m_iTo(invalid_node_index) {} Ocasionalmente é útil se poder criar um GraphEdge com ambos ou apenas um dos índices com um valor "inválido" (negativo). O valor enumerado invalid_node_index encontrado no arquivo NodeTypeEnumerations.h é utilizado aqui para iniciar os atributos From (nodo fonte) e To (nodo destino) no construtor padrão. virtual ~GraphEdge(){} int void From()const; SetFrom(int NewIndex); int void To()const; SetTo(int NewIndex); double Cost()const; void SetCost(double NewCost); }; Se você está trabalhando em uma plataforma onde uso de memória é um muito mais importante que a velocidade de pesquisa em um grafo, você pode obter bons resultados com grafos baseados em células (ou grafos de igual ou maior densidade) ao não armazenar explicitamente o custo de cada aresta. Ao invés disso, você pode economizar memória omitindo o campo de custo da classe GraphEdge e calcular o custo "no vôo" usando uma função de atributos de seus dois nodos adjacentes. Por exemplo, se o custo da aresta é igual à distância entre os dois nodos, a função poderia ser a distância Euclidiana. Algo como: //cost from A to B cost = Distance(NodeA.Position, NodeB.Position) Por causa de usualmente haver oito vezes mais arestas que nodos neste tipo de grafo, a economia de memória pode ser considerável quando grandes números de nodos são envolvidos. 2.3 A Classe SparseGraph Os nodos e as arestas de grafo são agrupados na classe SparseGraph. Esta é implementada como uma classe template, habilitando este tipo de grafo de usar qualquer tipo de nodo e aresta apropriado. Os algoritmos que operam em grafos devem ser capazes de acessar os dados dos nodos e das arestas rapidamente. Com isto em mente, a classe SparseGraph é projetada de modo que se possa acessar diretamente os números chave dos índices de cada nodo dentro um vetor de nodos do grafo (m_Nodes) e um vetor de listas de adjacência de arestas (m_Edges), dando um tempo de pesquisa de O(1). Entretanto, isto cria um problema quando um nodo é removido do grafo, já que este terá de ser removido do m_Nodes também, e com isso todo o indexamento para qualquer nodo com índice mais alto pode ser invalidado. Portanto, ao invés de apagar o nodo do vetor, seu índice é atribuído com o valor enumerado invalid_node_index, e todo os métodos da SparseGraph tratam este valor como se não haja um nodo presente. Aqui está o código da declaração da SparseGraph. template <class node_type, class edge_type> class SparseGraph { public: //enable easy client access to the edge and node types used in the graph typedef edge_type EdgeType; typedef node_type NodeType; //a few typedef typedef typedef more useful typedefs std::vector<node_type> std::list<edge_type> std::vector<EdgeList> NodeVector; EdgeList; EdgeListVector; private: //the nodes that comprise this graph NodeVector m_Nodes; //a vector of adjacency edge lists. (each node index keys into the //list of edges associated with that node) EdgeListVector m_Edges; //is this a directed graph? bool m_bDigraph; //the index of the next node to be added int m_iNextNodeIndex; /* EXTRANEOUS DETAIL OMITTED */ public: //Constructor SparseGraph(bool digraph): m_iNextNodeIndex(0), m_bDigraph(digraph){} //returns the node at the given index const NodeType& GetNode(int idx)const; //non-const version NodeType& GetNode(int idx); //const method for obtaining a reference to an edge const EdgeType& GetEdge(int from, int to)const; //non const version EdgeType& GetEdge(int from, int to); //retrieves the next free node index int GetNextFreeNodeIndex()const; //adds a node to the graph and returns its index int AddNode(NodeType node); //removes a node by setting its index to invalid_node_index void RemoveNode(int node); //methods to add and remove edges void AddEdge(EdgeType edge); void RemoveEdge(int from, int to); Note como a classe tem métodos para remover nodos e arestas. Isto é um recurso necessário se seu grafo é dinâmico e tem a capacidade de mudar como o progresso do jogo. Por exemplo, é fácil de representar uma fenda na terra causada por um terremoto ao se remover (e às vezes ao se adicionar) arestas em um grafo de navegação. Alternativamente, um jogo parecido com Command & Conquer pode adicionar e remover arestas quando os jogadores constroem ou destroem pontes e muros. //returns the number of active + inactive nodes present in the graph int NumNodes()const; //returns the number of active nodes present in the graph int NumActiveNodes()const; //returns the number of edges present in the graph int NumEdges()const; //returns true if the graph is directed bool isDigraph()const; //returns true if the graph contains no nodes bool isEmpty()const; //returns true if a node with the given index is present in the graph bool isPresent(int nd)const; //methods for loading and saving graphs from an open file stream or from //a filename bool Save(const char* FileName)const; bool Save(std::ofstream& stream)const; bool Load(const char* FileName); bool Load(std::ifstream& stream); //clears the graph ready for new node insertions void Clear(); //iterators clients may use to access nodes and edges class ConstEdgeIterator; class EdgeIterator; class NodeIterator; class ConstNodeIterator; }; A partir da informação nesta seção, você aprendeu que os grafos são ferramentas poderosas para você ter a sua disposição. Entretanto, a estrutura de dados de grafo só possui alguns poucos usos. Muito do poder dos grafos é apenas compreendido quando eles são operados por algoritmos projetados para explorá-los, ou para encontrar um nodo específico ou para encontrar um caminho entre nodos. O resto deste capítulo é dedicado a um estudo de vários desses algoritmos. 3. Algoritmos de Procura em Grafos A teoria dos garfos foi uma área de estudo popular entre matemáticos por muitos anos e numerosos algoritmos foram desenvolvidos para procura e exploração da topologia de um grafo. Além de outras coisas, ao se usar algoritmos de procura é possível: • Visitar cada nodo em um grafo, mapeando efetivamente a topologia do grafo; • Encontrar qualquer caminho entre dois nodos. Isto é útil se você quiser encontrar um nodo, mas não se importa em como você chega lá. Por exemplo, este tipo de procura pode se usado para encontrar uma (ou mais) solução do puzzle das Torres de Hanoi; • Encontrar o melhor caminho entre dois nodos. O que é “melhor” depende do problema. Se o grafo a ser pesquisado é uma grafo de navegação, o melhor caminho pode ser, o caminho mais curto entre os dois nodos, o caminho que permita o agente percorrer os dois pontos no tempo mais rápido, o caminho que evita a linha de visão dos inimigos ou o caminho mais silencioso (à la Thief). Se o grafo é um grafo de estados como o do puzzle das Torres de Hanoi, então o melhor caminho será o que chega à solução em menos etapas. Antes de ir as vias de fato, eu gostaria de tornar claro que muitos de vocês inicialmente irão achar alguns desses algoritmos difíceis de entender. De fato, eu acho que os algoritmos de procura em grafos deveriam vir com uma advertência de risco a saúde. Algo como o seguinte poderia ser apropriado: Atenção Tome cuidado! Algoritmos de procura têm a capacidade de criar nos cérebros humanos médios terríveis quantidades de frustração e confusão, levando a dor de cabeça, náusea e falta de sono. Gemidos espontâneos e excessivos não são incomuns. Por favor, fique atento a esses sintomas, eles são comuns nos primeiros estágios da curva de aprendizado e geralmente não são relevantes. A normalidade em geral retorna dentro de um período de tempo razoável. (Entretanto, se os sintomas persistirem, fique longe de estradas movimentadas, lâminas afiadas e armas carregadas. Procure assistência médica na primeira oportunidade). Falando sério, para muitas pessoas esta coisa pode ser difícil de compreender. Para essa razão eu vou tomar meu tempo explicando cada algoritmo. É muito importante que você compreende a teoria e não apenas use técnicas como "recorta e cola", pois frequentemente você pode desejar modificar um algoritmo para ele se encaixar em seus próprios requisitos. Sem uma compreensão de como essas pesquisas funcionam, qualquer modificação será quase impossível e você começara a arranhar sua cabeça em frustração. Peque um banco e sente-se. Vamos dar continuidade a isto! 3.1 Procuras em Grafos Não-Informadas Procuras em grafos não-informadas, ou procuras cegas como elas são às vezes chamadas, pesquisam um grafo sem considerar quaisquer custos de arestas associados. Elas podem distinguir nodos individuais e arestas, entretanto, habilitando elas a identificar um nodo final ou reconhecer um nodo ou aresta visitada anteriormente. Esta é a única informação requerida para elas explorarem completamente um grafo (para visitar todos os nodos) ou encontrar um caminho entre dois nodos. 3.1.1 Procura em Profundidade (Depth First Search) Conheça o pequeno Billy. Billy está na entrada de um típico parque de diversões temático: uma aglomeração de brinquedos e outras atrações com caminhos sinuosos através do parque conectando elas. Billy não têm um mapa, mas ele está ansioso para descobrir o que os brinquedos e os outros entretenimentos do parque oferecem. Felizmente, Billy conhece a teoria dos grafos e rapidamente percebe a similaridade entre a aparência do parque temático e um grafo. Ele vê que cada atração pode ser representada por um nodo e os caminhos entre elas por arestas. Sabendo isso, Billy pode garantir que vai visitar cada atração e caminhar por cada caminho usando um algoritmo de procura chamado procura em profundidade ou DFS (depth first search) para abreviar. A procura em profundidade é chamada assim, pois ela faz a procura indo tão profundo no grafo quanto for possível. Quando ela bate em um beco sem saída ela volta a um nodo mais raso onde possa continuar a exploração. Usando o parque temático como um exemplo, é assim que este algoritmo funciona: Na entrada do parque (o nodo raiz), Billy toma uma nota de sua descrição e dos caminhos que se estendem a partir dela (as arestas) em uma folha de papel. A seguir, ele escolhe um dos caminhos para descer. Ele não faz nenhuma distinção de qual - ele pode escolher um aleatoriamente, desde que não seja um caminho que ele já explorou. Toda vez que um caminho conduz Billy a uma nova atração ele toma uma nota de seu nome e dos caminhos que conectam ela. As ilustrações de A a D na Figura 5.14 demonstram os primeiros passos deste processo. As linhas pretas finas representam os caminhos inexplorados e as linhas realçadas mostram os caminhos que Billy escolheu descer. Figura 5.14 Quando ele chega na posição mostrada em D, Billy nota que não há nenhum novo caminho saindo do Pirate Ship (na terminologia dos grafos, este nodo é conhecido como um nodo terminal). Entretanto, para continuar a procura ele volta ao 3D Cinema onde há mais caminhos inexplorados para percorrer. Veja a Figura 5.15 E. Figura 5.15 Quando ele alcança o Ice Blaster há quatro caminhos inexplorados para tentar. Os primeiros dois levam ele a lugares que já visitou - a Entrada e a Funhouse - assim ele marca cada caminho como explorado antes de retroceder ao Ice Blaster para tentar outra rota. Eventualmente ele encontra um caminho conduzindo as Slot Machines. Veja Figura 5.15 F, G e H. Este processo de se mover através do grafo o mais longe possível antes de retroceder para caminhos previamente inexplorados continua até que o parque temático inteiro seja mapeado. As etapas de I a L na Figura 5.15 mostram alguns dos próximos passos do processo. A Figura 5.16 mostra o grafo terminado, após Billy ter visitado todas as atrações e andado por todos os caminhos. Figura 5.16: O mapa completo de Billy Nota Dado um nodo raiz, a procura em profundidade pode apenas garantir que todos os nodos e arestas serão visitados em um grafo conectado. Lembre, um grafo conectado é um onde qualquer nodo pode ser alcançado de qualquer outro. Se você está pesquisando em um grafo não-conectado, tal como C na Figura 5.3, então o algoritmo deve ser expandido para incluir um nodo raiz para cada sub-grafo. Implementação do Algoritmo O DFS é implementado como uma classe template e pode ser usada com qualquer implementação de grafos (como em grafos densos) usando a mesma interface da classe SparseGraph mostrada anteriormente. Primeiro vamos ver a declaração da classe, logo então eu irei descrever o algoritmo de procura mesmo. template<class graph_type> class Graph_SearchDFS { private: //to aid legibility enum {visited, unvisited, no_parent_assigned}; //create typedefs for the edge and node types used by the graph typedef typename graph_type::EdgeType Edge; typedef typename graph_type::NodeType Node; private: //a reference to the graph to be searched const graph_type & m_Graph; //this records the indexes of all the nodes that are visited as the //search progresses std::vector<int> m_Visited; O m_Visited contém o mesmo número de elementos que a quantia de nodos no grafo. Cada elemento é inicialmente ajustado como não-visitado (unvisited). Quando a procura progride, cada vez que um nodo é visitado, seu elemento correspondente no m_Visited será ajustado para visited. //this holds the route taken to the target. std::vector<int> m_Route; O m_Route também contém o mesmo número de elementos que a quantia de nodos no grafo. Cada elemento é ajustado inicialmente como no_parent_assigned. Quando a procura no grafo prossegue, este vetor armazena a rota para o nodo final ao gravar os pais de cada nodo no índice relevante. Por exemplo, se o caminho para o nodo final segue os nodos 3-8-27, então o m_Route[8] terá 3 e o m_Route[27] terá 8. //the source and target node indices int m_iSource, m_iTarget; Quando você explora um grafo, frequentemente você não vai querer procurar um objetivo específico (ou objetivos). Para usarmos a analogia do parque novamente, é como se você estivesse procurando por um brinquedo como o rollercoaster (montanha-russa). Com isto em mente, os algoritmos de procura em geral usam uma condição de término, geralmente na forma de um índice do nodo objetivo. //true if a path to the target has been found bool m_bFound; //this method performs the DFS search bool Search(); Este método é o código que implementa o algoritmo de procura em profundidade. Nós nos aprofundaremos nele logo em seguida. public: 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(); } //returns true if the target node has been located bool Found()const{return m_bFound;} //returns a vector of node indexes that comprise the shortest path //from the source to the target std::list<int> GetPathToTarget()const; }; O algoritmo de procura DFS é implementado usando uma std::stack de ponteiros const para as arestas que compreendem o grafo em que se está procurando. Uma stack é uma estrutura em forma de pilha, o último que entra é o primeiro que sai (Last In, First Out – usualmente abreviada como LIFO). A pilha é usada de uma maneira similar a que nosso amigo Billy usava as folhas de papel para explorar o parque temático: Arestas são colocadas nela quando a procura prossegue, assim como Billy registrava os caminhos que ele havia explorado. Daremos uma rápida olhada no código do método Search e então eu o explicarei com um exemplo para garantir que você entenda como a mágica é feita. template <class graph_type> bool Graph_SearchDFS<graph_type>::Search() { //create a std stack of pointers to edges std::stack<const Edge*> stack; //create a dummy edge and put on the stack Edge Dummy(m_iSource, m_iSource, 0); stack.push(&Dummy); //while there are edges on the stack keep searching while (!stack.empty()) { //grab the next edge const Edge* Next = stack.top(); //remove the edge from the stack stack.pop(); //make a note of the parent of the node this edge points to m_Route[Next->To] = Next->From(); //and mark it visited m_Visited[Next->To()] = visited; //if the target has been found the method can return success if (Next->To() == m_iTarget) { return true; } //push the edges leading from the node this edge points to onto //the stack (provided the edge does not point to a previously //visited node) 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); } } }//while //no path to target return false; } Para ajudar você a entender, vamos examinar um exemplo simples. Usando o grafo nãodirecionado da Figura 5.17, vamos dizer que queremos procurar o nodo 3 (o nodo alvo, T), começando do nodo 5 (o nodo fonte ou raiz, S). Figura 5.17: Um problema simples de procura em grafo O Search começa criando uma aresta falsa – uma que leva o nodo fonte de volta ao nodo fonte – e colocando-a na pilha. Veja a Figura 5.18. A aresta realçada indica a aresta na pilha. Figura 5.18: Uma aresta falsa é colocada na pilha. A procura prossegue ao se entrar em um loop while. Assim, enquanto houver arestas inexploradas na pilha, o algoritmo iterará através das seguintes etapas. Os comentários entre parêntesis descrevem a situação da primeira iteração através do loop. 1. Remover a aresta que está em cima da pilha. (A aresta falsa [5-5]); 2. Registre o pai do nodo destino da aresta inserindo o índice do pai no vetor m_Routes, no elemento referenciado pelo índice do nodo de destino. (Por causa da aresta falsa ser usada para iniciar o algoritmo, o pai do nodo 5 também é 5. Assim m_Route[5] é ajustado para 5); 3. Marque o nodo destino da aresta como visitado ao designar a enumeração visited ao índice relevante no vetor m_Visited (m_Visited[5] = visited); 4. Teste a condição de término. Se o nodo destino da aresta é o nodo final, então a procura pode retornar o sucesso. (O 5 não é o nodo final, então a procura continua); 5. Se o nodo destino da aresta não é o alvo, então forneça um nodo para a aresta atual apontar que já não tenha sido visitado, todas as arestas adjacentes são colocadas na pilha. (As arestas [5-2], [5-4] e [5-6] são colocadas na pilha). A Figura 5.19 mostra o estado atual do algoritmo depois de uma iteração do loop while. A cor cinza do nodo fonte indica que ele foi marcado como visitado. Figura 5.19: As arestas que saem do nodo 5 são colocadas na pilha. Neste ponto, os galhos do algoritmo voltam ao inicio do loop while, removendo a próxima aresta de cima da pilha (aresta [5-2]), marcando o nodo destino como visitado (nodo 2) e registrando o pai dele (o nodo 5 é pai do nodo 2). Em seguida, o algoritmo considera quais arestas devem ser colocadas na pilha. O nodo 2 (para o qual a aresta [5-2] aponta) tem duas arestas adjacentes: [2-1] e [2-5]. O nodo 5 foi marcado como visitado, então a aresta [2-5] não é colocada na pilha. Como o nodo 1 não foi visitado, a aresta que vai para ele, [2-1], é colocada na pilha. Veja a Figura 5.20. A linha negra grossa da [5-2] indica que esta aresta em particular não será mais considerada. Figura 5.20 Novamente os galhos do algoritmo voltam ao inicio do loop while e assim este remove a próxima aresta da pilha (aresta [2-1]), marca o nodo destino como visitado (nodo 1) e registra o pai deste (o nodo 2 é pai do nodo 1). O nodo 1 tem arestas indo para os nodos 3 e 2. O nodo 2 já foi visitado, então assim apenas a aresta [1-3] é colocada na pilha. Veja a Figura 5.21. Figura 5.21 Desta vez quando o algoritmo remove a próxima aresta [1-3] da pilha, depois do procedimento usual de marcar o nodo destino e o pai dele, o algoritmo descobre que ele localizou o objetivo, ponto no qual o algoritmo termina. Neste estágio o estado é mostrado na Figura 5.22. Figura 5.22 Durante a procura, o caminho para o nodo final é armazenado no vetor m_Route (representado nas figuras anteriores pela tabela usada para armazenar os pais dos nodos). O método GetPathToTarget extrai esta informação e retorna um vetor de inteiros representado os índices dos nodos que um agente deve seguir para ir do nodo fonte até o nodo objetivo. Aqui o código fonte: template <class Graph> std::list<int> Graph_SearchDFS<Graph>::GetPathToTarget()const { std::list<int> path; //just return an empty path if no path to target found or if //no target has been specified 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; } Este é um método muito simples. Ele começa tentando ver qual é o pai no nodo alvo, e então o pai deste nodo, e assim por diante até que o nodo fonte seja atingido. No exemplo que você seguiu anteriormente, o caminho que este método retorna é 5-2-1-3. Nota Por motivos de velocidade e eficiência, as implementações dos algoritmos de procura descritos neste capítulo são projetadas para operarem em grafos criados antes da procura. Entretanto, para alguns problemas será impossível se fazer isso por causa do tamanho do grafo ser muito grande para se armazenar na memória ou por causa da necessidade de se conservar memória apenas com a criação dos nodos e arestas que são essenciais para a procura. Por exemplo, se você quiser procurar o espaço de estados de um jogo como xadrez, é impossível se construir um grafo antes da procura, por causa número massivo de estados possíveis. Assim, os nodos e aresta devem ser criados durante a procura. O DFS em ação Para dar a você um retorno útil, eu criei um programa de demonstração que acompanha este capítulo, o qual demonstra as várias procuras em grafos em um grafo de navegação com formato de grade. Este tipo de arranjo de nodos é comumente encontrado em jogos baseados em tiles, onde um nodo é posicionado no centro de cada tile e as arestas o conectam aos oito vizinhos. Veja a Tela 5.1. As linhas verticais e horizontais representam os limites dos tiles, os pontos são os nodos do grafo e as linhas finas saindo dos nodos são as arestas de conexão. Infelizmente, a tela foi impressa em escala de cinzas, então pode ser difícil ver tudo isso claramente. Se você tem um computador próximo, eu recomendo que rode o executável PathFinder.exe, e aperte a tecla “G” para ver o grafo sendo mostrado. Tela 5.1 Nota Embora no mundo real as conexões diagonais na demonstração PathFinder seja maiores que as conexões verticais e horizontais, a procura em profundidade não considera qualquer custo associado a arestas, assim trata todas elas como iguais. Eu usei um arranjo de nodos baseado em grade para a demonstração porque assim é mais fácil de se criar grafos experimentais com tiles de bloqueio que representam obstáculos ou variação de terreno. Entretanto, isto não significa que seu jogo deve usar um grafo baseado em grade. Eu enfatizei este ponto porque vejo com muita freqüência que os iniciantes as vezes tem dificuldade de entender como um grafo pode ser de qualquer forma além de uma grade. Eles dizem coisas como, “eu sei que o algoritmo de procura XYZ funciona em jogos RTS baseados em tiles, mas é possível usá-lo em meu FPS?”. Eu vejo esta questão com freqüência e eu acho que ela é devida a maioria das demonstrações, tutoriais e artigos sobre pathfinding mostrarem exemplos usando um arranjo de nodos baseado em grade (pelas razões que eu já disse), e o pessoal acha que é dessa maneira que a coisa deve ser. Por favor... eu imploro a você, não cometa este mesmo erro! Os grafos podem ser de qualquer forma que você quiser (e com qualquer número de dimensões). De qualquer maneira, vamos voltar ao DFS. A Tela 5.2 mostra um simples mapa que eu criei bloqueando alguns tiles como obstáculos. Tela 5.2: Um simples mapa de teste (Pela claridade, o grafo não é mostrado, apenas os tiles). Como a impressão é em escala de cinzas, eu fiz os nodos fonte e objetivo mais fáceis de serem vistos ao marcá-los com um quadrado e um X, respectivamente. Os números no canto inferior direito mostram o número de nodos (n) e arestas (e) no grafo. Melhorias no DFS Alguns grafos podem ser muito profundos e o DFS pode facilmente demorar explorando caminhos errados. Em um cenário de pior caso, o DFS pode ser incapaz de se recuperar de uma escolha errada no início da pesquisa, vindo a ficar permanentemente incapacitado. Como um exemplo, vamos dizer que você deseja encontrar a solução para um cubo mágico misturado aleatoriamente. O espaço de estados inteiro deste problema é enorme e proíbe a geração do grafo de estados completo antes de qualquer pesquisa, portanto os nodos são criados em tempo de execução, quando cada estado é expandido começando a partir do nodo raiz. Em algum ponto na pesquisa, o algoritmo DFS pode escolher uma aresta conduzindo para uma sub-grafo de um espaço de estados que não contém um estado de solução, mas que também é muito grande para se expandi-lo todo, dado a força computacional disponível. Isto resulta em que uma solução nunca será retornada pelo DFS, e o computador efetivamente travará. Felizmente, pela limitação de quantas arestas de profundidade o algoritmo DFS pode pesquisar antes de começar a retroceder, pode-se prevenir isso. Isso é chamado de procura com profundidade limitada. Ao se utilizar uma procura com profundidade limitada, com uma profundidade em que o algoritmo pode pesquisar com a força computacional disponível, o DFS sempre retornará uma solução se ela existir nessa profundidade. Entretanto, a procura com profundidade limitada tem um obstáculo maior. Como você pode saber que limite atribuir a máxima profundidade de pesquisa? Para a maioria dos domínios de problemas, é impossível se julgar qual limite se escolher. Usando o exemplo do Cubo Mágico, uma solução pode estar a três ou quinze movimentos. Se a profundidade máxima de pesquisa for atribuída em dez, pode ou não haver uma solução. Se for atribuído um valor muito alto, o número de estados possíveis resultantes na pesquisa pode travar o computador. Felizmente, há uma maneira de se contornar este problema: DFS com aprofundamento iterativo. A procura com aprofundamento iterativo funciona usando o DFS para pesquisar um grafo com uma profundidade limita em um, logo então, dois, três, e assim por diante, até que a pesquisa seja completada. Embora a princípio isto possa parecer um desperdício de recursos já que os nodos em profundidades rasas são pesquisados múltiplas vezes, na realidade a maioria dos nodos estão sempre no limite do grafo pesquisado. Isso é especialmente verdade para pesquisas através de grafos com um alto fator de expansão. Dado um fator de expansão de oito, como o do grafo na demonstração PathFinder, o número de nodos no limite é mostrado na Tabela 5.1. Tabela 5.1 Profundidade Nodos no Limite Total de Nodos 0 1 1 1 8 9 2 64 73 3 512 585 4 4096 4681 n 8 n 1+ …+8n-1+8n Eu imagino que alguns de vocês podem pensar coisas como "Mas se, durante uma pesquisa DFS normal, uma profundidade de n trava o computador, seguramente quando o aprofundamento iterativo DFS alcançar a mesma profundidade, ele também travará o computador!" A resposta é sim, se o IDDFS (Iterative Deepening DFS) puder ir tão fundo, você terá problemas, mas o segredo para usar a abordagem do aprofundamento iterativo é impor um limite, usualmente definido como um limite de tempo. Quando o tempo destinado para a pesquisa expira, o algoritmo acaba, não interessando qual nível ele alcançou. Uma vantagem desta abordagem metódica é que se darmos o tempo bastante e um objetivo válido, o aprofundamento iterativo não só encontrará o objetivo, mas encontrará o objetivo com menos passos possíveis. A linha na Tela 5.3 ilustra o caminho que o DFS fez em sua busca ao objetivo. Como você pode ver, ele percorre quase toda a área do mapa antes de tropeçar sobre o nodo objetivo, claramente demonstrando que, embora o DFS possa encontrar o objetivo, ele não garante encontrar a melhor rota para ele. Tela 5.3: O DFS em ação Também note neste exemplo que o DFS não explorou quaisquer arestas que não estivessem no caminho para o nodo objetivo. Quando o nodo objetivo pode ser alcançado via múltiplos caminhos isto é comum no DFS, tornando ele um algoritmo veloz para ser empregado quando o comprimento do caminho não tem importância (por exemplo, quando estamos pesquisando em um espaço de estados para ver se um estado em particular existe, sem nos importarmos em determinar a rota mais rápida para esse estado). 3.1.2 Busca em Amplitude (Breadth First Search) Embora o DFS garanta encontrar um nodo objetivo em um grafo conectado, ele não garante encontrar o melhor caminho para esse nodo – o caminho que contém menos arestas. No exemplo anterior, o DFS resultou em um caminho com três arestas, enquanto o melhor caminho tinha duas: 5-4-3. Veja a Figura 5.22. O algoritmo BFS sai do nodo fonte e examina cada um dos nodos que estão nas suas arestas antes de então ir para cada um desses nodos e examinar todas as arestas que se conectam a eles e assim em diante. Você pode pensar nesta pesquisa como explorando todos os nodos que estão a uma aresta de distância do nodo fonte, então todos os nodos que estão a duas arestas de distância, então três arestas, e assim em diante até o nodo objetivo ser encontrado. Portanto, quando o objetivo é localizado, fica garantido que o caminho que conduz para ele contém o menor número de arestas possíveis. (Pode haver outros caminhos de comprimento igual, mas não haverá nenhum mais curto). Implementação do Algoritmo O algoritmo para BFS é quase o mesmo que o para DFS, exceto que este usa uma fila onde o primeiro que entra é primeiro que sai (First In, First Out - FIFO) em vez de uma pilha. Consequentemente, desta vez as arestas são tiradas da fila na mesma ordem em que elas são colocadas. Dê uma olhada no código fonte do método Search do BFS. template <class graph_type> bool Graph_SearchBFS< graph_type>::Search() { //create a std queue of pointer's edges std::queue<const Edge*> Q; //create a dummy edge and put on the queue const Edge Dummy(m_iSource, m_iSource, 0); Q.push(&Dummy); //mark the source node as visited m_Visited[m_iSource] = visited; //while there are edges in the queue keep searching while (!Q.empty()) { //grab the next edge const Edge* Next = Q.front(); Q.pop(); //mark the parent of this node m_Route[Next->To()] = Next->From(); //exit if the target has been found if (Next->To() == m_iTarget) { return true; } //push the edges leading from the node at the end of this edge //onto the queue graph_type::ConstEdgeIterator ConstEdgeItr(m_Graph, Next->To()); for (const Edge* pE=ConstEdgeItr.begin(); !ConstEdgeItr.end(); pE=ConstEdgeItr.next()) { //if the node hasn't already been visited we can push the //edge onto the queue if (m_Visited[pE->To()] == unvisited) { Q.push(pE); //the node is marked as visited here, BEFORE it is examined, because //it ensures a maximum of N edges are ever placed in the queue, // rather than E edges. m_Visited[pE->To()] = visited; } } } //no path to target return false; } Para esclarecer o algoritmo para você, vamos seguir o mesmo exemplo usado anteriormente. A Figura 5.23 irá refrescar sua memória. Figura 5.23: Encontre o caminho mais curto do nodo 5 para o 3. O BFS começa como o DFS. Primeiro uma aresta falsa [5-5] é criada e colocada na fila. Então o nodo fonte é marcado como visitado. Veja a Figura 5.24. Figura 5.24 A seguir o algoritmo toma nota do pai do nodo 5. Como antes, por causa da primeira aresta ser considerada uma aresta falsa, o nodo 5 é ajustado como pai dele mesmo. Esta aresta então é removida da frente da fila e todas as arestas adjacentes ao nodo 5 (somente as que apontam para nodos não-visitados) são adicionadas. Veja a Figura 5.25. Figura 5.25 Até aqui tudo parece como o DFS, mas agora é onde o algoritmo difere. A seguir, a aresta [5-6] é removida da fila. O nodo 5 é marcado como pai do nodo 6. Como ambas as arestas adjacentes ao nodo 6 apontam para nodos já visitados, elas não são adicionadas na fila. Veja a Figura 5.26. Figura 5.26 A próxima da fila é a aresta [5-4]. O nodo 5 é marcado como pai do nodo 4. O nodo 4 tem três arestas adjacentes, mas apenas a aresta [4-3] aponta para o nodo não marcado, assim só esta é colocada na fila. Veja a Figura 5.27. Figura 5.27 A próxima é a aresta [5-2]. O nodo 5 é marcado como pai do nodo 2 e a aresta [2-1] é colocada na fila. Veja a Figura 5.28. Figura 5.28 A aresta [4-3] é a próxima. O nodo 4 é marcado como pai do nodo 3. Como o nodo 3 é o nodo objetivo, neste ponto o algoritmo é encerrado. Veja a Figura 5.29. Usando o vetor m_Routes para procurar os pais do nodo objetivo até o nodo fonte, conseguimos o caminho 3-4-5. Este é o caminho entre os dois que contém menos arestas... o melhor caminho. Figura 5.29 Dica Você pode acelerar o BFS (e muitos outros algoritmos de procura em grafos) rodando duas procuras simultaneamente, uma começando do nodo fonte e outra do nodo objetivo, parando a procura quando elas se encontram. Isto é chamado de procura bidirecional. O BFS em Ação Vamos rodar o programa PathFinder novamente para ver o BFS operando no mundo real. Primeiro de tudo, eu acho que será útil para você examinar o exemplo simples mostrado na Tela 5.4. (Se você já estava rodando o programa PathFinder, carregue o arquivo no_obstacles_source_target_close.map, e clique no botão BF na barra de ferramentas). Tela 5.4 Neste caso não há obstáculos presentes. O nodo fonte é posicionado próximo ao nodo objetivo com somente alguns outros nodos (tiles) separando-os. Novamente a linha grossa mostra o caminho que o algoritmo BFS encontrou. As linhas finas representam todas as arestas que o algoritmo visitou no caminho para o objetivo. Isto claramente demonstra como o BFS sai do nodo fonte até que o nodo objetivo seja encontrado. As arestas visitadas formam um quadrado porque o BFS, assim como o DFS, trata todas as arestas como tendo o mesmo comprimento. O caminho verte para esquerda e então para a direita em vez de ir diretamente para o nodo objetivo pela mesma razão. Eles tomam o mesmo número de etapas, mas a forma do caminho é inteiramente dependente da ordem como os nodos das arestas são explorados. A Tela 5.5 mostra o BFS em operação no mesmo mapa visto anteriormente. A melhoria no comprimento do caminho é clara, entretanto isto é esperado já que o DFS não é feito para procuras de caminhos mais curto. Mais uma vez, note como todas as arestas com a mesma profundidade que a do nodo fonte até o nodo objetivo são visitadas. Tela 5.5 Infelizmente, por causa do BFS ser tão sistemático em sua exploração, ele pode ser tornar inviável para procuras que não sejam em um espaço de procura pequeno. Se nós representarmos o fator de expansão como b e o número de arestas entre o nodo objetivo e o fonte como d (depth, profundidade), então o número de nodos examinados é dado pela Equação 5.2. (5.2) Se o grafo a ser explorado é muito grande e tem um alto fator de expansão, então o BFS vai gastar muita memória e ter um desempenho horrível. Pior ainda, é se o espaço de estados tiver um fator de expansão tão alto que proíba a criação de um grafo completo antes da pesquisa, requerendo que o BFS expanda os nodos enquanto explora, uma pesquisa pode literalmente levar anos para terminar. Em seu livro Artificial Intelligence: A Modern Approach, Russell e Norvig dão um exemplo de um puzzle com um fator de expansão de 10; assumindo que se leva um milissegundo para expandir cada nodo, o BFS levará 3,500 anos para alcançar uma profundidade de 14! Os computadores se tornaram bestas muito mais rápidas desde que a primeira edição desse livro foi publicada, mas, até mesmo assim, você ficaria velho com a quantia de tempo que o BFS leva para resolver esse problema com essa profundidade. 3.2 Procuras em Grafos Baseadas em Custo Para muitos domínios de problemas, o grafo relatado contém um custo (às vezes chamado de peso) associado a travessia de uma aresta. Por exemplo, os grafos de navegação usualmente possuem arestas com um custo proporcional a distância entre os nodos que elas conectam. Para se encontrar os caminhos mais curtos nesses grafos, esses custos devem ser levados em conta. Não é o bastante simplesmente – como fizemos anteriormente com o BFS – encontrar o caminho que contém o menor número de arestas, pois, com um custo associado, pode ser muito melhor percorrer várias arestas curtas do que duas longas. Veja a Figura 5.30. Figura 5.30: O caminho 1 - 5 - 4 - 3 é mais curto que o 1 - 2 - 3, mesmo tendo mais arestas. Embora seja possível se usar o BFS ou o DFS para procurar exaustivamente todas as rotas para um objetivo, adicionando custos para cada caminho e então se selecionando o caminho com o menor custo, isto é obviamente uma solução muito ineficiente. Felizmente há métodos muito melhores a nossa disposição. 3.2.1 Relaxamento de Arestas Os algoritmos de procura discutidos no restante deste capítulo são baseados em uma técnica chamada relaxamento de arestas. Quando um algoritmo prossegue, ele junta informações sobre o melhor caminho encontrado até aqui (best path so far - BPSF) do nodo fonte até qualquer outro nodo na rota para o nodo objetivo. Esta informação é atualizada quando novas arestas são examinadas. Se uma aresta examinada recentemente infere que o caminho para um nodo pode ser tornado mais curto ao se usar ele em vez do melhor caminho existente, então essa aresta é adicionada e o caminho é atualizado de acordo. Este processo de relaxamento, como todas as operações com grafos, é muito mais fácil de entender ao se observar um diagrama. Veja a Figura 5.31. Com o grafo mostrado em A, o melhor caminho de 1 a 4 via 3 não é melhorado ao se examinar a aresta [5-4]. Assim nenhum relaxamento é necessário. Com o grafo B, entretanto, a aresta [5-4] pode ser utilizada para criar um caminho mais curto para 4; assim o BPSF deve ser atualizado de acordo para mudar o pai do nodo 4 de 3 para 5 (levando ao caminho 1 - 2 - 5 - 4). Figura 5.31 Este processo é chamado de relaxamento de arestas, pois imita a maneira como um pedaço de elástico esticado ao longo das arestas do BPSF pode relaxar (vir a ficar menos tenso) quando uma aresta encontrada facilita um caminho mais curto. Cada algoritmo mantém um std::vector de floats (indexados por nodo) representando o melhor custo total para cada nodo encontrado pelo algoritmo até aqui. Dado o caso generalizado mostrado na Figura 5.32, o pseudo-código para relaxar um caminho pode parecer um o seguinte: if (TotalCostToThisNode[t] > TotalCostToThisNode[n] + EdgeCost(n-to-t)) { TotalCostToThisNode[t] = TotalCostToThisNode[n] + EdgeCost(n-to-t)); Parent(t) = n; } Figura 5.32 3.2.2 Árvores dos Caminhos mais Curtos Dado um grafo G e um nodo fonte, a árvore do caminho mais curto (shortest path tree - SPT) é a sub-árvore de G que representa o caminho mais curto de qualquer nodo na SPT até o nodo fonte. Novamente, uma imagem vale mais que mil palavras, então dê uma olhada na Figura 5.33. Ela mostra uma SPT com a raiz posicionada no nodo 1. Figura 5.33: A árvore do caminho mais curto para o nodo 1 é mostrada na esquerda pelas arestas grossas e é representada novamente na direita por uma árvore direcionada. Para se encontrar o caminho mais curto no grafo para o nodo 1, tudo que você tem que fazer é traçar uma rota reversa na SPT saído do nodo em questão. Por exemplo, traçando o caminho do nodo 3 para o nodo 1, leva ao caminho 1 - 6 - 4 - 3. Os algoritmos a seguir encontram os caminhos mais curtos em grafos com pesos ao “fazer crescer” uma árvore de caminho mais curto a partir do nodo fonte. 3.2.3 O Algoritmo de Dijkstra O professor Edsger Wybe Dijkstra forneceu a informática muitas contribuições valiosas, mas uma das mais famosas é seu algoritmo para encontrar o caminho mais curto em grafos com pesos. O algoritmo de Dijkstra constrói uma árvore de caminho mais curto com uma aresta de cada vez, adicionando primeiro o nodo fonte na SPT e então adicionando a aresta que leva o caminho mais curto do nodo fonte até um nodo que não está na SPT. Este processo resulta em uma SPT contendo o caminho mais curto de cada nodo no grafo até o nodo fonte. Se o algoritmo é equipado com um nodo objetivo, o processo termina quando ele é encontrado. Nesse ponto o algoritmo termina, a SPT gerada contém o caminho mais curto do nodo fonte até o nodo objetivo e de cada nodo visitado durante a procura. Nota Histórica Dijkstra é também famoso por ter projetado e codificado o compilador Algol 60 e por denunciar ferventemente o uso do comando goto na programação. Eu também vi ele dizer que "a questão de se os computadores podem pensar é a mesma questão de se os submarinos podem nadar." Infelizmente, Dijkstra morreu em 2002 de câncer. Vamos seguir um exemplo usando o mesmo grafo mostrado na Figura 5.33 mas desta vez o nodo fonte será o nodo 5. Primeiro, o nodo 5 é adicionado na SPT e as arestas que saem dele são colocadas na fronteira de procura. Veja a Figura 5.34. Figura 5.34: O nodo 5 é adicionado na SPT. As arestas na fronteira de procura estão realçadas. O algoritmo então examina os nodos de destino das arestas na fronteira – 6 e 2 – e adiciona o mais próximo ao fonte (o nodo 2 está a distância de 1.9) na SPT. A seguir, as arestas que deixam o nodo 2 são adicionadas a fronteira. Veja a Figura 5.35. Figura 5.35: As arestas negras grossas representam as que estão na SPT. Novamente o algoritmo examina os nodos de destino na fronteira. O custo para ir até o nodo 3 a partir do fonte é 5 (1.9 + 3.1), o custo para ir até o nodo 6 é 3. Assim o nodo 6 é próximo nodo a ser adicionado na SPT, e todas as arestas que saem dele são adicionadas na fronteira. Veja a Figura 5.36. Figura 5.36 O processo é repetido mais uma vez. Como o custo para o nodo 4 é menor que o custo para o nodo 3, este é adicionado a SPT. Desta vez, entretanto, a única aresta que sai do nodo 4 vai para o nodo 3 – um nodo que já foi o nodo de destino de uma aresta na fronteira. Aqui é onde o relaxamento de arestas entra. Ao se examinar ambos os caminhos para 3, o algoritmo vê que o caminho 5 – 2 – 3 tem um custo de 5.0 e o caminho 5 – 6 – 4 – 3 têm um custo maior, com 7.8. Assim, a aresta [2-3] permanece na SPT e a aresta [4-3] é removida de qualquer consideração posterior. Veja a Figura 5.37. Figura 5.37 Finalmente o nodo 3 é adicionado na SPT. Veja a Figura 5.38. Note como a aresta [3-5] não foi adicionada na fronteira. Isto porque o nodo 5 já está na SPT e não precisa de consideração posterior. Adicionalmente, note como o nodo 1 não foi adicionado na SPT. Desde que haja somente arestas que saem do nodo 1, ele fica efetivamente isolado a partir dos outros nodos no grafo. Figura 5.38 Implementação do Algoritmo de Dijkstra A implementação do algoritmo de caminho mais curto Dijkstra pode ser difícil de entender a princípio e eu confesso que não foi de primeira que eu consegui escrever esta parte do capítulo, pois reconheço que não é fácil explicar! Eu acho que ambos precisamos respirar fundo antes de seguir: inspire... mantenha... três, dois, um... e expire. Agora está muito melhor. Certo, deixe-me começar mostrando a você a declaração da classe. Os comentários dentro do código fornecem explicações de cada um dos atributos, a maioria dos quais deve soar agora como familiares. template <class graph_type > class Graph_SearchDijkstra { private: //cria typedefs para os tipos nodo (node) e aresta (edge) usados pelo grafo typedef typename graph_type::EdgeType Edge; typedef typename graph_type::NodeType Node; private: const graph_type & m_Graph; //este vetor contém as arestas que compreendem a árvore de caminho mais curto //uma sub-árvore direcionada do grafo que encapsula os melhores caminhos a partir //de cada nodo na SPT para o nodo fonte. std::vector<const Edge*> m_ShortestPathTree; //este é indexado pelo índice do nodo e mantém o custo total de melhor //caminho encontrado até aqui para o dado nodo. Por exemplo, m_CostToThisNode[5] //manterá o custo total de todas as arestas que compreendem o melhor caminho //para o nodo 5 encontrado até aqui ns procura (se o nodo 5 está presente e foi //visitado é claro). std::vector<double> m_CostToThisNode; //este é um vetor indexado (por nodo) das arestas "pais" que saem dos nodos //conectados a SPT, mas que não foram ainda adicionadas na SPT. std::vector<const Edge*> m_SearchFrontier; int int m_iSource; 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(); } //retorna o vetor de arestas que define a SPT. Se um objetivo é fornecido //no construtor, então este será a SPT que compreende todos os nodos //examinados antes de o objetivo ser encontrado, se não, ele conterá todos os nodos //no grafo. std::vector<const Edge*> GetAllPaths()const; //retorna um vetor de índices de nodo que compreende o caminho mais curto //a partir do fonte até o objetivo. Ele calcula o caminho funcionando //reversamente através da SPT a partir do nodo objetivo. std::list<int> GetPathToTarget()const; //retorna o custo total para o objetivo double GetCostToTarget()const; }; Este algoritmo de procura é implementado usando uma fila de prioridade ordenada. Uma fila de prioridade, ou PQ (priority queue) para abreviar, é uma fila que mantém seus elementos ordenados em ordem de prioridade (nenhuma surpresa até então). Este tipo de estrutura de dados pode ser utilizado para armazenar os nodos de destino das arestas na fronteira de procura, em ordem crescente de distância (custo) do nodo fonte. Este método garante que o nodo na frente da PQ não é só um nodo que já está na SPT, mas é o nodo mais próximo ao nodo fonte. Isto faz sentido para você até aqui? Se não, por favor, leia este parágrafo outra vez com muita atenção. Uma PQ deve ser capaz de manter os elementos armazenados dentro de uma ordem. Isto implica que cada nodo do grafo deve ter um atributo adicional para armazenar os custos acumulados para esse nodo, a fim de que os operadores possam ser sobrecarregados para se obter o comportamento correto. Embora, se usar um atributo adicional seja certamente uma solução válida, eu optei por não mudar o nodo de grafo existente, além disso, isto pode ser problemático quando múltiplas procuras são executadas simultaneamente, já que cada procura individual utilizará a mesma estrutura de dados. Isto pode ser contornado ao se criar cópias dos nodos, mas então as preciosas memória e velocidade serão perdidas. A alternativa é usar uma fila de prioridade indexada (iPQ para abreviar). Este tipo de PQ indexa um vetor de chaves. Neste exemplo, as chaves são os custos acumulados para cada nodo armazenado no vetor m_CostToThisNode. Um nodo é adicionado na fila ao se inserir seu índice. Semelhantemente, quando um nodo é recuperado da iPQ, é seu índice que é retornado e não o próprio nodo (ou um ponteiro para o nodo). Este índice pode então ser utilizado para acessar o nodo e seus dados via o m_Graph::GetNode. É hora de mostrar a você algum código fonte. Garanta que você tomará seu tempo e compreenderá todas as linhas deste algoritmo; isto será de grande ajuda a você a longo prazo. Eu fiz comentários dentro do fonte para auxiliar na sua compreensão, mas, se você é um mero mortal, eu duvido que só os comentários sejam o bastante para da o "estalo" em sua primeira leitura. (Se depois de algumas leituras você ainda tem dificuldades com o algoritmo, eu recomendo fortemente que você siga o código em uma folha de papel usando um exemplo simples). template <class graph_type> void Graph_SearchDijkstra<graph_type>::Search() { //cria uma fila de prioridade indexada que ordena em ordem crescente //(frente para trás). Note que o úmero máximo de elementos que a iPQ //pode conter é NumNodes(). Isto é porque nenhum nodo pode ser representado //na fila mais de uma vez. IndexedPriorityQLow<double> pq(m_CostToThisNode, m_Graph.NumNodes()); //coloca o nodo fonte na fila pq.insert(m_iSource); //enquanto a fila não está vazia while(!pq.empty()) { //pega o nodo com o menor custo da fila. Não esqueça, o valor retornado //é um *índice de nodo*, não o próprio nodo. Este nodo já está na // SPT e é o nodo mais perto do nodo fonte int NextClosestNode = pq.Pop(); //move esta aresta da fronteira de procura para a SPT m_ShortestPathTree[NextClosestNode] = m_SearchFrontier[NextClosestNode]; //se o objetivo foi encontrado sai if (NextClosestNode == m_iTarget) return; //agora relaxe as arestas. Para cada aresta conectada ao próximo nodo mais perto graph_type::ConstEdgeIterator ConstEdgeItr(m_Graph, NextClosestNode); for (const Edge* pE=ConstEdgeItr.begin(); !ConstEdgeItr.end(); pE=ConstEdgeItr.next()) { //o custo total para o nodo que esta aresta aponta é o custo para o //nodo atual mais o custo da aresta que conecta ele. double NewCost = m_CostToThisNode[NextClosestNode] + pE->Cost(); //se esta aresta nunca esteve na fronteira, tome nota do custo //para atingir o nodo a que ela aponta, então adicione a aresta na fronteira //e o nodo de destino na PQ. if (m_SearchFrontier[pE->To()] == 0) { m_CostToThisNode[pE->To()] = NewCost; pq.insert(pE->To()); m_SearchFrontier[pE->To()] = pE; } //se não, teste para ver se o custo para atingir o nodo de destino via o //nodo atual é melhor que o melhor custo encontrado até aqui. Se //este caminho é melhor, designamos o novo custo para o nodo de destino, //atualiza esta entrada na PQ para refletir a mudança, e adiciona a //aresta na fronteira else if ( (NewCost < m_CostToThisNode[pE->To()]) && (m_ShortestPathTree[pE->To()] == 0) ) { m_CostToThisNode[pE->To()] = NewCost; //por causa de o custo ser menor que o anterior, a PQ deve ser //reordenada para contar com isto. pq.ChangePriority(pE->To()); m_SearchFrontier[pE->To()] = pE; } } } } Dica A implementação da fila de prioridade indexada utiliza uma pilha de dois sentidos para armazenar os elementos. Para grafos esparsos, se cada aresta examinada produz uma melhoria no custo (requerendo que o IndexedPriorityQLow::ChangePriority seja chamado), o algoritmo leva um tempo de Elog2N em um cenário de pior caso, entretanto na prática o tempo de execução será significantemente menor. É possível se ganhar melhorias de velocidade ao se usar uma pilha de d-sentidos onde d é uma função de densidade de grafo. Assim o tempo levado em um cenário de pior caso será de ElogdN. Agora que tudo foi dito, o algoritmo de caminho mais curto Dijkstra tem um desempenho muito bom e garante encontrar o caminho mais curto entre dois nodos se ele existe. O Algoritmo de Dijkstra em Ação Vamos iniciar o programa PathFinder novamente e ver como o algoritmo de Dijkstra se desempenha nos exemplos vistos anteriormente. A Tela 5.6 ilustra o algoritmo operando no problema simples. Tela 5.6 O resultado é similar ao BFS, porém agora a árvore que compreenda as arestas examinadas é de aparência circular. Isso é por que o algoritmo Dijkstra funciona com os custos das arestas e assim desta vez as arestas diagonais custam mais para serem atravessadas que as horizontais e verticais. Com isto em mente você pode ver como o algoritmo procurou em uma distância similar em todas as direções antes de atingir o objetivo. A Tela 5.7 mostra o algoritmo Dijkstra operando em um mapa mais complexo. Tela 5.7 Como o BFS, o algoritmo de Dijkstra examina muitas arestas. Será que não seria bom se o algoritmo pudesse ser informado quando progredisse para orientar a procura na direção correta? Bom, felizmente para nós, isto é possível. Damas e cavalheiros, por favor, uma salva de aplausos e seja bem vindo A*! 3.2.4 O Dijkstra com uma Melhoria: A* O algoritmo de Dijkstra faz a procura minimizando o custo do caminho até aqui. Isso pode ser melhorado significativamente ao se considerar, quando colocamos os nodos na fronteira, uma estimativa do custo do objetivo para cada nodo considerado. Essa estimativa é usualmente chamada de heurística, e o nome dado ao algoritmo que usa esse método de procura heuristicamente orientada é A* (pronunciado a-estrela). E ele é muito bom também! Se a heurística utilizada pelo algoritmo resulta em um valor menor que o custo real (subestimando o custo) de qualquer nodo para o objetivo, então é garantido que o A* leve a caminhos ótimos. Para grafos que contenham informações espaciais, tais como os grafos de navegação, há várias funções de heurística que você pode usar, a mais usada é o comprimento de uma linha reta entre os nodos em questão. Esta é às vezes chamada como a distância Euclidiana. O A* procede de forma quase idêntica ao algoritmo de procura de Dijkstra. A única diferença é o cálculo dos custos dos nodos na fronteira de procura. O custo ajustado F, para o nodo quando posicionado na fila de prioridade (a fronteira de procura), é calculado como: (5.3) Onde G é o custo cumulativo para atingir um nodo e H é a heurística estimada da distância para o objetivo. Para uma aresta E que está na fronteira e foi adicionada na SPT, o pseudocódigo para calcular o custo para o nodo de destino é o seguinte: Cost = AccumulativeCostTo(E.From) + E.Cost + CostTo(Target) Utilizando uma heurística desta maneira, o custo modificado direciona a procura na direção do objetivo em vez de irradiar igualmente em todas as direções. Isso resulta em que apenas algumas arestas são examinadas, assim a aceleração da procura é a diferença primária entre o A* e o algoritmo de Dijkstra. Nota Se você ajusta o custo da heurística em zero no A*, o procura resultante será exatamente a mesma que a do algoritmo de Dijkstra. Vamos dar uma olhada em como o A* opera nos grafos problemas usados no programa PathFinder. O A* em Ação A Tela 5.8 mostra o resultado do A* operando no problema exemplo simples. Como você pode ver, nenhuma aresta estranha foi considerada, o caminho leva diretamente ao objetivo. A função de heurística usada é a distância de uma linha reta entre dois nodos. Tela 5.8: Não supera o Go, então não ganha 200 libras. A Tela 5.9 é impressionante. Observe como apenas algumas arestas são consideradas pelo A* antes de descobrir o objetivo. Como uma consequência disso, o tempo levado para a pesquisa ser desempenhada é consideravelmente menor que para qualquer outra pesquisa (ainda que uma raiz quadrada do mal seja necessária para se calcular o custo da heurística). Tela 5.9 Nota O A* é induvidavelmente de eficiência ótima. Em outras palavras, nenhum outro algoritmo de procura expandirá tão poucos nodos na procura pelo caminho com menor custo entre o nodo fonte e o objetivo. Implementação do A* A classe A* é muito semelhante a Graph_SearchDijkstra. A implementação da procura requer que dois std::vectors de custos sejam mantidos: uma para o custo F de cada nodo, o qual é indexado em uma fila de prioridade, e um para o custo G de cada nodo. Em adição quando, quando criamos uma instância desta classe, devemos especificar, como um parâmetro template, a heurística a ser usada. Este projeto torna fácil o uso de heurísticas personalizadas com a classe, como a heurística da distância Manhattan, mencionada no fim deste capítulo. Aqui a declaração da classe para você usar: template <class graph_type, class heuristic> class Graph_SearchAStar { private: //create a typedef for the edge type used by the graph typedef typename graph_type::EdgeType Edge; private: const graph_type& m_Graph; //indexed into by node. Contains the "real" cumulative cost to that node std::vector<double> m_GCosts; //indexed into by node. Contains the cost from adding m_GCosts[n] to //the heuristic cost from n to the target node. This is the vector the //iPQ indexes into. std::vector<double> m_FCosts; std::vector<const Edge*> std::vector<const Edge*> m_ShortestPathTree; m_SearchFrontier; int int m_iSource; m_iTarget; //the A* search algorithm 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(); } //returns the vector of edges that the algorithm has examined std::vector<const Edge*> GetSPT()const; //returns a vector of node indexes that comprise the shortest path //from the source to the target std::list<int> GetPathToTarget()const; //returns the total cost to the target double GetCostToTarget()const; }; A heurística a ser usada por esta classe deve fornecer um método estático Calculate com a seguinte assinatura: //calculate the heuristic cost from node nd1 to node nd2 static double Calculate(const graph_type& G, int nd1, int nd2); Já que o grafo usado pela demonstração PathFinder representa informações espaciais, o custo de heurística é calculado como a distância de uma linha reta (também conhecida como distância Euclidiana) para o nodo objetivo a partir do nodo sob consideração. O seguinte código mostra como a heurística é implementada como uma classe que pode ser usada como uma parâmetro template para a Graph_SearchAStar. class Heuristic_Euclid { public: Heuristic_Euclid(){} //calculate the straight-line distance from node nd1 to node nd2 template <class graph_type> static double Calculate(const graph_type& G, int nd1, int nd2) { return Vec2DDistance(G.GetNode(nd1).Position, G.GetNode(nd2).Position); } }; O tipo da heurística é passado como um parâmetro template quando uma instância da classe de procura A* é criada. Aqui está como o programa de demonstração PathFinder cria uma instância da procura A* usando a heurística Euclidiana: //create a couple of typedefs so the code will sit comfortably on the page typedef SparseGraph<NavGraphNode<>, GraphEdge> NavGraph; typedef Graph_SearchAStar<NavGraph, Heuristic_Euclid> AStarSearch; //create an instance of the A* search using the Euclidean heuristic AStarSearch AStar(*m_pGraph, m_iSourceCell, m_iTargetCell); A implementação do método Search do A* é quase idêntico ao usado pelo algoritmo de Dijkstra. A única exceção é que o custo obtido para um nodo específico antes dele ser colocado na fronteira é agora calculado como G + H (em vez de apenas G). O valor de H é determinado pela chamada ao método estático da classe de heurística. template <class graph_type, class heuristic> void Graph_SearchAStar<graph_type, heuristic>::Search() { //create an indexed priority queue of nodes. The queue will give priority //to nodes with low F costs. (F=G+H) IndexedPriorityQLow<double> pq(m_FCosts, m_Graph.NumNodes()); //put the source node on the queue pq.insert(m_iSource); //while the queue is not empty while(!pq.empty()) { //get lowest cost node from the queue int NextClosestNode = pq.Pop(); //move this node from the frontier to the spanning tree m_ShortestPathTree[NextClosestNode] = m_SearchFrontier[NextClosestNode]; //if the target has been found exit if (NextClosestNode == m_iTarget) return; //now to test all the edges attached to this node graph_type::ConstEdgeIterator ConstEdgeItr(m_Graph, NextClosestNode); for (const Edge* pE=ConstEdgeItr.begin(); !ConstEdgeItr.end(); pE=ConstEdgeItr.next()) { //calculate the heuristic cost from this node to the target (H) double HCost = heuristic::Calculate(m_Graph, m_iTarget, pE->To()); //calculate the "real" cost to this node from the source (G) double GCost = m_GCosts[NextClosestNode] + pE->Cost(); //if the node has not been added to the frontier, add it and update //the G and F costs 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; } //if this node is already on the frontier but the cost to get here this //way is cheaper than has been found previously, update the node costs //and frontier accordingly. 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; } } } } Dica Se você está trabalhando com requisitos de memória muito restritos, você pode diminuir a quantia de memória que as procuras do A* ou do Dijkstra usam, limitando o número de nodos colocados na fila de prioridade. Em outras palavras, somente os n melhores nodos são mantidos na fila. Isso é conhecido como uma procura limitada (beam search ou Simplified Memory-Bounded). A Heurística da Distância Manhattan Você pode ter visto como a classe de algoritmo de procura A* pode ser usada com a heurística Euclidiana (distância em linha reta). Outra função de heurística popular entre programadores de jogos que possuem grafos de navegação com topologia em forma de grade, como jogos de guerra baseados em tiles, é a distância Manhattan entre dois nodos: a soma do deslocamento em tiles verticalmente e horizontalmente. Por exemplo, a distância Manhattan entre os nodos v e w na figura 5.39 é 10 (6 + 4). Figura 5.39: Calculo da distância Manhattan entre dois nodos A distância Manhattan fornece uma melhoria de velocidade sobre a heurística Euclidiana, pois nenhuma raiz quadrada é necessária para o calculo. 4. Conclusão Você deve agora ter uma compreensão decente de grafos e dos algoritmos que você pode usar para fazer procuras neles. Como a maioria das técnicas de IA, sua compreensão crescerá enormemente através da experiência prática, assim eu sugiro você a tentar resolver ao menos alguns dos seguintes problemas. 4.1 A prática leva a perfeição 1. Use um lápis e um papel para traçar os algoritmos DFS, BFS e Dijkstra no grafo a seguir. Use um nodo de início e fim para cada procura. Pontos extras serão dados para o uso de estilos e cores. 2. Crie uma classe de heurística com a distância Manhattan para estimar a distância entre um nodo e o objetivo em um grafo de navegação. Experimente essa heurística com diferentes grafos. Ela é melhor ou pior que a heurística Euclidiana para grafos baseados em grade? 3. A heurística Euclidiana calcula a distância de uma linha reta entre dois nodos. Esse cálculo requer o uso de uma raiz quadrada. Crie uma heurística que funcione no espaço da distância quadrada e note a forma do caminho que ela cria. 4. Crie um programa para encontrar a melhor solução para o puzzle Torres de Hanoi com n-argolas, onde n pode ser qualquer inteiro positivo. Para fazer isso você deve reescrever o algoritmo BFS para que os nodos e arestas sejam adicionados ao grafo de estados quando a procura prossegue. Esta é uma maneira excelente de você testar o seu entendimento do material apresentado neste capítulo. 5. Agora modifique o algoritmo que você criou no problema 4 para procurar a melhor solução usando DFS com aprofundamento iterativo. Como ele se compara? 6. Use o algoritmo A* para resolver um cubo mágico (Rubik’s Cube) misturado. Dê muita consideração ao projeto da função de heurística. Este é um problema difícil, procurar em um espaço de estados potencialmente enorme, então tente primeiro criar seu algoritmo para um cubo que esteja a somente uma rotação da solução, então duas e assim por diante. (Dica: Se você está tendo dificuldades em projetar uma heurística, procure na internet... há vários artigos interessantes sobre o assunto). Figura 5.40