LeetCode 382. Linked List Random Node

@(LeetCode)

问题描述

给定一个单链表,返回链表中随机节点的值。每个节点必须有相同的几率被选中。

如果链表非常大,并且它的长度未知?如何不使用额外空间来解决这个问题?

栗 1:

链表: 1->2->3
通过 getRandom 方法返回随机节点值,即 1,2,3 中任意一个,且概率相同。

想看英文的戳这里。

解题思路

我的解法

这种方式其实与题意不太符合,需要遍历 2 次。

首先计算出链表的长度 N,然后通过 random 方法得到 [0, N-1] 之间的一个随机数 x,从头遍历 x+1 个节点即达到随机的节点。

var Solution = function(head) {
    this.head = head

    let length = 0
    while (head) {
        length += 1
        head = head.next
    }

    this.length = length
};

/**
 * Returns a random node's value.
 * @return {number}
 */
Solution.prototype.getRandom = function() {
    if (this.length > 0) {
        const random = Math.floor(Math.random() * this.length)

        let i = 0
        let node = this.head
        while (i < random) {
            node = node.next
            i += 1
        }

        return node.val
    }
};

其他解法

理论基础

因为链表长度未知,可以认为它是动态变化的。即在总数 n 变化时,仍保持选中一个数的概率为 1/n

即需满足如下条件:

  • 当有 k 个数,任意选择一个数的概率为 1/k
  • 当有 k+1 个数,任意选择一个数的概率为 1/(k+1),也就是之前已选中的数的概率也变为 1/(k+1)
  • 当有 k+i 个数,任意选择一个数的概率为 1/(k+i),也就是之前已选中的数的概率也变为 1/(k+i)

推导过程

我们可以试着从以下简单的场景来了解。

假设有一个数组,其长度是动态变化的。

  • 初始值为[1],显然选择一个数的概率为 1
  • 当数组变成 [1,2],选择一个数的概率为 1/2,那么选择 2 的概率为 1/2
  • 当数组变成 [1,2,3],显然选择 3 的概率为 1/3,那么仍选择 2 的概率为多少呢?根据上面的结论,要为 1/3 才对。

根据逻辑思维推导:「这一步仍选 2」 = 「这一步不选 3」 并且「上一步选 2」。

用数学公式表示为:p(这一步仍选 2) = p(上一步选 2) * p(这一步不选 3)

显然,p(这一步不选 3) = 1- p(这步选 3)

则得出: p(这一步仍选 2) = p(上一步选 2) * (1 - p(这步选 3))

代入数值得到:p(这一步仍选 2) = 1/2 * (1 - 1/3) = 1/3

因此,当数组长度变成 3 后,选中 2 的概率变为 1/3,满足结论。

同理,推演到 k 个数。在 k 个数选中一个数 x 的概率为 1/k,那么当有 k+i 个数后,仍选中 x 的概率为:

p = p(上一步选中 x) * p(这一步不选中 k+i) = p(上一步选中 x) * (1 - p(这步选中 k+i))

上一步选中 x ,即在 k+i-1 个数中选一个数,其概率为 1/(k+i-1),这是前提条件。

很显然,p(这步选中 k+i) = 1/(k+i)
那么可得出 p = 1/(k+i-1) * (1 - 1/(k+i)) = 1/(k+i),则保证概率也是相同的。

解题方法

那么知道上述结论后,该题的思路转变如下。

如果目前有 n 个节点,当新增加一个节点,只需要保证该节点被选中的概率为 1/(n+1) 即可。

举个栗子:
若初始数据为 [1],只能选 1
新增 2 后,数据变成 [1,2],那么需保证 2 被选中的概率为 1/2 即可;
新增 3 后,数据变成 [1,2,3],那么需保证 3 被选中的概率为 1/3

所以只要根据当前数组的长度 n 取一个随机值 r = [0, n-1],如果 r 刚好等于 0 或者是 [0, n-1] 中的任意一个数,则表示其概率为 1/n, 这时 x 被替换成新数据。

js 代码如下:

Solution.prototype.getRandom = function() {
    let count = 1
    let node = this.head
    let result

    while (node) {
        // 生成 [0, count-1] 的随机数
        const random = Math.floor(Math.random() * (count))

        // 替换节点
        if (random === 0) {
            result = node
        }

        count += 1

        node = node.next
    }

    return result.val
};

Reservoir Sampling

该题其实是 Reservoir Sampling 水塘抽样算法的简化版。该算法保证了数据总数在变化时,仍能等概率抽取数据。基本思想如下:

  • 首先从 n 个数中取 k 个数放入水池中,每个数被选中的概率为 k/n
  • 在处理第 n+1 个数时,已经在水池中的 k 个数被选中的概率变为 k/(n+1)
  • 在处理第 n+i 个数时,已经在水池中的 k 个数被选中的概率变为 k/(n+i)
推导过程

如果在 n 中选中了 k 个数分别为 [x, y, z, ...],每个数选中的概率为 k/n
那么当为 n+1 个数时,x 仍被选中的概率为多少呢?即上一步保留 x 且这一步也保留 x

那么数学公式如下:

p(x) = p(上一步选择了 x) * p(这步「第 n+1 个数不替换 x」)
p(x) = p(上一步选择了 x) * p(1 -「这步选第 n+1 个数」且「替换 x」)

其中,已知条件如下:

p(上一步选择了 x) = k/n
p(这步选第 n+1 个数) = k/(n+1)

但「第 n+1 个数替换掉 x 」的概率是多少呢?因为只选 k 个数,在 k 个数中替换掉 1 个,其概率为 1/k

则我们可以得到:p(x)= k/n * (1 - k/(n+1) * 1/k) = k/(n+1),满足结论。

k = 1 则为题目中的问题。

重点

所以最为重要的是保证第 n+1 个数被选中的概率为 k/(n+1)

这里有两种方式保证概率,是等效的。

  1. 生成 0~1 之间的随机数,如果其值 < k/(n+1),则选中第 n+1 个数放入水池,替换掉其中的一个数。
  2. 生成 [0, n+1) 之间的随机数,如果其值 < k,则选中第 n+1 个数放入水池,替换掉其中的一个数。

其算法代码也比较简单:

// stream 表示数据流
// reservoir 用来存选中的数据
// 先取 k 个数放入水池
for ( int i = 0; i < k; i++)
    reservoir[i] = stream[i];
    
for (i = k; stream != null; i++) {
    p = random(0, i);
    if (p < k) reservoir[p] = stream[i];
}

以上就是整个的分析过程。

参考链接:
[1] https://www.cnblogs.com/strugglion/p/6424874.html
[2] https://gregable.com/2007/10/reservoir-sampling.html
[3] https://leetcode.com/problems/linked-list-random-node/discuss/85659/Brief-explanation-for-Reservoir-Sampling

你可能感兴趣的:(LeetCode 382. Linked List Random Node)