算法设计技巧与分析 | 堆和不相交集

343 阅读10分钟

数据结构——堆和不相交集

在许多算法中,需要大量用到如下两种操作:插入元素和寻找最大(小)值元素。为了提高这两种运算的效率,必须使用恰当的数据结构。

  • 普通队列:易插入元素,但求最大(小)值元素需要遍历整个队列。
  • 排序数组:易找到最大(小)值,但插入元素需要移动大量元素。
  • 堆:有效实现上述两种运算的简单数据结构。

定义

堆(Heap)由一个完全二叉树的结构来描述数据的关系,在这个树中,如果父节点的键值大于等于其任何一个子节点的键值,这样的二叉树称为最大堆;如果父节点的键值小于等于其任何一个子节点的键值,这样的二叉树称为最小堆。

image.png

堆的特点

  • 堆是一棵完全二叉树,堆所对应的树的节点的排列必须是从上到下,从左到右的依次排列,否则将不构成堆。
  • 在最大堆中,根节点值最大,叶子节点值较小,从根节点到叶子节点的一条路径上,节点值以非升序排列。(在下述描述中,若无特殊说明,均以大根堆为研究前提)
  • 任何一个父节点的值都大于等于其子节点的值,但节点的左右子节点值并无顺序要求,且上层节点的值不一定大于下层节点的值
  • 堆中每个节点的子树都是堆。
  • 有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):即将一组无序的数据构建成堆。

一、上移

  1. 若某个节点a[i]的键值大于其父节点的键值,便违背了堆的特性,需要进行调整。
  2. 调整方法:上移。
  3. 沿着a[i]到根节点的唯一一条路径,将a[i]移动到合适的位置上:比较a[i]及其父节点a[[i/2]]的键值,若key(a[i]) > key(a[[i/2]]),则二者进行交换,直到a[i]到达合适位置。 image.png
  4. 相关算法为:
堆上移 ShiftUp
1.  输入:输入数组a[1...n],需要上移的元素下标i2.  输出:上移后的数组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

二、下移

  1. 假如某个内部节点a[i](i ≤ [n/2]),其键值小于儿子节点的键值,即key(a[i]) < key(a[2i])或key(a[i]) < key(a[2i+1])(如果有右儿子存在),违背了堆特性,需要进行调整。
  2. 调整方法:下移。
  3. 沿着从a[i]到子节点(可能不唯一,则取其键值较大者)的路径,比较a[i]与子节点的键值,若key(a[i]) < max(a[2i],a[2i+1])则交换。这一过程直到叶子节点或满足堆特性为止。 image.png
  4. 相关算法为:
堆下移 ShiftDown
1.  输入:输入数组a[1...n],需要下移的元素下标i2.  输出:下移后的数组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

三、插入

  1. 思路:先将x添加到a[]的末尾,然后利用Sift-up,调整x在a[]种的位置,直到满足堆的特性。
  2. 相关算法为:
堆插入 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]
  1. 树的高度为[logn](向下取整),所以讲过一个元素插入大小为n的堆所需要的时间是O(logn)。

四、删除

  1. 思路:先用a[n]取代a[1],然后对a[i]作Sift-up或Sift-down,直到满足堆特性。
  2. 相关算法为:
堆删除 Delete
1.  输入:输入数组a[1...n],需要删除的元素下标i2.  输出:删除元素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]
  1. 所需要的时间是O(logn)。
  2. 不能直接用其子节点的值覆盖!若直接将子节点提上来,可能会破坏完全二叉树的结构,则不满足堆的定义。

五、构建

方法一:从一个空堆开始,逐步插入A中每个元素,直到A中所有元素都被转移到堆中。

时间复杂度为O(nlogn)。因为插入一个元素需要logn,总共需要插入n个元素。

方法二:直接对数据进行调整。

  • 自上而下的调整:一次调整需要O(nlogn),每一次调整确定一层的数值,总共需要logn次调整,总复杂度为O(n(logn)^2)。
  • 自下而上的调整:调整一次即可(对以节点i为根的子树进行调整),但复杂度仍是O(nlogn)
  • 自下而上的优化:①叶子节点不需要调整;②对子树进行调整;③第i层的节点的调整最多交换[logn] - i次。

image.png

image.png

构建复杂度

image.png

image.png 优化后自下而上的构造方式的复杂度为O(n)。

d堆

d堆:如三叉堆、四叉堆;树的层数为logdn。

  • d堆上移操作:因每次上移需要和其父节点比较,所以复杂度为O(logdn)。
  • d堆下移操作:因每次下移需要和其所有的子节点比较(共d个子节点),所以复杂度为O(dlogdn)。
  • O(logdn)=O(logn),O(dlogdn)=O(logn)

不相交集(并查集)

定义

在离散数学我们学过等价类是对集合S的一个划分,对集合S的划分形成了集合S的不相交集。不相交集可以用树表示。

image.png

不相交集的相关操作

不相交集:查找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. 当树的节点是1时,树的rank=0,高度为log1=0,成立;当树的节点数为2时,树的rank=1,高度为log2=1,成立。
  2. 假设树的节点个数分别为m和n,高度为rank-m和rank-n,满足rank-m ≤ logm(m ≥ 2^rank-m)和rank-n ≤ logn(n ≥ 2^rank-n),则按秩合并后,有以下三种情况:

image.png

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需要遍历,则复杂度得不到优化。所以叫部分解决方案

image.png

image.png

排序算法

堆排序

  1. 流程:将数组形成堆;依次取堆顶元素。
  2. 算法复杂度:时间复杂度为O(nlogn),空间复杂度为O(1)
  3. 算法实现:
HEAPSORT
输入:n个元素的数组A[1...n]
输出:以非降序排列的数组A1. 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]提供临时存储空间。

image.png

基数排序

算法:适用于具有相同或相近位数的数据的排序

  • 对所有数据按最后一位进行排序
  • 在上述排序的基础上,按前一位进行排序
  • 重复第二步一直到最高位
  • 算法实现:
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)