← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202630 phút đọc26 min read

Thuật toán đường đi ngắn nhấtShortest Path Algorithms

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Primitive relaxation
  4. Distance, parent, và dựng lại path
  5. Vì sao shortest path là simple path (và khi nào thì không)
  6. Khái niệm chính
  7. BFS — shortest path trên graph unweighted, O(V + E)
  8. Thuật toán Dijkstra — trọng số không âm, O((V + E) log V)
  9. Vì sao Dijkstra hỏng với edge âm
  10. 0-1 BFS — trọng số trong {0, 1}, O(V + E)
  11. Shortest path trên DAG — trọng số bất kỳ, O(V + E)
  12. Bellman-Ford — trọng số bất kỳ, O(V · E), phát hiện negative cycle
  13. Floyd-Warshall — mọi cặp, O(V³)
  14. A* — tìm kiếm có định hướng cho một cặp
  15. Dùng thuật toán nào khi nào
  16. Best Practices
  17. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. The relaxation primitive
  4. Distances, parents, and reconstructing the path
  5. Why shortest paths are simple paths (and when they are not)
  6. Key Concepts
  7. BFS — shortest paths on unweighted graphs, O(V + E)
  8. Dijkstra’s algorithm — non-negative weights, O((V + E) log V)
  9. Why Dijkstra breaks on negative edges
  10. 0-1 BFS — weights in {0, 1}, O(V + E)
  11. DAG shortest paths — any weights, O(V + E)
  12. Bellman-Ford — any weights, O(V · E), detects negative cycles
  13. Floyd-Warshall — all pairs, O(V³)
  14. A* — informed single-pair search
  15. Which algorithm to use when
  16. Best Practices
  17. References

Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.

Tổng quan

“Đi từ A tới B rẻ nhất bằng cách nào?” là câu hỏi mà các thuật toán graph được thuê để trả lời nhiều nhất. Chỉ đường, định tuyến mạng (OSPF theo đúng nghĩa đen là Dijkstra chạy trên mọi router), phát hiện arbitrage tiền tệ, tìm đường cho AI trong game, giải dependency có chi phí, minimum edit distance dưới dạng path trên lưới, và cả họ bài toán “số nước đi tối thiểu” — tất cả đều là bài toán shortest path khoác áo khác nhau.

Không có một thuật toán shortest path duy nhất, và lý do là bài toán này có nhiều biến thể đòi hỏi những kỹ thuật thật sự khác nhau:

Thứ quyết định bạn dùng thuật toán nào gần như hoàn toàn là trọng số edge: unweighted, trọng số trong {0, 1}, số nguyên nhỏ, số thực không âm bất kỳ, hay có thể âm. Mỗi ràng buộc mua cho bạn một thuật toán nhanh hơn; mỗi lần nới lỏng bắt bạn trả giá bằng một thuật toán chậm hơn. Toàn bộ note này là một chuyến đi xuống cái thang đó.

Có một sự thật cấu trúc nằm dưới tất cả: shortest path có tính optimal substructure. Nếu shortest path từ s tới t đi qua x, thì đoạn từ s tới x tự nó cũng là shortest path s→x. Nếu không, bạn có thể thay đoạn đầu bằng đoạn tốt hơn và cải thiện cả path — mâu thuẫn. Đây chính là thứ khiến các công thức greedy và dynamic programming bên dưới hợp lệ, và cũng chính là tính chất chống đỡ cho dynamic programming nói chung.

Mọi thứ ở đây giả định bạn đã đọc ./13-graph-data-structures.md — các cách biểu diễn, BFS, và DFS.

Kiến thức nền tảng

Primitive relaxation

Mọi thuật toán trong note này đều được xây từ một thao tác duy nhất. Duy trì một mảng dist[] với dist[v] là chi phí tốt nhất tới v tìm được cho tới lúc này (một cận trên của khoảng cách ngắn nhất thực sự, khởi tạo là cho mọi vertex trừ source). Relax một edge (u, v, w) là hỏi xem đi qua u có cải thiện ước lượng cho v không:

def relax(dist, parent, u, v, w):
    """Primitive duy nhất đứng sau BFS, Dijkstra, Bellman-Ford và Floyd-Warshall.
    Trả về True nếu ước lượng cho v được cải thiện."""
    if dist[u] + w < dist[v]:
        dist[v] = dist[u] + w
        parent[v] = u
        return True
    return False

Mảng dist chỉ bao giờ giảm xuống, và nó luôn là độ dài của một path có thật (không bao giờ là ước lượng thấp hơn thực tế), nên khi nó đã bằng khoảng cách ngắn nhất thật thì nó giữ nguyên đúng. Các thuật toán chỉ khác nhau ở thứ tự relax các edgecách chúng biết khi nào dừng:

Thuật toánThứ tự relaxĐiều kiện dừng
BFStheo số hopqueue rỗng
Dijkstratheo dist[u] tăng dầnmọi vertex đã được lấy ra một lần
Bellman-Fordmọi edge, lặp đi lặp lạiV−1 vòng, hoặc không còn thay đổi
Floyd-Warshalltheo vertex trung gian k được phépđã dùng hết mọi k
Relax trên DAGtheo topological ordermột lượt duy nhất

Distance, parent, và dựng lại path

Mọi hiện thực bên dưới đều trả về dist parent. Giữ parent[v] — vertex mà bạn đến từ đó trên path tốt nhất tìm được — chỉ tốn một mảng và một phép gán mỗi lần relax thành công, và đó là cách duy nhất để xuất ra lộ trình thật chứ không chỉ độ dài của nó. Mảng parent tạo thành một shortest-path tree gốc tại source: V−1 edge, mỗi edge cho một vertex khác source tới được.

def reconstruct_path(parent, source, target):
    """Lần theo parent pointer từ target về source. O(độ dài path)."""
    if target != source and parent[target] == -1:
        return []                     # không tới được
    path = []
    cur = target
    while cur != -1:
        path.append(cur)
        cur = parent[cur]
    path.reverse()
    return path

Vì sao shortest path là simple path (và khi nào thì không)

Nếu mọi cycle đều có trọng số không âm, thì luôn tồn tại một shortest path giữa hai vertex bất kỳ là simple — không lặp lại vertex nào — bởi đi vòng quanh một cycle không bao giờ giúp ích. Điều đó chặn độ dài path ở V−1 edge, và đó chính xác là số vòng mà Bellman-Ford cần.

Nếu có một negative-weight cycle tới được từ source và tới được đích, thì “shortest path” không phải là khó tính — nó không tồn tại. Bạn có thể đi vòng thêm một lần nữa và kéo chi phí xuống vô hạn. Do đó, mọi thuật toán đúng đắn đều phải hoặc cấm negative cycle bằng giả thiết, hoặc phát hiện ra và báo lại. Đây chính là đường đứt gãy chia tách Dijkstra khỏi Bellman-Ford.

Khái niệm chính

BFS — shortest path trên graph unweighted, O(V + E)

