DFS & BFSthầy Phúc

BFS — duyệt theo chiều rộng

Breadth-First Search. Thăm hết mọi đỉnh cách 1 bước, rồi mới tới mọi đỉnh cách 2 bước, rồi 3 bước. Lan ra như vết dầu trên mặt nước.

1. Ý tưởng

Nhỏ một giọt mực vào chậu nước. Vết mực không lao theo một hướng; nó lan đều ra mọi phía cùng một lúc. Sau một giây nó phủ hết vùng cách tâm 1cm, sau hai giây phủ hết vùng cách 2cm.

BFS làm đúng như vậy trên đồ thị. Từ đỉnh 1, nó thăm hết các đỉnh kề trực tiếp — đó là tầng 1. Rồi thăm hết các đỉnh kề của tầng 1 mà chưa thăm — tầng 2. Và tiếp tục.

tầng 0tầng 1tầng 2tầng 311223345678
BFS sau khi xử lý xong tầng 1: các đỉnh 1, 2, 3 đã được thăm; các đỉnh 4, 5, 6 đang xếp hàng chờ và đều thuộc tầng 2. Các dải ngang chính là các tầng — hãy để ý hình vẽ được xếp đúng theo tầng.

2. Tự bấm từng bước

Chú ý con số d= dưới mỗi đỉnh: đó là khoảng cách tới đỉnh xuất phát, và nó được gán đúng một lần, vào lúc đỉnh được đưa vào hàng đợi.

BFS trên đồ thị mẫu — bấm từng bước

1d=02345678

Hàng đợi (queue)

1
← đầu (lấy ra)đuôi (đưa vào) ←

Đã thăm (1)

1

Thứ tự thăm

chưa thăm đỉnh nào
khởi tạo

Bắt đầu: đưa đỉnh 1 vào hàng đợi và đánh dấu đã thăm. Khoảng cách d = 0.

Thứ tự thăm của BFS trên đồ thị mẫu là 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8. (Ở đồ thị này nó tình cờ là 1..8 theo đúng thứ tự — nhờ cách mình đánh số các đỉnh, để các em dễ nhớ.)

