Bài 25.4: Kỹ thuật loang trên mảng 2 chiều trong C++

Bài 25.4: Kỹ thuật loang trên mảng 2 chiều trong C++
Chào mừng bạn quay trở lại với series về C++ của FullhouseDev! Hôm nay, chúng ta sẽ cùng nhau "lặn sâu" vào một kỹ thuật quen зовсім quen thuộc trong nhiều lĩnh vực, từ chỉnh sửa ảnh đơn giản đến các bài toán phức tạp trên đồ thị: Kỹ thuật loang, hay còn gọi là Flood Fill.
Bạn đã bao giờ sử dụng công cụ "thùng sơn" trong các phần mềm đồ họa để tô màu một vùng liền mạch chưa? Đó chính là một ứng dụng trực quan của kỹ thuật loang đấy! Về cơ bản, kỹ thuật này giúp chúng ta xác định và thao tác (ví dụ: thay đổi màu, đếm số lượng) với một vùng liền kề các phần tử có cùng một thuộc tính (giá trị) trong một cấu trúc dữ liệu dạng lưới hoặc mảng 2 chiều.
Hãy tưởng tượng bạn có một tấm bản đồ số được biểu diễn bằng một mảng 2 chiều, nơi mỗi ô chứa một giá trị đại diện cho loại địa hình (ví dụ: 0 cho nước, 1 cho đất liền). Kỹ thuật loang có thể giúp bạn:
- Tô màu toàn bộ một hồ nước sau khi click vào một điểm bất kỳ trong hồ đó.
- Đếm diện tích của một "hòn đảo" (vùng đất liền liên tục).
- Tìm tất cả các ô trống liên thông trong một mê cung để xem có đường đi không.
Sức mạnh của kỹ thuật này nằm ở khả năng lan tỏa từ một điểm gốc ra các điểm lân cận, nhưng chỉ khi các điểm lân cận đó đáp ứng một điều kiện nhất định (thường là có cùng giá trị với điểm gốc hoặc giá trị mà ta muốn thay đổi).
Kỹ thuật Loang Hoạt Động Như Thế Nào?
Ý tưởng cốt lõi rất đơn giản:
- Bạn bắt đầu từ một điểm $(r, c)$ trên lưới.
- Kiểm tra xem điểm này có hợp lệ để "loang" không (ví dụ: nằm trong biên của lưới, có giá trị mong muốn).
- Nếu hợp lệ, bạn xử lý điểm đó (ví dụ: thay đổi giá trị của nó để đánh dấu là đã thăm hoặc thay đổi màu).
- Sau đó, bạn loang sang các điểm lân cận của nó (trên, dưới, trái, phải - hoặc cả 8 hướng tùy bài toán) và lặp lại quá trình này cho đến khi không còn điểm lân cận hợp lệ nào để loang nữa.
Quá trình "loang sang các điểm lân cận" chính là phần quan trọng nhất và có thể được triển khai theo hai cách phổ biến:
1. Sử dụng Đệ quy (Depth-First Search - DFS)
DFS về bản chất là đi sâu vào một nhánh trước khi quay lại và khám phá các nhánh khác. Với kỹ thuật loang, khi bạn loang từ $(r, c)$, bạn ngay lập tức gọi đệ quy để loang từ một trong các ô lân cận hợp lệ của nó.
Cấu trúc cơ bản của một hàm loang DFS:
void loang_dfs(int r, int c, int cu, int moi, vector<vector<int>>& a) {
// Kiem tra bien, gia tri
// Danh dau da tham
// Goi de quy lan can
}
Để thuận tiện cho việc duyệt các ô lân cận, chúng ta thường sử dụng hai mảng dr
và dc
lưu trữ sự thay đổi về chỉ số hàng và cột.
Ví dụ minh họa Flood Fill bằng DFS (4 hướng):
Giả sử chúng ta có một mảng biểu diễn vùng nước (0) và đất (1), muốn thay đổi tất cả vùng nước liên thông với điểm (1, 1) thành giá trị 2.
#include <iostream>
#include <vector>
using namespace std;
// Hướng di chuyển: trên, dưới, trái, phải
int h[] = {-1, 1, 0, 0};
int c[] = {0, 0, -1, 1};
int R, C; // Kích thước mảng
void loang_dfs(int r, int k, int cu, int moi, vector<vector<int>>& a) {
if (r < 0 || r >= R || k < 0 || k >= C) return;
if (a[r][k] != cu) return;
a[r][k] = moi;
for (int i = 0; i < 4; ++i) {
loang_dfs(r + h[i], k + c[i], cu, moi, a);
}
}
int main() {
vector<vector<int>> a = {
{1, 1, 1, 1, 1},
{1, 0, 0, 0, 1},
{1, 0, 1, 0, 1},
{1, 0, 0, 0, 1},
{1, 1, 1, 1, 1}
};
R = a.size();
C = a[0].size();
int r0 = 1;
int k0 = 1;
int cu = a[r0][k0];
int moi = 2;
cout << "Mang truoc khi loang:" << endl;
for (const auto& dong : a) {
for (int o : dong) {
cout << o << " ";
}
cout << endl;
}
loang_dfs(r0, k0, cu, moi, a);
cout << "\nMang sau khi loang:" << endl;
for (const auto& dong : a) {
for (int o : dong) {
cout << o << " ";
}
cout << endl;
}
return 0;
}
Mang truoc khi loang:
1 1 1 1 1
1 0 0 0 1
1 0 1 0 1
1 0 0 0 1
1 1 1 1 1
Mang sau khi loang:
1 1 1 1 1
1 2 2 2 1
1 2 1 2 1
1 2 2 2 1
1 1 1 1 1
Giải thích code DFS:
h
vàc
: Hai mảng này giúp chúng ta dễ dàng tính toán tọa độ của 4 ô lân cận từ ô hiện tại $(r, c)$. Ví dụ,(r + h[0], k + c[0])
tương ứng với ô phía trên $(r-1, c)$.- Hàm
loang_dfs
:- Tham số: Tọa độ $(r, c)$ của ô hiện tại, giá trị cũ (
cu
) mà ta muốn thay đổi, giá trị mới (moi
) sẽ thay thế, và mảnga
. - Điều kiện dừng: Nếu $(r, c)$ nằm ngoài phạm vi của mảng, hoặc giá trị của ô
a[r][c]
không phải làcu
, hàm dừng lại (không loang tiếp từ đây). - Xử lý: Nếu các điều kiện dừng không thỏa mãn, nghĩa là ô hiện tại hợp lệ để loang. Ta gán
a[r][c] = moi
để đánh dấu ô này đã được xử lý. - Đệ quy: Vòng lặp
for
duyệt qua 4 hướng. Với mỗi hướng, ta tính toán tọa độ ô lân cận $(nr, nc)$ và gọi đệ quyloang_dfs(nr, nc, ...)
để tiếp tục quá trình loang từ ô đó. Quá trình này sẽ tự động dừng lại khi gặp các điều kiện dừng đã nêu.
- Tham số: Tọa độ $(r, c)$ của ô hiện tại, giá trị cũ (
Lưu ý về DFS: Cách tiếp cận đệ quy rất thanh lịch nhưng có thể gặp vấn đề về tràn bộ nhớ stack nếu vùng cần loang quá lớn và sâu.
2. Sử dụng Hàng đợi (Breadth-First Search - BFS)
BFS về bản chất là khám phá theo từng "lớp" các đỉnh. Với kỹ thuật loang, bạn sẽ bắt đầu từ điểm gốc, thăm tất cả các ô lân cận hợp lệ của nó, sau đó thăm tất cả các ô lân cận hợp lệ của các ô lân cận đó, và cứ thế tiếp tục.
Cấu trúc cơ bản của Flood Fill bằng BFS:
- Khoi tao hang doi, them diem bat dau.
- Danh dau diem bat dau.
- Lap khi hang doi chua rong:
- Lay diem hien tai.
- Duyet cac o lan can:
- Kiem tra hop le.
- Doi gia tri va them vao hang doi.
Ví dụ minh họa Flood Fill bằng BFS (4 hướng):
Tiếp tục với ví dụ mảng nước/đất, thay đổi vùng nước liên thông với (1, 1) thành giá trị 2, sử dụng BFS.
#include <iostream>
#include <vector>
#include <queue>
#include <utility> // for pair
using namespace std;
// Hướng di chuyển: trên, dưới, trái, phải
int h_bfs[] = {-1, 1, 0, 0};
int c_bfs[] = {0, 0, -1, 1};
int R_bfs, C_bfs; // Kích thước mảng
void loang_bfs(int r, int k, int cu, int moi, vector<vector<int>>& a) {
if (r < 0 || r >= R_bfs || k < 0 || k >= C_bfs || a[r][k] != cu) return;
if (cu == moi) return;
queue<pair<int, int>> q;
q.push({r, k});
a[r][k] = moi;
while (!q.empty()) {
pair<int, int> hien_tai = q.front();
q.pop();
int hr = hien_tai.first;
int hc = hien_tai.second;
for (int i = 0; i < 4; ++i) {
int nr = hr + h_bfs[i];
int nc = hc + c_bfs[i];
if (nr >= 0 && nr < R_bfs && nc >= 0 && nc < C_bfs && a[nr][nc] == cu) {
a[nr][nc] = moi;
q.push({nr, nc});
}
}
}
}
int main() {
vector<vector<int>> a_bfs = {
{1, 1, 1, 1, 1},
{1, 0, 0, 0, 1},
{1, 0, 1, 0, 1},
{1, 0, 0, 0, 1},
{1, 1, 1, 1, 1}
};
R_bfs = a_bfs.size();
C_bfs = a_bfs[0].size();
int r0_bfs = 1;
int k0_bfs = 1;
int cu_bfs = a_bfs[r0_bfs][k0_bfs];
int moi_bfs = 2;
cout << "Mang truoc khi loang (BFS):" << endl;
for (const auto& dong : a_bfs) {
for (int o : dong) {
cout << o << " ";
}
cout << endl;
}
loang_bfs(r0_bfs, k0_bfs, cu_bfs, moi_bfs, a_bfs);
cout << "\nMang sau khi loang (BFS):" << endl;
for (const auto& dong : a_bfs) {
for (int o : dong) {
cout << o << " ";
}
cout << endl;
}
return 0;
}
Mang truoc khi loang (BFS):
1 1 1 1 1
1 0 0 0 1
1 0 1 0 1
1 0 0 0 1
1 1 1 1 1
Mang sau khi loang (BFS):
1 1 1 1 1
1 2 2 2 1
1 2 1 2 1
1 2 2 2 1
1 1 1 1 1
Giải thích code BFS:
queue<pair<int, int>> q;
: Chúng ta sử dụng một hàng đợi để lưu trữ các tọa độ $(r, c)$ cần thăm.pair
rất tiện để lưu trữ cặp tọa độ này.- Đẩy điểm bắt đầu: Điểm bắt đầu được đưa vào hàng đợi đầu tiên.
- Vòng lặp
while (!q.empty())
: Vòng lặp này tiếp tục cho đến khi không còn ô nào cần thăm trong hàng đợi. q.front()
vàq.pop()
: Lấy ô ở đầu hàng đợi ra để xử lý.- Kiểm tra ô lân cận: Duyệt qua 4 hướng tương tự như DFS. Điều kiện
a[nr][nc] == cu
là quan trọng để đảm bảo ta chỉ loang vào các ô có giá trị ban đầu giống với vùng cần loang và chưa bị đổi màu (vì khi đổi màu thànhmoi
, nó sẽ không còn bằngcu
nữa). - Đẩy vào hàng đợi: Nếu ô lân cận hợp lệ, ta đổi màu nó thành
moi
và đẩy tọa độ của nó vào hàng đợi để xử lý sau.
So sánh DFS và BFS trong Flood Fill:
- DFS (Đệ quy): Code thường ngắn gọn hơn, đặc biệt cho những người quen thuộc với đệ quy. Tuy nhiên, có nguy cơ tràn bộ nhớ stack với các vùng loang lớn.
- BFS (Hàng đợi): Sử dụng bộ nhớ heap cho hàng đợi, ít bị giới hạn bởi stack size. Đảm bảo khám phá theo từng lớp, có thể hữu ích trong một số bài toán (mặc dù trong Flood Fill cơ bản thì không quá khác biệt về kết quả cuối cùng so với DFS). Code tường minh hơn về luồng xử lý (iterative).
Cả hai cách tiếp cận đều cho kết quả đúng cho bài toán Flood Fill cơ bản là đổi màu một vùng liên thông. Việc chọn cách nào thường phụ thuộc vào sở thích cá nhân, giới hạn bộ nhớ stack (đối với DFS), hoặc yêu cầu cụ thể của bài toán.
Ứng dụng Thực tế và Biến thể
Kỹ thuật loang không chỉ dừng lại ở việc đổi màu đơn giản. Nó là nền tảng cho nhiều bài toán khác trên lưới:
- Đếm số lượng thành phần liên thông (Connected Components): Ví dụ, đếm số lượng "hòn đảo" trong bản đồ. Bạn có thể duyệt qua từng ô của lưới. Nếu gặp một ô "đất" (chưa được thăm), tăng bộ đếm số đảo lên 1 và sau đó dùng Flood Fill (DFS hoặc BFS) để "thăm" hết toàn bộ hòn đảo đó (bằng cách đổi giá trị của các ô "đất" trong đảo đó thành một giá trị khác, ví dụ -1, để đánh dấu là đã thăm).
- Tìm vùng liên thông lớn nhất: Tương tự như đếm số thành phần, nhưng trong quá trình loang, bạn đếm số ô đã thăm. Sau khi loang xong một vùng, cập nhật kích thước lớn nhất tìm được.
- Kiểm tra đường đi: Trong mê cung hoặc bản đồ, bạn có thể dùng biến thể của Flood Fill (hoặc BFS/DFS nói chung) để xem có đường đi từ điểm A đến điểm B qua các ô hợp lệ không.
Ví dụ: Đếm số "hòn đảo" bằng DFS (biến thể Flood Fill)
Giả sử mảng 2 chiều chứa 1 là đất, 0 là nước. Hai ô đất kề nhau theo 4 hướng tạo thành một hòn đảo.
#include <iostream>
#include <vector>
using namespace std;
int h_dao[] = {-1, 1, 0, 0};
int c_dao[] = {0, 0, -1, 1};
int R_dao, C_dao;
void tham_dao(int r, int k, vector<vector<int>>& a) {
if (r < 0 || r >= R_dao || k < 0 || k >= C_dao) return;
if (a[r][k] != 1) return; // Chỉ thăm đất (1)
a[r][k] = 2; // Đánh dấu đã thăm
for (int i = 0; i < 4; ++i) {
tham_dao(r + h_dao[i], k + c_dao[i], a);
}
}
int dem_dao(vector<vector<int>>& a) {
R_dao = a.size();
if (R_dao == 0) return 0;
C_dao = a[0].size();
if (C_dao == 0) return 0;
int so_dao = 0;
for (int i = 0; i < R_dao; ++i) {
for (int j = 0; j < C_dao; ++j) {
if (a[i][j] == 1) { // Nếu gặp đất chưa thăm
so_dao++;
tham_dao(i, j, a); // Thăm hết đảo
}
}
}
return so_dao;
}
int main() {
vector<vector<int>> a_dao = {
{0, 1, 0, 0, 0},
{1, 1, 0, 0, 0},
{0, 0, 1, 0, 0},
{0, 0, 0, 1, 1}
};
cout << "Mang ban dau:" << endl;
for (const auto& dong : a_dao) {
for (int o : dong) {
cout << o << " ";
}
cout << endl;
}
int so_luong_dao = dem_dao(a_dao);
cout << "\nSo luong dao: " << so_luong_dao << endl;
cout << "\nMang sau khi danh dau cac dao (o dat=2):" << endl;
for (const auto& dong : a_dao) {
for (int o : dong) {
cout << o << " ";
}
cout << endl;
}
return 0;
}
Mang ban dau:
0 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1
So luong dao: 3
Mang sau khi danh dau cac dao (o dat=2):
0 2 0 0 0
2 2 0 0 0
0 0 2 0 0
0 0 0 2 2
Giải thích code đếm đảo:
- Hàm
dem_dao
: Duyệt qua từng ô trong lưới. Nếu gặp một ô có giá trị1
(đất) chưa được thăm (vì hàmtham_dao
sẽ đổi giá trị của ô đất thành2
sau khi thăm), ta biết rằng đây là khởi đầu của một hòn đảo mới. Tăng biếnso_dao
lên 1 và gọitham_dao
từ ô này. - Hàm
tham_dao
: Chức năng tương tự như Flood Fill cơ bản, nhưng điều kiện loang làa[r][k] == 1
. Khi thăm một ô, ta đổi giá trị của nó thành2
để đánh dấu là đã thăm và không xử lý lại. Hàm này đảm bảo rằng tất cả các ô "đất" liên thông trong cùng một hòn đảo sẽ được thăm hết chỉ từ một lần gọi ban đầu.
Bài tập ví dụ: C++ Bài 11.A4: Đường chéo chính, đường chép phụ
Cho một mảng số nguyên A có \(n\) dòng và \(n\) cột. Hãy tính tổng đường chéo chính, đường chéo phụ.
Minh họa đường chéo chính (đỏ), đường chéo phụ (xanh)
INPUT FORMAT
Dòng đầu tiên nhập vào hai số nguyên dương \(n(1 \leq n\leq 10^3)\).
\(N\) dòng tiếp theo mỗi dòng nhập \(n\) số nguyên là giá trị của \(a_{i,j} (a_{i,j} \leq 10^3)\).
OUTPUT FORMAT
Dòng đầu tiên in ra tổng đường chéo chính.
Dòng thứ 2 in ra tổng đường chéo phụ.
Ví dụ 1:
Input
3 3
1 7 3
7 4 5
3 5 6
Ouput
11
10
Giải thích ví dụ mẫu
Tính tổng các phần tử trên đường chéo chính (từ góc trên trái đến góc dưới phải) và đường chéo phụ (từ góc trên phải đến góc dưới trái) của ma trận.
Bao gồm các thư viện cần thiết:
- Bạn sẽ cần thư viện để xử lý nhập xuất (
iostream
). - Để lưu trữ ma trận một cách linh hoạt với kích thước được đọc từ input,
vector
là lựa chọn tốt (vector
).
- Bạn sẽ cần thư viện để xử lý nhập xuất (
Đọc kích thước ma trận:
- Đọc số nguyên
n
từ dòng đầu tiên. Kích thước này sẽ xác định số dòng và số cột của ma trận vuông.
- Đọc số nguyên
Khởi tạo biến tổng:
- Tạo hai biến kiểu số nguyên (ví dụ:
long long
để đảm bảo không bị tràn số nếun
và giá trị phần tử lớn, mặc dù với giới hạn1000
thìint
cũng đủ trong hầu hết trường hợp) để lưu tổng đường chéo chính và tổng đường chéo phụ. Khởi tạo chúng bằng 0.
- Tạo hai biến kiểu số nguyên (ví dụ:
Lưu trữ và xử lý ma trận:
- Sử dụng một mảng 2 chiều hoặc
vector<vector<int>>
có kích thướcn x n
để lưu trữ các phần tử của ma trận.vector<vector<int>> matrix(n, vector<int>(n));
là cách hiện đại và an toàn. - Sử dụng hai vòng lặp lồng nhau để duyệt qua từng phần tử của ma trận. Vòng lặp ngoài điều khiển chỉ số dòng (từ 0 đến
n-1
), vòng lặp trong điều khiển chỉ số cột (từ 0 đếnn-1
). - Trong các vòng lặp này, đọc giá trị của phần tử tại vị trí hiện tại (dòng
i
, cộtj
). - Ngay sau khi đọc một phần tử, hãy kiểm tra vị trí của nó để xem nó thuộc đường chéo nào:
- Đường chéo chính: Một phần tử
a[i][j]
nằm trên đường chéo chính nếu chỉ số dòngi
bằng chỉ số cộtj
(i == j
). Nếu đúng, cộng giá trị của phần tử này vào biến tổng đường chéo chính. - Đường chéo phụ: Một phần tử
a[i][j]
nằm trên đường chéo phụ nếu tổng chỉ số dòngi
và chỉ số cộtj
bằngn - 1
(i + j == n - 1
). Nếu đúng, cộng giá trị của phần tử này vào biến tổng đường chéo phụ.
- Đường chéo chính: Một phần tử
- Sử dụng một mảng 2 chiều hoặc
In kết quả:
- Sau khi đã duyệt và xử lý hết tất cả các phần tử của ma trận, in giá trị của biến tổng đường chéo chính ra dòng đầu tiên.
- In giá trị của biến tổng đường chéo phụ ra dòng thứ hai.
- Sử dụng
cin
vàcout
để thực hiện nhập xuất.
Ghi chú:
- Đảm bảo bạn sử dụng chỉ số mảng/vector bắt đầu từ 0.
- Đối với ma trận kích thước lớn (
n=1000
), việc sử dụngvector
và cấp phát động là phù hợp hơn mảng tĩnh lớn trên stack. - Nếu
n
là số lẻ, phần tử chính giữa của ma trận sẽ nằm trên cả hai đường chéo. Cách kiểm trai == j
vài + j == n - 1
độc lập sẽ tự động cộng phần tử này vào cả hai tổng, đây là cách tính đúng theo đề bài.
#include <iostream>
#include <vector>
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n;
cin >> n; // Doc kich thuoc ma tran
long long chinh = 0; // Tong duong cheo chinh
long long phu = 0; // Tong duong cheo phu
// Luu tru ma tran
vector<vector<int>> a(n, vector<int>(n));
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
cin >> a[i][j]; // Doc gia tri phan tu
if (i == j) { // Kiem tra duong cheo chinh
chinh += a[i][j];
}
if (i + j == n - 1) { // Kiem tra duong cheo phu
phu += a[i][j];
}
}
}
cout << chinh << endl;
cout << phu << endl;
return 0;
}
Input:
3 3
1 7 3
7 4 5
3 5 6
Output:
11
10
Comments