数组的原地排列遵循以下规则

8
假设有一个数组,我们想要找到所有奇数索引(索引从0开始),并将它们移到末尾。所有偶数索引的内容移动到开头。所有奇数索引项目和所有偶数索引项目的相对顺序被保留。
例如,如果数组是:
a1 b1 a2 b2 ...  an bn    

操作后,它变成了

a1 a2 a3 ... an b1 b2 ... bn

这个能否在原地且O(n)时间内完成?

7
闻起来像作业吗? - Chowlett
8
你有没有考虑完全不做它?我的意思是,由于存在一个可以映射旧索引到新索引以及新索引到旧索引的函数,你可以在不移动任何东西的情况下以新顺序访问旧内容。 - Unreason
1
@Chris:这绝不是作业! - Aryabhatta
@Unreason: 非常有见地!我想不出使用这种洗牌的任何实际原因了。最初,我在一些递归中调用它,它仅在第k次递归时运行0到n/2^k个数组的范围内。这就是我想要进行洗牌的原因,将所有从0到n/2^(k-1)的偶数索引放在开头,以便下一次递归仅处理上一次递归中的偶数索引。现在,我只需为此问题提供1对1的映射即可。 - Chao Xu
1
@Mgccl:我明白了。我猜维基上所说的是在你递归地进行多次洗牌后获得的位反转属性。请参阅此链接:http://www.dspguide.com/ch12/2.htm。从你的提问方式来看,这也可能只是一次洗牌的情况 :-)。 - Aryabhatta
显示剩余7条评论
4个回答

7

这是可能的,但是非常复杂!更简单的O(nlogn)和O(1)空间解决方案在编写代码和缓存方面可能更好。

我们将解决一个与你的问题不同的问题,但是一旦我们解决了那个问题,你的问题就变得微不足道了。

考虑数组为

b1, a1, b2, a2, ..., bn, an

你需要将这个转换为

a1, a2, ..., an, b1, b2, ..., bn

与1到2n的索引一起工作,我们可以看到这是由以下内容给出的:

i -> (n+1)*i (mod 2n+1).

一种O(nlogn)时间复杂度且O(1)空间复杂度的解决方案

我们可以采用分治法来解决问题。

首先,对于接近n/2的m值,将数组

b1, a1, ..., bn , an

转换为

a1,a2,...am, b1,b2, ..bm, a(m+1), ..., an, b(m+1), ... , bn

通过递归地应用于前2m个元素,然后是剩余的元素。

现在我们只需要将中间数组循环移位m个位置(这可以在O(n)时间和O(1)空间内完成)

得到

a1, a2, .., am , a(m+1), ..., an, b1, b2, ..., bm, b(m+1), ..., bn.

当然,正如IVlad所指出的那样,这需要O(logn)的堆栈空间。我们可以通过以下方式解决这个问题:
我们有:
b1 a1, b2 a2, .. bm am, b(m+1) a(m+1), ..., bn an

现在交换数组后半部分的一对元素,以得到:
b1 a1, b2 a2, .. bm am, a(m+1) b(m+1), ..., an bn

现在对奇数位置的元素进行循环移位:b1,b2,..,bm,a(m + 1),a(m + 2)...,a(n)
这将得到类似以下的结果:
a(m+1) a1, a(m+2) a2, ..., a(2m) am, a(2m+1) b(m+1),...,an b(n-m), b1 b(n-m+1),...,bm bn

现在再次交换数组的后半部分,以得到
a(m+1) a1, a(m+2) a2, ..., a(2m) am, b(m+1) a(2m+1),...,b(n-m) an,b(n-m+1) b1,..., bn bm

现在递归地解决第一部分和第二部分,得到:
[a1 a2 ... am][a(m+1) ... a(2m)]   [a(2m+1) ...an b1 b2 .. bm][b(m+1) ... bn]

无论2m>=n还是不成立,此方法都可行。

因此,这是一个O(nlogn)时间复杂度和O(1)空间复杂度的算法。


一个O(n)时间复杂度和O(1)空间复杂度的解决方案。

所使用的思路类似于以下论文中使用的思路:Inshuffle的简单原地算法

您需要阅读该论文以了解下面的内容。我建议您还应阅读:如何掌握原地数组修改算法?

