1 条题解
-
0
题解:Restorani
题目分析
问题核心
给定一棵有 (n) 个节点的树(景点),(m) 家餐厅和 (m) 家甜品店(位置互不重复)。Malnar 先生从节点 1 出发,需按“餐厅→甜品店→餐厅→甜品店……”的顺序遍历所有餐厅和甜品店(各遍历一次),最后返回节点 1,求最短总用时及对应的遍历顺序。
关键约束与观察
- 路径特性:树中两点间路径唯一,路径长度为两点间边数(每边用时 1 分钟)。
- 遍历规则:
- 奇数位置必须是餐厅(1~m 的排列),偶数位置必须是甜品店(1~m 的排列)。
- 总用时为路径长度之和(含最后返回节点 1 的路程)。
- 最短路径核心:树的遍历中,重复经过的边是耗时的主要来源。最优策略需最小化重复路径,即利用“子树内餐厅与甜品店配对”减少往返次数。
数据范围挑战
- (n, m \leq 3 \times 10^5),需设计 (O(n + m)) 或 (O(n \log n)) 复杂度的树形 DP 算法,避免暴力枚举。
算法设计
核心思路
- 问题转化:将餐厅和甜品店视为两类“需求点”,树的每个子树需处理内部的需求点配对,未配对的需求点需向上传递到父节点,与其他子树的需求点配对。
- 配对与路径优化:
- 子树内若同时有餐厅和甜品店,优先配对,配对后无需往返父节点(仅需遍历子树一次)。
- 未配对的需求点(仅餐厅或仅甜品店)需传递到父节点,传递过程会产生往返路径(耗时为子树深度×2)。
- 数据结构辅助:用栈(
stek结构体)维护每个节点的未配对餐厅和甜品店,支持高效合并与配对操作;用双向链表维护最终的遍历顺序。
具体步骤
1. 输入处理与初始化
- 读取树的结构、餐厅位置((a_i))、甜品店位置((b_i))。
- 对每个节点 (x),维护
zivi[x][0](该节点的餐厅编号列表)、zivi[x][1](该节点的甜品店编号列表)。 - 初始化计数数组
cnt[x][0](子树 (x) 内未配对的餐厅数)、cnt[x][1](子树 (x) 内未配对的甜品店数)。
2. 树形 DP(DFS 遍历)
按后序遍历处理每个子树,核心逻辑:
- 子树合并:遍历当前节点的所有子节点,递归处理子树后,合并子树的未配对餐厅栈(
A[y])和甜品店栈(B[y])到当前节点的栈(A[x]、B[x])。 - 路径耗时计算:子树 (y) 中未配对的餐厅数与甜品店数的差值决定了往返路径耗时。若差值为 (d),则需往返父节点 (d) 次,耗时为 (2 \times \max(1, |d|))(
max(1, ...)确保至少往返一次,除非子树无需求点)。 - 需求点配对:当前节点的栈中若同时有餐厅和甜品店,持续配对(餐厅→甜品店),配对后的序列通过双向链表链接,减少重复路径。
- 未配对需求点传递:若配对后仍有未配对的餐厅或甜品店,保留在当前节点的栈中,传递到父节点继续配对。
3. 遍历顺序构建
- 用双向链表(
prv、nxt数组)维护配对后的序列,kraj[i]记录编号 (i) 的需求点在链表中的终点。 - 配对时,将餐厅的终点链接到甜品店的起点,形成“餐厅→甜品店”的连续路径。
- 最终链表的起点为
ret.X,按nxt数组遍历即可得到完整的遍历顺序。
4. 结果输出
- 总耗时
sol为所有子树往返耗时之和。 - 遍历双向链表,将餐厅编号(0~m-1)转化为 1~m,甜品店编号(m~2m-1)转化为 1~m,输出遍历顺序。
代码关键模块解析
1. 栈结构(
stek结构体)用于维护未配对的需求点,支持高效合并与弹出操作:
struct stek { int dno, vrh; // dno:栈底,vrh:栈顶 void push(int x) { /* 压栈,维护栈底和栈顶指针 */ } void pop() { /* 弹栈 */ } void ocisti() { /* 清空栈 */ } void merge(stek &tko) { /* 合并另一个栈到当前栈 */ } };- 合并操作(
merge):将另一个栈的所有元素链接到当前栈的栈顶,确保未配对需求点的有序性。
2. 双向链表操作(
link函数)用于构建遍历顺序,记录需求点的前后关联:
inline void link(int A, int B) { nxt[A] = B; // A 的下一个需求点是 B prv[B] = A; // B 的上一个需求点是 A }- 配对时,
link(kraj[a], b)表示餐厅 (a) 的终点链接到甜品店 (b) 的起点,形成连续路径。
3. 配对逻辑(
ubaci_novi函数)将未配对的需求点(或子树传递的配对序列)加入当前节点的栈,参与配对:
void ubaci_novi(stek &A, stek &B, pii novi) { if (A.vrh != -1) { int t = A.vrh; A.pop(); link(novi.Y, t); // 链接新序列的终点到栈顶餐厅 kraj[novi.X] = kraj[t]; // 更新新序列的终点 A.push(novi.X); } else { link(kraj[B.vrh], novi.X); // 链接栈顶甜品店的终点到新序列 kraj[B.vrh] = novi.Y; // 更新栈顶甜品店的终点 } }- 处理子树传递的配对序列(
smece),将其与当前节点的未配对需求点合并,优化路径。
4. 树形 DP 核心(
dfs函数)pii dfs(int x, int lst) { // 初始化当前节点的餐厅和甜品店栈 for (int t : zivi[x][0]) A[x].push(t); for (int t : zivi[x][1]) B[x].push(t); pii smece = {-1, -1}; // 子树传递的配对序列(起点,终点) for (int y : v[x]) { if (y == lst) continue; pii ret = dfs(y, x); // 递归处理子树 // 计算子树往返耗时 if (cnt[y][0] + cnt[y][1]) sol += 2LL * max(1, abs(cnt[y][0] - cnt[y][1])); // 合并子树的未配对计数 cnt[x][0] += cnt[y][0]; cnt[x][1] += cnt[y][1]; // 处理子树传递的配对序列 if (ret.X != -1) { if (smece.X == -1) smece = ret; else link(smece.Y, ret.X), smece.Y = ret.Y; } else { // 合并子树的未配对栈 if (A[y].vrh != -1) A[x].merge(A[y]); else B[x].merge(B[y]); } } // 将传递的配对序列加入当前节点的栈 if (smece.X != -1) ubaci_novi(A[x], B[x], smece); // 持续配对当前节点的餐厅和甜品店 while (A[x].vrh != -1 && B[x].vrh != -1) { int a = A[x].vrh; A[x].pop(); int b = B[x].vrh; B[x].pop(); link(kraj[a], b); // 餐厅a → 甜品店b pii novi = {a, kraj[b]}; if (A[x].vrh == -1 && B[x].vrh == -1) return novi; // 无未配对,返回当前序列 else ubaci_novi(A[x], B[x], novi); // 继续合并配对 } // 更新当前节点的未配对计数 cnt[x][0] = (A[x].vrh != -1); cnt[x][1] = (B[x].vrh != -1); return {-1, -1}; // 有未配对,不传递序列 }- 后序遍历确保子树优先处理,合并未配对需求点并计算耗时。
- 配对过程通过双向链表链接,形成最优遍历顺序,最小化重复路径。
时间复杂度与空间复杂度
- 时间复杂度:(O(n + m))。树形 DFS 遍历所有节点 (O(n)),每个需求点入栈、出栈、配对各一次 (O(m)),栈合并和链表操作均为线性时间。
- 空间复杂度:(O(n + m))。存储树的邻接表、栈结构、计数数组、链表指针,均为线性空间。
关键结论
- 子树配对优化:优先在子树内配对餐厅和甜品店,减少往返父节点的路径,是缩短总耗时的核心。
- 数据结构选型:栈用于高效维护未配对需求点,双向链表用于构建连续遍历顺序,确保算法在大规模数据下的效率。
- 耗时计算逻辑:子树未配对需求点的差值决定往返次数,耗时为 (2 \times \max(1, |d|)),确保覆盖所有必要的往返路径。
- 1
信息
- ID
- 5433
- 时间
- 2000ms
- 内存
- 512MiB
- 难度
- 10
- 标签
- 递交数
- 1
- 已通过
- 1
- 上传者