能否在O(N)的时间复杂度内就地重排一个数组?

17
如果我有一个大小为N的对象数组,并且我有一个唯一数字的数组,范围在1…N之间,是否有任何算法可以按照数字列表指定的顺序在原地(in-place)重新排列对象数组,并且以O(N)时间完成?
背景:我正在对相当大的对象进行类似快速排序的算法,因此在索引上进行交换比在对象本身上更快,并且只能在一个最终遍历中移动对象。我只是想知道是否可以在不为单独的数组分配内存的情况下执行此操作。
编辑:我不是在询问如何在O(N)时间内进行排序,而是如何在O(N)时间和O(1)空间内进行排序后的重新排列。抱歉没有表述得很清楚。

6
您可以使用指针数组(而不是对象),并在对其进行快速排序时交换指针。 - Nick Dandoulakis
你不能在O(n)时间内对数组进行排序。但是你可以就地排序(因此只使用O(n)空间)。这是你想知道的吗? - Sam DeFabbia-Kane
9个回答

16
我认为这应该可以解决问题:
static <T> void arrange(T[] data, int[] p) {
    boolean[] done = new boolean[p.length];        
    for (int i = 0; i < p.length; i++) {
        if (!done[i]) {
            T t = data[i];
            for (int j = i;;) {
                done[j] = true;

                if (p[j] != i) {
                    data[j] = data[p[j]];
                    j = p[j];
                } else {
                    data[j] = t;
                    break;
                }
            }                
        }
    }
}

注意:这是Java。如果您在没有垃圾回收的语言中执行此操作,请确保删除done
如果您关心空间,可以使用BitSet来处理done。我假设您可以承受每个元素额外的一个比特位,因为您似乎愿意使用排列数组,而排列数组的大小是其几倍。
该算法将T的实例复制n + k次,其中k是排列中循环的数量。通过跳过p [i] = i的那些i,您可以将其减少到最佳的复制次数。

1
如果p是一个临时数组,你可以用它来代替done数组 - 只需在数据[i]处于正确位置时将p[i]设置为-1(或其他一些哨兵值)。 - Mark Ransom
好主意,这样可以进一步优化内存访问。当然,这也有一定的风险,因为调用者可能没有预料到排列会被修改。 - meriton
1
"done" 目前存在泄漏,所以我完全支持使用 BitSet。 - Steve Jessop
忽略可能的并发问题,您始终可以就地修改p[],然后反转修改。 - Heath Hunnicutt
@Heath:在这种情况下,是的,因为p是一个包含数组索引的int数组,这些索引必然是非负的。因此,您可以使用符号位作为标志。但这是一个幸运的实现细节-可以说p应该是一个size_t数组或类似的东西。不过,在问题的上下文中,破坏p希望不是一个问题,因为它是由快速排序算法本身分配的。事实上,问题要求对一个已经使用了O(N)额外内存的算法进行O(1)空间最终步骤,似乎有点不公平... - Steve Jessop
1
+1,是的,这就是我想到的解决方案。如果你真的需要节省空间,在实践中可能可以不使用任何额外的存储空间,只需否定已在排列数组p[]中处理过的索引的符号即可。我们从零开始,所以我们知道它总是首先被处理的。最后,如果你需要恢复原始数组,再次否定符号使它们再次变为正数即可。 - Kris

7
您是指您有一个对象数组O [1..N],然后您有一个包含数字1..N的排列的数组P [1..N],最后您想要获得一个对象数组O1,使得对于所有k = 1..N,O1 [k] = O [P [k]]?例如,如果您的对象是字母A,B,C ... Y,Z,而您的数组P是[26,25,24,..,2,1],则您期望的输出是Z,Y,... C,B,A吗?如果是这样,我相信您可以仅使用O(1)附加内存在线性时间内完成它。颠倒数组元素是此方案的特殊情况。一般来说,我认为您需要将置换P分解为循环,然后将其用于移动原始数组O []的元素。如果这就是您要寻找的内容,我可以详细说明。编辑:其他人在我睡觉时已经提出了出色的解决方案,因此无需在此重复。 ^ _ ^编辑:我的O(1)附加空间确实不完全正确。我只考虑了“数据”元素,但实际上您还需要为每个置换元素存储一个位,因此,如果我们准确,我们需要O(log n)额外的位数。但是大多数情况下,使用符号位(如J.F. Sebastian建议的那样)就可以了,因此在实践中,我们可能不需要比我们已经拥有的更多的东西。

