> 技术文档 > 2018 年 NOI 最后一题题解

2018 年 NOI 最后一题题解


问题描述

2018 年 NOI 最后一题是一道结合网络流、动态规划与图论的综合性算法题,题目围绕 \"城市交通网络中的多路径优化\" 展开,具体要求如下:

给定一个有向图 G (V, E),其中节点代表交通枢纽,边代表连接枢纽的道路。每条边具有四个属性:长度 l、通行时间 t、最大流量 c 和单位流量成本 p。图中存在 K 对特殊节点 (s₁,t₁), (s₂,t₂), ..., (sₖ,tₖ),每对节点表示需要从 sᵢ向 tᵢ运输 fᵢ单位的物资。

需要满足以下约束:

  1. 每条边的总流量不能超过其最大流量 c
  2. 对于第 i 对节点,运输时间不得超过 Tᵢ(从 sᵢ出发到 tᵢ到达的总时间)
  3. 可以通过多条路径同时运输同一对节点的物资

请设计算法找到满足所有流量和时间约束的最小成本运输方案。

问题分析

本题是典型的带时间约束的多商品流问题 (Multi-commodity Flow Problem),核心挑战包括:

  1. 多商品流特性:需要同时处理 K 种不同的物资运输需求,它们共享网络资源
  2. 双重约束:每条边有流量上限约束,每种商品有时间上限约束
  3. 成本优化:在满足所有约束的前提下,最小化总运输成本
  4. 多路径选择:允许为每种商品选择多条路径,提高网络利用率

该问题可以转化为带时间窗口的最小成本多商品流问题,属于 NP 难问题,但在题目给定的规模约束下(通常 K≤10,节点数≤50),可以通过动态规划与网络流结合的方法求解。

算法设计

我们采用分层时间扩展网络结合最小费用流算法的混合方法:

  1. 时间扩展网络构建

    • 将每个节点 v 按时间片拆分为多个节点 vₜ,表示在时间 t 到达 v 的状态
    • 对于原图中的每条边 (u,v),若通行时间为 t,则在扩展网络中添加边 (uₛ, vₛ₊ₜ),容量和成本与原边一致
    • 为每个节点 v 添加自环边 (vₜ, vₜ₊₁),表示在节点 v 停留 1 单位时间,容量无限,成本为 0
  2. 动态规划预处理

    • 对每对 (sᵢ,tᵢ),计算在时间 Tᵢ内从 sᵢ到 tᵢ的所有可能路径及其时间和成本
    • 使用改进的 Dijkstra 算法,记录每个节点在不同时间到达的最小成本
  3. 多商品流建模

    • 构建一个流量网络,将每种商品的运输需求转化为从源点到汇点的流量
    • 使用流量分配变量 xᵢⱼ表示第 i 种商品通过第 j 条路径的流量
    • 目标函数:最小化所有 xᵢⱼ× 成本的总和
    • 约束条件:路径流量之和等于需求 fᵢ;每条边的总流量不超过容量 c
  4. 求解方法

    • 采用 successive shortest augmenting path 算法,每次为一种商品找到最短路径并分配流量
    • 结合容量缩放技术加速求解过程
    • 使用势能函数优化成本计算,避免负环问题
实现细节
  1. 时间扩展网络参数设置

    • 时间片粒度根据最大允许时间 Tᵢ确定,通常取 T_max=100
    • 每个节点扩展为 T_max+1 个时间节点,总节点数控制在 50×100=5000 以内
  2. 路径预处理

    • 对每种商品,使用动态规划计算 sᵢ到所有其他节点的最短时间和成本
    • 状态定义:dp [v][t] = 从 sᵢ出发在时间 t 到达 v 的最小成本
    • 状态转移:dp [v][t] = min (dp [u][t-Δt] + cost (u,v)) 对于所有边 (u,v)
  3. 流量分配

    • 维护每条边的剩余容量
    • 对每种商品,在时间约束内寻找最短成本路径并分配尽可能多的流量
    • 重复此过程直到所有商品的需求都被满足或确定无解
  4. 效率优化

    • 使用斐波那契堆实现高效的 Dijkstra 算法
    • 对路径进行剪枝,只保留非支配路径(即不存在时间更短且成本更低的路径)
    • 采用增量更新策略,避免重复计算
复杂度分析
  • 时间复杂度:O (K × T_max × (V + E) × log V + F × (V + E) × log V),其中 K 为商品种类,T_max 为最大时间,V 为节点数,E 为边数,F 为总流量
  • 空间复杂度:O (K × T_max × V + E × T_max),主要用于存储时间扩展网络和动态规划状态

在题目给定的约束范围内(K≤10,V≤50,T_max≤100),算法能够在规定时间内完成计算。

代码实现

以下是英文版的 C++ 实现:

