超市购物车的秘密:LinkedHashSet 的有序去重原理

73 阅读6分钟

一、超市排队的奥秘:LinkedHashSet 的基本概念

想象你在超市购物,推着一辆神奇的购物车 —— 它会自动去重,而且记住你放入商品的顺序。这就是 Java 中的LinkedHashSet,一个既可以去重又能保持插入顺序的神奇集合。与普通购物车(HashSet)不同,它会像排队一样记住你放入商品的先后顺序,让你在结账时能按放入顺序查看商品。

1.1 购物车的神奇特性

  • 去重功能:不能放入相同商品,就像不能买两件同样的牛奶

  • 有序性:取出商品时按放入顺序排列,而非随机

  • 底层实现:像超市的智能货架系统,用哈希表快速查找商品,用双向链表记录放入顺序

java

// 创建一个超市购物车(LinkedHashSet)
LinkedHashSet<String> shoppingCart = new LinkedHashSet<>();

// 放入商品(自动去重)
shoppingCart.add("牛奶");
shoppingCart.add("鸡蛋");
shoppingCart.add("面包");
shoppingCart.add("牛奶"); // 重复,不会加入

// 按放入顺序遍历购物车
System.out.println("购物车商品:");
for (String item : shoppingCart) {
    System.out.println(item);
}
// 输出:牛奶、鸡蛋、面包

1.2 与其他购物车的区别

购物车类型特点超市场景类比
LinkedHashSet有序去重按放入顺序记录的购物车
HashSet无序去重随意堆放的普通购物车
TreeSet有序去重且排序按商品名称字母排序的购物车

二、购物车的内部结构:哈希表 + 双向链表

超市的智能购物车内部有两个核心系统:

  1. 哈希检索系统:快速查找商品是否已存在
  2. 排队记录系统:用双向链表记录放入顺序

2.1 购物车的核心组件

java

// LinkedHashSet的核心结构(简化示意)
public class LinkedHashSet<E> extends HashSet<E> {
    // 内部使用LinkedHashMap,键是商品,值是固定常量
    private transient LinkedHashMap<E, Object> map;
    private static final Object PRESENT = new Object(); // 固定值

    // 构造函数:创建带初始容量的购物车
    public LinkedHashSet(int initialCapacity) {
        super(initialCapacity, 0.75f, true); // 创建LinkedHashMap
    }

    // 添加商品的核心方法
    public boolean add(E e) {
        return map.put(e, PRESENT) == null;
    }
}

// HashSet的关键构造函数
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

2.2 双向链表的排队记录

每个商品在购物车中不仅存在于哈希表中,还被双向链表连接,形成排队顺序:

plaintext

牛奶 <-> 鸡蛋 <-> 面包
  • 每个节点记录前一个商品和后一个商品的引用
  • 头节点是第一个放入的商品,尾节点是最后放入的
  • 遍历时从链表头开始,按顺序取出商品

三、购物车操作:添加、删除与查看

3.1 放入商品:add 方法

当你往购物车放牛奶时,购物车会:

  1. 用哈希表检查是否已存在牛奶

  2. 若不存在,加入哈希表,并添加到链表末尾

  3. 维护链表顺序,确保按放入顺序排列

java

// 添加商品的源码模拟
public boolean add(E e) {
    // 调用LinkedHashMap的put方法,键是商品,值是PRESENT
    return map.put(e, PRESENT) == null;
}

// LinkedHashMap的put方法简化逻辑
public V put(K key, V value) {
    // 计算哈希值确定位置
    int hash = hash(key);
    int index = indexFor(hash, table.length);
    
    // 检查该位置是否已有商品
    Node<K,V> first = table[index];
    if (first == null) {
        // 空位,直接添加
        table[index] = newNode(hash, key, value, null);
    } else {
        // 处理哈希冲突(链表或红黑树)
        // ...
    }
    
    // 维护双向链表:将新节点添加到链表末尾
    linkNodeLast(newNode);
    return null;
}

// 维护双向链表的方法
private void linkNodeLast(Node<K,V> p) {
    Node<K,V> last = tail;
    tail = p;
    if (last == null) {
        head = p; // 第一个节点
    } else {
        p.before = last;
        last.after = p; // 新节点接到链表末尾
    }
}

3.2 移除商品:remove 方法

当你决定不买鸡蛋时,购物车会:

  1. 在哈希表中找到鸡蛋的位置

  2. 从链表中切断前后连接,移除鸡蛋

  3. 调整哈希表和链表结构

java

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

// LinkedHashMap的remove方法简化逻辑
public V remove(Object key) {
    Node<K,V> e = removeNode(hash(key), key, null, false, true);
    return e == null ? null : e.value;
}

