集合-Set-TreeSet

3 阅读21分钟

概述

TreeSet 是 Java 集合框架中 Set 接口下唯一一个保证元素处于排序状态的实现类。它以委托机制将全部存储逻辑交给内部的 TreeMap,利用红黑树的自平衡特性维护键的有序性,并将元素作为键、共享一个哑元值,从而实现了有序、去重的集合。本文将从源码视角(基于 JDK 8)系统揭示 TreeSet 的底层存储结构、排序去重原理、自平衡过程、导航方法与范围视图的实现细节,并经由与 HashSetLinkedHashSetConcurrentSkipListSet 的全维对比,帮助你构建从理论到实践的全链路认知。

  • 基于 TreeMap 的红黑树实现:TreeSet 内部组合 NavigableMap(实际为 TreeMap),元素作为 Key,共享哑元对象 PRESENT,利用红黑树的自平衡特性维护有序性。
  • 两种排序模式:自然排序(元素实现 Comparable)与比较器排序(构造时传入 Comparator),Comparator 优先级高于 Comparable
  • 去重依赖比较而非哈希:TreeSet 通过 compareTocompare 返回 0 判定元素相等,而非 equals/hashCode,需保持比较器与 equals 的一致性。
  • 有序导航与范围视图:提供 lower/floor/ceiling/higher 等导航方法,以及 subSet/headSet/tailSet 等范围视图,支持高效的范围查找。
  • 较低的基本操作性能:增删查时间复杂度 O(log n),低于 HashSet 的 O(1),但支持有序遍历和范围操作,适用于需要排序的场景。

下面是本文的全文组织架构图,它展示了从基础认知到面试专题的六大篇章递进关系:

flowchart TB
    subgraph Part1["Part 1: 基础认知篇"]
        A1["模块1: 定义、特性与适用场景"]
        A2["模块2: 接口与继承体系"]
    end
    subgraph Part2["Part 2: 存储与构造篇"]
        B1["模块3: 存储结构与底层依赖"]
        B2["模块4: 构造方法"]
    end
    subgraph Part3["Part 3: 核心原理篇"]
        C1["模块5: add操作"]
        C2["模块6: remove与contains"]
        C3["模块7: 导航方法与范围视图"]
    end
    subgraph Part4["Part 4: 迭代与序列化篇"]
        D1["模块8: 迭代器与有序遍历"]
        D2["模块9: 序列化与克隆"]
    end
    subgraph Part5["Part 5: 对比与陷阱篇"]
        E1["模块10: vs HashSet/LinkedHashSet"]
        E2["模块11: vs ConcurrentSkipListSet"]
        E3["模块12: 常见陷阱与最佳实践"]
    end
    subgraph Part6["Part 6: 总结与面试篇"]
        F1["模块13: 性能总结"]
        F2["模块14: 面试高频专题"]
    end

    Part1 --> Part2 --> Part3 --> Part4 --> Part5 --> Part6

架构图说明:

  • 第一层(Part 1~Part 6) 呈现了从“是什么”到“怎么用”再到“为什么”和“如何选”的认知闭环。读者将沿“基础认知→存储构造→核心原理→迭代与序列化→对比与陷阱→总结与面试”的顺序逐步深入。
  • 第二层(模块分布) 中,Part 1 建立概念骨架,明确 TreeSet 在集合框架中的定位;Part 2 深入到类字段与构造器,揭开委托机制的实现细节;Part 3 是全文核心,通过源码级分析 addremove、导航方法,展现红黑树自平衡与有序操作的运作机理;Part 4 补充迭代器、序列化等辅助机制;Part 5 通过横向对比与陷阱剖析帮助读者进行工程选型与避坑;Part 6 用性能总结和独立面试专题将知识转化为实战竞争力。
  • 数据结构映射 上,subgraph 代表篇章,内部节点代表具体模块,流动方向展示了由表及里、由实现到应用的递进关系,与《Set 系列排序集合核心篇章》的定位高度契合。

Part 1:基础认知篇

模块 1:定义、核心特性与适用场景

定义

TreeSet基于红黑树(TreeMap)实现的有序、不包含重复元素的集合。它实现了 NavigableSet 接口,支持以自然顺序或自定义 Comparator 对元素进行排序。

