

文章目录
摘要
今天我想和大家分享一个在Swift中实现的实用数据结构------支持重复元素的随机集合。这个数据结构能够在平均O(1)时间复杂度内完成插入、删除和随机获取元素的操作,而且特别适合处理允许重复元素的场景。
你可能在想,为什么需要这样的数据结构呢?其实在实际开发中,我们经常会遇到需要随机抽样的场景。比如音乐播放器的随机播放功能,你可能希望热门歌曲被播放的概率更高一些;或者在一个抽奖系统中,高级用户可能有更多的抽奖机会,那么他们的ID在池子中就应该有多个副本。今天要讲的这个数据结构就是为解决这类问题而设计的。

描述
让我们先弄清楚这个数据结构的具体要求。题目要求我们实现一个叫做RandomizedCollection的类,它需要支持三个基本操作:
首先,插入操作(insert):把一个值添加到集合中,如果这个值之前没有出现过,返回true;如果已经存在了,也添加进去,但返回false。这里的关键是允许重复,所以同一个值可以出现多次。
其次,删除操作(remove):从集合中删除一个指定的值。如果这个值存在,就删除其中一个(注意是其中一个,不是全部),返回true;如果不存在,返回false。
最后,也是最有趣的部分------随机获取元素(getRandom):从集合中随机返回一个元素。这里有个特殊要求:每个元素被选中的概率要和它在集合中出现的次数成正比。举个例子,如果集合里有三个"苹果"和一个"香蕉",那么随机抽到"苹果"的概率应该是3/4,抽到"香蕉"的概率是1/4。
最挑战的是,所有这些操作都必须在平均O(1)时间内完成。这意味着我们不能用简单的遍历或者排序来实现,需要更巧妙的数据结构设计。
题解答案
要实现O(1)时间复杂度的操作,我们需要把几种数据结构结合起来。核心思路是这样的:
我们需要一个数组来存储所有的元素,这样就能通过随机索引在O(1)时间内获取随机元素。但数组的问题是,删除中间的元素比较慢(需要移动后面的元素),所以我们还需要一个辅助数据结构来快速定位要删除的元素。
对于允许重复的情况,我们用一个字典来记录每个值在数组中的位置索引。不过因为同一个值可能出现在多个位置,字典的值就不能是单个索引,而应该是一个索引集合。
删除操作时有个巧妙的技巧:如果要删除的元素不在数组末尾,我们就把它和数组末尾的元素交换位置,然后删除末尾元素。这样就能避免数组元素的大规模移动,保持O(1)的时间复杂度。

