title: Day32-数据结构与算法-树进阶
date: 2020-11-18 14:46:02
author: 子陌
常用的经典数据结构
- 如果设计一种数据结构,用来存放整数,要求提供三个接口
- 获取元素
- 获取最大值
- 删除最大值
获取最大值 | 删除最大值 | 添加元素 | ||
---|---|---|---|---|
动态数组/双向链表 | O(n) | O(n) | O(1) | |
有序动态数组/双向链表 | O(1) | O(1) | O(n) | 全排序有点浪费 |
BBST(平衡二叉树) | O(logn) | O(logn) | O(logn) | 杀鸡用了牛刀 |
-
有没有更优的数据结构来实现:
堆:获取最大值 O(1)、删除最大值 O(logn)、添加元素 O(logn)。
Top K问题
从海量数据N中找出前K个数据,例如从100w整数中找出最大的100个整数
- 如果使用排序算法进行全排序,需要O(nlogn)的时间复杂度
- Top K问题的解法之一:可以利用数据结构“堆”来解决(O(nlogk) k远远小于n)
堆(Heap)
- 堆也是一种树状的数据结构(不要跟内存模型中的“堆空间”混淆),常见的堆实现有
- 二叉堆(Binary Heap,完全二叉堆)
- 多叉堆(D-heap、D-ary Heap)
- 索引堆(Index Heap)
- 二项堆(Binomial Heap)
- 斐波那契堆(Fibonacci Heap)
- 左倾堆(Leftist Heap,左式堆)
- 斜堆(Skew Heap)
- 堆的一个重要性质:任意节点的值总是 ≥(≤)子节点的值
- 如果任意节点的值总是 ≥子节点的值,称为:最大堆、大根堆、大顶堆
- 如果任意节点的值总是≤子节点的值,称为:最小堆、小根堆、小顶堆
- 由此可见,堆中的元素必须具备可比较性(跟二叉搜索树一样)
堆的接口设计
package com.zimo.堆;
/**
* 堆的公共接口设计
*
* @author Liu_zimo
* @version v0.1 by 2020/11/18 15:14:33
*/
public interface Heap<E> {
int size(); //元素的数量
boolean isEmpty(); //是否为空
void clear(); //清空
void add(E element); //添加元素
E get(); //获得堆顶元素
E remove(); //删除堆顶元素
E replace(E element); //删除堆顶元素的同时插入一个新元素
}
堆的公共抽象类
package com.zimo.堆;
import java.util.Comparator;
/**
* 抽象堆 - 基类
*
* @author Liu_zimo
* @version v0.1 by 2020/11/19 10:37
*/
public abstract class AbstractHeap<E> implements Heap<E> {
protected int size;
protected Comparator<E> comparator;
public AbstractHeap() {
this(null);
}
public AbstractHeap(Comparator<E> comparator) {
this.comparator = comparator;
}
@Override
public int size() {
return size;
}
@Override
public boolean isEmpty() {
return size == 0;
}
// 比较接口
protected int compare(E e1, E e2){
return comparator != null ?comparator.compare(e1, e2) : ((Comparable<E>)e1).compareTo(e1);
}
}
二叉堆
二叉堆的逻辑结构就是一棵完全二叉树,所以也叫完全二叉堆
- 鉴于完全二叉树的一些特性,二叉堆的底层(物理结构)一般用数组实现即可
- 索引i的规律(n为元素数量)
- 如果 i = 0,它是根节点
- 如果 i >0,它的父节点编号为floor((i - 1) / 2)
- 如果 2i + 1 ≤ n - 1,它的左子节点索引为2i + 1
- 如果 2i + 1 > n - 1,它无左子节点
- 如果 2i + 2 ≤ n - 1,它的右子节点索引为2i + 2
- 如果 2i + 2 > n - 1,它无右子节点
最大堆 - 添加
-
循环执行以下操作(图中的80简称为node)
-
如果node > 父节点
与父节点交换位置
-
如果node ≤ 父节点,或者node没有父节点
退出循环
-
-
这个过程,叫做上滤(Sift Up)
- 时间复杂度为:O(logn)
最大堆 - 删除
-
用最后一个节点覆盖根节点
-
删除最后一个节点
-
循环执行以下操作(图中的43简称为node)
-
如果node < 子节点
与最大子节点交换位置
-
如果node ≥ 子节点,或者node没有子节点
退出循环
-
-
这个过程,叫做下滤(Sift Down)
- 时间复杂度为:O(logn)
最大堆 - 批量建堆(Heapify)
- 批量建堆,有两种做法
- 自上而下的上滤(本质是添加)
- 自下而上的下滤(本质是删除)
- 所有节点的深度之和
- (完全二叉树)仅仅是叶子节点,就有近n/2个,而且每个叶子节点的深度都是O(logn)级别的
- 因此,在叶子节点这一块,就达到了O(nlogn)级别
- O(nlogn)的时间复杂度足以利用排序算法对所有节点进行全排序
- 所有节点的高度之和
- 假设是满二叉树,节点总个数为n,树高为h,那么n = 2h - 1
- 所有节点的树高之和H(n) = 20 * (h - 0) + 21 * (h - 1) + 22 + … +2h - 1 * [h - (h - 1)]
- H(n) = h * (20+21+22+…+2h-1)-[1 * 21+2 * 22+3 * 23+…+(h - 1) * 2h-1]
- H(n) = h * (2h - 1) - [(h - 2) * 2h + 2]
- H(n) = h * 2h - h - h * 2h + 2h+1 - 2
- H(n) = 2h+1 - h - 2 = 2 * (2h - 1) - h = 2n - h = 2n - log2(n + 1) = O(n)
package com.zimo.堆;
import java.util.Comparator;
import java.util.Objects;
/**
* 二叉堆 - 最大堆优化
*
* @author Liu_zimo
* @version v0.1 by 2020/11/19 10:42
*/
public class BinaryHeap_1<E> extends AbstractHeap<E> {
private E[] elements;
private static final int DEFAULT_CAPACITY = 10;
public BinaryHeap_1() {
this(null,null);
}
public BinaryHeap_1(Comparator<E> comparator) {
this(null, comparator);
}
public BinaryHeap_1(E[] elements,Comparator<E> comparator) {
super(comparator);
if (elements == null || elements.length == 0){
this.elements = (E[]) new Object[DEFAULT_CAPACITY];
}else {
size = elements.length;
int capacity = Math.max(elements.length, DEFAULT_CAPACITY);
this.elements = (E[]) new Object[capacity];
for (int i = 0; i < elements.length; i++) {
this.elements[i] = elements[i];
}
heapify();
}
}
public BinaryHeap_1(E[] elements) {
this(elements, null);
}
@Override
public void clear() {
for (int i = 0; i < size; i++) {
elements[i] = null;
}
size = 0;
}
@Override
public void add(E element) {
elementNotNullCheck(element);
ensuerCapacity(size + 1);
elements[size++] = element;
siftUp(size - 1);
}
@Override
public E get() {
emptyCheck();
return elements[0];
}
@Override
public E remove() {
emptyCheck();
int lastIndex = --size;
E root = elements[0];
elements[0] = elements[lastIndex];
elements[lastIndex] = null;
siftDown(0);
return root;
}
@Override
public E replace(E element) {
elementNotNullCheck(element);
E root = null;
if (size == 0) {
elements[0] = element; // 如果添加根节点
size = 1;
}else {
root = elements[0];
elements[0] = element; // 不是根节点
siftDown(0); // 下滤
}
return root;
}
/**
* 批量建堆:自上而下的上滤、自下而上的下滤
*/
private void heapify() {
// 自上而下的上滤
// for (int i= 1; i < size; i++){
// siftUp(i);
// }
// 自下而上的下滤
for (int i = (size >> 1) - 1; i >= 0; i--){
siftDown(i);
}
}
// 扩容g
private void ensuerCapacity(int capacity) {
int oldCapacity = elements.length; // 旧数组的长度
if (oldCapacity >= capacity) return;
// 新容量为旧容量的1.5倍
int newCapacityLength = oldCapacity + (oldCapacity >> 1); // 等价于 oldCapacity * 1.5
E[] newElements = (E[]) new Object[newCapacityLength];
for (int i = 0; i < size; i++) {
newElements[i] = elements[i];
}
elements = newElements;
}
/**
* 让index位置的元素上滤
* @param index
*/
private void siftUp(int index){
E element = elements[index]; // 获取要上滤的节点
while (index > 0) {
int parentIndex = (index - 1) >> 1; // 获取父节点 (index - 1) / 2
E parent = elements[parentIndex];
if (compare(element, parent) < 0) break;
// 交换p、e的内容,重新赋值index
elements[index] = parent;
index = parentIndex;
}
elements[index] = element;
}
/**
* 让index位置的元素下滤
* @param index
*/
private void siftDown(int index) {
E element = elements[index];
int half = size >> 1; // 计算非叶子节点数量
// 必须保证index位置是非叶子节点
while(index < half) {
// 小于第一个叶子节点的索引(第一个叶子节点的索引 == 非叶子节点的数量)
// index的节点有2种情况:1.只有左子节点 2.同时有左右节点
int childIndex = (index << 1) + 1; // 左子节点 2i + 1
E childElement = elements[childIndex];
// 右子节点
int rightIndex = childIndex + 1;
if (rightIndex < size && compare(elements[rightIndex], childElement) > 0){
childElement = elements[childIndex = rightIndex];
}
if (compare(element, childElement) >= 0) break;
// 将子节点存放到index位置
elements[index] = childElement;
// 重新设置index
index = childIndex;
}
elements[index] = element;
}
// 检测堆是否为空
private void emptyCheck(){
if (size == 0){
throw new IndexOutOfBoundsException("Heap is Empty!");
}
}
// 检测元素是否为空
private void elementNotNullCheck(E element){
if (element == null){
throw new IllegalArgumentException("element must not be null!");
}
}
}
利用最大堆实现最小堆
public static void main(String[] args) {
Integer[] data = {
1, 2, 3, 4, 87, 54, 346, 154};
BinaryHeap_1<Integer> heap = new BinaryHeap_1<>(data, new Comparator<Integer>() {
@Override
public int compare(Integer t1, Integer t2) {
return t2 - t1; // 最小堆; t1 - t2 最大堆
}
});
System.out.println(heap.toString());
}
练习:Top K
从海量数据n中找出前k个最大值(使用小顶堆实现)
- 新建一个小顶堆
- 扫描n个整数
- 先将遍历到的前k个数据放入小顶堆中
- 从第k + 1个数开始,如果大于堆顶元素,就使用replace操作(删除堆顶元素,将第k + 1个数据添加到堆中)
- 扫描完毕剩下的就是最大的前k个数
public static void main(String[] args) {
// 新建一个小顶堆
BinaryHeap_1<Integer> heap = new BinaryHeap_1<>(new Comparator<Integer>() {
@Override
public int compare(Integer t1, Integer t2) {
return t2 - t1;
}
});
// 找出最大的前k个数
int k = 5;
Integer[] data = {
51, 30, 39, 92, 73, 24, 87, 54, 346, 154};
for (int i = 0; i < data.length; i++){
if(heap.size() < k){
// 前k个数添加到小顶堆
heap.add(data[i]) // logk
}else if(heap.get() < data[i]){
// k + 1个数大于堆顶元素
heap.replace(data[i]); // logk
}
}
}
优先级队列(Priority Queue)
-
优先级队列也是一个队列,因此也是提供以下接口:
int size();
元素的数量boolean isEmpty();
是否为空void enQueue(E element);
入队E deQueue();
出队E front();
获取队列的头元素void clear();
清空
-
普通的队列是FIFO原则,也就是先进先出
-
优先级队列则是按照优先级高低进行出队,比如将优先级最高的元素作为队头优先出队
优先队列的底层实现
- 根据优先队列的特点,很容易想到:可以直接利用二叉堆作为优先队列的底层实现
package com.zimo.堆.优先级队列;
import com.zimo.堆.BinaryHeap_1;
import java.util.Comparator;
/**
* 优先级队列 - 使用堆实现
*
* @author Liu_zimo
* @version v0.1 by 2020/11/27 11:32:40
*/
public class PriorityQueue<E> {
private BinaryHeap_1<E> heap;
public PriorityQueue() {
this(null);
}
public PriorityQueue(Comparator<E> comparator) {
this.heap = new BinaryHeap_1<>(comparator);
}
public int size(){
return heap.size();
}
public boolean isEmpty(){
return heap.isEmpty();
}
public void enQueue(E element){
heap.add(element);
}
/**
* 从队头弹出元素
* @return 弹出的元素
*/
public E deQueue(){
return heap.remove();
}
/**
* 获取队头元素
* @return 队头元素
*/
public E front(){
return heap.get();
}
/**
* 清空队列元素
*/
public void clear(){
heap.clear();
}
}
哈夫曼树
哈夫曼编码(Huffman Coding)
-
哈夫曼编码,又称为霍夫曼编码,它是现代压缩算法的基础
-
假设要把字符【ABBBCCCCCCCCDDDDDDEE】转成二进制编码进行传输
-
可以转成ASCII编码(6569,10000011000101),但是有点冗长,如果希望编码更短呢?“
1000001 1000010 1000010 1000010 … -
可以事先约定5个字母对应的二进制
A B C D E 000 001 010 011 100 - 对应的二进制编码:000001001001010010010010010010010010011011011011011011100100
- 一共20个字母,转成了60个二进制位
-
如果使用哈夫曼编码,可以压缩至41个二进制位,约为原来长度的68.3%
-
先计算出每个字母的出现频率(权值,这里直接用出现次数)
A B C D E 1 3 8 6 2 -
利用这些权值,构建一棵哈夫曼树(又称为霍夫曼树、最优二叉树)
- 以权值作为根节点构建n棵二叉树,组成森林
- 在森林中选出两个根节点最小的树合并,作为新树的左右子树,且新树的根节点为其左右子树根节点之和
- 从森林中删除刚才选取的2棵树,并将新树加入森林
- 重复2、3步骤,直到森林只剩一棵树为止,该树即为哈夫曼树
-
-
构建哈夫曼编码
-
left为0,right为1,可以得出5个字母对应的哈夫曼编码
A B C D E 1110 110 0 10 1111 - 【ABBBCCCCCCCCDDDDDDEE】的哈夫曼编码是:1110110110110000000001010101010101111
-
总结:
- n个权值构建出来的哈夫曼树拥有n个叶子节点
- 每个哈夫曼编码都不是另一个哈夫曼编码的前缀
- 哈夫曼树是带权路径长度最短的树,权值较大的节点离根较近
- 带权路径长度:树中所有的叶子节点的权值乘上其到根节点的路径长度。与最终的哈夫曼编码总长度成正比关系。
Trie
-
如何判断一堆不重复的字符串是否以某个前缀开头?
- 用Set/Map存储字符串
- 遍历所有字符串进行判断
- 时间复杂度O(n)
-
有没有更优的数据结构实现前缀搜索?
- Trie
-
Trie也叫做字典树、前缀树(Prefix Tree)、单词查找树
-
Trie搜索字符串的效率主要跟字符串的长度有关
-
假设使用Trie存储cat、dog、doggy、does、cast、add六个单词
接口设计与实现
-
int size();
元素的数量 -
boolean isEmpty();
是否为空 -
boolean contains(String str);
是否存在这个元素 -
void add(String str);
添加(类似set)V add(String str, V value);
(类似map) -
void remove(String str);
删除V remove(String str);
-
void clear();
清空 -
boolean starsWith(String prefix);
是否包含prefix前缀
package com.zimo.Trie;
import java.util.HashMap;
/**
* Trie实现
*
* @author Liu_zimo
* @version v0.1 by 2020/11/27 18:09
*/
public class Trie<V> {
private int size;
private Node<V> root;
public int size(){
return size;
}
public boolean isEmpty(){
return size == 0;
}
public void clear(){
size = 0;
root = null;
}
public V get(String str){
Node<V> node = node(str);
return node != null && node.word ? node.value : null;
}
public boolean contains(String key){
Node<V> node = node(key);
return node != null && node.word;
}
public V add(String key, V value){
keyCheck(key);
if (root == null){
root = new Node<>(null);
}
Node<V> node = this.root;
int length = key.length();
for (int i = 0; i < length; i++) {
char c = key.charAt(i);
boolean emptyChildren = node.children == null;
Node<V> childNode = emptyChildren ? null : node.children.get(c);
if (childNode == null) {
childNode = new Node<>(node);
childNode.character = c;
node.children = emptyChildren ? new HashMap<>() : node.children;
node.children.put(c, childNode);
}
node = childNode;
}
if (!node.word){
node.word = true;
node.value = value;
size++;
return null;
}
V oldValue = node.value;
node.value = value;
return oldValue;
}
public V remove(String key){
// 找到最后一个节点
Node<V> node = node(key);
// 如果不是单词结尾,不用做任何处理
if (node == null || !node.word) return null;
size--;
V oldValue = node.value;
if (node.children != null && node.children.isEmpty()){
node.word = false;
node.value = null;
return oldValue;
}
// 没有子节点
Node<V> parent = null;
while ((parent = node.parent) != null){
parent.children.remove(node.character);
if (parent.word || !parent.children.isEmpty()) break;
node = parent;
}
return oldValue;
}
public boolean startsWith(String prefix){
return node(prefix) != null;
}
private Node<V> node(String key){
keyCheck(key);
Node<V> node = this.root;
int length = key.length();
for (int i = 0; i < length; i++) {
if (node == null || node.children == null || node.children.isEmpty()) return null;
char c = key.charAt(i);
node = node.children.get(c);
}
return node;
}
private void keyCheck(String key){
if (key == null || key.length() == 0){
throw new IllegalArgumentException("key must not be null");
}
}
private static class Node<V>{
Node<V> parent;
Character character;
HashMap<Character, Node<V>> children;
V value;
boolean word; // 是否为单词的结尾
public Node(Node<V> parent) {
this.parent = parent;
}
}
}
Trie总结
- Trie的优点:搜索前缀的效率主要跟前缀的长度有关
- Trie的缺点:需要耗费大量的内存,因此还有待改进
- 更多Trie相关的数据结构和算法
- Double-array Trie、Suffix Tree、Patricia Tree、Crit-bit Tree、AC自动机
总结:常用的经典数据结构
zig、zag
- 有些教程里面
- 把右旋转叫做zig,旋转之后的状态叫做zigged
- 把左旋转叫做zag,旋转之后的状态叫做zagged
满二叉树定义
- 满二叉树:最后一层节点的度都为0,其他节点的度都为2
完全二叉树
- 完全二叉树:对节点从上至下、左至右开始编号,其所有编号都能与相同高度的满二叉树中的编号对应
二叉树
四则运算
- 四则运算的表达式可分为三种:
- 前缀表达式(prefix expression),又称为波兰表达式
- 中缀表达式(infix expression)
- 前缀表达式(postfix expression),又称为逆波兰表达式
前缀表达式 | 中缀表达式 | 后缀表达式 |
---|---|---|
+ 1 2 | 1 + 2 | 1 2 + |
+ 2 * 34 | 2 + 3 * 4 | 2 3 4 * + |
+ 9 * - 4 1 2 | 9 + (4 - 1) * 2 | 9 4 1 - 2 * + |
表达式树
-
如果将表达式的操作数作为叶子节点,运算符作为父节点(假设只是四刚运算)
- 这些节点刚好可以组成一棵二叉树
- 比如表达式:A / B + C * D - E
-
如果对这棵二叉树进行遍历
-
前序遍历
- + / A B * C D E
刚好就是前缀表达式(波兰表达式) -
中序遍历
A / B + C * D - E
刚好就是中缀表达式
-
后序遍历
A B / C D * + E -
刚好就是后缀表达式(逆波兰表达式)
非递归遍历二叉树(前中后序)
前序遍历 - 非递归
-
利用栈实现
- 设置node = root
- 循环执行以下操作
- 如果node != null
- 对node进行访问
- node.right入栈
- 设置node = node.left
- 如果node == null
- 如果栈为空遍历结束
- 如果栈不为空,弹出栈顶元素并赋值给node
// 非递归前序遍历
public void preorder(Visitor<E> visitor){
if (visitor == null || root == null)return;
Node<E> node = this.root;
Stack<Node<E>> stack = new Stack<>();
while (true){
if (node != null) {
// 访问节点
if (visitor.visitor(node.element)) return;
// 右子节点入栈
if (node.right != null) stack.push(node.right);
// 向左走之前,把右节点入栈
node = node.left;
}else if (stack.isEmpty()){
return;
}else {
node = stack.pop(); // 处理右边
}
}
}
前序遍历 - 非递归2
-
利用栈实现(类似层序遍历,层序遍历先左后右,这个是先右后左)
- 将root入栈
- 循环执行以下操作
- 如果栈不为空
- 弹出栈顶,进行访问
- node.right != null,入栈
- node.left != null, 入栈
// 非递归前序遍历2
public void preorder2(Visitor<E> visitor){
if (visitor == null || root == null)return;
Stack<Node<E>> stack = new Stack<>();
stack.push(root);
while (!stack.isEmpty()){
Node<E> node = stack.pop();
if (visitor.visitor(node.element)) return; // 访问node节点
if (node.right != null){
stack.push(node.right);
}
if (node.left != null){
stack.push(node.left);
}
}
}
中序遍历 - 非递归
-
利用栈实现
- 设置node = root
- 循环执行以下操作
- 如果node != null
- node 入栈
- 设置node = node.left
- 如果node == null
- 如果栈为空遍历结束
- 如果栈不为空,弹出栈顶元素并赋值给node
- 对node访问
- 设置node = node.right
// 非递归中序遍历
public void inorder(Visitor<E> visitor){
if (visitor == null || root == null)return;
Node<E> node = this.root;
Stack<Node<E>> stack = new Stack<>();
while (true){
if (node.left!=null) {
stack.push(node);
node = node.left; // 往左走
}else if (stack.isEmpty()){
return;
}else {
node = stack.pop();
if (visitor.visitor(node.element)) return; // 访问node节点
node = node.right; // 让右边进行中序遍历
}
}
}
后序遍历 - 非递归
-
利用栈实现
- 将root入栈
- 循环执行以下操作,直到栈为空
- 如果栈顶节点是叶子节点或者上一次访问的节点是栈顶节点的子节点
- 弹出栈顶节点,进行访问
- 否则
- 将栈顶节点的right、left按顺序入栈
// 非递归后序遍历
public void postorder(Visitor<E> visitor){
if (visitor == null || root == null)return;
Node<E> prev = null; // 记录上次弹出的栈的节点
Stack<Node<E>> stack = new Stack<>();
stack.push(root);
while (!stack.isEmpty()){
Node<E> top = stack.peek();
if (top.isLeaf() || (prev != null && prev.parent == top)){
prev = stack.pop();
if (visitor.visitor(prev.element)) return; // 访问node节点
}else {
if (top.right != null){
stack.push(top.right);
}
if (top.left != null) {
stack.push(top.left);
}
}
}
}
源码地址:https://gitee.com/Liu_zimo/JavaNote/tree/master/%E7%AC%AC%E4%B8%80%E5%AD%A3%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/src/com/zimo