Tablica mieszająca, BFS, DFS i Dijkstra w Javie

Zaktualizowano 22 października 2026

Tablica mieszająca (hash table) znajduje w Javie element średnio w O(1). W liście szukasz go w O(n), w posortowanej tablicy z wyszukiwaniem binarnym w O(log n). Potem w tym artykule zajmiemy się grafami: wierzchołkami i krawędziami, którymi opisujesz sieci dróg, znajomości albo sieci komputerowe, oraz algorytmami, które znajdują w nich drogi.

Idea haszowania

Tablica mieszająca to tablica kubełków (buckets). Funkcja mieszająca (funkcja skrótu) zamienia klucz na liczbę, a liczba % rozmiar wyznacza kubełek. Przy wyszukiwaniu liczysz tę samą funkcję jeszcze raz i zaglądasz tylko do tego jednego kubełka, zamiast przechodzić wszystkie elementy.

Prosta funkcja mieszająca dla napisów:

h = 0dla każdego znaku c:  h = (h * 31 + c) % size

Dobra funkcja mieszająca rozkłada klucze równomiernie i dla równych kluczy zawsze daje ten sam wynik. Dokładnie tak działa HashMap z kolekcji Javy: wywołuje hashCode() klucza, a w kubełku porównuje przez equals. Dlatego własne klasy używane jako klucze muszą odpowiednio nadpisać obie metody.

Kolizje i współczynnik wypełnienia

Jeśli dwa różne klucze trafią do tego samego kubełka, jest to kolizja. Kolizji nie da się uniknąć: możliwych napisów jest dużo więcej niż kubełków. Typowym rozwiązaniem jest metoda łańcuchowa (separate chaining): każdy kubełek jest listą, a kolidujące klucze stoją w niej jeden za drugim.

To, jak pełna jest tablica, mierzy współczynnik wypełnienia α = liczba elementów / liczba kubełków. Przy równomiernym rozkładzie w każdym kubełku leży średnio α elementów. Wyszukiwanie, wstawianie i usuwanie kosztują wtedy średnio O(1 + α), czyli O(1), dopóki α pozostaje małe. Dlatego HashMap automatycznie powiększa swoją tablicę, gdy tylko α przekroczy 0,75. W najgorszym przypadku, gdy wszystkie klucze trafią do tego samego kubełka, robi się z tego 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 i Eva kolidują w kubełku 1. Przy 5 elementach w 7 kubełkach współczynnik wypełnienia wynosi około 0,71.

Przechowywanie grafów

Graf składa się z wierzchołków i krawędzi. W grafie nieskierowanym krawędź działa w obie strony, w skierowanym tylko w jedną. Jeśli krawędzie mają długość albo koszt, graf nazywa się ważonym. Wierzchołki najłatwiej ponumerować od 0 do n - 1.

Są dwie typowe reprezentacje, przy n wierzchołkach i m krawędziach:

  • Lista sąsiedztwa: pamięć O(n + m). To, czy istnieje krawędź a–b, i wszystkich sąsiadów a znajdziesz w O(stopień a).
  • Macierz sąsiedztwa: pamięć O(n²). To, czy istnieje krawędź a–b, widzisz w O(1), wszyscy sąsiedzi a kosztują O(n).

Lista sąsiedztwa (List<List<Integer>>) pasuje do większości grafów, bo potrzebuje tylko tyle miejsca, ile jest krawędzi. Macierz (boolean[][]) opłaca się przy bardzo gęstych grafach albo gdy często sprawdzasz pojedyncze krawędzie.

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]);    }}

Przeszukiwanie wszerz i w głąb

Oba algorytmy odwiedzają wszystkie wierzchołki osiągalne ze startu, każdy dokładnie raz. Tablica typu boolean zapamiętuje, kto już był odwiedzony, bo inaczej chodziłyby w kółko.

  • Przeszukiwanie wszerz (BFS) działa z kolejką: najpierw wszyscy sąsiedzi, potem ich sąsiedzi, warstwa po warstwie. Dlatego w grafie nieważonym znajduje najkrótszą drogę: wierzchołek w warstwie k jest dokładnie k krawędzi od startu.
  • Przeszukiwanie w głąb (DFS) idzie najpierw tak głęboko, jak się da, a potem zawraca. Da się je krótko zapisać rekurencyjnie (stos wywołań zastępuje stos) i nadaje się do znajdowania spójnych składowych albo przejścia całego grafu.

Z listą sąsiedztwa oba kosztują O(n + m): każdy wierzchołek jest przetwarzany raz, a każda krawędź oglądana raz z każdego końca.

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);    }}

Najkrótsze drogi algorytmem Dijkstry

Jeśli krawędzie mają różne długości, przeszukiwanie wszerz nie wystarcza: droga z najmniejszą liczbą krawędzi nie musi być najkrótsza. Algorytm Dijkstry rozwiązuje to dla grafów bez ujemnych wag:

  1. dist[start] = 0, wszystkie inne „nieskończoność”.
  2. Weź jeszcze nieukończony wierzchołek z najmniejszym dist. Jego odległość jest teraz ostateczna.
  3. Sprawdź jego sąsiadów: jeśli droga przez niego jest krótsza, wpisz nową odległość (relaksacja).
  4. Powtarzaj, aż wszystkie osiągalne wierzchołki będą ukończone.

Wierzchołek z najmniejszą odległością podaje PriorityQueue, czyli kopiec. Dzięki temu Dijkstra kosztuje 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));    }}

Wierzchołek 1 dostaje najpierw odległość 4 przez bezpośrednią krawędź. Przez wierzchołek 2 to tylko 1 + 2 = 3. Stary wpis {1, 4} leży potem nadal w kolejce i zostaje pominięty.

Typowe błędy

  • Klucze porównane przez ==

    We własnej tablicy mieszającej entry.key == key sprawdza, czy to ten sam obiekt, a nie czy napisy są równe. Klucz zbudowany w czasie działania programu nie zostanie wtedy znaleziony. Porównuj przez entry.key.equals(key). Z tego samego powodu własne klasy kluczy muszą nadpisywać equals i hashCode razem.

  • Za późno oznaczony jako odwiedzony

    Jeśli w przeszukiwaniu wszerz oznaczasz wierzchołek dopiero wtedy, gdy wyjmujesz go z kolejki, inny sąsiad może go wcześniej wstawić jeszcze raz. Zostanie wtedy odwiedzony dwa razy. Dlatego oznaczaj go przy wstawianiu:

    if (!visited[w]) {    visited[w] = true;    queue.add(w);}
  • Zły algorytm dla najkrótszej drogi

    Przeszukiwanie wszerz liczy krawędzie. Dlatego w grafie ważonym daje złe odległości, tam potrzebujesz Dijkstry. Dijkstra z kolei wymaga, żeby żadna waga nie była ujemna: odległość, która raz została ustalona, nie może później zmaleć.

Zadania z tego tematu

Przećwicz to, czego się nauczyłeś, od razu w przeglądarce. Testy natychmiast sprawdzają twoje rozwiązanie.

Powiązane artykuły