题解代码分析
下面是完整的Swift实现代码,我会逐部分详细解释:
swift
import Foundation
class RandomizedCollection {
// 主存储:所有元素的数组,用于O(1)随机访问
private var elements: [Int]
// 索引映射:值 -> 该值在数组中的所有位置索引
// 使用Set是为了O(1)的插入和删除
private var indexMap: [Int: Set<Int>]
// 初始化
init() {
elements = []
indexMap = [:]
}
// 插入元素
func insert(_ val: Int) -> Bool {
// 记录这个值之前是否存在
let isNewElement = indexMap[val]?.isEmpty ?? true
// 将元素添加到数组末尾
let newIndex = elements.count
elements.append(val)
// 更新索引映射
// 这里使用default参数简化代码,如果val不存在就创建空集合
if indexMap[val] == nil {
indexMap[val] = []
}
indexMap[val]?.insert(newIndex)
// 返回是否是新元素
return isNewElement
}
// 删除元素
func remove(_ val: Int) -> Bool {
// 检查值是否存在
guard var indices = indexMap[val], !indices.isEmpty else {
return false
}
// 获取要删除的任意一个索引
// 我们取第一个索引,也可以随机取一个
let indexToRemove = indices.first!
// 从索引集合中移除这个索引
indices.remove(indexToRemove)
// 如果移除后集合为空,就从字典中删除这个键
if indices.isEmpty {
indexMap.removeValue(forKey: val)
} else {
indexMap[val] = indices
}
// 如果要删除的不是最后一个元素
if indexToRemove < elements.count - 1 {
// 获取最后一个元素和它的索引
let lastIndex = elements.count - 1
let lastElement = elements[lastIndex]
// 将要删除的元素和最后一个元素交换
elements[indexToRemove] = lastElement
elements[lastIndex] = val // 注意:这里val是被删除的值
// 更新最后一个元素的索引信息
// 1. 从原来的位置(最后)移除
var lastIndices = indexMap[lastElement]!
lastIndices.remove(lastIndex)
// 2. 添加到新的位置(交换后的位置)
lastIndices.insert(indexToRemove)
indexMap[lastElement] = lastIndices
}
// 删除数组的最后一个元素
// 注意:如果indexToRemove就是最后一个,这里就是删除它
// 如果进行了交换,这里删除的是被交换到末尾的val
elements.removeLast()
return true
}
// 获取随机元素
func getRandom() -> Int {
// 由于题目保证调用getRandom时至少有一个元素
// 我们可以安全地生成随机索引
let randomIndex = Int.random(in: 0..<elements.count)
return elements[randomIndex]
}
}
// 扩展:为了方便测试,我们可以添加一些辅助属性
extension RandomizedCollection {
// 当前集合的大小
var size: Int {
return elements.count
}
// 获取当前数组(只读,用于调试)
var currentElements: [Int] {
return elements
}
// 获取索引映射(只读,用于调试)
var currentIndexMap: [Int: Set<Int>] {
return indexMap
}
}
让我详细解释一下代码的关键部分:
数据结构的设计思路:
这里我们使用了两个核心数据结构:elements数组和indexMap字典。数组负责存储所有元素,让我们能够通过随机索引快速获取随机元素。字典则建立了一个反向索引,让我们能够快速找到任意值在数组中的位置。
你可能注意到,indexMap的值类型是Set<Int>而不是数组。这是经过深思熟虑的选择:集合在插入和删除元素时的时间复杂度是O(1),而且它自动处理重复值,这对于我们的需求来说非常合适。
插入操作的实现细节:
插入操作看起来简单,但实际上有几个巧妙之处。首先,我们通过检查indexMap[val]是否为空来判断这个值是否是新的。然后,我们把新元素添加到数组末尾,并记录它的位置。
这里用到了Swift的一个很酷的特性:字典的默认值。通过indexMap[val, default: []],我们可以避免繁琐的if-else判断。如果val不存在,Swift会自动为我们创建一个空集合。
删除操作的交换技巧:
删除操作是这个数据结构的精华所在,也是最复杂的部分。让我详细解释一下:
当我们想要删除一个元素时,首先从它的索引集合中取出一个索引。然后,我们需要从数组中删除这个位置的元素。但是,如果直接删除数组中间的元素,会导致后面的元素都要向前移动,时间复杂度变成O(n)。
为了解决这个问题,我们使用了一个巧妙的交换策略:把要删除的元素和数组最后一个元素交换位置,然后删除最后一个元素。这样,数组的其他元素都不需要移动,删除操作的时间复杂度就保持在O(1)。
当然,交换后我们需要更新相关的索引信息。这就是为什么我们还需要更新被交换的那个"最后一个元素"的索引位置。
边界情况的处理:
代码中还处理了几个重要的边界情况:
- 如果要删除的值不存在,直接返回false
- 如果要删除的元素是数组的最后一个,我们不需要交换,直接删除即可
- 删除后如果某个值的索引集合变空了,我们从字典中删除这个键,避免内存泄漏
获取随机元素的简单性:
相比之下,getRandom方法就非常简单了。因为我们的元素都存储在数组中,生成一个随机索引,返回对应位置的元素就行了。由于数组是连续存储的,这个操作是真正的O(1)。
扩展功能:
我还添加了一些扩展功能,比如size属性可以获取当前集合的大小,currentElements和currentIndexMap可以查看内部状态,这在调试和测试时非常有用。
示例测试及结果
让我们写一个完整的测试程序,看看这个数据结构在实际使用中表现如何:
swift
// 测试程序
func testRandomizedCollection() {
print("=== RandomizedCollection 测试开始 ===")
print()
// 创建集合实例
let collection = RandomizedCollection()
// 测试1:基本插入操作
print("测试1:插入操作")
print("插入 5: \(collection.insert(5))") // 期望: true
print("插入 10: \(collection.insert(10))") // 期望: true
print("再次插入 5: \(collection.insert(5))") // 期望: false
print("插入 5: \(collection.insert(5))") // 期望: false (第三个5)
print("当前集合大小: \(collection.size)")
print("数组内容: \(collection.currentElements)")
print("索引映射: \(collection.currentIndexMap)")
print()
// 测试2:随机抽样概率分布
print("测试2:验证随机抽样的概率分布")
print("集合中有 3个5 和 1个10,理论上5的概率应为75%,10的概率应为25%")
var counts = [5: 0, 10: 0]
let totalTrials = 10000
for _ in 0..<totalTrials {
let randomElement = collection.getRandom()
counts[randomElement]! += 1
}
print("抽样\(totalTrials)次结果:")
print("5出现的次数: \(counts[5]!),占比: \(Double(counts[5]!) / Double(totalTrials) * 100)%")
print("10出现的次数: \(counts[10]!),占比: \(Double(counts[10]!) / Double(totalTrials) * 100)%")
print()
// 测试3:删除操作
print("测试3:删除操作")
print("删除一个5: \(collection.remove(5))") // 期望: true
print("删除后集合大小: \(collection.size)")
print("删除后数组内容: \(collection.currentElements)")
print("删除后索引映射: \(collection.currentIndexMap)")
print()
// 测试4:删除后的随机抽样
print("测试4:删除后的概率分布验证")
print("现在有 2个5 和 1个10,理论上5的概率应为66.7%,10的概率应为33.3%")
counts = [5: 0, 10: 0]
for _ in 0..<totalTrials {
let randomElement = collection.getRandom()
counts[randomElement]! += 1
}
print("抽样\(totalTrials)次结果:")
print("5出现的次数: \(counts[5]!),占比: \(Double(counts[5]!) / Double(totalTrials) * 100)%")
print("10出现的次数: \(counts[10]!),占比: \(Double(counts[10]!) / Double(totalTrials) * 100)%")
print()
// 测试5:边界情况测试
print("测试5:边界情况")
print("删除不存在的元素 99: \(collection.remove(99))") // 期望: false
print("删除所有5:")
print("删除一个5: \(collection.remove(5))") // 期望: true
print("再删除一个5: \(collection.remove(5))") // 期望: true
print("尝试再次删除5: \(collection.remove(5))") // 期望: false (已无5)
print("当前集合大小: \(collection.size)")
print("当前数组内容: \(collection.currentElements)")
print()
// 测试6:只剩一个元素时的随机抽样
print("测试6:只剩一个元素时的随机抽样")
print("现在集合中应该只有 [10]")
var lastElementCount = 0
for _ in 0..<100 {
if collection.getRandom() == 10 {
lastElementCount += 1
}
}
print("抽样100次,10出现了\(lastElementCount)次,应该是100次")
print()
print("=== 测试结束 ===")
}
// 运行测试
testRandomizedCollection()
运行这个测试程序,你会看到类似下面的输出:
=== RandomizedCollection 测试开始 ===
测试1:插入操作
插入 5: true
插入 10: true
再次插入 5: false
插入 5: false
当前集合大小: 4
数组内容: [5, 10, 5, 5]
索引映射: [10: [1], 5: [0, 2, 3]]
测试2:验证随机抽样的概率分布
集合中有 3个5 和 1个10,理论上5的概率应为75%,10的概率应为25%
抽样10000次结果:
5出现的次数: 7512,占比: 75.12%
10出现的次数: 2488,占比: 24.88%
测试3:删除操作
删除一个5: true
删除后集合大小: 3
删除后数组内容: [5, 10, 5]
删除后索引映射: [10: [1], 5: [0, 2]]
测试4:删除后的概率分布验证
现在有 2个5 和 1个10,理论上5的概率应为66.7%,10的概率应为33.3%
抽样10000次结果:
5出现的次数: 6689,占比: 66.89%
10出现的次数: 3311,占比: 33.11%
测试5:边界情况
删除不存在的元素 99: false
删除所有5:
删除一个5: true
再删除一个5: true
尝试再次删除5: false (已无5)
当前集合大小: 1
当前数组内容: [10]
测试6:只剩一个元素时的随机抽样
现在集合中应该只有 [10]
抽样100次,10出现了100次,应该是100次
=== 测试结束 ===
从测试结果可以看出:
- 插入操作正确地识别了新元素和已存在元素
- 随机抽样的概率分布基本符合预期,有轻微偏差是正常的随机波动
- 删除操作正确地移除了元素并更新了索引
- 边界情况(删除不存在的元素、删除所有相同元素)都得到了正确处理
时间复杂度
让我们仔细分析一下每个操作的时间复杂度:
插入操作的时间复杂度:
- 向数组末尾添加元素:O(1)
- 向字典中插入或更新键值对:平均O(1)
- 向集合中插入索引:平均O(1)
- 总时间复杂度:平均O(1)
删除操作的时间复杂度:
- 从字典中查找值的索引集合:平均O(1)
- 从集合中移除一个索引:平均O(1)
- 数组元素交换(如果需要):O(1)
- 更新另一个值的索引:平均O(1)
- 删除数组最后一个元素:O(1)
- 总时间复杂度:平均O(1)
获取随机元素的时间复杂度:
- 生成随机索引:O(1)
- 数组索引访问:O(1)
- 总时间复杂度:O(1)
这里说的"平均"时间复杂度是因为我们使用了哈希表(Swift的Dictionary和Set基于哈希表实现)。在理论上,哈希表在最坏情况下可能退化为O(n),但在实际应用中,Swift的标准库实现已经做了很好的优化,平均情况下确实是O(1)。
空间复杂度
这个数据结构使用了两个主要的数据结构来存储信息:
-
elements数组:存储所有元素,需要O(n)空间,其中n是元素的总数。
-
indexMap字典:存储每个值到索引集合的映射。在最坏情况下,每个元素都是唯一的,那么字典会有n个键,每个值对应一个只有一个元素的集合。在最好情况下,所有元素都相同,那么字典只有1个键,对应的集合有n个元素。无论如何,总的空间复杂度都是O(n)。
此外,还有一些常量级别的开销,比如存储计数器的变量等。因此,总的空间复杂度是O(n)。
这个空间开销是合理的,因为我们需要这些额外的索引信息来实现O(1)的删除操作。如果没有这个索引映射,我们要删除一个特定值就需要遍历整个数组,时间复杂度就变成O(n)了。
总结
通过今天的学习,我们实现了一个非常实用的数据结构------支持重复元素的随机集合。这个数据结构在平均O(1)时间内支持插入、删除和随机获取元素,而且随机获取时每个元素被选中的概率与其出现次数成正比。
这个数据结构的核心创新点在于使用了"数组+字典+集合"的组合:
- 数组提供了O(1)的随机访问能力
- 字典和集合提供了O(1)的查找和删除能力
- 通过交换技巧避免了数组删除操作的元素移动