完全二叉树
什么是完全二叉树
-
满二叉树:一个二叉树,如果每层的节点数都达到最大值,在这个二叉树就是满二叉树。即如果二叉树的层数为,节点总数是,则它就是满二叉树
-
完全二叉树:对于深度为的、有个节点的二叉树,当且仅当每一个节点编号都与深度为的满二叉树中节点编号一一对应时称之为完全二叉树。
- 满二叉树也是一种特殊的完全二叉树
完全二叉树的性质
假设节点编号为i(根节点编号为0)
- 若,则父节点编号:
(i - 1) / 2 - 左孩子编号:
2 * i + 1 - 右孩子编号:
2 * i + 2 - 完全二叉树的第
n层(n不是最后一层)有个节点 - 节点数量为
N的完全二叉树的高度为(向上取整)
完全二叉树的表示方法
传统树结构
class TreeNode {
int val;
TreeNode left;
TreeNode right;
}
数组表示
使用arr[i] 代表完全二叉树中编号为i的节点的值
例如:
int[] heap = new int[] {0, 1, 2, 3, 4, 5, 6};
这个数组可以表示如下的这棵完全二叉树
堆的概念
堆在本质上是一种特殊的完全二叉树,它分为小根堆和大根堆
- 小根堆:完全二叉树中每一棵子树的根节点都是子树中最小的元素,即每个父节点的值都小于左孩子和右孩子的值。
- 大根堆:完全二叉树中每一棵子树的根节点都是子树中最大的元素,即每个父节点的值都大于左孩子和右孩子的值
例如:下面的这棵树是大根堆
下面的这棵树是小根堆
下面的这棵树不是堆结构
堆结构中的两个重要流程
以下的所有流程均以大根堆为例,即父节点的值>左右子节点的值
从下向上的调整:heapInsert
流程描述
heapInsert是指针对堆结构中的某个节点,不断地和自己的父节点比较,如果子节点的值大于父节点的值,则和父节点交换位置,直到不再大于父节点,或者自己已经是整个二叉树的根节点为止。
简单地说,heapInsert就是节点“上浮”直到符合堆结构要求的过程。
举个例子,对于这样的一个数组:[9, 7, 5, 4, 5, 2, 3, 1],它代表的是这样的一个堆结构。
如果在数组的最后面追加一个元素8,即数组变成了[9, 7, 5, 4, 5, 2, 3, 1, 8],此时显然是不符合大根堆要求的,那么如何才能调整这个堆结构,使得它重新变成一个大根堆呢。
设置一个指针i指向数组8位置的8,将它和父节点比较,父节点在数组中的index是(8 - 1) / 2 = 3,所以将8位置的8和3位置的4比较,8 > 4,所以将两个元素互换位置,数组变成了[9, 7, 5, 8, 5, 2, 3, 1, 4],对应的完全二叉树结构变成了下面这个样子。
此时,3位置的8继续和自己的父节点比较,父节点的index是(3 - 1) / 2 = 1,所以3位置的8和1位置的7做比较,8 > 7,所以和父节点交换位置,数组变成了[9, 8, 5, 7, 5, 2, 3, 1, 4],对应的完全二叉树结构变成了如下所示的样子。
再将1位置的8和父节点,也就是0位置的9做比较,8 < 9,所以已经不能再继续上浮了,此时终止整个流程,整个完全二叉树结构已经符合大根堆的要求。
代码
/**
* 从i位置向上看,不断的和自己的父节点pk,直到无法比父节点更大,或自己已经是整棵树的根节点为止
*/
public void heapInsert(int[] arr, int i) {
while (arr[i] > arr[(i - 1) / 2]) {
DUtils.swap(arr, i, (i - 1) / 2);
i = (i - 1) / 2;
}
}
这段代码中有一个点需要特殊说明一下,如果i = 0,也就是说要判断的节点已经来到了整棵树的根节点,那么(i - 1) / 2 = 0,在while循环的判断条件那里就是arr[0]和arr[0]做比较, 一定不满足循环条件,就一定会跳出循环。
时间复杂度分析
从流程上看,heapInsert就是节点的不断上浮的过程。已知节点数量为N的完全二叉树高度是,所以这个比较和上浮的过程最多会执行次,所以heapInsert的时间复杂度是
从上向下的调整:heapify
流程描述
上面提到的heapInsert是从下到上的调整,在堆结构的调整中还存在一种从上到下的调整。举个例子,对于这样的一个数组:[9, 8, 5, 7, 5, 2, 3, 1, 4],它对应的是如下所示的这样一个大根堆结构。
此时,如果由于某些原因,堆顶的9变成了1,显然大根堆结构又被破坏了。此时需要将堆顶元素1与自己的左右孩子对比,如果节点的值比左右孩子都大则终止流程,否则将节点和左右孩子中的较大者交换位置,不断地下沉,直到结构再次符合大根堆的要求,这个过程我们称之为heapify。
以上面的数组为例,设置一个指针指向0位置的1,首先去计算节点左右孩子在数组中的index,左孩子的index等于0 * 2 + 1 = 1,右孩子的index是0 * 2 + 2 = 2,这两个位置都没有超过堆的范围,然后再将arr[0]与arr[1]和arr[2]做对比,发现arr[1] = 8,是三个节点中的最大值,所以将0位置的1和1位置的8交换位置,数组变成了[8, 1, 5, 7, 5, 2, 3, 1, 4],对应的堆结构调整如下。
此时再将1位置的1和它的两个子节点做比较。左孩子的index是1 * 2 + 1 = 3,右孩子的index是1 * 2 + 2 = 4,arr[1],arr[3],arr[4]三个元素中最大的是3位置的7,所以将arr[1]和arr[3]交换位置,数组变成了[8, 7, 5, 1, 5, 2, 3, 1, 4],对应的堆结构调整如下。
现在再将3位置的1和它的两个孩子,也就是7位置的1和8位置的4做比较,发现三个元素中最大的是8位置的4,所以将3位置的1和8位置的4交换位置,数组变成了[8, 7, 5, 4, 5, 2, 3, 1, 1],对应的堆结构调整如下。
此时的i指向8位置的1,计算它的左右子节点的index,分别是2 * 8 + 1 = 17和2 * 8 + 2 = 18,发现都已经超过了堆的范围,所以说明它不再有左右子节点,终止流程,二叉树结构已经符合大根堆的要求。
代码
/**
* 从arr[i]开始不断下沉,直到满足大根堆的标准
* 如何下沉:不断和左右孩子中较大的元素交换位置
*/
private void heapify(int[] arr, int i, int heapSize) {
while (2 * i + 1 < heapSize) {
// 有左孩子
int swapIndex = i;
if (arr[2 * i + 1] > arr[swapIndex]) {
swapIndex = 2 * i + 1;
}
if (2 * i + 2 < heapSize && arr[2 * i + 2] > arr[swapIndex]) {
swapIndex = 2 * i + 2;
}
if (swapIndex == i) {
break;
}
DUtils.swap(arr, i, swapIndex);
i = swapIndex;
}
}
时间复杂度分析
从流程上可以看出来,heapify是一个节点的不断下沉的过程,和前面类似,已知完全二叉树的高度是,那么这个比较和交换的过程最多也就执行次,所以heapify的时间复杂度也是
堆排序
堆排序的流程
所谓堆排序,就是将一个无序数组通过堆结构变成有序的状态。堆排序一共有两个步骤:建堆和排序。我们下面的这个数组为例:[3, 5, 2, 7, 5, 4, 6]
建堆
首先设置一个变量heapSize,含义是arr[0...heapSize-1]是堆结构中的元素。初始状态heapSize = 0,意味着堆中没有任何元素。
令heapSize++,现在heapSize的值是1,意味着堆中只有一个元素,就是arr[0],现在是符合大根堆结构的要求的。
再让heapSize++,意味着堆中目前有两个元素。此时的完全二叉树是不符合堆结构的要求的,所以需要执行heapInsert,将1位置的5和0位置的3交换位置,数组变成[5, 3, 2, 7, 5, 4, 6],调整后的堆结构如下。
heapSize再+1变成3,即将数组2位置的2也插入堆结构,再执行heapInsert。2位置的2比父节点0位置的5小,所以直接结束流程,此时数组依然为[5, 3, 2, 7, 5, 4, 6],堆结构如下。
heapSize再+1变成4,即将数组3位置的7插入堆结构,再执行heapInsert。3位置的7首先和父节点1位置的3做比较,7 > 3所以交换位置,再和父节点0位置的5做比较,交换位置。此时数组变成[7, 5, 2, 3, 5, 4, 6],堆结构如下。
heapSize继续+1变成5,把4位置 5插入堆结构后执行heapInsert。4位置的5找到自己的父节点,也就是1位置的5发现不能上浮,所以流程结束,数组不变,此时的堆结构如下。
heapSize加1后变成6,把数组5位置的4插入堆结构后执行heapInsert。5位置的4比自己的父节点,也就是2位置的2大,所以交换位置,再找父节点找到了0位置的7,已经不能再上浮了,所以数组变成了[7, 5, 4, 3, 5, 2, 6],堆结构如下。
最后heapSize再+1变成7,把数组6位置的6插入堆结构再执行heapInsert,6先和父节点2位置的4比较后交换位置,再喝0位置的7做比较,然后终止流程,数组变成[7, 5, 6, 3, 5, 2, 4],堆结构如下。
至此,数组中的所有元素都已经插入到堆结构中了,建堆过程到此结束。
排序
到目前为止我们已经将数组中的所有元素建成了一个大根堆,也就是说堆顶元素就是堆中最大的元素。我们将堆顶元素arr[0]与堆范围内的最后一个元素arr[6]交换位置,数组变成了[4, 5, 6, 3, 5, 2, 7]。随后将heapSize--,意味着切断了6位置的7和整个堆结构的联系。此时的堆结构变成了如下的样子。
此时的堆结构是不符合大根堆要求的,所以我们针对换到堆顶的4执行heapify,让它不断下沉,直到整个结构符合大根堆要求。
本次heapify结束之后,数组变成了[6, 5, 4, 3, 5, 2, 7]
此时再将堆顶元素6和堆中最后一个元素5位置的2交换位置,数组变成[2, 5, 4, 3, 5, 6, 7],heapSize--,然后针对堆顶元素执行heapify,数组变成[5, 5, 4, 3, 2]
再将堆顶元素5和堆中最后一个元素2交换位置, 数组变成[2, 5, 4, 3, 5, 6, 7],heapSize--,再针对堆顶元素执行heapify,数组变成[5, 3, 4, 2, 5, 6, 7]。
堆顶的5和堆中最后一个元素2再交换位置,数组变成[2, 3, 4, 5, 5, 6, 7],heapSize--,再针对堆顶元素heapify,数组变成[4, 3, 2, 5, 5, 6, 7]。
堆顶的4和堆中最后的2再交换位置,数组变成[2, 3, 4, 5, 5, 6, 7],heapSize--,再针对堆顶的2执行heapify,数组变成[3, 2, 4, 5, 5, 6, 7]。
最后再将堆顶的3和堆中最后的2交换位置,heapSize--,再执行heapify,此时堆中已经只剩下一个元素了,堆排序的整个流程就可以终止了,数组变成[2, 3, 4, 5, 5, 6, 7],有序状态。
代码
堆排序的代码如下所示
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
// 1. 建堆
// heapSize: i+1
for (int i=0; i<arr.length; i++) {
heapInsert(arr, i);
}
int heapSize = arr.length;
while (heapSize > 0) {
DUtils.swap(arr, 0, --heapSize);
heapify(arr, 0, heapSize);
}
}
时间复杂度分析
分析一下上述流程的时间复杂度。上述流程首先分别在大小为1, 2, 3...N的堆上执行heapInsert,上面分析过heapInsert的时间复杂度上O(logN),所以建堆的时间花费就是 。
根据数学推导可以得知,当N趋近于无穷大时,和是同阶无穷大,所以建堆的时间复杂度就是
同样的,排序的过程也是分别在大小为N, N-1, ..., 2, 1的堆上执行heapify,heapify的时间复杂度也是O(logN),所以排序过程上时间复杂度也是
时间复杂度优化
在上述的两个步骤中,排序的时间复杂度已经没办法再优化了,但建堆还可以再做一步优化。还是以这个数组为例:[3, 5, 2, 7, 5, 4, 6],它可以表示如下这棵完全二叉树。
如何将这棵完全二叉树调整成大根堆呢?我们从最后一个节点开始,逐个节点进行heapify。所以首先针对二叉树的最下层节点从右侧向左,也就是6、4、5、7调用heapify。由于这几个节点已经是叶子节点了没办法再下沉了,所以可以直接退出流程。
然后再针对树的倒数第二层从右向左调用heapify。首先是2位置的2,将它和6位置的6交换位置。
再将1位置的5和3位置的7交换位置。数组变成了[3, 7, 6, 5, 5, 4, 2],对应的堆结构调整如下。
最后再针对0位置的3调用heapify。0位置的3首先和1位置的7交换位置,再和3位置的5交换位置,数组变成了7, 5, 5, 3, 5, 4, 2],对应的堆结构调整如下。
至此,建堆流程结束。
分析一下这个流程的时间复杂度。假设堆中一共有n个节点,那么堆的最后一层,大约有n/2个节点的heapify完全不需要调整,大约有n/4个节点的heapify最多调整一层,n/8个节点的heapify最多只需要调整两层,...。所以整个流程的时间花费(假设为就可以表示为
将上述公式两侧 可得
公式2和公式1错位相减后可以得到
根据等比数列的求和公式,上述公式可以写成
当趋近于无穷大时,一定是趋近于0的,所以上述公式的时间复杂度就是
堆的应用
leetcode 23.合并K个升序链表
题目描述
给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例 1:
输入:lists = [[1,4,5],[1,3,4],[2,6]] 输出:[1,1,2,3,4,4,5,6] 解释:链表数组如下: [ 1->4->5, 1->3->4, 2->6 ] 将它们合并到一个有序链表中得到。 1->1->2->3->4->4->5->6
示例 2:
输入:lists = [] 输出:[]
示例 3:
输入:lists = [[]] 输出:[]
解法1
【解法流程】
将k条链表中的所有节点存入一个集合,然后调用系统排序算法排序后重新拼接成一个新的链表
【复杂度分析】
假设k条链表中共有n个节点
- 时间复杂度
主要的时间花费在排序上,时间复杂度为O(NlogN)
- 空间复杂度
需要一个集合存储n个节点,所以空间复杂度O(n)
解法2
【解法流程】
准备一个大小为k的小根堆,将k条链表的头节点放入堆中,然后弹出堆顶元素,此时弹出的堆顶元素一定是k条链表中最小的元素,拼到结果链表中。再将弹出元素的下一个节点放入堆中,弹出堆顶元素,循环往复直到堆中元素为空。
【代码】
public ListNode mergeKLists(ListNode[] lists) {
PriorityQueue<ListNode> queue = new PriorityQueue<>(Comparator.comparingInt(n -> n.val));
for (ListNode node : lists) {
if (node != null) {
queue.offer(node);
}
}
ListNode head = new ListNode(0);
ListNode cur = head;
while (!queue.isEmpty()) {
ListNode poll = queue.poll();
cur.next = poll;
cur = poll;
if (poll.next != null) {
queue.offer(poll.next);
}
}
return head.next;
}
【复杂度分析】
- 时间复杂度
n个节点分别入堆出堆,在堆中调整的时间复杂度是O(logK),整体流程的时间复杂度是O(nlogK)
- 空间复杂度
整体流程借用了一个大小为k的堆,所以空间复杂度O(k)