核心特性

  1. 有序性:元素按升序或指定比较器排序,迭代顺序可预测。
  2. 基于比较去重:通过 compareTocompare 返回值是否为 0 来判断重复,而非 equals/hashCode
  3. O(log n) 时间性能:增删查均在对数时间内完成,但常数因子高于 HashSet
  4. 导航与范围视图:提供 lowerfloorceilinghigher 等导航方法,以及 subSet / headSet / tailSet 等视图。
  5. 非线程安全:多线程并发修改可能抛出 ConcurrentModificationException
  6. Null 元素容忍度取决于比较器。若使用自然排序,null 无法与任何对象比较,会抛 NullPointerException;若比较器支持 null,则允许插入 null(但 TreeMap 的默认比较器依旧不允许,通常不建议)。
  7. 依赖 TreeMap:内部所有操作均委托给 NavigableMap<E,Object> 实现,实质为 TreeMap

适用场景决策树

flowchart TD
    Start["需要一个Set实现"] --> Q1{"是否需要排序?"}
    Q1 -- 否 --> Q2{"是否需要保持插入顺序?"}
    Q2 -- 否 --> HashSet["HashSet<br>O(1)操作,无序"]
    Q2 -- 是 --> LinkedHashSet["LinkedHashSet<br>O(1)操作,保持插入顺序"]
    Q1 -- 是 --> Q3{"是否需要并发访问?"}
    Q3 -- 是 --> SkipList["ConcurrentSkipListSet<br>线程安全,跳表实现"]
    Q3 -- 否 --> TreeSet["TreeSet<br>红黑树实现,O(log n)"]

决策图说明:

  • 第一层决策:是否需要排序。若无排序需求,进入第二层:是否需要保持插入顺序。否→HashSet;是→LinkedHashSet
  • 若有排序需求:进入第三层:是否需要并发访问。是→选择线程安全的 ConcurrentSkipListSet;否→选择 TreeSet
  • 关键结论TreeSet 适用于需要元素排序的去重集合,例如排行榜、区间查询、按字典序输出等场景。反例场景:仅需快速去重且不关心顺序时,HashSet 的 O(1) 更具优势;需要插入顺序时使用 LinkedHashSet;在高并发排序场景下,应使用 ConcurrentSkipListSet

模块 2:接口与继承体系

TreeSet 的继承体系展现了其作为有序集合的完整能力。下面是其类图:

classDiagram
    class Iterable {
        <<interface>>
    }
    class Collection {
        <<interface>>
    }
    class Set {
        <<interface>>
    }
    class SortedSet {
        <<interface>>
        +comparator()
        +first()
        +last()
        +subSet()
        +headSet()
        +tailSet()
    }
    class NavigableSet {
        <<interface>>
        +lower()
        +floor()
        +ceiling()
        +higher()
        +pollFirst()
        +pollLast()
        +descendingSet()
    }
    class AbstractSet {
        <<abstract>>
    }
    class TreeSet {
        -NavigableMap m
        -Object PRESENT
        +add(E e)
        +remove(Object o)
        +contains(Object o)
        +lower(E e)
        ...
    }
    class TreeMap {
        -Comparator comparator
        -Entry root
        +put(K key, V value)
        +remove(Object key)
        ...
    }
    class Cloneable {
        <<interface>>
    }
    class Serializable {
        <<interface>>
    }

    Iterable <|-- Collection
    Collection <|-- Set
    Set <|-- SortedSet
    SortedSet <|-- NavigableSet
    AbstractSet ..|> Set
    AbstractSet <|-- TreeSet
    TreeSet ..|> NavigableSet
    TreeSet ..|> Cloneable
    TreeSet ..|> Serializable
    TreeSet *-- TreeMap : m

类图说明:

  • 第一层接口继承IterableCollectionSetSortedSetNavigableSetSortedSet 赋予了排序能力(subSetheadSettailSetfirstlast),NavigableSet 进一步扩展了导航方法lowerfloorceilinghigherpollFirstpollLastdescendingSet)。
  • 抽象类支持TreeSet 继承 AbstractSet,获得了 Set 接口的大部分模版实现,减少编码量。
  • 内部组合关系TreeSet 持有一个 NavigableMap 字段 m,其实际类型为 TreeMap。这表明 TreeSet 的所有操作均委托给 TreeMap 实例,元素作为键存储。
  • Cloneable 和 Serializable:TreeSet 支持浅拷贝和序列化,底层依赖 TreeMap 的相应实现。

Part 2:存储与构造篇

