模拟数组的随机迭代

6
我有一个给定大小的数组。我想以伪随机顺序遍历它,同时保持数组不变且每个元素只访问一次。如果当前状态可以存储在少量整数中,则最好不过。
我知道,没有存储完整数组就无法实现完全随机,但我并不需要顺序真正地随机。我需要用户感知为随机的顺序。该解决方案应使用亚线性空间。
一种可能的建议是使用大质数(在此处给出)。这种解决方案的问题在于存在明显的固定步骤(取模数组大小)。我更喜欢一种不那么明显非随机的解决方案。是否有更好的解决方案?

1
你是否愿意多次访问给定元素?或者你是在寻找元素的随机排列还是随机序列?你引用的问题涉及到第一个问题(也称为洗牌)。 - Walter
1
许多(大部分?)伪随机数生成器可以通过算法和一个或两个整数来描述它们的当前状态。在给定这些因素的情况下,它们未来的行为完全是确定性的,尽管表面上呈现出随机性 - 这就是“伪”在此处的含义。你寻找的不是伪随机数生成器吗? - High Performance Mark
显然,我需要避免重复(否则我每次都可以跳到随机元素)。我编辑了问题,添加了这些信息。事实上,期望的解决方案应该以一种方式存储当前状态,以便从任何状态在N步中遍历N个元素。 - Michal
很明显,我需要避免重复。不,这在你的编辑之前一点也不明显,并且对潜在解决方案有实质性影响。 - High Performance Mark
说实话,我认为原文已经很清楚了,“我想以伪随机顺序遍历它”。遍历确实意味着每个元素只被访问一次,但这可能只是我英语不好的问题。 - amit
6个回答

2

如果您可以获得最大周期可控的随机生成器来模拟洗牌,那么这个想法是不错的。

线性同余生成器使用以下公式计算随机数:

x[i + 1] = (a * x[i] + c) % m;

最大周期为m,当满足以下条件时可以达到最大周期:
  • 参数cm互质。
  • 对于每个能够整除m的质数r,都有a-1是r的倍数。
  • 如果m是4的倍数,则a-1也是4的倍数。

我的初稿涉及将m设为数组长度之后的下一个4的倍数,并找到合适的ac值。这既费时又明显得不出什么结果。

我重新考虑了这个方案。我们可以将m设为数组长度刚好能够容纳的最小2的幂。那么只有2是m的质因数,这将使得所有奇数和m互质。除了1和2外,m将是4的倍数,这意味着我们必须让a-1是4的倍数。

m设置为大于数组长度的值会导致我们需要放弃所有非法的数组索引值。这种情况最多会每隔一次出现一次,并且应该可以忽略不计。

以下代码生成的伪随机数的周期为m。我避免使用了简单的ac值,并在我的(不太多的)现场检查中,结果看起来还不错。至少没有明显的循环模式。

所以:

class RandomIndexer
{
public:
    RandomIndexer(size_t length) : len(length)
    {
        m = 8;
        while (m < length) m <<= 1;

        c = m / 6 + uniform(5 * m / 6);
        c |= 1;

        a = m / 12 * uniform(m / 6);
        a = 4*a + 1;
        x = uniform(m);                      
    }

    size_t next()
    {
        do { x = (a*x + c) % m; } while (x >= len);

        return x;
    }

private:
    static size_t uniform(size_t m)
    {
        double p = std::rand() / (1.0 + RAND_MAX);

        return static_cast<int>(m * p);
    }

    size_t len;
    size_t x;
    size_t a;
    size_t c;
    size_t m;
};

您可以像这样使用生成器:
std::vector<int> list;
for (size_t i = 0; i < 3; i++) list.push_back(i);

RandomIndexer ix(list.size());
for (size_t i = 0; i < list.size(); i++) {
    std::cout << list[ix.next()]<< std::endl;
}

