前面介绍了两种集合的用法,它们的共性为每个元素都是唯一的,区别在于一个无序一个有序。虽说往集合里面保存数据还算容易,但要从集合中取出数据就没那么方便了,因为集合居然不提供get方法,没有get方法怎么从一堆数据之中挑出你想要的东西呢?难道只能从头遍历集合的所有元素,再逐个加以辨别吗?显然这个缺陷是集合的硬伤,好比去银行开账户,存钱的时候大家都开开心心,可是等到取钱的时候,却发现柜员拿出一叠存单一张一张找过去,等找到你的存单之时,黄花菜儿都凉了。因此,实际开发中一般很少直接使用集合,而是使用集合的升级版本——映射。
映射指的是两个实体之间存在一对一的关系,例如一个身份证号码对应某个公民,一个书名对应某本书籍等等。有了映射关系之后,从一堆数据中寻找目标对象就好办了,只要给定目标对应的号码或者名称,根据映射关系能够立刻找到号码或名称代表的对象。这样下次去银行取钱,就不必等柜员兀自地翻存单,只要在电脑上输入身份证号,即可自动找到当初的存款记录。
Java编程通过“键值对”的概念来表达映射关系,它包含“键名”和“键值”两个实体,且键名与键值是一一对应的,相同的键名指向的键值也必然是相同的。如此一来,映射里面的每个元素都是一组键值对,即“Key→Value”,在代码中采取形如“Map<Key, Value>”的格式来表达,其中Key表示键名的数据类型,Value表示键值的数据类型。往映射里面保存数据的时候,需要填写完整的键值对信息;而从映射中取出数据,只需提供键名即可获得相应的键值信息。
由于Map属于接口,因此开发过程通常调用它的两个实现类,包括哈希图HashMap和红黑树TreeMap。映射与集合密切相关,它们的存储原理也类似,比如HashMap和HashSet一样采取哈希表结构,而TreeMap和TreeSet一样采取二叉树结构;不同的是,映射元素的唯一性和有序性是由各元素的键名决定的。因为HashMap和TreeMap仅仅是内部存储结构存在差异,外部的代码调用仍然保持一致,所以接下来就以HashMap为例阐述映射的具体用法。
与HashSet相比,HashMap在编码上主要有三处改动,分别说明如下。
一、创建映射实例必须同时指定键名和键值的数据类型,即HashMap后面的那对尖括号内部要有两个类型名称。下面是创建一个手机映射的代码例子:
// 创建一个哈希映射,该映射的键名为String类型,键值为MobilePhone类型 HashMap<String, MobilePhone> map = new HashMap<String, MobilePhone>();
二、往映射中添加新的键值对,调用的是put方法而非add方法,并且put方法的第一个参数为新元素的键名,第二个参数为新元素的键值。如果映射内部不存在该键名,则映射会直接增加一组键值对;如果映射已经存在该键名,则映射会自动将新的键值覆盖旧的键值。给手机映射添加若干组手机信息的代码示例如下:
map.put("米8", new MobilePhone("小米", 3000)); map.put("Mate20", new MobilePhone("华为", 6000)); map.put("荣耀10", new MobilePhone("荣耀", 2000)); map.put("红米6", new MobilePhone("红米", 1000)); map.put("OPPO R17", new MobilePhone("OPPO", 2800));
三、遍历映射内部的所有元素,也有好几种方式,依次说明如下。
1、通过迭代器遍历。首先调用映射实例的entrySet获得该映射的集合入口,再调用入口对象的iterator方法获得映射的迭代器,然后使用迭代器遍历整个映射。在遍历过程中,每次调用next方法得到的是下个位置的键值对记录,此时还需调用该记录的getKey方法才能获取键值对中的键名,调用getValue方法获取键值对中的键值。详细的迭代器遍历代码如下所示:
// 第一种遍历方式:显式指针,即使用迭代器 Set<Map.Entry<String, MobilePhone>> entry_set = map.entrySet(); Iterator<Map.Entry<String, MobilePhone>> iterator = entry_set.iterator(); while (iterator.hasNext()) { // 注意这里要先把入口取出来,这样才能分别getKey和getValue Map.Entry<String, MobilePhone> iterator_item = iterator.next(); // 获取该键值对的键名 String key = iterator_item.getKey(); // 获取该键值对的键值 MobilePhone value = iterator_item.getValue(); System.out.println(String.format("iterator_item key=%s, value=%s %d", key, value.getBrand(), value.getPrice())); }
2、通过for循环遍历。第一种遍历方式可以看到明确的迭代器对象,故而又被称作显式指针。其实迭代器仅仅起到了指示的作用,它完全可以被简化的for循环所取代。尽管在for循环中看不到迭代器对象,但编译器知道这里有个隐含着的迭代器,因此for循环遍历也被称作隐式指针。下面是采取for循环遍历手机映射的代码例子:
// 第二种遍历方式:隐式指针,即使用for循环 for (Map.Entry<String, MobilePhone> for_item : map.entrySet()) { // 获取该键值对的键名 String key = for_item.getKey(); // 获取该键值对的键值 MobilePhone value = for_item.getValue(); System.out.println(String.format("for_item key=%s, value=%s %d", key, value.getBrand(), value.getPrice())); }
3、通过键名集合遍历。有别于上述两种依次遍历键值对的方式,第三种方式先调用映射的keySet方法获得只包含键名的集合,再通过遍历键名集合来获取每个键名对应的键值。该方式的映射遍历代码示例如下:
// 第三种遍历方式:先获得键名的集合,再通过键名集合遍历整个映射 // 注意:HashMap的keySet方法返回的是无序集合 Set<String> key_set = map.keySet(); for (String key : key_set) { // 通过键名获取该键值对的键值 MobilePhone value = map.get(key); System.out.println(String.format("set_item key=%s, value=%s %d", key, value.getBrand(), value.getPrice())); }
4、通过forEach方法遍历。显然前面的几种方式都很啰嗦,自从Java8引入了Lambda表达式,遍历映射的所有元素也变得异常简洁了,单单下面一行代码就全部搞定:
// 第四种遍历方式:使用forEach方法夹带Lambda表达式进行遍历 map.forEach((key, value) -> System.out.println(String.format("each_item key=%s, value=%s %d", key, value.getBrand(), value.getPrice())) );
最后简要描述一下TreeMap背后的红黑树概念,这是各家公司面试Java开发人员的常见知识点。红黑树是一种自平衡二叉查找树,所谓平衡指的是像天平那样左右两边的重量相等,从而使天平保持不偏不倚的水平状态。当然对于二叉树来说,绝对的平衡是难以达到的,只能做到相对平衡,即左右两棵子树的高度差不大于1,同时左右两棵子树本身也是平衡二叉树。鉴于平衡二叉树的平衡特性,从根节点出发到达每个叶子节点的距离比较平均,使得整棵树的查找性能相对优越。高度平衡的二叉树在进行查找操作时表现近乎完美,但是给它添加新节点或者删除原节点的时候,二叉树为了在节点增删之后仍然保持高度平衡,可能不得不多次左旋右旋,一旦这棵树遇到频繁的节点添加和删除操作,它的整体性能将会急剧下降,真可谓鱼与熊掌不可兼得。
为了兼顾平衡二叉树的查找性能和节点增删后的旋转性能,另一种折中的自平衡二叉树(即红黑树)应运而生,红黑树并非高度平衡的,而是一种相对平衡,它的每次插入操作最多只要两次旋转,删除操作最多只要三次旋转。平衡二叉树要求左右子树的高度差不大于1,而红黑树只要求左右子树的高度差不大于根节点到叶子节点的最短距离,也就是说,根节点到叶子节点的最长距离不大于最短距离的两倍。红黑树不处于最理想的平衡状态,而是处于大致平衡的状态,那么对它的叶子节点进行增删之时,这样不管左旋还是右旋,只需少数几次旋转就能让左右字书的高度差保持在合理的范围内了。
更多Java技术文章参见《Java开发笔记(序)章节目录》