jario_
Wróć do przeglądu

Algorytmy i struktury danych · Rozdział 8

Hashing und Graphen

In einer Liste suchst du ein Element in O(n), in einem sortierten Array mit binärer Suche in O(log n). Eine Hashtabelle schafft es im Mittel in O(1). Danach geht es um Graphen: Knoten und Kanten, mit denen du Straßennetze, Freundschaften oder Netzwerke beschreibst, und um die Algorithmen, die darin Wege finden.

Die Idee des Hashings

Eine Hashtabelle ist ein Array von Fächern (engl. buckets). Eine Hashfunktion rechnet aus dem Schlüssel eine Zahl, und zahl % größe bestimmt das Fach. Zum Suchen rechnest du dieselbe Funktion noch einmal und schaust nur in dieses eine Fach, statt alle Elemente durchzugehen.

Eine einfache Hashfunktion für Strings:

h = 0für jedes Zeichen c:  h = (h * 31 + c) % size

Eine gute Hashfunktion verteilt die Schlüssel gleichmäßig und liefert für gleiche Schlüssel immer dasselbe Ergebnis. Genau so arbeitet HashMap: Sie ruft hashCode() des Schlüssels auf und vergleicht im Fach mit equals. Deshalb müssen eigene Klassen als Schlüssel beide Methoden passend überschreiben.

Kollisionen und Lastfaktor

Landen zwei verschiedene Schlüssel im selben Fach, ist das eine Kollision. Sie lassen sich nicht vermeiden: Es gibt viel mehr mögliche Strings als Fächer. Die übliche Lösung ist Verkettung (engl. separate chaining): Jedes Fach ist eine Liste, kollidierende Schlüssel stehen darin hintereinander.

Wie voll die Tabelle ist, misst der Lastfaktor α = Anzahl Elemente / Anzahl Fächer. Bei gleichmäßiger Verteilung liegen in jedem Fach im Mittel α Elemente. Suchen, Einfügen und Löschen kosten dann im Mittel O(1 + α), also O(1), solange α klein bleibt. HashMap vergrößert ihr Array deshalb automatisch, sobald α über 0,75 steigt. Im schlechtesten Fall, wenn alle Schlüssel im selben Fach landen, wird daraus O(n).

import java.util.*;
public class Main {    static int hash(String key, int size) {        int h = 0;        for (int i = 0; i < key.length(); i++) {            h = (h * 31 + key.charAt(i)) % size;        }        return h;    }
    public static void main(String[] args) {        int size = 7;        List<List<String>> buckets = new ArrayList<>();        for (int i = 0; i < size; i++) {            buckets.add(new LinkedList<>());        }
        String[] names = {"Tom", "Ana", "Max", "Eva", "Leo"};        for (String name : names) {            buckets.get(hash(name, size)).add(name);        }
        for (int i = 0; i < size; i++) {            System.out.println(i + ": " + buckets.get(i));        }        System.out.println("load factor: " + (double) names.length / size);    }}

Tom und Eva kollidieren in Fach 1. Bei 5 Elementen in 7 Fächern ist der Lastfaktor etwa 0,71.

Graphen speichern

Ein Graph besteht aus Knoten und Kanten. In einem ungerichteten Graphen gilt eine Kante in beide Richtungen, in einem gerichteten nur in eine. Haben Kanten eine Länge oder Kosten, heißt der Graph gewichtet. Die Knoten nummerierst du am einfachsten mit 0 bis n - 1.

Es gibt zwei übliche Darstellungen, mit n Knoten und m Kanten:

  • Adjazenzliste: Speicher O(n + m). Ob es die Kante a–b gibt und alle Nachbarn von a findest du in O(Grad von a).
  • Adjazenzmatrix: Speicher O(n²). Ob es die Kante a–b gibt, siehst du in O(1), alle Nachbarn von a kosten O(n).

Die Adjazenzliste (List<List<Integer>>) passt für die meisten Graphen, weil sie nur so viel Platz braucht, wie es Kanten gibt. Die Matrix (boolean[][]) lohnt sich bei sehr dichten Graphen oder wenn du oft einzelne Kanten prüfst.

import java.util.*;
public class Main {    public static void main(String[] args) {        int n = 5;        int[][] edges = {{0, 1}, {0, 2}, {1, 3}, {1, 4}, {2, 4}};
        // adjacency list: one list of neighbours per node        List<List<Integer>> list = new ArrayList<>();        for (int i = 0; i < n; i++) {            list.add(new ArrayList<>());        }        // adjacency matrix: matrix[a][b] is true if there is an edge        boolean[][] matrix = new boolean[n][n];
        for (int[] e : edges) {            list.get(e[0]).add(e[1]);            list.get(e[1]).add(e[0]);            matrix[e[0]][e[1]] = true;            matrix[e[1]][e[0]] = true;        }
        System.out.println("neighbours of 1: " + list.get(1));        System.out.println("edge 2-4? " + matrix[2][4]);        System.out.println("edge 3-4? " + matrix[3][4]);    }}

Breitensuche und Tiefensuche