Bảng mô phỏng đầy đủ — 26 bước
#Phép toánHàng đợi (queue)Đã thămDiễn biến
1khởi tạo 111Bắt đầu: đưa đỉnh 1 vào hàng đợi và đánh dấu đã thăm. Khoảng cách d = 0.
2lấy ra 11Lấy đỉnh 1 ra khỏi đầu hàng đợi và xử lý. Đây là đỉnh thứ 1 được thăm, ở tầng 0.
3đưa vào 221 2Xét đỉnh 2 kề với đỉnh 1: chưa thăm → đưa vào cuối hàng đợi và đánh dấu đã thăm ngay. d = 1.
4đưa vào 32 31 2 3Xét đỉnh 3 kề với đỉnh 1: chưa thăm → đưa vào cuối hàng đợi và đánh dấu đã thăm ngay. d = 1.
5lấy ra 231 2 3Lấy đỉnh 2 ra khỏi đầu hàng đợi và xử lý. Đây là đỉnh thứ 2 được thăm, ở tầng 1.
6bỏ qua 131 2 3Xét đỉnh 1 kề với đỉnh 2: đã thăm rồi, bỏ qua.
7đưa vào 43 41 2 3 4Xét đỉnh 4 kề với đỉnh 2: chưa thăm → đưa vào cuối hàng đợi và đánh dấu đã thăm ngay. d = 2.
8đưa vào 53 4 51 2 3 4 5Xét đỉnh 5 kề với đỉnh 2: chưa thăm → đưa vào cuối hàng đợi và đánh dấu đã thăm ngay. d = 2.
9lấy ra 34 51 2 3 4 5Lấy đỉnh 3 ra khỏi đầu hàng đợi và xử lý. Đây là đỉnh thứ 3 được thăm, ở tầng 1.
10bỏ qua 14 51 2 3 4 5Xét đỉnh 1 kề với đỉnh 3: đã thăm rồi, bỏ qua.
11đưa vào 64 5 61 2 3 4 5 6Xét đỉnh 6 kề với đỉnh 3: chưa thăm → đưa vào cuối hàng đợi và đánh dấu đã thăm ngay. d = 2.
12lấy ra 45 61 2 3 4 5 6Lấy đỉnh 4 ra khỏi đầu hàng đợi và xử lý. Đây là đỉnh thứ 4 được thăm, ở tầng 2.
13bỏ qua 25 61 2 3 4 5 6Xét đỉnh 2 kề với đỉnh 4: đã thăm rồi, bỏ qua.
14đưa vào 75 6 71 2 3 4 5 6 7Xét đỉnh 7 kề với đỉnh 4: chưa thăm → đưa vào cuối hàng đợi và đánh dấu đã thăm ngay. d = 3.
15lấy ra 56 71 2 3 4 5 6 7Lấy đỉnh 5 ra khỏi đầu hàng đợi và xử lý. Đây là đỉnh thứ 5 được thăm, ở tầng 2.
16bỏ qua 26 71 2 3 4 5 6 7Xét đỉnh 2 kề với đỉnh 5: đã thăm rồi, bỏ qua.
17bỏ qua 76 71 2 3 4 5 6 7Xét đỉnh 7 kề với đỉnh 5: đã thăm rồi, bỏ qua.
18lấy ra 671 2 3 4 5 6 7Lấy đỉnh 6 ra khỏi đầu hàng đợi và xử lý. Đây là đỉnh thứ 6 được thăm, ở tầng 2.
19bỏ qua 371 2 3 4 5 6 7Xét đỉnh 3 kề với đỉnh 6: đã thăm rồi, bỏ qua.
20đưa vào 87 81 2 3 4 5 6 7 8Xét đỉnh 8 kề với đỉnh 6: chưa thăm → đưa vào cuối hàng đợi và đánh dấu đã thăm ngay. d = 3.
21lấy ra 781 2 3 4 5 6 7 8Lấy đỉnh 7 ra khỏi đầu hàng đợi và xử lý. Đây là đỉnh thứ 7 được thăm, ở tầng 3.
22bỏ qua 481 2 3 4 5 6 7 8Xét đỉnh 4 kề với đỉnh 7: đã thăm rồi, bỏ qua.
23bỏ qua 581 2 3 4 5 6 7 8Xét đỉnh 5 kề với đỉnh 7: đã thăm rồi, bỏ qua.
24lấy ra 81 2 3 4 5 6 7 8Lấy đỉnh 8 ra khỏi đầu hàng đợi và xử lý. Đây là đỉnh thứ 8 được thăm, ở tầng 3.
25bỏ qua 61 2 3 4 5 6 7 8Xét đỉnh 6 kề với đỉnh 8: đã thăm rồi, bỏ qua.
26xong1 2 3 4 5 6 7 8Hàng chờ rỗng — BFS kết thúc. Thứ tự thăm: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8.

3. Cài đặt

BFS cơ bản, kèm mảng khoảng cách

vector<vector<int>> ke;
vector<bool> daTham;
vector<int> d;                       // d[v] = khoảng cách ngắn nhất từ đỉnh xuất phát

void bfs(int batDau) {
    queue<int> q;
    q.push(batDau);
    daTham[batDau] = true;           // đánh dấu KHI ĐƯA VÀO
    d[batDau] = 0;

    while (!q.empty()) {
        int u = q.front();           // lấy đỉnh Ở ĐẦU hàng đợi
        q.pop();
        cout << u << ' ';            // xử lý đỉnh u

        for (int v : ke[u])
            if (!daTham[v]) {
                daTham[v] = true;    // đánh dấu NGAY, không đợi tới lúc lấy ra
                d[v] = d[u] + 1;
                q.push(v);
            }
    }
}
from collections import deque

