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:
dist[start] = 0, wszystkie inne „nieskończoność”.- Weź jeszcze nieukończony wierzchołek z najmniejszym
dist. Jego odległość jest teraz ostateczna. - Sprawdź jego sąsiadów: jeśli droga przez niego jest krótsza, wpisz nową odległość (relaksacja).
- 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 == keysprawdza, 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 przezentry.key.equals(key). Z tego samego powodu własne klasy kluczy muszą nadpisywaćequalsihashCoderazem.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.
- Wartość hasza dla napisuPro
- Liczenie kolizjiPro
- Budowanie listy sąsiedztwaPro
- Tablica haszująca z łańcuchowaniemPro
- Przeszukiwanie wszerzPro
- Przeszukiwanie w głąbPro
- Co to wypisze? Przeszukiwanie wszerzPro
- Znajdź błąd: oznaczanie odwiedzonychPro
- Pseudokod: przeszukiwanie wszerzPro
- Liczenie spójnych składowychPro
- Najkrótsze ścieżki algorytmem DijkstryPro
Powiązane artykuły
- Drzewa i kopceDrzewo binarne, drzewo BST, kopiec i sortowanie przez kopcowanie w Javie: przechodzenie drzewa, przykłady i typowe błędy na kolokwium.Czytaj artykuł
- Listy, stos i kolejkaZbuduj sam stos, kolejkę i listę jednokierunkową w Javie: przykłady z tablicą, buforem cyklicznym i węzłami, złożoność oraz typowe błędy.Czytaj artykuł
- Listy i mapyListy i mapy w Javie w prosty sposób: ArrayList, HashSet i HashMap z przykładami. Do tego typowe błędy, np. List<int> i remove na liczbach.Czytaj artykuł