MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java中Set集合的底层实现原理剖析

2022-10-246.3k 阅读

Java中Set集合概述

在Java编程中,Set是一种重要的集合类型,它继承自Collection接口。Set集合的主要特点是它不允许包含重复的元素。这意味着,当你试图向Set集合中添加一个已经存在的元素时,该操作通常会失败(具体行为取决于Set的具体实现类)。这种特性使得Set集合在需要确保元素唯一性的场景中非常有用,例如,统计文本中不同单词的数量、处理数据库中的唯一标识符等。

Java提供了几种Set接口的实现类,其中最常用的有HashSetTreeSetLinkedHashSet。每个实现类都有其独特的底层实现原理,这也决定了它们在性能、排序特性和内存使用等方面的差异。

HashSet的底层实现原理

数据结构基础

HashSet是基于哈希表实现的Set集合。哈希表是一种以键值对(key - value)形式存储数据的数据结构,它通过哈希函数将键映射到一个特定的存储位置,从而实现快速的查找、插入和删除操作。在HashSet中,元素本身就是键,值则是一个固定的对象(在HashSet的实现中并没有显式地使用值,这里只是为了说明与哈希表的关系)。

哈希函数

哈希函数是哈希表实现的核心。它的作用是将一个对象(在这里是Set中的元素)映射为一个整数,这个整数通常被称为哈希码(hash code)。在Java中,每个对象都有一个hashCode()方法,HashSet会调用这个方法来获取元素的哈希码。例如:

public class MyClass {
    private int value;

    public MyClass(int value) {
        this.value = value;
    }

    @Override
    public int hashCode() {
        return Integer.hashCode(value);
    }
}

public class HashSetExample {
    public static void main(String[] args) {
        HashSet<MyClass> set = new HashSet<>();
        MyClass obj1 = new MyClass(10);
        MyClass obj2 = new MyClass(20);
        set.add(obj1);
        set.add(obj2);
        System.out.println(set);
    }
}

在上述代码中,MyClass重写了hashCode()方法,HashSet在添加元素时会根据这个方法返回的哈希码来确定元素的存储位置。

哈希冲突解决

尽管哈希函数可以将对象映射到特定的位置,但不同的对象可能会产生相同的哈希码,这种情况被称为哈希冲突。HashSet使用链地址法(separate chaining)来解决哈希冲突。简单来说,当发生哈希冲突时,HashSet会在对应的哈希桶(bucket)中使用链表来存储多个元素。

在Java 8及之后的版本中,如果链表长度超过一定阈值(默认为8),链表会转换为红黑树,以提高查找效率。这是因为链表在查找时的时间复杂度为O(n),而红黑树的时间复杂度为O(log n)。以下是HashSet可能的存储结构示意图:

哈希表数组
|    |    |    |    |
|----|----|----|----|
|链表 |树   |链表 |空  |
|----|----|----|----|

添加元素过程

当向HashSet中添加一个元素时,其具体过程如下:

  1. 调用元素的hashCode()方法获取哈希码。
  2. 根据哈希码计算出在哈希表数组中的索引位置。
  3. 检查该位置是否为空。如果为空,则直接将元素插入到该位置。
  4. 如果该位置不为空,则检查链表(或树)中是否已存在相同的元素。判断元素相同的依据是equals()方法。如果存在相同元素,则不进行插入操作;如果不存在,则将元素添加到链表(或树)的头部(对于链表)或合适的位置(对于树)。

例如,假设我们有一个HashSet,其哈希表数组大小为16:

HashSet<String> set = new HashSet<>();
set.add("apple");

"apple"调用hashCode()方法得到一个哈希码,假设为123456。通过对16取模(123456 % 16)得到索引位置,假设为10。如果索引10位置为空,则"apple"直接插入到该位置;如果不为空,则遍历链表(或树)查找是否有相同元素。

查找元素过程

查找元素时,HashSet同样先调用元素的hashCode()方法获取哈希码,计算出索引位置。然后在该位置的链表(或树)中查找元素。如果找到,则返回true;否则返回false