def bfs(bat_dau: int) -> None:
    q = deque([bat_dau])
    daTham[bat_dau] = True           # đánh dấu KHI ĐƯA VÀO
    d[bat_dau] = 0

    while q:
        u = q.popleft()              # lấy đỉnh Ở ĐẦU hàng đợi
        print(u, end=' ')            # xử lý đỉnh u

        for v in ke[u]:
            if not daTham[v]:
                daTham[v] = True     # đánh dấu NGAY, không đợi tới lúc lấy ra
                d[v] = d[u] + 1
                q.append(v)

So sánh với DFS đệ quy ở bài trước, chỉ có hai thay đổi thật sự: dùng queue lấy từ đầu thay vì ngăn xếp, và đánh dấu daTham[v] = true ở chỗ đưa vào chứ không phải chỗ lấy ra.

4. Vì sao BFS cho đường đi ngắn nhất

Đây là tính chất quan trọng nhất của BFS, và nó chỉ đúng trên đồ thị không trọng số (mọi cạnh coi như dài 1).

Lập luận gọn: BFS xử lý xong toàn bộ tầng k trước khi chạm tới bất kỳ đỉnh nào của tầng k+1. Cho nên lần đầu tiên BFS chạm tới đỉnh v, số tầng hiện tại chính là khoảng cách ngắn nhất tới v. Nếu tồn tại đường ngắn hơn, thì v đã bị chạm ở một tầng sớm hơn rồi — trái với việc “đây là lần đầu”.

Đó cũng là lý do dòng d[v] = d[u] + 1 chỉ chạy đúng một lần cho mỗi đỉnh và không bao giờ cần sửa lại. Hãy so với DFS: DFS tới đỉnh 5 qua đường dài 4 cạnh, BFS tới đỉnh 5 qua đường 2 cạnh.

0123401234
Mê cung 5×5, ô đen là tường. BFS từ (0,0) tới (4,4) trả về khoảng cách 8 — và đường tô đậm là một đường đi đạt đúng 8 bước. DFS cũng tới được đích, nhưng không đảm bảo con số nào.

BFS tìm đường trong mê cung — số trong ô là khoảng cách

01234012340

Hàng đợi (queue)

(0,0)
← đầu (lấy ra)đuôi (đưa vào) ←

Đã thăm (1)

(0,0)

Thứ tự thăm

chưa thăm đỉnh nào
khởi tạo

Bắt đầu: đưa ô (0,0) vào hàng đợi và đánh dấu đã thăm. Khoảng cách d = 0.

Muốn in ra cả đường đi, không chỉ độ dài, hãy lưu thêm “đỉnh cha”:

Truy vết đường đi ngắn nhất

vector<int> cha(n + 1, 0);           // cha[v] = đỉnh đã đưa v vào hàng đợi

// ... trong vòng BFS, lúc đưa v vào:
cha[v] = u;

// truy lại đường đi từ dinhDich về batDau, rồi đảo ngược
vector<int> duongDi;
for (int v = dinhDich; v != 0; v = cha[v])
    duongDi.push_back(v);
reverse(duongDi.begin(), duongDi.end());
cha = [0] * (n + 1)                  # cha[v] = đỉnh đã đưa v vào hàng đợi

# ... trong vòng BFS, lúc đưa v vào:
cha[v] = u

# truy lại đường đi từ dinh_dich về bat_dau, rồi đảo ngược
duong_di = []
v = dinh_dich
while v != 0:
    duong_di.append(v)
    v = cha[v]
duong_di.reverse()

5. Mẹo chốt tầng

Rất nhiều bài cần biết “đỉnh này thuộc tầng mấy” hoặc cần xử lý theo từng tầng một. Mẹo chuẩn là chốt sz = q.size() ở đầu mỗi lượt:

Xử lý BFS theo từng tầng