Trên graph unweighted (hoặc graph mà mọi edge có cùng trọng số), shortest path là path ít edge nhất, và BFS thuần tuý tính ra nó. Cấu trúc theo lớp của BFS chính là hàm khoảng cách: mọi thứ ở độ sâu BFS k cách source đúng k.

from collections import deque


def bfs_shortest_paths(adj, source):
    """Single-source shortest path không trọng số. Thời gian O(V + E), bộ nhớ O(V).

    Không gì thắng được cách này trên graph unweighted: Dijkstra sẽ làm đúng
    từng đó việc cộng thêm O(log V) overhead heap cho mỗi edge mà chẳng lợi gì."""
    n = len(adj)
    dist = [-1] * n
    parent = [-1] * n
    dist[source] = 0
    q = deque([source])

    while q:
        u = q.popleft()
        for v in adj[u]:
            if dist[v] == -1:
                dist[v] = dist[u] + 1
                parent[v] = u
                q.append(v)

    return dist, parent

Vì sao nó đúng: queue luôn được sắp theo khoảng cách, và tại mỗi thời điểm chỉ chứa tối đa hai giá trị khoảng cách khác nhau (kk+1). Nên lần đầu tiên một vertex được phát hiện, nó được phát hiện từ một vertex ở khoảng cách nhỏ nhất có thể. Nói chặt chẽ: BFS chính là Dijkstra khi mọi key là số nguyên tăng đúng 1 đơn vị, nên một FIFO queue đóng vai priority queue miễn phí.

Quy tắc thực dụng: nếu mọi trọng số bằng nhau, dùng BFS. Vớ lấy Dijkstra trên graph unweighted là lãng phí một hệ số log V và một cái heap bạn không cần.

Thuật toán Dijkstra — trọng số không âm, O((V + E) log V)

Dijkstra là con ngựa thồ. Nó tổng quát hoá BFS bằng cách thay FIFO queue bằng một priority queue khoá theo khoảng cách tạm thời, nên frontier mở rộng theo chi phí thay vì theo số hop.

Bất biến: khi một vertex được lấy ra khỏi priority queue với key nhỏ nhất, khoảng cách của nó là chung cuộc. Lập luận là một phép đổi chỗ greedy: giả sử ta lấy ra u với key d, và giả sử tồn tại một path rẻ hơn tới u. Path đó tại một điểm nào đó phải vượt từ tập vertex đã chốt sang tập chưa chốt, qua một edge (x, y) nào đó. Nhưng khi đó dist[y] ≤ d, nên y (chứ không phải u) đã được lấy ra trước, và vì mọi trọng số ≥ 0, phần còn lại của path từ y tới u không thể làm giảm tổng. Mâu thuẫn.

Mọi bước của lập luận đó đều phụ thuộc vào việc trọng số không âm.

import heapq


def dijkstra(adj, source):
    """Single-source shortest path với trọng số không âm.

    adj[u] là danh sách các cặp (v, w) với w >= 0.
    Thời gian O((V + E) log V), bộ nhớ O(V + E).
    """
    n = len(adj)
    INF = float("inf")
    dist = [INF] * n
    parent = [-1] * n
    dist[source] = 0

    pq = [(0, source)]                   # mỗi phần tử là (khoảng cách tạm thời, vertex)

    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:
            continue                     # phần tử cũ: ta đã tìm được u rẻ hơn rồi
        for v, w in adj[u]:
            nd = d + w
            if nd < dist[v]:             # relax edge (u, v, w)
                dist[v] = nd
                parent[v] = u
                heapq.heappush(pq, (nd, v))   # chèn "lazy" thay cho decrease-key

    return dist, parent

Vì sao là O((V + E) log V). Mỗi edge gây ra tối đa một lần push, nên heap chứa O(E) phần tử; mỗi lần push và pop tốn O(log E) = O(log V) (vì E ≤ V² nên log E ≤ 2 log V). Vertex đóng góp V lần pop. Tổng: O((V + E) log V).

Dòng if d > dist[u]: continue là dòng chịu lực. heapq của Python không có thao tác decrease-key, nên cách làm chuẩn là push một phần tử mới mỗi khi khoảng cách được cải thiện và để phần tử cũ lớn hơn nằm lại trong heap như rác. Phép kiểm tra phần tử cũ đó chính là thứ đảm bảo adjacency list của mỗi vertex chỉ bị quét đúng một lần. Xoá dòng này thì thuật toán vẫn trả về khoảng cách đúng nhưng có thể quét lại adjacency list nhiều lần.

Priority queueExtract-minDecrease-keyTổng
Mảng / quét tuyến tínhO(V)O(1)O(V² + E) — tốt nhất cho graph dense
Binary heap (heapq)O(log V)O(log V) (hoặc lazy push)O((V + E) log V) — lựa chọn mặc định
Fibonacci heapO(log V) amortizedO(1) amortizedO(E + V log V) — tốt hơn về lý thuyết, hiếm khi đáng

Bản O(V²) dùng mảng thật sự thắng trên graph dense: khi E ≈ V², O(V²) tốt hơn O(V² log V). Ưu thế của Fibonacci heap chỉ mang tính tiệm cận — hằng số của nó tệ đến mức binary heap thường thắng trên input thực tế ở mọi kích thước hợp lý.

Thoát sớm cho truy vấn single-pair: nếu bạn chỉ cần dist[target], hãy break ngay khi pop được target. Theo bất biến, khoảng cách của nó đã chung cuộc tại thời điểm đó. Đây là khoản tiết kiệm lớn trong thực tế và không tốn gì.

Vì sao Dijkstra hỏng với edge âm

Chỗ này đáng ngồi làm cho ra chứ đừng học thuộc, bởi “cứ cộng một hằng số lớn vào mọi trọng số” là cách sửa rất hấp dẫn và hoàn toàn sai.

Lấy graph sau:

            2
     S ----------> A
     |             ^
     | 3           | -2
     v             |
     B ------------+

Khoảng cách ngắn nhất thật là dist(A) = 1 (qua S→B→A, chi phí 3 + (−2) = 1) và dist(B) = 3.

Bây giờ chạy thử Dijkstra sách giáo khoa — bản duy trì một tập done và không bao giờ quay lại vertex đã chốt:

  1. Pop S (key 0). Relax: dist[A] = 2, dist[B] = 3.
  2. Pop A (key 2 — nhỏ nhất). Chốt A ở giá trị 2. A không có edge đi ra.
  3. Pop B (key 3). Relax B→A: 3 + (−2) = 1 < 2 — nhưng A đã chốt, nên cải thiện bị bỏ qua.
  4. Kết quả: dist[A] = 2. Sai. Đáp án là 1.

Bất biến greedy đã giả định rằng không vertex nào đến sau có thể đưa ra tuyến rẻ hơn, và một edge âm khiến đúng điều đó xảy ra được.