模块 3:存储结构与底层依赖(源码剖析)

核心字段

打开 JDK 8 TreeSet 源码,其核心字段定义如下(简化):

public class TreeSet<E> extends AbstractSet<E>
    implements NavigableSet<E>, Cloneable, java.io.Serializable {
    
    private transient NavigableMap<E,Object> m;
    private static final Object PRESENT = new Object();
    
    // 构造器...
}
  • m:实际存储元素的 NavigableMap,构造函数中会被赋予一个 TreeMap 实例。TreeMap 内部使用红黑树作为数据结构。
  • PRESENT:一个共享的哑元对象。因为 TreeMap 是键值对存储,而 Set 只需存储键,所以所有元素作为键,值统一为 PRESENT,以节约内存。

TreeMap.Entry 结构

TreeMap 内部的节点类型为 TreeMap.Entry<K,V>,它实现了 Map.Entry 并增加了红黑树所需的指针和颜色:

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; // 节点颜色(红/黑)
}

这种结构使得 TreeSet 的每个元素以 Entry<E,Object> 的形式存储在红黑树中,其中 key 即为集合元素,value 固定为 PRESENT

红黑树的五个性质:

  1. 每个节点是红色或黑色。
  2. 根节点是黑色。
  3. 所有叶子(NIL)是黑色。
  4. 每个红色节点的两个子节点都是黑色。(即不能有连续的红色节点)
  5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。

这些性质保证了没有一条路径会比其他路径长出两倍,从而树接近平衡,操作时间复杂度为 O(log n)。

组合关系图

classDiagram
    class TreeSet {
        -NavigableMap m
        -Object PRESENT
    }
    class TreeMap {
        -Entry root
        -int size
        -Comparator comparator
        +put(K key, V value)
        +remove(Object key)
    }
    class Entry {
        K key
        V value
        Entry left
        Entry right
        Entry parent
        boolean color
    }
    TreeSet *-- TreeMap : m (组合)
    TreeMap *-- Entry : root (红黑树节点)
    Entry -- Entry : left/right/parent

结构说明:

  • 组合关系TreeSetTreeMap强组合关系,TreeSet 的实例化必定会创建一个 TreeMap 实例赋给 m
  • 节点链接TreeMap 通过 root 字段持有整棵红黑树的根节点,每个 Entry 通过 leftrightparent 链接父子节点,形成二叉搜索树结构。
  • 颜色维护Entry.color 字段支持红黑树性质,确保插入与删除后通过旋转和变色维持平衡。
  • 数据映射:集合元素作为 Entry.key,值统一为 TreeSet.PRESENT。这个映射将 Set 语义映射到了 Map 存储模型上。

模块 4:构造方法(源码剖析)

TreeSet 提供了四个构造函数,均通过创建 TreeMap 实例来初始化底层的 m

  1. 无参构造 – 自然排序

    public TreeSet() {
        this(new TreeMap<>());
    }
    

    底层 TreeMap 使用自然排序,即要求元素必须实现 Comparable 接口。

  2. 指定比较器

    public TreeSet(Comparator<? super E> comparator) {
        this(new TreeMap<>(comparator));
    }
    

    当元素未实现 Comparable 或需要自定义顺序时,传入 ComparatorComparator 的优先级高于元素自身的 Comparable

  3. 从普通集合构造

    public TreeSet(Collection<? extends E> c) {
        this();
        addAll(c);
    }
    

    先创建自然排序的 TreeMap,然后调用 addAll 批量添加。若 c 的元素未实现 Comparable,会在 add 时抛出 ClassCastException

  4. 从有序集合构造

    public TreeSet(SortedSet<E> s) {
        this(s.comparator());
        addAll(s);
    }
    

    此构造器保留原 SortedSet 的比较器,并按原顺序批量插入,可高效复制有序集合并维护相同排序规则。

Demo 代码:不同构造方式演示

import java.util.*;

