Melaksanakan A * Pathfinding di Java

1. Pengenalan

Algoritma Pathfinding adalah teknik untuk menavigasi peta , yang membolehkan kita mencari jalan antara dua titik yang berbeza. Algoritma yang berbeza mempunyai kebaikan dan keburukan yang berbeza, selalunya dari segi kecekapan algoritma dan kecekapan laluan yang dihasilkannya.

2. Apa itu Algoritma Pathfinding?

Algoritma Pathfinding adalah teknik untuk menukar grafik - yang terdiri daripada nod dan tepi - menjadi laluan melalui grafik . Grafik ini boleh menjadi apa sahaja yang perlu dilalui. Untuk artikel ini, kami akan cuba melintasi sebahagian sistem London Underground:

("Peta Lintasan Bawah Tanah London DLR Crossrail" oleh kapal layar dilesenkan di bawah CC BY-SA 4.0)

Ini mempunyai banyak komponen menarik untuknya:

  • Kami mungkin atau tidak mempunyai laluan langsung antara titik permulaan dan akhir kami. Sebagai contoh, kita dapat pergi langsung dari "Earl's Court" ke "Monument", tetapi tidak ke "Angel".
  • Setiap langkah mempunyai kos tertentu. Dalam kes kami, ini adalah jarak antara stesen.
  • Setiap perhentian hanya dihubungkan ke subset kecil dari perhentian yang lain. Contohnya, "Regent's Park" dihubungkan secara langsung dengan hanya "Baker Street" dan "Oxford Circus".

Semua algoritma mencari jalan mengambil input kumpulan semua nod - stesen dalam kes kita - dan hubungan di antara mereka, dan juga titik permulaan dan akhir yang diinginkan. Hasilnya biasanya merupakan set nod yang akan membawa kita dari awal hingga akhir, mengikut urutan yang perlu kita jalani .

3. Apa itu A *?

A * adalah salah satu algoritma pencarian jalan khusus , pertama kali diterbitkan pada tahun 1968 oleh Peter Hart, Nils Nilsson, dan Bertram Raphael. Secara umum dianggap sebagai algoritma terbaik untuk digunakan ketika tidak ada kesempatan untuk menghitung rute sebelumnya dan tidak ada batasan penggunaan memori .

Kerumitan memori dan prestasi boleh menjadi O (b ^ d) dalam keadaan terburuk, jadi walaupun ia akan selalu menghasilkan laluan paling cekap, ia tidak selalu merupakan kaedah paling berkesan untuk melakukannya.

A * sebenarnya adalah variasi pada Algoritma Dijkstra, di mana terdapat maklumat tambahan yang disediakan untuk membantu memilih simpul seterusnya untuk digunakan. Maklumat tambahan ini tidak perlu sempurna - jika kita sudah mempunyai maklumat yang sempurna, maka pencarian jalan tidak ada gunanya. Tetapi semakin baik, semakin baik hasil akhirnya.

4. Bagaimana A * Berfungsi?

Algoritma A * berfungsi dengan memilih secara berulang secara berulang apakah jalan terbaik sejauh ini, dan berusaha untuk melihat apakah langkah seterusnya yang terbaik.

Semasa bekerja dengan algoritma ini, kami mempunyai beberapa data yang perlu kami ikuti. "Set terbuka" adalah semua node yang sedang kita pertimbangkan. Ini bukan setiap simpul dalam sistem, tetapi sebaliknya, setiap nod yang mungkin kita buat dari langkah seterusnya.

Kami juga akan memantau skor terbaik semasa, anggaran jumlah skor dan node sebelumnya terbaik semasa untuk setiap nod dalam sistem.

Sebagai sebahagian daripada ini, kita perlu dapat mengira dua skor yang berbeza. Salah satunya adalah skor untuk mendapatkan dari satu simpul ke yang berikutnya. Yang kedua adalah heuristik untuk memberikan anggaran kos dari mana-mana simpul ke destinasi. Anggaran ini tidak perlu tepat, tetapi ketepatan yang lebih besar akan memberikan hasil yang lebih baik. Satu-satunya syarat adalah bahawa kedua-dua skor itu selaras antara satu sama lain - iaitu, mereka berada dalam unit yang sama.

Pada awalnya, set terbuka kami terdiri daripada simpul permulaan kami, dan kami sama sekali tidak mempunyai maklumat mengenai nod lain.

Pada setiap lelaran, kami akan:

  • Pilih simpul dari set terbuka kami yang mempunyai anggaran jumlah skor terendah
  • Keluarkan nod ini dari set terbuka
  • Tambahkan ke set terbuka semua nod yang boleh kita capai darinya

Apabila kami melakukan ini, kami juga mengusahakan skor baru dari simpul ini kepada setiap yang baru untuk melihat apakah ini merupakan penambahbaikan pada apa yang telah kami capai setakat ini, dan jika ya, maka kami mengemas kini apa yang kami tahu mengenai simpul tersebut.

Ini kemudian berulang sehingga simpul dalam set terbuka kami yang mempunyai jumlah skor terkurang paling rendah adalah destinasi kami, pada ketika itu kami mendapat laluan.