删除元素过程

删除元素时,HashSet先通过hashCode()方法找到对应的索引位置,然后在链表(或树)中查找要删除的元素。如果找到,则将其从链表(或树)中移除。如果链表(或树)中元素数量减少到一定阈值(默认为6,对于由链表转换为树的情况),红黑树会转换回链表。

TreeSet的底层实现原理

数据结构基础

TreeSet是基于红黑树实现的Set集合。红黑树是一种自平衡的二叉搜索树,它满足以下几个特性:

  1. 每个节点要么是红色,要么是黑色。
  2. 根节点是黑色。
  3. 每个叶节点(NIL节点,空节点)是黑色的。
  4. 如果一个节点是红色的,则它的两个子节点都是黑色的。
  5. 从任意一个节点到其每个叶节点的所有路径都包含相同数目的黑色节点。

这些特性保证了红黑树在插入、删除和查找操作时的时间复杂度始终保持在O(log n),其中n是树中节点的数量。

元素比较

TreeSet中的元素必须是可比较的,这意味着元素的类必须实现Comparable接口,或者在创建TreeSet时提供一个Comparator接口的实现。例如:

public class MyNumber implements Comparable<MyNumber> {
    private int value;

    public MyNumber(int value) {
        this.value = value;
    }

    @Override
    public int compareTo(MyNumber other) {
        return Integer.compare(this.value, other.value);
    }
}

public class TreeSetExample {
    public static void main(String[] args) {
        TreeSet<MyNumber> set = new TreeSet<>();
        MyNumber num1 = new MyNumber(10);
        MyNumber num2 = new MyNumber(20);
        set.add(num1);
        set.add(num2);
        System.out.println(set);
    }
}

在上述代码中,MyNumber类实现了Comparable接口,TreeSet在添加元素时会根据compareTo()方法来确定元素在树中的位置。

添加元素过程

当向TreeSet中添加一个元素时,其具体过程如下:

  1. 从根节点开始,比较要添加的元素与当前节点。
  2. 如果要添加的元素小于当前节点,则向左子树移动;如果大于,则向右子树移动。
  3. 重复步骤2,直到找到一个空位置(叶节点),将元素插入到该位置。
  4. 插入后,可能会破坏红黑树的特性,因此需要进行调整操作,包括左旋、右旋、变色等操作,以恢复红黑树的平衡。

例如,假设我们有一个TreeSet,初始状态为空:

TreeSet<Integer> set = new TreeSet<>();
set.add(5);
set.add(3);
set.add(7);

首先添加5,此时根节点为5。然后添加3,3小于5,所以3成为5的左子节点。接着添加7,7大于5,所以7成为5的右子节点。添加后可能需要调整以保持红黑树的特性。

查找元素过程

查找元素时,TreeSet从根节点开始,比较要查找的元素与当前节点。如果相等,则返回true;如果小于当前节点,则向左子树查找;如果大于,则向右子树查找。重复此过程,直到找到元素或到达叶节点(未找到)。

删除元素过程

删除元素时,TreeSet同样从根节点开始查找要删除的元素。找到后,根据节点的情况进行不同的处理:

  1. 如果要删除的节点没有子节点,直接删除该节点。
  2. 如果要删除的节点只有一个子节点,用子节点替换该节点。
  3. 如果要删除的节点有两个子节点,找到其右子树中的最小节点(该节点没有左子节点),用该最小节点替换要删除的节点,然后删除最小节点。 删除后,同样可能需要进行调整操作,以恢复红黑树的平衡。

LinkedHashSet的底层实现原理

数据结构基础

LinkedHashSet继承自HashSet,它在HashSet的基础上增加了一个双向链表,用于维护元素插入的顺序或访问的顺序。这使得LinkedHashSet既具有HashSet的快速查找特性,又能保持元素的顺序。

插入顺序维护

当向LinkedHashSet中添加元素时,元素不仅会被插入到哈希表中,还会被添加到双向链表的尾部。例如:

LinkedHashSet<String> set = new LinkedHashSet<>();
set.add("apple");
set.add("banana");
set.add("cherry");

在上述代码中,元素按照"apple"、"banana"、"cherry"的顺序插入,双向链表会记录这个顺序。当遍历LinkedHashSet时,元素会按照插入顺序输出。

访问顺序维护

LinkedHashSet还可以按照访问顺序维护元素。通过构造函数传入true可以启用访问顺序模式。在这种模式下,当访问(读取或修改)一个元素时,该元素会被移动到双向链表的尾部。例如:

LinkedHashSet<Integer> set = new LinkedHashSet<>(16, 0.75f, true);
set.add(10);
set.add(20);
set.add(30);
set.contains(20);

在上述代码中,当调用contains(20)后,元素20会被移动到双向链表的尾部。

添加元素过程

LinkedHashSet添加元素的过程与HashSet基本相同,只是在插入到哈希表后,还会将元素添加到双向链表的尾部。

查找元素过程

查找元素时,LinkedHashSet先在哈希表中查找元素。如果找到,并且是按照访问顺序维护,则将元素移动到双向链表的尾部。

删除元素过程

删除元素时,LinkedHashSet先在哈希表中删除元素,然后从双向链表中移除对应的节点。

三种Set实现类的性能比较

  1. 插入性能

    • HashSet通常具有最快的插入速度,因为它只需要计算哈希码并插入到哈希表中,平均时间复杂度为O(1),但在哈希冲突严重时可能退化为O(n)。
    • TreeSet的插入时间复杂度为O(log n),因为需要在红黑树中找到合适的位置并进行平衡调整。
    • LinkedHashSet的插入性能与HashSet相近,因为它基于HashSet实现,只是多了双向链表的维护操作,额外开销较小。
  2. 查找性能

    • HashSet平均情况下查找速度最快,时间复杂度为O(1),但在哈希冲突严重时可能退化为O(n)。
    • TreeSet的查找时间复杂度为O(log n),因为需要在红黑树中进行比较查找。
    • LinkedHashSet的查找性能与HashSet基本相同,因为查找主要依赖哈希表。
  3. 删除性能

    • HashSet平均情况下删除速度较快,时间复杂度为O(1),但在哈希冲突严重时可能退化为O(n)。删除时如果链表长度减少到一定阈值,红黑树可能会转换回链表。
    • TreeSet的删除时间复杂度为O(log n),因为需要在红黑树中找到并删除节点,然后进行平衡调整。
    • LinkedHashSet的删除性能与HashSet相近,只是需要额外从双向链表中移除节点。
  4. 内存消耗

    • HashSet通常内存消耗相对较小,只需要维护哈希表。
    • TreeSet由于需要维护红黑树的结构,每个节点需要额外存储颜色信息以及父节点和子节点的引用,因此内存消耗相对较大。
    • LinkedHashSetHashSet的基础上增加了双向链表,所以内存消耗比HashSet略大。

应用场景选择

  1. 需要快速查找和插入,且不关心元素顺序

    • 优先选择HashSet。例如,在统计文本中不同单词的数量时,HashSet可以快速判断单词是否已经存在,提高统计效率。
  2. 需要元素有序,且插入和查找性能要求较高

    • 如果需要按照自然顺序(元素实现Comparable接口)或自定义顺序(提供Comparator)排序,选择TreeSet。例如,在对一组学生成绩进行排序并去重时,TreeSet可以满足需求。
  3. 需要保持元素插入顺序或访问顺序

    • 选择LinkedHashSet。例如,在实现一个简单的缓存系统时,LinkedHashSet可以按照访问顺序维护缓存元素,方便实现LRU(最近最少使用)缓存策略。

通过深入了解HashSetTreeSetLinkedHashSet的底层实现原理、性能特点和应用场景,开发者可以根据具体需求选择最合适的Set实现类,从而提高程序的性能和效率。在实际编程中,合理选择数据结构是优化程序性能的重要一环,希望本文能帮助读者更好地理解和应用Java中的Set集合。