int tang = 0;
while (!q.empty()) {
    int sz = q.size();               // CHỐT: đúng bằng số đỉnh của tầng này
    for (int i = 0; i < sz; i++) {
        int u = q.front();
        q.pop();
        // ... xử lý u, biết chắc u thuộc tầng "tang" ...
        for (int v : ke[u])
            if (!daTham[v]) { daTham[v] = true; q.push(v); }
    }
    tang++;                          // xong một tầng
}
tang = 0
while q:
    for _ in range(len(q)):          # CHỐT: đúng bằng số đỉnh của tầng này
        u = q.popleft()
        # ... xử lý u, biết chắc u thuộc tầng "tang" ...
        for v in ke[u]:
            if not daTham[v]:
                daTham[v] = True
                q.append(v)
    tang += 1                        # xong một tầng

Ở đầu mỗi lượt, hàng đợi chứa đúng và chỉ các đỉnh của một tầng. Lấy ra đúng sz đỉnh thì ta xử lý gọn một tầng; các đỉnh vừa được đưa vào thuộc tầng sau và sẽ đợi tới lượt kế tiếp.

Thiếu sz, vòng lặp trong sẽ ăn luôn cả các đỉnh mới — các tầng trộn lẫn vào nhau. Đây là lỗi thường gặp nhất ở bài LeetCode 102.

Mẹo này mở ra ba loại bài: in cây theo tầng (bài 102), đếm số bước ít nhất (bài 1091), và đếm thời gian lan truyền (bài 994).

6. Độ phức tạp

Chi phí Vì sao
Thời gian O(V + E) Giống DFS: mỗi đỉnh vào hàng đợi đúng một lần, mỗi cạnh xét hai lần.
Bộ nhớ O(V) Hàng đợi lớn nhất bằng tầng rộng nhất của đồ thị.

Trong thực tế BFS thường tốn bộ nhớ hơn DFS: một tầng của lưới 1000×1000 có thể chứa hàng nghìn ô cùng lúc, trong khi ngăn xếp của DFS chỉ sâu bằng độ dài một đường đi.

7. Lỗi thường gặp

8. Ba bài LeetCode

Ba bài này lần lượt dạy ba ý: tầng là gì, tầng là khoảng cách, và tầng có thể là thời gian.

#102Trung bình

Duyệt cây nhị phân theo tầngBinary Tree Level Order Traversal ↗

In các nút của cây theo từng tầng, mỗi tầng một dòng. Đây là BFS ở dạng thuần khiết nhất — và là bài để học mẹo "chốt kích thước hàng đợi" để biết một tầng kết thúc ở đâu.

Xem đáp án

Vì sao cây không cần mảng daTham

Cây là đồ thị không có chu trình và mỗi nút chỉ có một đường đi tới từ gốc. Không bao giờ có chuyện đi vòng về một nút cũ, nên phần “đánh dấu đã thăm” biến mất. Đây là lý do bài này là bài BFS dễ nhất: chỉ còn đúng phần cốt lõi — hàng đợi.

Trên đồ thị thường thì mảng daTham bắt buộc phải có. Đừng mang thói quen từ bài này sang đó.

Mẹo chốt kích thước hàng đợi

Ở đầu mỗi lượt, hàng đợi chứa đúng và chỉ các nút của một tầng. Ta ghi lại số lượng đó vào sz, rồi chỉ lấy ra đúng sz nút. Những nút con được đưa vào trong lúc đó thuộc tầng sau, và sẽ được xử lý ở lượt tiếp theo.

Không có sz, vòng lặp trong sẽ ăn luôn cả các nút mới vừa đưa vào, và kết quả là một danh sách phẳng chứ không phải danh sách theo tầng.

Cùng một mẹo, ba bài khác

Mẹo sz = q.size() dùng lại được ở rất nhiều bài: 199. Right Side View (lấy nút cuối mỗi tầng), 104. Maximum Depth (đếm số tầng), và bài 994 ngay dưới đây — ở đó một “tầng” là một phút.

Cài đặt