public class TreeSetConstructDemo {
    public static void main(String[] args) {
        // 1. 自然排序(Integer实现Comparable)
        TreeSet<Integer> set1 = new TreeSet<>();
        Collections.addAll(set1, 5, 2, 8);
        System.out.println(set1); // [2, 5, 8]

        // 2. 自定义比较器(降序)
        TreeSet<Integer> set2 = new TreeSet<>(Comparator.reverseOrder());
        Collections.addAll(set2, 5, 2, 8);
        System.out.println(set2); // [8, 5, 2]

        // 3. 从Collection构造(元素必须可比较)
        List<String> list = Arrays.asList("banana", "apple", "cherry");
        TreeSet<String> set3 = new TreeSet<>(list);
        System.out.println(set3); // [apple, banana, cherry]

        // 4. 从SortedSet构造(保留比较器)
        SortedSet<String> sorted = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        sorted.add("Apple");
        sorted.add("banana");
        TreeSet<String> set4 = new TreeSet<>(sorted);
        set4.add("APPLE"); // 忽略,因为大小写不敏感比较相同
        System.out.println(set4); // [Apple, banana]
    }
}

Part 3:核心原理篇

模块 5:add 操作——有序插入与去重(源码剖析)

TreeSetadd 方法直接委托给内部 TreeMapput

// TreeSet.java
public boolean add(E e) {
    return m.put(e, PRESENT) == null; // 若键不存在则返回null代表新增成功
}

TreeMap.put 核心流程

TreeMap.put 方法执行以下步骤(基于 JDK 8 简化):

  1. 若根节点 root == null,直接创建新节点并设为根,着色为,完成。
  2. 否则,从根开始沿红黑树向下搜索:
    • 通过 comparator(若存在)或元素自身的 Comparable 进行比较。
    • cmp < 0,进入左子树;cmp > 0,进入右子树。
    • cmp == 0表示键相等,则用新值覆盖旧值,并返回旧值(TreeSet 层判断为重复,add 返回 false)。
  3. 找到插入位置(叶子),创建新 Entry 并链接到父节点,默认为红色
  4. 调用 fixAfterInsertion(e) 修复红黑树性质(可能触发旋转和变色)。

fixAfterInsertion 自平衡简述:

  • 插入节点 X 为红色。循环处理直到 X 成为根或父节点为黑。
  • Case 1:X 的叔叔节点为红色 → 父与叔变黑,祖父变红,X 指向祖父继续循环。
  • Case 2:X 的叔叔为黑色,且 X 为内侧孙子 → 通过旋转将 X 转为外侧情形。
  • Case 3:X 的叔叔为黑色,且 X 为外侧孙子 → 父变黑,祖父变红,对祖父旋转。
  • 最终根节点强制涂黑

add 操作调用链与自平衡流程图

sequenceDiagram
    participant Client
    participant TreeSet
    participant TreeMap
    participant RedBlackTree

    Client->>TreeSet: add(element)
    TreeSet->>TreeMap: put(element, PRESENT)
    
    alt 树为空
        TreeMap->>RedBlackTree: 创建黑色根节点
        TreeMap-->>TreeSet: null (新增成功)
    else 树非空
        TreeMap->>RedBlackTree: 从根比较查找插入位置
        loop 二叉查找
            RedBlackTree->>RedBlackTree: compare(element, current.key)
            alt cmp == 0
                RedBlackTree->>RedBlackTree: 更新value, 返回旧值
                TreeMap-->>TreeSet: 旧值 (add返回false)
            else cmp < 0 或 > 0
                RedBlackTree->>RedBlackTree: 移向左/右子节点
            end
        end
        RedBlackTree->>RedBlackTree: 创建红色节点并链接
        RedBlackTree->>RedBlackTree: fixAfterInsertion(新节点)
        loop 直到根或父为黑
            RedBlackTree->>RedBlackTree: 检查父/叔颜色
            alt 叔叔红色
                RedBlackTree->>RedBlackTree: 父叔黑,祖父红,上溯
            else 叔叔黑色
                alt 内侧孙子
                    RedBlackTree->>RedBlackTree: 旋转父节点
                end
                RedBlackTree->>RedBlackTree: 父黑,祖父红,旋转祖父
            end
        end
        RedBlackTree->>RedBlackTree: 根节点设为黑色
        TreeMap-->>TreeSet: null (新增成功)
    end
    TreeSet-->>Client: true/false

