声明:
本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。
本博客已标明出处,如有侵权请告知,马上删除。
10.3 排序二叉树
HashMap 和 HashSet,它们的共同实现机制是哈希表,一个共同的限制是没有顺序,我们提到,它们都有一个能保持顺序的对应类 TreeMap 和 TreeSet,这两个类的共同实现基础是排序二叉树。为了更好的理解 TreeMap 和 TreeSet,本节我们先来介绍排序二叉树的一些基本概念和算法。
10.3.1 基本概念
先来说树的概念,现实中,树是从下往上长的,树会分叉,在计算机程序中,一般而言,与现实相反,树是从上往下长的,也会分叉,有个根节点,每个节点可以有一个或多个孩子节点,没有孩子节点的节点一般称为叶子节点。
二叉树是一个树,每个节点最多有两个孩子节点,一左一右,左边的称为左孩子,右边的称为右孩子,我们看两个例子,如图 10-5 所示。
图 10-5 中,这两棵树都是二叉树,左边的根节点为 5,除了叶子节点外,每个节点都有两个孩子节点,右边的根节点为 7,有的节点有两个孩子节点,有的只有一个。
树有一个高度或深度的概念,是从根到叶子节点经过的节点个数的最大值,左边树的高度为 3,右边的为 5。
排序二叉树也是二叉树,它没有重复元素,而且是有序的二叉树,什么顺序呢?对每个节点而言:
- 如果左子树不为空,则左子树上的所有节点都小于该节点
- 如果右子树不为空,则右子树上的所有节点都大于该节点
上面的两颗二叉树都是排序二叉树。比如说左边的树,根节点为 5,左边的都小于 5,右边的都大于 5。再看右边的树,根节点为 7,左边的都小于 7,右边的都大于 7,在以 3 为根的左子树中,其右子树的值都大于 3。
排序二叉树有什么优点?如何在树中进行基本操作如查找、遍历、插入和删除呢?我们来看一下基本的算法。
10.3.2 基本算法
10.3.2.1 查找
排序二叉树有一个很好的优点,在其中查找一个元素是很方便、也很高效的,基本步骤为:
- 首先与根节点比较,如果相同,就找到了
- 如果小于根节点,则到左子树中递归查找
- 如果大于根节点,则到右子树中递归查找
这个步骤与在数组中进行二分查找或者说折半查找的思路是类似的,如果二叉树是比较平衡的,类似上图中左边的二叉树,则每次比较都能将比较范围缩小一半,效率很高。
此外,在排序二叉树中,可以方便的查找最小最大值,最小值即为最左边的节点,从根节点一路查找左孩子即可,最大值即为最右边的节点,从根节点一路查找右孩子即可。
10.3.2.2 遍历
排序二叉树也可以方便的按序遍历,用递归的方式,用如下算法即可按序遍历:
- 访问左子树
- 访问当前节点
- 访问右子树
比如,遍历访问图 10-6 的二叉树。
从根节点开始,但先访问根节点的左子树,一直到最左边的节点,所以第一个访问的是 1,1 没有右子树,返回上一层,访问 3,然后访问 3 的右子树,4 没有左子树,所以访问 4,然后是 4 的右子树 6,依次类推,访问顺序就是有序的:1 3 4 6 7 8 9。
不用递归的方式,也可以实现按序遍历,第一个节点为最左边的节点,从第一个节点开始,依次找后继节点。给定一个节点,找其后继节点的算法为:
- 如果该节点有右孩子,则后继为右子树中最小的节点。
- 如果该节点没有右孩子,则后继为父节点或某个祖先节点,从当前节点往上找,如果它是父亲节点的右孩子,则继续找父节点,直到它不是右孩子或父节点为空,第一个非右孩子节点的父亲节点就是后继节点,如果找不到这样的祖先节点,则后继为空,遍历结束。
文字描述比较抽象,我们来看个图,以图 10-6 为例,每个节点的后继如图 10-7 浅色箭头所示。
对每个节点,对照算法,我们再详细解释下:
- 第一个节点 1 没有右孩子,它不是父节点的右孩子,所以它的后继节点就是其父节点 3。
- 3 有右孩子,右子树中最小的就是 4,所以 3 的后继节点为 4。
- 4 有右孩子,右子树中只有一个节点 6,所以 4 的后继节点为 6。
- 6 没有右孩子,往上找父节点,它是父节点 4 的右孩子,4 又是父节点 3 的右孩子,3 不是父节点 7 的右孩子,所以 6 的后继节点为 3 的父节点 7。
- 7 有右孩子,右子树中最小的是 8,所以 7 的后继节点为 8。
- 8 没有右孩子,往上找父节点,它不是父节点 9 的右孩子,所以它的后继节点就是其父节点 9。
- 9 没有右孩子,往上找父节点,它是父节点 7 的右孩子,接着往上找,但 7 已经是根节点,父节点为空,所以后继为空。
怎么构建排序二叉树呢?可以在插入、删除元素的过程中形成和保持。
10.3.2.3 插入
在排序二叉树中,插入元素首先要找插入位置,即新节点的父节点,怎么找呢?与查找元素类似,从根节点开始往下找,其步骤为:
- 与当前节点比较,如果相同,表示已经存在了,不能再插入。
- 如果小于当前节点,则到左子树中寻找,如果左子树为空,则当前节点即为要找的父节点。
- 如果大于当前节点,则到右子树中寻找,如果右子树为空,则当前节点即为要找的父节点。
- 找到父节点后,即可插入,如果插入元素小于父节点,则作为左孩子插入,否则作为右孩子插入。
我们来看个例子,依次插入 7, 3, 4, 1, 9, 6, 8 的过程,这个过程如图 10-8 所示。
10.3.2.4 删除
从排序二叉树中删除一个节点要复杂一些,有三种情况:
- 节点为叶子节点
- 节点只有一个孩子
- 节点有两个孩子
我们分别来看下。
如果节点为叶子节点,则很简单,可以直接删掉,修改父节点的对应孩子为空即可。
如果节点只有一个孩子节点,则替换待删节点为孩子节点,或者说,在孩子节点和父节点之间直接建立链接。比如说,在图 10-9 中,左边二叉树中删除节点 4,就是让 4 的父节点 3 与 4 的孩子节点 6 直接建立链接。
如果节点有两个孩子,则首先找该节点的后继(根据之前介绍的后继算法,后继为右子树中最小的节点,这个后继一定没有左孩子),找到后继后,替换待删节点为后继的内容,然后再删除后继节点。后继节点没有左孩子,这就将两个孩子的情况转换为了叶子节点或只有一个孩子的情况。
比如说,在图 10-10 中,从左边二叉树中删除节点 3,3 有两个孩子,后继为 4,首先替换 3 的内容为 4,然后再删除节点 4。
10.3.3 平衡的排序二叉树
从前面的描述中可以看出,排序二叉树的形状与插入和删除的顺序密切相关,极端情况下,排序二叉树可能退化为一个链表,比如说,如果插入顺序为:1 3 4 6 7 8 9,则排序二叉树形状如图 10-11 所示。
退化为链表后,排序二叉树的优点就都没有了,即使没有退化为链表,如果排序二叉树高度不平衡,效率也会变的很低。
平衡具体定义是什么呢?有一种高度平衡的定义,即任何节点的左右子树的高度差最多为一。满足这个平衡定义的排序二叉树又被称为 AVL 树,这个名字源于它的发明者 G.M. Adelson-Velsky 和 E.M. Landis,在他们的算法中,在插入和删除节点时,通过一次或多次旋转操作来重新平衡树。
在 TreeMap 的实现中,用的并不是 AVL 树,而是红黑树,与 AVL 树类似,红黑树也是一种平衡的排序二叉树,也是在插入和删除节点时通过旋转操作来平衡的,但它并不是高度平衡的,而是大致平衡的,所谓大致是指,它确保,对于任意一条从根到叶子节点的路径,没有任何一条路径的长度会比其他路径长过两倍。红黑树减弱了对平衡的要求,但降低了保持平衡需要的开销,在实际应用中,统计性能高于 AVL 树。
为什么叫红黑树呢?因为它对每个节点进行着色,颜色或黑或红,并对节点的着色有一些约束,满足这个约束即可以确保树是大致平衡的。
对 AVL 树和红黑树,它们保持平衡的细节都是比较复杂的,我们就不介绍了,我们需要知道的就是,它们都是排序二叉树,都通过在插入和删除时执行开销不大的旋转操作保持了树的高度平衡或大致平衡,从而保证了树的查找效率。
10.3.4 小结
本节介绍了排序二叉树的基本概念和算法。
排序二叉树保持了元素的顺序,而且是一种综合效率很高的数据结构,基本的保存、删除、查找的效率都为 O(h),h 为树的高度。在树平衡的情况下,h 为 log(N),N 为节点数,比如,如果 N 为 1024,则 log(N) 为10。
基本的排序二叉树不能保证树的平衡,可能退化为一个链表,有很多保持树平衡的算法,AVL 树是第一个,能保证树的高度平衡,但红黑树是实际中使用更为广泛的,虽然只能保证大致平衡,但降低了维持树平衡需要的开销,整体统计效果更好。
与哈希表一样,树也是计算机程序中一种重要的数据结构和思维方式。为了能够快速操作数据,哈希和树是两种基本的思维方式,不需要顺序,优先考虑哈希,需要顺序,考虑树。除了容器类 TreeMap/TreeSet,数据库中的索引结构也是基于树的(不过基于 B 树,而不是二叉树),而索引是能够在大量数据中快速访问数据的关键。
理解了排序二叉树的基本概念和算法,理解 TreeMap 和 TreeSet 就比较容易了,让我们在接下来的两节中探讨这两个类。
10.4 剖析 TreeMap
在介绍了 HashMap 时,我们提到,HashMap 有一个重要局限,键值对之间没有特定的顺序。我们还提到,Map 接口有另一个重要的实现类 TreeMap,在 TreeMap 中,键值对之间按键有序,TreeMap 的实现基础是排序二叉树,上节我们介绍了排序二叉树的基本概念和算法,本节我们来详细讨论 TreeMap。
除了 Map 接口,因为有序,TreeMap 还实现了更多接口和方法,下面,我们先来看 TreeMap 的用法,然后探讨其内部实现。
10.4.1 基本用法
10.4.1.1 构造方法
TreeMap 有两个基本构造方法:
public TreeMap()
public TreeMap(Comparator<? super K> comparator)
第一个为默认构造方法,如果使用默认构造方法,要求 Map 中的键实现 Comparabe 接口,TreeMap 内部进行各种比较时会调用键的 Comparable 接口中的 compareTo 方法。
第二个接受一个比较器对象 comparator,如果 comparator 不为 null,在 TreeMap 内部进行比较时会调用这个 comparator 的 compare 方法,而不再调用键的 compareTo 方法,也不再要求键实现 Comparable 接口。
应该用哪一个呢?第一个更为简单,但要求键实现 Comparable 接口,且期望的排序和键的比较结果是一致的,第二个更为灵活,不要求键实现 Comparable 接口,比较器可以用灵活复杂的方式进行实现。
需要强调的是,TreeMap 是按键而不是按值有序,无论哪一种,都是对键而非值进行比较。
接下来,我们来看一些简单的使用 TreeMap 的例子。
10.4.1.2 基本例子
看段简单的示例代码:
Map<String, String> map = new TreeMap<>();
map.put("a", "abstract");
map.put("c", "call");
map.put("b", "basic");
map.put("T", "tree");
for(Entry<String,String> kv : map.entrySet()){
System.out.print(kv.getKey()+"="+kv.getValue()+" ");
}
创建了一个 TreeMap,但只是当做 Map 使用,不过迭代时,其输出却是按键排序的,输出为:
T=tree a=abstract b=basic c=call
T 排在最前面,是因为大写字母都小于小写字母。如果希望忽略大小写呢?可以传递一个比较器,String 类有一个静态成员 CASE_INSENSITIVE_ORDER,它就是一个忽略大小写的 Comparator 对象,替换第一行代码为:
Map<String, String> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
输出就会变为:
a=abstract b=basic c=call T=tree
正常排序是从小到大,如果希望逆序呢?可以传递一个不同的 Comparator 对象,第一行代码可以替换为:
Map<String, String> map = new TreeMap<>(new Comparator<String>(){
@Override
public int compare(String o1, String o2) {
return o2.compareTo(o1);
}
});
这样,输出会变为:
c=call b=basic a=abstract T=tree
为什么这样就可以逆序呢?正常排序中,compare 方法内,是 o1.compareTo(o2),两个对象翻过来,自然就是逆序了,Collections 类有一个静态方法 reverseOrder() 可以返回一个逆序比较器,也就是说,上面代码也可以替换为:
Map<String, String> map = new TreeMap<>(Collections.reverseOrder());
如果希望逆序且忽略大小写呢?第一行可以替换为:
Map<String, String> map = new TreeMap<>(
Collections.reverseOrder(String.CASE_INSENSITIVE_ORDER));
需要说明的是,TreeMap 使用键的比较结果对键进行排重,即使键实际上不同,但只要比较结果相同,它们就会被认为相同,键只会保存一份。比如,如下代码:
Map<String, String> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
map.put("T", "tree");
map.put("t", "try");
for(Entry<String,String> kv : map.entrySet()){
System.out.print(kv.getKey()+"="+kv.getValue()+" ");
}
看上去有两个不同的键 “T” 和 “t”,但因为比较器忽略大小写,所以只会有一个,输出会是:
T=try
键为第一次 put 时的,这里即 “T”,而值为最后一次 put 时的,这里即 “try”。
10.4.1.3 日期例子
我们再来看一个例子,键为字符串形式的日期,值为一个统计数字,希望按照日期输出,代码为:
Map<String, Integer> map = new TreeMap<>();
map.put("2016-7-3", 100);
map.put("2016-7-10", 120);
map.put("2016-8-1", 90);
for(Entry<String,Integer> kv : map.entrySet()){
System.out.println(kv.getKey()+","+kv.getValue());
}
输出为:
2016-7-10,120
2016-7-3,100
2016-8-1,90
7 月 10 号的排在了 7 月 3 号的前面,与期望的不符,这是因为,它们是按照字符串比较的,按字符串,2016-7-10 就是小于 2016-7-3,因为第一个不同之处 1 小于 3。
怎么解决呢?可以使用一个自定义的比较器,将字符串转换为日期,按日期进行比较,第一行代码可以改为:
Map<String, Integer> map = new TreeMap<>(new Comparator<String>() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
@Override
public int compare(String o1, String o2) {
try {
return sdf.parse(o1).compareTo(sdf.parse(o2));
} catch (ParseException e) {
e.printStackTrace();
return 0;
}
}
});
这样,输出就符合期望了,会变为:
2016-7-3,100
2016-7-10,120
2016-8-1,90
10.4.1.4 基本用法小结
以上就是 TreeMap 的基本用法,与 HashMap 相比:
- 相同的是,它们都实现了 Map 接口,都可以按 Map 进行操作。
- 不同的是,迭代时,TreeMap 按键有序,为了实现有序,它要求:要么键实现 Comparable 接口,要么创建 TreeMap 时传递一个 Comparator 对象。
不过,由于 TreeMap 按键有序,它还支持更多接口和方法,具体来说,它还实现了 SortedMap 和 NavigableMap 接口,而 NavigableMap 接口扩展了 SortedMap,通过这两个接口,可以方便地根据键的顺序进行查找,如第一个、最后一个、某一范围的键、邻近键等,限于篇幅,我们就不介绍了,具体可参见 API 文档。
10.4.2 基本实现原理
TreeMap 内部是用红黑树实现的,红黑树是一种大致平衡的排序二叉树,上节我们介绍了排序二叉树的基本概念和算法,本节我们主要看 TreeMap 的一些代码实现,先来看 TreeMap 的内部组成。
10.4.2.1 内部组成
TreeMap 内部主要有如下成员:
private final Comparator<? super K> comparator;
private transient Entry<K,V> root = null;
private transient int size = 0;
comparator 就是比较器,在构造方法中传递,如果没传,就是 null。size 为当前键值对个数。root 指向树的根节点,从根节点可以访问到每个节点,节点的类型为 Entry。Entry 是 TreeMap 的一个内部类,其内部成员和构造方法为:
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left = null;
Entry<K,V> right = null;
Entry<K,V> parent;
boolean color = BLACK;
/**
* Make a new cell with given key, value, and parent, and with
* {@code null} child links, and BLACK color.
*/
Entry(K key, V value, Entry<K,V> parent) {
this.key = key;
this.value = value;
this.parent = parent;
}
}
每个节点除了键(key)和值(value)之外,还有三个引用,分别指向其左孩子(left)、右孩子(right)和父节点(parent),对于根节点,父节点为 null,对于叶子节点,孩子节点都为 null,还有一个成员 color 表示颜色,TreeMap 是用红黑树实现的,每个节点都有一个颜色,非黑即红。
了解了 TreeMap 的内部组成,我们来看一些主要方法的实现代码。
10.4.2.2 保存键值对
put 方法的代码稍微有点长,我们分段来看,先看第一段,添加第一个节点的情况:
public V put(K key, V value) {
Entry<K,V> t = root;
if (t == null) {
compare(key, key); // type (and possibly null) check
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
...
当添加第一个节点时,root 为 null,执行的就是这段代码,主要就是新建一个节点,设置 root 指向它,size 设置为 1,modCount++ 的含义与之前几节介绍的类似,用于迭代过程中检测结构性变化。
令人费解的是 compare 调用,compare(key, key);,key 与 key 比,有什么意义呢?我们看 compare 方法的代码:
final int compare(Object k1, Object k2) {
return comparator==null ? ((Comparable<? super K>)k1).compareTo((K)k2)
: comparator.compare((K)k1, (K)k2);
}
其实,这里的目的不是为了比较,而是为了检查 key 的类型和 null,如果类型不匹配或为 null,compare 方法会抛出异常。
如果不是第一次添加,会执行后面的代码,添加的关键步骤是寻找父节点,找父节点根据是否设置了comparator 分为两种情况,我们先来看设置了的情况,代码为:
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
if (cpr != null) {
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
寻找是一个从根节点开始循环的过程,在循环中,cmp 保存比较结果,t 指向当前比较节点,parent 为 t 的父节点,循环结束后 parent 就是要找的父节点。
t 一开始指向根节点,从根节点开始比较键,如果小于根节点,就将 t 设为左孩子,与左孩子比较,大于就与右孩子比较,就这样一直比,直到 t 为 null 或比较结果为 0。如果比较结果为 0,表示已经有这个键了,设置值,然后返回。如果 t 为 null,则当退出循环时,parent 就指向待插入节点的父节点。
我们再来看没有设置 comparator 的情况,代码为:
else {
if (key == null)
throw new NullPointerException();
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
基本逻辑是一样的,当退出循环时 parent 指向父节点,只是,如果没有设置 comparator,则假设 key 一定实现了 Comparable 接口,使用 Comparable 接口的 compareTo 方法进行比较。
找到父节点后,就是新建一个节点,根据新的键与父节点键的比较结果,插入作为左孩子或右孩子,并增加 size 和 modCount,代码如下:
Entry<K,V> e = new Entry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
fixAfterInsertion(e);
size++;
modCount++;
代码大部分都容易理解,不过,里面有一行重要调用 fixAfterInsertion(e);,它就是在调整树的结构,使之符合红黑树的约束,保持大致平衡,其代码我们就不介绍了。
稍微总结一下,其基本思路就是,循环比较找到父节点,并插入作为其左孩子或右孩子,然后调整保持树的大致平衡。
10.4.2.3 根据键获取值
代码为:
public V get(Object key) {
Entry<K,V> p = getEntry(key);
return (p==null ? null : p.value);
}
就是根据 key 找对应节点 p,找到节点后获取值 p.value,来看 getEntry 的代码:
final Entry<K,V> getEntry(Object key) {
// Offload comparator-based version for sake of performance
if (comparator != null)
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
Comparable<? super K> k = (Comparable<? super K>) key;
Entry<K,V> p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;
}
如果 comparator 不为空,调用单独的方法 getEntryUsingComparator,否则,假定 key 实现了 Comparable 接口,使用接口的 compareTo 方法进行比较。找的逻辑也很简单,从根开始找,小于往左边找,大于往右边找,直到找到为止,如果没找到,返回 null。getEntryUsingComparator 方法的逻辑是类似,就不赘述了。
10.4.2.4 查看是否包含某个值
TreeMap 可以高效的按键进行查找,但如果要根据值进行查找,则需要遍历,我们来看代码:
public boolean containsValue(Object value) {
for (Entry<K,V> e = getFirstEntry(); e != null; e = successor(e))
if (valEquals(value, e.value))
return true;
return false;
}
主体就是一个循环遍历,getFirstEntry 方法返回第一个节点,successor 方法返回给定节点的后继节点,valEquals 就是比较值,从第一个节点开始,逐个进行比较,直到找到为止,如果循环结束也没找到则返回 false。
getFirstEntry 的代码为:
final Entry<K,V> getFirstEntry() {
Entry<K,V> p = root;
if (p != null)
while (p.left != null)
p = p.left;
return p;
}
代码很简单,第一个节点就是最左边的节点。
上节我们介绍过找后继的算法,successor 的具体代码为:
static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {
if (t == null)
return null;
else if (t.right != null) {
Entry<K,V> p = t.right;
while (p.left != null)
p = p.left;
return p;
} else {
Entry<K,V> p = t.parent;
Entry<K,V> ch = t;
while (p != null && ch == p.right) {
ch = p;
p = p.parent;
}
return p;
}
}
如上节后继算法所述,有两种情况:
- 如果有右孩子(t.right!=null),则后继为右子树中最小的节点。
- 如果没有右孩子,后继为某祖先节点,从当前节点往上找,如果它是父节点的右孩子,则继续找父节点,直到它不是右孩子或父节点为空,第一个非右孩子节点的父亲节点就是后继节点,如果父节点为空,则后继为 null。
代码与算法是对应的,就不再赘述了,可以参考图 10-7,进行对照。
10.4.2.5 根据键删除键值对
删除的代码为:
public V remove(Object key) {
Entry<K,V> p = getEntry(key);
if (p == null)
return null;
V oldValue = p.value;
deleteEntry(p);
return oldValue;
}
根据 key 找到节点,调用 deleteEntry 删除节点,然后返回原来的值。
上节介绍过节点删除的算法,节点有三种情况:
- 叶子节点:这个容易处理,直接修改父节点对应引用置 null 即可。
- 只有一个孩子:就是在父亲节点和孩子节点直接建立链接。
- 有两个孩子:先找到后继,找到后,替换当前节点的内容为后继节点,然后再删除后继节点,因为这个后继节点一定没有左孩子,所以就将两个孩子的情况转换为了前面两种情况。
deleteEntry 的具体代码也稍微有点长,我们分段来看:
private void deleteEntry(Entry<K,V> p) {
modCount++;
size--;
// If strictly internal, copy successor's element to p and then make p
// point to successor.
if (p.left != null && p.right != null) {
Entry<K,V> s = successor(p);
p.key = s.key;
p.value = s.value;
p = s;
} // p has 2 children
这里处理的就是两个孩子的情况,s 为后继,当前节点 p 的 key 和 value 设置为了 s 的 key 和 value,然后将待删节点 p 指向了 s,这样就转换为了一个孩子或叶子节点的情况。
再往下看一个孩子情况的代码:
// Start fixup at replacement node, if it exists.
Entry<K,V> replacement = (p.left != null ? p.left : p.right);
if (replacement != null) {
// Link replacement to parent
replacement.parent = p.parent;
if (p.parent == null)
root = replacement;
else if (p == p.parent.left)
p.parent.left = replacement;
else
p.parent.right = replacement;
// Null out links so they are OK to use by fixAfterDeletion.
p.left = p.right = p.parent = null;
// Fix replacement
if (p.color == BLACK)
fixAfterDeletion(replacement);
} else if (p.parent == null) { // return if we are the only node.
p 为待删节点,replacement 为要替换 p 的孩子节点,主体代码就是在 p 的父节点 p.parent 和 replacement 之间建立链接,以替换 p.parent 和 p 原来的链接,如果 p.parent 为 null,则修改 root 以指向新的根。fixAfterDeletion 重新平衡树。
最后来看叶子节点的情况:
} else if (p.parent == null) { // return if we are the only node.
root = null;
} else { // No children. Use self as phantom replacement and unlink.
if (p.color == BLACK)
fixAfterDeletion(p);
if (p.parent != null) {
if (p == p.parent.left)
p.parent.left = null;
else if (p == p.parent.right)
p.parent.right = null;
p.parent = null;
}
}
再具体分为两种情况,一种是删除最后一个节点,修改 root 为 null,否则就是根据待删节点是父节点的左孩子还是右孩子,相应的设置孩子节点为 null。
10.4.2.6 实现原理小结
以上就是 TreeMap 的基本实现原理,与上节介绍的排序二叉树的基本概念和算法是一致的,只是 TreeMap 用了红黑树。
10.4.3 TreeMap 特点分析
与 HashMap 相比,TreeMap 同样实现了 Map 接口,但内部使用红黑树实现,红黑树是统计效率比较高的大致平衡的排序二叉树,这决定了它有如下特点:
- 按键有序,TreeMap 同样实现了 SortedMap 和 NavigableMap 接口,可以方便的根据键的顺序进行查找,如第一个、最后一个、某一范围的键、邻近键等。
- 为了按键有序,TreeMap 要求键实现 Comparable 接口或通过构造方法提供一个 Comparator 对象。
- 根据键保存、查找、删除的效率比较高,为 O(h),h 为树的高度,在树平衡的情况下,h 为 log(N),N 为节点数。
应该用 HashMap 还是 TreeMap 呢?不要求排序,优先考虑 HashMap,要求排序,考虑 TreeMap。
10.4.4 小结
本节介绍了 TreeMap 的用法和实现原理,在用法方面,它实现了Map接口,但按键有序,同样实现了 SortedMap 和 NavigableMap 接口,在内部实现上,它使用红黑树,整体效率比较高。
HashMap 有对应的 TreeMap,HashSet 也有对应的 TreeSet,下节,我们来看 TreeSet。
10.5 剖析 TreeSet
介绍 HashSet 时,我们提到,HashSet 有一个重要局限,元素之间没有特定的顺序,我们还提到,Set 接口还有另一个重要的实现类 TreeSet,它是有序的,与 HashSet 和 HashMap 的关系一样,TreeSet 是基于 TreeMap 的,上节我们介绍了 TreeMap,本节我们来详细讨论 TreeSet。
下面,我们先来看 TreeSet 的用法,然后看实现原理,最后总结分析 TreeSet 的特点。
10.5.1 基本用法
10.5.1.1 构造方法
TreeSet 的基本构造方法有两个:
public TreeSet()
public TreeSet(Comparator<? super E> comparator)
默认构造方法假定元素实现了 Comparable 接口,第二个使用传入的比较器,不要求元素实现 Comparable。
10.5.1.2 基本例子
TreeSet 经常也只是当做 Set 使用,只是希望迭代输出有序,如下面代码所示:
Set<String> words = new TreeSet<String>();
words.addAll(Arrays.asList(new String[]{
"tree", "map", "hash", "map",
}));
for(String w : words){
System.out.print(w+" ");
}
输出为:
hash map tree
TreeSet 实现了两点:排重和有序。
如果希望不同的排序,可以传递一个 Comparator,如下所示:
Set<String> words = new TreeSet<String>(new Comparator<String>(){
@Override
public int compare(String o1, String o2) {
return o1.compareToIgnoreCase(o2);
}});
words.addAll(Arrays.asList(new String[]{
"tree", "map", "hash", "Map",
}));
System.out.println(words);
忽略大小写进行比较,输出为:
[hash, map, tree]
需要注意的是,Set 是排重的,排重是基于比较结果的,结果为 0 即视为相同,“map” 和 "Map"虽然不同,但比较结果为 0,所以只会保留第一个元素。
以上就是 TreeSet 的基本用法,简单易用。不过,因为有序,TreeSet 还实现了 NavigableSet 和 SortedSet 接口,NavigableSet 扩展了 SortedSet,可以方便地根据顺序进行查找和操作,如第一个、最后一个、某一取值范围、某一值的邻近元素等,限于篇幅,我们就不介绍了,具体可参见 API 文档。
10.5.2 基本实现原理
之前章节介绍过,HashSet 是基于 HashMap 实现的,元素就是 HashMap 中的键,值是一个固定的值,TreeSet 是类似的,它是基于 TreeMap 实现的,我们具体来看一下代码,先看其内部组成。
10.5.2.1 内部组成
TreeSet 的内部有如下成员:
private transient NavigableMap<E,Object> m;
private static final Object PRESENT = new Object();
m 就是背后的那个 TreeMap,这里用的是更为通用的接口类型 NavigableMap,PRESENT 就是那个固定的共享值。
TreeSet 的方法实现主要就是调用 m 的方法,我们具体来看下。
10.5.2.2 构造方法
几个构造方法的代码为:
TreeSet(NavigableMap<E,Object> m) {
this.m = m;
}
public TreeSet() {
this(new TreeMap<E,Object>());
}
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<>(comparator));
}
public TreeSet(Collection<? extends E> c) {
this();
addAll(c);
}
public TreeSet(SortedSet<E> s) {
this(s.comparator());
addAll(s);
}
代码都比较简单,就不解释了。
10.5.2.3 添加元素
add 方法的代码为:
public boolean add(E e) {
return m.put(e, PRESENT)==null;
}
就是调用 map 的 put 方法,元素 e 用作键,值就是固定值 PRESENT,put 返回 null 表示原来没有对应的键,添加成功了。
10.5.2.4 检查是否包含元素
代码为:
public boolean contains(Object o) {
return m.containsKey(o);
}
就是检查 map 中是否包含对应的键。
10.5.2.5 删除元素
代码为:
public boolean remove(Object o) {
return m.remove(o)==PRESENT;
}
就是调用 map 的 remove 方法,返回值为 PRESENT 表示原来有对应的键且删除成功了。
10.5.2.6 实现原理小结
TreeSet 的实现代码都比较简单,主要就是调用内部 NavigatableMap 的方法。
10.5.3 TreeSet 特点分析
与 HashSet 相比,TreeSet 同样实现了 Set 接口,但内部基于 TreeMap 实现,而 TreeMap 基于大致平衡的排序二叉树 - 红黑树,这决定了它有如下特点:
- 没有重复元素
- 添加、删除元素、判断元素是否存在,效率比较高,为 O(log(N)),N 为元素个数。
- 有序,TreeSet 同样实现了 SortedSet 和 NavigatableSet 接口,可以方便的根据顺序进行查找和操作,如第一个、最后一个、某一取值范围、某一值的邻近元素等。
- 为了有序,TreeSet 要求元素实现 Comparable 接口或通过构造方法提供一个 Comparator 对象。