这可能有点棘手。我建议小心编程,并在之后进行充分测试。只要排列数组可以写入,我认为您无需明确处理循环。此外,如果数组非常大,局部性可能很重要,而此方法的局部性可能非常糟糕。(局部性 = 一次仅使用相对较少的缓存行或内存页。) - David Thornley
是的,我明天会提供更多细节 - 我需要坐下来用纸和笔思考一下。这个想法是每个排列都可以分解成不相交循环的乘积,然后每个循环移动需要存储一个临时值的元素。我们反转顺序的例子有置换[1,2,...,26] -> [26, 25, ... , 2,1],它将被分解成循环(1 26)(2 25)等...在这种情况下处理一个循环只需要进行一次交换:存储一个值,移动另一个值,从临时变量中恢复。但你可以将其推广到任何循环长度。 - Kris
1
我已经完成了详细的工作,可以查看我的答案以获取结果。然而,我不知道如何使用O(1)内存进行分解,因此我允许每个要排序的元素额外使用一位。 - meriton
1
我认为无法在O(1)空间,O(n)时间内完成。请参阅相关问题“算法确定数组是否包含n...n+m?”https://dev59.com/63VC5IYBdhLWcg3wz0l9 我能想到的最好方法是使用符号位来标记已访问的项https://dev59.com/63VC5IYBdhLWcg3wz0l9/algorithm-to-determine-if-array-contains-n-nm/311497#311497 - jfs

7
这种方法是按照排列的“置换循环”而不是从左到右索引数组。但由于你必须从某个地方开始,每次需要新的置换循环时,搜索未置换元素是从左到右的:
// 伪代码 N:整数,N>0 // N是元素数量 swaps:整数[0..N] data [N]:对象数组 permute [N]:整数数组[-1..N]表示置换(使用的元素为-1) next_scan_start:整数; next_scan_start = 0; while (swaps = 0) break; next_scan_start = idx_cycle_search + 1; //这是可证明的不变量。简而言之,在 permute [] 中的非负元素的数量等于(N-swaps) assert(idx_cycle_search = 0) { swap(data [idx_cycle_search],data [permute [idx_cycle_search]) swaps++;
old_idx = idx_cycle_search; idx_cycle_search = permute [idx_cycle_search]; permute [old_idx] = -1; //也可以使用'= -idx_cycle_search-1'而不是'-1',并允许撤消对permute []数组的这些更改 } }

2
你的答案接近正确。如果你在每个循环后不重新开始 idx_cycle_search,那么你将以 O(n) 的执行时间完成。如果 permute 是恒等变换,那么你当前的算法将在此循环中执行 n * (n + 1) / 2 次迭代。 - meriton

1

如果您不介意为额外的索引散列表分配内存,您可以保留原始位置到当前位置的映射,以获得接近O(n)的时间复杂度。这是一个Ruby示例,因为它易于阅读且类似于伪代码。(这可能可以更短或更符合Ruby习惯用语,但我已经写出来了以便清晰明了。)

#!/usr/bin/ruby

objects       = ['d', 'e', 'a', 'c', 'b']
order         = [2, 4, 3, 0, 1]
cur_locations = {}

order.each_with_index do |orig_location, ordinality|
  # Find the current location of the item.
  cur_location = orig_location
  while not cur_locations[cur_location].nil? do
    cur_location = cur_locations[cur_location]
  end

  # Swap the items and keep track of whatever we swapped forward.
  objects[ordinality], objects[cur_location] = objects[cur_location], objects[ordinality]
  cur_locations[ordinality] = orig_location
end

puts objects.join(' ')

这显然需要一些额外的哈希内存,但由于它仅用于索引而不是您的“相当大”的对象,希望这是可以接受的。由于哈希查找是O(1),即使由于一个项目已经向前交换了多次并且您必须多次重写cur_location而导致复杂度略微增加,但整个算法应该相对接近O(n)。