我知道这仍不是一个很好的随机数生成器,但它相当快速,不需要数组的副本,并且似乎工作得不错。
如果随机选择ac的方法产生了糟糕的结果,那么将生成器限制在某些二的幂次方上,并硬编码已被证明为好的文学值可能是一个好主意。

你确定 while (p*p < m) 这个条件就足够了吗?如果有一个更大的质数能整除m怎么办?例如,956 = 4 * 239,而你的算法(如果我检查正确)得出a=5,这是错误的。 - Michal
这个解决方案的另一个问题是它看起来不够随机 - 很容易看出两个相邻项之间有一个固定的步长。 - Michal
你说得对。我们必须找到所有的因数。在_m_是4和一个质数的乘积的情况下,a 实际上是1.(它是 m + 1,但模算术意味着它与1一样好。)我已经改正了代码。 - M Oehm
LCG不是一个好的生成器。当_a_ = 1时,它变得非常糟糕。在这种情况下,_a_可能应该与另一个(随机?)数字相乘,以使其不那么明显。找到良好的参数集需要一些研究。(当数组只是枚举时,模式当然更明显。由于练习的重点是索引数组,因此数组的结构可能会隐藏规律性。) - M Oehm
好的,计划变更。将_m_尽可能接近数组长度的整个业务是无意义的。我们可以跳过整个寻找质数的过程,直接使用下一个二的幂次方。我已经更新了代码并进行了一些检查,结果看起来还不错。(可能仍然有很大的改进空间,但我认为这是一个可行的解决方案。) - M Oehm

2

这个算法怎么样?

为了伪随机遍历大小为n的数组,可以采用以下步骤:

  1. 创建一个大小为k的小数组。
  2. 使用大质数方法填充小数组,i = 0。
  3. 使用RNG从小数组中随机删除一个位置,i += 1。
  4. 如果i < n - k,则使用大质数方法添加一个新位置。
  5. 如果i < n,则返回第3步。

k越高,获得的随机性就越大。这种方法将使您延迟使用质数方法生成数字。

类似的方法也可用于提前生成序列中的数字,只需创建另一个名为“跳表”的数组即可。随机选择序列后面的项目,使用它们来遍历下一个位置,然后将它们添加到跳表中。当它们自然到达时,会在跳表中搜索并禁止它们,然后从跳表中移除,此时您可以随机添加另一个项目到跳表中。


我最终采用了跳表的变体。所有项目都是通过大质数方法生成的,但每次需要选择一个数字时,我首先向跳表中添加随机数量的项目(最多到一个小固定大小)。然后,我要么从跳表中随机选择,要么返回由大质数方法计算的下一个数字(这也是随机决定的)。它非常容易实现,数字看起来真的很随机。 - Michal

0

这里有一个Java解决方案,可以很容易地转换为C++,与M Oehm上面的解决方案类似,尽管选择LCG参数的方式不同。

import java.util.Enumeration;
import java.util.Random;

public class RandomPermuteIterator implements Enumeration<Long> {
    int c = 1013904223, a = 1664525;
    long seed, N, m, next;
    boolean hasNext = true;

    public RandomPermuteIterator(long N) throws Exception {
        if (N <= 0 || N > Math.pow(2, 62)) throw new Exception("Unsupported size: " + N);
        this.N = N;
        m = (long) Math.pow(2, Math.ceil(Math.log(N) / Math.log(2)));
        next = seed = new Random().nextInt((int) Math.min(N, Integer.MAX_VALUE));
    }

    public static void main(String[] args) throws Exception {
        RandomPermuteIterator r = new RandomPermuteIterator(100);
        while (r.hasMoreElements()) System.out.print(r.nextElement() + " ");
        //output:50 52 3 6 45 40 26 49 92 11 80 2 4 19 86 61 65 44 27 62 5 32 82 9 84 35 38 77 72 7 ...
    }

    @Override
    public boolean hasMoreElements() {
        return hasNext;
    }

