17.面试算法-经典变形树

1. 堆和优先级队列

1.1 堆的概念与特征

堆结构是一种非常重要的基础数据结构,也是算法的重要内容,很多题目甚至只能用堆来进行,所以我们必须先明确什么类型的题目可以用堆,以及如何使用堆来解决。由于堆的构造和维护过程都非常复杂,因此面试时一般不需要手写堆的实现过程,但是jdk已经提供了一些工具,因此需要知道思路,并且大致知道如何基于jdk来实现。

堆是将一组数据按照完全二叉树的存储顺序,将数据存储在一个一维数组中的结构。堆有两种结构,一种称为大顶堆,一种称为小顶堆,如下图。
在这里插入图片描述
小顶堆:任意节点的值均小于等于它的左右孩子,并且最小的值位于堆顶,即根节点处。

大顶堆:任意节点的值均大于等于它的左右孩子,并且最大的值位于堆顶,即根节点处。

有些地方也叫大根堆、小根堆,或者最大堆、最小堆都一个意思。大和小的特征等都是类似的,只是比较的时候是按照大还是小来定,我们这里在原理方面的介绍就按照最大堆来进行,后面的题目再根据情况来定。

既然是将一组数据按照树的结构存储在一维数组中,而且还是完全二叉树,那么父子之间关系的建立就很重要了。假设一个节点的下标为i。
1、当i = 0时,为根节点。
2、当i>=1时,父节点为(i - 1)/2

1.1.1 堆的构造过程

使用数组构建堆时,就是先按照层次将所有元素依次填入二叉树中,使其成为二叉树,然后再不断调整,最终使其符合堆结构。

下面就看一下如何建立一个大堆:

  1. 将元素依次排到完全二叉树节点上去,如下左图所示。
  2. int i = (size - 2)/2 = 4 (思考一下这里为什么是size-2而不是size-1)。找到数组中的4号下标。65大于其孩子,满足大堆性质,所以不用交换。如下右图
    在这里插入图片描述
  3. 然后i=i-1;然后用2和其孩子比较,2和204交换。交换之后204所在的子树满足大堆,如下左图。
  4. 54和其孩子比较,54和92交换。此时92所在子树满足大堆,如下右图。
    在这里插入图片描述
  5. 继续,23和其孩子比较,23和204交换,交换完之后,23的子树却不满足了,所以还需调整它的子树。如下两图所示。
    在这里插入图片描述
  6. 12和204交换,仍然出现不平衡的情况,以此类推,直到根节点也满足要求就完毕了。
    在这里插入图片描述

这样我们就建好了一个大顶堆,从图中可以看到,根元素是整个树中值最大的那个,而第二大和第三大就是其左右子树,具体是哪个更大则是未知的,需要比较一下才知道。

另外,对于同一组数据,如果输入的序列不一样,那最终构造的树是否也会不一样呢?非常有可能,那这样的树有什么意义呢?我们后面再看,这里先理解堆是这么构建的就行了。

1.1.2 插入操作

从上面可以看到根节点和其左右子节点是堆里的老大老二和老三,其他结点则没有太明显的规律,那如果要插入一个新元素,该怎么做呢?直接说规则,将元素插入到保持其为完全二叉树的最后一个位置,然后顺着这条支路一直向上调整,每前进一层就要保证其子树都满足堆否则就去处理子树,直到完全满足要求。

看一个例子,如下图,要插入300,我们将其插入到31的右孩子位置,然后不断向上爬,31<300,所以两者要交换,再向上发现300⽐65大,所以两者要交换。最后300比根元素204大,两者也交换。最后就得到了新的堆。完整过程如下所示:
在这里插入图片描述

1.1.3 删除操作

堆本身比较特殊,一般对堆中的数据进行操作都是针对堆顶的元素,即每次都从堆中获得最大值或最小值,其他的不关心,所以我们删除的时候,也是删除堆顶。如果直接删掉堆顶,整个结构被破坏了,群龙无首就不易管理了。所以实际策略是先将堆中最后一个元素(假如为A)和堆顶元素进行替换,然后删除堆中最后一个元素。之后再从根开始逐步与左右比较,谁更大谁上位。然后A再继续与子树比较,如果有更大的继续交换,直到自己所在的子树也满足大顶堆。