Beide besuchen alle Knoten, die vom Start aus erreichbar sind, jeden genau einmal. Ein boolean-Array merkt sich, wer schon besucht ist, sonst laufen sie in Kreisen.

  • Die Breitensuche (BFS) arbeitet mit einer Queue: erst alle Nachbarn, dann deren Nachbarn, Schicht für Schicht. Darum findet sie in einem ungewichteten Graphen den kürzesten Weg: Ein Knoten in Schicht k ist genau k Kanten vom Start entfernt.
  • Die Tiefensuche (DFS) geht erst so tief wie möglich und kehrt dann um. Sie lässt sich kurz rekursiv schreiben (der Aufrufstapel ersetzt den Stack) und eignet sich, um Komponenten zu finden oder einen Graphen vollständig zu durchlaufen.

Beide kosten mit einer Adjazenzliste O(n + m): Jeder Knoten kommt einmal dran, jede Kante wird von beiden Enden aus einmal angeschaut.

import java.util.*;
public class Main {    static List<List<Integer>> graph = List.of(            List.of(1, 2), List.of(0, 3, 4), List.of(0, 4), List.of(1), List.of(1, 2));
    static List<Integer> bfs(int start) {        List<Integer> order = new ArrayList<>();        boolean[] visited = new boolean[graph.size()];        Deque<Integer> queue = new ArrayDeque<>();        visited[start] = true;        queue.add(start);        while (!queue.isEmpty()) {            int v = queue.poll();            order.add(v);            for (int w : graph.get(v)) {                if (!visited[w]) {                    visited[w] = true; // mark when adding, not when taking out                    queue.add(w);                }            }        }        return order;    }
    static void dfs(int v, boolean[] visited, List<Integer> order) {        visited[v] = true;        order.add(v);        for (int w : graph.get(v)) {            if (!visited[w]) {                dfs(w, visited, order);            }        }    }
    public static void main(String[] args) {        System.out.println("BFS: " + bfs(0));        List<Integer> order = new ArrayList<>();        dfs(0, new boolean[graph.size()], order);        System.out.println("DFS: " + order);    }}

Kürzeste Wege mit Dijkstra

Haben Kanten verschiedene Längen, reicht die Breitensuche nicht: Der Weg mit den wenigsten Kanten ist nicht unbedingt der kürzeste. Der Algorithmus von Dijkstra löst das für Graphen ohne negative Gewichte:

  1. dist[start] = 0, alle anderen „unendlich“.
  2. Nimm den noch nicht fertigen Knoten mit dem kleinsten dist. Sein Abstand steht jetzt fest.
  3. Prüfe seine Nachbarn: Ist der Weg über ihn kürzer, trage den neuen Abstand ein (Relaxieren).
  4. Wiederhole, bis alle erreichbaren Knoten fertig sind.

Den Knoten mit dem kleinsten Abstand liefert eine PriorityQueue (ein Heap aus Kapitel 7). So kostet Dijkstra O((n + m) · log n).

import java.util.*;
public class Main {    public static void main(String[] args) {        // neighbours as {node, length}        List<List<int[]>> graph = List.of(                List.of(new int[]{1, 4}, new int[]{2, 1}),                List.of(new int[]{0, 4}, new int[]{2, 2}, new int[]{3, 1}),                List.of(new int[]{0, 1}, new int[]{1, 2}, new int[]{3, 5}),                List.of(new int[]{1, 1}, new int[]{2, 5}));
        int[] dist = new int[graph.size()];        Arrays.fill(dist, Integer.MAX_VALUE);        dist[0] = 0;        PriorityQueue<int[]> queue = new PriorityQueue<>((x, y) -> Integer.compare(x[1], y[1]));        queue.add(new int[]{0, 0});
        while (!queue.isEmpty()) {            int[] current = queue.poll();            int v = current[0];            if (current[1] > dist[v]) {                continue; // outdated entry            }            System.out.println("done: " + v + " at " + dist[v]);            for (int[] edge : graph.get(v)) {                int candidate = dist[v] + edge[1];                if (candidate < dist[edge[0]]) {                    dist[edge[0]] = candidate;                    queue.add(new int[]{edge[0], candidate});                }            }        }        System.out.println(Arrays.toString(dist));    }}

Knoten 1 bekommt zuerst den Abstand 4 über die direkte Kante. Über Knoten 2 sind es nur 1 + 2 = 3. Der alte Eintrag {1, 4} liegt danach noch in der Queue und wird übersprungen.

Typowe błędy

  • Schlüssel mit == verglichen

    In einer eigenen Hashtabelle prüft entry.key == key, ob es dasselbe Objekt ist, nicht ob die Strings gleich sind. Ein Schlüssel, der zur Laufzeit gebaut wurde, wird dann nicht gefunden. Vergleiche mit entry.key.equals(key). Aus demselben Grund müssen eigene Schlüsselklassen equals und hashCode gemeinsam überschreiben.

  • Zu spät als besucht markiert

    Markierst du einen Knoten in der Breitensuche erst, wenn du ihn aus der Queue nimmst, kann ihn ein anderer Nachbar vorher noch einmal hineinstellen. Er wird dann doppelt besucht. Markiere ihn deshalb beim Hineinstellen:

    if (!visited[w]) {    visited[w] = true;    queue.add(w);}
  • Falscher Algorithmus für den kürzesten Weg

    Die Breitensuche zählt Kanten. In einem gewichteten Graphen liefert sie deshalb falsche Abstände, dort brauchst du Dijkstra. Dijkstra wiederum setzt voraus, dass kein Gewicht negativ ist: Ein Abstand, der einmal feststeht, darf später nicht mehr kleiner werden.