在 n log n 时间内洗牌链表的算法

Posted

技术标签:

【中文标题】在 n log n 时间内洗牌链表的算法【英文标题】:Algorithm for Shuffling a Linked List in n log n time 【发布时间】:2012-08-23 10:55:21 【问题描述】:

我正在尝试使用分治算法对链表进行洗牌,该算法在线性 (n log n) 时间和对数 (log n) 额外空间内随机洗牌。

我知道我可以进行类似于可用于简单值数组的 Knuth shuffle,但我不确定如何通过分而治之的方式做到这一点。我的意思是,我实际上在划分什么?我是否只是划分到列表中的每个单独节点,然后使用一些随机值将列表随机组合在一起?

还是给每个节点一个随机数,然后根据随机数对节点进行归并排序?

【问题讨论】:

注意:这只是书中的一个练习,它实际上没有评分,也没有给出任何类型的学分。这实际上是为了丰富我自己的发展。 你不能给每个节点一个 O(log n) 额外存储空间的数字;这为每个数字单独占用 O(1) 空间,因此总共占用 O(n) 存储空间。 但是如果你有 O(n) 存储,只需将它复制到一个数组中,然后使用普通的 O(n) shuffle。 喜欢这个:***.com/questions/11309200/… JDK 中的Collections.sort 方法将列表转储到数组中,执行Fisher-Yates shuffle,然后将数组转换回列表。它需要线性时间和空间。 【参考方案1】:

接下来呢?执行与归并排序相同的过程。合并时,不是按排序顺序从两个列表中选择一个元素(一个接一个),而是掷硬币。根据掷硬币的结果,选择是从第一个列表还是从第二个列表中选择一个元素。


编辑 (2022-01-12):正如 GA1 在 answer below 中指出的那样,此算法不会随机产生均匀的排列。


算法。

shuffle(list):
    if list contains a single element
        return list

    list1,list2 = [],[]
    while list not empty:
        move front element from list to list1
        if list not empty: move front element from list to list2

    shuffle(list1)
    shuffle(list2)

    if length(list2) < length(list1):
        i = pick a number uniformly at random in [0..length(list2)]             
        insert a dummy node into list2 at location i 

    # merge
    while list1 and list2 are not empty:
        if coin flip is Heads:
            move front element from list1 to list
        else:
            move front element from list2 to list

    if list1 not empty: append list1 to list
    if list2 not empty: append list2 to list

    remove the dummy node from list
        

空间的关键在于将列表分成两部分不需要任何额外的空间。我们唯一需要的额外空间是在递归期间在堆栈上维护 log n 个元素。

虚拟节点的重点是实现插入和删除虚拟元素保持元素分布均匀。


编辑 (2022-01-12):正如 Riley 在 cmets 中指出的那样,以下分析存在缺陷


分析。 为什么分布均匀?在最终合并之后,任何给定数字出现在i 位置的概率如下所示。要么是:

在自己的列表中i-th的位置,并且该列表赢得了第一个i次的抛硬币,这个概率是1/2^i; 在自己的列表中i-1-st位置,并且该列表在抛硬币i-1包括最后一个中输了一次,这个概率是(i-1) choose 11/2^i; 在i-2-nd在自己的列表中,并且该列表赢得了i-2次抛硬币包括最后一个并输了两次,这个概率是(i-1) choose 21/2^i; 等等。

所以概率

P_i(n) = \sum_j=0^i-1 (i-1 choose j) * 1/2^i * P_j(n/2).

你可以归纳地证明P_i(n) = 1/n。我让您验证基本情况并假设P_j(n/2) = 2/n。术语\sum_j=0^i-1 (i-1 choose j) 正是i-1 位二进制数的个数,即2^i-1。所以我们得到

P_i(n) = \sum_j=0^i-1 (i-1 choose j) * 1/2^i * 2/n
       = 2/n * 1/2^i * \sum_j=0^i-1 (i-1 choose j)
       = 1/n * 1/2^i-1 * 2^i-1
       = 1/n

我希望这是有道理的。我们需要的唯一假设是n 是偶数,并且这两个列表是统一打乱的。这是通过添加(然后删除)虚拟节点来实现的。

附:我最初的直觉远非严格,但我将其列出以防万一。想象一下,我们将 1 到 n 之间的数字随机分配给列表的元素。现在我们对这些数字进行归并排序。在合并的任何给定步骤中,它需要确定两个列表的哪个头部较小。但是一个大于另一个的概率应该正好是 1/2,所以我们可以通过掷硬币来模拟。

附言有没有办法在这里嵌入 LaTeX?

【讨论】:

