数据结构——堆和不相交集
堆
在许多算法中,需要大量用到如下两种操作:插入元素和寻找最大(小)值元素。为了提高这两种运算的效率,必须使用恰当的数据结构。
- 普通队列:易插入元素,但求最大(小)值元素需要遍历整个队列。
- 排序数组:易找到最大(小)值,但插入元素需要移动大量元素。
- 堆:有效实现上述两种运算的简单数据结构。
定义
堆(Heap)由一个完全二叉树的结构来描述数据的关系,在这个树中,如果父节点的键值大于等于其任何一个子节点的键值,这样的二叉树称为最大堆;如果父节点的键值小于等于其任何一个子节点的键值,这样的二叉树称为最小堆。
堆的特点
- 堆是一棵完全二叉树,堆所对应的树的节点的排列必须是从上到下,从左到右的依次排列,否则将不构成堆。
- 在最大堆中,根节点值最大,叶子节点值较小,从根节点到叶子节点的一条路径上,节点值以非升序排列。(在下述描述中,若无特殊说明,均以大根堆为研究前提)
- 任何一个父节点的值都大于等于其子节点的值,但节点的左右子节点值并无顺序要求,且上层节点的值不一定大于下层节点的值。
- 堆中每个节点的子树都是堆。
- 有n个节点的堆T,可以用一个数组a[1...n]来存储,按照节点从上到下,从左到右的顺序一次存储。
- T的根节点存储在a[1]中;假设T的节点x存储在a[j]中,那么它的左右子节点分别存放在a[2j]及a[2j+1]中(如果有的话);a[j]的父节点如果不是根节点,则存储在a[[j/2]]中。
对堆的操作
对堆的操作主要有以下5种:
- 上移(sift-up):即将堆中的元素往根节点的方向移动;
- 下移(sift-down):即将堆中的元素往叶子点的方向移动;
- 删除(delete):即删除堆中的某个元素;
- 插入(insert):即将一个新的元素插入到堆中;
- 构建(makeheap):即将一组无序的数据构建成堆。
一、上移
- 若某个节点a[i]的键值大于其父节点的键值,便违背了堆的特性,需要进行调整。
- 调整方法:上移。
- 沿着a[i]到根节点的唯一一条路径,将a[i]移动到合适的位置上:比较a[i]及其父节点a[[i/2]]的键值,若key(a[i]) > key(a[[i/2]]),则二者进行交换,直到a[i]到达合适位置。
- 相关算法为:
堆上移 ShiftUp
1. 输入:输入数组a[1...n],需要上移的元素下标i。
2. 输出:上移后的数组a[1...n]。
3. if i = 1 then
4. return a /*根节点,无需上移*/
5. end if
6. repeat
7. if a[i] > a[[i/2]] then
8. swap a[i] and a[[i/2]]
9. i = [i/2]
10. else
11. return a
12. end if
13. until i = 1
14. return a
二、下移
- 假如某个内部节点a[i](i ≤ [n/2]),其键值小于儿子节点的键值,即key(a[i]) < key(a[2i])或key(a[i]) < key(a[2i+1])(如果有右儿子存在),违背了堆特性,需要进行调整。
- 调整方法:下移。
- 沿着从a[i]到子节点(可能不唯一,则取其键值较大者)的路径,比较a[i]与子节点的键值,若key(a[i]) < max(a[2i],a[2i+1])则交换。这一过程直到叶子节点或满足堆特性为止。
- 相关算法为:
堆下移 ShiftDown
1. 输入:输入数组a[1...n],需要下移的元素下标i。
2. 输出:下移后的数组a[1...n]。
3. if 2 * i > n then
4. return a /*叶子节点,无需下移*/
5. end if
6. repeat
7. if a[2i] > a[2i + 1] then
8. t = 2i
9. else
10. t = 2i + 1
11. end if
12. if a[i] < a[t] then
13. swap a[i] and a[t]
14. i = t
15. else
16. return a
17. end if
18. until 2 * i > n
19. return a
三、插入
- 思路:先将x添加到a[]的末尾,然后利用Sift-up,调整x在a[]种的位置,直到满足堆的特性。
- 相关算法为:
堆插入 Insert
1. 输入:输入数组a[1...n],需要插入的元素x。
2. 输出:插入元素x后的数组a[1...n+1]。
3. a[n+1] = x
4. SiftUp(a,n+1)
5. return a[1...n+1]
- 树的高度为[logn](向下取整),所以讲过一个元素插入大小为n的堆所需要的时间是O(logn)。
四、删除
- 思路:先用a[n]取代a[1],然后对a[i]作Sift-up或Sift-down,直到满足堆特性。
- 相关算法为:
堆删除 Delete
1. 输入:输入数组a[1...n],需要删除的元素下标i。
2. 输出:删除元素x后的数组a[1...n-1]。
3. if i = n then /*最末尾的节点,直接删除*/
4. return a[1...n-1]
5. end if
6. if a[n] ≥ a[i] then
7. a[i] = a[n]
8. SiftUp(a,i)
9. else
10. a[i] = a[n]
11. SiftDown(a,i)
12. end if
13. return a[1...n-1]
- 所需要的时间是O(logn)。
- 不能直接用其子节点的值覆盖!若直接将子节点提上来,可能会破坏完全二叉树的结构,则不满足堆的定义。
五、构建
方法一:从一个空堆开始,逐步插入A中每个元素,直到A中所有元素都被转移到堆中。
时间复杂度为O(nlogn)。因为插入一个元素需要logn,总共需要插入n个元素。
方法二:直接对数据进行调整。
- 自上而下的调整:一次调整需要O(nlogn),每一次调整确定一层的数值,总共需要logn次调整,总复杂度为O(n(logn)^2)。
- 自下而上的调整:调整一次即可(对以节点i为根的子树进行调整),但复杂度仍是O(nlogn)
- 自下而上的优化:①叶子节点不需要调整;②对子树进行调整;③第i层的节点的调整最多交换[logn] - i次。
构建复杂度
优化后自下而上的构造方式的复杂度为O(n)。
d堆
d堆:如三叉堆、四叉堆;树的层数为logdn。
- d堆上移操作:因每次上移需要和其父节点比较,所以复杂度为O(logdn)。
- d堆下移操作:因每次下移需要和其所有的子节点比较(共d个子节点),所以复杂度为O(dlogdn)。
- O(logdn)=O(logn),O(dlogdn)=O(logn)
不相交集(并查集)
定义
在离散数学我们学过等价类是对集合S的一个划分,对集合S的划分形成了集合S的不相交集。不相交集可以用树表示。
不相交集的相关操作
不相交集:查找FIND(x)、合并UNION(X,Y)
- FIND(x):寻找包含元素x的集合的名字。记root(x)为包含元素x的树的根,则FIND(x)返回root(x).
- UNION(x):将包含元素x和y的两个集合合并,重命名。执行合并UNION(x,y)时,首先依据x找到root(x),记为u,依据y找到root(y),记为v;然后将u指向v。
秩和基于秩的合并
秩是赋予节点的一个值,这个值表明了节点所在树中的高度,每个节点的秩初始化值为0,一个树的根节点的秩代表了这棵树的高度。当对两棵树(x,y)(x、y分别代表两棵树的根节点)合并运算时,如果rank(x)小于rank(y),则x指向y,所有节点的秩不变;如果rank(x)大于rank(y),则y指向x,所有节点的秩不变;如果rank(x)等于rank(y),则x指向y,节点y的秩+1。
按秩合并的顺序决定了树的高度。
定理:设经过秩合并树的节点个数为n,则树的高度至多为[logn]。
通过归纳法证明:
- 当树的节点是1时,树的rank=0,高度为log1=0,成立;当树的节点数为2时,树的rank=1,高度为log2=1,成立。
- 假设树的节点个数分别为m和n,高度为rank-m和rank-n,满足rank-m ≤ logm(m ≥ 2^rank-m)和rank-n ≤ logn(n ≥ 2^rank-n),则按秩合并后,有以下三种情况:
Q:是不是通过基于秩的合并就总能得到最优的不相交集?
A:不一定:如有四个节点,先合并1和2,再合并3和4,不一定是最优的。
Q:如何优化?
A:
- 合并时调整:复杂度从O(logn)变为O(n)
- 专门设置一个调整操作:也为O(n)
- 部分解决方案:路径压缩
路径压缩
定义:路径压缩:在执行Find操作时,对访问的每一个节点,如果此节点并不是直接指向根节点,则让此节点指向根节点。
路径压缩查找FindCompress(ai)
1. 输入:元素ai;
2. 输出:ai所在树的根节点,同时对此树进行路径压缩;
3. root = Find(ai)
4. x = ai
5. while x ≠ root do
6. y = Parent(x)
7. Parent(x) = root
8. x = y
9. end while
10. return root
这个算法中为什么不对rank进行改变?
改变rank需要遍历,则复杂度得不到优化。所以叫部分解决方案。
排序算法
堆排序
- 流程:将数组形成堆;依次取堆顶元素。
- 算法复杂度:时间复杂度为O(nlogn),空间复杂度为O(1)
- 算法实现:
HEAPSORT
输入:n个元素的数组A[1...n]
输出:以非降序排列的数组A。
1. MAKEHEAP(A)
2. for j = n downto 2
3. swap A[1] and A[j]
4. SIFT-DOWN(A[1...j],1)
5. end for
Q:排序的最优算法是不是nlogn? A:目前所知,如果是比较排序的话,最优算法为nlogn,如果是非比较排序,可以更低。
非比较排序:计数排序
算法:适用于整数排序且整数数值较小
- 统计每个数的个数,存储在数组C:C的标号代表数值,C的值代表个数。
- 将C的每个元素值依次往后累加:C[i] = C[i] + C[i+1];针对每个数x,得出x应放的位置n(小于等于x的元素有n-1个)。
- 从后到前一次将x放在第n个位置,保证排序稳定性。
- 算法实现:
COUNTINGSORT(A,B,k)
1. for i = 1 to k
2. do C[i] = 0
3. for j = 1 to length[A]
4. do C[A[j]] = C[A[j]] + 1
5. /*C[i] now contains the number of elements equal to i.*/
6. for i = 2 to k
7. do C[i] = C[i] + C[i - 1]
8. /*C[i] now contains the number of elements less than or equal to i.*/
9. for j = length[A] downto 1
10. do B[C[A[j]]] = A[j]
11. C[A[j]] = C[A[j]] - 1
其中A[1...n]为要排序的数组,B[1...n]存放排放好的数组,k为最大的数,C[0...k]提供临时存储空间。
基数排序
算法:适用于具有相同或相近位数的数据的排序
- 对所有数据按最后一位进行排序
- 在上述排序的基础上,按前一位进行排序
- 重复第二步一直到最高位
- 算法实现:
RADINSORT
输入:一张有n个数的表L = {a1,a2,...,an}和k位数字。
输出:按非降序排列的L。
1. for j = 1 to k
2. 准备10个空表L0,L1,...,L9。
3. while L 非空
4. a = L中的下一元素;删除表L中的a。
5. i = a 中的第j位数字;将a加入表Li中
6. end while
7. L = L0
8. for i = 1 to 9
9. L = L,Li /*将表Li加入L中*/
10. end for
11. end for
12. return L
- L0,L1,...,L9表用于存放每一位上相应的数据,即当比较第i位时,数据a的第i位为5,则存放在L5中。
- L按从低到高顺序存放L0一直到L9。
- 算法时间复杂度(按迭代次数计算):O(kn) = O(n);空间复杂度(十个表,每个表都是n):O(10n) = O(n)