1 条题解
-
0
题目分析
题意理解
我们有一棵树结构,每个节点 i 的父节点是 ⌊i/k⌋。需要给每个节点分配一个难度值,满足:
- 树约束:每个节点的难度 ≥ 其父节点的难度
- 字典序最大:在满足条件1的情况下,使得序列的字典序最大
关键观察
- 树结构:这实际上是一个完全k叉树的结构
- 贪心策略:为了得到字典序最大的解,我们应该从前往后为每个位置分配尽可能大的值
- 预留空间:给某个节点分配值时,需要为其子树预留足够的位置
算法思路
核心思想:线段树 + 贪心
1. 预处理
- 排序难度数组
- 计算每个节点的子树大小
- 处理相同难度的计数(用于去重)
2. 线段树维护
线段树
tr[u]维护:从位置i开始往右,还能选择多少个值(考虑子树大小约束)初始时:
tr[i] = n - i + 1,表示位置 i 及右边总共有多少个空位3. 贪心过程
对于每个位置 i(从1到n):
- 如果父节点还没有处理,先处理父节点的约束
- 在线段树上二分查找最左边的满足
tr[x] >= sz[i]的位置 - 选择该位置对应的难度值
- 更新线段树:为节点 i 的子树预留空间
代码详解
#include <bits/stdc++.h> using namespace std; #define ll long long #define pb push_back #define ls u*2 #define rs u*2+1 #define mid (l+r)/2 const int N = 5e5 + 5; int n, d[N], fa[N], sz[N], cnt[N], ans[N]; double k; bool vis[N]; // 线段树维护可用位置 int tr[4 * N], lz[4 * N]; // 更新节点 void upd(int u) { tr[u] = min(tr[ls], tr[rs]); } // 懒标记下传 void tag(int u, int k) { tr[u] += k, lz[u] += k; } void pd(int u) { if (lz[u]) { tag(ls, lz[u]), tag(rs, lz[u]); lz[u] = 0; } } // 建树:初始每个位置i的可用数量为n-i+1 void build(int l, int r, int u) { if (l == r) { tr[u] = n - l + 1; return; } build(l, mid, ls), build(mid + 1, r, rs); upd(u); } // 区间加 void add(int l, int r, int u, int L, int R, int k) { if (l >= L && r <= R) { tag(u, k); return; } pd(u); if (L <= mid) add(l, mid, ls, L, R, k); if (R > mid) add(mid + 1, r, rs, L, R, k); upd(u); } // 二分查找:找到第一个tr[x] < k的位置 int find(int l, int r, int u, int k) { if (tr[u] >= k) return -1; // 没有满足的位置 if (l == r) return l; // 找到目标位置 pd(u); // 优先在左子树查找 if (tr[ls] < k) return find(l, mid, ls, k); else return find(mid + 1, r, rs, k); } int main() { scanf("%d%lf", &n, &k); // 读入并排序难度值 for (int i = 1; i <= n; i++) scanf("%d", &d[i]); sort(d + 1, d + n + 1); // 预处理相同值的计数 for (int i = 2; i <= n; i++) if (d[i] == d[i - 1]) cnt[i] = cnt[i - 1] + 1; // 构建树结构 for (int i = 1; i <= n; i++) fa[i] = i / k; // 父节点编号 // 计算每个节点的子树大小(自底向上) for (int i = n; i >= 1; i--) { sz[i]++; sz[fa[i]] += sz[i]; } // 初始化线段树 build(1, n, 1); // 贪心分配难度值 for (int i = 1; i <= n; i++) { // 处理父节点的约束:为父节点的子树释放空间 if (fa[i] && !vis[fa[i]]) { vis[fa[i]] = 1; // 父节点已经确定,释放多余的预留空间 add(1, n, 1, 1, ans[fa[i]], sz[fa[i]] - 1); } // 二分查找最左边的满足条件的位置 int x = find(1, n, 1, sz[i]); if (x == -1) x = n; // 如果没有找到,用最后一个位置 else x--; // 找到的是第一个不满足的位置,前一个位置满足 // 处理相同值:选择最右边的相同值 x -= cnt[x]; ans[i] = x + cnt[x]; cnt[x]++; // 为当前节点的子树预留空间 add(1, n, 1, 1, ans[i], -sz[i]); } // 输出结果 for (int i = 1; i <= n; i++) printf("%d ", d[ans[i]]); return 0; }算法核心要点详解
1. 线段树的作用
线段树
tr[i]表示:从位置 i 开始往右,考虑所有已分配的子树约束后,还能选择多少个值。关键理解:
- 初始:
tr[i] = n - i + 1(位置i右边总共有这么多空位) - 当为节点分配位置 x 时,需要为其大小为 sz 的子树预留空间:
- 对区间 [1, x] 执行
-sz操作 - 表示这些位置左边多了一个大小为 sz 的子树
- 对区间 [1, x] 执行
2. 二分查找的逻辑
find(1, n, 1, sz[i])寻找第一个tr[x] < sz[i]的位置 x。为什么这样找:
- 我们要找最左边的满足
tr[pos] >= sz[i]的位置 - 即:从该位置开始往右,至少有 sz[i] 个可用位置
- 这样能保证为子树预留足够空间
3. 相同值的处理
x -= cnt[x]; ans[i] = x + cnt[x]; cnt[x]++;这段代码确保相同难度值时,我们选择最右边的相同值,这是为了字典序最大。
4. 父节点处理
当处理到节点 i 时,如果其父节点还没有被标记处理:
- 释放父节点多余的预留空间
- 因为父节点已经确定,其子树大小约束可以放宽
举例说明
以样例为例:
输入: n=4, k=2.0, d=[114,514,1919,810] 排序后: d=[114,514,810,1919] 树结构: 1(父:0) 2(父:1) 3(父:1) 4(父:2)分配过程:
- 节点1:选择最右边的满足条件的位置 → 114
- 节点2:在剩余位置中选择 → 810
- 节点3:选择 → 514
- 节点4:选择 → 1919
结果:
114 810 514 1919复杂度分析
- 排序:O(n log n)
- 线段树操作:每次 O(log n)
- 总复杂度:O(n log n)
总结
这道题的巧妙之处在于:
- 逆向思维:用线段树维护"剩余可用位置"而非直接分配
- 贪心策略:在满足子树约束的前提下尽可能选择大的值
- 相同值处理:通过计数确保字典序最大
- 树形约束:通过子树大小来预留空间
是一个很好的数据结构与贪心思想结合的题目。
- 1
信息
- ID
- 5093
- 时间
- 1000ms
- 内存
- 256MiB
- 难度
- 10
- 标签
- 递交数
- 1
- 已通过
- 1
- 上传者