A* (pronunciado "A-star") es un algoritmo de búsqueda de rutas y recorrido de gráficos , que se utiliza en muchos campos de la informática debido a su integridad, optimización y eficiencia óptima. [1] Dado un gráfico ponderado , un nodo fuente y un nodo objetivo, el algoritmo encuentra el camino más corto (con respecto a los pesos dados) desde el origen hasta el objetivo.
Un inconveniente práctico importante es su complejidad espacial , ya que almacena todos los nodos generados en la memoria. Por lo tanto, en los sistemas prácticos de rutas de viaje , generalmente se ve superado por algoritmos que pueden preprocesar el gráfico para lograr un mejor rendimiento, [2] así como por enfoques limitados por memoria; sin embargo, A* sigue siendo la mejor solución en muchos casos. [3]
Peter Hart , Nils Nilsson y Bertram Raphael del Instituto de Investigación de Stanford (ahora SRI International ) publicaron por primera vez el algoritmo en 1968. [4] Puede verse como una extensión del algoritmo de Dijkstra . A* logra un mejor rendimiento utilizando heurísticas para guiar su búsqueda.
En comparación con el algoritmo de Dijkstra, el algoritmo A* solo encuentra el camino más corto desde una fuente específica hasta un objetivo específico, y no el árbol de camino más corto desde una fuente específica hasta todos los objetivos posibles. Ésta es una compensación necesaria para utilizar una heurística dirigida a un objetivo específico. Para el algoritmo de Dijkstra, dado que se genera todo el árbol de ruta más corta, cada nodo es un objetivo y no puede haber una heurística dirigida a un objetivo específico.
A* fue creado como parte del proyecto Shakey , cuyo objetivo era construir un robot móvil que pudiera planificar sus propias acciones. Nils Nilsson propuso originalmente utilizar el algoritmo Graph Traverser [5] para la planificación de rutas de Shakey. [6] Graph Traverser se guía por una función heurística h ( n ) , la distancia estimada desde el nodo n hasta el nodo objetivo: ignora por completo g ( n ) , la distancia desde el nodo inicial hasta n . Bertram Raphael sugirió usar la suma, g ( n ) + h ( n ) . [7] Peter Hart inventó los conceptos que ahora llamamos admisibilidad y consistencia de funciones heurísticas. A* fue diseñado originalmente para encontrar caminos de menor costo cuando el costo de un camino es la suma de sus costos, pero se ha demostrado que A* puede usarse para encontrar caminos óptimos para cualquier problema que satisfaga las condiciones de un álgebra de costos. [8]
El artículo original de A* de 1968 [4] contenía un teorema que afirmaba que ningún algoritmo [a] similar a A* podría expandir menos nodos que A* si la función heurística es consistente y la regla de desempate de A* se elige adecuadamente. Unos años más tarde se publicó una "corrección" [9] afirmando que no se requería coherencia, pero se demostró que esto era falso en 1985 en el estudio definitivo de Dechter y Pearl sobre la optimización de A* (ahora llamada eficiencia óptima), que dio un ejemplo de A* con una heurística que era admisible pero no consistente expandiendo arbitrariamente más nodos que un algoritmo alternativo tipo A*. [10]
A* es un algoritmo de búsqueda informado , o búsqueda del mejor primero , lo que significa que está formulado en términos de gráficos ponderados : comenzando desde un nodo inicial específico de un gráfico, su objetivo es encontrar una ruta hacia el nodo objetivo dado que tenga el menor coste (menor distancia recorrida, menor tiempo, etc.). Para ello, mantiene un árbol de rutas que se originan en el nodo inicial y extiende esas rutas un borde a la vez hasta alcanzar el nodo objetivo.
En cada iteración de su bucle principal, A* necesita determinar cuál de sus caminos extenderá. Lo hace basándose en el costo del camino y una estimación del costo requerido para extender el camino hasta la meta. Específicamente, A* selecciona la ruta que minimiza
donde n es el siguiente nodo en la ruta, g ( n ) es el costo de la ruta desde el nodo inicial hasta n , y h ( n ) es una función heurística que estima el costo de la ruta más barata desde n hasta la meta. La función heurística es específica del problema. Si la función heurística es admisible (lo que significa que nunca sobreestima el costo real para llegar a la meta), se garantiza que A* devolverá un camino de menor costo desde el principio hasta la meta.
Las implementaciones típicas de A* utilizan una cola de prioridad para realizar la selección repetida de nodos de costo mínimo (estimado) para expandir. Esta cola de prioridad se conoce como conjunto abierto , franja o frontera . En cada paso del algoritmo, el nodo con el valor f ( x ) más bajo se elimina de la cola, los valores f y g de sus vecinos se actualizan en consecuencia y estos vecinos se agregan a la cola. El algoritmo continúa hasta que un nodo eliminado (por lo tanto, el nodo con el valor f más bajo de todos los nodos marginales) es un nodo objetivo. [b] El valor f de ese objetivo es también el costo del camino más corto, ya que h en el objetivo es cero en una heurística admisible.
El algoritmo descrito hasta ahora nos da sólo la longitud del camino más corto. Para encontrar la secuencia real de pasos, el algoritmo se puede revisar fácilmente de modo que cada nodo en la ruta realice un seguimiento de su predecesor. Después de ejecutar este algoritmo, el nodo final apuntará a su predecesor, y así sucesivamente, hasta que el predecesor de algún nodo sea el nodo inicial.
Por ejemplo, al buscar la ruta más corta en un mapa, h ( x ) podría representar la distancia en línea recta hasta la meta, ya que físicamente es la distancia más pequeña posible entre dos puntos cualesquiera. Para un mapa de cuadrícula de un videojuego, usar la distancia Taxicab o la distancia Chebyshev es mejor dependiendo del conjunto de movimientos disponibles (de 4 direcciones u 8 direcciones).
Si la heurística h satisface la condición adicional h ( x ) ≤ d ( x , y ) + h ( y ) para cada arista ( x , y ) del gráfico (donde d denota la longitud de esa arista), entonces h se llama monótono o consistente . Con una heurística consistente, se garantiza que A* encontrará una ruta óptima sin procesar ningún nodo más de una vez y A* es equivalente a ejecutar el algoritmo de Dijkstra con el costo reducido d' ( x , y ) = d ( x , y ) + h ( y ) - h ( x ) [ cita necesaria ] .
El siguiente pseudocódigo describe el algoritmo:
función reconstruct_path ( cameFrom , actual ) total_path := {current} mientras está actual en cameFrom . Claves : actual : = vino de [ actual ] ruta_total . anteponer ( actual ) devolver ruta_total // A* encuentra un camino desde el principio hasta la meta. // h es la función heurística. h(n) estima el costo para alcanzar la meta desde el nodo n. función A_Star ( inicio , objetivo , h ) // El conjunto de nodos descubiertos que pueden necesitar ser (re)expandidos. // Inicialmente, sólo se conoce el nodo inicial. // Esto generalmente se implementa como un montón mínimo o cola de prioridad en lugar de un conjunto de hash. openSet := {inicio} // Para el nodo n, vinoDe[n] es el nodo que lo precede inmediatamente en la ruta más barata desde el // inicio hasta n conocido actualmente. cameFrom := un mapa vacío // Para el nodo n, gScore[n] es el costo de la ruta más barata desde el inicio hasta n conocida actualmente. gScore := mapa con valor predeterminado de Infinity gScore [ inicio ] := 0 // Para el nodo n, fScore[n] := gScore[n] + h(n). fScore[n] representa nuestra mejor suposición actual sobre // cuán barata podría ser una ruta de principio a fin si pasa por n. fScore := mapa con valor predeterminado de Infinity fScore [ inicio ] := h ( inicio ) mientras openSet no está vacío // Esta operación puede ocurrir en tiempo O(Log(N)) si openSet es un montón mínimo o una cola de prioridad actual : = el nodo en openSet que tiene el valor fScore [] más bajo si actual = retorno de objetivo reconstruct_path ( viene de , actual ) abrirConjunto . Eliminar ( actual ) para cada vecino del actual // d(actual,vecino) es el peso del borde desde el actual al vecino // tentative_gScore es la distancia desde el inicio hasta el vecino hasta el actual tentative_gScore := gScore [ actual ] + d ( actual , vecino ) if tentative_gScore < gScore [ vecino ] // Esta ruta al vecino es mejor que cualquier anterior. ¡Grábalo! cameFrom [ vecino ] := gScore actual [ vecino ] := tentative_gScore fScore [ vecino ] := tentative_gScore + h ( vecino ) si el vecino no está en openSet openSet . agregar vecino ) _ // El conjunto abierto está vacío pero el objetivo nunca se alcanzó. Error de devolución
Observación: En este pseudocódigo, si se llega a un nodo por una ruta, se elimina de openSet y posteriormente se llega a un nodo por una ruta más barata, se agregará nuevamente a openSet. Esto es esencial para garantizar que la ruta devuelta sea óptima si la función heurística es admisible pero no consistente . Si la heurística es consistente, cuando se elimina un nodo de openSet, se garantiza que la ruta hacia él será óptima, por lo que la prueba ' tentative_gScore < gScore[neighbor]
' siempre fallará si se vuelve a alcanzar el nodo.
Un ejemplo de un algoritmo A* en acción donde los nodos son ciudades conectadas con carreteras y h(x) es la distancia en línea recta hasta el punto objetivo:
Clave: verde: inicio; azul: gol; naranja: visitado
El algoritmo A* tiene aplicaciones en el mundo real. En este ejemplo, los bordes son vías de ferrocarril y h(x) es la distancia del círculo máximo (la distancia más corta posible en una esfera) al objetivo. El algoritmo busca un camino entre Washington, DC y Los Ángeles.
Hay una serie de optimizaciones simples o detalles de implementación que pueden afectar significativamente el rendimiento de una implementación A*. El primer detalle a tener en cuenta es que la forma en que la cola de prioridad maneja los vínculos puede tener un efecto significativo en el rendimiento en algunas situaciones. Si se rompen los vínculos y la cola se comporta de manera LIFO , A* se comportará como una búsqueda en profundidad entre rutas de igual costo (evitando explorar más de una solución igualmente óptima).
Cuando se requiere una ruta al final de la búsqueda, es común mantener con cada nodo una referencia al padre de ese nodo. Al final de la búsqueda, estas referencias se pueden utilizar para recuperar la ruta óptima. Si estas referencias se mantienen, entonces puede ser importante que el mismo nodo no aparezca en la cola de prioridad más de una vez (cada entrada corresponde a una ruta diferente al nodo y cada una con un costo diferente). Un enfoque estándar aquí es verificar si un nodo a punto de agregarse ya aparece en la cola de prioridad. Si es así, entonces los punteros de prioridad y padre se cambian para corresponder a la ruta de menor costo. Una cola de prioridad basada en montón binario estándar no admite directamente la operación de búsqueda de uno de sus elementos, pero se puede aumentar con una tabla hash que asigna elementos a su posición en el montón, permitiendo que esta operación de disminución de prioridad se realice en tiempo logarítmico. Alternativamente, un montón de Fibonacci puede realizar las mismas operaciones de disminución de prioridad en un tiempo amortizado constante .
El algoritmo de Dijkstra , como otro ejemplo de algoritmo de búsqueda de costo uniforme, puede verse como un caso especial de A* donde para todo x . [11] [12] La búsqueda general en profundidad se puede implementar usando A* considerando que hay un contador global C inicializado con un valor muy grande. Cada vez que procesamos un nodo asignamos C a todos sus vecinos recién descubiertos. Después de cada tarea, reducimos el contador C en uno. Por tanto, cuanto antes se descubra un nodo, mayor será su valor. Tanto el algoritmo de Dijkstra como la búsqueda en profundidad se pueden implementar de manera más eficiente sin incluir un valor en cada nodo.
En gráficos finitos con pesos de borde no negativos, se garantiza que A* terminará y estará completo , es decir, siempre encontrará una solución (un camino desde el principio hasta la meta) si existe. En gráficos infinitos con un factor de ramificación finito y costos de borde acotados desde cero ( para algunos fijos ), se garantiza que A* terminará solo si existe una solución. [1]
Se dice que un algoritmo de búsqueda es admisible si se garantiza que devolverá una solución óptima. Si la función heurística utilizada por A* es admisible , entonces A* es admisible. Una "prueba" intuitiva de esto es la siguiente:
Cuando A* finaliza su búsqueda, ha encontrado un camino desde el inicio hasta la meta cuyo costo real es menor que el costo estimado de cualquier camino desde el inicio hasta la meta a través de cualquier nodo abierto (el valor del nodo). Cuando la heurística es admisible, esas estimaciones son optimistas (no del todo; consulte el párrafo siguiente), por lo que A* puede ignorar con seguridad esos nodos porque no es posible que conduzcan a una solución más barata que la que ya tiene. En otras palabras, A* nunca pasará por alto la posibilidad de un camino de menor costo desde el principio hasta la meta y, por lo tanto, continuará buscando hasta que tales posibilidades no existan.
La prueba real es un poco más complicada porque no se garantiza que los valores de los nodos abiertos sean optimistas incluso si la heurística es admisible. Esto se debe a que no se garantiza que los valores de los nodos abiertos sean óptimos, por lo que no se garantiza que la suma sea optimista.
El algoritmo A es óptimamente eficiente con respecto a un conjunto de algoritmos alternativos Alts en un conjunto de problemas P si para cada problema P en P y cada algoritmo A′ en Alts , el conjunto de nodos expandidos por A al resolver P es un subconjunto (posiblemente igual) del conjunto de nodos expandidos por A′ al resolver P. El estudio definitivo de la eficiencia óptima de A* se debe a Rina Dechter y Judea Pearl. [10] Consideraron que una variedad de definiciones de Alts y P en combinación con la heurística de A* eran simplemente admisibles o eran a la vez consistentes y admisibles. El resultado positivo más interesante que demostraron es que A*, con una heurística consistente, es óptimamente eficiente con respecto a todos los algoritmos de búsqueda similares a A* admisibles en todos los problemas de búsqueda "no patológicos". En términos generales, su noción del problema no patológico es lo que ahora entendemos por "hasta el desempate". Este resultado no se cumple si la heurística de A* es admisible pero no consistente. En ese caso, Dechter y Pearl demostraron que existen algoritmos admisibles similares a A* que pueden expandir arbitrariamente menos nodos que A* en algunos problemas no patológicos.
La eficiencia óptima se trata del conjunto de nodos expandidos, no del número de expansiones de nodos (el número de iteraciones del bucle principal de A*). Cuando la heurística utilizada es admisible pero no consistente, es posible que un nodo se expanda A* muchas veces, un número exponencial de veces en el peor de los casos. [13] En tales circunstancias, el algoritmo de Dijkstra podría superar a A* por un amplio margen. Sin embargo, investigaciones más recientes encontraron que este caso patológico solo ocurre en ciertas situaciones artificiales donde el peso del borde del gráfico de búsqueda es exponencial en el tamaño del gráfico y que ciertas heurísticas inconsistentes (pero admisibles) pueden conducir a un número reducido de expansiones de nodos. en búsquedas A*. [14] [15]
Si bien el criterio de admisibilidad garantiza un camino de solución óptimo, también significa que A* debe examinar todos los caminos igualmente meritorios para encontrar el camino óptimo. Para calcular los caminos más cortos aproximados, es posible acelerar la búsqueda a expensas de la optimización relajando el criterio de admisibilidad. A menudo queremos limitar esta relajación, de modo que podamos garantizar que la ruta de solución no sea peor que (1 + ε ) veces la ruta de solución óptima. Esta nueva garantía se denomina ε -admisible.
Hay varios algoritmos ε -admisibles:
La complejidad temporal de A* depende de la heurística. En el peor de los casos de un espacio de búsqueda ilimitado, el número de nodos expandidos es exponencial en la profundidad de la solución (el camino más corto) d : O ( b d ) , donde b es el factor de ramificación (el número promedio de sucesores por estado ). [23] Esto supone que existe un estado objetivo y que es alcanzable desde el estado inicial; si no es así y el espacio de estados es infinito, el algoritmo no terminará.
La función heurística tiene un efecto importante en el desempeño práctico de la búsqueda A*, ya que una buena heurística permite a A* eliminar muchos de los bd nodos que una búsqueda no informada expandiría. Su calidad se puede expresar en términos del factor de ramificación efectivo b * , que se puede determinar empíricamente para una instancia de problema midiendo el número de nodos generados por la expansión, N , y la profundidad de la solución, y luego resolviendo [24]
Las buenas heurísticas son aquellas con un factor de ramificación efectivo bajo (siendo el óptimo b * = 1 ).
La complejidad temporal es polinómica cuando el espacio de búsqueda es un árbol, hay un único estado objetivo y la función heurística h cumple la siguiente condición:
donde h * es la heurística óptima, el costo exacto para llegar de x a la meta. En otras palabras, el error de h no crecerá más rápido que el logaritmo de la "heurística perfecta" h * que devuelve la distancia verdadera desde x hasta la meta. [17] [23]
La complejidad espacial de A* es aproximadamente la misma que la de todos los demás algoritmos de búsqueda de gráficos, ya que mantiene todos los nodos generados en la memoria. [1] En la práctica, este resulta ser el mayor inconveniente de la búsqueda A*, lo que lleva al desarrollo de búsquedas heurísticas limitadas a la memoria, como la profundización iterativa A* , la A* limitada a la memoria y la SMA* .
A* se utiliza a menudo para el problema común de búsqueda de rutas en aplicaciones como videojuegos, pero originalmente se diseñó como un algoritmo general de recorrido de gráficos. [4] Encuentra aplicaciones en diversos problemas, incluido el problema del análisis sintáctico utilizando gramáticas estocásticas en PNL . [25] Otros casos incluyen una búsqueda informativa con aprendizaje en línea. [26]
Lo que distingue a A* de un algoritmo codicioso de búsqueda de lo mejor primero es que tiene en cuenta el costo/distancia ya recorrida, g ( n ) .
Algunas variantes comunes del algoritmo de Dijkstra pueden verse como un caso especial de A* donde la heurística para todos los nodos; [11] [12] a su vez, tanto Dijkstra como A* son casos especiales de programación dinámica . [27] A* en sí mismo es un caso especial de generalización de rama y límite . [28]
A* es similar a la búsqueda de haz excepto que la búsqueda de haz mantiene un límite en el número de caminos que tiene que explorar. [29]
A* también se puede adaptar a un algoritmo de búsqueda bidireccional . Se debe tener especial cuidado con el criterio de parada. [33]
Uno de los primeros problemas que consideramos fue cómo planificar una secuencia de "puntos de ruta" que Shakey pudiera utilizar para navegar de un lugar a otro. […] El problema de navegación de Shakey es un problema de búsqueda, similar a los que mencioné anteriormente.
Bertram Raphael, que dirigía el trabajo sobre Shakey en ese momento, observó que un mejor valor para la puntuación sería la suma de la distancia recorrida hasta ahora desde la posición inicial más mi estimación heurística de hasta dónde tenía que llegar el robot.