流程图说明:

  • 第一层 – 调用委托TreeSet.add(e) 将操作完全委托给 TreeMap.put(e, PRESENT),返回值决定是否新增成功。
  • 第二层 – 二叉查找TreeMap.put 从根开始比较,利用比较器/Comparable确定方向。若遇到 cmp == 0 的节点,直接替换旧值并返回旧值,此时 add 返回 false(去重)。这是 TreeSet 基于比较判等的核心体现。
  • 第三层 – 新节点插入:若查找至叶子未发现相同键,则在该处创建红色新节点并链接到父节点。选择红色是为了尽量不破坏性质 5(黑高),减少修复复杂度。
  • 第四层 – 自平衡修复fixAfterInsertion 循环处理可能违反的性质(连续红节点)。**Case 1(叔红)**通过变色上推问题;**Case 2/3(叔黑)**通过旋转解决。关键结论:插入后至多两次旋转即可恢复平衡,每次修复保证 O(log n) 时间。
  • 终点:无论修复如何,最终将根节点设为黑色,确保性质 2 成立。此时 put 返回 nulladd 返回 true

Demo:add 与重复判断

TreeSet<Person> set = new TreeSet<>(Comparator.comparingInt(Person::getAge));
set.add(new Person("Alice", 30));
set.add(new Person("Bob", 30)); // compare结果为0,判定重复,插入失败
System.out.println(set.size()); // 1

模块 6:remove 与 contains 操作

remove 源码

public boolean remove(Object o) {
    return m.remove(o) == PRESENT;
}

TreeMap.remove(key) 的核心流程:

  1. 调用 getEntry(key) 利用二叉查找定位节点。
  2. 若找到,调用 deleteEntry(p) 删除节点并调用 fixAfterDeletion(x) 修复平衡。
  3. 删除节点时根据其子节点数目分三种情况:
    • 无子节点:直接删除。
    • 只有一个子节点:用子节点替代。
    • 有两个子节点:找到后继节点(右子树最小节点),拷贝键值,转为删除后继节点(前两种情况)。
  4. 若删除的节点为黑色,必须调用 fixAfterDeletion 调整黑高平衡(可能涉及兄弟节点借黑或变色旋转)。

contains 源码

public boolean contains(Object o) {
    return m.containsKey(o);
}

TreeMap.containsKey 直接调用 getEntry(key) 进行纯查找,不修改树结构,时间复杂度 O(log n)。

remove 调用链流程图

flowchart TD
    A["TreeSet.remove(o)"] --> B["TreeMap.remove(o)"]
    B --> C{"getEntry(o) 查找节点"}
    C -->|"找不到"| D["返回 null remove返回false"]
    C -->|"找到节点p"| E{"判断p子节点数"}
    E -->|"无子节点"| F["直接解除链接"]
    E -->|"一个子节点"| G["用该子节点替换p"]
    E -->|"两个子节点"| H["找到后继节点succ (右子树最小节点)"]
    H --> I["拷贝succ键值到p"]
    I --> J["转变为删除succ (子节点数≤1)"]
    J --> F
    F --> K{"p的颜色?"}
    G --> K
    K -->|"红色"| L["无需调整 remove返回PRESENT"]
    K -->|"黑色"| M["fixAfterDeletion(replacement)"]
    M --> L

流程图说明:

  • 第一层:查找getEntry 基于比较器/Comparable 进行二分定位,若未找到直接返回 nullremove 返回 false
  • 第二层:删除逻辑。根据待删节点 p 的子节点数量分类处理。双子女情况通过寻找后继(右子树最左节点)转换为删除至多一个子节点的问题,巧妙复用单子逻辑。
  • 第三层:颜色修复关键结论:删除红色节点不影响黑高,无需调整;删除黑色节点会破坏性质 5,需要调用 fixAfterDeletion,通过兄弟节点的颜色与子节点情况,执行借黑(旋转+变色)操作,确保树重新平衡。contains 操作完全不经过修改路径,仅有查找过程。

模块 7:导航方法与范围视图(源码剖析)

TreeSet 的导航能力来自 NavigableSet 接口,内部全部依赖于 TreeMap 的对应实现。

导航方法源码映射(以 lower 为例)

// TreeSet.java
public E lower(E e) {
    return m.lowerKey(e);
}

TreeMap.lowerKey 的实现核心是 getLowerEntry(key) 方法,它在红黑树中进行二分定位,寻找严格小于 key 的最大节点。同理,floorceilinghigher 分别对应 getFloorEntrygetCeilingEntrygetHigherEntry

导航方法查找示意图(lower):

