Một cách cài đặt đồ thị bằng phương pháp lập trình hướng đối tượng
Trần Minh Quang
Đồ thị (Graph) có thể nói là một trong những cấu trúc rời rạc thường được sử dụng nhất
trong ngành khoa học máy tính. Mỗi khi cần mô hình hóa một tập đối tượng cùng với các
mối quan hệ giữa chúng, chúng ta luôn có thể trừa tượng hóa (abstract) chúng bằng đồ thị.
Một mạng giao thông, mạng máy tính, quan hệ quen biết giữa một nhóm người, quan hệ về
sở thích (A thích ăn cam, B thích ăn quýt)…v…v. tất cả đều có thể đưa về mô hình của
một đồ thị.
Mục đích của bài viết tuy nhiên không phải là giới thiệu lý thuyết về đồ thị cũng như
phương pháp lập trình hướng đối tượng mà muốn trình bày một cách cài đặt đồ thị tương
đối hiệu quả bằng phương pháp lập trình hướng đối tượng bằng ngôn ngữ lập trình Java.
Vì vậy, tác giả xem như người đọc đã biết lý thuyết về đồ thị cũng như lập trình hướng đối
tượng trên Java.
I. Nhắc lại khái niệm của đồ thị
Đồ thị G được cấu thành từ một tập hợp các đỉnh (Vertices) và các cạnh (Edges) ký hiệu là
G = (V, E).
Đồ thị G gọi là đồ thị vô hướng nếu các cạnh của G không có thứ tự, tức là chúng không
phân biệt cạnh AB với BA (tương tự đường 2 chiều).
Đồ thị G gọi là đồ thị vô hướng nếu các cạnh của G có thứ tự, tức là cạnh AB khác với
cạnh BA (tương tự đường 1 chiều).
Đồ thị có trọng số là đồ thị trong đó mỗi cạnh của đồ thị được gán bằng trọng số (khoảng
cách, chi phí..v..v).
II. Phân tích & Thiết kế
Như thường lệ, khi bắt đầu thiết kế một chương trình hướng đối tượng chúng ta thường
phải đặt ra các mục tiêu sau đây:
a. Mô tả (interface) của đồ thị phải độc lập với các biến thể cài đặt chúng
Vì đồ thị là một khái niệm chung chung, có rất nhiều cách đề cài đặt nó. Người này có thể
cài đặt bằng phương pháp danh sách cạnh kề (Adjacency List), người kia lại thích bằng ma
trận (matrix). Kiến trúc (Architecture) của chương trình chúng ta phải đảm bảo tách rời
interface một đồ thị với các biến thể cài đặt chúng.
b. Khả năng mở rộng (scalable)
Là khả năng mở rộng chương trình mà kô phải tốn nhiều công sức thiết kế lại kiến trúc của
nó. Chẳng hạn: đồ thị chúng ta sẽ cài đặt ở đây là đồ thị không trọng số, giả sử sau này ta
muốn mở rộng sang loại đồ thị có trọng số thì toàn bộ kiến trúc của chương trình không
được phép sụp đổ và phải thiết kế lại. Đây là một yêu cầu rất quan trọng trong lĩnh vực
phát triển phần mềm vì khách hàng sau khi sử dụng phần mêm một thời gian thường có
nhu cầu bổ sung các chức năng mới hoặc thay đổi một số chức năng của chương trình
(change requests)
c. Khả năng sử dụng lại (resuable)
Đó là khi những đoạn mã chúng ta viết một lần và luôn có thể được sử dụng lại cho các
ứng dụng tiếp theo. Ví dụ như: ta bỏ công ra để cài đặt đồ thị này, sau này có một lúc nào
đấy ta nghiên cứu về mạng Neuron của lĩnh vực trí tuệ nhân tạo (AI). Vì mạng Neuron
thực chất cũng là một đồ thị, ta có thể sử dụng lại các lớp (class) mà ta viết hôm này với
khả năng kế thừa của OOP rồi thay đổi một số thuộc tính (Attribute) hoặc phương thức
(Method) cho phù hợp với yêu cầu bài toán mới, ta có thể giải quyết vấn đề mới trong thời
gian ngắn hơn nhiều là khi ta phải làm mọi thứ từ đầu. Đấy âu cũng là một nét đẹp của
OOP vậy!
Một đồ thị có nghĩa vụ cần cung cấp những giao tiếp (Methods) cơ bản sau đây:
- lấy số đỉnh của đồ thị: v()
- lấy số cạnh của đồ thi: e()
- thêm một cạnh vào đồ thị: ađ(int u, int v)
- xóa một cạnh khỏi đồ thị: remove(int u, int v)
- kiểm tra một cạnh có thuộc đồ thị hay không: contains(int u, int v)
- lấy ra tất cả các đỉnh kề với một đỉnh cho trước: adj(int u)
- hiển thị đồ thị ra màn hình: displayGraph()
Với Java ta có thể mô tả “định nghĩa” một đồ họa như trên bằng interface như sau:
public interface Graph
{
boolean ađ(int u, int v);
boolean remove(int u, int v);
List adj(int u);
boolean contains(int u, int v);
int v();
int e();
void displayGraph();
}
Có một điều cần lưu ý là chúng ta cần hết sức thận trọng khi thiết kế một interface!. Khi
một lớp (class) cài đặt một interface thì nó phải cài đặt tất cả các phương thức của interface
ấy, chẳng hạn: ta cài đặt đồ họa bằng ma trận vào gọi lớp này là: GraphMatrix. Ta sẽ có
khai báo là: GraphMatrix implements Graph. Khi đấy trong phần cài đặt của lớp
GraphMatrix nhất thiết phải chứa đầy đủ 7 phương thức của interface Graph từ ađ(int u,
int v) cho đến displayGraph().
Lại giả sử rằng ta viết một thư viện cài đặt đồ thị trong đó interface Graph của chúng ta
chứa 7 phương thức như ở trên. Một công ty phần mềm mua thư viện này về và khi dẫn
xuất (extends) interface Graph của chúng ta họ sẽ phải cài đặt đủ 7 phương thức này.
Một thời gian sau, chúng ta thấy cảm thấy không ưng ý về interface Graph ở trên và muốn
thêm vào một vài phương thức mới ví dụ như: gradeSequence() đưa ra danh sách các bậc
của các đỉnh của đồ thị chẳng hạn…v…v. Sau khi sửa đổi lại thư viện, công ty nọ cũng
nhận được một bản cập nhật như theo thỏa thuận với interface Graph chúng ta vừa sửa
đổi này.
Và tại họa đã xảy ra….toàn bộ những chương trình mà công ty đã viết sử dụng interface
Graph đều không họat động nữa vì các lớp cài đặt interface Graph đều không cài đặt các
phuơng phức mới thêm vào interface sau này (như gradeSequence()).
Bài học rút ra ở đây là: việc thiết kế interface đòi hỏi chúng ta phải nhìn trước được tất cả
các phương thức mà interface cung cấp. Điều này tất nhiên là không hề đơn giản chút nào!!
Quay trở lại với interface Graph. Nếu suy nghĩ kỹ ta sẽ nhận thấy một trong những thao tác
quan trọng đối với một đồ thị là duyệt đồ thị theo một thứ tự nào đấy (chẳng hạn theo
chiều rộng BFS, hoặc chiều sâu DFS). Với Java điều này tương đương với “Graph is
iterable”. Java cung cấp interface “Iterable”, theo Java SDK Documentation:
public interface Iterable
{
Iterator iterator()
Returns an iterator over a set of elements of type T.
}
“Graph is iterable” chuyển thể sang ngôn ngữ Java: public interface Graph extends Iterable
với khai báo này, interface Graph sẽ có thêm một phương thức mới kế thừa từ interface
Iterable là Iterator iterator (). Phương thức này trả về một đối tượng “Iterator” dùng để
duyệt đồ thị của chúng ta.
III. Các phương pháp cài đặt đồ thị
Ở trên chúng ta mới chỉ mô tả “thế nào là một đồ thị” theo nghĩa một interface mà không
hề đưa ra bất cứ quy định về cài đặt nào.
Có rất nhiều cách đề cài đặt một đồ thị, 3 phương pháp phổ biến nhất là:
a) Bằng ma trận (Matrix)
Đồ thị N đỉnh được lưu trữ bởi một ma trận N x N trong đó a[u][v] = 1 nếu tồn tại cạnh
giữa u và v
b) Bằng danh sách kề (Adjacency list)
Đồ thị N đỉnh. Một danh sách gồm N phần tử, mỗi phần tử i của danh sách này lại là một
danh sách chưa các đỉnh liền kề với đỉnh i.
c) Danh sách cạnh (Edges list)
Một danh sách chứa tất cả các cạnh của đồ thị
Dưới đây là mã nguồn chương trình cài đặt đồ thị đơn giản tức là: đồ thị vô hướng, không
cho phép cạnh nối một đỉnh với chính nó, giữa một cặp đỉnh tồn tại tối đa một cạnh liên
kết chúng bằng ma trận:
import java.util.*;
public class GraphMatrix implements Graph
{
private int v, e;
private boolean connected[][];
//Contructure
public GraphMatrix(int v)
{
//Only allow positive number of vertices
if (v > 0)
{
this.e = 0;
this.v = v;
//Connected array is automatically initialized with “false”
connected = new boolean[v][v];
}
}
public boolean ađ(int u, int v)
{
if (!isValidNode(u) || !isValidNode(v) || (u == v) || contains(u, v))
return false;
connected[u][v] = true;
connected[v][u] = true;
this.e++;
return true;
}
public boolean remove(int u, int v)
{
if (!isValidNode(u) || !isValidNode(v) || (u == v) || !contains(u, v))
return false;
connected[u][v] = false;
connected[v][u] = false;
this.e--;
return true;
}
public List<Integer> adj(int u)
{