如果您想要提前构建原始位置到当前位置的完整哈希表,或者保留当前位置到原始位置的反向哈希表,并稍微修改算法以将其降至严格的O(n),那么也可以。这会更加复杂并占用更多空间,因此这是我编写的版本,但修改不应该很困难。

编辑:实际上,我相当确定时间复杂度只是O(n),因为每个序数最多只能有一个关联的跳跃,因此最大查找次数限制为n。


1
#!/usr/bin/env python

def rearrange(objects, permutation):
    """Rearrange `objects` inplace according to `permutation`.

       ``result = [objects[p] for p in permutation]``
    """
    seen = [False] * len(permutation)
    for i, already_seen in enumerate(seen):
        if not already_seen: # start permutation cycle
            first_obj, j = objects[i], i
            while True:
                seen[j] = True
                p = permutation[j]
                if p == i: # end permutation cycle
                    objects[j] = first_obj    # [old] p -> j
                    break
                objects[j], j = objects[p], p #       p -> j

我写完后注意到,这个算法与@meriton's answer in Java中的算法相同。

以下是代码的test函数:

def test():
    import itertools
    N = 9
    for perm in itertools.permutations(range(N)):
        L = range(N)
        LL = L[:]
        rearrange(L, perm)
        assert L == [LL[i] for i in perm] == list(perm), (L, list(perm), LL)

    # test whether assertions are enabled
    try:
        assert 0
    except AssertionError:
        pass
    else:
        raise RuntimeError("assertions must be enabled for the test")

if __name__ == "__main__":
    test()

0

我恐怕只能使用快速排序的方法了。实际上,我正在进行某种空间分区层次结构,使用枢轴将对象列表在每个级别上分成两部分。 - int3

0

我可以使用O(N)的空间来完成它——将其复制到新数组中,然后再复制回来。

编辑:我知道有一种算法可以进行操作。这个想法是在整数1..N的数组上执行交换,同时在你的大对象数组上镜像交换。我现在只是找不到这个算法。


3
你猜测可能忽略了“原地”这个限制? - Alex Feinman
1
他所描述的算法已经被实施,只是它使用了 O(n) 的额外内存。这可能不是 OP 想要的,但在他所要求的范围内。 - David Seiler
我所说的“原地”是指使用O(1)额外的内存.. 是的。 - int3

0

我浏览了那篇论文。在我看来,他们似乎没有声称最坏情况下的时间复杂度为O(n)。 - meriton

0

我最终为此编写了一个不同的算法,它首先生成一个交换列表以应用顺序,然后运行交换以应用它。优点是,如果您将排序应用于多个列表,则可以重复使用交换列表,因为交换算法非常简单。

void make_swaps(vector<int> order, vector<pair<int,int>> &swaps)
{
    // order[0] is the index in the old list of the new list's first value.
    // Invert the mapping: inverse[0] is the index in the new list of the
    // old list's first value.
    vector<int> inverse(order.size());
    for(int i = 0; i < order.size(); ++i)
        inverse[order[i]] = i;

    swaps.resize(0);

    for(int idx1 = 0; idx1 < order.size(); ++idx1)
    {
        // Swap list[idx] with list[order[idx]], and record this swap.
        int idx2 = order[idx1];
        if(idx1 == idx2)
            continue;

        swaps.push_back(make_pair(idx1, idx2));

        // list[idx1] is now in the correct place, but whoever wanted the value we moved out
        // of idx2 now needs to look in its new position.
        int idx1_dep = inverse[idx1];
        order[idx1_dep] = idx2;
        inverse[idx2] = idx1_dep;
    }
}

template<typename T>
void run_swaps(T data, const vector<pair<int,int>> &swaps)
{
    for(const auto &s: swaps)
    {
        int src = s.first;
        int dst = s.second;
        swap(data[src], data[dst]);
    }
}

void test()
{
    vector<int> order = { 2, 3, 1, 4, 0 };

    vector<pair<int,int>> swaps;
    make_swaps(order, swaps);

    vector<string> data = { "a", "b", "c", "d", "e" };
    run_swaps(data, swaps);
}

请勿编辑我回答中您不喜欢的内容。 - Glenn Maynard

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