上面的过程可以理解为皇上突然驾崩了,这时候先找个顾命大臣维持局面,大臣先看左右两个皇子谁更强谁就是老大。然后大臣自己再逐步隐退,直到找到属于自己的位置。
在这里插入图片描述
最后新的堆结构如下:
在这里插入图片描述
说了这么多,你觉得这东西的价值在哪里呢?价值就在于大顶堆的根节点是整个树最大的那个,增加时会根据根的大小来决定要不要加,而删除操作只删除根元素。这个特征可以在很多场景下有奇妙的应用,后面的算法题全都基于这一点。

这里可能有些人还有疑问,感觉不管插入还是删除,堆的操作都不简单,那为什么还说堆的效率比较高呢?

这是因为堆元素的数量是有限制的,一般不用将所有的元素都放到堆里。后面题目中可以看到,在序列中找K大,则堆的大小就是K。如果K个链表合并,那么堆就是K。原理后面详细展开。

1.2 堆与优先级队列的关系

我们在做题时经常会看到有些地方叫堆,有些地方叫优先级队列,两者到底啥关系呢?

优先队列:说到底还是一种队列,他的工作就是poll()/peek()出队列中最大/最小的那个元素,所以叫带有优先级的队列。能够实现优先功能的策略不一定只有堆,例如二项堆、平衡树、线段树、C++里会用二进制分组的vector来实现一个优先队列。

堆:堆是一个很大的概念他并不一定是完全二叉树。我们之前用完全二叉树是因为这个很容易被数组储存,但是除了这种二叉堆之外我们还有二项堆、斐波那契堆,这种堆就不属于二叉树。

所以说,优先队列和堆不是一个同level的概念,但是java的PriorityQueue就是堆实现的,因此在java领域可以认为堆就是优先级队列,优先级队列就是堆。换做其他场景则不行。

1.3 堆的常见面试题

说了这么多堆的性质,我们来看一下堆到底怎么解决问题的。关于堆的问题,记住口诀:

查找:找大用小,大的进;找小用大,小的进。
排序:升序用小,降序用大。

查找的口诀解释一下就是:

如果是找K大,则用小堆,后续数据只有比根元素更大时才允许进入堆。如果是找K小,则对应反过来。后面我们结合例子分析为什么。

1.3.1 LeetCode215 在数组中找第K大的元素

给定整数数组nums和整数k,请返回数组中第k个最大的元素。
请注意,你需要找的是数组排序后的第k个最大的元素,而不是第k个不同的元素。

示例1:
输入 : [3,2,1,5,6,4] 和  k = 2 
输出 : 5

示例2:
输入 : [3,2,3,1,2,4,5,5,6] 和  k = 4 
输出 : 4

这个题是一道非常重要的题,主要解决方法有三个,选择法,堆排序法和快速排序法。

选择法很简单,就是先遍历一遍找到最大的元素,然后再遍历一遍找第二大的,然后再遍历一遍找第三大的,直到第K次就找到了目标值了。但是这种方法只适合在面试的时候预热,面试官不会让你这么简单就开始写代码,因为该方法的时间复杂度为O(NK)。比较好的方法是堆排序法和快速排序法。快速排序我们后面再分析,这里先看堆排序如何解决问题。

这个题其实用大堆小堆都可以解决的,但是我们推荐“找最大用小堆,找最小用大堆,找中间用两个堆”,这样更容易理解,适用范围也更广。我们构造一个大小只有4的小根堆,为了更好说明情况,我们扩展一下序列[3,2,3,1,2,4,5,1,5,6,2,3]。

堆满了之后,对于小根堆,并一定所有新来的元素都可以入堆的,只有大于根元素的才可以插入到堆中,否则就直接抛弃。这是一个很重要的前提。

