[HNOI2006] 公路修建问题

GitHub跳转原题关系图返回列表

二分最大允许造价 T。对每个 T,只看二级造价不超过 T 的边是否能连通全图,再看一级造价不超过 T 的边最多能提供多少条一级公路,从而判断可行性。

OJ: luogu

题目 ID: P2323

难度:省选/NOI-

标签:图论二分答案最小生成树并查集贪心

日期: 2026-06-20 01:23

题意

nn 个景点,候选公路一共若干条。

每条候选公路有两种修法:

  • 一级公路,花费 c1c1
  • 二级公路,花费 c2c2

并且 c2<=c1c2 <= c1

你要选出 n1n-1 条公路把所有景点连起来,并且:

  • 其中至少有 kk 条是一级公路
  • 所选公路里,花费最大的那一条尽量小

最后输出这个最小可能的“最大花费”,以及一组对应方案。

思路

先看一个小数据暴力:

cpp
// brute.cpp:小数据枚举所有生成树,再枚举一级/二级公路分配。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 10;
const int MAXM = 20;
const int INF = 1e9;

struct Edge {
    int id;
    int u, v;
    int c1, c2;
} edges[MAXM];

struct AnswerEdge {
    int id;
    int level;
};

int n, need_level1, m_input;
int edge_cnt;
int picked[MAXM];
int fa[MAXN];
int best_cost = INF;
vector<AnswerEdge> best_answer;

void init_dsu() {
    for (int i = 1; i <= n; i++) {
        fa[i] = i;
    }
}

int find_root(int x) {
    if (fa[x] == x) {
        return x;
    }
    fa[x] = find_root(fa[x]);
    return fa[x];
}

bool unite(int x, int y) {
    x = find_root(x);
    y = find_root(y);
    if (x == y) {
        return false;
    }
    fa[x] = y;
    return true;
}

void update_answer(const vector<AnswerEdge> &answer, int cost) {
    vector<AnswerEdge> cur = answer;
    sort(cur.begin(), cur.end(), [](const AnswerEdge &a, const AnswerEdge &b) {
        return a.id < b.id;
    });

    if (cost < best_cost) {
        best_cost = cost;
        best_answer = cur;
    }
}

void enumerate_types(int pos, int cnt_level1, int cur_cost, vector<AnswerEdge> &answer) {
    if (pos > n - 1) {
        if (cnt_level1 >= need_level1) {
            update_answer(answer, cur_cost);
        }
        return;
    }

    Edge &e = edges[picked[pos]];

    answer.push_back({e.id, 1});
    enumerate_types(pos + 1, cnt_level1 + 1, max(cur_cost, e.c1), answer);
    answer.pop_back();

    answer.push_back({e.id, 2});
    enumerate_types(pos + 1, cnt_level1, max(cur_cost, e.c2), answer);
    answer.pop_back();
}

void check_tree(int picked_cnt) {
    if (picked_cnt != n - 1) {
        return;
    }

    init_dsu();
    for (int i = 1; i <= picked_cnt; i++) {
        Edge &e = edges[picked[i]];
        if (!unite(e.u, e.v)) {
            return;
        }
    }

    int root = find_root(1);
    for (int i = 2; i <= n; i++) {
        if (find_root(i) != root) {
            return;
        }
    }

    vector<AnswerEdge> answer;
    enumerate_types(1, 0, 0, answer);
}

void dfs(int pos, int picked_cnt) {
    if (picked_cnt > n - 1) {
        return;
    }
    if (pos > edge_cnt) {
        check_tree(picked_cnt);
        return;
    }
    if (picked_cnt + (edge_cnt - pos + 1) < n - 1) {
        return;
    }

    picked[picked_cnt + 1] = pos;
    dfs(pos + 1, picked_cnt + 1);
    dfs(pos + 1, picked_cnt);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> need_level1 >> m_input;

    edge_cnt = 0;
    int a, b, c1, c2;
    while (cin >> a >> b >> c1 >> c2) {
        edge_cnt++;
        edges[edge_cnt] = {edge_cnt, a, b, c1, c2};
    }

    dfs(1, 0);

    if (best_answer.empty()) {
        cout << -1 << '\n';
        return 0;
    }

    cout << best_cost << '\n';
    for (AnswerEdge e : best_answer) {
        cout << e.id << ' ' << e.level << '\n';
    }

    return 0;
}

暴力直接枚举生成树,再枚举每条边修一级还是二级:

  • 只保留至少有 kk 条一级公路的方案
  • 计算这组方案里的最大边权
  • 取最小值

这个思路按定义完全正确,但显然只适合很小数据。

关键在于:目标不是最小化总花费,而是最小化所选边中的最大花费

这类题的标准做法就是二分答案。

设我们猜测答案是 TT,问题变成:

是否存在一棵生成树,使得每条被选中的边费用都不超过 TT,并且至少有 kk 条一级公路?

对一条边 (u,v,c1,c2) 来说:

  • 如果 c2<=Tc2 <= T,那么它至少可以按二级公路使用
  • 如果 c1<=Tc1 <= T,那么它还可以按一级公路使用

于是把边分成两类:

  1. 可用边c2<=Tc2 <= T
  2. 可作为一级公路的边c1<=Tc1 <= T

先看“能不能连成树”:

  • 只要所有可用边能把全图连通,就至少存在一棵生成树所有边费用都不超过 TT

