超市储物柜里的秘密:HashSet 去重原理大揭秘

110 阅读6分钟

一、超市存包系统:HashSet 的基本概念

想象你来到超市,存包时会拿到一个存包凭证,上面有一个柜子编号。超市的存包系统有两个神奇特性:

  1. 相同物品只能存一次(去重)

  2. 取包时直接用凭证找柜子,速度非常快

这就是 Java 中的HashSet,一个基于哈希表实现的集合,专门用于快速去重和查找。

java

// 创建超市存包系统(HashSet)
HashSet<String> storage = new HashSet<>();

// 存包操作
storage.add("苹果");
storage.add("香蕉");
storage.add("苹果");  // 重复物品,存包失败

// 查看存包情况
System.out.println("储物柜中的物品:" + storage);  // 输出:[香蕉, 苹果]

// 检查物品是否存在
boolean hasApple = storage.contains("苹果");
System.out.println("是否存了苹果:" + hasApple);  // 输出:true

1.1 存包系统的核心特性

特性超市场景比喻技术解释
去重不能存两个相同物品不允许重复元素
快速查找凭凭证直接找柜子平均 O (1) 时间复杂度
无序性取包顺序和存包顺序无关不保证元素存储顺序
基于哈希表柜子编号由物品决定通过哈希函数计算存储位置

二、储物柜的内部结构:哈希表的工作原理

超市的存包系统背后有一套智能分配机制:

  1. 哈希函数:根据物品名称计算柜子编号
  2. 储物柜数组:存放物品的柜子
  3. 冲突处理:当多个物品算到同一个柜子时的解决方案

2.1 核心存储结构

java

public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, Serializable {
    // 内部使用HashMap存储,键是物品,值是固定常量
    private transient HashMap<E, Object> map;
    // 所有键对应的值都是这个常量
    private static final Object PRESENT = new Object();
    
    // 构造函数:创建存包系统
    public HashSet() {
        map = new HashMap<>();
    }
    
    // 存包方法
    public boolean add(E e) {
        // 调用HashMap的put方法,键是物品,值是PRESENT
        // 如果返回null,说明存包成功(无重复)
        return map.put(e, PRESENT) == null;
    }
    
    // 检查物品是否存在
    public boolean contains(Object o) {
        // 检查HashMap中是否有这个键
        return map.containsKey(o);
    }
}

2.2 哈希函数与柜子分配

当你存 "苹果" 时,系统会:

  1. 计算 "苹果" 的哈希值:hash("苹果")

  2. 用哈希值对柜子总数取模,得到柜子编号:index = hash % capacity

  3. 将 "苹果" 存到该编号的柜子里

java

// 简化的哈希值计算和柜子分配
int hash = "苹果".hashCode();  // 计算哈希值
int capacity = 16;  // 柜子总数
int index = hash & (capacity - 1);  // 等价于hash % capacity,计算柜子编号

2.3 处理 "柜子不够" 的情况:哈希冲突

当两个物品算到同一个柜子时(哈希冲突),系统会用 "挂链表" 的方式处理:

plaintext

柜子10: 苹果 → 苹果汁 → 苹果醋

如果链表太长(超过 8 个物品),会升级成 "抽屉柜"(红黑树),提高查找效率。

java

// HashMap中处理哈希冲突的关键代码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // ...
    
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        // 处理哈希冲突
        if (p.hash == hash && keys相等) {
            // 键相同,替换值
        } else if (p instanceof TreeNode) {
            // 红黑树节点,插入到树中
        } else {
            // 链表节点,插入到链表末尾
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 链表太长,转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                // ...
            }
        }
    }
    // ...
}

三、存包系统的日常操作:增删查

3.1 存包 (add):快速判断是否已存

java

// 存包示例
HashSet<String> bag = new HashSet<>();
bag.add("雨伞");        // 存包成功
boolean added = bag.add("雨伞");  // 返回false,因为已存在
System.out.println("存包结果:" + added);  // 输出:false

3.2 取包 (remove):快速找到并取出

java

// 取包示例
bag.remove("雨伞");  // 取出雨伞
boolean removed = bag.remove("雨衣");  // 雨衣不存在,返回false
System.out.println("取包结果:" + removed);  // 输出:false

3.3 查看 (contains):快速判断是否在柜

java

// 查看示例
boolean hasUmbrella = bag.contains("雨伞");  // 雨伞已取出,返回false
System.out.println("是否有雨伞:" + hasUmbrella);  // 输出:false

四、多人同时存包:线程安全问题

当多个顾客同时操作存包系统时,可能会出问题:

java

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

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

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

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

安全的存包系统:线程安全方案

  1. 加锁方案:给存包系统加一把大锁

java

// 使用Collections.synchronizedSet包装
Set<String> safeBag = Collections.synchronizedSet(new HashSet<>());

// 存包时需要手动加锁
synchronized (safeBag) {
    safeBag.add("重要物品");
}
  1. 并发专用方案:使用 ConcurrentHashMap 实现的集合

java

import java.util.concurrent.ConcurrentHashMap;

// 自定义线程安全的HashSet
class ThreadSafeHashSet<E> {
    private final ConcurrentHashMap<E, Boolean> map;
    
    public ThreadSafeHashSet() {
        map = new ConcurrentHashMap<>();
    }
    
    public boolean add(E e) {
        return map.putIfAbsent(e, true) == null;
    }
    
    public boolean contains(E e) {
        return map.containsKey(e);
    }
}

五、存包系统的性能分析

5.1 正常情况下的速度:非常快!

操作时间复杂度超市场景比喻
存包 (add)O(1)直接找到柜子存包
取包 (remove)O(1)直接找到柜子取包
查看 (contains)O(1)直接查看柜子是否有物品

5.2 特殊情况:柜子太挤时会变慢

当很多物品算到同一个柜子时(哈希冲突严重),操作时间会变长:

  • 链表很长时:O (n)
  • 红黑树时:O (log n)

六、HashSet 的典型应用场景

6.1 数据去重:学生点名系统

java

// 记录出勤学生,自动去重
HashSet<String> presentStudents = new HashSet<>();

// 学生签到
presentStudents.add("张三");
presentStudents.add("李四");
presentStudents.add("张三");  // 重复签到,无效

// 统计出勤人数
System.out.println("出勤人数:" + presentStudents.size());

6.2 快速查找:黑名单系统

java

// 网站黑名单系统
HashSet<String> blacklist = new HashSet<>();

// 添加黑名单用户
blacklist.add("恶意用户1");
blacklist.add("恶意用户2");

// 检查用户是否在黑名单
boolean isBlocked = blacklist.contains("恶意用户1");
System.out.println("是否禁止访问:" + isBlocked);

6.3 交集、并集运算:数据分析

java

// 商品分类分析
HashSet<String> electronics = new HashSet<>(Arrays.asList("手机", "电脑", "平板"));
HashSet<String> hotSales = new HashSet<>(Arrays.asList("手机", "耳机", "电视"));

// 求交集:既属于电子产品又热销的商品
HashSet<String> intersection = new HashSet<>(electronics);
intersection.retainAll(hotSales);
System.out.println("热销电子产品:" + intersection);

七、总结:HashSet 的核心秘密

通过超市存包的比喻,我们理解了HashSet的核心原理:

  1. 基于HashMap实现,元素作为键,值用固定常量PRESENT

  2. 利用哈希函数快速定位存储位置,平均 O (1) 操作效率

  3. 哈希冲突时用链表或红黑树处理,保证性能

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

  5. 适用于需要快速去重和查找的场景

记住这个存包系统的故事,下次遇到需要去重或快速查找的问题,就知道该请HashSet出场了!