Partie 1. Algorithme de recherche général
Présentation
Trouver un chemin est l'un de ces sujets qui sont généralement les plus difficiles pour les développeurs de jeux. Les gens comprennent particulièrement mal l'algorithme
A * , et beaucoup pensent qu'il s'agit d'une sorte de magie incompréhensible.
Le but de cet article est d'expliquer la recherche du chemin en général et de
A * en particulier d'une manière très compréhensible et accessible, mettant ainsi un terme à l'idée fausse répandue que ce sujet est complexe. Avec la bonne explication, tout est assez simple.
Veuillez noter que dans l'article, nous considérerons la recherche d'un moyen
pour les jeux ; contrairement aux articles plus acadĂ©miques, nous omettons les algorithmes de recherche tels que Depth-First ou Breadth-First. Au lieu de cela, nous allons essayer de passer de zĂ©ro Ă
A * le plus rapidement possible.
Dans la première partie, nous expliquerons les concepts les plus simples pour trouver un chemin. En comprenant ces concepts de base, vous vous rendrez compte que
A * est étonnamment évident.
Circuit simple
Bien que vous puissiez appliquer ces concepts à des environnements 3D complexes arbitraires, commençons par un schéma extrêmement simple: une grille carrée de 5 x 5. Pour plus de commodité, j'ai marqué chaque cellule avec une lettre majuscule.
Maille simple.La toute première chose que nous ferons est d'imaginer cet environnement sous forme de graphique. Je ne vais pas expliquer en détail ce qu'est un graphique; Autrement dit, il s'agit d'un ensemble de cercles reliés par des flèches. Les cercles sont appelés
«nœuds» et les flèches sont
appelées «bords» .
Chaque nœud représente un
«état» dans lequel le personnage peut être. Dans notre cas, l'état du personnage est sa position, nous créons donc un nœud pour chaque cellule de la grille:
Nœuds représentant les cellules de la grille.Maintenant, ajoutez les côtes. Ils indiquent les états qui peuvent être
"atteints" à partir de chaque état donné; dans notre cas, nous pouvons passer de n'importe quelle cellule à la suivante, à l'exception des cellules bloquées:
Les arcs indiquent les mouvements autorisés entre les cellules de la grille.Si nous pouvons passer de
A Ă
B , alors nous disons que
B est un
«voisin» du nœud
A.Il convient de noter que les cĂ´tes ont une
direction ; nous avons besoin d'arĂŞtes de
A Ă
B et de
B Ă
A. Cela peut sembler superflu, mais pas lorsque des «conditions» plus complexes peuvent survenir. Par exemple, un personnage peut tomber du toit au sol, mais ne peut pas sauter du sol au toit. Vous pouvez passer de l'état "vivant" à l'état "mort", mais pas l'inverse. Et ainsi de suite.
Exemple
Supposons que nous voulons passer de
A Ă
T. Nous commençons par
A. Vous pouvez faire exactement deux actions: aller en
B ou aller en
F.Disons que nous sommes passĂ©s Ă
B. Maintenant, nous pouvons faire deux choses: revenir Ă
A ou aller Ă
C. Nous nous souvenons que nous étions déjà en
A et avons considéré les options là -bas, donc cela n'a aucun sens de le faire à nouveau (sinon nous pouvons passer toute la journée à déplacer
A →
B →
A →
B ...). Nous allons donc passer Ă
C.Étant en
C , nous n'avons nulle part oĂą aller. Revenir Ă
B est inutile, c'est-Ă -dire que c'est une impasse. Choisir la transition vers
B quand nous étions en
A était une mauvaise idée; vous devriez peut-être essayer
F Ă la place?
Nous répétons simplement ce processus jusqu'à ce que nous nous retrouvions en
T. À ce moment, nous recréons simplement le chemin à partir de
A , en revenant dans nos pas. Nous sommes en
T ; comment en sommes-nous arrivés là ? De
o ? Autrement dit, la fin du chemin a la forme
O →
T. Comment sommes-nous arrivĂ©s Ă
O ? Et ainsi de suite.
Gardez à l'esprit que nous ne bougeons pas vraiment; tout cela n'était qu'un processus de réflexion. Nous continuons de nous tenir en
A , et nous n'en sortirons pas avant d'avoir trouvé tout le chemin. Quand je dis «déménagé en
B », je veux dire «imaginez que nous avons déménagé en
B ».
Algorithme général
Cette section est la partie la plus importante de tout l'article . Vous
devez absolument le comprendre afin de pouvoir réaliser la recherche du chemin; le reste (y compris
A * ) ne sont que des détails. Dans cette section, vous comprendrez jusqu'à ce que vous en
compreniez le sens .
De plus, cette section est incroyablement simple.
Essayons de formaliser notre exemple, en le transformant en pseudo-code.
Nous devons suivre les nœuds que nous savons atteindre depuis le nœud de départ. Au début, ce n'est que le nœud de départ, mais dans le processus d '«exploration» de la grille, nous apprendrons comment accéder à d'autres nœuds. Appelons cette liste de nœuds
reachable
:
reachable = [start_node]
Nous devons également suivre les nœuds déjà examinés afin de ne pas les reconsidérer.
explored
les
explored
:
explored = []
Ensuite, je décrirai le cœur de l'algorithme : à chaque étape de la recherche, nous sélectionnons l'un des nœuds que nous savons atteindre et regardons quels nouveaux nœuds nous pouvons en tirer. Si nous déterminons comment atteindre le nœud final (cible), le problème est résolu! Sinon, nous poursuivons la recherche.
Si simple, qu'est-ce qui déçoit même? Et c'est vrai. Mais c'est tout l'algorithme. Écrivons-le étape par étape avec un pseudo-code.
Nous continuons à chercher jusqu'à ce que nous arrivions au nœud final (dans ce cas, nous trouvons le chemin du nœud initial au nœud final), ou jusqu'à ce que nous manquions de nœuds dans lesquels vous pouvez rechercher (dans ce cas, il n'y a aucun moyen entre les nœuds de début et de fin) .
while reachable is not empty:
Nous choisissons l'un des nœuds vers lesquels nous savons arriver et qui n'a pas encore été étudié:
node = choose_node(reachable)
Si nous venons d'apprendre à se rendre au nœud final, la tâche est terminée! Nous avons juste besoin de construire le chemin en suivant les liens
previous
vers le nœud de départ:
if node == goal_node: path = [] while node != None: path.add(node) node = node.previous return path
Cela n'a aucun sens d'examiner le nœud plus d'une fois, nous allons donc suivre ceci:
reachable.remove(node) explored.add(node)
Nous identifions des nœuds que nous ne pouvons pas atteindre d'ici. Nous commençons par une liste de nœuds adjacents au nœud actuel et supprimons ceux que nous avons déjà examinés:
new_reachable = get_adjacent_nodes(node) - explored
Nous prenons chacun d'eux:
for adjacent in new_reachable:
Si nous savons déjà comment atteindre le nœud, ignorez-le. Sinon, ajoutez-le à la liste
reachable
, en suivant comment il y est entré:
if adjacent not in reachable: adjacent.previous = node
Trouver le nœud final est une façon de quitter la boucle. La seconde est lorsque l'
reachable
devient vide: nous n'avons plus de nœuds qui peuvent être explorés, et nous n'avons pas atteint le nœud final, c'est-à -dire qu'il n'y a aucun moyen de passer du nœud initial au nœud final:
return None
Et ... c'est tout. Il s'agit de l'algorithme entier, et le code de construction du chemin est alloué dans une méthode distincte:
function find_path (start_node, end_node): reachable = [start_node] explored = [] while reachable is not empty:
Voici la fonction qui construit le chemin, en suivant les liens
previous
vers le nœud de départ:
function build_path (to_node): path = [] while to_node != None: path.add(to_node) to_node = to_node.previous return path
C’est tout.
Il s'agit du pseudocode de
chaque algorithme de recherche de chemin, y compris
A * .
Relisez cette section jusqu'Ă ce que vous compreniez comment tout fonctionne et, plus important encore,
pourquoi tout fonctionne. Il serait idéal de dessiner un exemple à la main sur papier, mais vous pouvez également regarder une démo interactive:
Démo interactive
Voici une démo et un exemple de l'implémentation de l'algorithme montré ci-dessus (vous pouvez l'exécuter sur la
page de l'article original ).
choose_node
sélectionne simplement un nœud aléatoire. Vous pouvez démarrer l'algorithme étape par étape et consulter la liste des
explored
reachable
et
explored
, ainsi que la destination
previous
liens
previous
.
Notez que la recherche se termine dès qu'un chemin est détecté; il peut arriver que certains nœuds ne soient même pas pris en compte.
Conclusion
L'algorithme présenté ici est un algorithme général pour
tout algorithme de recherche de chemin.
Mais qu'est-ce qui distingue chaque algorithme d'un autre, pourquoi
A * est
A * ?
Voici un conseil: si vous exécutez la recherche dans la démo plusieurs fois, vous verrez que l'algorithme ne trouve pas toujours le même chemin. Il trouve
un chemin, et ce chemin n'est pas nécessairement le
plus court . Pourquoi?
Partie 2. Stratégies de recherche
Si vous ne comprenez pas complètement l'algorithme décrit dans la section précédente, revenez-y et relisez-le, car il est nécessaire pour comprendre d'autres informations. Lorsque vous le comprendrez,
A * vous semblera tout Ă fait naturel et logique.
Ingrédient secret
À la fin de la partie précédente, j'ai laissé deux questions ouvertes: si chaque algorithme de recherche utilise le même code, pourquoi
A * se comporte-t-il comme
A * ? Et pourquoi la démo trouve-t-elle parfois des chemins différents?
Les réponses aux deux questions sont liées les unes aux autres. Bien que l'algorithme soit bien défini, j'ai laissé un aspect non résolu, et il s'avère que c'est la clé pour expliquer le comportement des algorithmes de recherche:
node = choose_node(reachable)
C'est cette chaîne à l'allure innocente qui distingue tous les algorithmes de recherche les uns des autres.
choose_node
dépend de la méthode d'
choose_node
de
choose_node
.
Alors pourquoi la démo trouve-t-elle des chemins différents? Parce que sa méthode
choose_node
sélectionne un nœud de manière aléatoire.
La longueur compte
Avant de plonger dans les différences de comportement de la fonction
choose_node
, nous devons corriger une petite erreur dans l'algorithme décrit ci-dessus.
Lorsque nous avons considéré les nœuds adjacents au courant, nous avons ignoré ceux qui savent déjà comment réaliser:
if adjacent not in reachable: adjacent.previous = node
C'est une erreur: que se passe-t-il si nous venons de découvrir la
meilleure façon de s'y rendre? Dans ce cas, il est nécessaire de modifier le lien de nœud
previous
pour refléter ce chemin plus court.
Pour ce faire, nous devons connaître la longueur du chemin du nœud de départ à tout nœud accessible. Nous appellerons cela le coût du chemin. Pour l'instant, nous supposons que le passage d'un nœud à l'un des nœuds voisins a un coût constant de
1
.
Avant de commencer la recherche, nous attribuons la valeur de
cost
de chaque nœud à l'
infinity
; grâce à cela,
tout chemin sera plus court que cela. Nous allons également définir le
cost
nœud
start_node
sur
0
.
Voici Ă quoi ressemblera le code:
if adjacent not in reachable: reachable.add(adjacent)
Même coût de recherche
Jetons maintenant un œil à la méthode
choose_node
. Si nous nous efforçons de trouver le chemin le plus court possible, alors choisir un nœud au hasard n'est pas une bonne idée.
Il est préférable de choisir un nœud que nous pouvons atteindre à partir du nœud initial le long du chemin le plus court; grâce à cela, nous préférons généralement les chemins plus courts aux plus longs. Cela ne signifie pas que les chemins plus longs ne seront pas du tout pris en compte, cela signifie que les chemins plus courts seront considérés en premier. Puisque l'algorithme se termine immédiatement après avoir trouvé un chemin approprié, cela devrait nous permettre de trouver des chemins courts.
Voici un exemple possible de la fonction
choose_node
:
function choose_node (reachable): best_node = None for node in reachable: if best_node == None or best_node.cost > node.cost: best_node = node return best_node
Intuitivement, la recherche de cet algorithme se développe «radialement» à partir du nœud de début jusqu'à ce qu'il atteigne le nœud de fin. Voici
une démonstration interactive de ce comportement:
Conclusion
Un simple changement dans la méthode de choix du nœud considéré par les éléments suivants nous a permis d'obtenir un assez bon algorithme: il trouve le chemin le plus court du début au nœud final.
Mais cet algorithme, dans une certaine mesure, reste "stupide". Il continue de chercher partout jusqu'à ce qu'il tombe sur un nœud terminal. Par exemple, quel est le point dans l'exemple illustré ci-dessus pour rechercher dans la direction
A , s'il est évident que nous nous
éloignons du nœud final?
Est-il possible de rendre
choose_node
plus intelligent? Pouvons-nous lui faire
diriger la recherche vers le nœud final , sans même connaître à l'avance le chemin correct?
Il s'avère que nous pouvons - dans la partie suivante, nous arrivons enfin Ă
choose_node
, qui nous permet de transformer l'algorithme de recherche de chemin général en
A * .
Partie 3. Retirez le voile du secret de A *
L'algorithme obtenu dans la partie précédente est assez bon: il trouve le chemin le plus court du nœud de départ au nœud final. Cependant, il gaspille son énergie: il considère les façons qu'une personne appelle manifestement erronées - elles
s'éloignent généralement du but. Comment éviter cela?
Algorithme magique
Imaginez que nous exécutions un algorithme de recherche sur un ordinateur spécial avec une puce qui peut faire de la
magie . Grâce à cette puce incroyable, nous pouvons exprimer
choose_node
très simple, ce qui garantit de créer le chemin le plus court sans perdre de temps à explorer des chemins partiels qui ne mènent nulle part:
function choose_node (reachable): return magic(reachable, " , ")
Cela semble tentant, mais les puces magiques ont toujours besoin d'une sorte de code de bas niveau. Voici une bonne approximation:
function choose_node (reachable): min_cost = infinity best_node = None for node in reachable: cost_start_to_node = node.cost cost_node_to_goal = magic(node, " ") total_cost = cost_start_to_node + cost_node_to_goal if min_cost > total_cost: min_cost = total_cost best_node = node return best_node
C'est une excellente façon de sélectionner le nœud suivant: vous sélectionnez un nœud qui nous donne le chemin le plus court du début au nœud final, ce dont nous avons besoin.
Nous avons également minimisé la quantité de magie utilisée: nous savons exactement quel est le coût du déplacement du nœud de départ à chaque nœud (c'est
node.cost
), et nous utilisons la magie uniquement pour prédire le coût du déplacement du nœud au nœud final.
Pas magique, mais assez génial A *
Malheureusement, les puces magiques sont nouvelles et nous avons besoin de l'assistance d'un équipement obsolète. La plupart du code nous convient, à l'exception de cette ligne:
Autrement dit, nous ne pouvons pas utiliser la magie pour découvrir le coût d'un chemin inexploré. Eh bien, faisons une prédiction. Nous serons optimistes et supposerons qu'il n'y a rien entre les nœuds actuels et finaux, et nous pouvons simplement nous déplacer directement:
cost_node_to_goal = distance(node, goal_node)
Notez que le
chemin le
plus court et la
distance minimale sont différents: la distance minimale implique qu'il n'y a absolument aucun obstacle entre les nœuds actuels et finaux.
Cette estimation est assez simple Ă obtenir. Dans nos exemples de grille, il s'agit de la
distance des blocs de ville entre deux nœuds (c'est-à -dire
abs(Ax - Bx) + abs(Ay - By)
). Si nous pouvions nous déplacer en diagonale, alors la valeur serait
sqrt( (Ax - Bx)^2 + (Ay - By)^2 )
, et ainsi de suite. Plus important encore, nous n'obtenons jamais une estimation de valeur
trop élevée.
Voici donc une version non
choose_node
de
choose_node
:
function choose_node (reachable): min_cost = infinity best_node = None for node in reachable: cost_start_to_node = node.cost cost_node_to_goal = estimate_distance(node, goal_node) total_cost = cost_start_to_node + cost_node_to_goal if min_cost > total_cost: min_cost = total_cost best_node = node return best_node
Une fonction qui estime la distance du courant au nœud final est appelée
heuristique , et cet algorithme de recherche, mesdames et messieurs, est appelé ...
A * .
Démo interactive
Pendant que vous vous remettez du choc causé par la prise de conscience que le mystérieux
A * est en fait
si simple , vous pouvez regarder la démo (ou l'exécuter dans l'
article original ). Vous remarquerez que, contrairement à l'exemple précédent, la recherche passe très peu de temps à se déplacer dans la mauvaise direction.
Conclusion
Enfin, nous sommes arrivés à l'algorithme
A * , qui n'est rien de plus que l'algorithme de recherche général décrit dans la première partie de l'article avec quelques améliorations décrites dans la deuxième partie et en utilisant la fonction
choose_node
, qui sélectionne le nœud qui, à notre avis, nous rapproche de nœud d'extrémité. C’est tout.
Voici un pseudo-code complet de la méthode principale pour votre référence:
function find_path (start_node, end_node): reachable = [start_node] explored = [] while reachable is not empty:
Méthode
build_path
:
function build_path (to_node): path = [] while to_node != None: path.add(to_node) to_node = to_node.previous return path
Et voici la méthode
choose_node
, qui la transforme en
A * :
function choose_node (reachable): min_cost = infinity best_node = None for node in reachable: cost_start_to_node = node.cost cost_node_to_goal = estimate_distance(node, goal_node) total_cost = cost_start_to_node + cost_node_to_goal if min_cost > total_cost: min_cost = total_cost best_node = node return best_node
C’est tout.
Mais pourquoi avons-nous besoin de la
partie 4 ?
Maintenant que vous comprenez comment fonctionne
A * , je veux parler de certains domaines étonnants de son application, qui sont loin d'être limités à trouver des chemins dans une grille de cellules.
Partie 4. A * en pratique
Les trois premières parties de l'article commencent par les fondements mêmes des algorithmes de recherche de chemin et se terminent par une description claire de l'algorithme
A * . Tout cela est génial en théorie, mais comprendre comment cela est applicable dans la pratique est un sujet complètement différent.
Par exemple, que se passe-t-il si notre monde n'est pas une grille?
Que faire si un personnage ne peut pas pivoter instantanément de 90 degrés?
Comment construire un graphe si le monde est sans fin?
Et si nous ne nous soucions pas de la longueur du chemin, mais que nous dépendons de l'énergie solaire et que nous devons être sous le soleil autant que possible?
Comment trouver le chemin le plus court vers l'un des deux nœuds d'extrémité?
Fonction de coût
Dans les premiers exemples, nous avons recherché le chemin le plus court entre les nœuds de début et de fin. Cependant, au lieu de stocker des longueurs de chemin partielles dans la
length
variable, nous l'avons appelé
cost
. Pourquoi?
Nous pouvons faire en sorte que
A * recherche non seulement le
chemin le
plus court , mais aussi le
meilleur , et la définition du «meilleur» peut être choisie en fonction de nos objectifs. Lorsque nous avons besoin du chemin le plus court, le coût est la longueur du chemin, mais si nous voulons minimiser, par exemple, la consommation de carburant, nous devons l'utiliser comme coût. Si nous voulons maximiser le «temps passé sous le soleil», alors le coût est le temps passé sans soleil. Et ainsi de suite.
Dans le cas gĂ©nĂ©ral, cela signifie que les coĂ»ts correspondants sont associĂ©s Ă chaque bord du graphique. Dans les exemples ci-dessus, la valeur a Ă©tĂ© dĂ©finie implicitement et a toujours Ă©tĂ© considĂ©rĂ©e comme Ă©gale Ă
1
, car nous avons compté les étapes en cours de route. Mais nous pouvons changer le coût de la côte selon ce que nous voulons minimiser.
Fonction de critère
Disons que notre objet est une voiture et qu'il doit se rendre à la station-service. Tout ravitaillement nous conviendra. Il prend l'itinéraire le plus court jusqu'à la station-service la plus proche.
L'approche naïve consistera à calculer tour à tour le chemin le plus court vers chaque ravitaillement et à sélectionner le plus court. Cela fonctionnera, mais ce sera un processus assez coûteux.
Et si nous pouvions remplacer un
goal_node
par une méthode qui, sur un nœud donné, peut dire s'il est fini ou non. Grâce à cela, nous pouvons rechercher plusieurs objectifs en même temps. Nous devons également modifier l'heuristique afin qu'elle renvoie le coût estimé minimum de tous les nœuds d'extrémité possibles.
Selon les spécificités de la situation, nous ne pourrons peut-être pas atteindre l'objectif
parfaitement , ou cela coûtera trop cher (si nous envoyons le personnage à travers une demi-carte énorme, la différence d'un pouce est-elle importante?), Donc la méthode
is_goal_node
peut retourner
true
lorsque nous Nous sommes "assez proches".
Aucune certitude n'est requise.
Représenter le monde comme une grille discrète peut ne pas être suffisant pour de nombreux jeux. Prenons, par exemple, un jeu de tir à la première personne ou un jeu de course. Le monde est discret, mais il ne peut pas être représenté comme une grille.
Mais il y a un problème plus grave: et si le monde était sans fin? Dans ce cas, même si nous pouvons le présenter sous forme de grille, alors nous ne pourrons tout simplement pas construire un graphe correspondant à la grille, car il doit être infini.
Cependant, tout n'est pas perdu. Bien sûr, pour l'algorithme de recherche de graphiques, nous avons certainement besoin d'un graphique. Mais personne n'a dit que le graphique devait être
complet !
Si vous regardez attentivement l'algorithme, vous remarquerez que nous ne faisons rien avec le graphique dans son ensemble; nous examinons le graphe localement, obtenant des nœuds que nous pouvons atteindre à partir du nœud en question. Comme le montre la démo
A * , certains nœuds du graphique ne sont pas du tout étudiés.
Alors pourquoi ne construisons-nous pas simplement le graphique dans le processus de recherche?
Nous faisons de la position actuelle du personnage le nĹ“ud de dĂ©part. Lors de l'appel Ă
get_adjacent_nodes
il peut déterminer les façons possibles dont le personnage peut se déplacer à partir d'un nœud donné et créer des nœuds voisins à la volée.
Au-delĂ de trois dimensions
MĂŞme si votre monde est
vraiment un maillage 2D, il y a d'autres aspects à considérer. Par exemple, que faire si un personnage ne peut pas pivoter instantanément de 90 ou 180 degrés, comme c'est généralement le cas?
L'état représenté par chaque nœud de recherche ne doit pas être simplement une
position ; au contraire, il peut comprendre un ensemble de valeurs arbitrairement complexes. Par exemple, si les virages à 90 degrés prennent autant de temps que la transition d'une cellule à une autre, l'état du personnage peut être défini comme
[position, heading]
. Chaque nœud peut représenter non seulement la position du personnage, mais aussi la direction de son regard; et les nouveaux bords du graphique (explicites ou indirects) reflètent cela.
Si vous revenez Ă la grille 5x5 d'origine, la position de recherche initiale peut maintenant ĂŞtre
[A, East]
. Les nœuds voisins sont maintenant
[B, East]
et
[A, South]
- si nous voulons atteindre
F , nous devons d'abord ajuster la direction afin que le chemin prenne la forme
[A, East]
,
[A, South]
,
[F, South]
.
Tireur à la première personne? Au moins quatre dimensions:
[X, Y, Z, Heading]
. Peut-ĂŞtre mĂŞme
[X, Y, Z, Heading, Health, Ammo]
.
Notez que plus l'état est complexe, plus la fonction heuristique doit être complexe.
Un * lui -
même est simple; l'art naît souvent d'une bonne heuristique.
Conclusion
Le but de cet article est de dissiper une fois pour toutes le mythe selon lequel
A * est un algorithme mystique qui ne peut être déchiffré. Au contraire, j'ai montré qu'il n'y a rien de mystérieux et qu'en fait on peut tout simplement en déduire en partant de zéro.
Lectures complémentaires
Amit Patel a une excellente
«Introduction à l'algorithme A *» (et ses autres articles sur divers sujets sont également excellents!).