

文章目录
摘要
这道题其实挺有意思的,它要求我们从链表中随机选择一个节点,并返回该节点的值。每个节点被选中的概率要相等。听起来简单,但实际做起来还是需要一些技巧的。如果链表长度已知,我们可以先遍历一遍得到长度,然后随机选择一个索引。但如果链表长度未知,或者链表非常大,就需要用到水塘抽样算法了。
这道题的核心在于如何在不知道链表长度的情况下,保证每个节点被选中的概率相等。今天我们就用 Swift 来搞定这道题,顺便聊聊水塘抽样算法在实际开发中的应用场景。

描述
题目要求是这样的:给你一个单链表,随机选择链表的一个节点,并返回相应的节点值。每个节点被选中的概率一样。
实现 Solution 类:
Solution(ListNode head):使用链表头节点初始化对象int getRandom():从链表中随机选择一个节点并返回该节点的值。链表中所有节点被选中的概率相等
示例:
输入
["Solution", "getRandom", "getRandom", "getRandom", "getRandom", "getRandom"]
[[[1, 2, 3]], [], [], [], [], []]
输出
[null, 1, 3, 2, 2, 3]
解释
Solution solution = new Solution([1, 2, 3]);
solution.getRandom(); // 返回 1
solution.getRandom(); // 返回 3
solution.getRandom(); // 返回 2
solution.getRandom(); // 返回 2
solution.getRandom(); // 返回 3
// getRandom() 方法应随机返回 1、2、3中的一个,每个元素被返回的概率相等。
提示:
- 链表中的节点数在范围
[1, 10^4]内 -10^4 <= Node.val <= 10^4- 至多调用
getRandom方法10^4次
进阶:
- 如果链表非常大且长度未知,该怎么处理?
- 你能否在不使用额外空间的情况下解决此问题?
这道题的核心思路是什么呢?如果链表长度已知,我们可以先遍历一遍得到长度,然后随机选择一个索引,再遍历到对应位置。但如果链表长度未知,或者链表非常大,就需要用到水塘抽样算法(Reservoir Sampling),它可以在只遍历一次链表的情况下,保证每个节点被选中的概率相等。

