初识TreeMap
之前的文章讲解了两种Map,分别是HashMap与LinkedHashMap,它们保证了以O(1)的时间复杂度进行增、删、改、查,从存储角度考虑,这两种数据结构是非常优秀的。另外,LinkedHashMap还额外地保证了Map的遍历顺序可以与put顺序一致,解决了HashMap本身无序的问题。
尽管如此,HashMap与LinkedHashMap还是有自己的局限性—它们不具备统计性能,或者说它们的统计性能时间复杂度并不是很好才更准确,所有的统计必须遍历所有Entry,因此时间复杂度为O(N)。比如Map的Key有1、2、3、4、5、6、7,我现在要统计:
- 所有Key比3大的键值对有哪些
- Key最小的和Key最大的是哪两个
就类似这些操作,HashMap和LinkedHashMap做得比较差,此时我们可以使用TreeMap。TreeMap的Key按照自然顺序进行排序或者根据创建映射时提供的Comparator接口进行排序。
TreeMap为增、删、改、查这些操作提供了log(N)的时间开销,从存储角度而言,这比HashMap与LinkedHashMap的O(1)时间复杂度要差些;但是在统计性能上,TreeMap同样可以保证log(N)的时间开销,这又比HashMap与LinkedHashMap的O(N)时间复杂度好不少。
因此总结而言:如果只需要存储功能,使用HashMap与LinkedHashMap是一种更好的选择;如果还需要保证统计性能或者需要对Key按照一定规则进行排序,那么使用TreeMap是一种更好的选择
红黑树的一些基本概念
二叉查找树是在生成的时候是非常容易失衡的,造成的最坏情况就是一边倒(即只有左子树/右子树),这样会导致树检索的效率大大降低。
红黑树是为了维护二叉查找树的平衡而产生的一种树,红黑树的特性大致有三个(换句话说,插入、删除节点后整个红黑树也必须满足下面的三个性质,如果不满足则必须进行旋转):
- 根节点与叶节点都是黑色节点,其中叶节点为Null节点
- 每个红色节点的两个子节点都是黑色节点,换句话说就是不能有连续两个红色节点
- 从根节点到所有叶子节点上的黑色节点数量是相同的
上述的性质约束了红黑树的关键:从根到叶子的最长可能路径不多于最短可能路径的两倍长。得到这个结论的理由是:
- 红黑树中最短的可能路径是全部为黑色节点的路径
- 红黑树中最长的可能路径是红黑相间的路径
结果就是这个树大致上是平衡的,因为比如插入、删除和查找某个值这样的操作最坏情况都要求与树的高度成比例,这个高度的理论上限允许红黑树在最坏情况下都是高效的,而不同于普通的二叉查找树,最终保证了红黑树能够以O(logn) 的时间复杂度进行搜索、插入、删除。
可以看到根节点到所有NULL LEAF节点(即叶子节点)所经过的黑色节点都是2个。
另外从这张图上我们还能得到一个结论:红黑树并不是高度的平衡树。所谓平衡树指的是一棵空树或它的左右两个子树的高度(路径边数)差的绝对值不超过1,但是我们看:
- 最左边的路径0026–>0017–>0012–>0010–>0003–>NULL LEAF,它的高度为5
- 最后边的路径0026–>0041–>0047–>NULL LEAF,它的高度为3
左右子树的高度差值为2,因此红黑树并不是高度平衡的,它放弃了高度平衡的特性而只追求部分平衡,这种特性降低了插入、删除时对树旋转的要求,从而提升了树的整体性能。而其他平衡树比如AVL树虽然查找性能为性能是O(logn),但是为了维护其平衡特性,可能要在插入、删除操作时进行多次的旋转,产生比较大的消耗。
public class TreeMap<K,V>
extends AbstractMap<K,V>
implements NavigableMap<K,V>, Cloneable, java.io.Serializable
TreeMap基本数据结构
TreeMap基于红黑树实现,既然是红黑树,那么每个节点中除了Key–>Value映射之外,必然存储了红黑树节点特有的一些内容,它们是:
- 父节点引用
- 左子节点引用
- 右子节点引用
- 节点颜色
TreeMap的节点Java代码定义为:
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK; //由于颜色只有红色和黑色两种,因此颜色可以使用布尔类型(boolean)来表示,黑色表示为true,红色为false。
/**
* 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;
}
/**
* Returns the key.
*
* @return the key
*/
public K getKey() {
return key;
}
/**
* Returns the value associated with the key.
*
* @return the value associated with the key
*/
public V getValue() {
return value;
}
/**
* Replaces the value currently associated with the key with the given
* value.
*
* @return the value associated with the key before this method was
* called
*/
public V setValue(V value) {
V oldValue = this.value;
this.value = value;
return oldValue;
}
public boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
return valEquals(key,e.getKey()) && valEquals(value,e.getValue());
}
public int hashCode() {
int keyHash = (key==null ? 0 : key.hashCode());
int valueHash = (value==null ? 0 : value.hashCode());
return keyHash ^ valueHash;
}
public String toString() {
return key + "=" + value;
}
}
TreeMap添加数据流程总结
首先看一下TreeMap如何添加数据,测试代码为:
public class MapTest {
@Test
public void testTreeMap() {
TreeMap<Integer, String> treeMap = new TreeMap<Integer, String>();
treeMap.put(10, "10");
treeMap.put(85, "85");
treeMap.put(15, "15");
treeMap.put(70, "70");
treeMap.put(20, "20");
treeMap.put(60, "60");
treeMap.put(30, "30");
treeMap.put(50, "50");
for (Map.Entry<Integer, String> entry : treeMap.entrySet()) {
System.out.println(entry.getKey() + ":" + entry.getValue());
}
}
}
首先看一下treeMap的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;
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator; //treemap内部的比较器
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);
}
else {
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key; //通过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);
}
Entry<K,V> e = new Entry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
fixAfterInsertion(e); //插入之后进行修复,包括左旋、右旋、重新着色这些操作,让树保持平衡性
size++;
modCount++;
return null;
}
下面看一下put每个数据之后TreeMap都做了什么操作,借此来理清TreeMap的实现原理。
put(10, “10″)
首先是put(10, “10″),由于此时TreeMap中没有任何节点,因此10为根且根节点为黑色节点,put(10, “10″)之后的数据结构为:
put(85, “85″)
接着是put(85, “85″),这一步也不难,85比10大,因此在10的右节点上,但是由于85不是根节点,因此会执行fixAfterInsertion方法进行数据修正,看一下fixAfterInsertion方法代码实现:
private void fixAfterInsertion(Entry<K,V> x) {
x.color = RED;
while (x != null && x != root && x.parent.color == RED) {
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == rightOf(parentOf(x))) {
x = parentOf(x);
rotateLeft(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
}
} else {
Entry<K,V> y = leftOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == leftOf(parentOf(x))) {
x = parentOf(x);
rotateRight(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateLeft(parentOf(parentOf(x)));
}
}
}
root.color = BLACK;
}
它将默认的插入的那个节点着色成为红色,这很好理解:
根据红黑树的性质(3),红黑树要求从根节点到叶子所有叶子节点上经过的黑色节点个数是相同的,因此如果插入的节点着色为黑色,那必然有可能导致某条路径上的黑色节点数量大于其他路径上的黑色节点数量,因此默认插入的节点必须是红色的,以此来维持红黑树的性质(3)。
当然插入节点着色为红色节点后,有可能导致的问题是违反性质(2),即出现连续两个红色节点,这就需要通过旋转操作去改变树的结构,解决这个问题。
接着看while的判断,前两个条件都满足,但是因为85这个节点的父节点是根节点的,根节点是黑色节点,因此这个条件不满足,while循环不进去,直接执行一次30行的代码给根节点着色为黑色(因为在旋转过程中有可能导致根节点为红色,而红黑树的根节点必须是黑色,因此最后不管根节点是不是黑色,都要重新着色确保根节点是黑色的)。
那么put(85, “85″)之后,整个树的结构变为:
fixAfterInsertion方法流程
while (x != null && x != root && x.parent.color == RED) {
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == rightOf(parentOf(x))) {
x = parentOf(x);
rotateLeft(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
}
}
....
}
判断当前节点的父节点与当前节点的父节点的父节点的左子节点是否同一个节点。翻译一下就是:当前节点是否为左子节点插入。对这整段代码我用流程图描述一下:
能看出,红黑树的插入最多只需要进行两次旋转,至于红黑树的旋转,后面结合代码进行讲解。
put(15, “15″)
看完fixAfterInsertion方法流程之后,继续添加数据,这次添加的是put(15, “15″),15比10大且比85小,因此15最终应当是85的左子节点,默认插入的是红色节点,因此首先将15作为红色节点插入85的左子节点后的结构应当是:
但是显然这里违反了红黑树的性质(2),即连续出现了两个红色节点,因此此时必须进行旋转。回看前面fixAfterInsertion的流程,上面演示的是左子树插入流程,右子树一样,可以看到这是右子树内侧插入,需要进行两次旋转操作:
- 对新插入节点的父节点进行一次右旋操作
- 新插入节点的父节点着色为黑色,新插入节点的祖父节点着色为红色
- 对新插入节点的祖父节点进行一次左旋操作
旋转是红黑树中最难理解也是最核心的操作,右旋和左旋是对称的操作,我个人的理解,以右旋为例,对某个节点x进行右旋,其实质是:
- 降低左子树的高度,增加右子树的高度
- 将x变为当前位置的右子节点
左旋是同样的道理,在旋转的时候一定要记住这两句话,这将会帮助我们清楚地知道在不同的场景下旋转如何进行。
先看一下(1)也就是”对新插入节点的父节点进行一次右旋操作”,源代码为rotateRight方法:
private void rotateRight(Entry<K,V> p) {
if (p != null) {
Entry<K,V> l = p.left;
p.left = l.right;
if (l.right != null) l.right.parent = p;
l.parent = p.parent;
if (p.parent == null)
root = l;
else if (p.parent.right == p)
p.parent.right = l;
else p.parent.left = l;
l.right = p;
p.parent = l;
}
}
再用一张示例图表示一下右旋各节点的变化,旋转不会改变节点颜色,这里就不区分红色节点和黑色节点了,a是需要进行右旋的节点:
左旋与右旋是一个对称的操作,大家可以试试看把右图的b节点进行左旋,就变成了左图了。
回到实际的例子,对85这个节点进行右旋之后还有一次着色操作(2),分别是将x的父节点着色为黑色,将x的祖父节点着色为红色,那么此时的树形结构应当为:
然后对节点10进行一次左旋操作(3),左旋之后的结构为:
最后不管根节点是不是黑色,都将根节点着色为黑色,那么插入15之后的数据结构就变为了上图,满足红黑树的三条特性。