在Swift中实现允许重复的O(1)随机集合


文章目录

摘要

今天我想和大家分享一个在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)。

当然,交换后我们需要更新相关的索引信息。这就是为什么我们还需要更新被交换的那个"最后一个元素"的索引位置。

边界情况的处理

代码中还处理了几个重要的边界情况:

  1. 如果要删除的值不存在,直接返回false
  2. 如果要删除的元素是数组的最后一个,我们不需要交换,直接删除即可
  3. 删除后如果某个值的索引集合变空了,我们从字典中删除这个键,避免内存泄漏

获取随机元素的简单性

相比之下,getRandom方法就非常简单了。因为我们的元素都存储在数组中,生成一个随机索引,返回对应位置的元素就行了。由于数组是连续存储的,这个操作是真正的O(1)。

扩展功能

我还添加了一些扩展功能,比如size属性可以获取当前集合的大小,currentElementscurrentIndexMap可以查看内部状态,这在调试和测试时非常有用。

示例测试及结果

让我们写一个完整的测试程序,看看这个数据结构在实际使用中表现如何:

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次

=== 测试结束 ===

从测试结果可以看出:

  1. 插入操作正确地识别了新元素和已存在元素
  2. 随机抽样的概率分布基本符合预期,有轻微偏差是正常的随机波动
  3. 删除操作正确地移除了元素并更新了索引
  4. 边界情况(删除不存在的元素、删除所有相同元素)都得到了正确处理

时间复杂度

让我们仔细分析一下每个操作的时间复杂度:

插入操作的时间复杂度:

  • 向数组末尾添加元素: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)。

空间复杂度

这个数据结构使用了两个主要的数据结构来存储信息:

  1. elements数组:存储所有元素,需要O(n)空间,其中n是元素的总数。

  2. indexMap字典:存储每个值到索引集合的映射。在最坏情况下,每个元素都是唯一的,那么字典会有n个键,每个值对应一个只有一个元素的集合。在最好情况下,所有元素都相同,那么字典只有1个键,对应的集合有n个元素。无论如何,总的空间复杂度都是O(n)。

此外,还有一些常量级别的开销,比如存储计数器的变量等。因此,总的空间复杂度是O(n)。

这个空间开销是合理的,因为我们需要这些额外的索引信息来实现O(1)的删除操作。如果没有这个索引映射,我们要删除一个特定值就需要遍历整个数组,时间复杂度就变成O(n)了。

总结

通过今天的学习,我们实现了一个非常实用的数据结构------支持重复元素的随机集合。这个数据结构在平均O(1)时间内支持插入、删除和随机获取元素,而且随机获取时每个元素被选中的概率与其出现次数成正比。

这个数据结构的核心创新点在于使用了"数组+字典+集合"的组合:

  • 数组提供了O(1)的随机访问能力
  • 字典和集合提供了O(1)的查找和删除能力
  • 通过交换技巧避免了数组删除操作的元素移动
相关推荐
Filotimo_1 小时前
在java后端开发中,LEFT JOIN的用法
java·开发语言·windows
承渊政道1 小时前
C++学习之旅【C++Vector类介绍—入门指南与核心概念解析】
c语言·开发语言·c++·学习·visual studio
2301_797312261 小时前
学习Java43天
java·开发语言
冰暮流星2 小时前
javascript之do-while循环
开发语言·javascript·ecmascript
2501_944424123 小时前
Flutter for OpenHarmony游戏集合App实战之连连看路径连线
android·开发语言·前端·javascript·flutter·游戏·php
C系语言3 小时前
python用pip生成requirements.txt
开发语言·python·pip
燃于AC之乐4 小时前
深入解剖STL Vector:从底层原理到核心接口的灵活运用
开发语言·c++·迭代器·stl·vector·源码分析·底层原理
星火开发设计10 小时前
C++ 数组:一维数组的定义、遍历与常见操作
java·开发语言·数据结构·c++·学习·数组·知识
TTGGGFF11 小时前
控制系统建模仿真(一):掌握控制系统设计的 MAD 流程与 MATLAB 基础运算
开发语言·matlab