另外元素进入的时候,先替换根元素,如果发现左右两个子树都小该怎么办呢?很显然应该与更小的那个比较,这样才能保证根元素一定是当前堆最小的。假如两个子孩子的值一样呢?那就随便选一个。
在这里插入图片描述
新元素插入的时候只是替换根元素,然后重新构造成小堆,完成之后,你会神奇的发现此时根的根元素正好是第4大的元素。

这时候你会发现,不管要处理的序列有多大,或者是不是固定的,根元素每次都恰好是当前序列下的第K大元素。上面的图收篇幅所限,我们省略了部分调整环节,请读者自行脑补。

上的代码自己实现是非常困难的,我们可以使用jdk的优先队列来解决,其思路是很简单的。由于找第 K 大元素,其实就是整个数组排序以后后半部分最小的那个元素。因此,我们可以维护一个有 K 个元素的最小堆:

  • 如果当前堆不满,直接添加;
  • 堆满的时候,如果新读到的数小于等于堆顶,肯定不是我们要找的元素,只有新遍历到的数大于堆顶的时候, 才将堆顶拿出,然后放入新读到的数,进而让堆自己去调整内部结构。

说明:这里最合适的操作其实是replace(),即直接把新读进来的元素放在堆顶,然后执行下沉(siftDown())操作。Java当中的PriorityQueue没有提供这个操作,只好先poll()再offer()。

优先队列的写法就很多了,这里只例举一个有代表性的,其它的写法大同小异,没有本质差别。

import java.util.PriorityQueue;
public class Solution {
	public int findKthLargest(int[] nums, int k) {
		if(k>n){
			return -1;
		}
		int len = nums.length;
		// 使用一个含有k个元素的最小堆
		PriorityQueue<Integer> minHeap = new PriorityQueue<>(k, (a, b) -> a - b);
		for (int i = 0; i < k; i++) {
			minHeap.add(nums[i]);
		}
		for (int i = k; i < len; i++) {
			// 看一眼,不拿出,因为有可能没有必要替换 Integer topEle = minHeap.peek();
			// 只要当前遍历的元素比堆顶元素大,堆顶弹出,遍历的元素进去
			if (nums[i] > topEle) {
				minHeap.poll();
				minHeap.offer(nums[i]);
			}
		}
		return minHeap.peek();
	}
}

1.3.2 LeetCode703 数据流中找第K大元素

这个题与剑指offer的59题基本一致。先看要求:

设计一个找到数据流中第 k 大元素的类(class)。注意是排序后的第 k 大元素,不是第 k 个不同的元素。 请实现 KthLargest 类:
1、KthLargest(int k, int[] nums) 使用整数 k 和整数流 nums 初始化对象。
2、int add(int val) 将val 插入数据流 nums 后,返回当前数据流中第 k 大的元素。 

示例:

输入:
["KthLargest", "add", "add", "add", "add", "add"]
[[3, [4, 5, 8, 2]], [3], [5], [10], [9], [4]]

输出:[null, 4, 5, 5, 8, 8]

解释:
KthLargest kthLargest = new KthLargest(3, [4, 5, 8, 2]);
kthLargest.add(3); // 返回 4
kthLargest.add(5); // 返回 5
kthLargest.add(10); // 返回 5
kthLargest.add(9); // 返回 8
kthLargest.add(4); // 返回 8

分析

这个题几乎只能用堆来进行。我们可以把流数据换成无穷大的数组,大数据等等都是一样的解法。

我们需要先用给定的元素{4, 5, 8, 2}构造一个大小为K=3的小根堆(题目要求找第几小,堆就多大),新元素来的时候与根元素对比:

  • 如果新元素比根元素大,则删除根节点,并将新元素入堆
  • 如果新元素比根节点小,则不做处理
  • 需要求第K大时,返回根节点就行了

解题思路与上面一题的完全一致。代码如下:

class KthLargest {
	PriorityQueue<Integer> pq;
	int k;

	public KthLargest(int k, int[] nums) {
		this.k = k;
		pq = new PriorityQueue<Integer>();
		for (int x : nums) {
			add(x);
		}
	}