Bản lazy-heap ở trên tình cờ sống sót qua ví dụ ba vertex này — nó không có tập done, nên sẽ push lại A với key 1 và sửa được đáp án. Đó không phải sự cứu rỗi mà là một cái bẫy: trên graph được dựng ác ý, bản lazy pop lại vertex số lần luỹ thừa (cấu trúc kinh điển của Johnson nối k gadget để ép ra 2^k lần pop), và trên graph có negative cycle thì nó không bao giờ dừng. Đừng chạy Dijkstra trên graph có edge âm.

Và cũng đừng đổi trọng số bằng cách cộng hằng số. Giả sử S→T tốn 1, S→X tốn −2, và X→T tốn 2. Đáp án đúng: S→X→T = 0, rẻ hơn S→T = 1. Cộng 2 vào mọi trọng số để khử số âm: giờ S→T = 3 còn S→X→T = 0 + 4 = 4. Edge trực tiếp thắng. Một phép dịch hằng số phạt các path tỉ lệ với số edge chúng dùng, nên nó thay đổi path nào là ngắn nhất. Phép đổi trọng số đúng đắn là thuật toán Johnson, dùng Bellman-Ford từ một super-source ảo để tính một potential h(v) cho mỗi vertex và đặt w'(u,v) = w(u,v) + h(u) − h(v). Biến đổi này vừa không âm vừa triệt tiêu theo kiểu telescoping dọc mọi path, nên chi phí tương đối giữa các path được bảo toàn.

0-1 BFS — trọng số trong {0, 1}, O(V + E)

Khi mọi trọng số edge là 0 hoặc 1, bạn có thể bỏ hẳn heap. Dùng deque: edge trọng số 0 giữ bạn ở cùng “lớp” nên push vào đầu; edge trọng số 1 đẩy bạn sang lớp kế nên push vào cuối. Deque luôn được sắp theo khoảng cách với tối đa hai giá trị khác nhau, đúng như queue của BFS.

from collections import deque