    @Override
    public Long nextElement() {
        next = (a * next + c) % m;
        while (next >= N) next = (a * next + c) % m;
        if (next == seed) hasNext = false;
        return  next;
    }
}

0

正如其他人指出的那样,您可以通过打乱数组索引的方式创建一种“飞行计划”,然后按照它进行操作。这违反了“最好能够用几个整数存储当前状态”的约束,但这真的很重要吗?有严格的性能限制吗?毕竟,我认为如果您不接受重复,那么您需要在某个地方或以某种方式存储已经访问过的项目。

或者,您可以选择一种侵入式解决方案,并在数组的每个元素中存储一个bool,告诉您该元素是否已被选择。可以通过继承(根据需要多次)以几乎干净的方式完成此操作。
这种解决方案带来了许多问题,例如线程安全性,当然也违反了“保持数组完整”的约束。


您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - amit
也许这可以与随机数生成器的使用相结合,其种子可以被强制。该程序将选择一个随机种子。每当需要洗牌数据时,它可以从原始数组和种子重新生成。 - Patricia Shanahan
@gd1 我假设随机数生成器将用于驱动明显的选择 Fisher-Yates 洗牌算法 - Patricia Shanahan
@PatriciaShanahan:OP 不想修改输入数组,最好不要使用额外的空间。这就是使这件事情变得困难的原因,当然任何人都可以调用 std::random_shuffle,那应该是 Fisher-Yates 算法。 - gd1
1
我理解你的观点,但实际上,在你所谓的“虚假”随机性上达成共识非常困难,因为这是一个模糊的概念,很难形式化。即使我们提出了一个“有些”随机的解决方案,你可能会说它不够随机,或者不是你想要的伪随机性,当我们指出真正的随机性是无法实现的时,你可能总是说你不需要它,因为你对你的“一些”随机性感到满意。我会好好考虑一下 :) - gd1
显示剩余4条评论

0
你提到的二次剩余(“使用大质数”)是众所周知的,可以工作,并保证迭代每个元素仅一次(如果需要的话,但似乎不是严格要求?)。不幸的是,它们不是“非常随机”,除了是质数之外,模数还有其他要求才能正常工作。
Jeff Preshing的网站上有一页详细描述了这种技术,并建议将剩余生成器的输出再次带入固定偏移量的生成器中
然而,由于您说您只需要“用户认为是随机的”,因此似乎您可以使用哈希函数(例如cityhash或siphash)连续输入整数。输出将是一个“随机”的整数,并且至少目前为止会有严格的1:1映射(因为可能的哈希值比输入更多)。
现在问题是,您的数组很可能不是那么大,因此您需要以某种方式缩小这些生成的索引的范围而不生成重复项(这很困难)。

显而易见的解决方案(取模)不起作用,因为它几乎保证会产生大量重复。

使用位掩码将范围限制在下一个更大的二次幂上应该可以避免引入偏差,并且丢弃超出边界的索引(生成新索引)也应该可以工作。请注意,这需要非确定性时间--但是这两个组合应该在平均情况下工作得相当好(最多尝试几次)。

否则,“真正有效”的解决方案就是像Kamil Kilolajczyk指出的那样对索引数组进行洗牌(尽管您不想要那样做)。


-1

OP询问如何在不操作数组或存储新数组的情况下实现。 - amit
但是使用这个方法,您可以创建一个新的简单索引数组0..length-1(而不是对象数组的克隆),对它们进行随机排序并使用这些随机排序后的索引获取对象。 - Kamil Mikolajczyk
1
我的条件是避免类似大小的额外数据。显然,如果我们允许数组的副本,那么随机重排将会执行。 - Michal
是的,但我建议创建一个整数临时数组,其大小比复杂对象小...或者也许这个数组非常大,甚至亿万个整数的临时数组也不可接受,那么好吧,我错了 :) - Kamil Mikolajczyk

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接