	public int add(int val) {
		pq.offer(val);
		if (pq.size() > k) {
			pq.poll();
		}
		return pq.peek();
	}
}

反过来,如果找第K小呢?就要用大根堆了。

所以我们说:找第K大用小根堆,找第K小用大根堆。

1.3.3 堆排序原理

前面介绍了如何用堆来进行特殊情况的查找,堆的另一个很重要的作用是可以进行排序,那怎么排的呢?其实非常简单,我们知道在大顶堆中,根节点是整个结构最大的元素,我先将其拿走,剩下的重排,此时根节点就是第二大的元素,我再将其拿走,再排,依次类推。最后堆只剩一个元素的时候,是不是拿走的数据也就排好序了?

具体来说,建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。

这个过程有点类似上面讲的“删除堆顶元素” 的操作,当堆顶元素移除之后,我们把下标为 n 的元素放到堆顶,然后再通过堆化的方法,将剩下的 n-1 个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是 n-1 的位置,一直重复这个过程,直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了。

当然在上面的过程中,放到最后一个位置的元素就不参与排序和计算了。

看一个例子,我们对上面第一章的序列 [12 23 54 2 65 45 92 47 204 31]进行排序,首先构建一个大顶堆,然后每次我们都让根元素出堆,剩下的继续调整为大顶堆:
在这里插入图片描述
这时候你会发现出堆的序列刚好是: 204、92、65、54、47、45… 。也就是刚好是从大到小的顺序排列的。 所以我们可以明白 ,如果是一个小顶堆,那自然是升序的。所以在排序的时候:

排序:升序用小,降序用大。

这个与前面的查找是相反的。

明白了这几个堆的特征,再做相关题目就毫无压力了。

1.3.4 Leetcode23 合并K个排序链表

给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。

输入: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

这个问题五六种方法,我们现在就来看堆排序如何解决。因为每个队列都是从小到大排序的,我们每次都要找最小的元素,所以我们要用小根堆,构建方法和操作与大顶堆完全一样,不同的是每次比较谁更小。使用堆合并的策略是不管几个链表,最终都是按照顺序来的。每次都将剩余节点的最小值加到输出链表尾部,然后进行堆调整,最后 堆空的时候,合并也就完成了。

还有一个问题,这个堆应该定义为多大呢?给了几个链表,堆就定义多大。

public ListNode mergeKLists(ListNode[] lists) {
	if (lists == null || lists.length == 0) 
		return null;

	PriorityQueue<ListNode> q = new PriorityQueue<>(Comparator.comparing(node ->
node.val));
	for (int i = 0; i < lists.length; i++) {
		if (lists[i] != null) {
			q.add(lists[i]);
		}
	}

	ListNode dummy = new ListNode(0);
	ListNode tail = dummy;

	while (!q.isEmpty()) {
		tail.next = q.poll();
		tail = tail.next;
		if (tail.next != null) {
			q.add(tail.next);
		}
	}
	return dummy.next;
}

1.3.5 LeetCode239 滑动窗口最大值

这道题与剑指offer59类似,我们也是先看题意:

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回 滑动窗口中的最大值 。

示例:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

分析

遇到这种“最大值”或者“最小值” ,我们首先考虑优先队列(堆)看看能不能解决问题,其中的大根堆可以帮助我们实时维护一系列元素中的最大值。

这个题其实就是每次框移动的时候,都输出框的最大值。框的大小就是堆的大小,滑动窗口移动的时候,堆就跟着调整元素。具体来说:

初始时,我们将数组 nums 的前 k 个元素放入优先队列中。每当我们向右移动窗口时,我们就可以把一个新的元素放入优先队列中,此时堆顶的元素就是堆中所有元素的最大值。然而这个最大值可能并不在滑动窗口中,在这种情况下,这个值在数组 nums 中的位置出现在滑动窗口左边界的左侧。因此,当我们后续继续向右移动窗口时,这个值就永远不可能出现在滑动窗口中了,我们可以将其永久地从优先队列中移除。

