文章目录
- 摘要
- 描述
- 题解答案
- 题解代码分析
- 示例测试及结果
- 时间复杂度
- 空间复杂度
- 总结
摘要
今天我想和大家分享一个在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实现代码,我会逐部分详细解释:
importFoundationclassRandomizedCollection{// 主存储:所有元素的数组,用于O(1)随机访问privatevarelements:[Int]// 索引映射:值 -> 该值在数组中的所有位置索引// 使用Set是为了O(1)的插入和删除privatevarindexMap:[Int:Set<Int>]// 初始化init(){elements=[]indexMap=[:]}// 插入元素funcinsert(_val:Int)->Bool{// 记录这个值之前是否存在letisNewElement=indexMap[val]?.isEmpty??true// 将元素添加到数组末尾letnewIndex=elements.count elements.append(val)// 更新索引映射// 这里使用default参数简化代码,如果val不存在就创建空集合ifindexMap[val]==nil{indexMap[val]=[]}indexMap[val]?.insert(newIndex)// 返回是否是新元素returnisNewElement}// 删除元素funcremove(_val:Int)->Bool{// 检查值是否存在guardvarindices=indexMap[val],!indices.isEmptyelse{returnfalse}// 获取要删除的任意一个索引// 我们取第一个索引,也可以随机取一个letindexToRemove=indices.first!// 从索引集合中移除这个索引indices.remove(indexToRemove)// 如果移除后集合为空,就从字典中删除这个键ifindices.isEmpty{indexMap.removeValue(forKey:val)}else{indexMap[val]=indices}// 如果要删除的不是最后一个元素ifindexToRemove<elements.count-1{// 获取最后一个元素和它的索引letlastIndex=elements.count-1letlastElement=elements[lastIndex]// 将要删除的元素和最后一个元素交换elements[indexToRemove]=lastElement elements[lastIndex]=val// 注意:这里val是被删除的值// 更新最后一个元素的索引信息// 1. 从原来的位置(最后)移除varlastIndices=indexMap[lastElement]!lastIndices.remove(lastIndex)// 2. 添加到新的位置(交换后的位置)lastIndices.insert(indexToRemove)indexMap[lastElement]=lastIndices}// 删除数组的最后一个元素// 注意:如果indexToRemove就是最后一个,这里就是删除它// 如果进行了交换,这里删除的是被交换到末尾的valelements.removeLast()returntrue}// 获取随机元素funcgetRandom()->Int{// 由于题目保证调用getRandom时至少有一个元素// 我们可以安全地生成随机索引letrandomIndex=Int.random(in:0..<elements.count)returnelements[randomIndex]}}// 扩展:为了方便测试,我们可以添加一些辅助属性extensionRandomizedCollection{// 当前集合的大小varsize:Int{returnelements.count}// 获取当前数组(只读,用于调试)varcurrentElements:[Int]{returnelements}// 获取索引映射(只读,用于调试)varcurrentIndexMap:[Int:Set<Int>]{returnindexMap}}让我详细解释一下代码的关键部分:
数据结构的设计思路:
这里我们使用了两个核心数据结构: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可以查看内部状态,这在调试和测试时非常有用。
示例测试及结果
让我们写一个完整的测试程序,看看这个数据结构在实际使用中表现如何:
// 测试程序functestRandomizedCollection(){print("=== RandomizedCollection 测试开始 ===")print()// 创建集合实例letcollection=RandomizedCollection()// 测试1:基本插入操作print("测试1:插入操作")print("插入 5:\(collection.insert(5))")// 期望: trueprint("插入 10:\(collection.insert(10))")// 期望: trueprint("再次插入 5:\(collection.insert(5))")// 期望: falseprint("插入 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%")varcounts=[5:0,10:0]lettotalTrials=10000for_in0..<totalTrials{letrandomElement=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))")// 期望: trueprint("删除后集合大小:\(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_in0..<totalTrials{letrandomElement=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))")// 期望: falseprint("删除所有5:")print("删除一个5:\(collection.remove(5))")// 期望: trueprint("再删除一个5:\(collection.remove(5))")// 期望: trueprint("尝试再次删除5:\(collection.remove(5))")// 期望: false (已无5)print("当前集合大小:\(collection.size)")print("当前数组内容:\(collection.currentElements)")print()// 测试6:只剩一个元素时的随机抽样print("测试6:只剩一个元素时的随机抽样")print("现在集合中应该只有 [10]")varlastElementCount=0for_in0..<100{ifcollection.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)的查找和删除能力
- 通过交换技巧避免了数组删除操作的元素移动