1. 题目背景与需求分析
LeetCode 381题要求我们设计一个支持重复元素的数据结构,能够在O(1)时间复杂度内完成插入、删除和获取随机元素的操作。这个题目看似简单,但要在满足时间复杂度要求的同时处理重复元素,需要巧妙的数据结构组合。
1.1 核心需求拆解
这个数据结构需要满足三个核心操作:
- insert(val):插入元素val到集合中
- remove(val):从集合中移除元素val
- getRandom():随机返回集合中的一个元素
特别需要注意的是:
- 集合允许包含重复元素
- 随机获取元素时,每个元素被选中的概率应该与其在集合中的出现次数成正比
- 所有操作的时间复杂度必须是O(1)
1.2 为什么这是个有挑战的问题?
常规的数据结构很难同时满足这些要求:
- 使用纯ArrayList:随机访问是O(1),但删除特定元素需要O(n)时间
- 使用纯HashSet:插入和删除是O(1),但无法实现随机访问
- 使用LinkedHashSet:可以保持顺序,但无法处理重复元素
2. 数据结构设计与原理
2.1 组合数据结构方案
经过分析,我们需要组合使用三种数据结构:
2.1.1 ArrayList:存储所有元素
- 提供O(1)的随机访问能力
- 支持在末尾快速插入和删除
- 存储所有元素的实际值,包括重复项
2.1.2 HashMap<Integer, LinkedHashSet>:维护值到索引的映射
- 键是元素值
- 值是该元素在ArrayList中所有出现位置的索引集合
- 使用LinkedHashSet保证O(1)的增删和有序性
2.1.3 Random:生成随机数
- 用于实现getRandom()操作
- 在ArrayList的范围内生成随机索引
2.2 为什么选择LinkedHashSet?
相比普通HashSet,LinkedHashSet有以下优势:
- 保持插入顺序,使得我们可以方便地获取"第一个"或"最后一个"索引
- 迭代时行为可预测,便于调试和边界情况处理
- 虽然理论时间复杂度相同,但实际性能更稳定
3. 完整代码实现与解析
3.1 类定义与初始化
java复制import java.util.*;
class RandomizedCollection {
private List<Integer> nums; // 存储所有元素
private Map<Integer, LinkedHashSet<Integer>> valToIndices; // 值到索引的映射
private Random random; // 随机数生成器
public RandomizedCollection() {
nums = new ArrayList<>();
valToIndices = new HashMap<>();
random = new Random();
}
}
3.2 插入操作实现
java复制public boolean insert(int val) {
// 获取或创建该值的索引集合
LinkedHashSet<Integer> indices = valToIndices.computeIfAbsent(val,
k -> new LinkedHashSet<>());
// 添加新索引(当前列表大小就是新元素的索引)
indices.add(nums.size());
// 添加元素到列表末尾
nums.add(val);
// 返回是否是新插入的元素(之前不存在)
return indices.size() == 1;
}
插入操作的关键点:
- 使用computeIfAbsent简化代码,避免显式的null检查
- 新元素的索引总是当前列表的大小(nums.size())
- 返回值表示该元素是否是新插入的(之前不存在)
3.3 删除操作实现
java复制public boolean remove(int val) {
// 检查元素是否存在
if (!valToIndices.containsKey(val) ||
valToIndices.get(val).isEmpty()) {
return false;
}
// 获取要删除元素的任意一个索引(LinkedHashSet的第一个)
LinkedHashSet<Integer> indicesToRemove = valToIndices.get(val);
int indexToRemove = indicesToRemove.iterator().next();
// 获取最后一个元素及其值
int lastIndex = nums.size() - 1;
int lastVal = nums.get(lastIndex);
// 如果不是删除最后一个元素,需要交换
if (indexToRemove < lastIndex) {
// 将最后一个元素移动到要删除的位置
nums.set(indexToRemove, lastVal);
// 更新最后一个元素的索引映射
LinkedHashSet<Integer> lastValIndices = valToIndices.get(lastVal);
lastValIndices.remove(lastIndex);
lastValIndices.add(indexToRemove);
}
// 从索引集合中移除该索引
indicesToRemove.remove(indexToRemove);
// 如果该值的索引集合为空,从map中移除
if (indicesToRemove.isEmpty()) {
valToIndices.remove(val);
}
// 从列表中移除最后一个元素
nums.remove(lastIndex);
return true;
}
删除操作的巧妙之处:
- 通过交换要删除的元素和最后一个元素,使得删除操作可以在O(1)时间内完成
- 需要同时更新两个元素的索引映射
- 处理了当删除的元素是最后一个元素的特殊情况
3.4 随机获取操作实现
java复制public int getRandom() {
// 在列表范围内生成随机索引
int randomIndex = random.nextInt(nums.size());
// 返回对应位置的元素
return nums.get(randomIndex);
}
这个操作之所以简单,是因为我们维护了一个包含所有元素的ArrayList,可以直接利用它的随机访问特性。
4. 复杂度分析与证明
4.1 时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| insert | O(1) | ArrayList末尾插入O(1),HashMap/LinkedHashSet操作O(1) |
| remove | O(1) | 交换元素O(1),更新索引映射O(1) |
| getRandom | O(1) | 随机数生成O(1),数组访问O(1) |
4.2 空间复杂度分析
空间复杂度是O(N),其中N是集合中元素的总数(包括重复元素)。这是因为:
- ArrayList存储所有元素
- HashMap存储每个值到其索引的映射
- LinkedHashSet存储每个值的所有索引
5. 边界情况与异常处理
5.1 空集合处理
java复制// 在getRandom中处理空集合
public int getRandom() {
if (nums.isEmpty()) {
throw new IllegalStateException("Collection is empty");
}
return nums.get(random.nextInt(nums.size()));
}
5.2 删除不存在的元素
java复制// 在remove方法中已经处理
if (!valToIndices.containsKey(val) ||
valToIndices.get(val).isEmpty()) {
return false;
}
5.3 大量重复元素的情况
当某个元素出现次数非常多时,其索引集合会很大。但由于LinkedHashSet的操作仍然是O(1),所以不影响整体时间复杂度。
6. 测试用例与验证
6.1 基础测试
java复制public static void main(String[] args) {
RandomizedCollection rc = new RandomizedCollection();
// 测试插入
System.out.println(rc.insert(1)); // true (第一次插入1)
System.out.println(rc.insert(1)); // false (第二次插入1)
System.out.println(rc.insert(2)); // true (第一次插入2)
// 测试随机获取
for (int i = 0; i < 10; i++) {
System.out.print(rc.getRandom() + " "); // 应该1出现的概率是2的两倍
}
System.out.println();
// 测试删除
System.out.println(rc.remove(1)); // true (删除一个1)
System.out.println(rc.remove(3)); // false (删除不存在的元素)
// 验证删除后是否正确
for (int i = 0; i < 10; i++) {
System.out.print(rc.getRandom() + " "); // 现在应该1和2出现概率相同
}
System.out.println();
}
6.2 压力测试
java复制// 测试大量数据
RandomizedCollection largeTest = new RandomizedCollection();
for (int i = 0; i < 100000; i++) {
largeTest.insert(i % 100); // 插入10万个元素,有100个不同的值
}
// 测试删除
for (int i = 0; i < 50000; i++) {
largeTest.remove(i % 100);
}
// 验证随机性
Map<Integer, Integer> count = new HashMap<>();
for (int i = 0; i < 100000; i++) {
int val = largeTest.getRandom();
count.put(val, count.getOrDefault(val, 0) + 1);
}
System.out.println("元素分布统计: " + count);
7. 性能优化与变种
7.1 使用TreeSet替代LinkedHashSet
在某些场景下,使用TreeSet可能更有优势:
java复制private Map<Integer, TreeSet<Integer>> valToIndices;
public boolean insert(int val) {
TreeSet<Integer> indices = valToIndices.computeIfAbsent(val,
k -> new TreeSet<>());
indices.add(nums.size());
nums.add(val);
return indices.size() == 1;
}
public boolean remove(int val) {
TreeSet<Integer> indices = valToIndices.get(val);
if (indices == null || indices.isEmpty()) {
return false;
}
// 获取最大的索引(使用TreeSet的特性)
int indexToRemove = indices.last();
// ...其余逻辑相同
}
TreeSet的优势:
- 可以方便地获取最大或最小索引
- 在某些删除场景下可能更高效
7.2 线程安全版本
如果需要线程安全,可以使用并发集合:
java复制class ConcurrentRandomizedCollection {
private List<Integer> nums = Collections.synchronizedList(new ArrayList<>());
private Map<Integer, Set<Integer>> valToIndices =
new ConcurrentHashMap<>();
private Random random = new Random();
// 需要同步的方法
public synchronized boolean insert(int val) {
Set<Integer> indices = valToIndices.computeIfAbsent(val,
k -> Collections.synchronizedSet(new LinkedHashSet<>()));
indices.add(nums.size());
nums.add(val);
return indices.size() == 1;
}
// 其他方法也需要同步
}
7.3 延迟删除优化
对于频繁删除的场景,可以采用延迟删除策略:
java复制class LazyRandomizedCollection {
private List<Integer> nums = new ArrayList<>();
private Map<Integer, LinkedHashSet<Integer>> valToIndices = new HashMap<>();
private Set<Integer> removedIndices = new HashSet<>();
private int size = 0;
public boolean insert(int val) {
valToIndices.computeIfAbsent(val, k -> new LinkedHashSet<>())
.add(nums.size());
nums.add(val);
size++;
return true;
}
public boolean remove(int val) {
if (!valToIndices.containsKey(val)) return false;
int index = valToIndices.get(val).iterator().next();
removedIndices.add(index);
valToIndices.get(val).remove(index);
size--;
// 定期清理
if (removedIndices.size() > size / 2) {
compact();
}
return true;
}
private void compact() {
// 实现压缩逻辑,重建数据结构
}
}
8. 实际应用场景
这种数据结构在以下场景中非常有用:
- 随机抽样系统:需要从大量数据中随机抽取样本,且数据可能重复
- 游戏开发:随机掉落物品,不同物品有不同的掉落概率
- 推荐系统:随机推荐内容,热门内容应该有更高概率被推荐
- 测试数据生成:需要生成包含重复项的随机测试数据
9. 常见问题与解决方案
9.1 为什么删除操作要交换元素?
直接删除ArrayList中间的元素会导致后续元素前移,时间复杂度为O(n)。通过将要删除的元素与最后一个元素交换,然后删除最后一个元素,可以保持O(1)的时间复杂度。
9.2 如何处理并发修改?
如果需要线程安全,可以使用同步集合或并发集合,如Collections.synchronizedList和ConcurrentHashMap。但要注意复合操作的原子性。
9.3 为什么随机获取的概率是均匀的?
因为所有元素(包括重复项)都存储在ArrayList中,每个位置被选中的概率相同,所以出现次数多的元素自然有更高的概率被选中。
9.4 内存占用是否过高?
对于包含大量重复元素的情况,索引映射会占用额外空间。如果内存是主要考虑因素,可以考虑压缩存储方案,如只存储每个值的计数和部分索引。
10. 扩展思考
10.1 支持其他操作
可以扩展这个数据结构以支持更多操作:
- getFrequency(val):获取某个值的出现次数
- sample(k):随机获取k个元素
- removeRandom():随机删除一个元素
10.2 分布式版本
对于超大规模数据,可以考虑分布式实现:
- 使用多个节点分别存储部分数据
- 通过一致性哈希确定数据位置
- 随机访问时根据权重选择节点
10.3 持久化存储
如果需要持久化,可以考虑:
- 序列化ArrayList和HashMap到磁盘
- 使用数据库存储元素和索引
- 实现增量保存机制
这个数据结构的设计展示了如何通过组合基本数据结构来解决复杂问题。关键在于理解每种数据结构的特性,并巧妙地将它们结合起来以弥补各自的不足。在实际工程中,这种组合式设计思想非常常见,也是区分普通程序员和优秀程序员的重要标志之一。