我们不断地移除堆顶的元素,直到其确实出现在滑动窗口中。此时,堆顶元素就是滑动窗口中的最大值。为了方便判断堆顶元素与滑动窗口的位置关系,我们可以在优先队列中存储二元组 (num,index),表示元素 num 在数组中的下标为index。

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        int n = nums.length;
        PriorityQueue<int[]> pq = new PriorityQueue<int[]>(new Comparator<int[]>() {
            public int compare(int[] pair1, int[] pair2) {
                return pair1[0] != pair2[0] ? pair2[0] - pair1[0] : pair2[1] - pair1[1];
            }
        });
        for (int i = 0; i < k; ++i) {
            pq.offer(new int[]{nums[i], i});
        }
        int[] ans = new int[n - k + 1];
        ans[0] = pq.peek()[0];
        for (int i = k; i < n; ++i) {
            pq.offer(new int[]{nums[i], i});
            while (pq.peek()[1] <= i - k) {
                pq.poll();
            }
            ans[i - k + 1] = pq.peek()[0];
        }
        return ans;
    }
}

本题还可以用单调队列等方法进行。

1.3.6 LeetCode347 前K个高频元素

给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按任意顺序返回答案。

示例 1:
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]

示例 2:
输入: nums = [1], k = 1
输出: [1]

分析

这个题的关键是要先确定元素的出现频率,为此先用Hash记录每个元素的出现次数。然后再使用堆。具体方法是:

首先遍历整个数组,并使用哈希表记录每个数字出现的次数,并形成一个「出现次数数组」。找出原数组的前 k 个 高频元素,就相当于找出「出现次数数组」的前 kk 大的值。

最简单的做法是给「出现次数数组」排序。但由于可能有 O(N)个不同的出现次数(其中 N 为原数组长度),故总的算法复杂度会达到 O(NlogN) ,不满足题目的要求。

在这里,我们可以利用堆的思想:建立一个小顶堆,然后遍历「出现次数数组」:

  • 如果堆的元素个数小于 kk ,就可以直接插入堆中。
  • 如果堆的元素个数等于 k,则检查堆顶与当前出现次数的大小。如果堆顶更大,说明至少有 k 个数字的出现次数比当前值大,故舍弃当前值;否则,就弹出堆顶,并将当前值插入堆中。

看代码:

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        Map<Integer, Integer> occurrences = new HashMap<Integer, Integer>();
        for (int num : nums) {
            occurrences.put(num, occurrences.getOrDefault(num, 0) + 1);
        }

        // int[] 的第一个元素代表数组的值,第二个元素代表了该值出现的次数
        PriorityQueue<int[]> queue = new PriorityQueue<int[]>(new Comparator<int[]>() {
            public int compare(int[] m, int[] n) {
                return m[1] - n[1];
            }
        });
        for (Map.Entry<Integer, Integer> entry : occurrences.entrySet()) {
            int num = entry.getKey(), count = entry.getValue();
            if (queue.size() == k) {
                if (queue.peek()[1] < count) {
                    queue.poll();
                    queue.offer(new int[]{num, count});
                }
            } else {
                queue.offer(new int[]{num, count});
            }
        }
        int[] ret = new int[k];
        for (int i = 0; i < k; ++i) {
            ret[i] = queue.poll()[0];
        }
        return ret;
    }
}

1.3.7 LeetCode692 前K个高频单词

先看题目要求:

给一非空的单词列表,返回前 k 个出现次数最多的单词。

返回的答案应该按单词出现频率由高到低排序。如果不同的单词有相同出现频率,按字母顺序排序。

示例 1:
输入: words = ["i", "love", "leetcode", "i", "love", "coding"], k = 2
输出: ["i", "love"]
解析: "i" 和 "love" 为出现次数最多的两个单词,均为2次。
    注意,按字母顺序 "i" 在 "love" 之前。