flowchart TD
    Start["开始 lower(e)"] --> Finder["从根节点开始"]
    Finder --> Loop{"节点t != null?"}
    Loop -- 否 --> Return["返回 lastResult"]
    Loop -- 是 --> Compare{"compare(e, t.key)"}
    Compare -- "> 0 (e > t.key)" --> Record["记录t为候选<br>lastResult = t.key"]
    Record --> GoRight["t = t.right"]
    GoRight --> Loop
    Compare -- "<= 0" --> GoLeft["t = t.left"]
    GoLeft --> Loop

流程图说明:

  • 数据结构映射:利用二叉搜索树性质,当 e 大于当前节点键时,当前节点成为候选答案(因为它小于 e),然后进入右子树寻找可能更大的候选;当 e 小于等于当前键时,直接进入左子树寻找更小的节点。遍历结束后 lastResult 即为严格小于 e 的最大键。
  • 方法对应TreeMap.getLowerEntry 忠实执行上述逻辑,floor 等仅有细微差异(比较条件调整为包含等号)。
  • 时间复杂度:O(log n),得益于红黑树的平衡高度。

范围视图(subSet/headSet/tailSet)

public NavigableSet<E> subSet(E fromElement, boolean fromInclusive,
                              E toElement, boolean toInclusive) {
    return new TreeSet<>(m.subMap(fromElement, fromInclusive,
                                  toElement, toInclusive));
}