这基本上是在上述论文中解决的问题的反向置换。

当2n+1为3的幂(记为3^m)时,解决此问题就足够了,因为之后可以使用分治(就像O(nlogn)的解决方案一样)。

现在2n+1和n+1互质,因此在模3^m的情况下,我们可以看到n+1必须是2的某个幂次方。(再次参见那篇论文,以了解为什么:基本上,与3^m互质的任何数在模3^m下都是2的幂次方)。

假设n+1=2^k(我们还不知道k,并且请注意这是模3^m的情况)。

找到k的方法是计算n+1的幂,直到它变为1。这给了我们k(最多需要O(n)时间)。

现在我们可以看到排列的循环(请参见上述论文/stackoverflow链接)从以下位置开始:

2^a*3^b

其中0 <= a < k,0 <= b < m。

因此,您从每个可能的(a,b)对开始,并跟随置换的循环,这将给出一个时间复杂度为O(n),原地算法,因为您不会超过一定数量的次数触摸每个元素!

这有点简短(!),如果您需要更多信息,请告诉我。


递归难道不会使得(至少)第一个解决方案的内存使用量为O(log n)吗? - IVlad
1
@IVlad:说得好!通常栈空间已经存在(即我们实际上不必分配它),因此对于小函数的O(logn)递归调用(即非常少的局部变量),如果它们没有溢出堆栈,仍然被认为是O(1)空间。您是正确的,也许有一种方法可以通过尾递归来解决这个问题……我需要考虑一下。 - Aryabhatta
对于正在阅读所选解决方案的人: 还要阅读unreason的评论。它让我意识到,对于大多数(如果不是所有)实际原因,不需要显式洗牌来完成此操作。 - Chao Xu
1
@Mgccl:在嵌入式设备等内存较低且需要原地算法的情况下,可能需要显式洗牌。我认为,如果您需要原地算法,则更有可能需要显式洗牌!无论如何,这都是没有支持数据的谈话 :-) - Aryabhatta
2
@IVlad:我已经编辑了答案,通过使其成为尾递归,消除了对递归调用的依赖。 - Aryabhatta

3
你所拥有的是一个N X 2矩阵,用一维数组表示,你希望将其转置为一个2 X N矩阵。
例如,你的列表:
a1, b1, a2, b2, ... an, bn 同样可以表示为矩阵:
x1,1, x1,2, x2,1, x2,2, ... xn,1, xn,2 你想要将其转置为:
x1,1, x2,1, ... xn,1, x1,2, x2,2, ... xn,2 原地矩阵转置算法可以完成这个任务。 编辑

好的,让我来解释一下。请尝试以下代码:

i = 0                /* linear array index */
do j = 1 to c        /* j = 1 to virtural columns */
  do k = 1 to r      /* k = 1 to virtural rows */
    i = i + 1
    sp = (k - 1) * c + j
    do while sp < i
      ct = (sp - 1) % r + 1
      rt = sp - (ct - 1) * r
      sp = (rt - 1) * c + ct
    end
    if i \= sp then say 'swap('i',' sp')'   /* swap elements */
  end
end

这将打印出需要交换的数组元素。这适用于由列和行排列的线性数组表示的任何大小的矩阵。使用N X 2矩阵,元素将按以下方式排列:
x1,1, x1,2, x2,1, x2,2, ... xn,1, xn,2 该算法打印出需要交换的元素,以产生如下排序的数组:
x1,1, x2,1, ... xn,1, x1,2, x2,2, ... xn,2 例如,从r = 4,c = 2和数组开始:
 A1, B1, A2, B2, A3, B3, A4, B4

需要进行以下交换:
swap(2, 3)
swap(3, 5)
swap(4, 7)
swap(6, 7)

变成:

 A1, A2, A3, A4, B1, B2, B3, B4