示例 2:
输入: ["the", "day", "is", "sunny", "the", "the", "the", "sunny", "is", "is"], k = 4
输出: ["the", "is", "sunny", "day"]
解析: "the", "is", "sunny" 和 "day" 是出现次数最多的四个单词,
    出现次数依次为 4, 3, 2 和 1 次。

这个题与上面的基本一致,不同的是需要针对单词的特征做一些处理罢了。

如果不使用堆,我们首先想到的也是先处理出每一个单词出现的频率,然后依据每个单词出现的频率降序排序,最后返回前 k个字符串即可。所以写代码的关键在于排序。如果对java高级特征比较熟悉,我们可以采用lambda表达式来简化操作,代码如下:

class Solution {
    public List<String> topKFrequent(String[] words, int k) {
    	// 1.初始化哈希表 key->字符串 value->出现的次数。
        Map<String, Integer> count = new HashMap<String, Integer>();
        for (String word : words) {
            count.put(word, cnt.getOrDefault(word, 0) + 1);
        }
        // 2.⽤list存储字符key,然后自定义Comparator比较器对value进行排序
        List<String> candidates = new ArrayList<>(count.keySet());
        // 此处为使用lambda 写法
        fcandidates.sort((a, b) -> {
			// 字符串频率相等按照字典序比较使得大的在堆顶,Java 可以直接使用compareTo方法即可。
			if (count.get(a).equals(count.get(b))) {
				return a.compareTo(b);
			} else {
				// 字符串频率不等则按照频率排列。
				return count.get(b) - count.get(a);
			}
		});
		// 3.截取前 K 大个高频单词返回结果。
		return candidates.subList(0, k);
    }
}

这就避免了复杂的排序操作。

第二种方式是使用小根堆。先用哈希表统计单词的出现频率,然后因为题目要求前 K 大。所以构建一个大小为 K的小根堆按照上述规则自定义排序的比较器。然后依次将单词加入堆中,当堆中的单词个数超过 K 个后,我们需要弹 出顶部最小的元素使得堆中始终保留 K 个元素,遍历完成后剩余的 K 个元素就是前 K 大的。最后我们依次弹出堆中的 K个元素加入到所求的结果集合中。

注意:因为构建的是小根堆,所以从顶部弹出的元素顺序是从小到大的,所以最后我们还需要反转集合。

public class Solution {
	public List<String> topKFrequent(String[] words, int k) { 
		// 1.先用哈希表统计单词出现的频率
		Map<String, Integer> count = new HashMap();
		for (String word : words) {
			count.put(word, count.getOrDefault(word, 0) + 1);
		}
		// 2.构建小根堆,这里需要自己构建比较规则,此处为lambda写法,Java的优先队列默认实现就是小根堆
		PriorityQueue<String> minHeap = new PriorityQueue<>((s1, s2) -> {
			if (count.get(s1).equals(count.get(s2))) {
				return s2.compareTo(s1);
			} else {
				return count.get(s1) - count.get(s2);
			}
		});
		// 3.依次向堆加入元素。
		for (String s : count.keySet()) {
			minHeap.offer(s);
			// 当堆中元素个数大于k个的时候,需要弹出堆顶最小的元素。
			if (minHeap.size() > k) {
				minHeap.poll();
			}
		}
		// 4.依次弹出堆中的K个元素,放入结果集合中。
		List<String> res = new ArrayList<String>(k);
		while (minHeap.size() > 0) {
			res.add(minHeap.poll());
		}
		// 5.注意最后需要反转元素的顺序。 
		Collections.reverse(res);
		return res;
	}
}

上面的单词同样可以换成ip地址,url等等,就又产生了很多算法考题,但是原理都是一样的。

2. 平衡树和AVL树

本部分主要是理解性质的内容,不写代码。平衡的概念和原理在做其他题目的时候要考虑,这里只是为了扫盲。

这部分我们来分析平衡二叉树相关的问题,与前面的的不同,我们这一部分主要是理解,一般不需要写代码去实现。前面我们介绍的搜索树可以方便的执行查找,但是如果不加约束,搜索树中的元素频繁变化可能会发生这样的情况:
在这里插入图片描述
对于一颗树(不管是否为二叉树),查找的次数取决于树的深度,深度越小,查找速度越快,所以右侧的树查找效率不高。