// 移除节点并调整链表
final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    
    // 找到节点后,调整双向链表
    if (e != null) {
        if (e.before != null)
            e.before.after = e.after;
        else
            head = e.after;
        if (e.after != null)
            e.after.before = e.before;
        else
            tail = e.before;
        // ...
    }
    return e;
}

3.3 查看购物车:iterator 方法

遍历购物车时,会按链表顺序依次取出商品:

java

// 遍历购物车的源码逻辑
public Iterator<E> iterator() {
    return map.keySet().iterator();
}

// LinkedHashMap的keySet迭代器
public Set<K> keySet() {
    Set<K> ks = keySet;
    if (ks == null) {
        ks = new LinkedKeySet();
        keySet = ks;
    }
    return ks;
}

// 链表迭代器,按before-after顺序遍历
class LinkedKeyIterator extends LinkedHashIterator implements Iterator<K> {
    public K next() { return nextNode().key; }
}

class LinkedHashIterator {
    LinkedHashMap.Entry<K,V> next = head; // 从链表头开始
    
    public boolean hasNext() {
        return next != null;
    }
    
    final LinkedHashMap.Entry<K,V> nextNode() {
        LinkedHashMap.Entry<K,V> e = next;
        next = e.after; // 移到下一个节点
        return e;
    }
}

四、购物车的性能与线程安全

4.1 性能特点

操作时间复杂度超市场景解释
添加 (add)O(1)快速检查并放入购物车
移除 (remove)O(1)快速找到并移除商品
包含 (contains)O(1)快速检查商品是否在购物车
遍历 (iterator)O(n)按顺序查看所有商品

4.2 多顾客同时使用的问题

当多个顾客同时操作同一购物车时,可能出现混乱:

java

// 危险!多线程同时操作LinkedHashSet
LinkedHashSet<String> cart = new LinkedHashSet<>();

Thread customer1 = new Thread(() -> {
    for (int i = 0; i < 100; i++) {
        cart.add("商品" + i);
    }
});

Thread customer2 = new Thread(() -> {
    for (int i = 50; i < 150; i++) {
        cart.remove("商品" + i);
    }
});

customer1.start();
customer2.start();
// 可能抛出ConcurrentModificationException或数据错乱

4.3 安全的购物车方案

使用同步包装或并发容器:

java

// 方案1:使用Collections.synchronizedSet
Set<String> safeCart = Collections.synchronizedSet(new LinkedHashSet<>());

// 方案2:自定义线程安全的LinkedHashSet
class ThreadSafeLinkedHashSet<E> {
    private final Set<E> set;
    
    public ThreadSafeLinkedHashSet() {
        set = Collections.synchronizedSet(new LinkedHashSet<>());
    }
    
    public synchronized boolean add(E e) {
        return set.add(e);
    }
    
    public synchronized boolean remove(Object o) {
        return set.remove(o);
    }
    
    // 其他方法类似...
}

五、LinkedHashSet 的应用场景

5.1 网页访问记录

记录用户访问过的网页,去重并保持访问顺序:

java

LinkedHashSet<String> visitedPages = new LinkedHashSet<>();

// 用户访问网页
visitedPages.add("https://home.com");
visitedPages.add("https://product.com");
visitedPages.add("https://cart.com");
visitedPages.add("https://home.com"); // 重复,忽略

// 显示访问历史
System.out.println("最近访问记录:");
for (String page : visitedPages) {
    System.out.println(page);
}

5.2 音乐播放列表

创建去重但保持添加顺序的播放列表:

java

LinkedHashSet<String> playlist = new LinkedHashSet<>();

playlist.add("歌曲A");
playlist.add("歌曲B");
playlist.add("歌曲A"); // 重复,不添加
playlist.add("歌曲C");

System.out.println("播放顺序:");
for (String song : playlist) {
    System.out.println(song);
}

5.3 任务执行记录

记录任务执行顺序,去重并按添加顺序执行:

java

LinkedHashSet<String> taskList = new LinkedHashSet<>();

taskList.add("任务1");
taskList.add("任务2");
taskList.add("任务1"); // 重复
taskList.add("任务3");

System.out.println("任务执行顺序:");
for (String task : taskList) {
    System.out.println(task);
}

六、总结:LinkedHashSet 的核心秘密

通过超市购物车的比喻,我们理解了LinkedHashSet的核心原理:

  • 基于LinkedHashMap实现,键是元素,值是固定常量

  • 用哈希表实现快速去重,用双向链表维护插入顺序

  • 添加、删除、查找操作平均时间复杂度 O (1),遍历 O (n)

  • 非线程安全,多线程场景需额外处理

  • 适用于需要去重并保持顺序的场景,如访问记录、播放列表

记住这个购物车的故事,下次需要有序去重时,你就知道该请LinkedHashSet出场了!