Lost Cows(0ms)——详细解析
Author:RedamancyXun
题干
描述
N(2 <= N <= 8,000) cows have unique brands in the range (1…N). In a spectacular display of poor judgment, they visited the neighborhood‘watering hole’and drank a few too many beers before dinner. When it was time to line up for their evening meal, they did not line up in the required ascending numerical order of their brands.
Regrettably, FJ does not have a way to sort them. Furthermore, he’s not very good at observing problems. Instead of writing down each cow’s brand, he determined a rather silly statistic: For each cow in line, he knows the number of cows that precede that cow in line that do, in fact, have smaller brands than that cow.
Given this data, tell FJ the exact ordering of the cows.
输入描述
-
Line 1: A single integer, N.
-
Lines 2…N: These N-1 lines describe the number of cows that precede a given cow in line and have brands smaller than that cow. Of course, no cows precede the first cow in line, so she is not listed. Line 2 of the input describes the number of preceding cows whose brands are smaller than the cow in slot #2; line 3 describes the number of preceding cows whose brands are smaller than the cow in slot #3; and so on.
输出描述
- Lines 1…N: Each of the N lines of output tells the brand of a cow in line. Line #1 of the output tells the brand of the first cow in line; line 2 tells the brand of the second cow; and so on.
用例输入 | 用例输出 |
---|---|
5 | 2 |
1 | 4 |
2 | 5 |
1 | 3 |
0 | 1 |
一、利用线段数求解
重要知识点:线段数
- 引用知乎线段树例题(1)——Difficult Lost Cows 作者:RandomWalk
- 引用知乎算法学习笔记(14): 线段树 作者:Pecco
线段树基本定义:
线段树是一棵二叉树。顾名思义,二叉树的每一个节点都代表了一个线段。线段树的根节点代表最大的线段,每个节点的左儿子是这个线段的前一半,右儿子是线段的后一半,叶子结点是单个点。在每一个节点中,都记录了这个区间的一些相关信息。
假如一个节点是[left, right],那么它的左儿子是[left, (left + right) / 2],右儿子是
[(left + right) / 2 + 1, right]。注意到,线段树的每个节点,要么是有两个儿子节点,要么就是叶子结点(不存在只有左儿子或只有右儿子的情况)。
例如下图就是一个简单的线段树:
线段树作用:
线段树(Segment Tree)几乎是算法竞赛最常用的数据结构了,它主要用于维护区间信息(要求满足结合律)。与树状数组相比,它可以实现O(log n)的区间修改,还可以同时支持多种操作(加、乘),更具通用性。
线段树的功能概括来说就是——区间更新,区间查询。
当我们需要更新一个区间的值,或者查询一个区间的值时,我们只需要找到那个区间,进行更新或查询即可。举一个具体的例子:比如说,在一个操作序列中,我们需要经常将一个区间的数都加上n,并且查询某个区间的数的和。
但是,并不是每个区间都在线段树上有对应的节点。幸运的是,我们可以将任意一个区间分解为线段树上的若干个节点。比如说,在上边的那个线段树中,我们要将区间[3, 8]分解为一系列节点,可以发现它是3 + [4, 5] + [6, 8](当然,你可以说他也可以分解为3, 4,5, 6, 7, 8,但是当我们得到一个比较大的区间后,就没有必要把它再往下分了)。
有了区间分解,我们就可以对任意区间进行区间更新和区间查询。
具体地进行区间分解时,我们看一看这个区间是否跨越了当前线段的中点。如果全部落在左边,那么就进入左子树分解;如果全部落在右边,就进入右子树分解;如果跨越了中点,就要同时进入左子树和右子树。当要被分解的区间正好等于当前区间时,递归就终止了。
线段树复杂度:
一个长度为n的区间,叶子结点一共有n个,总结点个数为2n - 1,空间复杂度为O(n)。
建树时,先建立一个根节点,然后递归的建立左子树和右子树,建树的时间复杂度和节点个数相同,也是O(n)。
进行区间分解操作时,一次操作要么进入左子树,要么进入右子树。而最后的遍历次数,最坏的情况也是树高度。这个条件就限制住了区间分解操作的时间。可以证明,区间分解的时间复杂度是O(log n)。
如此好的时间复杂度,就使得线段树有着广泛的应用。
线段树建立:
线段树是一棵平衡二叉树。母结点代表整个区间的和,越往下区间越小。注意,线段树的每个节点都对应一条线段(区间),但并不保证所有的线段(区间)都是线段树的节点,这两者应当区分开。
如果有一个数组[1,2,3,4,5],那么它对应的线段树大概长这个样子:
每个节点p的左右子节点的编号分别为2p和2p + 1,假如节点p储存区间[a, b]的和,设
mid = (l + r) / 2,那么两个子节点分别储存[l, mid]和[mid + 1, r]的和。可以发现,左节点对应的区间长度,与右节点相同或者比之恰好多1。
如何从数组建立一棵线段树?我们可以考虑递归地进行:
void build(int l = 1, int r = n, int p = 1)
{
if (l == r) // 到达叶子节点
tree[p] = A[l]; // 用数组中的数据赋值
else
{
int mid = (l + r) / 2;
build(l, mid, p * 2); // 先建立左子节点
build(mid + 1, r, p * 2 + 1); // 再建立右子节点
tree[p] = tree[p * 2] + tree[p * 2 + 1]; // 该节点的值等于左右子节点之和
}
}
最后我们可以得到tree[] = {*, 15, 6, 9, 3, 3, 4, 5, 1, 2}。
线段数区间更新:
在讲区间修改前,要先引入一个“懒标记”(或延迟标记)的概念。懒标记是线段树的精髓所在。对于区间修改,朴素的想法是用递归的方式一层层修改(类似于线段树的建立),但这样的时间复杂度比较高。使用懒标记后,对于那些正好是线段树节点的区间,我们不继续递归下去,而是打上一个标记,将来要用到它的子区间的时候,再向下传递。
代码比较复杂,我们慢慢解释:
void update(int l, int r, int d, int p = 1, int cl = 1, int cr = n)
{
if (cl > r || cr < l) // 区间无交集
return; // 剪枝
else if (cl >= l && cr <= r) { // 当前节点对应的区间包含在目标区间中
tree[p] += (cr - cl + 1) * d; // 更新当前区间的值
if (cr > cl) // 如果不是叶子节点
mark[p] += d; // 给当前区间打上标记
}
else // 与目标区间有交集,但不包含于其中
{
int mid = (cl + cr) / 2;
mark[p * 2] += mark[p]; // 标记向下传递
mark[p * 2 + 1] += mark[p];
tree[p * 2] += mark[p] * (mid - cl + 1); // 往下更新一层
tree[p * 2 + 1] += mark[p] * (cr - mid);
mark[p] = 0; // 清除标记
update(l, r, d, p * 2, cl, mid); // 递归地往下寻找
update(l, r, d, p * 2 + 1, mid + 1, cr);
tree[p] = tree[p * 2] + tree[p * 2 + 1]; // 根据子节点更新当前节点的值
}
}
更新时,我们是从最大的区间开始,递归向下处理。注意到,任何区间都是线段树上某些节点的并集。于是我们记目标区间为[l, r],当前区间为[cl, cr],当前节点为p,我们会遇到三种情况:
- 当前区间与目标区间没有交集:
这时直接结束递归。
- 当前区间被包括在目标区间里:
这时可以更新当前区间,别忘了乘上区间长度:
tree[p] += (cr - cl + 1) * d;
然后打上懒标记(叶子节点可以不打标记,因为不会再向下传递了):
mark[p] += d;
这个标记表示“该区间上每一个点都要加上d”。因为原来可能存在标记,所以是+=而不是=。
- 当前区间与目标区间相交,但不包含于其中:
这时把当前区间一分为二,分别进行处理。如果存在懒标记,要先把懒标记传递给子节点(注意也是+=,因为原来可能存在懒标记):
int mid = (cl + cr) / 2;
mark[p * 2] += mark[p];
mark[p * 2 + 1] += mark[p];
两个子节点的值也就需要相应的更新(后面乘的是区间长度):
tree[p * 2] += mark[p] * (mid - cl + 1);
tree[p * 2 + 1] += mark[p] * (cr - mid);
不要忘记清除该节点的懒标记:
mark[p] = 0;
这个过程并不是递归的,我们只往下传递一层(所以叫“懒”标记啊!),以后要用再才继续传递。其实我们常常把这个传递过程封装成一个函数:
void push_down(int p, int len)
{
mark[p * 2] += mark[p];
mark[p * 2 + 1] += mark[p];
tree[p * 2] += mark[p] * (len - len / 2);
tree[p * 2 + 1] += mark[p] * (len / 2); // 右边的区间可能要短一点
mark[p] = 0;
}
然后在update函数中这样调用:
push_down(p, cr - cl + 1);
传递完标记后,再递归地去处理左右两个子节点。
至于单点修改,只需要令左右端点相等即可。
线段树区间查询:
有了区间修改的经验,区间查询的方法完全类似,直接上代码了:
int query(int l, int r, int p = 1, int cl = 1, int cr = n)
{
if (cl > r || cr < l)
return 0;
else if (cl >= l && cr <= r)
return tree[p];
else
{
ll mid = (cl + cr) / 2;
push_down(p, cr - cl + 1);
return query(l, r, p * 2, cl, mid) + query(l, r, p * 2 + 1, mid + 1, cr);
// 上一行拆成三行写就和区间修改格式一致了
}
}
一样的递归,一样自顶至底地寻找,一样的合并信息。
Lost Cows详解:
题目理解:
先抽象出数学模型:现在有1, 2, 3, …, n共n个数的排列,我不知道这个排列长什么样,我只知道对于每个数,排在他之前比他小的数有多少个。比如对于排列2, 4, 5, 3, 1,从第二个数开始(因为第一个数没有意义),在他之前比他小的数分别为1, 2, 1, 0个。现在知道后一个序列,让我们求出原始的排列。数据范围: 2 ≤ n ≤ 8000。
思路分析:
这个数据范围还是很大的,而我们追求运行时间控制在0ms,因此程序的时间复杂度一定不能很高,O(n²)显然已经超了。
我们先考虑让我们自己手算的话该怎么做。现在有一个序列1, 1, 3, 0,我们肯定是从最后一个数0开始,因为我们知道他前边没有一个数比他小。那他肯定就是最小的数1了。现在1被排除掉,只剩2, 3, 4, 5。再看倒数第二个数3,他前边有三个数比他小,那他肯定就是最大的数5。于是,我们发现了这道题的规律。就是我们要从后往前找,假如最后一个数是k,那么我现在就是从1, 2, 3, …, n中选择第k + 1小的数放到最后一个,然后把这个数踢出去。再看倒数第二个数i,现在就是在剩余的数中,选择第i + 1小的数放到倒数第二位……一直继续下去,直到第一个数。[1]
于是问题就变成了,怎么在一系列数中选择第k小的数。我们很自然地想到了快速选择算法select,这个算法类似于快排,是以分治为基础的,他的时间复杂度是O(n)的。这里要选择n次,时间复杂度就变成O(n²)了,这还不算一些删除数据的时间,太慢,显然不行。
如果我们维护一个实时排序的数组呢?将一个数二分插入其中需要O(log n)时间,整个数组的构建需要(nlog n),看上去还很好,后边选择第k小的数只要在常数时间就可完成。但问题是,现在我们还需要删除数据。如果维护一个数组,删除元素最坏的时间复杂度就是O(n)了。这样整个的时间复杂度还是O(n²),不太行。
那么如何利用线段树来做题呢?问题的关键在于我们要维护一个怎样的线段树,线段树的节点需要记录哪些信息。
现在是一个1到n的排列,我们很容易想到建立一个以[1, n]为根的线段树。那么每个节点记录什么信息呢?我们要做的操作就是删除其中的一些数。那么我们只要记录下每一个区间被删了几个数,整个数组的情况就清楚了。
假设我们要删除一个节点,就把它叶子节点的值设为0,及其所有祖先的值都减掉1,时间复杂度O(log n)。
假设我们现在要找剩下的数中第k小的数。我们要检查这个k是在左子树还是右子树,然后进入相应的子树查找即可,时间复杂度O(log n)。
因此,整个操作的时间复杂度就是O(nlog n)的。对于这个数据规模可以了。
代码实现:
#include <cstdio>
struct Tree
{
int L, R;
struct Tree *Left, *Right;
int n; //剩余数个数
int Mid()
{
return (L + R) >> 1;
}
};
int nodeCount = 0;
const int N = 100010;
Tree LineTree[2 * N]; //线段树
int brank[N]; //记录最后的排列
int front[N]; //记录初始序列
void buildTree(Tree* root, int L, int R);
int kthSmallAndDelete(int k, Tree* root);
int main()
{
int n;
scanf("%d", &n);
for (int i = 2; i <= n; i++)
scanf("%d", &front[i]);
buildTree(LineTree, 1, n);
for (int i = n; i > 1; i--)
brank[i] = kthSmallAndDelete(front[i] + 1, LineTree);
brank[1] = kthSmallAndDelete(1, LineTree);
for (int i = 1; i <= n; i++)
printf("%d\n", brank[i]);
return 0;
}
void buildTree(Tree* root, int L, int R)//建树函数
{
root->L = L;
root->R = R;
root->n = R - L + 1;
if (L == R)
return;
root->Left = &LineTree[++nodeCount];
root->Right = &LineTree[++nodeCount];
buildTree(root->Left, L, root->Mid());
buildTree(root->Right, root->Mid() + 1, R);
}
int kthSmallAndDelete(int k, Tree* root)//找到第k小的数并删除
{
root->n--;
if (root->L == root->R)
return root->L;
if (k <= root->Left->n) //若第k小的数在左边,就往左子树找
return kthSmallAndDelete(k, root->Left);
else //若第k小的数在左边,往右子树找
return kthSmallAndDelete(k - root->Left->n, root->Right);
}
二、利用树状数组求解:
重要知识点:树状数组
引用知乎算法学习笔记(2): 树状数组 作者:Pecco
树状数组引入:
树状数组(Binary Index Tree, BIT)也是很多OIer心中最简洁优美的数据结构之一。最简单的树状数组支持两种操作,时间复杂度均为O(log n):
- 单点修改:更改数组中一个元素的值
- 区间查询:查询一个区间内所有元素的和
对于普通数组而言,单点修改的时间复杂度是O(1),但区间求和的时间复杂度是O(n)。
当然,我们也可以用前缀和的方法维护这个数组,这样的话区间求和的时间复杂度就降到了O(1),但是单点修改会影响后面所有的元素,时间复杂度是O(n)。
程序最后跑多长时间,是由最慢的一环决定的,因此现在我们希望找到这样一种折中的方法:无论单点修改还是区间查询,它都能不那么慢地完成。
注意到对[a, b]进行区间查询只需查询[1, b]和[1, a)然后相减即可(前缀和就是这样进行区间查询的),所以我们可以把区间查询问题转化为求前n项和的问题。
关于数组的维护,有个很自然的想法:可以用一个数组C维护若干个小区间,单点修改时,只更新包含这一元素的区间;求前n项和时,通过将区间进行组合,得到从1到n的区间,然后对所有用到的区间求和。实际上,设原数组是A,如果Ci维护的区间是[Ai, Ai],此结构就相当于普通数组(还浪费了一倍内存);如果Ci维护的区间就是[1, Ai],此结构就相当于前缀和。
现在我们试图寻找一种结构,一方面,单点修改时需要更新的区间不会太多;另一方面,区间查询时需要用来组合的区间也不会太多。
树状数组就是这样一种结构,它巧妙地利用了二进制(实际上,树状数组的英文名BIT,直译过来就是二进制下标树)。例如11,转化为二进制数就是(1011)2,如果我们要求前11项和,可以分别查询((0000)2, (1000)2]、((1000)2, (1010)2]以及((1010)2, (1011)2]的和再相加。这三个区间怎么来的呢?其实就是不断地去掉二进制数最右边的一个1的过程(如下图)。
我们定义,二进制数最右边的一个1,连带着它之后的0为lowbit(x)(稍后再来看如何实现)。那么我们用Ci维护区间(Ai - lowbit(Ai), Ai],这样显然查询前n项和时需要合并的区间数是少于log2 n的。树状数组的结构大概像下面这样:
那么如何更新呢,大家会发现更新就是一个“爬树”的过程。一路往上更新,直到MAXN(树状数组的容量)。
我们举个例子来看看这树是怎么爬的。 现有二进制数(100110)2,包含它的最小区间当然是((100100)2, (100110)2]。然后,它也肯定位于区间((100000)2, (101000)2]内。然后是((100000)2, (110000)2],再然后是(0, (1000000)2]……
如上图,每一步都把从右边起一系列连续的1变为0,再把这一系列1的前一位0变为1。这看起来像是一个进位的过程对吧?实际上,每一次加的正是lowbit(x)。(神奇吧?)这样,我们更新的区间数不会超过log2 MAXN。一个能以O(log n)时间复杂度进行单点修改和区间查询的数据结构就诞生了。
lowbit()函数实现:
前面已经讲得很详细了,代码实现倒是一件简单的事了。不过我们需要先解决一个问题:lowbit怎么算?如果一位一位验证的话,会形成额外的时间开销。然而,我们有这样神奇的一个公式:
lowbit(x) = (x) & (-x)
为什么可以这样?我们需要知道,计算机里有符号数一般是以补码的形式存储的。-x相当于x按位取反再加1,会把结尾处原来1000…的形式,变成0111…,再变成1000…;而前面每一位都与原来相反。这时我们再把它和x按位与,得到的结果便是lowbit(x)。下面的图中举了两个例子:
树状数组单点修改:
int tree[MAXN];
inline void update(int i, int x)
{
for (int pos = i; pos < MAXN; pos += lowbit(pos))
tree[pos] += x;
}
树状数组求前n项和:
inline int query(int n)
{
int ans = 0;
for (int pos = n; pos; pos -= lowbit(pos))
ans += tree[pos];
return ans;
}
树状数组区间查询:
inline int query(int a, int b)
{
return query(b) - query(a - 1);
}
初始化的时候,我们只需要update每个点的初始值即可
代码实现:(分析同上,这里直接给代码)
#include <cstdio> //虽然我们用的是C++,但是为了让运行更快,还是使用格式化输入输
#include <cstring> 出替代流输入输出
#include <algorithm>
using namespace std;
const int MAXN = 8008; //初始化常量最大储存
int tree[MAXN] = { 0 }; //初始化全局线段树数组
int n; //初始化cows总数
int lowbit(int x); //lowbit相当于求父亲节点和子节点在tree中的位置差值[2]
int sum(int x); //sum相当于对于求出[1, x]的总删去数的数量
void add(int x, int v); //add相当于把删除的节点对应的叶子节点及其所有祖先的值都加上1
int main()
{
while (scanf("%d", &n) != EOF) { //输入n以及避免无输入
/*这里我使用了动态存储的方式定义cows和result,是为了节约内存,
但是相比静态存储,消耗的时间会更多,虽然依旧能达到0ms*/
int* cows = (int*)malloc(n * sizeof(int)); //储存多少cows在前
int* result = (int*)malloc(n * sizeof(int)); //储存序号排序结果
cows[1] = 0; //最前面的cow定义0个cow在其前
for (int i = 2; i <= n; i++)
scanf("%d", &cows[i]);
result[n] = cows[n] + 1; //最后位置的cow序号直接确认
add(cows[n] + 1, 1); //删除节点对应各节点+1
//进行二分查找寻找剩余cows中第cows[i] + 1小的cow序号
for (int i = n - 1; i >= 1; i--) {
int L = 1, R = n;
while (R > L) {
int mid = (R + L + 1) / 2;
//这3个if else语句的顺序换一换 时间就可能是16ms 63ms 0ms等
if (sum(mid - 1) + cows[i] < mid - 1)
R = mid - 1;
else if (sum(mid - 1) + cows[i] == mid - 1)
L = mid;
else
L = mid + 1;
}
result[i] = L; //确认位置序号
add(result[i], 1); //删除节点对应各节点+1
}
for (int i = 1; i <= n; i++)
printf("%d\n", result[i]);
//释放内存
free(cows);
free(result);
}
return 0;
}
int lowbit(int x) {
return x & (-x);
}
int sum(int x) {
int res = 0;
while (x) {
res += tree[x];
x -= lowbit(x);
}
return res;
}
void add(int x, int v) {
while (x <= n) {
tree[x] += v;
x += lowbit(x);
}
}
[1]相应可参考的代码:
#include <stdio.h>
int main()
{
int N;
int sort[8000];
int number[8000];
int result[8000];
scanf("%d", &N);
for (int i = 1; i < N; i++){
scanf("%d", &sort[i]);
number[i] = i + 1;
}
sort[0] = 0;
number[0] = 1;
for (int i = N - 1; i >= 0; i--){
int start = 0;
int count = 0;
if (sort[i] == 0){
while (number[start] == 0)
start++;
}
for (int j = start; count < sort[i]; j++){
if (number[j] > 0)
count++;
}
while (number[j] == 0)
j++;
result[i] = number[j];
number[j] = 0;
}
for (int i = 0; i < N; i++)
printf("%d\n", result[i]);
return 0;
}