这个算法在空间和时间上都很高效。 大O符号 我的O-foo不是很好,但我会尽力……
矩阵包含2列('A'和'B')和'M'行。为了将其表示为线性数组,我们需要2M个元素。让我们称这个数字为N(线性数组的大小)。
该算法有两个迭代循环,一个用于“r”(行),另一个用于“c”(列)。总迭代次数为r * c,对于我们的情况来说,即为2M = N。到目前为止还不错。
通配符是内部的DO WHILE循环。对于给定数量的行,需要多少次迭代?答案可能是:相当多。基于一些经验结果(如下所示),看起来DO WHILE迭代次数是一个涉及'r'的复杂函数,当'c'=2时(或者可能是任何值'c')。我没有足够的O-foo来确定这个函数是什么。然而,它不应该比N3更糟糕(一次完整的矩阵“追逐”,N2次每个元素,N)。从理论上讲,这不是一个好的情况。所以我猜这使得它成为O(N3)?这可能是一个非O(N)算法,但在实际情况下,根据下面的经验数据,它似乎表现接近O(N)。我有点迷失了 - 欢迎评论!

关于DO WHILE循环的一个观察:它使用简单变量的整数基础数学(不需要数组引用)。如果你要进行非线性运算,这肯定是最便宜的地方!

需要多少次交换?每次迭代通过外部两个循环的交换次数限制为一次,最多N次。交换次数符合O(N)性能。

我猜这不是一个O(N)算法,但对于中等大小的2列矩阵似乎具有合理的行为。

以下是各种2列矩阵尺寸的经验结果:

      Rows Loops per row
========== =============
       500             9
     1,000            19
     1,500            21
     2,000            12
     2,500            18
     3,000            23
     3,500            26
 1,000,000            30
 2,000,000            40
 3,000,000            45
10,000,000            59
20,000,000            39
30,000,000            60

"“每行的循环次数”随着行数增加而增加,但不会以惊人的速度增长。危险在于可能会达到某个“甜点”,导致其呈指数级增长——但我不知道它是否真的有这个潜力。”
“对Mgccl的建议是,在其应用程序中典型的行数范围内对该算法进行基准测试,然后决定相对于其他算法基准测试,是否能产生可接受的性能。大O分析很有趣,但数据操作范围内的结果才是关键。”
“最后一次机会:为什么这个算法有效?”
“转置由线性形式表示的矩阵:”
“给定具有M行和N列的矩阵X。”
“将X以行优先顺序布置成一个线性数组。该数组将按以下方式组织:”"
11, 12, 13, ... 1N, 21, 22, 23, ... 2N, ... M1, M2, M3 ... MN

描述线性数组中每个元素的符号为:

    X[i,r,c]
其中:i是元素X在线性数组中的索引 r是元素X的行编号 c是元素X的列编号。

使用此符号,可以按行主序生成一个数组:

i = 0
for j = 1 to M    /* Row counter */
  for k = 1 to N  /* Column counter */
    i = i + 1     /* array index of element j, k */
    say 'X['i','j',k']'
  end
end

请注意,给定j(行)和k(列)的值,可以计算i如下:
i = (j - 1) * N + k

矩阵X的转置是通过在r和c范围内交换X [i,r,c]和X [t,c,r]元素构建的。本质上,我们交换所有行变量和列变量。使用上面描述的符号,这相当于交换线性数组元素:

    exchange(X[i,r,c], X[t,c,r])
其中:i = (r - 1) * N + c t = (c - 1) * M + i

将矩阵转置所需的交换次数将小于M * N,因为最多只需要一个交换即可将元素放置到正确的位置。在某些情况下,不需要交换,因为该元素已经“就位”。例如,X的第一个和最后一个元素永远不需要交换。

通过按增加i的顺序遍历线性数组,我们知道只要没有任何交换涉及i> t的元素,对于所有索引小于或等于i的元素,矩阵将以列主顺序排列。

每当 i > t 时,这意味着在索引 t 处进行了先前的交换。 在上述指示下,t 处的元素被交换到某个新位置 t'。给定索引 t,我们可以计算行主索引 t' 以及与之关联的行和列号,如下所示:
 c' = (t - 1) % M + 1
 r' = t - (c' - 1) * M
 t' = (r' - 1) * N + c'

再次,如果t'小于i,则意味着此元素也已交换,我们必须继续进行另一轮计算。将t设置为计算出的t'并重复。最终,i将变成<= t,并且可以进行交换。基本上,我们通过所有先前的交换“追踪”元素,直到在线性数组中找到它在i处或i右侧。

对于线性数组中的每个元素重复此循环,矩阵将被转置。