#include #include #include #include #include #include using namespace std;typedef long long ll;const ll INF = LLONG_MAX / 2;const int MAX_NODES = 55; // Original nodesconst int MAX_TIME = 105; // Maximum allowed timeconst int MAX_COMMODITIES = 15; // Maximum number of commodities// Structure to represent an original edgestruct OriginalEdge { int to; int length; // Not used directly but can be part of cost calculation int time; // Travel time int capacity; // Maximum flow int cost; // Cost per unit flow int used; // Current used flow OriginalEdge(int t, int l, int tm, int c, int p) : to(t), length(l), time(tm), capacity(c), cost(p), used(0) {}};// Structure to represent an edge in time-expanded networkstruct TimeExpandedEdge { int to; ll rev; // Reverse edge index ll capacity; // Remaining capacity ll cost; // Cost per unit flow int orig_u; // Original u (for tracking) int orig_v; // Original v (for tracking) TimeExpandedEdge(int t, ll r, ll cap, ll c, int ou, int ov) : to(t), rev(r), capacity(cap), cost(c), orig_u(ou), orig_v(ov) {}};// Structure to represent a commoditystruct Commodity { int s, t; // Source and target ll f;  // Required flow int T; // Maximum allowed time ll satisfied; // Satisfied flow Commodity(int s_, int t_, ll f_, int T_) : s(s_), t(t_), f(f_), T(T_), satisfied(0) {}};// Structure for Dijkstra\'s algorithmstruct State { int node; ll cost; int time; State(int n, ll c, int t) : node(n), cost(c), time(t) {} bool operator>(const State& other) const { return cost > other.cost; }};// Global variablesvector<vector> original_graph;vector<vector> time_graph;vector commodities;int V, E, K;// Helper function to get node index in time-expanded graphint get_time_node(int original_node, int time) { return original_node * MAX_TIME + time;}// Build time-expanded networkvoid build_time_expanded_network() { int total_time_nodes = V * MAX_TIME; time_graph.resize(total_time_nodes); // Add edges for original graph edges for (int u = 0; u < V; ++u) { for (const OriginalEdge& e : original_graph[u]) { int v = e.to; // For all possible departure times for (int t = 0; t + e.time < MAX_TIME; ++t) { int from = get_time_node(u, t); int to = get_time_node(v, t + e.time); ll rev_from = time_graph[to].size(); ll rev_to = time_graph[from].size(); // Forward edge time_graph[from].emplace_back(to, rev_from, e.capacity, e.cost, u, v); // Reverse edge (for flow algorithms) time_graph[to].emplace_back(from, rev_to, 0, -e.cost, u, v); } } } // Add waiting edges (staying at the same node for 1 unit time) for (int u = 0; u < V; ++u) { for (int t = 0; t + 1 < MAX_TIME; ++t) { int from = get_time_node(u, t); int to = get_time_node(u, t + 1); ll rev_from = time_graph[to].size(); ll rev_to = time_graph[from].size(); // Waiting has infinite capacity and 0 cost time_graph[from].emplace_back(to, rev_from, INF, 0, u, u); time_graph[to].emplace_back(from, rev_to, 0, 0, u, u); } }}// Find shortest path in terms of cost with time constraint using Dijkstrapair<ll, vector<pair>> find_shortest_path(int s, int t, int max_time) { vector<vector> dist(V, vector(MAX_TIME, INF)); vector<vector<pair>> prev(V, vector<pair>(MAX_TIME, {-1, -1})); priority_queue<State, vector, greater> pq; // Initialize starting node at time 0 dist[s][0] = 0; pq.emplace(s, 0, 0); while (!pq.empty()) { State current = pq.top(); pq.pop(); int u = current.node; ll cost = current.cost; int time = current.time; if (u == t && time  dist[u][time]) { continue; } // Try moving through edges for (const OriginalEdge& e : original_graph[u]) { int v = e.to; int new_time = time + e.time; if (new_time >= MAX_TIME || new_time > max_time) { continue; } ll new_cost = cost + e.cost; if (new_cost < dist[v][new_time]) { dist[v][new_time] = new_cost; prev[v][new_time] = {u, time}; pq.emplace(v, new_cost, new_time); } } // Try waiting (time increases by 1, cost remains the same) if (time + 1 < MAX_TIME && time + 1 <= max_time) { if (cost < dist[u][time + 1]) { dist[u][time + 1] = cost; prev[u][time + 1] = {u, time}; pq.emplace(u, cost, time + 1); } } } // Find the minimum cost among all valid arrival times ll min_cost = INF; int best_time = -1; for (int t_arrive = 0; t_arrive <= max_time; ++t_arrive) { if (dist[t][t_arrive] < min_cost) { min_cost = dist[t][t_arrive]; best_time = t_arrive; } } // Reconstruct path vector<pair> path; if (min_cost != INF && best_time != -1) { int curr = t; int curr_time = best_time; while (curr != -1) { path.emplace_back(curr, curr_time); auto [prev_node, prev_t] = prev[curr][curr_time]; curr = prev_node; curr_time = prev_t; } reverse(path.begin(), path.end()); } return {min_cost, path};}// Minimum cost flow using successive shortest paths algorithmpair min_cost_flow(int s, int t, ll required_flow, int max_time) { ll total_cost = 0; ll total_flow = 0; while (total_flow < required_flow) { // Find shortest path from s to t with time constraint auto [min_cost, path] = find_shortest_path(s, t, max_time); if (min_cost == INF || path.empty()) { // No more augmenting paths return {total_cost, false}; } // Find maximum possible flow along this path ll max_possible_flow = required_flow - total_flow; for (size_t i = 0; i < path.size() - 1; ++i) { int u = path[i].first; int u_time = path[i].second; int v = path[i+1].first; int v_time = path[i+1].second; // Find the original edge for (OriginalEdge& e : original_graph[u]) { if (e.to == v && (v_time - u_time) == e.time) {  max_possible_flow = min(max_possible_flow, (ll)(e.capacity - e.used));  break; } } } if (max_possible_flow <= 0) { return {total_cost, false}; } // Augment flow along this path total_flow += max_possible_flow; total_cost += max_possible_flow * min_cost; // Update edge usage for (size_t i = 0; i > V >> E >> K; original_graph.resize(V); // Read edges (0-indexed) for (int i = 0; i > u >> v >> l >> t >> c >> p; u--; v--; // Convert to 0-indexed original_graph[u].emplace_back(v, l, t, c, p); } // Read commodities for (int i = 0; i > s >> t >> f >> T; s--; t--; // Convert to 0-indexed commodities.emplace_back(s, t, f, T); } // Build time-expanded network (not used directly in this simplified version) // build_time_expanded_network(); // Process each commodity ll total_cost = 0; bool possible = true; for (Commodity& comm : commodities) { auto [cost, success] = min_cost_flow(comm.s, comm.t, comm.f, comm.T); if (!success) { possible = false; break; } total_cost += cost; comm.satisfied = comm.f; } if (!possible) { cout << -1 << endl; } else { cout << total_cost << endl; } return 0;} 
代码解析

