在n log n时间内混合链表的算法

时间:2012-08-28 21:14:16

标签: algorithm linked-list shuffle divide-and-conquer

我正在尝试使用分而治之算法对链表进行混洗,该算法随后在线性(n log n)时间和对数(log n)额外空间中随机混洗链接列表。

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

或者我是否给每个节点一个随机数,然后根据随机数在节点上进行合并?

7 个答案:

答案 0 :(得分:25)

以下怎么样?执行与合并排序相同的过程。合并时,不是从排序顺序的两个列表中选择一个元素(逐个),而是翻转硬币。根据硬币翻转的结果选择是从第一个列表还是从第二个列表中选择一个元素。

<强>算法。

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元素。

虚拟节点的要点是认识到插入和移除虚设元素使得元素的分布保持均匀。

<强>分析。 为什么分布均匀?在最终合并之后,在位置P_i(n)中结束的任何给定数字的概率i如下。它是:

  • 位于自己列表中的i位置,该列表赢得了第一次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是偶数,并且两个列表均匀地进行了混洗。这是通过添加(然后删除)虚拟节点来实现的。

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

P.P.S。有没有办法在这里嵌入LaTeX?

答案 1 :(得分:3)

代码

Up shuffle方法

这个(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]均匀随机数来完成的,该随机数是从第一个列表中弹出的节点与弹出的节点总数(在两个列表中)的比率。

Down shuffle方法

你可以在递归细分时进行随机播放,这在我的简单测试中表现出稍微(但始终如一)的更好表现。它可能来自较少的指令,或者另一方面它可能由于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

它包含的代码在执行时打印一个矩阵,表示输入列表的每个元素,在指定的运行次数后,它在输出列表的每个位置出现的次数。一个相当均匀的矩阵'显示'字符分布的均匀性,因此洗牌的均匀性。

以下是使用(非幂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

答案 2 :(得分:3)

我会说,foxcub的答案是错误的。为了证明我将为一个完美的混乱列表引入一个有用的定义(称之为数组或序列或任何你想要的)。

定义:假设我们有一个包含元素L和索引a1, a2 ... an的列表1, 2, 3..... n。如果我们将L公开给一个shuffle操作(我们没有访问权限的内部组件)L被完全洗牌,当且仅当知道某些k(k< n)元素的索引时,我们才能不推断剩余n-k元素的索引。这是剩余的n-k元素同样可能在任何剩余的n-k索引中显示。

示例:如果我们有一个四元素列表[a, b, c, d]并且在洗牌之后,我们知道它的第一个元素是a[a, .., .., ..]),而不是任何元素的概率{ {1}}发生,比方说,第三个单元格等于b, c, d


现在,算法不符合定义的最小列表有三个元素。但算法无论如何都将它转换为4元素列表,因此我们将尝试显示4元素列表的不正确性。

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

l2 = [b, d]


现在尝试回答两个问题。
1。合并到最终结果l1shuffled = [a , c] l2shuffled = [b , d] l1shuffled = [a , c] l2shuffled = [d , b] l1shuffled = [c , a] l2shuffled = [b , d] l1shuffled = [c , a] l2shuffled = [d , b] 之后的概率是该列表的第一个元素。
很简单,我们可以看到上面四对中只有两对(同样可能)可以给出这样的结果(a)。对于这些对中的每一对,必须在合并例程(p1 = 1/2)中首次翻转时绘制heads。因此,将p2 = 1/2作为a的第一个元素的概率为Lshuffled,这是正确的。


2。知道p = p1*p2 = 1/4位于a的第一个位置,Lshuffled的概率是多少(我们也可以选择cb而不会丢失一般性)在d 的第二个位置
现在,根据完全混洗列表的上述定义,答案应该是Lshuffled,因为列表中的三个剩余单元格中有三个数字
让我们看看算法是否可以保证。
选择1/3作为1的第一个元素后,我们现在要么:
Lshuffled
或:
l1shuffled = [c] l2shuffled = [b, d] 在两种情况下选择l1shuffled = [c] l2shuffled = [d, b]的概率等于翻转3heads)的概率,因此将p3 = 1/2作为第二个元素的可能性知道3的第一个元素元素Lshuffled等于Lshuffled1的{​​{1}}。 1/2结束了算法错误的证明。

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

为每个元素1/2 != 1/3提供n元素的列表k,每个元素<n:在对列表进行混洗后{{1} }次,如果我们计算了ak索引上发生m次的时间,则此计数将概率为akk趋于无穷大。

答案 3 :(得分:2)

您实际上可以做得更好:最佳列表混洗算法是 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)空间算法。基本思路很简单:将左半部分和右半部分混合,然后通过从两个列表中随机选择来合并它们。值得注意的两件事:

  1. 通过使算法迭代而不是递归,并在每个合并步骤结束时返回指向新的最后一个元素的指针,我们将空间要求减少到O(1),同时保持最小的时间成本。
  2. 为了确保所有输入尺寸都具有相同的可能性,我们在合并时使用Gilbert-Shannon-Reeds模型riffle shuffle的概率(参见http://en.wikipedia.org/wiki/Gilbert%E2%80%93Shannon%E2%80%93Reeds_model)。
  3. 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中所述,这只留下了恒定数量的信息。

答案 4 :(得分:1)

自上而下合并排序而不进行比较。虽然合并不进行任何比较,但只需交换元素。

答案 5 :(得分:1)

以下是一种可能的解决方案:

#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)。

更精确的运行时分析需要理解短语&#34;几乎可以肯定。&#34;

答案 6 :(得分:0)

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

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

将其循环播放,直到到达列表末尾为止。