4.1. Contoh Berfungsi

Sebagai contoh, mari kita mulakan dari "Marylebone" dan cuba mencari jalan ke "Bond Street".

Pada awalnya, set terbuka kami hanya terdiri dari "Marylebone" . Ini bermakna bahawa ini adalah simpul simpul yang mana kita mendapat "skor skor keseluruhan" terbaik.

Perhentian seterusnya kami ialah "Edgware Road", dengan biaya 0,4403 km, atau "Baker Street", dengan biaya 0,4153 km. Namun, "Edgware Road" berada di arah yang salah, jadi heuristik kami dari sini ke tujuan memberikan skor 1.4284 km, sedangkan "Baker Street" memiliki skor heuristik 1.0753 km.

Ini bererti bahawa setelah lelaran ini, set terbuka kami terdiri daripada dua entri - "Edgware Road", dengan anggaran skor keseluruhan 1.8687 km, dan "Baker Street", dengan anggaran jumlah skor 1.4906 km.

Pengulangan kedua kami kemudian akan bermula dari "Baker Street", kerana ini mempunyai anggaran jumlah skor terendah. Dari sini, hentian seterusnya kami boleh menjadi "Marylebone", "St. John's Wood "," Great Portland Street ", Regent's Park", atau "Bond Street".

Kami tidak akan menyelesaikan semua ini, tetapi mari kita ambil "Marylebone" sebagai contoh menarik. Kos untuk sampai ke sana lagi ialah 0,4153 km, tetapi ini bermakna jumlah kos sekarang adalah 0,8306 km. Selain itu heuristik dari sini ke destinasi memberikan skor 1.323 km.

Ini bermakna jumlah skor yang dianggarkan adalah 2.1536 km, yang lebih buruk daripada skor sebelumnya untuk nod ini. Ini masuk akal kerana kita harus melakukan kerja tambahan untuk tidak mendapat tempat dalam kes ini. Ini bermaksud bahawa kita tidak akan menganggap ini sebagai jalan yang sesuai. Oleh itu, perincian untuk "Marylebone" tidak diperbaharui, dan tidak ditambahkan kembali ke set terbuka.

5. Pelaksanaan Java

Setelah kita membincangkan bagaimana ini berfungsi, mari kita laksanakan. Kami akan membina penyelesaian generik, dan kemudian kami akan melaksanakan kod yang diperlukan agar ia berfungsi untuk London Underground. Kita kemudian dapat menggunakannya untuk senario lain dengan hanya menerapkan bahagian-bahagian tertentu.

5.1. Mewakili Grafik

Pertama, kita mesti dapat mewakili grafik kita yang ingin kita lalui. Ini terdiri daripada dua kelas - nod individu dan kemudian grafik secara keseluruhan.

Kami akan mewakili nod individu kami dengan antara muka yang dipanggil GraphNode :

public interface GraphNode { String getId(); }

Setiap nod kami mesti mempunyai ID. Apa-apa sahaja yang khusus untuk grafik tertentu ini dan tidak diperlukan untuk penyelesaian umum. Kelas-kelas ini adalah Java Beans sederhana tanpa logik khas.

Our overall graph is then represented by a class simply called Graph:

public class Graph { private final Set nodes; private final Map
    
      connections; public T getNode(String id) { return nodes.stream() .filter(node -> node.getId().equals(id)) .findFirst() .orElseThrow(() -> new IllegalArgumentException("No node found with ID")); } public Set getConnections(T node) { return connections.get(node.getId()).stream() .map(this::getNode) .collect(Collectors.toSet()); } }
    

This stores all of the nodes in our graph and has knowledge of which nodes connect to which. We can then get any node by ID, or all of the nodes connected to a given node.

At this point, we're capable of representing any form of graph we wish, with any number of edges between any number of nodes.

5.2. Steps on Our Route

The next thing we need is our mechanism for finding routes through the graph.

The first part of this is some way to generate a score between any two nodes. We'll the Scorer interface for both the score to the next node and the estimate to the destination:

public interface Scorer { double computeCost(T from, T to); }

Given a start and an end node, we then get a score for traveling between them.

We also need a wrapper around our nodes that carries some extra information. Instead of being a GraphNode, this is a RouteNode – because it's a node in our computed route instead of one in the entire graph:

class RouteNode implements Comparable { private final T current; private T previous; private double routeScore; private double estimatedScore; RouteNode(T current) { this(current, null, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY); } RouteNode(T current, T previous, double routeScore, double estimatedScore) { this.current = current; this.previous = previous; this.routeScore = routeScore; this.estimatedScore = estimatedScore; } }

As with GraphNode, these are simple Java Beans used to store the current state of each node for the current route computation. We've given this a simple constructor for the common case, when we're first visiting a node and have no additional information about it yet.

These also need to be Comparable though, so that we can order them by the estimated score as part of the algorithm. This means the addition of a compareTo() method to fulfill the requirements of the Comparable interface:

@Override public int compareTo(RouteNode other) { if (this.estimatedScore > other.estimatedScore) { return 1; } else if (this.estimatedScore < other.estimatedScore) { return -1; } else { return 0; } }

5.3. Finding Our Route