@Nealb:我猜平均时间复杂度将为O((NM/logMN)^2)。这是一个随着大小增加而交换次数会振荡的问题。因此,不要选择像100万、200万这样的孤立值,我建议您选择一个范围,比如从1,000,000到1,100,000等。 - Aryabhatta
@Moron。我运行了每个由100到10,000行组成的2列矩阵。表现最佳的行选择(128)平均每行通过内部循环4次。最坏情况(9,487)需要平均每行37次迭代。这表明O((MN / logMN)^ 2)是最坏情况性能的一个过高估计。我注意到随着行数增加,每行的平均迭代次数也会增加。这一观察结果表明算法在某种程度上呈指数增长(但对于小到中等大小的矩阵仍然表现良好)。 - NealB
@Nealb。那只是一个猜测。无论如何,仅凭几个值是无法确定的。O(blah)隐藏了一个常数,这个常数可能非常小/大等等。 - Aryabhatta
请问您能澄清一下 j 和 k 的索引范围吗?c 和 r 是否包含在它们各自的范围内?符号表示是这样的,但如果您习惯以任何类似 C 或 Java 的方式编写 for 循环,则似乎不太直观。 - shuhalo
@Martin 我花了几分钟时间才明白你所困惑的问题。在我的回答中,我在不同的部分使用了r和c这两个变量(多次编辑的乐趣)。在“最后一次尝试”之前,r是矩阵中总行数,k是从1到r的索引。c是总列数。之后,我开始使用N来表示列数,而c只是一个索引(从1到N)。r变成了M(行数)。现在没有时间,但稍后我会尝试重新编辑答案。此外,数组索引是基于1的(而不是C / Java中的零)。 - NealB
显示剩余8条评论

0

好的,让我们看一个例子:

0 1 2 3 4 5 6 7 8 9 10

0 2 4 6 8 10 1 3 5 7 9

结果的前一半包含索引为原始索引 i 的 i / 2 元素,而另一半则是 i - n / 2 + 1,其中 n 是数组中的项目数。

这基本上是排序算法的一个特殊情况,只是设置了一个特殊的顺序。

因此,这里有一个使用冒泡排序算法对整数数组进行排序的想法。我们需要将偶数值移到前面,留下奇数值:

0 1 2 3 4 5 6 7 8 9 10
  * *
0 2 1 3 4 5 6 7 8 9 10
      * *
0 2 1 4 3 5 6 7 8 9 10
    * *
0 2 4 1 3 5 6 7 8 9 10
          * *
0 2 4 1 3 6 5 7 8 9 10
        * *
...
0 2 4 6 1 3 5 7 8 9 10
              * *
...
0 2 4 6 8 1 3 5 7 9 10
                  * *

只有在保留索引的情况下才能正常工作(即在此情况下,索引与数组值相关)。而冒泡排序的性能为O(n^2),内存使用量为1。

如果仅使用通用排序算法,则无法在O(n)时间和1内存中完成[编辑:],最好的通用排序算法的执行时间为O(nlog n):

http://en.wikipedia.org/wiki/Sorting_algorithm

为了解决您的问题,最好的方法是生成一个符合您条件的索引数组:
size_t* indices = new size_t[n]; 

// n is the number of items in the original array
for (int i = 0; i < n; i++)
{
    if (i < n / 2)
    {
       indices[i] = i * 2;
    }
    else
    {
       indices[i] = i - n / 2 + 1;
    }
}

然后简单地使用它将原始数组中的项目映射到新位置:

// i is the new index here, which makes it appear as if ValuesArray has been sorted
Type* getOddEvenValue(size_t newIndex)
{
     // ValuesArray and indices should be available in this scope, of course
     return ValuesArray[indices[newIndex]];
}

这个东西可以在O(n)的时间内运行,但也需要O(n)的内存。但如果Type的大小比int的大小大得多,那么它仍然可以节省内存使用量(而不是复制Type对象)。

[编辑2:]

与优美的面向对象编码和andand did相反,后者还要求您将原始向量复制到类的实例中,您可以简单地编写一个函数:

 size_t permuted_index(size_t i, size_t vecSize)
 {
    return ( i < vecSize/2 ? i * 2 : 2 * (i - vecSize/2) + 1);
 }