我承认如果项目数不是 2^k 会发生什么我很模糊。 我看不出您的算法将如何生成随机统一的随机列表。例如,给定合并中的两个随机统一的混洗列表,我不明白第二个元素(例如第一个列表的)如何位于结果的第一个位置(假设您插入前面,否则反转)。因此,给定两个随机统一的混洗列表,您的合并不会产生两者的随机统一混洗。如果“随机合并”在任何一步都不起作用,那么最后一步肯定也不会。 我创建了一个小 lua 脚本来实现你的解决方案(没有使用你的虚拟节点,它不会增加任何关于随机性的值),你可以查看它:gist.github.com/Aszarsha/9359090。我留下了一个运行示例的评论。结果矩阵表示,对于输入列表的每个条目,它在指定的运行次数后出现在“混洗”列表的每个位置的次数。清晰的对角线显示了偏差,而无偏差的 shuffle 将显示一个相当均匀的矩阵。 PS:当你甚至不确定你是否遵循时,你怎么能不同意? 我还应该指出,虚拟节点对于随机性至关重要。您可以在您的代码中看到它:如果您使用 2 个元素的幂,您将获得更多的偶数。 您当然是绝对正确的,代码中存在(过去,现在已更正)一个明显的错误,并且虚拟节点对于两种情况的非幂次确实是必需的。无论如何我都会离开我的 cmets,因为我相信矩阵实验本身就有它的优点(即使它“证明”与我的相反)。【参考方案2】:

代码

上洗牌方法

这个 (lua) 版本是从 foxcub 的回答中改进的,以消除对虚拟节点的需要。

为了稍微简化此答案中的代码,此版本假设您的列表知道它们的大小。如果他们不这样做,您总是可以在O(n) 时间找到它,但更好的是:可以在代码中进行一些简单的调整以不需要事先计算它(例如细分一比二而不是先和下半场)。