Now we're in a position to actually generate our routes across our graph. This will be a class called RouteFinder:

public class RouteFinder { private final Graph graph; private final Scorer nextNodeScorer; private final Scorer targetScorer; public List findRoute(T from, T to) { throw new IllegalStateException("No route found"); } }

We have the graph that we are finding the routes across, and our two scorers – one for the exact score for the next node, and one for the estimated score to our destination. We've also got a method that will take a start and end node and compute the best route between the two.

This method is to be our A* algorithm. All the rest of our code goes inside this method.

We start with some basic setup – our “open set” of nodes that we can consider as the next step, and a map of every node that we've visited so far and what we know about it:

Queue openSet = new PriorityQueue(); Map
    
      allNodes = new HashMap(); RouteNode start = new RouteNode(from, null, 0d, targetScorer.computeCost(from, to)); openSet.add(start); allNodes.put(from, start);
    

Our open set initially has a single node – our start point. There is no previous node for this, there's a score of 0 to get there, and we've got an estimate of how far it is from our destination.

The use of a PriorityQueue for the open set means that we automatically get the best entry off of it, based on our compareTo() method from earlier.

Now we iterate until either we run out of nodes to look at, or the best available node is our destination:

while (!openSet.isEmpty()) { RouteNode next = openSet.poll(); if (next.getCurrent().equals(to)) { List route = new ArrayList(); RouteNode current = next; do { route.add(0, current.getCurrent()); current = allNodes.get(current.getPrevious()); } while (current != null); return route; } // ...

When we've found our destination, we can build our route by repeatedly looking at the previous node until we reach our starting point.

Next, if we haven't reached our destination, we can work out what to do next:

 graph.getConnections(next.getCurrent()).forEach(connection -> { RouteNode nextNode = allNodes.getOrDefault(connection, new RouteNode(connection)); allNodes.put(connection, nextNode);   double newScore = next.getRouteScore() + nextNodeScorer.computeCost(next.getCurrent(), connection); if (newScore < nextNode.getRouteScore()) { nextNode.setPrevious(next.getCurrent()); nextNode.setRouteScore(newScore); nextNode.setEstimatedScore(newScore + targetScorer.computeCost(connection, to)); openSet.add(nextNode); } }); throw new IllegalStateException("No route found"); }

Here, we're iterating over the connected nodes from our graph. For each of these, we get the RouteNode that we have for it – creating a new one if needed.

We then compute the new score for this node and see if it's cheaper than what we had so far. If it is then we update it to match this new route and add it to the open set for consideration next time around.

This is the entire algorithm. We keep repeating this until we either reach our goal or fail to get there.

5.4. Specific Details for the London Underground

What we have so far is a generic A* pathfinder, but it's lacking the specifics we need for our exact use case. This means we need a concrete implementation of both GraphNode and Scorer.

Our nodes are stations on the underground, and we'll model them with the Station class:

public class Station implements GraphNode { private final String id; private final String name; private final double latitude; private final double longitude; }

The name is useful for seeing the output, and the latitude and longitude are for our scoring.

In this scenario, we only need a single implementation of Scorer. We're going to use the Haversine formula for this, to compute the straight-line distance between two pairs of latitude/longitude:

public class HaversineScorer implements Scorer { @Override public double computeCost(Station from, Station to) { double R = 6372.8; // Earth's Radius, in kilometers double dLat = Math.toRadians(to.getLatitude() - from.getLatitude()); double dLon = Math.toRadians(to.getLongitude() - from.getLongitude()); double lat1 = Math.toRadians(from.getLatitude()); double lat2 = Math.toRadians(to.getLatitude()); double a = Math.pow(Math.sin(dLat / 2),2) + Math.pow(Math.sin(dLon / 2),2) * Math.cos(lat1) * Math.cos(lat2); double c = 2 * Math.asin(Math.sqrt(a)); return R * c; } }

We now have almost everything necessary to calculate paths between any two pairs of stations. The only thing missing is the graph of connections between them. This is available in GitHub.

Let's use it for mapping out a route. We'll generate one from Earl's Court up to Angel. This has a number of different options for travel, on a minimum of two tube lines:

public void findRoute() { List route = routeFinder.findRoute(underground.getNode("74"), underground.getNode("7")); System.out.println(route.stream().map(Station::getName).collect(Collectors.toList())); }

This generates a route of Earl's Court -> South Kensington -> Green Park -> Euston -> Angel.

The obvious route that many people would have taken would likely be Earl's Count -> Monument -> Angel, because that's got fewer changes. Instead, this has taken a significantly more direct route even though it meant more changes.

6. Conclusion

Dalam artikel ini, kami telah melihat apa itu algoritma A *, bagaimana ia berfungsi, dan bagaimana menerapkannya dalam projek kami sendiri. Mengapa tidak mengambil ini dan memanjangkannya untuk kegunaan anda sendiri?

Mungkin cuba memperluasnya untuk mengambil kira pertukaran antara saluran tiub, dan lihat bagaimana hal itu mempengaruhi laluan yang dipilih?

Sekali lagi, kod lengkap untuk artikel tersebut terdapat di GitHub.