【学习笔记】数据结构
这边放一些不知道放哪里的数据结构。
1.左偏树(可并堆)
普通堆的合并复杂度为 \(O(n\log m)\),而左偏树能够以 \(O(\log n+\log m)\) 的复杂度完成合并。
当然右偏也是没有一点问题的。
- dist 的定义
对于二叉树,定义外节点为仅有一个儿子的节点,那么外节点 \(i\) 的 \(dis_i=1\),空节点 \(j\) 的 \(dis_j=0\)。
\(dis\) 表示当前点到其子树中深度最浅的外节点的距离加一。
一棵大小为 \(n\) 的二叉树,根节点的 \(dis\) 不超过 \(\left\lceil{\log_{2}(n+1)}\right\rceil\)。
证明:
-
令根节点为 \(r\),易得一棵 \(dis_r=x\) 的二叉树至少有 \(x-1\) 层是满的。
-
那么至少有 \(2^x-1\) 个点。转换一下就是上面的东西了。
- 左偏树
左偏树是一棵二叉树,令左儿子为 \(ls\),右儿子为 \(rs\),则有 \(dis_{ls}\ge dis_{rs}\)。
这个很容易维护,发现左右儿子不平衡交换一下就好。同时父节点的 \(dis\) 也固定是 \(dis_{rs}+1\) 了。
加入一个数可以看做进行一个合并,所以最主要的操作就是如何维护堆的合并了。
合并时首先要保证堆的性质,按小根堆讨论,把更小的当作堆顶,然后把这个根的右儿子和另一个堆合并。然后合并完之后把不满足左偏性质的调一下位置。
因为合并时保证了堆的性质所以正确性是有的。
- 复杂度证明:
因为左偏性质,每一次往下递归都会使 \(dis\) 减一而大小为 \(n\) 的堆堆顶的 \(dis\) 是 \(\log n\),所以复杂度是 \(O(\log n+\log m)\)。
- 删除
删根直接合并它的左右儿子。
删除任意点就把自己的左右儿子合并然后看父节点有没有需要调整(\(dis\) 是不是从右儿子继承的或者是不是满足左偏性质),如果需要调整就往上调。
注意左偏树的深度 没有保证,上面删任意点的复杂度是 \(\log n\) 的原因是每递归一次 \(dis\) 都会减一。
然后左偏树是能够支持 打 tag 的,删根或者合并的时候下传就好。
有一个原理和左偏树差不多的搞法叫随机堆,每次合并的时候用随机数决定要不要换左右儿子,平均下来复杂度差不多也是 \(\log n\) 的,但是感觉没有复杂度确定的左偏树好。
- 可持久化可并堆
或者直接说 k 短路 算了。
用可并堆的复杂度在正边权的情况下是 \(O((n+m)\log m+k\log k)\) 的。负边权的情况下可能还没有 A* 跑得快(?
因为求 k 短路,肯定和最短路要扯上关系,那么先在 反图 上跑一个 dijk 得到每个点 \(i\) 到 \(n\) 的最短路 \(dis_i\),构成最短路树,最短路树就是只保留最短路转移的那些边,容易发现这些边一定构成树结构。
然后我们考虑原图上的一个路径现在怎么表示:可以用一个边集 \(S\) 表示,其中 \(S\) 的每一条边对应的反图的边都是非树边。我们把这些边按照一定顺序排,那么从 \(1\) 到 \(n\) 的边集 \(S\) 就成了 \(1\to S_{1,0},S_{1,1}\to S_{2,0},\dots,S_{s,1}\to n\)。一条边 \(s_{0/1}\) 代表路径 \(s_0\to s_1\)。对于这样一条路径,相对 \(dis_1\) 来说,增长的长度很容易表示出来。令一条非树边的贡献 \(\Delta s=dis_{s_1}+w-dis{s_0}\)。\(S\) 的贡献就是 \(\sum\Delta s\)。
此时我们就能够把所有可行路径的长度表示出来了,接下来我们考虑如何寻找解。可以考虑将边集中的最后一条边换掉,也可以新加一条边。显然这样是能够得到所有解的。因为边集有序,我们换的边的起点一定是最后一条边的终点能够直接或间接到达的点。直接到的点非常好处理,间接到达的点分为两种,一种是沿着最短路树往上面走然后在一个点跳出去,也就是说当前点可选的边集中一定包含所有其最短路树上的祖先的边集。那么我们就需要支持类似边集的复制合并。另一种是沿着非树边往外面跳,至少经过两条非树边,很容易发现每多经过一条非树边,路径长度一定变大,那么这种情况根本不可能成为局部的最优解,也就完全没有必要考虑。至于新增一条边就可以直接在最短路树上找个祖先跳就好了。
重新分析一下我们要干什么,对于一个点代表的能更新的边集,它包含所有祖先的边集,且祖先边集仍然有用,然后要在这些边集中找到总贡献最小的更新答案。也就是说我们需要支持两个堆的合并,并且合并后被合并的堆仍然存在。考虑可持久化左偏树。加上可持久化其实很简单,就是原本合并直接合到节点上,现在我们新开一个点合并就能够保证被合并的堆不被打乱。
2.笛卡尔树
不是很常用。其实就是实现单调栈的,但是分治性质更好。
对于每个节点有两个键值 \((x,y)\),构成的笛卡尔树满足对于 \(x\) 满足 bst 的性质(左子树所有数小于本身,右子树所有数大于本身),对于 \(y\) 满足小根堆的性质(父节点值小于等于子节点值)。
在 \(x\) 递增给出的情况下能够在线性时间内构造出笛卡尔树。因为我们要满足 bst 的性质,根据 \(x\) 递增,我们只需要一直加在最右就好了。所以我们维护这棵树最右边的儿子,在没有 \(y\) 限制的情况下可以直接连到它右边,在有 \(y\) 限制的情况下我们可以考虑把整个右链维护出来,插入 \((x_i,y_i)\) 时找到最后一个 \(y_j<y_i\) 在右链上的 \(j\),为了维护小根堆的性质把后面的东西都连到 \(i\) 的左子树,保证正确性。
关于复杂度,我们使用栈来维护这个右链,那么找最后一个就是从后往前跳,然后找完之后经过的东西都应该不在右链上了,所以每个元素只会进栈出栈各一次,复杂度为线性。
3.珂朵莉树
其实是对 STL-set 的扩展运用,一般用于解决数据随机带有推平性质的题目。
首先它的复杂度基于数据的随机性,可以 证明 大概是一个 \(\log\) 范围内的。
思想及其简单,就是把序列里面值相同的值合起来组成一个三元组 \((l,r,v)\) 代表区间和值,插到一个 set 里面维护。
首先是唯一的核心操作 split(p)
,把一个整体拆分成两份,目的是为了把 \(p\) 作为一个区间的开头,即把 \((l,r,v)\) 拆成 \((l,p-1,v)\) 和 \((p,r,v)\)。
set 中 lower_bound
的作用是找到第一个大于等于的位置,在此处对应包含它的区间的后面一个区间。如果这个不是 set 自带的结尾并且第一个就是 \(p\),显然我们没有必要继续拆下去,我们把 \(p\) 作为开头的目的已经完成了。不然我们也找到了这个区间,把这个区间抹掉然后把两个新的插进去就好了。注意这里的 split 是可以返回值的,set 中的插入函数也是带有返回值的,我们可以把 insert({p,r,v}).first
即插入后的迭代器返回去,某些时候可以省很多事。
然后是推平操作,这个真的超级简单,我们将要推平的区间先表示出来,即拆出来一个以 \(l\) 开头的 \(lit\) 和一个以 \(r+1\) 开头的 \(rit\),注意这里要先 split(r+1)
,原因是先分割左边可能导致后面的区间出现变动导致前面返回的迭代器失效,此时再用前面的迭代器就会出现奇奇怪怪的错误。然后做完之后我们 erase(lit,rit)
再进行一次插入就好。erase
这个函数能够把 \([lit,rit)\) 之间的迭代器抹去,完美适配珂树的要求。
剩下的所有所有东西都是基于这个东西的暴力,每段每段做就好了,复杂度参考上面给的链接。
4.K-D Tree
维护多维空间内的点,用起来比较类似线段树。基于数据随机的情况下复杂度为 \(O(\log)\),但是能够卡,很多情况下一个圆就可以把 K-D Tree 卡死。一般来说用来乱搞拿分,不要过度依赖。打出来 KD-Tree 的题基本上都有其他解法,很多时候写 KD-Tree 只是因为好写或者骗分,所以不是很需要考虑复杂度,当然适当必要的剪枝是需要的。
利用替罪羊树思想进行维护。每一层按照不同维度交替将空间分割,对于一个节点 \(p\) 维护的空间,在这个空间内的所有元素中寻找一个当前维度处于中位的点趋于平衡地切割成两个空间并把点分为左右两个儿子。注意每个节点需要维护一个空间,所以需要维护当前的维度和当前维护属于这个节点的范围。可以支持打 tag,插入类比替罪羊,查询类比线段树。重构时为了尽量优化复杂度选择中位数,可以用 STL nth_element
实现。
遇到多个位置相同的点询问允许的情况下可以看做不一样的点,节省很多空间和码量。
遇到离线题可以考虑把所有点先建出来然后考虑打一个激活标记代表一个点现在存不存在(如果有插入删除的话),可以很大程度上优化替罪羊的重构。