class Solution {
public:
    vector<vector<int>> levelOrder(TreeNode* root) {
        vector<vector<int>> kq;
        if (root == nullptr) return kq;      // cây rỗng: đừng quên!

        queue<TreeNode*> q;
        q.push(root);
        while (!q.empty()) {
            int sz = q.size();               // CHỐT số nút của tầng này
            vector<int> tang;
            for (int i = 0; i < sz; i++) {   // lấy đúng sz nút, không hơn
                TreeNode* u = q.front();
                q.pop();
                tang.push_back(u->val);
                if (u->left)  q.push(u->left);
                if (u->right) q.push(u->right);
            }
            kq.push_back(tang);
        }
        return kq;
    }
};
class Solution:
    def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
        if root is None:                     # cây rỗng: đừng quên!
            return []

        kq = []
        q = deque([root])
        while q:
            sz = len(q)                      # CHỐT số nút của tầng này
            tang = []
            for _ in range(sz):              # lấy đúng sz nút, không hơn
                u = q.popleft()
                tang.append(u.val)
                if u.left:
                    q.append(u.left)
                if u.right:
                    q.append(u.right)
            kq.append(tang)
        return kq
Độ phức tạp
O(n) thời gian, O(n) bộ nhớ — hàng đợi có thể chứa cả tầng rộng nhất của cây.
Chỗ dễ sai
Dùng while (!q.empty()) mà không chốt sz = q.size() trước vòng lặp trong. Hàng đợi lớn dần trong lúc đang lặp, các tầng trộn vào nhau.
#1091Trung bình

Đường đi ngắn nhất trong lưới nhị phânShortest Path in Binary Matrix ↗

Đi từ góc trên bên trái xuống góc dưới bên phải, được đi cả 8 hướng, tránh các ô bị chặn. Đây là bài chứng minh vì sao BFS mới tìm được đường ngắn nhất — DFS sẽ cho một đường đi, nhưng gần như chắc chắn không ngắn nhất.

Xem đáp án

Vì sao BFS cho đường ngắn nhất

BFS lan ra theo từng tầng: thăm hết mọi ô cách xuất phát 1 bước, rồi mọi ô cách 2 bước, rồi 3… Lần đầu tiên BFS chạm tới một ô, số tầng hiện tại chính là khoảng cách ngắn nhất tới ô đó. Không đường nào có thể ngắn hơn, vì nếu có thì ô đã bị chạm ở tầng sớm hơn rồi.

DFS thì đi sâu một nhánh tới cùng. Nó cũng tới được đích, nhưng bằng con đường nó gặp đầu tiên — thường là đường rất dài. Muốn có đường ngắn nhất bằng DFS thì phải thử mọi đường, tức là quay lui, và chi phí tăng theo hàm mũ.

Tám hướng thay vì bốn

Hai vòng dr, dc từ -1 đến 1 cho ra 9 cặp; bỏ cặp (0,0) còn đúng 8 hướng. Viết như vậy gọn hơn khai báo hai mảng 8 phần tử, và không sợ gõ sai một dấu.

Đánh dấu khi đưa vào, không phải khi lấy ra

Đây là lỗi kinh điển. Hãy tưởng tượng ô X có 5 ô kề đều nằm cùng một tầng. Nếu chỉ đánh dấu lúc lấy ra, cả 5 ô đó đều thấy X “chưa thăm” và đẩy X vào hàng đợi: X nằm 5 lần trong hàng đợi. Với lưới 100×100 số bản sao bùng nổ và chương trình quá thời gian.

Đánh dấu ngay khi đưa vào thì mỗi ô vào hàng đợi đúng một lần. Trong khi đó, DFS bằng ngăn xếp cố tình đánh dấu khi lấy ra — hai thuật toán khác nhau ở đúng chỗ này, và đó không phải sự tuỳ tiện.

Cài đặt

