文章目录
第3章、查找
我们会使用符号表这个词来描述一张抽象的表格, 我们会将信息(值)存储在其中,然后按照指定的键来搜索并获取这些信息。键和值的具体意义取决于不同的应用。
符号表有时被称为字典,类似于那本将单词的释义按照字母顺序排列起来的历史悠久的参考书。在英语字典里,键就是单词,值就是单词对应的定义、发音和词源。
符号表有时又叫做索引,即书本最后将术语按照字母顺序列出以方便查找的那部分。在一本书的索引中,键就是术语,而值就是书中该术语出现的所有页码。
1、符号表
符号表最主要的目的就是将一个键和一个值联系起来。用例能够将一个键值对插入符号表并希望在之后能够从符号表的所有键值对中按照键直接找到相对应的值。
符号表是一种存储键值对的数据结构,支持两种操作:插入(put),即将一组新的键值对存入表中;查找(get),即根据给定的键得到相应的值。
API
-
重复的键
我们的所有实现都遵循以下规则:
- 每个键只对应着一个值(表中不允许存在重复的键) ;
- 当用例代码向表中存人的键值对和表中已有的键(及关联的值)冲突时,新的值会替代旧的值。
在一个关联数组(符号表)中,键可以是任意类型,我们可以用它来快速访问数组的内容。一些编程语言(非Java)直接支持程序员使用
st[key]
来代替st.get(key)
,st[key]=val
来代替st.put(key, va1)
,其中key ( 键)和va1 (值)都可以是任意类型的对象。 -
空(null)键
键不能为空,使用空键会产生一个运行时异常。
-
空(null)值
我们还规定不允许有空值。这个规定的直接原因是在我们的API定义中,当键不存在时get()方法会返回空,这也意味着任何不在表中的键关联的值都是空。这个规定产生了两个结果:
-
我们可以用get()方法是否返回空来测试给定的键是否存在于符号表中;
-
我们可以将空值作为put()方法的第二个参数存人表中来实现删除。
-
删除操作
在符号表中,删除的实现可以有两种方法:
- 延时删除,将键对应的值置为空,然后在某个时候删去所有值为空的键;
- 即时删除,立刻从表中删除指定的键。
put(key,null) 是delete(key)的一种简单的(延时型)实现。而实现(即时型) delete()就是为了替代这种默认的方案。在我们的符号表实现中不会使用默认的方案。
有序符号表
-
向下取整和向上取整
对于给定的键,向下取整( floor )操作(找出小于等于该键的最大键)和向上取整( ceiling )操作(找
出大于等于该键的最小键)。
无序链表中的顺序查找
符号表中使用的数据结构的一个简单选择是链表,每个结点存储一个键值对。
-
get()的实现即为遍历链表,用equals()方法比较需被查找的键和每个结点中的键。如果匹配成功我们就返回相应的值,否则我们返回null。
-
put() 的实现也是遍历链表,用equals()方法比较需被查找的键和每个结点中的键。
- 如果匹配成功我们就用第二个参数指定的值更新和该键相关联的值
- 否则我们就用给定的键值对创建一个新的结点并将其插入到链表的开头。
public class SequentialSearchST<Key,Value> {
private Node first;//链表首节点
private class Node{//链表节点的定义
Key key;
Value val;
Node next;
public Node(Key key, Value val, Node next) {
this.key = key;
this.val = val;
this.next = next;
}
}
public Value get(Key key){//查找给定的键,返回相关联的值
for (Node x=first;x!=null;x=x.next){
if (key.equals(x.key))
return x.val;//命中
}
return null;
}
public void put(Key key,Value val){
//查找给定的键,找到则更新它的值,否则在表中创建新节点
for (Node x=first;x!=null;x=x.next){
if (key.equals(x.key)){
x.val=val;//如果表中已经存在该键,则更新其值
return;
}
}
//如果不存在该键,就在链表的头部插入新节点
first=new Node(key,val,first);
}
}
这种方法也被称为顺序查找:在查找中我们一个一个地顺序遍历符号表中的所有键并使用equals()方法来寻找与被查找的键匹配的键。
在含有N对键值的基于(无序)链表的符号表中,未命中的查找和插入操作都需要N次比较。命中的查找在最坏情况下需要N次比较。特别地,向一个空表中插入N个不同的键需要~N^2/2次比较。
基于链表的实现以及顺序查找是非常低效的。
有序数组中的二分查找
它使用的数据结构是一对平行的数组,一个储存键,一个储存值。
这份实现的核心是rank()方法,使用二分查找,返回表中小于给定键的键的数量。
- 对于get(方法,只要给定的键存在于表中,rank()方法就能够精确地告诉我们在哪里能够找到它(如果找不到,那它肯定就不在表中了)。
- 对于put()方法,只要给定的键存在于表中,rank() 方法就能够精确地告诉我们到哪里去更新它的值,以及当键不在表中时将键存储到表的何处。
我们将所有更大的键向后移动一格来腾出位置(从后向前移动)并将给定的键值对分别插入到各自数组中的合适位置。
尽管能够保证查找所需的时间是对数级别的,BinarySearchST仍然无法支持我们处理大型问题,因为put()方法还是太慢了。二分查找减少了比较的次数但无法减少运行所需时间,向大小为N的有序数组中插入一个新的元素在最坏情况下需要访问~2N次数组,因此向一个空符号表中插入N个元素在最坏情况下需要访问 ~N^2次数组。
总结
有序数组的二分查找优化了查找的速度,但是却减慢了插入的速度。
要支持高效的插人操作,我们需要一种链式结构。 但单链接的链表是无法使用二分查找法的,因为二分查找的高效来自于能够快速通过索引取得任何子数组的中间元素(但得到一条链表的中间元素的唯一方法只能是沿链表遍历)。为了将二分查找的效率和链表的灵活性结合起来,我们需要更加复杂的数据结构。能够同时拥有两者的就是二叉查找树。
2、二叉查找树
使用每个结点含有两个链接( 链表中每个结点只含有一个链接)的二叉查找树,是能够将链表插入的灵活性和有序数组查找的高效性结合起来的符号表实现。
一棵二叉查找树(BST)是一棵二叉树,其中每个结点都含有一个Comparable的键(以及相关联的值)且每个结点的键都大于其左子树中的任意结点的键而小于右子树的任意结点的键。
基本实现:
-
数据表示:
我们嵌套定义了一个私有类来表示二叉查找树上的一个结点。每个结点都含有一个键、一个值、一条左链接、一条右链接和一个结点计数器。左链接指向一棵由小于该结点的所有键组成的二叉查找树,右链接指向一棵由大于该结点的所有键组成的二叉查找树。变量N给出了以该结点为根的子树的结点总数。size()会将空链接的值当作0,这样我们就能保证以下公式对于二叉树中的任意结点x总是成立。
size(x) = size(x.1eft) + size(x.right) + 1
一棵二叉查找树代表了一组键(及其相应的值)的集合,而同一个集合可以用多棵不同的二叉查找树表示。如果我们将一棵二叉查找树的所有键投影到一条直线上,保证一个结点的左子树中的键出现在它的左边,右子树中的键出现在它的右边,那么我们一定可以得到一条有序的键列。我们会利用二叉查找树的这种天生的灵活性,用多棵二叉查找树表示同一组有序的键来实现构建和使用二叉查找树的高效算法。
-
查找:
这段代码用二叉查找树实现了有序符号表的API,树由Node对象组成,每个对象都含有一对键值、
两条链接和一个结点计数器N。每个Node对象都是一棵含有N个结点的子树的根结点,它的左链接指向一棵由小于该结点的所有键组成的二叉查找树,右链接指向一棵由大于该结点的所有键组成的二叉查找
树。root变量指向二叉查找树的根结点Node对象(这棵树包含了符号表中的所有键值对)。public class BST<Key extends Comparable<Key>,Value> { private Node root; private class Node{ private Key key;//键 private Value val;//值 private Node left,right;//指向子树的链接 private int N;//以该节点为根的子树中的节点总数 public Node(Key key,Value val,int N){ this.key=key;this.val=val;this.N=N; } } //公共的方法,供BST的用户使用,返回整个二分搜索树的大小 public int size(){ return size(root); } //私有的方法,供编写BST的程序员使用,返回二分搜索树中某个节点的子树的大小。 private int size(Node x){ if(x==null)return 0; else return x.N; } public Value get(Key key){ return get(root,key); } //私有的方法,供公共的get方法使用,利用了递归 private Value get(Node x,Key key){ //在以x为根节点的子树中查找并返回key所对应的值 //如果找不到则返回null if (x==null)return null; int cmp=key.compareTo(x.key); if (cmp<0)return get(x.left,key);//如果key小于当前的节点,就在左子树中寻找 else if(cmp>0)return get(x.right,key);//如果key大于当前的节点,就在右子树中寻找 else return x.val;//如果找到了(key等于当前节点),则返回当前节点的值 } public void put(Key key,Value val){ //查找key,找到则更新它的值,否则为它创建一个新的节点 //从根节点root开始查找,查找完后再从底层一层一层的返回到root //在下面的put()方法中递归地更新搜索路上每个父节点指向子节点的链接 //最后回到这里更新根节点root的链接 root=put(root,key,val); } private Node put(Node x,Key key,Value val){ //如果key不存在(找到空链接),则创建一个以key和val为键值对,节点数量为1的节点,插入到该子树中 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 x.val=val;//如果这个键已经存在,则更新该键的值 //更新节点子树的大小(使用这个公式而不是简单对N++的原因是: //只有当键不存在时,树的大小才会加1,当键存在时,树的大小不变 //而使用该公式,则可以将树的大小格式化,同时概括了以上两种情况) x.N=size(x.left)+size(x.right)+1; //返回当前的节点,重置搜索路上每个父节点指向子节点的链接 return x; } }
可以将递归调用前的代码想象成沿着树向下走:它会将给定的键和每个结点的键相比较并根据结果向左或者向右移动到下一个结点。
可以将递归调用后的代码想象成沿着树向上爬:
- 对于get()方法,这对应着一系列的返回指令( return)
- 对于put()方法,这意味着重置搜索路径上每个父结点指向子结点的链接,并增加路径上每个结点中的计数器的值。
-
分析:
命题C:在由N个随机键构造的二叉查找树中,查找命中平均所需的比较次数为~ 2lnN(约1.39lgN)。
命题D:在由N个随机键构造的二叉查找树中插入操作和查找未命中平均所需的比较次数为~ 2lnN (约1.39IgN)。
命题C说明在二叉查找树中查找随机键的成本比二分查找高约39%。命题D说明这些额外的成本是值得的,因为插人一个新键的成本是对数级别的,这是基于二分查找的有序数组所不具备的灵活性,因为它的插人操作所需访问数组的次数是线性级别的。
-
最大键和最小键:
如果根结点的左链接为空,那么一棵二叉查找树中最小的键就是根结点;如果左链接非空,那么树中的最小键就是左子树中的最小键。
public Key min(){ return min(root).key;//从根节点开始寻找 } private Node min(Node x){ //如果没有左子树,则当前的根节点就是最小的键 if(x.left==null)return x; return min(x.left);//如果有左子树,就在左子树中寻找最小值 } public Key max(){ return max(root).key; } private Node max(Node x){ if(x.right==null)return x; return max(x.right); }
-
向上取整和向下取整
-
如果给定的键key小于二叉查找树的根结点的键,在根结点的左子树中寻找小于等于key的最大键( floor)
-
如果给定的键key等于二叉查找树的根结点,根节点就是查找结果。
-
如果给定的键key大于二叉查找树的根结点,在根节点的右子树中寻找
- 当根结点右子树中存在小于等于key的结点时,该节点就是查找结果
- 当根结点右子树中不存在小于等于key的结点时,查找结果为null,返回到离自己最近的右节点的父节点就是小于等于key的最大键。
本质上就是:
- 如果搜索中遇到了与键值相等的节点,就返回该节点
- 如果没有遇到,就一直向下搜索,直到遇到空节点(null),开始沿着树向上返回
- 返回到离空节点最近的的右节点的父节点,该节点就是小于等于key的最大键,返回该节点
- 如果搜索途中没有任何右节点,这棵树中就没有小于等于key的最大键,返回null
public Key floor(Key key){ Node x=floor(root,key); if (x==null)return null;//如果查找的结果为null,就返回null return x.key;//如果查找的结果不为null,就返回查找到的键值 } private Node floor(Node x,Key key){ if(x==null)return null; int cmp=key.compareTo(x.key); //如果给定的键key小于二叉查找树的根结点的键,在根结点的左子树中寻找小于等于key的最大键 if (cmp<0)return floor(x.left,key); //如果给定的键key等于二叉查找树的根结点,当前的根节点就是查找结果,直接返回 if(cmp==0)return x; //如果给定的键key大于二叉查找树的根结点,在根节点的右子树中寻找 Node t=floor(x.right,key); //在右子树中寻找完后返回到当前函数,t记录了寻找结果。 //当右子树中不存在小于等于key的结点时,查找结果为null,当前的根节点就是查找结果 if (t==null)return x; //当右子树中存在小于等于key的结点时,t的值为该节点,返回t。 return t; } public Key ceil(Key key){ Node t=ceil(root,key); if (t==null)return null; return t.key; } private Node ceil(Node x,Key key){ if (x==null)return null; int cmp=key.compareTo(x.key); if(cmp>0)return ceil(x.right,key); if(cmp==0)return x; Node t=ceil(x.left,key); if (t==null)return x; return t; }
在跟着返回(return)语句的条件语句(if)后不需要使用else if,因为执行完当前的条件语句后,当前的函数直接结束了,没有机会执行后面的语句。
-
-
选择:
假设我们想找到排名为k的键(即树中正好有k个小于它的键)。
- 如果左子树中的结点数t大于k,那么我们就继续(递归地)在左子树中查找排名为k的键;
- 如果t等于k,我们就返回根结点中的键;
- 如果t小于k,我们就(递归地)在右子树中查找排名为(k-t-1)的键。
这段描述既说明了select()方法的递归实现同时也证明了它的正确性。
public Key select(int k){ return select(root,k).key; } private Node select(Node x,int k){ if (x==null)return null; //利用size()求出某一节点子树的节点数量大小 int t=size(x.left); //如果左子树中的结点数t大于k,那么我们就继续(递归地)在左子树中查找排名为k的键; if(t>k)return select(x.left,k); //如果t等于k,我们就返回根结点中的键; if (t==k)return x; //如果t小于k,我们就(递归地)在右子树中查找排名为(k-t-1)的键。 return select(x.right,k-t-1); }
-
排名:
rank()是select()的逆方法,它会返回给定键的排名。它的实现和select()类似:
- 如果给定的键和根结点的键相等,我们返回左子树中的结点总数t;
- 如果给定的键小于根结点,我们会返回该键在左子树中的排名( 递归计算) ;
- 如果给定的键大于根结点,我们会返回t+1 (根结点)加上它在右子树中的排名(递归计算)。
public int rank(Key key){ return rank(root,key); } private int rank(Node x,Key key){ if (x==null)return 0; int cmp=key.compareTo(x.key); if (cmp<0)return rank(x.left,key); if (cmp==0)return size(x.left); return 1+size(x.left)+rank(x.right,key); }
-
删除:
-
删除最大键和删除最小键:
对于deleteMin(),我们要不断深入根结点的左子树中直至遇见一个空链接,然后将指向该结点的链接指向该结点的右子树(只需要在递归调用中返回它的右链接即可)。此时已经没有任何链接指向要被删除的结点,因此它会被垃圾收集器清理掉。
public void deleteMin(){ root=deleteMin(root); } private Node deleteMin(Node x){ //遇见一个空链接,然后将指向该结点的链接指向该结点的右子树(只需要在递归调用中返回它的右链接即可) if (x.left==null)return x.right; x.left=deleteMin(x.left); //调整子树节点数量的大小 x.N=size(x.left)+size(x.right)+1; return x; }
-
删除任意键:
我们可以用类似的方式删除任意只有一个子结点(或者没有子结点)的结点,但无法删除一个拥有两个子结点的结点,因为删除之后我们要处理两棵子树,但被删除结点的父结点只有一条空出来
的链接。解决方法:在删除节点x后用它的右子树中的最小节点填补它的位置
-
首先搜索要删除的节点所在位置
-
将指向即将被删除的结点的链接保存为t
-
将x指向它的右子树中的最小节点min(t.right)
-
将x从树中删除,然后将x的父节点指向x的右节点(deleteMin(t.right)),此时在树中已没有任何链接指向x(指向的节点)
-
将x的右链接指向原来的右子树t.right(deleteMin(t.right)的返回值)
-
将x的左链接(本为空)设为t.left
public void delete(Key key){ root=delete(root,key); } private Node delete(Node x,Key key){ //首先寻找要删除的键值所在的位置 //如果没有找到键值,就返回null if (x==null)return null; int cmp=key.compareTo(x.key); //x.left指向传回来的节点,返回途中更新经过的节点链接 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; if (x.left==null)return x.right; //如果被删除的节点有两个节点,就进行以下操作 Node t=x; x.right=deleteMin(t.right); x.left=t.left; } x.N=size(x.left)+size(x.right)+1; return x; }
-
-
-
中序遍历:
private void print(Node x){ if (x==null)return; print(x.left); System.out.println(x.key); print(x.right); }
-
范围查找:
将所有落在给定范围以内的键加人一个队列Queue并跳过那些不可能含有所查找键的子树。Keys()方法返回一个
Iterable<Key>
类型的对象,用例不需要知道我们使用Queue来收集符合条件的键,我们使用什么数据结构来实现Iterable<Key>
并不重要,用例只要能够使用Java的foreach语句遍历返回的所有键就可以了。为了确保以给定结点为根的子树中所有在指定范围之内的键加入队列,我们会(递归地)查找根结点的左子树,然后查找根结点,然后(递归地)查找根结点的右子树。
//公共接口,供用户使用,查找整个二叉树的所有键值,返回一个可迭代的集合 public Iterable<Key> Keys(){ return Keys(min(),max()); } //公共接口,供用户使用,查找指定范围的键值 public Iterable<Key> Keys(Key lo,Key hi){ Queue<Key> queue=new Queue<>(); Keys(root,queue,lo,hi); return queue; } private void Keys(Node x,Queue<Key>queue,Key lo,Key hi){ if (x==null)return; int cmplo=lo.compareTo(x.key); int cmphi=hi.compareTo(x.key); if (cmplo<0)Keys(x.left,queue,lo,hi); if (cmplo<=0&&cmphi>=0)queue.enqueue(x.key); if (cmphi>0)Keys(x.right,queue,lo,hi); }
性能分析:
在一棵二叉查找树中,所有操作在最坏情况下所需的时间都和树的高度成正比。
随机键构造的二叉查找树的平均高度为树中结点数的对数级别,对于足够大的N,这个值趋近于2.99lgN。
如果构造树的键不是随机的可以使用平衡二叉查找树,它能保证无论键的插人顺序如何,树的高度都将是总键数的对数。
3、平衡查找树
平衡查找树是一种二分查找树,可以保证无论如何构造它,它的运行时间都是对数级别的。理想情况下我们希望能够保持二分查找树的平衡性。在一棵含有N个结点的树中,我们希望树高为~lgN,这样我们就能保证所有查找都能在 ~lgN次比较内结束,就和二分查找一样。
2-3查找树
我们将一棵标准的二叉查找树中的结点称为2-结点(含有一个键和两条链接),而现在我们引入3-结点,它含有两个键和三条链接。
一棵2-3查找树或为一棵空树,或由以下结点组成:
- 2-结点,含有一个键(及其对应的值)和两条链接,左链接指向的2-3树中的键都小于该结点,右链接指向的2-3树中的键都大于该结点。
- 3-结点,含有两个键(及其对应的值)和三条链接,左链接指向的2-3树中的键都小于该结点,中链接指向的2-3树中的键都位于该结点的两个键之间,右链接指向的2-3树中的键都大于该结点。
一棵完美平衡的2-3查找树中的所有空链接到根结点的距离都应该是相同的。简洁起见,这里我们用2-3树指代一棵完美平衡的2-3查找树(在其他情况下这个词应该表示一种更一般的结构)。
-
查找:
要判断一个键是否在树中,我们先将它和根结点中的键比较。如果它和其中任意一个相等,查找命中;否则我们就根据比较的结果找到指向相应区间的链接,并在其指向的子树中递归地继续查找。如果这是个空链接,查找未命中。
-
向2-结点中插入新键:
把这个2-结点替换为一个3-结点,将要插人的键保存在其中即可,这样树可以保持完美平衡性。
-
向一棵只含有一个3-结点的树中插入新键:
这棵树中有两个键,所以在它唯一的结点中已经没有可插人新键的空间了。
- 为了将新键插人,我们先临时将新键存入该结点中,使之成为一个4-结点。
- 它很自然地扩展了以前的结点并含有3个键和4条链接。
- 然后将它转换为一棵由3个2-结点组成的2-3树,其中一个结点(根)含有中键,一个结点含有3个键中的最小者(和根结点的左链接相连),一个结点含有3个键中的最大者(和根结点的右链接相连)。
这棵树既是一棵含有3个结点的二叉查找树,同时也是一棵完美平衡的2-3树,因为其中所有的空链接到根结点的距离都相等。插入前树的高度为0,插入后树的高度为1。
-
向一个父结点为2-结点的3-结点中插入新键:
- 先创建一个临时的4-节点并将其分解
- 将中键移动到原来的父节点中
这次转换也并不影响(完美平衡的) 2-3树的主要性质。树仍然是有序的,因为中键被移动到父结点中去了;树仍然是完美平衡的,插人后所有的空链接到根结点的距离仍然相同。
-
向一个父结点为3-结点的3-结点中插入新键:
我们一直向上不断分解临时的4-结点并将中键插人更高层的父结点,直至遇到一个2-结点并将它替换为一个不需要继续分解的3-结点,或者是到达3-结点的根。
-
分解根结点:
如果从插入结点到根结点的路径上全都是3-结点,我们的根结点最终变成一个临时的 4-结点。我们将临时的4-结点分解为3个2-结点,使得树高加1。这次最后的变换仍然保持了树的完美平衡性,因为它变换的是根结点。
将一个4-结点分解为一棵2-3树可能有6种情况:
和标准的二叉查找树由上向下生长不同,2-3 树的生长是由下向上的。
2-3树在最坏情况下仍有较好的性能。每个操作中处理每个结点的时间都不会超过一个很小的常数,且这两个操作都只会访问一条路径上的结点,所以任何查找或者插入的成本都肯定不会超过对数级别。通过对比由相同的键构造的二叉查找树,完美平衡的2-3树要平展得多。
例如,含有10亿个结点的一棵 2-3树的高度仅在19到30之间。我们最多只需要访问30个结点就能够在10亿个键中进行任意查找和插入操作,这是相当惊人的。
红黑二叉查找树
我们将树中的链接分为两种类型:
- 红链接将两个2-结点连接起来构成一个3-结点
- 黑链接则是2-3树中的普通链接。
我们将3-结点表示为由一条左斜的红色链接相连的两个2-结点。
这种表示法的一个优点是,我们无需修改就可以直接使用标准二叉查找树的get()方法。对于任意的2-3树,只要对结点进行转换,我们都可以立即派生出一棵对应的二叉查找树。我们将用这种方式表示2-3 树的二叉查找树称为红黑二叉查找树。
红黑树的定义是含有红黑链接并满足下列条件的二叉查找树:
- 红链接均为左链接;
- 没有任何一个结点同时和两条红链接相连;
- 该树是完美黑色平衡的,即任意空链接到根结点的路径上的黑链接数量相同。
满足这样定义的红黑树和相应的2-3树是一一对应的。
如果我们将一棵红黑树中的红链接画平,那么所有的空链接到根结点的距离都将是相同的。如果我们将由红链接相连的结点合并,得到的就是一棵2-3树。无论我们选择用何种方式去定义它们,红黑树都既是二叉查找树,也是2-3树。因此,我们能够将两个算法的优点结合起来:二叉查找树中简洁高效的查找方法和2-3树中高效的平衡插入算法。
基本实现:
-
颜色表示:
每个结点都只会有一条指向自己的链接(从它的父结点指向它),我们将链接的颜色保存在表示结点的Node数据类型的布尔变量color中。
如果指向它的链接是红色的,那么该变量为true,黑色则为false。
我们约定空链接为黑色。
我们定义了两个常量RED和BLACK来设置和测试这个变量。我们使用私有方法isRed()来测试一个结点和它的父结点之间的链接的颜色。
当我们提到一个结点的颜色时,我们指的是指向该结点的链接的颜色,反之亦然。
private static final boolean RED=true; private static final boolean BLACK=false; private class Node{ Key key;//键 Value val;//相关联的值 int N;//该子树中的节点总数 boolean color;//它的父节点指向它的链接颜色 Node left,right;//左右子树 public Node(Key key, Value val, int n, boolean color) { this.key = key; this.val = val; N = n; this.color = color; } } //判断该节点的父节点指向该结点的链接的颜色是不是红色 private boolean isRed(Node x){ //如果该节点为空,规定空链接为黑色 if (x==null)return false; return x.color==RED; }
-
旋转:
在我们实现的某些操作中可能会出现红色右链接或者两条连续的红链接,通过旋转操作改变红链接的指向。
左旋转:将一条红色的右链接需要被转化为左链接,对应的方法接受一条指向红黑树中某个节点的链接作为参数,将用两个键中的较小者作为根结点变为将较大者作为根结点。
具体步骤:
-
获取另一个节点
-
改变两个节点之间的链接关系(左旋转)
- 将h的右链接指向x的左子树;
- x的左链接指向h
从而将x变成根节点
-
改变两个节点链接的颜色
-
因为x变成了根节点,所以x颜色就等于h原来的颜色
-
红链接现在指向h,所以h的颜色等于红色
-
-
调整子树的节点数
- 虽然x变成了根节点,但是从3-节点的角度来说,子树的大小没有发生变化,所以x的节点数等于原来的h的节点数;
- h的子树发生了变化,用公式来调整节点数
-
用rotateRight()和rotateLeft()的返回值重置父节点中相应的链接
//左旋转 public Node rotateLeft(Node h){ //获取另一个节点 Node x=h.right; //改变两个节点之间的链接关系,将h的右链接指向x的左子树 //x的左链接指向h,从而将x变成根节点 h.right=x.left; x.left=h; //改变两个节点链接的颜色 //因为x变成了根节点,所以x颜色就等于h原来的颜色 //红链接现在指向h,所以h的颜色等于红色 x.color=h.color; h.color=RED; //改变子树的节点数 //虽然x变成了根节点,但是从3-节点的角度来说,子树的大小没有发生 //变化,所以x的节点数等于原来的h的节点数 //h的子树发生了变化,用公式来调整节点数 x.N=h.N; h.N=1+size(h.right)+size(h.left); return x; } //右旋转 public Node rotateRight(Node h){ Node x=h.left; h.left=x.right; x.right=h; x.color=h.color; h.color=RED; x.N=h.N; h.N=1+size(h.left)+size(h.right); return x; }
-
-
颜色转换:
我们用一个方法flipColors()来转换一个结点的两个红色子结点的颜色。将子结点的颜色由红变黑,同时将父结点的颜色由黑变红。
-
向 2-结点中插入新键
- 如果新键小于老键,我们只需要新增一个红色的结点即可,新的红黑树和单个3-结点完全等
价。 - 如果新键大于老键,那么新增的红色结点将会产生一条红色的右链接。我们需要使用root =
rotateLeft(root)来将其旋转为红色左链接并修正根结点的链接,插人操作才算完成。
- 如果新键小于老键,我们只需要新增一个红色的结点即可,新的红黑树和单个3-结点完全等
-
向一个3-结点中插入新键
-
新键大于原树中的两个键:
它被连接到3-结点的右链接。此时树是平衡的,根结点为中间大小的键,它有两条红链接分别和较小和较大的结点相连。将两条链接的颜色都由红变黑,就得到了一棵由三个结点组成、高为2的平衡树。其他两种情况最终也会转化成这种情况。
-
新键小于原树中的两个键:
它会被连接到最左边的空链接,这样就产生了两条连续的红链接。此时我们只需要将上层的红链接右旋转即可得到第一种情况 (中值键为根结点并和其他两个结点用红链接相连)。
-
新键介于原树中的两个键之间:
这又会产生两条连续的红链接,一条红色左链接接一条红色右链接。此时我们只需要将下层的红链接左旋转即可得到第一二种情况(两条连续的红色左链接)。
-
-
向树底部的3-节点插入新键
指向新结点的链接可能是3-结点的右链接(此时我们只需要转换颜色即可),或是左链接(此时我们需要进行右旋转然后再转换颜色),或是中链接(此时我们需要先左旋转下层链接然后右旋转上层链接,最后再转换颜色)。
颜色转换使中结点的链接变红,相当于将它送入了父结点。这意味着在父结点中继续插入一个新键,我们也会继续用相同的办法解决这个问题。
插入的三种情况分别需要一次转换、两次转换、三次转换,且每一种情况的操作都在前一种的基础上进行,所以在沿着插入点到根结点的路径向上移动时在所经过的每个结点中顺序完成下列操作,我们就能完成插入操作:
- 第一步:这一步以中间节点为中心,如果右子结点是红色的而左子结点是黑色的,进行左旋转;完成后返回中间的节点
-
第二步:这一步以最右边的节点为中心,如果左子结点是红色的且它的左子结点也是红色的,进行右旋转;完成后返回中间的节点。
所以第一步不能和第二步连续执行,只能等第一步执行完之后,返回到上一个函数调用,中心移动到最右边的节点时,才能执行第二步。
-
第三步:这一步以中间的节点为中心,如果左右子结点均为红色,进行颜色转换。执行完第二步后可以紧接着执行第三步。
public void put(Key key,Value val){ root=put(root,key,val); root.color=BLACK; } private Node put(Node x,Key key,Value val){ //当x等于null时,插入新的节点,不管是插入到2-节点还是3-节点 //新的节点的颜色都是红色,插入之后再进行调整。 if (x==null)return new Node(key,val,1,RED); 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 x.val=val; //添加完节点后,开始自底向上调整树的链接 if (isRed(x.right)&&!isRed(x.left))x=rotateLeft(x); if (isRed(x.left)&&isRed(x.left.left))x=rotateRight(x); if (isRed(x.left)&&isRed(x.right))flipColors(x); //自底向上对每个节点调整子树的大小 x.N=size(x.left)+size(x.right)+1; return x; }
性能分析:
所有基于红黑树的符号表实现都能保证操作的运行时间为对数级别(范围查找除外)
无论键的插人顺序如何,红黑树都几乎是完美平衡的。
一棵大小为N的红黑树的高度不会超过2lgN。
一棵大小为N的红黑树中,根结点到任意结点的平均路径长度为~ 1.00lgN。
4、散列表
散列表:我们可以用一个数组来实现无序的符号表,使用算术操作将键转化为数组的索引,而数组中键i处储存的就是它对应的值。这样我们就可以快速访问任意键的值。
使用散列的查找算法分为两步:
- 第一步是用散列函数将被查找的键转化为数组的一个索引。
理想情况下,不同的键都能转化为不同的索引值。但是实际我们需要面对两个或者多个键都会散列到相同的索引值的情况。
- 因此,散列查找的第二步就是一个处理碰撞冲突的过程
在描述了多种散列函数的计算后,我们会学习两种解决碰撞的方法:拉链法和线性探测法。
使用散列表,可以实现在一般应用中拥有(均摊后)常数级别的查找和插入操作的符号表。这使得它在很多情况下成为实现简单符号表的最佳选择。
散列函数
如果我们有一个能够保存M个键值对的数组,那么我们就需要一个能够将任意键转化为该数组范围内的索引( [0,M-1]范围内的整数)的散列函数。我们要找的散列函数应该易于计算并且能够均匀分布所有的键,即对于任意键,0到M-1之间的每个整数都有相等的可能性与之对应(与键无关)。
散列函数和键的类型有关,对于每种类型的键都我们都需要一个与之对应的散列函数。
-
如果键是一个数,比如社会保险号,我们就可以直接使用这个数
除留余数法:我们选择大小为素数M的数组,对于任意正整数k,计算k除以M的余数。这个方法能够有效地将键散布在0到M-1的范围内。
如果M不是素数,我们可能无法利用键中包含的所有信息,这可能导致我们无法均匀地散列散列值。
如果键是十进制数而M为10^k,那么我们只能利用键的后k位,这可能会产生一些问题。
- 假设键为电话号码的区号且M=100。由于历史原因,美国的大部分区号中间位都是0或者1,因此这种方法会将大量的键散列为小于20的索引,但如果使用素数97,散列值的分布显然会更好(一个离100更远的素数会更好)。
- 互联网中使用的IP地址也不是随机的,所以如果我们想用除留余数法将其散列就需要用素数(2的幂除外)大小的数组。
-
如果键是0到1之间的浮点数:
我们可以将它乘以M并四舍五人得到一个0至M-1之间的索引值。尽管这个方法很容易理解,但它是有缺陷的,因为这种情况下键的高位起的作用更大,最低位对散列的结果没有影响。修正这个问题的办法是将键表示为二进制数然后再使用除留余数法(Java就是这么做的)。
-
如果键是一个字符串,比如一个人的名字,我们就需要将这个字符串转化为一个数
利用除留余数法计算string s的散列值:
int hash = 0; for (int i = 0;i < s.length(); i++) hash= (R*hash + s.charAt(i)) % M;
使用一个较小的素数,例如31,可以保证字符串中的所有字符都能发挥作用。Java 的String的默认实现使用了一个类似的方法。
-
如果键含有多个部分(组合键),比如邮件地址、日期,我们需要用某种方法将这些部分结合起来
假设被查找的键的类型是Date,其中含有几个整型的域: day (两个数字表示的日),month
(两个数字表示的月 )和year (4个数字表示的年) 。我们可以这样计算它的散列值:int hash = (((day * R + month) %M) * R+year) %M;
hashCode()方法
每种数据类型都需要相应的散列函数,于是Java令所有数据类型都继承了一个能够返回一个32位整数的hashCode()方法。
每一种数据类型的hashCode()方法都必须和equals()方法一致:
- 如果a. equals(b)返回true,那么a. hashCode()的返回值必然和b. hashCode()的返回值相同。
- 如果两个对象的hashCode()方法的返回值不同,这两个对象一定是不同的。
- 但如果两个对象的hashCode()方法的返回值相同,这两个对象也有可能不同,我们还需要用equals()方法进行判断。
这说明如果你要为自定义的数据类型定义散列函数,你需要同时重写hashCode()和equals()两个方法。默认散列函数会返回对象的内存地址,但这只适用于很少的情况。
-
自定义的hashCode()方法:
散列表的用例希望hashCode()方法能够将键平均地散布为所有可能的32位整数。也就是说,对于任意对象x,你可以调用x. hashCode()并认为有均等的机会得到2^32中的任意一个32位整数值。对于自已定义的数据类型,必须试着自己实现这一点。
-
Date例子展示了一种可行的方案:用实例变量的整数值和除留余数法得到散列值。
int hash = (((day * R + month) %M) * R+year) %M;
-
在Java中,所有的数据类型都继承了hashCode()方法,因此还有一个更简单的做法:将对象中的每个变量的hashCode()返回值转化为32位整数并计算得到散列值
public Class Transaction { private final String who; private final Date when; private final double amount; public int hashCode() { int hash = 17; hash = 31 * hash + who.hashCode(); hash = 31 * hash + when.hashCode(); hash=31 * hash+ ((Double) amount). hashCode(); return hash; } }
-
-
软缓存
如果散列值的计算很耗时,那么我们可以将每个键的散列值缓存起来,即在每个键中使用一个hash变量来保存它的hashCode()的返回值。
第一次调用hashCode()方法时,我们需要计算对象的散列值,但之后对hashCode()方法的调用会直接返回hash变量的值。Java的String对象的hashCode()方法就使用了这种方法来减少计算量。
-
将hashCode()的返回值转化为一个数组索引:
因为我们需要的是数组的索引而不是一个32位的整数,我们在实现中会将默认的hashCode()方法和除留余数法结合起来产生一个0到M-1的整数:
private int hash(Key x) { return (x.hashCode() & 0x7ffffff) % M; }
这段代码会将符号位屏蔽(将一个 32位整数变为一个 31位非负整数),然后用除留余数法计算它除以M的余数。在使用这样的代码时我们一般会将数组的大小M取为素数以充分利用原散列值的所有位。
总的来说,要为一个数据类型实现一个优秀的散列方法需要满足三个条件:
- 一致性:等价的键必然产生相等的散列值
- 高效性:计算简便
- 均匀性:均匀地散列所有的键
基于拉链法的散列表
散列算法的第二步是碰撞处理,也就是处理两个或多个键的散列值相同的情况。
一种直接的办法是将大小为M的数组中的每个元素指向一条链表,链表中的每个结点都存储了散列值为该元素的索引的键值对。这种方法被称为拉链法,因为发生冲突的元素都被存储在链表中。
这个方法的基本思想就是选择足够大的M,使得所有链表都尽可能短以保证高效的查找。
查找分两步:首先根据散列值找到对应的链表,然后沿着链表顺序查找相应的键。
因为我们要用M条链表保存N个键,无论键在各个链表中的分布如何,链表的平均长度肯定是N/M。
拉链法的一种实现方法是使用一个SequentialSearchST
(无序链表的顺序查找)对象的数组,在put()和get()的实现中先计算散列函数来选定被查找的SequantialSearchST
对象,然后使用符号表的put()和get()方法来完成相应的任务。
public class SeparateChainingHashST<Key,Value> {
private int N;//键值对总数
private int M;//散列表的大小
private SequentialSearchST<Key,Value>[]st;//存放链表对象的数组
//默认构造函数,创建997条链表(数组的大小也就是997)
public SeparateChainingHashST(){
this(997);
}
public SeparateChainingHashST(int M){
//创建M条链表
this.M=M;
//创建类型为SequentialSearchST的数组,创建时需要进行类型转换,因为java不允许泛型的数组
st=(SequentialSearchST<Key,Value>[])new SequentialSearchST[M];
for (int i=0;i<M;i++){
st[i]=new SequentialSearchST<>();
}
}
private int hash(Key key){
//将键的hashCode转化为数组的索引
return (key.hashCode()&0x7fffffff)%M;
}
//查找
public Value get(Key key){
//首先根据散列值找到对应的链表,然后沿着链表顺序查找相应的键
return st[hash(key)].get(key);
}
//插入
public void put(Key key,Value val){
//首先根据散列值找到对应的链表,然后在链表中插入
st[hash(key)].put(key,val);
}
}
这段简单的符号表实现维护着一条链表的数组,用散列函数来为每个键选择一条链表。 在创建st[]时需要进行类型转换,因为Java不允许泛型的数组。
hashCode()由数据类型自己实现,返回一个32位的整数,要尽量做到将键平均地散布为所有可能的32位整数。将hashCode()转化为数组索引由散列表实现,将默认的hashCode()方法和除留余数法结合起来产生一个0到M-1的整数。
默认的构造函数会使用997条链表,因此对于较大的符号表,这种实现比SequentialSearchST大约会快1000倍。
在一张含有M条链表和N个键的的散列表中,未命中查找和插入操作所需的比较次数为~N/M。
散列表的大小
在实现基于拉链法的散列表时,我们的目标是选择适当的数组大小M,既不会因为空链表而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
而拉链法的一个好处就是这并不是关键性的选择。如果存入的键多于预期,查找所需的时间只会比选择更大的数组稍长;如果少于预期,虽然有些空间浪费但查找会非常快。
当内存不是很紧张时,可以选择一个足够大的M,使得查找需要的时间变为常数;当内存紧张时,选择尽量大的M仍然能够将性能提高M倍。
另一种方法是动态调整数组的大小以保持短小的链表。
散列最主要的目的在于均匀地将键散布开来,因此在计算散列后键的顺序信息就丢失了。如果你需要快速找到最大或者最小的键,或是查找某个范围内的键,或是实现有序符号表API中的其他任何方法,散列表都不是合适的选择,因为这些操作的运行时间都将会是线性的。
基于线性探测法的散列表
实现散列表的另一种方式就是用大小为M的数组保存N个键值对,其中M>N。我们需要依靠数组中的空位解决碰撞冲突。基于这种策略的所有方法被统称为开放地址散列表。
开放地址散列表中最简单的方法叫做线性探测法:当碰撞发生时(当一个键的散列值已经被另个不同的键占用),我们直接检查散列表中的下一个位置(将索引值加1)。这样的线性探测可能会产生三种结果:
- 该位置的键和被查找的键相同,命中
- 该位置的键和被查找的键不同,继续查找,直接检查数组中的下一个位置(将索引值加1,到达数组结尾时折回数组的开头)
- 查找到键为null ,散列表中不存在该键,结束查找操作。如果是插入操作的话,此时在该位置插入新的键值对。
- 找到该键,命中。
开放地址类的散列表的核心思想是与其将内存用作链表,不如将它们作为在散列表的空元素。这些空元素可以作为查找结束的标志。
我们在实现中使用了并行数组,一条保存键,一条保存值,并像前面讨论的那样使用散列函数产生访问数据所需的数组索引。
public class LinearProbingHashST<Key,Value> {
private int N;//符号表中键值对的总数
private int M=16;//线性探测表的大小(即数组的大小)
private Key[]keys;//键
private Value[]vals;//值
public LinearProbingHashST(){
keys=(Key[])new Object[M];
vals=(Value[])new Object[M];
}
public LinearProbingHashST(int cap){
M=cap;
keys=(Key[])new Object[M];
vals=(Value[])new Object[M];
}
private int hash(Key key){
return (key.hashCode()&0x7fffffff)%M;
}
private void resize(int n){};
public void put(Key key,Value val){
if (N>M/2)resize(2*M);
int i;
//用散列函数产生数组索引,从此处开始向后查找,直到键为null
//将索引值加1,到达数组结尾时折回数组的开头(i=(i+1)%M)
for(i=hash(key);keys[i]!=null;i=(i+1)%M){
if (key.equals(keys[i])){
vals[i]=val;
return;
}
}
//在键为null的地方插入新的键值对
keys[i]=key;
vals[i]=val;
N++;
}
public Value get(Key key){
for (int i=hash(key);keys[i]!=null;i=(i+1)%M){
//找到了就返回值
if (key.equals(keys[i]))
return vals[i];
}
//没找到就返回空
return null;
}
}
-
删除操作:
直接将该键所在的位置设为null是不行的,因为这会使得在此位置之后的元素无法被查找。我们也不能将被删除位置之后所有的元素都向前挪动一位,因为这样会导致部分元素偏离散列值。
因此,我们需要将簇中被删除键的右侧的所有键重新插入散列表。
public void delete(Key key){ int i=hash(key); //查找要删除的键的位置 while (!key.equals(keys[i])) i=(i+1)%M; keys[i]=null; vals[i]=null; i=(i+1)%M; //对被删除元素后面的所有元素重新插入,遇到空键为止 while (keys[i]!=null){ Key keyToRedo=keys[i]; Value valToRedo=vals[i]; keys[i]=null; vals[i]=null; N--; put(keyToRedo,valToRedo); i=(i+1)%M; } N--; //M大于等于8而且N等于M/8时就要动态调整大小为M/2 if (N>0&&N==M/8)resize(M/2); }
拉链法和开放地址类的散列表的性能都依赖于a =N/M的比值,但意义有所不同。
我们将a称为散列表的使用率。
- 对于基于拉链法的散列表,a是链表的平均长度,因此一般大于1
- 对于基于线性探测的散列表,a是表中已被占用的空间的比例,它是不可能大于1的。事实上,在
LinearProbingHashST
中我们不允许a达到1 ( 散列表被占满),因为此时未命中的查找会导致
无限循环。为了保证性能,我们会动态调整数组的大小来保证使用率在1/8到1/2之间。
当散列表快满的时候查找所需的探测次数是巨大的(a越趋近于1,由公式可知探测的次数也越来越大),但当使用率a小于1/2时探测的预计次数只在1.5到2.5之间。所以,我们需要为此来考虑动态调整散列表数组的大小。
调整数组的大小
-
线性探测法:
它会创建一个新的给定大小的
LinearProbingHashST
,保存原表中的keys和values变量,然后将原表中所有的键重新散列并插人到新表中(注意!不能直接按照原数组的位置直接插入!要重新散列之后再插入!)private void resize(int cap){ LinearProbingHashST<Key,Value>t=new LinearProbingHashST<>(cap); for (int i=0;i<M;i++){ if (keys[i]!=null) t.put(keys[i],vals[i]); } keys=t.keys; vals=t.vals; M=t.M; };
-
拉链法:
对于拉链法,如果你能准确地估计用例所需的散列表的大小N,调整数组的工作并不是必需的,只需要根据查找耗时和( 1+N/M)成正比来选取一个适当的M即可。而对于线性探测法,调整数组的大小是必需的,因为当用例插人的键值对数量超过预期时它的查找时间不仅会变得非常长,还会在散列表被填满时
进入无限循环。
Java 的Integer、Double 和Long类型的hashCode()方法是如何实现的?
Integer类型会直接返回该整数的32位值。对于Double和Long类型,Java会返回值的机器表示的前32位和后32位异或的结果。这些方法可能不够随机,但它们的确能够将值散列。
散列表的查找比红黑树更快吗?
这取决于键的类型,它决定了hashCode()的计算成本是否大于compareTo()的比较成本。对于常见的键类型以及Java的默认实现,这两者的成本是近似的,因此散列表会比红黑树快得多,因为它所需的操作次数是固定的。但需要注意的是,如果要进行有序性相关的操作,这个问题就没有意义了,因为散列表无法高效地支持这些操作。
5、应用
-
编译器
符号表最早期的应用之一就是组织程序代码的信息。最初,计算机程序只是一串简单的数字,但程序员们很快发现使用符号来表示操作和内存地址(变量名)要方便得多。将名称和数字关联起来就需要一-张符号表。 随着程序的增长,符号表操作的性能逐渐变成了程序开发效率的瓶颈,为此而开发的数据结构和算法就是我们在本章中学习的内容。
-
文件系统
我们都在使用符号表定期整理计算机系统中的数据。也许其中最明显的例子就是文件系统了,因为是它将文件名(键)和文件内容的地址(值)关联起来。音乐播放器同样使用文件系统关联了歌曲名( 键)和歌曲的位置(值)
-
互联网DNS
域名系统(DNS)是互联网信息组织的基础,它可以将人类能够理解的URL (键,如www.princeton.edu或是www.wikipedia.org )和计算机网络中路由器能够理解的IP地址(值,如208.216.181.15或是207.142.131.206 ) 关联起来。这个系统被称为下一代“电话黄页”。有了它,人们就可以使用便于记忆的域名,而机器也可以高效地处理对应的数字。全球互联网的路由器中每秒钟进行的符号表查找次数是个天文数字,所以性能显然非常重要。每年,互联网上都会新增上百万台电脑和其他设备,因此互联网路由器中的符号表也需要能够动态地适应它们。