题解答案
下面是完整的 Swift 解决方案,包含两种方法:基础方法和进阶方法。
基础方法:已知链表长度
如果链表长度已知或可以预先计算,我们可以用这种方法:
swift
// 链表节点定义
public class ListNode {
public var val: Int
public var next: ListNode?
public init(_ val: Int) {
self.val = val
self.next = nil
}
}
class Solution {
private var head: ListNode?
private var length: Int = 0
init(_ head: ListNode?) {
self.head = head
// 计算链表长度
var current = head
while current != nil {
length += 1
current = current?.next
}
}
func getRandom() -> Int {
// 随机选择一个索引(0 到 length-1)
let randomIndex = Int.random(in: 0..<length)
// 遍历到对应位置
var current = head
for _ in 0..<randomIndex {
current = current?.next
}
return current!.val
}
}
进阶方法:水塘抽样算法(推荐)
如果链表长度未知或非常大,使用水塘抽样算法:
swift
class Solution {
private var head: ListNode?
init(_ head: ListNode?) {
self.head = head
}
func getRandom() -> Int {
var current = head
var result = 0
var count = 0
// 遍历链表
while current != nil {
count += 1
// 以 1/count 的概率选择当前节点
if Int.random(in: 0..<count) == 0 {
result = current!.val
}
current = current?.next
}
return result
}
}
题解代码分析
让我们一步步分析这两种解决方案:
基础方法分析
基础方法适用于链表长度已知或可以预先计算的情况:
swift
init(_ head: ListNode?) {
self.head = head
// 计算链表长度
var current = head
while current != nil {
length += 1
current = current?.next
}
}
初始化时,我们遍历一遍链表来计算长度。时间复杂度是 O(n),其中 n 是链表的长度。
swift
func getRandom() -> Int {
let randomIndex = Int.random(in: 0..<length)
var current = head
for _ in 0..<randomIndex {
current = current?.next
}
return current!.val
}
getRandom() 方法:
- 随机选择一个索引(0 到 length-1)
- 从链表头开始遍历到对应位置
- 返回该节点的值
时间复杂度是 O(n),因为最坏情况下需要遍历整个链表。空间复杂度是 O(1),只使用了常数空间。
优点:
- 实现简单,容易理解
- 每个节点被选中的概率确实相等(1/n)
缺点:
- 需要预先知道链表长度
- 每次
getRandom()都需要遍历链表,如果链表很长,性能不好 - 如果链表长度未知,无法使用
进阶方法:水塘抽样算法
水塘抽样算法可以在不知道链表长度的情况下,保证每个节点被选中的概率相等:
swift
func getRandom() -> Int {
var current = head
var result = 0
var count = 0
// 遍历链表
while current != nil {
count += 1
// 以 1/count 的概率选择当前节点
if Int.random(in: 0..<count) == 0 {
result = current!.val
}
current = current?.next
}
return result
}
算法原理:
水塘抽样算法的核心思想是:当我们遍历到第 i 个节点时,以 1/i 的概率选择它,替换之前选中的节点。
让我们看看为什么这样能保证每个节点被选中的概率相等:
- 第 1 个节点:被选中的概率是 1/1 = 1
- 第 2 个节点:被选中的概率是 1/2,第 1 个节点保持被选中的概率是 1 × (1 - 1/2) = 1/2
- 第 3 个节点:被选中的概率是 1/3,之前节点保持被选中的概率是 1/2 × (1 - 1/3) = 1/3
- 第 i 个节点:被选中的概率是 1/i,之前节点保持被选中的概率是 1/(i-1) × (1 - 1/i) = 1/i
所以,每个节点最终被选中的概率都是 1/n,其中 n 是链表的长度。
优点:
- 不需要预先知道链表长度
- 只需要遍历一次链表
- 空间复杂度是 O(1),不需要额外空间
- 适用于链表非常大或长度未知的情况
缺点:
- 每次调用
getRandom()都需要遍历整个链表 - 如果链表很长,性能可能不如基础方法(如果长度已知)
为什么水塘抽样算法能工作?
让我们用一个例子来说明。假设链表有 3 个节点:[1, 2, 3]。
遍历过程:
-
遍历到节点 1:
- count = 1
- 随机数范围:[0, 1),即 {0}
- 如果随机数是 0,选择节点 1
- 节点 1 被选中的概率:1/1 = 1
- result = 1
-
遍历到节点 2:
- count = 2
- 随机数范围:[0, 2),即 {0, 1}
- 如果随机数是 0,选择节点 2,替换之前的 result
- 节点 2 被选中的概率:1/2
- 节点 1 保持被选中的概率:1 × (1 - 1/2) = 1/2
- result 可能是 1 或 2
-
遍历到节点 3:
- count = 3
- 随机数范围:[0, 3),即 {0, 1, 2}
- 如果随机数是 0,选择节点 3,替换之前的 result
- 节点 3 被选中的概率:1/3
- 之前节点保持被选中的概率:1/2 × (1 - 1/3) = 1/3
- result 可能是 1、2 或 3
最终概率:
- 节点 1 被选中的概率:1/3
- 节点 2 被选中的概率:1/3
- 节点 3 被选中的概率:1/3
每个节点被选中的概率都相等!
示例测试及结果
让我们用几个例子来测试一下这个解决方案:
示例 1:基础测试
swift
// 创建链表 [1, 2, 3]
let node1 = ListNode(1)
let node2 = ListNode(2)
let node3 = ListNode(3)
node1.next = node2
node2.next = node3
let solution = Solution(node1)
// 调用多次 getRandom()
for i in 1...10 {
print("第 \(i) 次 getRandom(): \(solution.getRandom())")
}
可能的输出:
第 1 次 getRandom(): 2
第 2 次 getRandom(): 3
第 3 次 getRandom(): 1
第 4 次 getRandom(): 2
第 5 次 getRandom(): 3
第 6 次 getRandom(): 1
第 7 次 getRandom(): 3
第 8 次 getRandom(): 1
第 9 次 getRandom(): 2
第 10 次 getRandom(): 2
每次调用都会返回 1、2 或 3 中的一个,每个值被返回的概率应该大致相等。
示例 2:概率验证
swift
let node1 = ListNode(1)
let node2 = ListNode(2)
let node3 = ListNode(3)
node1.next = node2
node2.next = node3
let solution = Solution(node1)
// 统计每个值被选中的次数
var count: [Int: Int] = [:]
let totalCalls = 10000
for _ in 0..<totalCalls {
let value = solution.getRandom()
count[value, default: 0] += 1
}
print("调用 \(totalCalls) 次的结果分布:")
for (value, times) in count.sorted(by: { $0.key < $1.key }) {
let percentage = Double(times) / Double(totalCalls) * 100
print(" 值 \(value): \(times) 次 (\(String(format: "%.2f", percentage))%)")
}
可能的输出:
调用 10000 次的结果分布:
值 1: 3334 次 (33.34%)
值 2: 3321 次 (33.21%)
值 3: 3345 次 (33.45%)
可以看到,每个值被选中的概率都接近 33.33%,说明算法是正确的。
示例 3:单节点链表
swift
let node1 = ListNode(42)
let solution = Solution(node1)
print("单节点链表 getRandom(): \(solution.getRandom())") // 总是返回 42
对于单节点链表,getRandom() 总是返回该节点的值。
示例 4:长链表测试
swift
// 创建包含 100 个节点的链表
var head: ListNode? = ListNode(1)
var current = head
for i in 2...100 {
current?.next = ListNode(i)
current = current?.next
}
let solution = Solution(head!)
// 测试多次调用
var results: [Int] = []
for _ in 0..<100 {
results.append(solution.getRandom())
}
print("长链表测试,前 20 个结果: \(Array(results.prefix(20)))")
这个测试展示了算法在处理长链表时的表现。
时间复杂度
让我们分析一下两种方法的时间复杂度:
基础方法
- 初始化:O(n),需要遍历链表计算长度
- getRandom():O(n),最坏情况下需要遍历整个链表
如果调用 getRandom() k 次,总时间复杂度是 O(n + k×n) = O(k×n)。
水塘抽样算法
- 初始化:O(1),只需要保存头节点
- getRandom():O(n),需要遍历整个链表
如果调用 getRandom() k 次,总时间复杂度是 O(k×n)。
虽然两种方法的时间复杂度相同,但水塘抽样算法的优势在于:
- 不需要预先知道链表长度
- 初始化时间更短(O(1) vs O(n))
- 适用于链表长度未知或非常大的情况
空间复杂度
让我们分析一下两种方法的空间复杂度:
基础方法
- 存储头节点:O(1)
- 存储长度:O(1)
- 总空间复杂度:O(1)
水塘抽样算法
- 存储头节点:O(1)
- 遍历时的临时变量:O(1)
- 总空间复杂度:O(1)
两种方法的空间复杂度都是 O(1),满足进阶要求(不使用额外空间)。
实际应用场景
水塘抽样算法在实际开发中应用非常广泛,特别是在处理大数据流时:
场景一:日志采样
在处理大量日志时,我们可能只需要采样一部分进行分析:
swift
class LogSampler {
private var logStream: [String]
private var sampleSize: Int
init(logStream: [String], sampleSize: Int) {
self.logStream = logStream
self.sampleSize = sampleSize
}
func sample() -> [String] {
var samples: [String] = []
for (index, log) in logStream.enumerated() {
if samples.count < sampleSize {
samples.append(log)
} else {
// 以 sampleSize/(index+1) 的概率替换
let randomIndex = Int.random(in: 0..<(index + 1))
if randomIndex < sampleSize {
samples[randomIndex] = log
}
}
}
return samples
}
}
场景二:数据流中的随机元素
在处理数据流时,我们可能需要随机选择一个元素:
swift
class StreamRandomSelector {
private var stream: [Int]
private var selectedValue: Int?
private var count: Int = 0
init(stream: [Int]) {
self.stream = stream
}
func process() -> Int? {
for value in stream {
count += 1
if Int.random(in: 0..<count) == 0 {
selectedValue = value
}
}
return selectedValue
}
}
场景三:在线算法中的随机选择
在一些在线算法中,我们需要在处理数据流的过程中随机选择元素:
swift
class OnlineRandomSelector<T> {
private var selected: T?
private var count: Int = 0
func add(_ element: T) {
count += 1
if Int.random(in: 0..<count) == 0 {
selected = element
}
}
func getRandom() -> T? {
return selected
}
}
场景四:分布式系统中的负载均衡
在分布式系统中,我们可能需要从多个服务器中随机选择一个:
swift
class ServerSelector {
private var servers: [String]
init(servers: [String]) {
self.servers = servers
}
func selectRandom() -> String {
var selected = servers[0]
for i in 1..<servers.count {
if Int.random(in: 0..<(i + 1)) == 0 {
selected = servers[i]
}
}
return selected
}
}
总结
这道题虽然看起来简单,但实际上涉及了一个非常重要的算法:水塘抽样算法。
关键点总结:
- 基础方法:适用于链表长度已知的情况,实现简单但需要预先计算长度
- 水塘抽样算法:适用于链表长度未知或非常大的情况,只需要遍历一次链表
- 概率保证:水塘抽样算法能保证每个节点被选中的概率相等(1/n)
- 空间复杂度:两种方法都是 O(1),满足进阶要求
- 实际应用:水塘抽样算法在处理大数据流、日志采样等场景中非常有用
选择建议:
- 如果链表长度已知且不会变化,使用基础方法
- 如果链表长度未知或非常大,使用水塘抽样算法
- 如果需要多次调用
getRandom()且链表很长,可以考虑缓存长度(基础方法)或优化水塘抽样算法