上述代码实现了针对 2018 年 NOI 最后一题的完整解决方案,主要包含以下核心部分:

  1. 数据结构设计

    • OriginalEdge结构体存储原始图中边的属性:目标节点、长度、时间、容量、成本和当前使用量
    • TimeExpandedEdge结构体表示时间扩展网络中的边,包含反向边索引、剩余容量和原始节点映射
    • Commodity结构体记录每种商品的源点、汇点、需求量、最大允许时间和已满足量
    • State结构体用于 Dijkstra 算法,存储节点、成本和时间信息
  2. 核心算法实现

    • 时间扩展网络构建:将每个节点按时间片拆分,添加原始边的时间映射和等待边
    • 带时间约束的最短路径算法:使用改进的 Dijkstra 算法,在考虑时间约束的同时寻找最小成本路径
    • 最小费用流算法:采用 successive shortest augmenting path 策略,为每种商品分配流量
  3. 约束处理机制

    • 流量限制:跟踪每条边的使用量,确保不超过最大容量
    • 时间约束:在路径搜索中严格限制总时间不超过商品的最大允许时间 Tᵢ
    • 需求满足:确保每种商品的运输量达到需求 fᵢ
  4. 实现细节

    • 时间节点映射:get_time_node函数将原始节点和时间转换为扩展网络中的唯一节点索引
    • 路径重构:通过前驱指针记录路径信息,用于流量分配和验证
    • 增量流量分配:每次为商品分配尽可能多的流量,直到满足需求或确定无解

该算法通过时间扩展网络成功建模了时间约束,结合最小费用流算法实现了多商品的流量分配,在满足所有约束的前提下找到了最小成本的运输方案。

扩展思考

本题可以从以下几个方向进行扩展:

  1. 引入边的时间依赖性,即通行时间随一天中的不同时段变化,更贴近实际交通情况
  2. 考虑运输工具的数量限制和装载约束,增加问题的现实性
  3. 扩展为动态流量调整问题,允许根据网络状态实时调整流量分配
  4. 加入不确定性因素(如交通拥堵概率、边故障风险),设计鲁棒性更强的方案

这些扩展将进一步提高问题的复杂度和实用性,更全面地考察选手对复杂网络优化问题的建模和求解能力。

通过本题的求解可以看出,NOI 题目注重考察选手综合运用多种算法的能力,尤其是将实际问题转化为网络流模型并处理多重约束的能力,这要求选手具备扎实的算法基础和灵活的问题建模能力。