范围视图返回一个新的 TreeSet,但其内部的 m 是原 TreeMap子视图(SubMap。这意味着视图与原集合共享同一底层树结构,一方的结构性修改(添加/删除)会反映到另一方,并受视图区间边界的限制。


Part 4:迭代与序列化篇

模块 8:迭代器与有序遍历

迭代器实现

public Iterator<E> iterator() {
    return m.navigableKeySet().iterator();
}

实际上,TreeMapKeyIterator 基于中序遍历实现。中序遍历的顺序为:左子树 → 根 → 右子树,这正是红黑树提供升序遍历的关键。

中序遍历迭代器原理:

stateDiagram-v2
    [*] --> 未初始化
    未初始化 --> 压栈最左路径: 初始化
    压栈最左路径 --> 弹出栈顶节点: next()
    弹出栈顶节点 --> 转向右子: 右子存在?
    转向右子 --> 压栈右子的最左路径: 是
    压栈右子的最左路径 --> 弹出栈顶节点
    转向右子 --> 弹出栈顶节点: 否,且栈非空
    弹出栈顶节点 --> [*]: 栈空且无右子

状态图说明:

  • 初始化阶段:迭代器创建时,从根开始将最左路径上的所有节点压入栈。栈顶即是最小元素。
  • 迭代推进:每次调用 next(),弹出栈顶节点并返回;随后转向该节点的右子树,并将右子树的最左路径压栈。该过程严格遵循左-根-右的中序顺序,从而保证了升序输出。
  • 降序迭代器 descendingIterator() 则采用对称的右-根-左顺序,或利用 descendingMap().keySet().iterator() 实现。
  • fail-fast 机制TreeMap 内部维护 modCount,迭代器创建时记录 expectedModCount,每次访问前检查是否被并发修改,若不一致立即抛出 ConcurrentModificationException

模块 9:序列化与克隆

  • 序列化TreeSet 实现了 SerializablewriteObject 方法委托内部 TreeMapwriteObject 保存比较器、容量和所有键(值不保存,反序列化时重建 PRESENT)。readObject 同理恢复树结构。
  • 克隆clone() 方法调用 super.clone() 后,对内部 TreeMap 进行浅拷贝(TreeMap.clone()),因此新旧集合共享相同的元素引用,但树结构独立。

Part 5:对比与陷阱篇

模块 10:TreeSet vs HashSet vs LinkedHashSet——全维对比

特性HashSetLinkedHashSetTreeSet
底层结构HashMap (哈希表)LinkedHashMap (哈希表+双向链表)TreeMap (红黑树)
元素顺序无序保持插入顺序按排序规则(自然/比较器)
判重逻辑hashCode + equalshashCode + equalscompareTo / compare 返回 0
基本操作时间复杂度O(1) 平均O(1) 平均O(log n)
null 元素允许一个 null允许一个 null通常不允许(取决于比较器)
导航/范围操作lowerfloorsubSet
迭代性能与容量正比与size正比与size正比,有序
内存开销较低中等(额外链表)较高(节点含颜色、指针)
适用场景快速去重,无序需要插入顺序的去重需要排序、范围查询、有序遍历

选型决策树(同模块1)已给出,关键结论: 如果仅需快速去重不关心顺序,HashSet 是首选;需要记录插入顺序则用 LinkedHashSet;只有在需要排序或导航操作时,才应选择 TreeSet使用 TreeSet 必须重视比较器与 equals 的一致性,否则可能破坏 Set 的语义。

模块 11:TreeSet vs ConcurrentSkipListSet

两者均实现 NavigableSet,API 高度兼容,但内部实现与并发行为截然不同。

  • 数据结构ConcurrentSkipListSet 基于跳表(Skip List) 实现,通过多层索引支持快速查找;TreeSet 基于红黑树。
  • 线程安全ConcurrentSkipListSet线程安全的,支持高并发下的无锁/轻量级锁操作;TreeSet 非线程安全,并发修改将导致不可预知的结果。
  • 性能:单线程下红黑树常数因子稍优于跳表,但跳表更易于实现非阻塞并发。在需要并发有序遍历或频繁并发修改时,应使用 ConcurrentSkipListSet
  • null 元素:两者都不允许 null(除非比较器支持,但跳表实现也规范禁止)。选择时主要依据并发需求。

模块 12:常见陷阱与最佳实践

陷阱 1:未实现 Comparable 且未传 Comparator

TreeSet<Object> set = new TreeSet<>();
set.add(new Object()); // 抛出 ClassCastException

修复:实现 Comparable 或提供 Comparator

陷阱 2:比较器与 equals 不一致

假设 Personequals 基于身份证,而 Comparator 仅基于姓名。

TreeSet<Person> set = new TreeSet<>(Comparator.comparing(Person::getName));
Person p1 = new Person("Alice", "ID001");
Person p2 = new Person("Alice", "ID002");
set.add(p1);
set.add(p2); // compare返回0,视为重复,但equals不等

后果:Set 语义被破坏,p1p2 本该视为不同元素却只存其一。最佳实践:确保 compareToComparatorequals 定义一致。

陷阱 3:存入元素后修改比较字段

TreeSet<Person> set = new TreeSet<>(Comparator.comparing(Person::getAge));
Person p = new Person("Bob", 25);
set.add(p);
set.add(new Person("Alice", 30));
p.setAge(40); // 修改导致排序失效,无法正常查找或删除

原理:红黑树的排序基于插入时键的属性,后续改变不会触发树的重排,导致结构混乱。最佳实践:存入 TreeSet 的元素最好为不可变对象(如 StringLocalDate)。

陷阱 4:并发修改

多线程同时修改 TreeSet 可能抛出 ConcurrentModificationException 或导致数据不一致。解决方案:用 Collections.synchronizedNavigableSet 包装,或使用 ConcurrentSkipListSet

陷阱 5:子视图与原集合的结构性修改冲突

NavigableSet<Integer> sub = set.subSet(1, true, 10, false);
sub.add(5);   // 正常
set.add(0);   // 合法但可能影响sub的边界
set.add(8);   // 合法,但可能导致sub的某些操作异常

范围视图的边界在违反约束插入时会抛 IllegalArgumentException,但原集合的合法修改不应导致视图失效,但 JDK 源码保证视图的依赖关系,需谨慎同步修改。


Part 6:总结与面试篇

模块 13:注意事项与性能总结

  • 时间复杂度:增删查、导航操作均为 O(log n);first/last 因红黑树维护最左最右节点可达 O(1)。
  • 空间开销:每个元素存储为一个 TreeMap.Entry,额外包含 leftrightparentcolor 四个引用和一个布尔值,空间消耗高于 HashMapNode
  • 元素约束:鼓励使用不可变对象作为元素,避免比较键可变带来的问题。
  • 比较器优先:构造时传入 Comparator 可覆盖自然顺序,且允许排序对象未实现 Comparable
  • 视图时效性subSet 等视图是实时反应原集合变化的,共享底层数据。

模块 14:面试高频专题

1. TreeSet 的底层数据结构是什么?为什么能保证有序?
标准回答:底层基于 TreeMap,使用红黑树存储。红黑树是自平衡的二叉搜索树,通过维护键的比较顺序和自平衡性质,保证中序遍历为升序,从而实现有序性。
追问:如何实现去重?
回答:通过 compareTocompare 返回 0 判定为相同元素,TreeMap.put 会替换旧值而键不变,TreeSet 据此返回 false
加分回答:可提及红黑树的五个性质及旋转修复,以及 NavigableSet 接口扩展的导航能力。

2. TreeSet 如何判断元素是否重复?与 HashSet 有何不同?
标准回答:TreeSet 依赖 compareTo/compare 返回值 0 判重,HashSet 依赖 hashCodeequals。因此如果一个类重写 equalsComparable 按不同逻辑比较,TreeSet 可能出现语义矛盾。
追问:当一个类同时实现了 Comparable 和 equals,但逻辑不一致,TreeSet 会怎样?
加分回答:会破坏 Set 契约(Set 内部不应包含 equals 为 true 的两个元素,但可能因 compare 返回非 0 而同时存在)。

3. 什么是红黑树?它在 TreeSet 中的作用是什么?
标准回答:红黑树是近似平衡的二叉搜索树,通过节点颜色和旋转保持从根到叶最长路径不超过最短路径的两倍。在 TreeSet 中,它确保增删查的操作时间复杂度为 O(log n),并提供有序迭代。
追问:与 AVL 树相比有何优势?
加分回答:红黑树牺牲了部分严格平衡性,但插入删除时的旋转次数更少(最多三次),在修改频繁的场景综合性能更优。

4. TreeSet 的 add 操作源码流程(追问 fixAfterInsertion)
见模块 5 详细流程图及分析。

5. TreeSet 是否允许 null 元素?为什么?
标准回答:通常不允许。使用自然排序时,null 无法与任何对象比较,会抛 NullPointerException;若提供了能处理 null 的比较器,则可以插入 null,但大多数比较器(包括 Comparator.naturalOrder())不处理 null。工程上推荐不要插入 null。
追问:如果比较器特别处理 null,TreeMap 的 put 会正常插入吗?
加分回答:会,只要比较器能返回有意义的值,TreeMap 就会将 null 作为普通键对待。但后续操作必须确保所有比较都能处理 null,否则仍然抛出异常。

6. 自然排序和比较器排序的区别?何时用哪个?
标准回答:自然排序要求元素实现 Comparable,排序逻辑与类绑定;比较器排序通过 Comparator 外部定义,不修改原类,且可提供多种排序策略。当类本身具备自然顺序且对顺序无特殊要求时用自然排序;需要降序、特殊规则或类未实现 Comparable 时用比较器。
追问:两者同时存在时哪个起作用?
加分回答:比较器优先级高于自然排序。

7. TreeSet 和 HashSet 的性能对比?如何选择?
标准回答:HashSet O(1),TreeSet O(log n);HashSet 无序,TreeSet 有序。需要排序、范围查询时选 TreeSet;仅需快速去重时选 HashSet。内存开销 TreeSet 更大。可在模块 10 对比表中补充。

8. TreeSet 的导航方法(lower/floor/ceiling/higher)如何使用?
标准回答lower(e) 返回严格小于 e 的最大元素;floor(e) 返回小于等于 e 的最大元素;ceiling(e) 返回大于等于 e 的最小元素;higher(e) 返回严格大于 e 的最小元素。它们均以 O(log n) 复杂度实现,适用于区间匹配场景。

9. 为什么比较器要与 equals 保持一致?不一致会有什么后果?
标准回答SortedSet 的接口契约强烈推荐(但不强制)比较器与 equals 一致。如果不一致,SortedSet 的行为虽能遵守自身顺序,但可能违背 Set 接口基于 equals 的约定,例如两个 equals 的对象可能因其 compare 结果不同而同时在集合中,破坏了 Set 唯一性语义。

10. TreeSet 线程安全吗?并发中有什么替代方案?
标准回答:不是。并发环境可用 Collections.synchronizedNavigableSet(new TreeSet<>()) 进行同步包装,或直接使用 ConcurrentSkipListSet,后者基于跳表提供更好的并发伸缩性。
追问synchronizedNavigableSetConcurrentSkipListSet 的区别?
加分回答:前者为所有方法加锁,迭代需外部同步;后者无锁或轻量级锁,迭代器弱一致性,不抛 ConcurrentModificationException

11. 修改 TreeSet 中元素的比较字段会发生什么?如何避免?
标准回答:修改会影响红黑树的有序结构,但树不自动重排,导致查找、删除可能失败,迭代顺序错乱。避免方法:只在 TreeSet 中存储不可变对象,或保证修改字段不参与比较器逻辑;若必须修改,先移除,修改后再重新加入。