为此,我们需要采取一些措施来进行调整,这就是平衡二叉树,简称平衡树(AVL树),平衡树的特点是树上任一 节点的左右子树高度之差都不超过1。

专业的名字叫平衡因子=左子树深度-右子树深度。例如上面的两幅图我们分别标记了每个节点的平衡因子。

构造平衡二叉树,基本原理都是在插入的时候先确定插入是否导致节点出现不平衡的情况,如果出现了就要进行调整,例如对上图插入67,此时从70开始变得不平衡,所以要将其调整成右侧的样子:
在这里插入图片描述
我们可以看到主要将最早开始不平衡的位置,也就是70的位置进行调整,就可以重新实现平衡,因此我们就将最早出现不平衡的位置称之为“最小不平衡子树” ,我们每次调整的对象也都是最小不平衡子树。

不平衡在一般的教材会说有四种情况,但就两种思路,LL和RR的思路是一样的,LR和RL的思路也是一样的。分成 四种是为了方便分析的。假如最小不平衡位置为A,那么:

  • LL:在A的左孩子的左子树中插入导致不平衡
  • LR:在A的左孩子的右子树中插入导致不平衡
  • RR:在A的右孩子的右子树中插入导致不平衡
  • RL:在A的左孩子的右子树中插入导致不平衡

这四种情况的基本策略都是一个“老子不行,儿子顶上” ,先看LL:
在这里插入图片描述
LL的意思就是在A的左孩子的左子树B中插入导致不平衡,上图中红色位置表示即将插入元素的子树,H表示各个子树的深度。从左图可以看到,BL和BR的深度都是H,假如在BL中再添加一个元素将导致BL的深度变成H+1,这样A 的平衡因子就是2,出现了不平衡。

如何解决呢? ”老子不行,儿子顶上“ ,将A退位, B继位成根节点,同时将B的右子树送给A做左子树(想继位就要付出点代价)。

这样一来,对于A,左右子树高度都是H,因此平衡因子为0。对于B来说,左子树插入新元素后BL的高度是H+1, 而右子树由父皇管着,高度也是H+1,所以平衡因子也是0。

对于RR,就是在A的右孩子的右子树中插入导致不平衡。也就是将BR中插入一个元素导致其高度变成H+1了处理方式也是一样的,只不过左右反了一下,如图:
在这里插入图片描述
这里可以看到不管LL还是RR,出现不平衡时,都是 "将皇位给深度更大的皇子 " ,然后自己带着两个比较弱的皇子偏安一隅。

再看假如新插入的节点在内侧,导致不平衡又会出现什么情况呢?也就是LR和RL。

我们以LR为例说明,假如是在左子树的右孩子上添加,此时再按照上面的策略就不行了,因为调整后B的右子树又多了,如下图。
在这里插入图片描述
此时只是将出现问题的BR从左子树交到了右子树中,根节点仍然是不平衡的。此时BR需要再拆分成一个平衡的子树,也就是上面的C拆分成CL和CR,高度分别都是H-1。此时在B和C之间进行左旋转,如图:
在这里插入图片描述
选装之后的结构如图3所示,很明显此时A又不平衡了,此时再在A和C之间进行一次右旋转, C成为根节点。
在这里插入图片描述
此时可以看到如果插入的位置再CL上也是一样的,只不过CL高度是H,而CR是H-1,而树本身仍然是平衡的。 对于RL的操作与上面的一致,只不过左右反了而已。

这里的C从一个无名小卒两次飞跃直接成了根节点,这让我想起一部曾经万人空巷的老电视剧《上海滩》的一个情节,周润发饰演的许文强刚入帮会美乐戏院时排行第三,前面有老板和炳哥,他该怎么迅速上位呢?首先在老大和老二之间制造矛盾,老二被逼之下杀了老板,然后许文强以给老大报仇为由就将老二给干掉了,然后自己顺理成章当了老大,这里是一样的道理。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值