LeetCode 382 链表随机节点


文章目录

摘要

这道题其实挺有意思的,它要求我们从链表中随机选择一个节点,并返回该节点的值。每个节点被选中的概率要相等。听起来简单,但实际做起来还是需要一些技巧的。如果链表长度已知,我们可以先遍历一遍得到长度,然后随机选择一个索引。但如果链表长度未知,或者链表非常大,就需要用到水塘抽样算法了。

这道题的核心在于如何在不知道链表长度的情况下,保证每个节点被选中的概率相等。今天我们就用 Swift 来搞定这道题,顺便聊聊水塘抽样算法在实际开发中的应用场景。

描述

题目要求是这样的:给你一个单链表,随机选择链表的一个节点,并返回相应的节点值。每个节点被选中的概率一样。

实现 Solution 类:

  1. Solution(ListNode head):使用链表头节点初始化对象
  2. 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() 方法:

  1. 随机选择一个索引(0 到 length-1)
  2. 从链表头开始遍历到对应位置
  3. 返回该节点的值

时间复杂度是 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. 遍历到节点 1

    • count = 1
    • 随机数范围:[0, 1),即 {0}
    • 如果随机数是 0,选择节点 1
    • 节点 1 被选中的概率:1/1 = 1
    • result = 1
  2. 遍历到节点 2

    • count = 2
    • 随机数范围:[0, 2),即 {0, 1}
    • 如果随机数是 0,选择节点 2,替换之前的 result
    • 节点 2 被选中的概率:1/2
    • 节点 1 保持被选中的概率:1 × (1 - 1/2) = 1/2
    • result 可能是 1 或 2
  3. 遍历到节点 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. 基础方法:适用于链表长度已知的情况,实现简单但需要预先计算长度
  2. 水塘抽样算法:适用于链表长度未知或非常大的情况,只需要遍历一次链表
  3. 概率保证:水塘抽样算法能保证每个节点被选中的概率相等(1/n)
  4. 空间复杂度:两种方法都是 O(1),满足进阶要求
  5. 实际应用:水塘抽样算法在处理大数据流、日志采样等场景中非常有用

选择建议:

  • 如果链表长度已知且不会变化,使用基础方法
  • 如果链表长度未知或非常大,使用水塘抽样算法
  • 如果需要多次调用 getRandom() 且链表很长,可以考虑缓存长度(基础方法)或优化水塘抽样算法
相关推荐
老鼠只爱大米2 小时前
LeetCode经典算法面试题 #19:删除链表的倒数第N个结点(双指针、栈辅助法等多种实现方案详细解析)
算法·leetcode·链表·双指针·删除链表节点·一趟扫描
chao_7892 小时前
跳跃游戏系列【贪心算法】
python·算法·游戏·贪心算法·贪心
波波0072 小时前
每日一题:.NET 中什么是 LOH(大对象堆)?为什么频繁使用大数组或大字符串可能导致性能问题?如何优化?
java·jvm·算法
独自破碎E2 小时前
动态规划-正则表达式匹配
算法·正则表达式·动态规划
Gofarlic_OMS2 小时前
Fluent许可证使用合规性报告自动化生成系统
java·大数据·运维·人工智能·算法·matlab·自动化
漫随流水2 小时前
leetcode回溯算法(131.分割回文串)
数据结构·算法·leetcode·回溯算法
我家大宝最可爱2 小时前
强化学习基础-重要性采样
算法·机器学习·概率论
Remember_9932 小时前
文件系统与IO操作:深入解析与Java实践
java·开发语言·数据结构·ide·python·算法
大江东去浪淘尽千古风流人物2 小时前
【DSP】DSP核心组件 SM算法部署
算法