Hướng dẫn giải của Khôi phục nông trại


Chỉ dùng lời giải này khi không có ý tưởng, và đừng copy-paste code từ lời giải này. Hãy tôn trọng người ra đề và người viết lời giải.
Nộp một lời giải chính thức trước khi tự giải là một hành động có thể bị ban.

Lời giải này đang bị ẩn cho đến khi bạn chọn mở ra.

Chúng tôi khuyên bạn nên tự thử giải bài trước. Việc mở lời giải có thể làm lộ mất ý tưởng chính trước khi bạn có cơ hội tự giải.

Bạn phải đăng nhập để mở lời giải này.

Đăng nhập

Tác giả: Hiếu Nguyễn, zadertran_, sussyboy, thien_quang_23

Hiểu bài toán

Bài toán yêu cầu tìm tổng độ dài nhỏ nhất của các con đường mới cần xây dựng để tất cả các đồng cỏ (nodes) đều nối thông với nhau. Ban đầu, có N đồng cỏ tại các tọa độ (Xi, Yi) và M con đường cũ vẫn còn tồn tại. Ta có thể xây dựng con đường mới giữa bất kỳ hai đồng cỏ nào với độ dài bằng khoảng cách Euclid giữa chúng. Đây về cơ bản là bài toán tìm cây khung nhỏ nhất (Minimum Spanning Tree - MST) của đồ thị mà trong đó các cạnh đã có sẵn (M con đường cũ) có trọng số bằng 0 (hoặc đã được 'kết nối' sẵn), và các cạnh tiềm năng giữa các đồng cỏ chưa nối có trọng số bằng khoảng cách giữa chúng.

Nói cách khác, ta cần kết nối các node sao cho t tổng độ dài các cạnh mới thêm vào là nhỏ nhất. Ta có thể coi các M con đường cũ là các cạnh có độ dài 0 (đã được thêm vào MST trước).

Các cách tiếp cận

Cách Prim's Algorithm (Tối ưu hóa)
#include <iostream>
#include <vector>
#include <cmath>
#include <iomanip>
#include <limits>

using namespace std;

const double INF = 1e18;

int main() {
    int N, M;
    cin >> N >> M;

    vector<pair<int, int>> coords(N + 1);
    for (int i = 1; i <= N; ++i) {
        cin >> coords[i].first >> coords[i].second;
    }

    // Danh sách kề (adjacency list) cho các cạnh đã cho
    vector<vector<int>> adj(N + 1);
    for (int i = 0; i < M; ++i) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    vector<double> minDist(N + 1, INF);
    vector<bool> visited(N + 1, false);

    // Khởi tạo Prim từ node 1, khoảng cách về 0
    minDist[1] = 0.0;
    double totalLength = 0;
    int nodesAdded = 0;

    // Hàng đợi ưu tiên lưu {khoang_cach, node}
    // Sử dụng min-heap
    priority_queue<pair<double, int>, vector<pair<double, int>>, greater<pair<double, int>>> pq;
    pq.push({0.0, 1});

    while (!pq.empty()) {
        double d = pq.top().first;
        int u = pq.top().second;
        pq.pop();

        if (visited[u]) continue;

        visited[u] = true;
        totalLength += d;
        nodesAdded++;

        // Cập nhật cho các node kề qua đường cũ (độ dài 0)
        for (int v : adj[u]) {
            if (!visited[v] && minDist[v] > 0) {
                minDist[v] = 0;
                pq.push({0.0, v});
            }
        }

        // Cập nhật cho các node còn lại qua đường mới (tính khoảng cách)
        for (int v = 1; v <= N; ++v) {
            if (v == u || visited[v]) continue;

            double dist = sqrt(pow(coords[u].first - coords[v].first, 2) + 
                               pow(coords[u].second - coords[v].second, 2));

            if (dist < minDist[v]) {
                minDist[v] = dist;
                pq.push({dist, v});
            }
        }
    }

    cout << fixed << setprecision(2) << totalLength << endl;

    return 0;
}
  • Time Complexity: O(N^2 log N)
  • Space Complexity: O(N^2)