再看“最多能有多少条一级公路”:

  • 把所有 c1<=Tc1 <= T 的边单独拿出来看,它们会形成若干个连通块
  • 在这些边内部,一棵极大生成森林最多能选出 ncntn - cnt 条一级公路,其中 cnt 是这些点集的连通块个数

这是上界,而且可达到:

  • 先把所有一级可行边尽量选成一片森林
  • 再用普通可用边把这些块接起来

所以对于给定的 TT,可行条件就是:

  1. c2<=Tc2 <= T 的边能连通全图
  2. c1<=Tc1 <= T 的边最多能提供的一级公路数量不少于 kk

这两个条件都能用并查集线性检查,因此整体可以二分 TT

二分出最小可行 TT 后,再构造一组答案:

  1. 先用所有 c1<=Tc1 <= T 的边做一片极大森林,并把这些边都当作一级公路
  2. 再用 c2<=Tc2 <= T 的边把整张图补成生成树,这些边作为二级公路

因为极大森林已经保证一级公路数量尽量多,所以只要 TT 可行,这样构造出来的一定满足“至少 kk 条一级公路”。

代码

cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 10005;
const int MAXM = 20005;

struct Edge {
    int id;
    int u, v;
    int c1, c2;
} edges[MAXM];

struct AnswerEdge {
    int id;
    int level;
};

int n, need_level1, m_input;
int edge_cnt;
int fa1[MAXN], fa2[MAXN], fa_build[MAXN];

void init_dsu(int fa[]) {
    for (int i = 1; i <= n; i++) {
        fa[i] = i;
    }
}

int find_root(int fa[], int x) {
    if (fa[x] == x) {
        return x;
    }
    fa[x] = find_root(fa, fa[x]);
    return fa[x];
}

bool unite(int fa[], int x, int y) {
    x = find_root(fa, x);
    y = find_root(fa, y);
    if (x == y) {
        return false;
    }
    fa[x] = y;
    return true;
}

bool feasible(int limit_cost) {
    init_dsu(fa1);
    init_dsu(fa2);

    int comp_all = n;
    int comp_level1 = n;

    for (int i = 1; i <= edge_cnt; i++) {
        if (edges[i].c2 <= limit_cost && unite(fa1, edges[i].u, edges[i].v)) {
            comp_all--;
        }
        if (edges[i].c1 <= limit_cost && unite(fa2, edges[i].u, edges[i].v)) {
            comp_level1--;
        }
    }

    int max_level1_edges = n - comp_level1;
    return comp_all == 1 && max_level1_edges >= need_level1;
}

vector<AnswerEdge> build_answer(int limit_cost) {
    vector<AnswerEdge> answer;
    init_dsu(fa_build);

    // 先尽量加入所有能加入的一级公路,得到一级公路子图的极大生成森林。
    for (int i = 1; i <= edge_cnt; i++) {
        if (edges[i].c1 > limit_cost) {
            continue;
        }
        if (unite(fa_build, edges[i].u, edges[i].v)) {
            answer.push_back({edges[i].id, 1});
        }
    }

    // 再用二级可行的边把各个一级公路连通块接起来。
    for (int i = 1; i <= edge_cnt; i++) {
        if ((int)answer.size() == n - 1) {
            break;
        }
        if (edges[i].c2 > limit_cost) {
            continue;
        }
        if (unite(fa_build, edges[i].u, edges[i].v)) {
            answer.push_back({edges[i].id, 2});
        }
    }

    sort(answer.begin(), answer.end(), [](const AnswerEdge &a, const AnswerEdge &b) {
        return a.id < b.id;
    });
    return answer;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> need_level1 >> m_input;

    edge_cnt = 0;
    vector<int> values;
    int a, b, c1, c2;
    while (cin >> a >> b >> c1 >> c2) {
        edge_cnt++;
        edges[edge_cnt] = {edge_cnt, a, b, c1, c2};
        values.push_back(c1);
        values.push_back(c2);
    }

    if (edge_cnt == 0) {
        cout << -1 << '\n';
        return 0;
    }

    sort(values.begin(), values.end());
    values.erase(unique(values.begin(), values.end()), values.end());

    if (!feasible(values.back())) {
        cout << -1 << '\n';
        return 0;
    }

    int left = 0;
    int right = (int)values.size() - 1;
    while (left < right) {
        int mid = (left + right) >> 1;
        if (feasible(values[mid])) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }

    int best = values[left];
    vector<AnswerEdge> answer = build_answer(best);

    cout << best << '\n';
    for (AnswerEdge e : answer) {
        cout << e.id << ' ' << e.level << '\n';
    }

    return 0;
}

复杂度

设实际读到的候选公路数为 mm

每次二分检查:

  • 扫一遍边做两个并查集,复杂度 O(mα(n))O(m α(n))

二分次数是 O(logV)O(log V),其中 VV 是费用值域或不同费用个数。

总复杂度可以写成:

O(mlogm+mα(n)logV)O(m log m + m α(n) log V)

空间复杂度 O(n+m)O(n + m)

总结

这题最关键的一步,是把“至少 kk 条一级公路”转化成一个图论上界:在所有一级可行边构成的子图里,最多能放多少条一级公路。这样一来,原题就从一个看起来很难的双重限制,降成了一个标准的二分可行性问题。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析