Menerapkan A * Pathfinding di Java

1. Perkenalan

Algoritme pencarian jalan adalah teknik untuk menavigasi peta , memungkinkan kita menemukan rute antara dua titik berbeda. Algoritme yang berbeda memiliki pro dan kontra yang berbeda, seringkali dalam hal efisiensi algoritme dan efisiensi rute yang dihasilkannya.

2. Apakah Algoritma Pathfinding itu?

Algoritma Pathfinding adalah teknik untuk mengubah grafik - yang terdiri dari node dan edge - menjadi rute melalui grafik . Grafik ini dapat berupa apa saja yang membutuhkan traverse. Untuk artikel ini, kami akan mencoba melintasi sebagian dari sistem London Underground:

(“Peta Crossrail DLR Bawah Tanah London Underground” oleh sameboat berlisensi di bawah CC BY-SA 4.0)

Ini memiliki banyak komponen yang menarik:

  • Kami mungkin atau mungkin tidak memiliki rute langsung antara titik awal dan akhir kami. Misalnya, kita bisa langsung dari "Earl's Court" ke "Monument", tapi tidak ke "Angel".
  • Setiap langkah memiliki biaya tertentu. Dalam kasus kami, ini adalah jarak antar stasiun.
  • Setiap perhentian hanya terhubung ke sebagian kecil perhentian lainnya. Misalnya, "Taman Regent" terhubung langsung hanya ke "Jalan Baker" dan "Sirkus Oxford".

Semua algoritma pathfinding mengambil kumpulan masukan dari semua node - stasiun dalam kasus kami - dan koneksi di antara mereka, dan juga titik awal dan akhir yang diinginkan. Outputnya biasanya kumpulan node yang akan membawa kita dari awal hingga akhir, dalam urutan yang harus kita lakukan .

3. Apakah A *?

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

Baik memori dan kompleksitas kinerja dapat menjadi O (b ^ d) dalam kasus terburuk, jadi meskipun ini akan selalu menghasilkan rute yang paling efisien, itu tidak selalu merupakan cara yang paling efisien untuk melakukannya.

A * sebenarnya adalah variasi dari Algoritma Dijkstra, dimana terdapat informasi tambahan yang disediakan untuk membantu memilih node berikutnya untuk digunakan. Informasi tambahan ini tidak perlu sempurna - jika kita sudah memiliki informasi yang sempurna, maka pencarian jalan tidak ada gunanya. Tetapi semakin baik, semakin baik hasil akhirnya.

4. Bagaimana A * Bekerja?

Algoritme A * bekerja dengan memilih secara berulang rute terbaik sejauh ini, dan mencoba melihat apa langkah terbaik berikutnya.

Saat bekerja dengan algoritme ini, kami memiliki beberapa data yang perlu kami lacak. "Open set" adalah semua node yang sedang kita pertimbangkan. Ini bukan setiap node di sistem, tetapi sebagai gantinya, ini adalah setiap node yang mungkin kita buat langkah selanjutnya.

Kami juga akan melacak skor terbaik saat ini, estimasi skor total, dan node terbaik sebelumnya untuk setiap node dalam sistem.

Sebagai bagian dari ini, kita perlu menghitung dua nilai yang berbeda. Salah satunya adalah skor untuk berpindah dari satu node ke node berikutnya. Yang kedua adalah heuristik untuk memberikan perkiraan biaya dari setiap node ke tujuan. Perkiraan ini tidak perlu akurat, tetapi akurasi yang lebih tinggi akan memberikan hasil yang lebih baik. Satu-satunya persyaratan adalah bahwa kedua skor tersebut konsisten satu sama lain - artinya, keduanya berada dalam unit yang sama.

Pada awalnya, set terbuka kami terdiri dari node awal kami, dan kami tidak memiliki informasi tentang node lain sama sekali.

Pada setiap iterasi, kami akan:

  • Pilih node dari set terbuka kami yang memiliki skor total perkiraan terendah
  • Hapus node ini dari set terbuka
  • Tambahkan ke set terbuka semua node yang bisa kita jangkau darinya

