[toc] ## Các khái niệm Đồ thị là một cấu trúc rời rạc gồm các đỉnh và các cạnh nối với các đỉnh đó. Được mô tả hình thức: $$ G = (V, E) $$ Trong đó: - $V$ gọi là tập các đỉnh (Vertices) và $E$ gọi là tập các cạnh (Edges). Có thể coi $E$ là tập các cặp $(u,v)$ với $u, v \in V$. ### Đồ thị vô hướng Đồ thị $G$ là đồ thị vô hướng khi $u$ nối với $v$ cũng là $v$ nối với $u$, $(u, v) = (v,u)$. ### Đồ thị có hướng Đồ thị $G$ là đồ thị có hướng khi $u$ nối với $v$ khác $v$ nối với $u$, $(u, v) \neq (v,u)$. ## Biểu diễn đồ thị ### Ma trận kề - Ma trận kề của đồ thị $𝐺$, ký hiệu $𝐵(𝐺)$, là một ma trận nhị phân cấp $𝑁 \times 𝑁$: $𝐵 = (𝐵_{𝑖,𝑗})$ với $𝐵_{𝑖,𝑗}$ được định nghĩa: + Với đồ thị không có trọng số + $𝐵_{𝑖,𝑗} = 1$ nếu có cạnh nối $𝑖$ tới $𝑗$ + $𝐵_{𝑖,𝑗} = 0$ trong trường hợp ngược lại + Với đồ thị có trọng số + $𝐵_{𝑖,𝑗} = x$ nếu có cạnh nối $𝑖$ tới $𝑗$ với trọng số $x$ + $𝐵_{𝑖,𝑗} = 0$ nếu không có cạnh nối $i$ tới $j$ - Trong c++ ta dùng mảng 2 chiều để biểu diễn ma trận kề. ![](https://hackmd.io/_uploads/S1_kbHku3.png) ### Danh sách kề - Là danh sách các mảng hoặc danh sách liên kết, trong đó mỗi phần tử tương ứng với một đỉnh trong đồ thị. Mỗi phần tử trong danh sách này chứa thông tin về các đỉnh kề của đỉnh tương ứng đó. - Trong C++ ta sử dụng một mảng `vector` để biểu diễn danh sách kề. ![](https://hackmd.io/_uploads/SkgjVrJuh.png) ### Danh sách cạnh - Là danh sách các cặp đỉnh, mỗi cặp đỉnh tương ứng với một cạnh trong đồ thị. Mỗi cạnh được biểu diễn bằng hai đỉnh mà nó kết nối. ![](https://hackmd.io/_uploads/SJzsrBJdn.png) ## Duyệt đồ thị ### Theo chiều sâu (Depth-First Search - DFS) Dựa trên ý tưởng quay lui. Ta sẽ đi toàn bộ đường, sau đó khi không thể đi được nữa ta sẽ quay lui để tìm đường khác. ```cpp= vector <int> adj[MAX_N]; // danh sách kề bool lab[MAX_N]; // mảng xác định một đỉnh đã được xét nếu là 1, chưa nếu là 0 void DFS(int u){ // xử lý đỉnh u (tùy theo yêu cầu đề) lab[u] = 1; // đánh dấu đỉnh u đã được xét for (int i=0; i<adj[u].size(); i++){ // duyệt qua những đỉnh kề với đỉnh u đang xét int v = adj[u][i]; // v là đỉnh kề u if (lab[v] == 0){ // nếu đỉnh v chưa được xét DFS(v); // xét tiếp đỉnh v tiếp theo } } } ``` ### Theo chiều rộng (Breadth-First Search - BFS) Ta sẽ tìm tất cả các đỉnh kề $s$, sau đó tiếp tục từ các đỉnh đó sẽ đến các đỉnh kề. Sử dụng `queue` để lưu các đỉnh sẽ được thăm. ```cpp= vector <int> adj[MAX_N]; // danh sách kề bool lab[MAX_N]; // mảng xác định một đỉnh đã được xét nếu là 1, chưa nếu là 0 void BFS(int s){ queue <int> q; q.push(s); // đưa đỉnh bắt đầu vào hàng đợi // xử lý đỉnh s lab[s] = 1; // đánh dấu s đã được xét while(s.size() > 0){ // lặp khi hàng đợi khác rỗng int u = q.front(); // lấy ra phần tử đầu hàng đợi for (int i=0; i<adj[u].size(); i++){ int v = adj[u][i]; if (lab[v] == 0){ // nếu v chưa được xét // xử lý đỉnh v lab[v] = 1; // đánh dẫu đỉnh v đã được xét q.push(v); // đưa đỉnh kề chưa được xét vào hàng đợi để xét tiếp trong tương lai } } } } ``` ![](https://hackmd.io/_uploads/B1RU8cx_2.gif) [Minh họa thuật toán ở đây](https://visualgo.net/en/dfsbfs) ## Một số bài toán về đồ thị ### Tìm đường đi ngắn nhất #### Dijkstra Thuật toán này đã được học ở môn Cấu Trúc Rời Rạc, chúng ta đã làm nhiều nên mình không cần nói chi tiết ở đây :v. Thuật toán tìm đường đi ngắn nhất từ $1$ đỉnh đến các đỉnh còn lại trong đồ thị. Độ phức tạp: $O(N\log(N))$ ```cpp= const int mN = 2e5 + 10; // khởi tạo giới hạn ban đầu const long long oo = 1e18 + 10ll; // định nghĩa dương vô cùng typedef pair<long long, int> pli; // định nghĩa lại kiểu dữ liệu để tiện hơn int numNode, numEdge; // khai báo số đỉnh, số cạnh long long d[mN]; // khởi tạo mảng khoảng cách vector <pli> adj[mN]; // khai báo danh sách kề void dijkstra(int start){ // gọi hàm dijkstra với đỉnh bắt đầu là start priority_queue <pli, vector<pli>, greater<pli>> pq; // khai báo hàng đợi ưu tiên sắp xếp với ưu tiên trọng số giảm dần int u, v; long long du, uv; for (int i = 1; i <= numNode; ++i) d[i] = oo; // khởi tạo khoảng cách ban đầu là dương vô cùng d[start] = 0; // cho đỉnh bắt đầu có khoảng cách là 0 pq.push(pli(0ll, start)); // đưa trọng số khoảng cách của đỉnh bắt đầu vào trong while (pq.size()) { // trong khi vẫn còn đỉnh trong hàng đợi u = pq.top().second; // lấy ra đỉnh u để xét tiếp các cạnh kế tiếp du = pq.top().first; // lấy ra khoảng cách ngắn nhất từ start đên u pq.pop(); // xóa ra khỏi hàng đợi ưu tiên if (du != d[u]) continue; // nếu đã xét từ trước thì không cần xét tiếp for (int i = 0; v=adj[u][i].second; i++) { // lấy đỉnh kề của u là v uv = adj[u][i].first; // lấy trọng số kề của u if (d[v] > du + uv) { // nếu trọng số khoảng cách của đỉnh v lớn hơn tổng khoảng cách từ đỉnh nguồn đến u và trọng số cạnh u đến v thì cập nhật lại trọng số khoảng cách mới cho đỉnh v d[v] = du + uv; pq.push(pli(d[v], v)); // đưa vào hàng đợi để xét tiếp các đỉnh kế với v } } } } ``` ![](https://hackmd.io/_uploads/rk06H9gun.gif) ### Tìm cây khung nhỏ nhất/lớn nhất 2 thuật toán này cũng đã quá quen với chúng ta ở môn Cấu trúc rời rạc. #### Kruskal Ta sẽ sử dụng cấu trúc dữ liệu [Disjoint set union](https://vnoi.info/wiki/algo/data-structures/disjoint-set-union.md) để có thể nối các cạnh vào thành cây khung và các cây khung với nhau. ```cpp= int par[maxN]; //Lưu node gốc của tập hợp //Nếu node đó là gốc thì mảng thể hiện số lượng node có trong tập hợp. int root(int x){ //tìm gốc của node đồng thời tối ưu cho những lần sau if(par[x] < 0? x:(par[x] = root(par[x]))) } void union(int x, int y){ //nếu đã chung gốc => chung tập hợp, không cần nối if((x = root(x) == (y = root(y)) return; //nếu số lượng ở tập x nhỏ hơn y, ta nối x vào y và ngược lại if(par[x] < par[y]) swap(x, y); par[x] += par[y]; par[y] = x; } struct edge{ //danh sách cạnh int u, v, w; edge(int uu = 0, int vv = 0, int ww = 0){ u = uu; v = vv; w = ww; } //overload bool operator < (edge& x){ return this -> w < x.w; } bool operator <= (edge& x){ return this -> w <= x.w; } bool operator > (edge& x){ return this -> w > x.w; } bool operator >= (edge& x){ return this -> w >= x.w; } }; pair<vector<edge>, int> kruskal(vector<edge>& el){ sort(el.begin(), el.end()); vector<edge> spanningTree; for(int i = 1; i <= n; i++){ par[i] = -1; } int edges = 0; //số cạnh trong cây khung = n - 1, chỉ cần chạy tới đó int totalWeight = 0; for(auto e: el){ //nếu chung tập hợp, bỏ qua if(root(e.u) == root(e.v)) continue; totalWeight += e.w; union(e.u, e.v); spanningTree.push_back(e); edges++; if(edges == n - 1) break; } return make_pair(spanningTree, totalWeight); } ``` ![](https://hackmd.io/_uploads/B1Ewvcxdn.gif) #### Prim ```cpp= typedef pair<int, int> ii; // định nghĩa kiểu dữ liệu để thuận tiện gọi hơn const int mN = 2e5 + 10; // khởi tạo giới hạn const int oo = 1e9 + 10ll; // khởi tạo dương vô cùng int numNode, numEdge, d[mN]; // số đỉnh, số cạnh, trọng số khoảng cách vector <ii> adj[mN]; // khai báo danh sách cạnh kề int prim(int u) { // bắt đầu random ở một đỉnh bất kỳ int totalWeight = 0, v, du, uv; // khai báo priority_queue<ii, vector<ii>, greater<ii>> pq; // tạo một hàng đợi ưu tiên đề lấy trọng số nhỏ nhất để ưu tiên lấy cạnh đó ra for (int i = 1; i <= numNode; i++) d[i] = oo; // khởi tạo mọi đỉnh có khoảng cách dương vô cùng pq.push(ii(0, u)); // đưa đỉnh bắt đầu vào hàng đợi d[u] = 0; // cho đỉnh bắt đầu khoảng cách là 0 while (pq.size()) { // khi còn đỉnh để xét u = pq.top().second; // lấy ra đỉnh u để xét tiếp các cạnh kế tiếp du = pq.top().first; // lấy ra trọng số cạnh hiện tại pq.pop(); // xóa ra khỏi hàng đợi ưu tiên if (du != d[u]) continue; // nếu đã xét từ trước thì không cần xét tiếp totalWeight += du; // lấy cạnh thêm vào tăng đúng 1 lượng trọng số cạnh d[u] = 0; // xóa đỉnh u bằng cách gán trọng số bằng 0 for (int i = 0; i < (int) adj[u].size(); i++) { v = adj[u][i].second; // xét đỉnh kế tiếp uv = adj[u][i].first; // xét cạnh kề if (d[v] > uv) { // nếu cạnh trước đó gán cho v chưa phải cạnh nhỏ nhất d[v] = uv; // cập nhật cạnh nhỏ nhất (u, v) cho tập cạnh pq.push({d[v], v}); // đưa vào hàng đợi để xét tiếp } } } return totalWeight; // trả về tổng số cạnh } ``` ![](https://hackmd.io/_uploads/HysiPceun.gif)