Coursera - Algorithm (Princeton) - 课程笔记 - Week 4

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的子节点为2k2k+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个键值的最大堆
    • 不断地移除其最大键值(因为最大的键值是放在当前堆的最后一位的,因此最后处理的结果,就是最小的在数组首位,最大的在数字末位)
  • 堆的建立:使用自底向上方法建立一个最大堆(从最后的元素检查到最上面的元素)

  • 排序:不断地删去剩余项目中的最大值

  • 算法实现:

    • 第一趟:使用自底向上方法完成堆的建立,kN/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()
        { }
    }
    
  • 事件预测

    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只是单纯地比较引用是否相等(只想同一个实例)

  • 等价测试:如何测试两个对象是否等价(内部值相等)?对于任何引用xyz

    • 自反性: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 N1
向下取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 N1 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 (因为不对称的树导致整个树对应的键顺序不再随机)
    • 需要一个更有效的删除方法
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值