Week 4
优先队列 Priority Queues
接口和基本实现 APIs and Elementary Implementations
- 集合(Collections):插入和删除元素的基本数据结构,不同的集和区别在于如何选择下一个删除的元素
- 优先队列:对于全序的数据类型,其返回最大或者最小的元素
- API如下:(注意,要求泛型类型必须是Comparable的)
public class MaxPQ<Key extends Comparable<Key>> {
MaxPQ(); // create an empty priority queue
MaxPQ(Key[] a); // create a priority queue with given keys
void insert(Key v); // insert a key into the priority queue
Key delMax(); // return and remove the largest key
boolean isEmpty(); // is the priority queue empty?
Key max(); // return the largest key
int size(); // number of entries in the priority queue
}
-
挑战问题:从一个 N N N个项目的流中找到最大的 M M M个
- 排序:时间复杂度 N log N N \log N NlogN,空间需求为 N N N(不理想)
- 基本优先队列:时间复杂度 N M NM NM(过高),空间需求 M M M(最大容忍)
- 二元堆:时间复杂度 N log M N \log M NlogM(目前最理想),空间需求 M M M
- 理论最优:时间 N N N,空间 M M M
-
两种实现优先队列的思路:
- 不排序,使用普通的队列实现,在要求取出最大(小)元素时遍历并比较得到(寻找最大值代价较高)
public class UnorderedMaxPQ<Key extends Comparable<Key>> { private Key[] pq; // pq[i] = ith element on pq private int N; // number of elements on pq public UnorderedMaxPQ(int capacity) { pq = (Key[]) new Comparable[capacity]; } public boolean isEmpty() { return N == 0; } public void insert(Key x) { pq[N++] = x; } // 可以使用成倍数组 public Key delMax() { // 遍历挑出最大的 int max = 0; for (int i = 1; i < N; i++) if (less(max, i)) max = i; exch(max, N-1); return pq[--N]; } }
- 排序,每插入一个元素进行一次排序,在要求取出最大(小)元素时只需将队头元素取出(插入时代价较高)
- 目标:插入和寻找最大值的代价都能达到 log N \log N logN
二元堆 Binary Heap
-
类比完全二叉树
- 二叉树:其为空,或者其节点连接着左二叉子树或者右二叉子树
- 完全树:除了最底层节点外,整个树都是完美平衡的
- 性质: N N N个节点的完全二叉树的高度为 ⌊ log N ⌋ \lfloor \log N \rfloor ⌊logN⌋
-
二叉堆:一个堆排序的完全二叉树的数组表示
- 节点保存键值
- 堆有序:父节点键值不小于子节点键值
- 数组表示
- 起始索引为1
- 按照层级顺序排列节点(1层1个,2层2个,3层4个,……)
- 不需要任何显式链接(数组)
- 在整个树上的移动只是对索引的简单运算
-
二叉堆的性质:
a[1]
是最大的键值,也是二叉树的根节点- 使用对索引的计算在树上移动
- 节点
k
的父节点为k/2
- 节点
k
的子节点为2k
和2k+1
- 节点
-
在堆上更新值
-
场景1:当子节点键值变得比父节点大
- 将子节点键值与父节点交换
- 重复这一过程,直到恢复堆有序
private void swim(int k) { while (k > 1 && less(k/2, k)) { exch(k, k/2); k = k/2; } }
-
推广1:在堆上插入新值
- 将新节点放到堆的末尾
- 向上移动之
- 最多 1 + log N 1+\log N 1+logN次比较
public void insert(Key x) { pq[++N] = x; swim(N); }
-
场景2 :父节点的键值变得比其中一个(或者两个都)小
- 与较大的那个子节点交换
- 重复这一过程,直到顺序恢复
private void sink(int k) { while (2*k <= N) { int j = 2*k; if (j < N && less(j, j+1)) j++; if (!less(k, j)) break; exch(k, j); k = j; } }
-
推广:从对上删除最大值
- 与最尾部的节点交换
- 向下移动之
- 最多$2 \log N $次比较
public Key delMax() { Key max = pq[1]; exch(1, N--); sink(1); pq[N+1] = null; //避免游荡对象 return max; }
-
-
优先队列的二元堆实现代码
public class MaxPQ<Key extends Comparable<Key>>
{
private Key[] pq;
private int N;
//基本容量,具体实现是应考虑可变大小数组
public MaxPQ(int capacity)
{ pq = (Key[]) new Comparable[capacity+1]; }
//优先队列操作函数(堆版本)
public boolean isEmpty()
{ return N == 0; }
public void insert(Key key)
{ /* see previous code */ }
public Key delMax()
{ /* see previous code */ }
//堆基本操作函数
private void swim(int k)
{ /* see previous code */ }
private void sink(int k)
{ /* see previous code */ }
//数列相关的函数
private boolean less(int i, int j)
{ return pq[i].compareTo(pq[j]) < 0; }
private void exch(int i, int j)
{ Key t = pq[i]; pq[i] = pq[j]; pq[j] = t; }
}
- 插入和寻找的时间复杂度都达到了 log N \log N logN
- 一些有关二元堆的考量
- 键值的不可异变
- 当键值位于优先队列时,客户端不允许更新这些键值
- 使用不可异变的键值实现
- 下溢和上溢
- 当尝试从空队列中删除最大值时应抛出异常(下溢)
- 使用无参构造器并使用可变大小的数列(上溢)
- 面向最小值的优先队列
- 对比较机制改造,即
less()
改为greater()
- 对比较机制改造,即
- 一些其他的操作
- 任意地移去一个项目
- 改变一个项目的优先级
- 这些操作都可以使用
swim()
和sink()
辅助实现
- 键值的不可异变
- 不可异变性的优点
- 简化调试过程
- 面对恶意代码更加安全
- 简化编程(不需要考虑客户端可修改数据的复杂情况)
- 确保现有内容的绝对稳定(不会被更改,不会出现违例)
- 不可异变性的缺点
- 每尝试修改一次值,则意味着新建一个实例
堆排序 Heapsort
-
“就地排序”的基本思路
- 创建一个关于所有 N N N个键值的最大堆
- 不断地移除其最大键值(因为最大的键值是放在当前堆的最后一位的,因此最后处理的结果,就是最小的在数组首位,最大的在数字末位)
-
堆的建立:使用自底向上方法建立一个最大堆(从最后的元素检查到最上面的元素)
-
排序:不断地删去剩余项目中的最大值
-
算法实现:
- 第一趟:使用自底向上方法完成堆的建立,
k
从N/2
开始(最后的一个非叶子节点)
for (int k = N/2; k >= 1; k--) sink(a, k, N);//这个函数是sink函数的扩展,指对用数组a实现的堆,从k开始,一直下沉到N
- 第二趟:一次完成一个删除最大值操作,注意不解决游离问题,而是将其留下
while (N > 1) { exch(a, 1, N--); sink(a, 1, N); }
- 第一趟:使用自底向上方法完成堆的建立,
-
堆的完整实现
public class Heap
{
public static void sort(Comparable[] a)
{
int N = a.length;
for (int k = N/2; k >= 1; k--)
sink(a, k, N);
while (N > 1)
{
exch(a, 1, N);
sink(a, 1, --N);
}
}
//这个sink函数就是上述的拓展版本,传入实现的数组名,从哪个位置开始,到哪个位置结束
private static void sink(Comparable[] a, int k, int N)
{ /* as before */ }
private static boolean less(Comparable[] a, int i, int j)
{ /* as before */ }
private static void exch(Comparable[] a, int i, int j)
{ /* as before */ }
}
- 堆排序的性质
- 堆的建设会用到 ≤ 2 N \le 2N ≤2N次比较和交换
- 堆排序会用到 ≤ 2 N log N \le 2N \log N ≤2NlogN次比较和交换
- 堆排序是第一个就地排序同时最坏 N log N N\log N NlogN的排序算法
- 但是堆排序的应用范围并不是很广:
- 内部循环需要做的事情比快排更多,耗费更多时间
- 没有很好地利用cache内存(树节点非临近,不访问临近节点,没有办法利用cache加速)
- 不稳定(更多地采用归并排序)
原位 | 稳定 | 最坏 | 平均 | 最好 | 备注 | |
---|---|---|---|---|---|---|
选排 | 是 | 否 | N 2 2 \frac{N^2}2 2N2 | N 2 2 \frac{N^2}2 2N2 | N 2 2 \frac{N^2}2 2N2 | 只需要 N N N次交换 |
插排 | 是 | 是 | N 2 2 \frac{N^2}2 2N2 | N 2 4 \frac{N^2}4 4N2 | N N N | 主要用于小 N N N或者部分有序数列 |
希排 | 是 | 否 | ? | ? | N N N | 代码紧凑,次二次方,中等大小表现优秀 |
归排 | 否 | 是 | N lg N N \lg N NlgN | N lg N N \lg N NlgN | N lg N N \lg N NlgN | N lg N N \lg N NlgN性能保证,稳定 |
快排 | 是 | 否 | N 2 2 \frac{N^2}2 2N2 | 2 N ln N 2N \ln N 2NlnN | N lg N N \lg N NlgN | 实践中最快,大概率保证 N lg N N \lg N NlgN |
3路快排 | 是 | 否 | N 2 2 \frac{N^2}2 2N2 | 2 N ln N 2N \ln N 2NlnN | N N N | 提高了出现相同key时快排的性能表现 |
堆排 | 是 | 否 | 2 N lg N 2N\lg N 2NlgN | 2 N lg N 2N\lg N 2NlgN | N lg N N\lg N NlgN | N lg N N \lg N NlgN性能保证,原地操作 |
??? | 是 | 是 | N lg N N \lg N NlgN | N lg N N \lg N NlgN | N lg N N \lg N NlgN |
事件驱动模拟 Event Driven Simulation
- 优先队列的一个很重要的应用
- 举例:原子级别动态模拟
- 目标:根据弹性碰撞定律模拟 N N N个粒子的移动
- 硬片模型:
- 移动的粒子通过弹性碰撞相互交互以及与墙壁交互
- 每一个硬片有已知的位置、速度、质量和半径
- 没有其他的力的作用
- 特点:将宏观观察与微观动力相结合
- 麦克斯韦—玻尔兹曼:速度的分布式温度的函数
- 爱因斯坦:花粉粒的布朗运动解释
- 一种实现:时间驱动的模拟
public class BouncingBalls
{
public static void main(String[] args)
{
int N = Integer.parseInt(args[0]);
Ball[] balls = new Ball[N];
for (int i = 0; i < N; i++)
balls[i] = new Ball();
while(true)
{
StdDraw.clear();
for (int i = 0; i < N; i++)
{
balls[i].move(0.5);//让每一个球都沿当前速度前进0.5个单位,没有相互碰撞,只有对墙的碰撞
balls[i].draw();
}
StdDraw.show(50);
}
}
}
- 上述代码中
Ball
类的实现
public class Ball
{
private double rx, ry; // position
private double vx, vy; // velocity
private final double radius; // radius
public Ball(...)
{ /* initialize position and velocity */ }
public void move(double dt)
{
//下两行判断球是否撞墙
if ((rx + vx*dt < radius) || (rx + vx*dt > 1.0 - radius)) { vx = -vx; }
if ((ry + vy*dt < radius) || (ry + vy*dt > 1.0 - radius)) { vy = -vy; }
rx = rx + vx*dt;
ry = ry + vy*dt;
}
public void draw()
{ StdDraw.filledCircle(rx, ry, radius); }
}
- 上述代码的问题:没能解决两球相撞的情况
- 时间驱动方法(检查所有粒子对在每一个时间步长后的重叠情况,再根据重叠情况推算碰撞情况)会产生二次时间复杂度,对于大量粒子无法实现,且时间间隔的取值很大程度会影响性能
- 改进:事件驱动方法,只在有事情发生的时候更新状态
- 粒子的两次碰撞间,其运动为直线
- 只考虑碰撞发生的时间
- 使用优先队列维护未来发生的碰撞,按照时间优先顺序
- 通过取出队列中的最小值以获得下一次碰撞的情况
- 给定位置、速度和粒子半径,可以预测二者之间(不考虑其他的条件)的碰撞情况或者其与墙壁的碰撞情况
- 当碰撞发生时,根据弹性碰撞定律更新碰撞的粒子的情况(动量守恒,呵呵)
- 粒子的实现
public class Particle
{
private double rx, ry; // position
private double vx, vy; // velocity
private final double radius; // radius
private final double mass; // mass
private int count; // number of collisions
public Particle(...) { }
public void move(double dt) { }
public void draw() { }
//碰撞时间之计算
public double timeToHit(Particle that) { }
public double timeToHitVerticalWall() { }
public double timeToHitHorizontalWall() { }
//碰撞后的位置和速度之计算
public void bounceOff(Particle that) { }
public void bounceOffVerticalWall() { }
public void bounceOffHorizontalWall() { }
}
- 碰撞的预测和处理的代码
public double timeToHit(Particle that)
{
if (this == that) return INFINITY;
double dx = that.rx - this.rx, dy = that.ry - this.ry;
double dvx = that.vx - this.vx; dvy = that.vy - this.vy;
double dvdr = dx*dvx + dy*dvy;
if( dvdr > 0) return INFINITY;
double dvdv = dvx*dvx + dvy*dvy;
double drdr = dx*dx + dy*dy;
double sigma = this.radius + that.radius;
double d = (dvdr*dvdr) - dvdv * (drdr - sigma*sigma);
if (d < 0) return INFINITY;
return -(dvdr + Math.sqrt(d)) / dvdv;
}
public void bounceOff(Particle that)
{
double dx = that.rx - this.rx, dy = that.ry - this.ry;
double dvx = that.vx - this.vx, dvy = that.vy - this.vy;
double dvdr = dx*dvx + dy*dvy;
double dist = this.radius + that.radius;
double J = 2 * this.mass * that.mass * dvdr / ((this.mass + that.mass) * dist);
double Jx = J * dx / dist;
double Jy = J * dy / dist;
this.vx += Jx / this.mass;
this.vy += Jy / this.mass;
that.vx -= Jx / that.mass;
that.vy -= Jy / that.mass;
this.count++;
that.count++;
}
-
在实现中为了表示“不会发生的碰撞事件”,使用“无穷大”将这个事件维护在优先队列中
-
事件驱动模拟的主循环实现
- 初始化
- 将所有潜在的粒子-墙碰撞事件插入到优先队列中
- 将所有潜在的粒子-粒子碰撞事件插入到优先队列中
- 之所以说是“潜在的”,以为目前无法预知在其他的碰撞事件发生后,某些理论可发生的碰撞是否仍会法发生
- 主循环
- 删除优先队列中即将发生的事件
- 如果这个事件无效了(使用
Particle
类中的count
),就忽略之 - 将所有粒子的状态更新到当前事件的事件,所有的更新按照直线运动
- 更新碰撞例子的速度
- 预测这两个粒子相关的全部事件并插入到优先队列中
- 初始化
-
事件实现
- 事件1:两个粒子都不是
null
,则是粒子-粒子碰撞 - 事件2:两个粒子中有一个是
null
,则是粒子-墙碰撞 - 事件3:两个粒子都是
null
,地图重绘
private class Event implements Comparable<Event> { private double time; // time of event private Particle a, b; // particles involved in event private int countA, countB; // collision counts for a and b public Event(double t, Particle a, Particle b) { } public int compareTo(Event that) { return this.time - that.time; } //检查被干扰的情况下是否该事件有效 public boolean isValid() { } }
- 事件1:两个粒子都不是
-
事件预测
public class CollisionSystem { private MinPQ<Event> pq; // the priority queue private double t = 0.0; // simulation clock time private Particle[] particles; // the array of particles public CollisionSystem(Particle[] particles) { } private void predict(Particle a) { if (a == null) return; for (int i = 0; i < N; i++) { double dt = a.timeToHit(particles[i]); pq.insert(new Event(t + dt, a, particles[i])); } pq.insert(new Event(t + a.timeToHitVerticalWall() , a, null)); pq.insert(new Event(t + a.timeToHitHorizontalWall(), null, a)); } private void redraw() { } public void simulate() { /* see next slide */ } }
-
模拟
public void simulate() { pq = new MinPQ<Event>(); for(int i = 0; i < N; i++) predict(particles[i]); pq.insert(new Event(0, null, null));//经常插入该事件,确保每一次更新都能够被重绘 while(!pq.isEmpty()) { Event event = pq.delMin(); if(!event.isValid()) continue; Particle a = event.a; Particle b = event.b; //更新所有粒子的位置 for(int i = 0; i < N; i++) particles[i].move(event.time - t); t = event.time; //完成事件 if (a != null && b != null) a.bounceOff(b); else if (a != null && b == null) a.bounceOffVerticalWall() else if (a == null && b != null) b.bounceOffHorizontalWall(); else if (a == null && b == null) redraw(); //插入新的事件 predict(a); predict(b); } }
元素级标记表 Elementary Symbol Table
标记表接口API Symbol Table API
- 键-值对抽象
- 以一个特定的键插入一个值
- 给定一个键,就能够找到对应的值
- 上述的这种数据抽象有着很多基础性的应用,用于管理一些列值
- 基本标记表API:关联数组抽象,一个键之关联一个值
public class ST<Key, Value> {
ST(); // create a symbol table
void put(Key key, Value val); // put key-value pair into the table (remove key from table if value is null) a[key] = val
Value get(Key key); // value paired with key (null if key is absent) a[key]?
void delete(Key key); // remove key (and its value) from table
boolean contains(Key key); // is there a value paired with key?
boolean isEmpty(); // is the table empty?
int size(); // number of key-value pairs in the table
Iterable<Key> keys(); // all the keys in the table
}
-
上述抽象相当于Java中的数组,给定一个键(索引),我们能从数组中找到一个或零个值,或者对指定索引位进行赋值
-
一些约定:
-
值不可以为空
null
-
方法
get()
在键不存在的情况下返回空null
-
方法
put()
将使用新值覆写旧值(如果存在) -
上述约定的预期结果
- 实现
contains()
会非常简单
public boolean contains (Key key) { return get(key) != null }
- 可以实现一个懒惰版本的
delete()
public void delete (Key key) { put(key, null); }
- 实现
-
-
值的类型:任何泛化类型
-
键的类型:一些自然假设
- 假设键是
Comparable
,可以使用compareTo()
,有序的键可以帮助实现更好的数据结构设计(应用一些排序算法)以及更广泛的应用 - 假设键是任何泛化类型,使用
equals
以辨别是否相等 - 假设键是任何泛化类型,使用
equals
以辨别是否相等,同时使用hashCode()
以对键进行扰乱
- 假设键是
-
最佳实践经验:使用不可异变类型用于标记表的键
-
传统Java中内建的等价函数
equals
只是单纯地比较引用是否相等(只想同一个实例) -
等价测试:如何测试两个对象是否等价(内部值相等)?对于任何引用
x
,y
和z
:- 自反性:
x.equals(x)
为true
- 对称性:
x.equals(y)
为true
,当且仅当y.equals(x)
为true
- 传递性:如果
x.equals(y)
为true
并且y.equals(z)
为true
,那么x.equals(z)
为true
- 非空:
x.equals(null)
为false
- 自反性:
-
一些实现细节
public final class Date implements Comparable<Date>
{ // 禁止继承以避免违反对称性
private final int month;
private final int day;
private final int year;
...
public boolean equals(Object y)
{
if (y == this) return true; // 对绝对等价(同一个对象)的操作优化
if (y == null) return false; // 非空
if (y.getClass() != this.getClass()) // 同类型
return false;
Date that = (Date) y; // 对传入的类型进行转型
// 检查所有的成员相同(值相同)
if (this.day != that.day ) return false;
if (this.month != that.month) return false;
if (this.year != that.year ) return false;
return true;
}
}
- 详细的针对自定义类型的等价函数的设计方案
- 对引用等价性的操作优化
- 非空检查
- 类型检查并进行转型
- 检查每一个数据域
- 原始类型使用
==
- 对象使用
equals()
- 数组,对每一个项都进行检查
- 原始类型使用
- 最佳实践经验:
- 不需要处理使用其他域计算得出的数据域
- 先比较最容易不相同的数据域
- 确保
equals()
和compareTo()
的一致性
基本实现 Elementary Implementations
在链表中进行序列查找 Sequential Search in a Linked List
- 数据结构:维护一个(未排序的)存储键值对的链表
- 搜索:遍历所有的键,直到匹配结束
- 插入:遍历所有的键,直到匹配结束;如果未有匹配,则将其加到最前
- 实际上是对链表实现的栈或队列代码的略微修改版本
- 最坏情况下,搜索和插入的时间复杂度都为 N N N
- 平均情况下,搜索的复杂度为 N 2 \frac N2 2N,插入的复杂度仍为 N N N
- 由于链表无序,因此无法提供对有序键的迭代
- 使用相等
equals()
进行比较
在有序数组中进行二分查找 Binary Search in an Ordered Array
- 数据结构:维护一个存储键值对的有序数组
- 一个排序(rank)辅助函数:多少个键是小于k的
- 代码实现:
public Value get(Key key)
{
if (isEmpty()) return null;
int i = rank(key);
if (i < N && keys[i].compareTo(key) == 0) return vals[i];
else return null;
}
private int rank(Key key)
{
int lo = 0, hi = N-1;
while (lo <= hi)
{
int mid = lo + (hi - lo) / 2;
int cmp = key.compareTo(keys[mid]);
if (cmp < 0) hi = mid - 1;
else if (cmp > 0) lo = mid + 1;
else if (cmp == 0) return mid;
}
return lo;
}
- 一个小问题:如果要插入,就要将比当前元素大的键都右移一位
- 最坏情况下,查找为 log N \log N logN,插入为 N N N(考虑挪动元素的消耗)
- 平均情况下,查找仍为 log N \log N logN(因为一直都是二分查找),但是插入为 N 2 \frac N2 2N
- 使用比较函数
compareTo()
进行比较,效率更高 - 上述二者都存在的一个问题:插入操作时间复杂度线性,无法用于大规模元素
有序操作 Ordered Operations
- 当键可比较且存储有序时,我们可以利用这些特性扩展标记表的接口,增加一些和顺序相关的操作
public class ST<Key extends Comparable<Key>, Value> //可以使用comparble的接口方法
{
ST(); // create an ordered symbol table
void put(Key key, Value val); // put key-value pair into the table (remove key from table if value is null)
Value get(Key key); // value paired with key (null if key is absent)
void delete(Key key); // remove key (and its value) from table
boolean contains(Key key); // is there a value paired with key?
boolean isEmpty(); // is the table empty?
int size(); // number of key-value pairs
// ---------------------------------------
Key min(); // smallest key
Key max(); // largest key
Key floor(Key key); // largest key less than or equal to key
Key ceiling(Key key); // smallest key greater than or equal to key
int rank(Key key); // number of keys less than key
Key select(int k); // key of rank k
void deleteMin(); // delete smallest key
void deleteMax(); // delete largest key
int size(Key lo, Key hi); // number of keys in [lo..hi]
Iterable<Key> keys(Key lo, Key hi); // keys in [lo..hi], in sorted order
Iterable<Key> keys(); // all keys in the table, in sorted order
}
- 使用二分查找的有序标记表的操作性能总结
序列查找 | 二分查找 | |
---|---|---|
查找search | N N N | log N \log N logN |
插入insert /删除delete | N N N |
N
N
N (数组实现需要挪动元素) |
最大max /最小min | N N N | 1 |
向下取floor /向上取ceiling | N N N | log N \log N logN |
排列rank | N N N | log N \log N logN |
选择select | N N N | 1 1 1 |
有序迭代orderedIteration |
N
log
N
N \log N
NlogN (需要先确保有序再迭代) | N N N |
- 使用二分查找并不是一个很好的方法,因为在插入和删除上很难承受大规模元素
二分查找树 Binary Search Tree
- 为我们上述描述的有序标记表接口提供更好的算法
- 二分查找树(BST):一个对称有序(symmetric order)的二叉树
- 二叉树:
- 空树
- 与另外两个独立的二叉树相连(左子树和右子树)
- 对称有序:每一个节点有一个键,每一个节点的键
- 比其全部左子树的键大
- 比其全部右子树的键小
- Java中的实现:一个二分查找树的引用,是对其根节点的引用,一个节点由四部分组成
- 键
- 值
- 左子树的引用
- 右子树的引用
private class Node
{
private Key key;
private Value val;
private Node left, right;
public Node(Key key, Value val)
{
this.key = key;
this.val = val;
}
}
- 一个二分查找树的骨架如下:
public class BST<Key extends Comparable<Key>, Value>
{
private Node root;
private class Node
{ /* see previous slide */ }
public void put(Key key, Value val)
{ /* see next slides */ }
public Value get(Key key)
{ /* see next slides */ }
public void delete(Key key)
{ /* see next slides */ }
public Iterable<Key> iterator()
{ /* see next slides */ }
}
-
查找:如果目标比当前节点元素小,进一步向左子树查找;否则向右(如果到达空树,说明该元素不存在)
- 查找代价是节点的当前深度加1
get
方法的实现如下:
public Value get(Key key) { Node x = root; while (x != null) { int cmp = key.compareTo(x.key); if (cmp < 0) x = x.left; else if (cmp > 0) x = x.right; else if (cmp == 0) return x.val; } return null; }
-
插入:先查找,找到键,则覆写新值;未找到键(空树),则创建新节点
- 插入代价是节点的当前深度加1(实际就是查找的代价)
put
方法的实现如下(递归实现,相比传统实现更加精巧,避免了创建新节点是对父节点的状态恢复):
public void put(Key key, Value val) { root = put(root, key, val); } private Node put(Node x, Key key, Value val) { if (x == null) return new Node(key, val); int cmp = key.compareTo(x.key); if (cmp < 0) x.left = put(x.left, key, val); else if (cmp > 0) x.right = put(x.right, key, val); else if (cmp == 0) x.val = val; return x; }
-
对于同一组键,键的插入顺序决定了不同的二叉树形状,因此也就决定了查找和插入的比较次数
- 最佳的形状就是整个树是平衡的(平衡二叉查找树)
- 一般的树则是分布比较均匀,看起来比较平很
- 最糟糕的情况就是完全不平衡,偏向一侧(键插入顺序完全有序)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vdZLVKfe-1587834188874)(assets/1546759641120.png)]
- 数学分析:
- (因和快排的划分类似)如果 N N N个不同的键以随机顺序插入,预期的比较次数(树的平均高度) ∼ 2 ln N \sim 2 \ln N ∼2lnN
- 经证明,上述条件下树的最大高度为 ∼ 4.311 ln N \sim 4.311 \ln N ∼4.311lnN
- 最糟糕的情况高度则是 N N N
二叉查找树的有序操作 Ordered Operations in BSTs
-
取极值
- 取最小
min
:表中最小值,即从根节点一直找到最左的节点 - 取最大
max
:表中最大值,即从根节点一直找到最右的节点
- 取最小
-
向上或者向下取值
-
向下取值
floor
:不大于给定键的最大键- 情景一:k等于根节点键,则向下取值即为k
- 情景二:k小于根节点键,则目标在左子树
- 情景三:k大于根节点键,则如果右子树有任何节点不大于k,那么目标在右子树;否则目标就是根节点
public Key floor(Key key) { Node x = floor(root, key); if (x == null) return null; return x.key; } private Node floor(Node x, Key key) { if (x == null) return null; int cmp = key.compareTo(x.key); //情景1 if (cmp == 0) return x; //情景2 if (cmp < 0) return floor(x.left, key); //情景3 Node t = floor(x.right, key); if (t != null) return t; else return x; }
-
向上取值
ceiling
:不小于给定键的最小键- 相关逻辑和代码与上述过程十分接近
-
-
子树计数:在每个节点中,存储以该节点为根节点的子树中节点个数,实现
size()
时,直接返回根节点存储的结果即可- 新的节点实现
private class Node { private Key key; private Value val; private Node left; private Node right; private int count; // 子树中节点计数值(算自己) }
- 表大小的实现
public int size() { return size(root); } private int size(Node x) { if (x == null) return 0; // 空树定义为0 return x.count; }
- 一个略微复杂的情况:插入新的键时,树的计数值需要更新
private Node put(Node x, Key key, Value val) { if (x == null) return new Node(key, val, 1); int cmp = key.compareTo(x.key); if (cmp < 0) x.left = put(x.left, key, val); else if (cmp > 0) x.right = put(x.right, key, val); else if (cmp == 0) x.val = val; x.count = 1 + size(x.left) + size(x.right); // 重新计算一次(用递归解决检查问题) return x; }
-
排列
rank
:有多少个键小于k- 比当前节点小:朝着左子树找
- 比当前节点大:朝着右子树找,同时加上左子树的
size
(比当前节点小的所有节点)再加一(当前节点)(这些节点都比他小) - 和当前节点相等:返回当前节点的左子树的
size
-
顺序迭代:树的中序遍历(按元素大小顺序,使用队列保存合理)
- 遍历左子树
- 当前节点
- 遍历右子树
public Iterable<Key> keys() { Queue<Key> q = new Queue<Key>(); inorder(root, q); return q; } private void inorder(Node x, Queue<Key> q) { if (x == null) return; inorder(x.left, q); q.enqueue(x.key); inorder(x.right, q); }
-
目前除了删除操作外的性能总结,h为树的高度(随即顺序的键时,正比于 log N \log N logN)
序列查找 | 二分查找 | BST | |
---|---|---|---|
查找search | N N N | log N \log N logN | h h h |
插入insert /删除delete | N N N |
N
N
N (数组实现需要挪动元素) | h h h |
最大max /最小min | N N N | 1 | h h h |
向下取floor /向上取ceiling | N N N | log N \log N logN | h h h |
排列rank | N N N | log N \log N logN | h h h |
选择select | N N N | 1 1 1 | h h h |
有序迭代orderedIteration |
N
log
N
N \log N
NlogN (需要先确保有序再迭代) | N N N | N N N |
BST的删除操作 Deletion in BSTs
-
对于之前的链表和有序数组的实现,元素的删除都能达到 N 2 \frac N2 2N
-
懒方法
- 将对应键的值置为
null
- 保留该键,只用于引导搜索,但是不等于任何值
- 在没有太多的删除操作的情况下,可以尽可能保证增删查的代价为 ∼ 2 ln N ′ \sim 2 \ln N^\prime ∼2lnN′
- 但显然,维护太多的“墓碑”将会严重影响效率(甚至爆内存)
- 将对应键的值置为
-
删除最小值:
- 找到最左元素
- 将这个元素替换为其右链接(节点与子树)
- 更新计数
public void deleteMin() { root = deleteMin(root); } private Node deleteMin(Node x) { // 用右链接替换当前节点 if (x.left == null) return x.right; x.left = deleteMin(x.left); // 更新计数 x.count = 1 + size(x.left) + size(x.right); return x; }
-
删除最大值与上述同理
-
一般性删除算法:Hibbard Deletion,为了删除键k,先找到包含该键的节点t
- 情况0:(0个子节点)将父节点的相应链接置为null
- 情况1:(1个子节点)将父节点的相应链接置为子节点
- 情况2:(2个子节点)找到其继任者,即右子树中的最小节点(先往右,然后一直向左)从右子树中删除该节点(删除最小值),并将这个值替换掉目标删除的节点
public void delete(Key key) { root = delete(root, key); } private Node delete(Node x, Key key) { if (x == null) return null; // 先找到这个键 int cmp = key.compareTo(x.key); if (cmp < 0) x.left = delete(x.left, key); else if (cmp > 0) x.right = delete(x.right, key); else { // 找到了键,开始删除 if (x.right == null) return x.left; // 没有右节点(如果没有左节点就是null) if (x.left == null) return x.right; // 没有左节点 // 找到后继,然后调整 Node t = x; x = min(t.right); x.right = deleteMin(t.right); x.left = t.left; } // 更新计数 x.count = size(x.left) + size(x.right) + 1; return x; }
-
一个问题:不对称
- 删除的代价变成了 n \sqrt n n(因为不对称的树导致整个树对应的键顺序不再随机)
- 需要一个更有效的删除方法