在这篇文章中,我们将了解LinkedHashSet类,它从1.4版开始就包含在java.util中。
目录。
- 介绍和继承
- Java中的集合
- Java中的哈希集
- Java中的LinkedHashSet
- 使用实例
- 复杂性
介绍和继承
链接哈希集是一种存储和检索数据的高效方式。从1.4版本开始,每个Java版本都包含了LinkedHashSet这个类。要使用这个类,你首先需要导入java.util.LinkedHashSet。
LinkedHashSet扩展了HashSet类并实现了Set、Cloneable和Serializable接口。在内部,LinkedHashSet使用一个哈希表来存储数据,并使用一个双链表来维持条目顺序。在下面的章节中,我们将分解LinkedHashSet所继承的集合、哈希表和双链表的重要特性。
Java中的集合
一个Set是一个没有任何重复的元素的集合,并且最多只有一个空元素。Java中的Set接口为任何继承自该接口的类描述了Set的特性。AbstractSet类是这个接口的最小实现,它使其他类更容易实现这个接口。Set接口中有许多方法,但最重要的是add(E e)、remove(Object o)、contains(Object o)和size(),它们分别添加一个元素、移除一个元素、检查一个元素是否包含在集合中,以及检查集合中元素的总数。
值得注意的是,集合中的元素是没有顺序的。因此,如果你使用for-each循环来打印Set中的每个元素,这些元素将以某种任意的顺序打印,这可能与它们被插入Set的顺序不同。
Java中的HashSet
HashSet是Set的一个实现,它使用一个哈希表。因此,HashSet扩展了AbstractSet,并由HashMap<K,V>的一个实例来支持。我们已经讨论了Set的功能,所以让我们把注意力转向HashMap<K,V>的使用。
基本上,一个地图只是一个键值对的集合。你可以把字典看作是地图的一种类型,其中每个词是一个键,每个定义是一个值 (事实上,Java 的 HashMap 与 Python 的 Dictionary 非常相似)。HashMap 是一种依赖散列的地图。散列是使用某种算法将一个键作为输入并输出一个叫做 "散列 "的整数值的过程。
例如,假设你有一个键值对<1, "one">,你想把它插入一个哈希图,其中的哈希函数是key%10。那么,1%10=1,所以这个特定键的哈希值是1。然后,值 "one "可能被插入到存放地图中所有值的数组的第一个索引中。像其他地图一样,插入发生在恒定的O(1)时间内。然而,HashMaps的真正优势在于,在最好的情况下,搜索和检索也发生在O(1)时间内。如果你想找到与键1相关的值,那么你可以简单地计算该键的哈希值并检查值数组中的相应位置。
这种最好的情况只发生在没有碰撞的情况下,也就是说,如果每个键都有一个唯一的哈希值。然而,在我们的例子中,我们可以看到,1、11、21、31等都会产生相同的哈希值。处理这些碰撞的一个常用技术叫做线性链。如果数组中的一个位置已经被占用(被其他具有相同哈希值的键值对占用),那么我们将以线性方式检查数组的其余部分,直到我们遇到一个空位。同样地,当我们搜索一个键的值时,我们可能要在数组中进行迭代,以找到我们插入该键值对的位置,这是线性链的结果。在最坏的情况下,这将花费O(n)时间。
HashSet采用了HashMap来加快元素的检索速度。当每个元素e被添加到集合中时,一个条目<e,e>被添加到内部HashMap中。然后,为了在集合中找到e,HashSet可以使用HashMap的快速检索,而不是像普通的Set那样迭代每个元素。
链接哈希集
到目前为止,我们已经了解到HashSet是一个没有重复的无序元素集合,它使用HashMap来在近乎恒定的时间内定位元素。LinkedHashSet是对HashSet的扩展,它在方程中加入了一个双链表并解决了顺序问题。在一个双链列表中,列表中的每个项目,或者说 "节点",都包含两个指针,分别指向它前面的节点和后面的节点。在LinkedHashSet中,每个元素都作为一个节点被存储在双链表中,按照它们被插入的顺序。这很有用,因为很多时候,你会希望能够根据元素的输入顺序看到集合中的所有元素。HashMap仍然用于查找单个元素,但是如果你打印LinkedHashSet中的每个元素,你会发现它们总是按照它们被插入的顺序打印。
下面是LinkedHashSet中的一些重要方法。
- add(E e): 向集合中添加一个元素
- remove(Object o): 从集合中删除一个元素
- contains(Object o): 检查一个元素是否存在于集合中。
- iterate(): 返回一个集合元素的Iterator对象,按照它们被输入的顺序。
使用实例
让我们看一下Java中的一个例子。
import java.util.LinkedHashSet;
class LinkedHashSetDemo {
public static void main(String[] args) {
//Create a LinkedHashSet of Strings
LinkedHashSet<String> lhs = new LinkedHashSet<>();
//Add elements to our LinkedHashSet
lhs.add("Hello");
lhs.add("Error");
lhs.add("World!");
//Print elements in order of entry
System.out.print("My Set: ");
for (String string : lhs) {
System.out.print(string+" ");
}
System.out.println();
//Test "contains" method
System.out.println("\"Error\" detected: "+lhs.contains("Error"));
//Remove "Error" from the set
System.out.println("Removing \"Error\": "+lhs.remove("Error"));
//Test contains again
System.out.println("\"Error\" detected: "+lhs.contains("Error"));
//See what happens when we try to remove an element that is not in the set
System.out.println("Removing \"Error\": "+lhs.remove("Error"));
//Print all elements of modified set
System.out.print("My Set: ");
for (String string : lhs) {
System.out.print(string+" ");
}
}
}
复杂度
我们已经简单的提到了复杂性,特别是在HashSet部分,但是我们将在这里更详细的研究它。
LinkedHashSet的运行时间复杂度与HashSet相同。这意味着在最好的情况下,插入、移除和搜索都是在恒定的O(1)时间内发生的。但是,最好的情况下有多大可能呢?
嗯,这完全取决于碰撞的数量。哈希表中发生的碰撞越多,就越需要线性链(或其他解决碰撞的过程)。正如我们已经看到的,这减慢了搜索和删除的速度。为了尽可能地避免碰撞,有一些实现细节需要考虑。
- 选择一个好的哈希函数。一个好的哈希函数不会对许多不同的输入给出相同的输出。modulo函数作为散列函数相当好用,特别是当除数(modulus运算符之后的数字)相对较大时。然而,hashCode()方法被写进了Java的总体对象类中,所以你可能不必担心这个细节问题。
- 选择一个初始容量和一个负载系数。初始容量设定了你最初可以向哈希表插入多少个值的限制。换句话说,它是可以生成的不同哈希值的数量,也就是哈希函数的范围。对于%10,初始容量是10(0到9,包括)。更高的初始容量可能会提高时间复杂度,但会占用更多空间。负载因子是一个浮动值,决定何时增加哈希表的容量。当这种情况发生时,表的大小通常会增加一倍,整个数据集被重新洗牌。由于现在有两倍的空间用于相同数量的数据,碰撞通常会减少,操作也更接近于最佳状态。如果负载系数太低,重新洗牌,这是一个昂贵的过程,会发生得太频繁。如果重洗太高,你可能会遇到更多的碰撞。一般认为0.75的负载因子(重洗将在75%的容量下发生)是减少开销存储和减少时间复杂度之间的良好平衡。
LinkedHashSet类有两个构造函数,你可以设置初始容量或者同时设置初始容量和负载因子。一般来说,它的性能和HashSet类一样好,在大约O(1)时间内添加、删除和查找元素。LinkedHashSet确实需要稍多的空间来存储双链表,但是通常在LinkedHashSet中迭代的速度要比普通的HashSet快,因为它不需要检查哈希表中的每一个空间,它可以直接在列表中迭代。