function listUpShuffle (l)
    local lsz = #l
    if lsz <= 1 then return l end

    local lsz2 = math.floor(lsz/2)
    local l1, l2 = , 
    for k = 1, lsz2     do l1[#l1+1] = l[k] end
    for k = lsz2+1, lsz do l2[#l2+1] = l[k] end

    l1 = listUpShuffle(l1)
    l2 = listUpShuffle(l2)

    local res = 
    local i, j = 1, 1
    while i <= #l1 or j <= #l2 do
        local rem1, rem2 = #l1-i+1, #l2-j+1
        if math.random() < rem1/(rem1+rem2) then
            res[#res+1] = l1[i]
            i = i+1
        else
            res[#res+1] = l2[j]
            j = j+1
        end
    end
    return res
end

为避免使用虚拟节点,您必须通过改变在每个列表中选择的概率来补偿两个中间列表可能具有不同长度的事实。这是通过测试 [0,1] 统一随机数与从第一个列表中弹出的节点与弹出的节点总数(在两个列表中)的比率来完成的。

下洗牌方法

您还可以在递归细分时随机播放,在我的简陋测试中显示出稍微(但始终如一)更好的性能。它可能来自较少的指令,或者另一方面它可能是由于 luajit 中的缓存预热而出现的,因此您必须针对您的用例进行分析。

function listDownShuffle (l)
    local lsz = #l
    if lsz <= 1 then return l end

    local lsz2 = math.floor(lsz/2)
    local l1, l2 = , 
    for i = 1, lsz do
        local rem1, rem2 = lsz2-#l1, lsz-lsz2-#l2
        if math.random() < rem1/(rem1+rem2) then
            l1[#l1+1] = l[i]
        else
            l2[#l2+1] = l[i]
        end
    end

    l1 = listDownShuffle(l1)
    l2 = listDownShuffle(l2)

    local res = 
    for i = 1, #l1 do res[#res+1] = l1[i] end
    for i = 1, #l2 do res[#res+1] = l2[i] end
    return res
end

测试

完整来源在my listShuffle.lua Gist。

它包含的代码在执行时打印一个矩阵,该矩阵表示输入列表的每个元素在指定的运行次数后它在输出列表的每个位置出现的次数。一个相当均匀的矩阵“显示”了字符分布的均匀性,从而显示了 shuffle 的均匀性。

这是一个使用(非 2 的幂)3 元素列表运行 1000000 次迭代的示例:

>> luajit listShuffle.lua 1000000 3
Up shuffle bias matrix:
333331 332782 333887
333377 333655 332968
333292 333563 333145
Down shuffle bias matrix:
333120 333521 333359
333435 333088 333477
333445 333391 333164

【讨论】:

向上、向上、向上、向下、向下、向下、向上、向上、向上、向下、向下、向下。这应该显示 lua 预热或缓存是否有干扰,以及大致有多少。【参考方案3】:

我会说,那个狐狸宝宝的回答是错误的。为了证明我将为一个完美的洗牌列表引入一个有用的定义(称它为数组或序列或任何你想要的)。

定义:假设我们有一个列表L,其中包含元素a1, a2 ... an 和索引1, 2, 3..... n。如果我们将L 暴露给洗牌操作(我们无法访问其内部)L 完全洗牌当且仅当通过知道一些 k (k&lt; n) 元素的索引我们不能推断出剩余的n-k 元素。也就是说,剩余的n-k 元素同样有可能在剩余的任何n-k 索引处显示。

示例:如果我们有一个四元素列表[a, b, c, d],并且在对其进行洗牌后,我们知道它的第一个元素是a ([a, .., .., ..]),而不是任何元素b, c, d 出现的概率,假设第三个单元格等于1/3

现在,算法不满足定义的最小列表具有三个元素。但是算法无论如何都会将其转换为 4 元素列表,因此我们将尝试证明它对于 4 元素列表的不正确性。

考虑一个输入L = [a, b, c, d]在算法的第一次运行之后,L 将分为l1 = [a, c]l2 = [b, d]。在将这两个子列表打乱后(但在合并到四元素结果之前),我们可以得到四个同样可能的 2 元素列表:

l1shuffled = [a , c]     l2shuffled = [b , d]
l1shuffled = [a , c]     l2shuffled = [d , b]
l1shuffled = [c , a]     l2shuffled = [b , d]
l1shuffled = [c , a]     l2shuffled = [d , b]

现在尝试回答两个问题。 1.合并到最终结果中a 成为列表第一个元素的概率是多少? 简单地说,我们可以看到上面四对中只有两对(同样,同样可能)可以给出这样的结果(p1 = 1/2)。对于这些对中的每一对heads,必须在合并例程的第一次翻转期间绘制 (p2 = 1/2)。因此,a 作为Lshuffled 的第一个元素的概率是p = p1*p2 = 1/4,这是正确的。

2.知道aLshuffled的第一个位置,c(我们也可以选择bd而不失一般性)在@的第二个位置的概率是多少987654351@ 现在,根据上述完美洗牌列表的定义,答案应该是1/3,因为列表中剩余的三个单元格中要放入三个数字 让我们看看算法是否能保证这一点。 选择1 作为Lshuffled 的第一个元素后,我们现在将有: l1shuffled = [c] l2shuffled = [b, d] 或者: l1shuffled = [c] l2shuffled = [d, b] 在两种情况下选择3 的概率等于翻转heads (p3 = 1/2) 的概率,因此当知道Lshuffled 的第二个元素3 的概率是Lshuffled 的第一个元素是 1 等于 1/21/2 != 1/3 结束了算法不正确的证明。

有趣的是,该算法满足了完美洗牌的必要(但不充分)条件,即:

给定一个 n 元素列表,对于每个索引 k (&lt;n),对于每个元素 ak:在将列表洗牌 m 次之后,如果我们计算了当ak 出现在k 索引上,这个计数将倾向于m/n,而m 倾向于无穷大。

【讨论】:

确实如此。这就是为什么合并需要使用基于列表长度的概率来选择元素,就像我的回答一样。【参考方案4】:

您实际上可以做得比这更好:最好的列表洗牌算法是 O(n log n) 时间,而只是 O(1) 空间。 (你也可以在 O(n) 时间O(n) 空间 中洗牌,方法是为列表构造一个指针数组,使用 Knuth 将其洗牌并重新线程化相应的列表。)

复杂性证明

要了解为什么 O(n log n) 时间对于 O(1) 空间来说是最小的,请注意:

使用 O(1) 空间,更新任意列表元素的后继元素必然需要 O(n) 时间。 Wlog,您可以假设每当更新一个元素时,您也会更新所有其他元素(如果您愿意,可以保持它们不变),因为这也只需要 O(n) 时间。 对于 O(1) 空间,最多有 O(1) 个元素可供选择作为您要更新的任何元素的后继元素(这些具体元素显然取决于算法)。 因此,一次 O(n) 时间更新所有元素可能会导致最多 c^n 个不同的列表排列。 因为有 n! = O(n^n) = O(c^(n log n)) 可能的列表排列,您至少需要 O(log n) 更新。

链表数据结构(因为 Python)

import collections

class Cons(collections.Sequence):
    def __init__(self, head, tail=None):
        self.head = head
        self.tail = tail

    def __getitem__(self, index):
        current, n = self, index
        while n > 0:
            if isinstance(current, Cons):
                current, n = current.tail, n - 1
            else:
                raise ValueError("Out of bounds index [0]".format(index))
        return current

    def __len__(self):
        current, length = self, 0
        while isinstance(current, Cons):
            current, length = current.tail, length + 1
        return length

    def __repr__(self):
        current, rep = self, []
        while isinstance(current, Cons):
            rep.extend((str(current.head), "::"))
            current = current.tail
        rep.append(str(current))
        return "".join(rep)

合并式算法

这是一个基于迭代归并排序的O(n log n)时间和O(1)空间算法。基本思想很简单:洗牌左半部分,然后是右半部分,然后通过从两个列表中随机选择来合并它们。有两点值得注意:

    通过使算法迭代而不是递归,并在每个合并步骤结束时返回指向新最后一个元素的指针,我们将空间需求减少到 O(1),同时保持时间成本最小。 为确保所有输入大小的所有可能性均等,我们在合并时使用来自 Gilbert–Shannon–Reeds 模型 riffle shuffle 的概率(请参阅 http://en.wikipedia.org/wiki/Gilbert%E2%80%93Shannon%E2%80%93Reeds_model)。
import random

def riffle_lists(head, list1, len1, list2, len2):
    """Riffle shuffle two sublists in place. Returns the new last element."""
    for _ in range(len1 + len2):
        if random.random() < (len1 / (len1 + len2)):
            next, list1, len1 = list1, list1.tail, len1 - 1
        else:
            next, list2, len2 = list2, list2.tail, len2 - 1
        head.tail, head = next, next
    head.tail = list2
    return head

def shuffle_list(list):
    """Shuffle a list in place using an iterative merge-style algorithm."""
    dummy = Cons(None, list)
    i, n = 1, len(list)
    while (i < n):
        head, nleft = dummy, n
        while (nleft > i):
            head = riffle_lists(head, head[1], i, head[i + 1], min(i, nleft - i))
            nleft -= 2 * i
        i *= 2
    return dummy[1]

另一种算法

另一种有趣的 O(n log n) 算法会产生不太均匀的随机播放,只需简单地将列表随机播放 3/2 log_2(n) 次。如http://en.wikipedia.org/wiki/Gilbert%E2%80%93Shannon%E2%80%93Reeds_model 中所述,这仅留下恒定数量的信息。

【讨论】:

【参考方案5】:

这是一种可能的解决方案:

#include <stdlib.h>

typedef struct node_s 
   struct node_s * next;
   int data;
 node_s, *node_p;

void shuffle_helper( node_p first, node_p last ) 
   static const int half = RAND_MAX / 2;
   while( (first != last) && (first->next != last) ) 
      node_p firsts[2] = 0, 0;
      node_p *lasts[2] = 0, 0;
      int counts[2] = 0, 0, lesser;
      while( first != last ) 
         int choice = (rand() <= half);
         node_p next = first->next;
         first->next = firsts[choice];
         if( !lasts[choice] ) lasts[choice] = &(first->next);
         ++counts[choice];
         first = next;
      

      lesser = (counts[0] < counts[1]);

      if( !counts[lesser] ) 
         first = firsts[!lesser];
         *(lasts[!lesser]) = last;
         continue;
      

      *(lasts[0]) = firsts[1];
      *(lasts[1]) = last;

      shuffle_helper( firsts[lesser], firsts[!lesser] );

      first = firsts[!lesser];
      last = *(lasts[!lesser]);
   


void shuffle_list( node_p thelist )  shuffle_helper( thelist, NULL ); 

这基本上是快速排序,但没有枢轴,并且具有随机分区。

外部while 循环替换了递归调用。

内部while 循环将每个元素随机移动到两个子列表之一中。

在内部while 循环之后,我们将子列表相互连接。

然后,我们在较小的子列表上进行递归,并在较大的子列表上循环。

由于较小的子列表永远不会超过初始列表大小的一半,因此最坏情况的递归深度是元素数量的以二为底的对数。所需的内存量是递归深度的 O(1) 倍。

平均运行时间和对rand() 的调用次数为 O(N log N)。

更精确的运行时分析需要理解“几乎肯定”这个短语。

【讨论】:

【参考方案6】:

没有比较的自下而上合并排序。合并时不做任何比较,只是交换元素。

【讨论】:

【参考方案7】:

您可以遍历列表,在每个节点随机生成 0 或 1。

如果为1,则删除该节点并将其作为列表的第一个节点。 如果为 0,则什么也不做。

循环直到你到达列表的末尾。

【讨论】:

以上是关于在 n log n 时间内洗牌链表的算法的主要内容,如果未能解决你的问题,请参考以下文章

在 O(logk) 时间内删除 K 个排序的双向链表的最小值

每天一道算法题(java数据结构与算法)——>删除链表的倒数第 N 个结点

c_cpp 使用常量空间复杂度在O(n log n)时间内对链表进行排序。

链表的数组实现中,令链表和自由表变得紧凑《算法导论》10.3-5

算法-删除链表的倒数第 N 个结点

算法-删除链表的倒数第 N 个结点