def zero_one_bfs(adj, source):
    """Shortest path khi mọi trọng số là 0 hoặc 1. Thời gian O(V + E), bộ nhớ O(V).

    adj[u] là danh sách các cặp (v, w) với w thuộc {0, 1}.
    """
    n = len(adj)
    INF = float("inf")
    dist = [INF] * n
    parent = [-1] * n
    dist[source] = 0
    dq = deque([source])

    while dq:
        u = dq.popleft()
        for v, w in adj[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                parent[v] = u
                if w == 0:
                    dq.appendleft(v)     # cùng lớp -> phải xử lý trước
                else:
                    dq.append(v)         # lớp kế tiếp -> xếp sau tất cả

    return dist, parent

Cách này biến một bài O(E log V) thành O(V + E), và điều đó quan trọng ở quy mô lớn. Ứng dụng kinh điển là các bài grid có hai loại nước đi: “đi qua ô trống thì miễn phí, phá tường thì tốn 1 — cần phá ít nhất bao nhiêu tường?”. Mô hình nước đi miễn phí thành trọng số 0 và nước đi tốn kém thành trọng số 1, rồi 0-1 BFS trả lời trong một lượt tuyến tính duy nhất.

Bản tổng quát cho trọng số nguyên nhỏ 0..Cthuật toán Dial: giữ V·C + 1 bucket đánh chỉ số theo khoảng cách và quét chúng theo thứ tự, cho O(E + V·C). Đáng dùng khi C rất nhỏ; ngoài ra dùng heap đơn giản hơn.

Shortest path trên DAG — trọng số bất kỳ, O(V + E)

Nếu graph là directed acyclic graph, bạn còn làm tốt hơn Dijkstra kể cả khi có trọng số âm: xử lý vertex theo topological order và relax các edge đi ra của mỗi vertex đúng một lần. Khi tới u, mọi path đi vào u đều đã được xét, nên dist[u] là chung cuộc.

def dag_shortest_paths(adj, source, topo_order):
    """Shortest path trên DAG. Trọng số có thể âm; DAG không có cycle nên
    negative cycle là không thể. Thời gian O(V + E), bộ nhớ O(V).

    `topo_order` lấy từ topological sort - xem 13-graph-data-structures.md.
    """
    n = len(adj)
    INF = float("inf")
    dist = [INF] * n
    parent = [-1] * n
    dist[source] = 0

    for u in topo_order:
        if dist[u] == INF:
            continue                     # u không tới được từ source
        for v, w in adj[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                parent[v] = u

    return dist, parent

Đổi dấu trọng số thì bạn có longest path trên DAG, chính là critical path của một lịch dự án hay một build graph — bài toán NP-hard trên graph tổng quát nhưng tuyến tính ở đây. Đây là thuật toán shortest path bị dùng thiếu nhiều nhất: nếu graph của bạn là DAG, hãy dùng nó.

Bellman-Ford — trọng số bất kỳ, O(V · E), phát hiện negative cycle

Bellman-Ford từ bỏ tính tham lam. Nó relax mọi edge, V−1 lần. Sau vòng thứ i, mọi dist[v] tới được bằng path có nhiều nhất i edge đều đã đúng; vì shortest simple path có nhiều nhất V−1 edge, V−1 vòng là đủ.

def bellman_ford(n, edges, source):
    """Single-source shortest path với trọng số edge bất kỳ.

    `edges` là danh sách bộ ba (u, v, w) - biểu diễn edge list.
    Thời gian O(V * E), bộ nhớ O(V). Ném ValueError khi có negative cycle tới được.
    """
    INF = float("inf")
    dist = [INF] * n
    parent = [-1] * n
    dist[source] = 0

    for _ in range(n - 1):
        changed = False
        for u, v, w in edges:
            if dist[u] != INF and dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                parent[v] = u
                changed = True
        if not changed:
            break                        # một lượt đầy đủ không đổi gì: đã xong

    # Thêm một lượt nữa. Nếu vẫn còn cải thiện được thì không có đáp án hữu hạn.
    for u, v, w in edges:
        if dist[u] != INF and dist[u] + w < dist[v]:
            raise ValueError("graph contains a reachable negative-weight cycle")

    return dist, parent

Vì sao là O(V · E). V−1 vòng nhân với E phép relax mỗi vòng. Việc thoát sớm (“một lượt đầy đủ không đổi gì”) thường khiến nó nhanh hơn nhiều trong thực tế, nhưng trường hợp xấu nhất là có thật: một path graph với edge được cấp theo đúng thứ tự tệ nhất cần đủ V−1 vòng.

Phát hiện negative cycle chính là lượt phụ đó. Sau V−1 vòng, mọi khoảng cách đều chung cuộc nếu không có negative cycle. Vậy nếu vòng thứ V vẫn cải thiện được thứ gì đó thì có một negative cycle tới được. Đây không phải lợi ích phụ — đó là lý do Bellman-Ford được dùng để phát hiện arbitrage tiền tệ (một negative cycle trong không gian −log(tỷ giá) là một vòng lợi nhuận không rủi ro) và trong các giao thức định tuyến distance-vector như RIP.

Để lấy ra chính cycle đó chứ không chỉ biết nó tồn tại, hãy ghi lại vertex nào được relax ở vòng cuối và lần ngược V bước parent để chắc chắn bạn đã rơi vào bên trong cycle:

def find_negative_cycle(n, edges):
    """Trả về một negative cycle dưới dạng danh sách vertex, hoặc None.

    dist khởi tạo bằng 0 ở mọi nơi, tương đương một super-source ảo có edge
    trọng số 0 tới mọi vertex - nhờ vậy tìm được negative cycle ở bất kỳ đâu
    trong graph, không chỉ những cycle tới được từ một source đã chọn.
    Thời gian O(V * E).
    """
    dist = [0] * n
    parent = [-1] * n
    x = -1

    for _ in range(n):
        x = -1
        for u, v, w in edges:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                parent[v] = u
                x = v                    # nhớ vertex cuối cùng được cải thiện
        if x == -1:
            return None                  # một lượt không đổi gì: không có negative cycle

    # x nằm trên hoặc ở hạ lưu một negative cycle. Lần ngược n liên kết parent
    # đảm bảo ta rơi vào một vertex thực sự nằm trong cycle.
    for _ in range(n):
        x = parent[x]

    cycle = [x]
    cur = parent[x]
    while cur != x:
        cycle.append(cur)
        cur = parent[cur]
    cycle.append(x)
    cycle.reverse()
    return cycle

SPFA (Shortest Path Faster Algorithm) là Bellman-Ford với một queue chứa “các vertex vừa có khoảng cách thay đổi”, nên bạn chỉ relax lại các edge đi ra từ những vertex có khả năng tạo cải thiện. Nó thường nhanh hơn hẳn trên graph thực tế, nhưng trường hợp xấu nhất vẫn là O(V·E) và input phá nó rất dễ dựng — trong giới competitive programming, SPFA đã bị cố ý đánh sập nhiều năm nay. Hãy dùng nó với nhận thức rằng đó là tối ưu hệ số hằng, không phải cải thiện độ phức tạp.

Floyd-Warshall — mọi cặp, O(V³)

Floyd-Warshall tính khoảng cách ngắn nhất giữa mọi cặp vertex bằng ba vòng lặp lồng nhau và không cần cấu trúc dữ liệu nào. Đó là một bài dynamic programming sách giáo khoa: gọi d[k][i][j] là khoảng cách i→j ngắn nhất chỉ dùng các vertex 0..k−1 làm trung gian. Khi đó

d[k][i][j] = min( d[k-1][i][j],                 # không đi qua k
                  d[k-1][i][k] + d[k-1][k][j] ) # đi qua k

và chiều k có thể bỏ đi tại chỗ, đó là lý do hiện thực thật chỉ dùng mảng 2 chiều.

def floyd_warshall(n, edges):
    """All-pairs shortest path. Thời gian O(V^3), bộ nhớ O(V^2).

    Xử lý được edge âm. Ném lỗi khi có negative cycle (nhận ra qua dist[v][v] < 0).
    `nxt[i][j]` là hop đầu tiên trên một shortest path i -> j, dùng để dựng lại path.
    """
    INF = float("inf")
    dist = [[INF] * n for _ in range(n)]
    nxt = [[-1] * n for _ in range(n)]

    for v in range(n):
        dist[v][v] = 0
        nxt[v][v] = v
    for u, v, w in edges:
        if w < dist[u][v]:               # giữ lại parallel edge rẻ nhất
            dist[u][v] = w
            nxt[u][v] = v

    for k in range(n):                   # k BẮT BUỘC là vòng lặp ngoài cùng
        for i in range(n):
            dik = dist[i][k]
            if dik == INF:
                continue                 # i không tới được k: bỏ qua cả hàng
            row_k = dist[k]
            row_i = dist[i]
            for j in range(n):
                if dik + row_k[j] < row_i[j]:
                    row_i[j] = dik + row_k[j]
                    nxt[i][j] = nxt[i][k]

    for v in range(n):
        if dist[v][v] < 0:
            raise ValueError("negative cycle through vertex %d" % v)

    return dist, nxt


def fw_path(nxt, i, j):
    """Dựng lại một shortest path từ bảng `nxt`. O(độ dài path)."""
    if nxt[i][j] == -1:
        return []
    path = [i]
    while i != j:
        i = nxt[i][j]
        path.append(i)
    return path

Vòng k bắt buộc phải nằm ngoài cùng. Đây là bug kinh điển của Floyd-Warshall: hoán đổi các vòng lặp thì công thức truy hồi không còn đảm bảo d[i][k]d[k][j] đã chung cuộc lúc bạn dùng chúng, và bạn nhận về những đáp án lớn hơn thực tế theo cách trông vẫn rất hợp lý.

Khi nào dùng: graph nhỏ và dense (V ≤ 400 trong Python, V ≤ 1000 trong C++), khi bạn cần mọi cặp, hoặc khi bạn muốn transitive closure (thay min/+ bằng or/and là bạn có thuật toán reachability của Warshall). Với graph sparse và V lớn, chạy Dijkstra từ mọi vertex tốn O(V(V + E) log V), thắng O(V³) một cách rõ ràng — và thuật toán Johnson làm y hệt nhưng cho phép edge âm, bằng cách đổi trọng số qua potential của Bellman-Ford trước.

A* — tìm kiếm có định hướng cho một cặp

Dijkstra mở rộng đều về mọi phía, điều đó lãng phí khi bạn đã biết đích nằm đâu đó. A* thêm một heuristic h(v): ước lượng chi phí còn lại từ v tới đích. Thay vì ưu tiên theo g(v) (chi phí đã đi), nó ưu tiên theo f(v) = g(v) + h(v) — ước lượng tổng chi phí của tuyến tốt nhất đi qua v. Hiệu ứng là kéo lệch việc tìm kiếm về phía đích.

import heapq


def a_star(neighbours, start, goal, h):
    """A* shortest path cho một cặp.

    neighbours(u) -> iterable các (v, w) với w >= 0
    h(u)          -> ước lượng chi phí còn lại từ u tới goal (phải admissible)

    Trả về (cost, path). Với h đồng nhất bằng 0 thì đây đúng là Dijkstra.
    """
    open_heap = [(h(start), 0, start)]   # (f = g + h, g, vertex)
    g_score = {start: 0}
    parent = {start: None}
    closed = set()

    while open_heap:
        f, g, u = heapq.heappop(open_heap)
        if u in closed:
            continue                     # phần tử cũ
        if u == goal:
            path = []
            node = goal
            while node is not None:
                path.append(node)
                node = parent[node]
            path.reverse()
            return g, path
        closed.add(u)

        for v, w in neighbours(u):
            ng = g + w
            if ng < g_score.get(v, float("inf")):
                g_score[v] = ng
                parent[v] = u
                heapq.heappush(open_heap, (ng + h(v), ng, v))

    return float("inf"), []

Hai tính chất của heuristic quyết định A* có đúng hay không:

Nếu heuristic của bạn admissible nhưng không consistent, tập closed có thể chốt một vertex quá sớm. Cách sửa là cho phép mở lại (xoá v khỏi closed khi tìm được g tốt hơn) — đúng, nhưng có thể chậm hơn nhiều.

Các heuristic chuẩn:

Bối cảnhHeuristicConsistent?
Grid, đi 4 hướng, chi phí bằng nhaukhoảng cách Manhattan abs(dr) + abs(dc)
Grid, đi 8 hướng (đường chéo tốn 1)Chebyshev max(abs(dr), abs(dc))
Mạng đường bộ có toạ độkhoảng cách đường chim bay ÷ tốc độ tối đa
15-puzzletổng khoảng cách Manhattan của các ô
Bất kỳh ≡ 0có — và A* suy biến thành Dijkstra

Đánh đổi ở đây rất rõ: h admissible càng lớn thì cắt tỉa càng nhiều. h ≡ 0 là Dijkstra (đúng, chậm). h bằng đúng khoảng cách còn lại sẽ đi thẳng tới đích (đúng, nhưng tính được nó chính là bài toán ban đầu). Heuristic thực tế nằm ở giữa. Nếu bạn cố tình ước lượng vượt — “weighted A*”, dùng ε · h với ε > 1 — bạn có một cuộc tìm kiếm nhanh hơn nhiều và trả về path tệ hơn tối ưu tối đa ε lần, thường là đánh đổi kỹ thuật đúng đắn cho một game hay một UI.

A* không cải thiện độ phức tạp xấu nhất: với heuristic vô dụng, nó đúng bằng Dijkstra, O((V + E) log V). Giá trị của nó nằm hoàn toàn ở hệ số hằng, và trên một graph đường bộ cỡ lục địa với heuristic tốt, hệ số hằng đó là hàng bậc độ lớn.

Một kỹ thuật cắt tỉa liên quan cho trường hợp single-pair là bidirectional search: chạy một tìm kiếm xuôi từ s và một tìm kiếm ngược từ t, dừng khi chúng gặp nhau. Nếu phép tìm kiếm duyệt một quả cầu bán kính d, thì hai quả cầu bán kính d/2 nhỏ hơn theo cấp luỹ thừa trên graph có hệ số phân nhánh b2·b^(d/2) thay vì b^d. Điều kiện dừng khó hơn vẻ ngoài của nó (bạn không được dừng ở vertex chung đầu tiên; phải dừng khi tổng hai key ở frontier vượt quá path tốt nhất tìm được), và đó là lý do các hệ thống chỉ đường thực tế dùng nó rất cẩn thận hoặc dùng contraction hierarchy thay thế.

Dùng thuật toán nào khi nào

Thuật toánBài toánTrọng sốThời gianBộ nhớPhát hiện negative cycle
BFSSSSPunweighted (bằng nhau)O(V + E)O(V)không áp dụng
0-1 BFS (deque)SSSP{0, 1}O(V + E)O(V)không áp dụng
DialSSSPsố nguyên 0..CO(E + V·C)O(V·C)không áp dụng
Relax trên DAGSSSPbất kỳ (chỉ DAG)O(V + E)O(V)không áp dụng (DAG không có)
Dijkstra + binary heapSSSP≥ 0O((V + E) log V)O(V + E)không
Dijkstra + mảngSSSP≥ 0, graph denseO(V² + E)O(V)không
Bellman-FordSSSPbất kỳO(V · E)O(V)
SPFASSSPbất kỳO(V · E) xấu nhất, thường nhanhO(V)
A*một cặp≥ 0 + heuristicO((V + E) log V) xấu nhất, thường ít hơn nhiềuO(V + E)không
Bidirectional Dijkstramột cặp≥ 0~O(b^(d/2))O(V)không
Floyd-WarshallAPSPbất kỳ (không negative cycle)O(V³)O(V²) (dist[v][v] < 0)
JohnsonAPSPbất kỳO(V·E + V² log V)O(V²) (pha Bellman-Ford)

Quy trình quyết định, theo thứ tự:

  1. Mọi trọng số bằng nhau? → BFS.
  2. Trọng số chỉ gồm 0 và 1? → 0-1 BFS.
  3. Graph là DAG? → relax theo topological order (dùng được cả với trọng số âm).
  4. Có trọng số âm không? → Bellman-Ford (hoặc Johnson cho all-pairs). Ngược lại thì Dijkstra.
  5. Cần mọi cặp? → Floyd-Warshall nếu dense/nhỏ, V × Dijkstra nếu sparse, Johnson nếu sparse có số âm.
  6. Một đích cụ thể, và bạn ước lượng được khoảng cách còn lại? → A*.

Best Practices

Tài liệu tham khảo

Part of the Data Structures & Algorithms Roadmap knowledge base.

Overview

“What is the cheapest way to get from A to B?” is the question that graph algorithms are most often hired to answer. Route planning, network routing protocols (OSPF is literally Dijkstra running on every router), currency arbitrage detection, game AI pathfinding, dependency resolution with costs, minimum-edit-distance as a grid path, and the “minimum number of moves” family of puzzle problems are all shortest-path problems wearing different clothes.

There is no single shortest-path algorithm, and the reason is that the problem comes in variants that admit genuinely different techniques:

What decides which algorithm you use is almost entirely the edge weights: unweighted, weights in {0, 1}, small integers, arbitrary non-negative reals, or possibly negative. Each restriction buys you a faster algorithm; each relaxation costs you one. The whole of this note is a walk down that ladder.

One structural fact underlies all of it: shortest paths have optimal substructure. If the shortest path from s to t passes through x, then the portion from s to x is itself a shortest s→x path. If it were not, you could splice in the better prefix and improve the whole path — a contradiction. This is what makes the greedy and dynamic-programming formulations below valid, and it is the same property that powers dynamic programming generally.

Everything here assumes you have read ./13-graph-data-structures.md — the representations, BFS, and DFS.

Fundamentals

The relaxation primitive

Every algorithm in this note is built from one operation. Maintain an array dist[] where dist[v] is the best cost to v found so far (an upper bound on the true shortest distance, starting at for everything but the source). Relaxing an edge (u, v, w) asks whether going through u improves your estimate for v:

def relax(dist, parent, u, v, w):
    """The single primitive behind BFS, Dijkstra, Bellman-Ford and Floyd-Warshall.
    Returns True if the estimate for v improved."""
    if dist[u] + w < dist[v]:
        dist[v] = dist[u] + w
        parent[v] = u
        return True
    return False

The dist array is only ever lowered, and it is always the length of some real path (never an underestimate), so once it equals the true shortest distance it stays correct. The algorithms differ only in the order in which they relax edges and how they know when to stop:

AlgorithmRelaxation orderStopping rule
BFSin order of hop countqueue empties
Dijkstrain increasing order of dist[u]every vertex extracted once
Bellman-Fordall edges, over and overV−1 rounds, or no change
Floyd-Warshallby allowed intermediate vertex kall k used
DAG relaxationtopological orderone pass

Distances, parents, and reconstructing the path

Every implementation below returns dist and parent. Keeping parent[v] — the vertex you arrived from on the best path found so far — costs one array and one assignment per successful relaxation, and it is the only way to output the actual route rather than just its length. The parent array forms a shortest-path tree rooted at the source: V−1 edges, one per reachable non-source vertex.

def reconstruct_path(parent, source, target):
    """Follow parent pointers from target back to source. O(path length)."""
    if target != source and parent[target] == -1:
        return []                     # unreachable
    path = []
    cur = target
    while cur != -1:
        path.append(cur)
        cur = parent[cur]
    path.reverse()
    return path

Why shortest paths are simple paths (and when they are not)

If all cycles have non-negative weight, some shortest path between any two vertices is simple — it repeats no vertex — because walking around a cycle can never help. That bounds path length at V−1 edges, which is exactly the number of rounds Bellman-Ford needs.

If a negative-weight cycle is reachable from the source and can reach the target, “the shortest path” is not merely hard to compute — it does not exist. You can go around the cycle again and drive the cost down without bound. Any correct algorithm must therefore either forbid negative cycles by assumption or detect them and say so. This is the fault line that separates Dijkstra from Bellman-Ford.

Key Concepts

BFS — shortest paths on unweighted graphs, O(V + E)

On an unweighted graph (or one where every edge has the same weight), the shortest path is the one with the fewest edges, and plain BFS computes it. The layer structure of BFS is the distance function: everything at BFS depth k is exactly distance k from the source.

from collections import deque


def bfs_shortest_paths(adj, source):
    """Unweighted single-source shortest paths. Time O(V + E), space O(V).

    Nothing beats this on an unweighted graph: Dijkstra would do the same work
    plus O(log V) heap overhead per edge for no benefit whatsoever."""
    n = len(adj)
    dist = [-1] * n
    parent = [-1] * n
    dist[source] = 0
    q = deque([source])

    while q:
        u = q.popleft()
        for v in adj[u]:
            if dist[v] == -1:
                dist[v] = dist[u] + 1
                parent[v] = u
                q.append(v)

    return dist, parent

Why it is correct: the queue is always sorted by distance, and holds at most two distinct distance values at a time (k and k+1). So the first time a vertex is discovered, it is discovered from a vertex at the minimum possible distance. Formally: BFS is Dijkstra where every key is an integer that increases by exactly one, so a FIFO queue serves as a priority queue for free.

The practical rule: if all weights are equal, use BFS. Reaching for Dijkstra on an unweighted graph is a log V factor of wasted work and a heap you did not need.

Dijkstra’s algorithm — non-negative weights, O((V + E) log V)

Dijkstra’s is the workhorse. It generalizes BFS by replacing the FIFO queue with a priority queue keyed on tentative distance, so the frontier expands in order of cost rather than hop count.

The invariant: when a vertex is extracted from the priority queue with the minimum key, its distance is final. The argument is a greedy exchange: suppose we extract u with key d, and suppose there existed a cheaper path to u. That path must at some point cross from the set of finalized vertices to the unfinalized ones, at some edge (x, y). But then dist[y] ≤ d, so y (not u) would have been extracted, and since all edge weights are ≥ 0, the rest of the path from y to u cannot reduce the total. Contradiction.

Every step of that argument depends on weights being non-negative.

import heapq


def dijkstra(adj, source):
    """Single-source shortest paths with non-negative weights.

    adj[u] is a list of (v, w) pairs with w >= 0.
    Time O((V + E) log V), space O(V + E).
    """
    n = len(adj)
    INF = float("inf")
    dist = [INF] * n
    parent = [-1] * n
    dist[source] = 0

    pq = [(0, source)]                   # entries are (tentative distance, vertex)

    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:
            continue                     # stale entry: we already found u cheaper
        for v, w in adj[u]:
            nd = d + w
            if nd < dist[v]:             # relax the edge (u, v, w)
                dist[v] = nd
                parent[v] = u
                heapq.heappush(pq, (nd, v))   # "lazy" insert instead of decrease-key

    return dist, parent

Why O((V + E) log V). Each edge can cause at most one push, so the heap holds O(E) entries; each push and pop costs O(log E) = O(log V) (since E ≤ V², log E ≤ 2 log V). Vertices contribute V pops. Total: O((V + E) log V).

The if d > dist[u]: continue line is load-bearing. Python’s heapq has no decrease-key operation, so the standard workaround is to push a new entry every time a distance improves and leave the old, larger entry in the heap as garbage. That stale-entry check is what makes each vertex’s adjacency list get scanned only once. Delete the line and the algorithm still returns correct distances but may rescan adjacency lists repeatedly.

Priority queueExtract-minDecrease-keyTotal
Array / linear scanO(V)O(1)O(V² + E) — best for dense graphs
Binary heap (heapq)O(log V)O(log V) (or lazy push)O((V + E) log V) — the default
Fibonacci heapO(log V) amortizedO(1) amortizedO(E + V log V) — theoretically better, rarely worth it

The O(V²) array version genuinely wins on dense graphs: when E ≈ V², O(V²) beats O(V² log V). The Fibonacci heap’s advantage is asymptotic only — its constants are bad enough that a binary heap usually wins on real inputs up to enormous sizes.

Early exit for single-pair queries: if you only need dist[target], break out of the loop the moment you pop target. Its distance is final at that point by the invariant. This is a large saving in practice and costs nothing.

Why Dijkstra breaks on negative edges

This is worth working through rather than memorizing, because “just add a big constant to every weight” is a tempting and completely wrong fix.

Take this graph:

            2
     S ----------> A
     |             ^
     | 3           | -2
     v             |
     B ------------+

The true shortest distances are dist(A) = 1 (via S→B→A, cost 3 + (−2) = 1) and dist(B) = 3.

Now trace the textbook Dijkstra — the variant that maintains a done set and never revisits a finalized vertex:

  1. Pop S (key 0). Relax: dist[A] = 2, dist[B] = 3.
  2. Pop A (key 2 — the minimum). Mark A finalized at 2. A has no outgoing edges.
  3. Pop B (key 3). Relax B→A: 3 + (−2) = 1 < 2 — but A is already finalized, so the improvement is discarded.
  4. Output: dist[A] = 2. Wrong. The answer is 1.

The greedy invariant assumed that no later vertex could offer a cheaper route, and a negative edge makes exactly that possible.

The lazy-heap implementation above happens to survive this three-vertex example — it has no done set, so it would re-push A with key 1 and fix the answer. That is not a rescue, it is a trap: on adversarial graphs the lazy version re-pops vertices an exponential number of times (Johnson’s classic construction chains k gadgets to force 2^k pops), and on a graph with a negative cycle it never terminates at all. Do not run Dijkstra on graphs with negative edges.

And do not reweight by adding a constant. Suppose S→T costs 1, S→X costs −2, and X→T costs 2. True answer: S→X→T = 0, cheaper than S→T = 1. Add 2 to every weight to eliminate the negative: now S→T = 3 and S→X→T = 0 + 4 = 4. The direct edge wins. A constant shift penalizes paths in proportion to how many edges they use, so it changes which path is shortest. The correct reweighting is Johnson’s algorithm, which uses Bellman-Ford from a virtual super-source to compute a potential h(v) per vertex and sets w'(u,v) = w(u,v) + h(u) − h(v). That transformation is non-negative and telescopes along any path, so relative path costs are preserved.

0-1 BFS — weights in {0, 1}, O(V + E)

When every edge weight is 0 or 1, you can drop the heap entirely. Use a deque: a 0-weight edge keeps you in the same “layer”, so push to the front; a 1-weight edge advances one layer, so push to the back. The deque stays sorted by distance with at most two distinct values, exactly like a BFS queue.

from collections import deque


def zero_one_bfs(adj, source):
    """Shortest paths when every weight is 0 or 1. Time O(V + E), space O(V).

    adj[u] is a list of (v, w) with w in {0, 1}.
    """
    n = len(adj)
    INF = float("inf")
    dist = [INF] * n
    parent = [-1] * n
    dist[source] = 0
    dq = deque([source])

    while dq:
        u = dq.popleft()
        for v, w in adj[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                parent[v] = u
                if w == 0:
                    dq.appendleft(v)     # same layer -> must be processed first
                else:
                    dq.append(v)         # next layer -> goes behind everything

    return dist, parent

This turns an O(E log V) problem into an O(V + E) one, which matters at scale. The classic use is grid problems with two kinds of move: “walking through an empty cell is free, breaking a wall costs 1 — what is the minimum number of walls to break?” Model the free move as weight 0 and the costly move as weight 1, and 0-1 BFS answers it in a single linear pass.

The generalization to small integer weights 0..C is Dial’s algorithm: keep V·C + 1 buckets indexed by distance and sweep them in order, for O(E + V·C). Worth it when C is tiny; a heap is simpler otherwise.

DAG shortest paths — any weights, O(V + E)

If the graph is a directed acyclic graph, you can do better than Dijkstra even with negative weights: process vertices in topological order and relax each vertex’s outgoing edges once. By the time you reach u, every path into u has already been considered, so dist[u] is final.

def dag_shortest_paths(adj, source, topo_order):
    """Shortest paths on a DAG. Weights may be negative; a DAG has no cycles,
    so negative cycles are impossible. Time O(V + E), space O(V).

    `topo_order` comes from topological sort - see 13-graph-data-structures.md.
    """
    n = len(adj)
    INF = float("inf")
    dist = [INF] * n
    parent = [-1] * n
    dist[source] = 0

    for u in topo_order:
        if dist[u] == INF:
            continue                     # u is not reachable from source
        for v, w in adj[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                parent[v] = u

    return dist, parent

Negate the weights and you get longest path in a DAG, which is the critical path of a project schedule or a build graph — and which is NP-hard on general graphs but linear here. This is the single most under-used shortest-path algorithm: if your graph is a DAG, use it.

Bellman-Ford — any weights, O(V · E), detects negative cycles

Bellman-Ford abandons greed. It relaxes every edge, V−1 times. After round i, every dist[v] reachable by a path of at most i edges is correct; since a shortest simple path has at most V−1 edges, V−1 rounds suffice.

def bellman_ford(n, edges, source):
    """Single-source shortest paths with arbitrary edge weights.

    `edges` is a list of (u, v, w) triples - the edge-list representation.
    Time O(V * E), space O(V). Raises ValueError on a reachable negative cycle.
    """
    INF = float("inf")
    dist = [INF] * n
    parent = [-1] * n
    dist[source] = 0

    for _ in range(n - 1):
        changed = False
        for u, v, w in edges:
            if dist[u] != INF and dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                parent[v] = u
                changed = True
        if not changed:
            break                        # a full pass changed nothing: we are done

    # One extra pass. If anything still improves, no finite answer exists.
    for u, v, w in edges:
        if dist[u] != INF and dist[u] + w < dist[v]:
            raise ValueError("graph contains a reachable negative-weight cycle")

    return dist, parent

Why O(V · E). V−1 rounds times E relaxations each. The early exit (“a full pass changed nothing”) often makes it far faster in practice, but the worst case is real: a path graph with edges supplied in exactly the wrong order needs all V−1 rounds.

Negative-cycle detection is the extra pass. After V−1 rounds every distance is final if no negative cycle exists. So if a V-th round can still improve something, a negative cycle is reachable. This is not a side benefit — it is the reason Bellman-Ford is used in currency arbitrage detection (a negative cycle in −log(exchange rate) space is a risk-free profit loop) and in distance-vector routing protocols like RIP.

To recover the cycle itself rather than just its existence, track which vertex was relaxed in the final round and walk back V parent steps to be certain you have landed inside the cycle:

def find_negative_cycle(n, edges):
    """Return one negative cycle as a list of vertices, or None.

    dist starts at 0 everywhere, which is equivalent to a virtual super-source
    with 0-weight edges to every vertex - this finds a negative cycle anywhere
    in the graph, not only those reachable from a chosen source.
    Time O(V * E).
    """
    dist = [0] * n
    parent = [-1] * n
    x = -1

    for _ in range(n):
        x = -1
        for u, v, w in edges:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                parent[v] = u
                x = v                    # remember the last vertex improved
        if x == -1:
            return None                  # a full pass with no change: no negative cycle

    # x is on or downstream of a negative cycle. Walking back n parent links
    # is guaranteed to land us on a vertex that is actually in the cycle.
    for _ in range(n):
        x = parent[x]

    cycle = [x]
    cur = parent[x]
    while cur != x:
        cycle.append(cur)
        cur = parent[cur]
    cycle.append(x)
    cycle.reverse()
    return cycle

SPFA (Shortest Path Faster Algorithm) is Bellman-Ford with a queue of “vertices whose distance just changed”, so you only re-relax edges out of vertices that could produce an improvement. It is often dramatically faster on real graphs, but its worst case is still O(V·E) and adversarial inputs are easy to construct — it has been deliberately broken in competitive programming for years. Use it knowing it is a constant-factor optimization, not a complexity improvement.

Floyd-Warshall — all pairs, O(V³)

Floyd-Warshall computes the shortest distance between every pair of vertices with three nested loops and no data structures at all. It is a textbook dynamic program: let d[k][i][j] be the shortest i→j distance using only vertices 0..k−1 as intermediates. Then

d[k][i][j] = min( d[k-1][i][j],                 # do not route through k
                  d[k-1][i][k] + d[k-1][k][j] ) # route through k

and the k dimension can be dropped in place, which is why the real implementation is a 2D array.

def floyd_warshall(n, edges):
    """All-pairs shortest paths. Time O(V^3), space O(V^2).

    Handles negative edges. Raises on a negative cycle (detected as dist[v][v] < 0).
    `nxt[i][j]` is the first hop on a shortest i -> j path, for reconstruction.
    """
    INF = float("inf")
    dist = [[INF] * n for _ in range(n)]
    nxt = [[-1] * n for _ in range(n)]

    for v in range(n):
        dist[v][v] = 0
        nxt[v][v] = v
    for u, v, w in edges:
        if w < dist[u][v]:               # keep the cheapest parallel edge
            dist[u][v] = w
            nxt[u][v] = v

    for k in range(n):                   # k MUST be the outermost loop
        for i in range(n):
            dik = dist[i][k]
            if dik == INF:
                continue                 # i cannot reach k: skip the whole row
            row_k = dist[k]
            row_i = dist[i]
            for j in range(n):
                if dik + row_k[j] < row_i[j]:
                    row_i[j] = dik + row_k[j]
                    nxt[i][j] = nxt[i][k]

    for v in range(n):
        if dist[v][v] < 0:
            raise ValueError("negative cycle through vertex %d" % v)

    return dist, nxt


def fw_path(nxt, i, j):
    """Reconstruct a shortest path from the `nxt` table. O(path length)."""
    if nxt[i][j] == -1:
        return []
    path = [i]
    while i != j:
        i = nxt[i][j]
        path.append(i)
    return path

The k loop must be outermost. This is the classic Floyd-Warshall bug: swap the loops and the recurrence no longer guarantees that d[i][k] and d[k][j] are already final when you use them, and you get answers that are too large in ways that look plausible.

When to use it: small dense graphs (V ≤ 400 or so in Python, V ≤ 1000 in C++), when you need all pairs, or when you want transitive closure (replace min/+ with or/and and you have Warshall’s reachability algorithm). For sparse graphs with V large, running Dijkstra from every vertex is O(V(V + E) log V), which beats O(V³) handily — and Johnson’s algorithm does the same with negative edges allowed, by reweighting with Bellman-Ford potentials first.

Dijkstra explores in every direction equally, which is wasteful when you know roughly where the goal is. A* adds a heuristic h(v): an estimate of the remaining cost from v to the goal. Instead of prioritizing by g(v) (cost so far), it prioritizes by f(v) = g(v) + h(v) — estimated total cost of the best route through v. The effect is to bias the search towards the goal.

import heapq


def a_star(neighbours, start, goal, h):
    """A* single-pair shortest path.

    neighbours(u) -> iterable of (v, w) with w >= 0
    h(u)          -> estimated remaining cost from u to goal (must be admissible)

    Returns (cost, path). With h identically 0 this is exactly Dijkstra.
    """
    open_heap = [(h(start), 0, start)]   # (f = g + h, g, vertex)
    g_score = {start: 0}
    parent = {start: None}
    closed = set()

    while open_heap:
        f, g, u = heapq.heappop(open_heap)
        if u in closed:
            continue                     # stale entry
        if u == goal:
            path = []
            node = goal
            while node is not None:
                path.append(node)
                node = parent[node]
            path.reverse()
            return g, path
        closed.add(u)

        for v, w in neighbours(u):
            ng = g + w
            if ng < g_score.get(v, float("inf")):
                g_score[v] = ng
                parent[v] = u
                heapq.heappush(open_heap, (ng + h(v), ng, v))

    return float("inf"), []

Two properties of the heuristic decide whether A* is correct:

If your heuristic is admissible but not consistent, the closed set can finalize a vertex too early. The fix is to allow reopening (remove v from closed when a better g is found) — correct, but potentially much slower.

Standard heuristics:

DomainHeuristicConsistent?
Grid, 4-directional moves, unit costManhattan distance abs(dr) + abs(dc)yes
Grid, 8-directional (diagonals cost 1)Chebyshev max(abs(dr), abs(dc))yes
Road network with straight-line coordinatesgreat-circle distance ÷ max speedyes
15-puzzlesum of Manhattan distances of tilesyes
Anythingh ≡ 0yes — and A* degenerates to Dijkstra

The trade-off is sharp: a larger admissible h prunes more. h ≡ 0 is Dijkstra (correct, slow). h equal to the exact remaining distance would walk straight to the goal (correct, but computing it is the original problem). Real heuristics live between. If you knowingly overestimate — “weighted A*”, using ε · h for ε > 1 — you get a much faster search that returns a path at most ε times optimal, which is often the right engineering trade for a game or a UI.

A* does not improve the worst-case complexity: with a useless heuristic it is exactly Dijkstra, O((V + E) log V). Its value is entirely in the constant factor, and on a continent-scale road graph with a good heuristic that constant factor is orders of magnitude.

A related pruning technique for the single-pair case is bidirectional search: run one search forward from s and one backward from t, and stop when they meet. If the search explores a ball of radius d, two balls of radius d/2 are exponentially smaller in a graph with branching factor b2·b^(d/2) instead of b^d. The termination condition is fiddlier than it looks (you cannot stop at the first common vertex; you must stop when the sum of the two frontier keys exceeds the best path found so far), which is why production route planners use it carefully or use contraction hierarchies instead.

Which algorithm to use when

AlgorithmProblemWeightsTimeSpaceDetects negative cycles
BFSSSSPunweighted (all equal)O(V + E)O(V)n/a
0-1 BFS (deque)SSSP{0, 1}O(V + E)O(V)n/a
Dial’sSSSPintegers 0..CO(E + V·C)O(V·C)n/a
DAG relaxationSSSPany (DAG only)O(V + E)O(V)n/a (a DAG has none)
Dijkstra + binary heapSSSP≥ 0O((V + E) log V)O(V + E)no
Dijkstra + arraySSSP≥ 0, dense graphO(V² + E)O(V)no
Bellman-FordSSSPanyO(V · E)O(V)yes
SPFASSSPanyO(V · E) worst, fast typicalO(V)yes
A*single pair≥ 0 + heuristicO((V + E) log V) worst, much less typicalO(V + E)no
Bidirectional Dijkstrasingle pair≥ 0~O(b^(d/2))O(V)no
Floyd-WarshallAPSPany (no neg cycle)O(V³)O(V²)yes (dist[v][v] < 0)
Johnson’sAPSPanyO(V·E + V² log V)O(V²)yes (Bellman-Ford phase)

The decision procedure, in order:

  1. Are all weights equal? → BFS.
  2. Are weights only 0 and 1? → 0-1 BFS.
  3. Is the graph a DAG? → topological-order relaxation (works with negative weights too).
  4. Any negative weights? → Bellman-Ford (or Johnson’s for all-pairs). Otherwise Dijkstra.
  5. Do you need all pairs? → Floyd-Warshall if dense/small, V × Dijkstra if sparse, Johnson’s if sparse and negative.
  6. One specific target, and can you estimate remaining distance? → A*.

Best Practices

References