class Solution {
public:
    int shortestPathBinaryMatrix(vector<vector<int>>& grid) {
        int n = grid.size();
        if (grid[0][0] == 1 || grid[n - 1][n - 1] == 1) return -1;

        queue<pair<int, int>> q;
        q.push({0, 0});
        grid[0][0] = 1;                      // đánh dấu KHI ĐƯA VÀO
        int d = 1;                           // độ dài tính theo số ô

        while (!q.empty()) {
            int sz = q.size();               // một lượt = một tầng khoảng cách
            for (int i = 0; i < sz; i++) {
                auto [r, c] = q.front();
                q.pop();
                if (r == n - 1 && c == n - 1) return d;   // tới đích: d là ngắn nhất

                for (int dr = -1; dr <= 1; dr++)
                    for (int dc = -1; dc <= 1; dc++) {
                        if (dr == 0 && dc == 0) continue;
                        int nr = r + dr, nc = c + dc;
                        if (nr < 0 || nc < 0 || nr >= n || nc >= n) continue;
                        if (grid[nr][nc] != 0) continue;  // tường hoặc đã thăm
                        grid[nr][nc] = 1;                 // đánh dấu NGAY
                        q.push({nr, nc});
                    }
            }
            d++;
        }
        return -1;                           // hàng đợi cạn mà chưa tới đích
    }
};
class Solution:
    def shortestPathBinaryMatrix(self, grid: List[List[int]]) -> int:
        n = len(grid)
        if grid[0][0] == 1 or grid[n - 1][n - 1] == 1:
            return -1

        q = deque([(0, 0)])
        grid[0][0] = 1                       # đánh dấu KHI ĐƯA VÀO
        d = 1                                # độ dài tính theo số ô

        while q:
            for _ in range(len(q)):          # một lượt = một tầng khoảng cách
                r, c = q.popleft()
                if r == n - 1 and c == n - 1:
                    return d                 # tới đích: d là ngắn nhất

                for dr in (-1, 0, 1):
                    for dc in (-1, 0, 1):
                        if dr == 0 and dc == 0:
                            continue
                        nr, nc = r + dr, c + dc
                        if not (0 <= nr < n and 0 <= nc < n):
                            continue
                        if grid[nr][nc] != 0:  # tường hoặc đã thăm
                            continue
                        grid[nr][nc] = 1       # đánh dấu NGAY
                        q.append((nr, nc))
            d += 1
        return -1                            # hàng đợi cạn mà chưa tới đích
Độ phức tạp
O(n²) thời gian, O(n²) bộ nhớ cho hàng đợi.
Chỗ dễ sai
Đánh dấu ô khi lấy ra thay vì khi đưa vào. Một ô có thể được nhiều ô kề đẩy vào hàng đợi trước khi được lấy ra, hàng đợi phình lên và khoảng cách sai.
#994Trung bình

Cam thối lan raRotting Oranges ↗

Mỗi phút, cam thối làm thối bốn quả cam kề nó. Hỏi sau bao nhiêu phút thì không còn cam tươi. Bài này dạy hai ý mới: BFS có thể bắt đầu từ nhiều đỉnh cùng lúc, và một "tầng" có thể mang nghĩa thời gian.

Xem đáp án

BFS đa nguồn (multi-source)

Từ trước tới giờ ta luôn bắt đầu BFS từ một đỉnh. Ở bài này có thể có nhiều quả cam thối ngay từ phút 0, và chúng lan ra đồng thời.

Cách xử lý đẹp đến mức đáng ngạc nhiên: đưa tất cả các nguồn vào hàng đợi trước khi vào vòng lặp, coi chúng đều ở tầng 0. Phần còn lại của thuật toán không thay đổi một dòng nào.

Vì sao đúng? Vì BFS vẫn xử lý hết tầng 0 trước tầng 1. Một quả cam tươi sẽ bị quả cam thối gần nhất làm thối trước tiên — đúng như mô tả của đề.

Hãy hình dung: thay vì n nguồn, ta tưởng tượng có một “đỉnh ảo” nối tới cả n quả cam thối. BFS từ đỉnh ảo đó, bỏ qua bước đầu, chính là BFS đa nguồn.