Đây là cách tiếp cận dựa trên thuật toán Prim. Thay vì xây dựng toàn bộ đồ thị đầy đủ (vốn có O(N^2) cạnh), ta cập nhật khoảng cách đến các node còn lại ngay trong vòng lặp của Prim.

  1. Khởi tạo: Mảng minDist lưu khoảng cách nhỏ nhất từ node đó đến cây khung đang xây dựng. Ban đầu tất cả là vô cùng, node 1 là 0.
  2. Xử lý đường cũ: Dùng danh sách kề để lưu các cặp node đã có đường sẵn. Khi một node u được thêm vào MST, ta duyệt qua các node kề v của u qua đường cũ. Nếu có đường cũ, ta đẩy v vào hàng đợi với khoảng cách 0. Điều này đảm bảo các node nối qua đường cũ được ưu tiên nối trước với giá trị 0.
  3. Xử lý đường mới: Duyệt qua tất cả các node v chưa được thêm vào MST. Tính khoảng cách Euclid từ u đến v. Nếu khoảng cách này nhỏ hơn khoảng cách đang lưu (minDist[v]), ta cập nhật và đẩy vào hàng đợi.
  4. Tổng kết: Khi hàng đợi rỗng, totalLength chính là tổng độ dài các con đường mới cần xây.

Lưu ý: Cách này không tối ưu hoàn toàn về mặt độ phức tạp so với Kruskal nhưng dễ cài đặt và hoạt động tốt với N=1000.

Cách Kruskal với Union Find và Cạnh 0
#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm>
#include <iomanip>

using namespace std;

struct Edge {
    int u, v;
    double w;
    bool isOld;

    bool operator<(const Edge& other) const {
        return w < other.w;
    }
};

vector<int> parent;

int find_set(int v) {
    if (v == parent[v]) return v;
    return parent[v] = find_set(parent[v]);
}

bool union_sets(int a, int b) {
    a = find_set(a);
    b = find_set(b);
    if (a != b) {
        parent[b] = a;
        return true;
    }
    return false;
}

int main() {
    int N, M;
    cin >> N >> M;

    vector<pair<int, int>> coords(N + 1);
    for (int i = 1; i <= N; ++i) {
        cin >> coords[i].first >> coords[i].second;
    }

    vector<Edge> edges;
    // Đọc các đường cũ
    for (int i = 0; i < M; ++i) {
        int u, v;
        cin >> u >> v;
        edges.push_back({u, v, 0.0, true});
    }

    // Tạo các cạnh mới (Full graph)
    for (int i = 1; i <= N; ++i) {
        for (int j = i + 1; j <= N; ++j) {
            double dist = sqrt(pow(coords[i].first - coords[j].first, 2) + 
                               pow(coords[i].second - coords[j].second, 2));
            edges.push_back({i, j, dist, false});
        }
    }

    // Sắp xếp các cạnh theo trọng số tăng dần
    sort(edges.begin(), edges.end());

    // Khởi tạo DSU
    parent.resize(N + 1);
    for (int i = 1; i <= N; ++i) parent[i] = i;

    double totalLength = 0;
    int edgesCount = 0;

    for (const auto& edge : edges) {
        if (union_sets(edge.u, edge.v)) {
            // Chỉ cộng độ dài nếu là đường mới (trọng số > 0)
            // Hoặc đơn giản là cộng edge.w, vì đường cũ w=0
            totalLength += edge.w;
            edgesCount++;
            if (edgesCount == N - 1) break;
        }
    }

    cout << fixed << setprecision(2) << totalLength << endl;

    return 0;
}
  • Time Complexity: O(N^2 log N^2) = O(N^2 log N)
  • Space Complexity: O(N^2)

Đây là cách tiếp cận kinh điển sử dụng thuật toán Kruskal.

  1. Tạo danh sách cạnh:
    • Tạo các cạnh cho M con đường cũ với trọng số là 0.
    • Tạo các cạnh cho mọi cặp node còn lại (i, j) với trọng số là khoảng cách Euclid. Tổng số cạnh là M + N*(N-1)/2.
  2. Sắp xếp: Sắp xếp tất cả các cạnh theo trọng số tăng dần. Do các cạnh cũ có trọng số 0, chúng sẽ được xét đầu tiên.
  3. Kết nối (DSU): Dùng cấu trúc dữ liệu Union-Find (Disjoint Set Union) để quản lý các thành phần liên thông.
    • Duyệt qua các cạnh đã sort.
    • Nếu hai node của cạnh chưa thuộc cùng một thành phần, ta nối chúng lại.
    • Nếu cạnh là đường mới (trọng số > 0), cộng dồn độ dài vào biến kết quả.
  4. Dừng lại: Khi đã có đủ N-1 cạnh, ta dừng lại.

Ưu điểm: Logic rõ ràng, dễ chứng minh độ đúng đắn. Nhược điểm: Phải tạo và sort một lượng lớn cạnh (khoảng 500.000 cạnh với N=1000), tốn bộ nhớ và thời gian sort.