Saat kami melakukan ini, kami juga menghitung skor baru dari node ini ke setiap node baru untuk melihat apakah ini peningkatan dari apa yang kami dapatkan sejauh ini, dan jika ya, maka kami memperbarui apa yang kami ketahui tentang node tersebut.

Ini kemudian berulang sampai node di set terbuka kami yang memiliki estimasi skor total terendah adalah tujuan kami, di titik mana kami mendapatkan rute kami.

4.1. Contoh yang Berhasil

Misalnya, mari kita mulai dari "Marylebone" dan mencoba mencari jalan ke "Bond Street".

Pada awalnya, set terbuka kami hanya terdiri dari "Marylebone" . Itu berarti bahwa ini secara implisit adalah simpul yang kita punya "perkiraan skor total" terbaiknya.

Pemberhentian kami berikutnya dapat berupa "Jalan Edgware", dengan biaya 0,4403 km, atau "Jalan Baker", dengan biaya 0,4153 km. Namun, "Edgware Road" salah arah, jadi heuristik kami dari sini ke tujuan memberikan skor 1.4284 km, sedangkan "Baker Street" memiliki skor heuristik 1.0753 km.

Ini berarti bahwa setelah iterasi ini set terbuka kami terdiri dari dua entri - “Edgware Road”, dengan perkiraan skor total 1,8687 km, dan “Jalan Baker”, dengan perkiraan skor total 1,4906 km.

Kemudian iterasi kedua kami akan dimulai dari "Jalan Baker", karena ini memiliki skor total perkiraan terendah. Dari sini, perhentian kami berikutnya bisa jadi "Marylebone", "St. John's Wood ”,“ Great Portland Street ”, Regent's Park”, atau “Bond Street”.

Kami tidak akan membahas semua ini, tapi mari kita "Marylebone" sebagai contoh yang menarik. Biaya ke sana lagi 0,4153 km, tapi ini berarti total biaya sekarang 0,8306 km. Selain itu heuristik dari sini ke tujuan memberikan skor 1,323 km.

Artinya, skor total yang diperkirakan adalah 2,1536 km, lebih buruk dari skor sebelumnya untuk node ini. Ini masuk akal karena kami harus melakukan pekerjaan ekstra untuk tidak mendapatkan hasil dalam kasus ini. Ini berarti bahwa kami tidak akan menganggap ini sebagai rute yang layak. Dengan demikian, detail untuk "Marylebone" tidak diperbarui, dan tidak ditambahkan kembali ke set terbuka.

5. Implementasi Java

Sekarang kita telah membahas cara kerjanya, mari kita benar-benar menerapkannya. Kami akan membuat solusi umum, dan kemudian kami akan menerapkan kode yang diperlukan agar solusi tersebut berfungsi untuk London Underground. Kami kemudian dapat menggunakannya untuk skenario lain dengan menerapkan hanya bagian-bagian tertentu itu.

5.1. Mewakili Grafik

Pertama, kita harus bisa merepresentasikan grafik kita yang ingin kita lintasi. Ini terdiri dari dua kelas - node individu dan kemudian grafik secara keseluruhan.

Kami akan mewakili node individu kami dengan antarmuka yang disebut GraphNode :

public interface GraphNode { String getId(); }

Setiap node kita harus memiliki ID. Ada lagi yang khusus untuk grafik khusus ini dan tidak diperlukan untuk solusi umum. Kelas-kelas ini adalah Kacang Java sederhana tanpa logika khusus.

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, kita telah melihat apa itu algoritma A *, cara kerjanya, dan cara menerapkannya dalam proyek kita sendiri. Mengapa tidak mengambil ini dan memperpanjangnya untuk Anda gunakan sendiri?

Mungkin mencoba untuk memperluasnya untuk memperhitungkan interchange antar jalur tube, dan lihat bagaimana hal itu mempengaruhi rute yang dipilih?

Dan lagi, kode lengkap untuk artikel tersedia di GitHub.