Tầng mang nghĩa thời gian

Trong bài 1091, một tầng là một bước đi. Ở đây, một tầng là một phút. Cấu trúc code giống hệt nhau — vẫn sz = q.size() rồi phut++ sau mỗi lượt. Cái thay đổi chỉ là cách ta đọc con số đó.

Đây là lúc để nhận ra: BFS không đo “khoảng cách” theo nghĩa hình học. Nó đo số lần lan truyền. Số đó là gì thì tuỳ bài: bước đi, phút, số lần đổi chữ trong một từ…

Hai điều kiện biên phải nhớ

  • while (!q.empty() && tuoi > 0): dừng ngay khi hết cam tươi, nếu không sẽ đếm thừa một phút cho lượt cuối chẳng làm thối được gì.
  • Kiểm tra tuoi == 0 lúc trả về: cam nằm trong ô bị vây kín không bao giờ thối, đáp án là -1.
  • Lưới không có quả cam tươi nào ngay từ đầu: tuoi == 0, vòng while không chạy, trả về 0. Đúng.

Cài đặt

class Solution {
public:
    int orangesRotting(vector<vector<int>>& grid) {
        int n = grid.size(), m = grid[0].size(), tuoi = 0;
        queue<pair<int, int>> q;

        // ĐA NGUỒN: đưa MỌI quả cam thối ban đầu vào hàng đợi cùng lúc
        for (int i = 0; i < n; i++)
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 2) q.push({i, j});
                else if (grid[i][j] == 1) tuoi++;
            }

        int dr[] = {-1, 0, 1, 0}, dc[] = {0, 1, 0, -1};
        int phut = 0;

        while (!q.empty() && tuoi > 0) {
            int sz = q.size();               // một tầng = một PHÚT
            for (int i = 0; i < sz; i++) {
                auto [r, c] = q.front();
                q.pop();
                for (int k = 0; k < 4; k++) {
                    int nr = r + dr[k], nc = c + dc[k];
                    if (nr < 0 || nc < 0 || nr >= n || nc >= m) continue;
                    if (grid[nr][nc] != 1) continue;   // ô trống hoặc đã thối
                    grid[nr][nc] = 2;                  // thối ngay, khi ĐƯA VÀO
                    tuoi--;
                    q.push({nr, nc});
                }
            }
            phut++;
        }
        return tuoi == 0 ? phut : -1;        // còn cam tươi → không bao giờ thối hết
    }
};
class Solution:
    def orangesRotting(self, grid: List[List[int]]) -> int:
        n, m = len(grid), len(grid[0])
        q = deque()
        tuoi = 0

        # ĐA NGUỒN: đưa MỌI quả cam thối ban đầu vào hàng đợi cùng lúc
        for i in range(n):
            for j in range(m):
                if grid[i][j] == 2:
                    q.append((i, j))
                elif grid[i][j] == 1:
                    tuoi += 1

        phut = 0
        while q and tuoi > 0:
            for _ in range(len(q)):          # một tầng = một PHÚT
                r, c = q.popleft()
                for dr, dc in ((-1, 0), (0, 1), (1, 0), (0, -1)):
                    nr, nc = r + dr, c + dc
                    if not (0 <= nr < n and 0 <= nc < m):
                        continue
                    if grid[nr][nc] != 1:    # ô trống hoặc đã thối
                        continue
                    grid[nr][nc] = 2         # thối ngay, khi ĐƯA VÀO
                    tuoi -= 1
                    q.append((nr, nc))
            phut += 1

        return phut if tuoi == 0 else -1     # còn cam tươi → không bao giờ thối hết
Độ phức tạp
O(n·m) thời gian, O(n·m) bộ nhớ.
Chỗ dễ sai
Trả về phut mà không kiểm tra còn cam tươi sót lại — cam bị tường nước vây kín thì đáp án phải là -1. Và nếu ngay từ đầu không có cam tươi thì đáp án là 0, không phải -1.