每当您需要获取排列值时,请使用它。实际上,这只需要O(n)的时间,因为对于给定的向量,permutted_index最多只会被评估n次(在完整的“for each”循环中至少如此)。


O(n) 时间复杂度和 O(1) 空间复杂度是可能的。 - Aryabhatta
嗯,是的,他实际上已经证明了这一点。他的解决方案也可以以过程方式实现,顺便说一句--类只是为了面向对象编程的好处。不幸的是,我没有完全理解你的解决方案。它似乎比需要的更复杂。 - Andrei Sosnin
正如所述,andand的解决方案并没有解决问题。它只是在检索元素时添加了一层间接性。实际上并没有进行任何物理移动。例如,假设您必须为不同长度的子数组重复执行此操作多次等等。那个解决方案将不再起作用... - Aryabhatta
@Moron:它确实解决了整数索引的初始问题。但是,这种方法无法解决泛型化的问题。 - Andrei Sosnin

0

O(1) 时间,O(1) 额外空间

#include <vector>
#include <iostream>
#include <ctime>
#include <boost/random.hpp>

template <typename T>
class pvec
{
private:
    // Stores values to permute
    std::vector<T> v;

    // Flag; true when permuted; false when not
    bool permuted;

public:
    pvec(size_t size) : v(size), permuted(false) {}

    // Set the permutation flag
    void set_p(bool p) { permuted = p; }

    // When the permuted flag is set, return the permuted element, otherwise
    // return the non-permuted element
    T& operator[](size_t i) { return (permuted ? v[p_ind(i)] : v[i]); }

    // Pass through the size of the vector used
    size_t size() const { return v.size(); }

private:
    // Used to determine the permuted index of a given index
    size_t p_ind(size_t i) const
    {
        return ( i < v.size()/2 ? i * 2 : 2 * (i - v.size()/2) + 1);
    }
};

// Used to display a pvec instance
template <typename T>
std::ostream& operator<<(std::ostream& os, pvec<T>& v)
{
    for (size_t i = 0; i < v.size(); ++i)
        os << (i == 0 ? "<" : ", ") << i << ":" << v[i];
    os << ">";
    return os;
}

int main(int argc, char** argv)
{
    const size_t size = 8;
    pvec<uint32_t> v(size);  // Array containing test values

    // Boost Random Number Library used to generate random values in the test
    // array
    boost::mt19937 rng(std::time(0));
    boost::uniform_int<> dist(0,65536);
    boost::variate_generator<boost::mt19937&, boost::uniform_int<> >
        var(rng, dist);

    // Generate random test values
    for (size_t i = 0; i < size; ++i)
        v[i] = var();

    // Observer original vector
    std::cout << v << std::endl;

    // Set the permutation flag O(1) time
    v.set_p(true);

    // Observe the permuted vector
    std::cout << v << std::endl;

    return 0;
}

输出:

<0:44961, 1:246, 2:54618, 3:41468, 4:12646, 5:21691, 6:26697, 7:36409>
<0:44961, 1:54618, 2:12646, 3:26697, 4:246, 5:41468, 6:21691, 7:36409>

3
这并不是O(1),因为打印答案本身就是O(n)的。此外,问题明确要求以某种方式移动元素,而不仅仅是打印它们。另外,使用混杂了boost和复杂类的C++来回答算法问题并不是个好主意。伪代码或易于理解的C++会更有帮助。-1。 - IVlad
@IVlad:排列涉及设置一个标志来告诉类调用排列,这是O(1)的;确实,观察是O(N),但那只是为了说明目的。至于移动的问题,该类抽象了这个必要性;它实现了在指定排列中移动元素的效果,而实际上并没有这样做。至于Boost,它只是用随机值填充示例数组……精华在于这个类。 - andand
@IVlad:另外,您可能需要阅读原帖后续的评论,了解不需要显式移动的情况。 - andand
我仍然认为伪代码对于这个问题会更好,但是看到你添加了注释,我取消了投票。 - IVlad
@IVlad:我承认你说的没错;但从我的角度来看,我想确保它能够正常工作,并且我可以验证排列背后的数学是否正确。 - andand
1
无论是为了打印还是其他目的,获取内存中的一系列项目的任何尝试都将导致O(n)的运行时间。但是O(n)的时间/O(i)的内存也是可以接受的! - Andrei Sosnin

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