Cách Thuật toán Prim Tối ưu (Khởi tạo với DSU)
// Code tương tự Approach 1 nhưng tối ưu hơn việc kiểm tra visited
// Giảm thiểu việc tính toán lại khoảng cách
#include <iostream>
#include <vector>
#include <cmath>
#include <queue>
#include <iomanip>
#include <algorithm>

using namespace std;

const double INF = 1e18;

int main() {
    int N, M;
    cin >> N >> M;

    vector<pair<int, int>> coords(N + 1);
    for (int i = 1; i <= N; ++i) {
        cin >> coords[i].first >> coords[i].second;
    }

    vector<vector<int>> adj(N + 1);
    for (int i = 0; i < M; ++i) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    vector<double> minDist(N + 1, INF);
    vector<bool> visited(N + 1, false);
    vector<bool> inQueue(N + 1, false); // Đánh dấu node có trong hàng đợi chưa

    priority_queue<pair<double, int>, vector<pair<double, int>>, greater<pair<double, int>>> pq;

    // Bắt đầu từ node 1
    minDist[1] = 0;
    pq.push({0, 1});
    inQueue[1] = true;

    double totalLength = 0;
    int count = 0;

    while (!pq.empty() && count < N) {
        double d = pq.top().first;
        int u = pq.top().second;
        pq.pop();

        if (visited[u]) continue;

        visited[u] = true;
        totalLength += d;
        count++;

        // 1. Xử lý đường cũ: Duyệt kề
        for (int v : adj[u]) {
            if (!visited[v] && minDist[v] > 0) {
                minDist[v] = 0;
                pq.push({0, v});
                inQueue[v] = true;
            }
        }

        // 2. Xử lý đường mới: Cập nhật khoảng cách cho các node còn lại
        for (int v = 1; v <= N; ++v) {
            if (v == u || visited[v]) continue;

            double dist = sqrt(pow(coords[u].first - coords[v].first, 2) + 
                               pow(coords[u].second - coords[v].second, 2));

            if (dist < minDist[v]) {
                minDist[v] = dist;
                pq.push({dist, v});
                inQueue[v] = true;
            }
        }
    }

    cout << fixed << setprecision(2) << totalLength << endl;

    return 0;
}
  • Time Complexity: O(N^2 log N)
  • Space Complexity: O(N^2)

Đây là biến thể của Prim, được tối ưu hóa một chút để tránh các thao tác thừa. Tuy nhiên, về cơ bản độ phức tạp vẫn là O(N^2 log N) do đặc thù bài toán đồ thị đầy đủ (không thể dùng Adjacency List chuẩn).

Cách tiếp cận này tập trung vào việc:

  1. Luôn ưu tiên các node có đường cũ (trọng số 0).
  2. Cập nhật khoảng cách (Relaxation) cho các node còn lại dựa trên node vừa được thêm vào.

Đây là giải pháp tối ưu nhất trong các giải pháp được cung cấp về mặt cân đối giữa độ phức tạp và khả năng cài đặt cho N=1000.

Phân tích độ phức tạp

Cách tiếp cận Time Space Tên
1 O(N^2 log N) O(N^2) Prim's Algorithm (Tối ưu hóa)
2 O(N^2 log N^2) = O(N^2 log N) O(N^2) Kruskal với Union Find và Cạnh 0
3 O(N^2 log N) O(N^2) Thuật toán Prim Tối ưu (Khởi tạo với DSU)

Bài học kinh nghiệm

  • Bài toán quy về tìm cây khung nhỏ nhất (MST) với các cạnh đường cũ có trọng số bằng 0.
  • Với N=1000, đồ thị đầy đủ có ~500,000 cạnh. Các thuật toán MST chuẩn (Kruskal, Prim) đều có thể chạy được nhưng cần tối ưu hóa.
  • Sử dụng hàng đợi ưu tiên (Priority Queue) trong Prim giúp chọn cạnh tiếp theo nhanh hơn so với duyệt toàn bộ.

Lỗi thường gặp

  • Sai số tính toán số thực (floating point error): Cần dùng kiểu double và chú ý đến độ chính xác khi so sánh.
  • Quên xử lý các con đường cũ: Nếu chỉ coi graph là đầy đủ và bỏ qua M đường cũ, kết quả sẽ sai (vì có thể chọn đường mới đắt hơn đường cũ đang free).
  • Lưu trữ quá nhiều cạnh: Nếu cài đặt Kruskal full edge mà không dùng vector struct compact, có thể tràn bộ nhớ stack hoặc tốn nhiều thời gian sort.

Bình luận

Please read the guidelines before commenting.


Không có bình luận tại thời điểm này.