【问题标题】:Reverse an array without using iteration在不使用迭代的情况下反转数组
【发布时间】:2020-10-14 01:40:44
【问题描述】:

今天有人问我一个问题,我不相信这是可能的,但我可能是错的,或者是我想多了。如何在不使用 C 中的迭代的情况下反转数组?

我的想法是这是不可能的,因为数组可以是任意大小,并且在不使用某种形式的迭代的情况下,任何 C 程序都不能在考虑到这种支持的情况下表达。

【问题讨论】:

  • 这是一个课堂问题吗?面试题?一般的谜题?
  • 一个例子可能是个好主意:{ 0, 1, 2, 3, 4 } 在内存中变成 { 4, 3, 2, 1, 0 }。
  • +1。这可能是不可能的。但我们需要一些证据或一群人来确认它是否可能。 :)
  • 这将主要归结为定义,例如究竟什么属于/不属于“迭代”。
  • 这里的迭代指的是 for(;;)/while(),我认为。所以递归可能是解决方案。如果我们将迭代称为访问所有元素,那么它是不可能的。

标签: c algorithm


【解决方案1】:

您的问题的答案是,是的,可以在不迭代的情况下反转数组。问题本身的措辞可能模棱两可,但问题的精神是显而易见的:可以使用递归算法;在这个意义上,recursive的含义也完全没有歧义。

如果在与顶级公司的面试中,你被问到这个问题,那么下面的伪代码就足以证明你真正理解什么是递归:

function reverse(array)

    if (length(array) < 2) then
        return array

    left_half = reverse(array[0 .. (n/2)-1])
    right_half = reverse(array[(n/2) .. (n-1)])

    return right_half + left_half

end

例如,如果我们有一个包含 16 个元素的数组,其中包含拉丁字母的前 16 个字母 [A]..[P],则上述反向算法可以可视化如下:

                   Original Input

1.                ABCDEFHGIJKLMNOP                   Recurse
2.        ABCDEFGH                IJKLMNOP           Recurse
3.    ABCD        EFGH        IJKL        MNOP       Recurse
4.  AB    CD    EF    GH    IJ    KL    MN    OP     Recurse

5. A  B  C  D  E  F  G  H  I  J  K  L  M  N  O  P    Terminate

6.  BA    DC    FE    HG    JI    LK    NM    PO     Reverse
7.    DCBA        HGFE        LKJI        PONM       Reverse
8.        HGFEDCBA                PONMLKJI           Reverse
9.                PONMLKJIHGFEDCBA                   Reverse

                  Reversed Output

使用递归算法解决的任何问题都遵循分而治之范式,即:

  1. 问题被划分为 [两个或更多] 子问题,其中每个子问题小于原始问题,但可以以与原始问题类似的方式解决(划分 )。

  2. 问题被划分为 [两个或更多] 子问题,其中每个子问题都是独立的,可以递归解决,如果足够小,也可以直接解决(征服 )。

  3. 问题被划分为 [两个或更多] 子问题,其中这些子问题的结果被组合以给出原始问题的解决方案(Combine)。 p>

上面用于反转数组的伪代码严格满足上述条件。因此,它可以被认为是一种递归算法,我们可以毫无疑问地声明,可以在不使用迭代的情况下对数组进行反转。


其他背景信息
迭代、递归实现和递归算法的区别

递归实现意味着算法是递归的,这是一个常见的误解。它们不是等价的。这是一个明确的解释,包括对上述解决方案的详细解释。



什么是迭代和递归?

早在 1990 年,计算机科学领域最受尊敬的三位现代算法分析学者Thomas H. CormenCharles E. LeisersonRonald L. Rivest,发表了他们广受好评的Introduction to Algorithms .在这本书中,它代表了 200 多本受人尊敬的文本的汇集,并且 20 多年来一直被用作世界上大多数一流大学教授算法的第一个也是唯一一个文本,Mssrs . Cormen、Leiserson 和 Rivest 明确说明了什么构成 迭代 以及什么构成 递归

在他们对两种经典排序算法插入排序合并排序的分析和比较中,他们解释了迭代和递归算法(有时称为iteration 的经典数学概念在相同的上下文中使用时,增量算法可以消除歧义。

首先,插入排序被归类为迭代算法,其行为总结如下:

对子数组 A[1..j-1] 进行排序后,我们将单个项 A[j] 插入到适当的位置,生成排序后的数组 A [1..j].

来源:Introduction to Algorithms - Cormen, Leisersen, Rivest, 1990 MIT Press

该语句将迭代算法归类为依赖于算法先前执行(“迭代”)的结果或状态的算法,并且此类结果或状态信息随后用于解决当前迭代的问题。

另一方面,归并排序被归类为递归算法。递归算法符合称为分而治之的处理范例,这是一组区分递归算法与非递归算法的操作的三个基本标准。如果在处理给定问题期间,算法可以被认为是递归的:

  1. 问题被划分为 [两个或更多] 子问题,其中每个子问题小于原始问题,但可以以与原始问题类似的方式解决(划分 )。

  2. 问题被划分为 [两个或更多] 子问题,其中每个子问题可以递归解决,如果足够小,则可以直接解决(Conquer)。

  3. 问题被划分为 [两个或更多] 子问题,其中这些子问题的结果被组合以给出原始问题的解决方案(Combine)。 p>

参考:Introduction to Algorithms - Cormen, Leisersen, Rivest, 1990 MIT Press

迭代算法和递归算法都会继续工作,直到达到终止条件。插入排序的终止条件是第 j 项已正确放置在数组 A[1..j] 中。分治算法的终止条件是范式的准则 2“触底”,即子问题的大小达到足够小的大小,无需进一步细分即可解决。

请务必注意,分而治之范式要求子问题必须能够以与原始问题类似的方式解决,以允许递归。由于原始问题是一个独立问题,没有外部依赖项,因此子问题也必须是可解决的,就好像它们是没有外部依赖项的独立问题一样,特别是在其他子问题上。这意味着分治算法中的子问题应该自然独立

相反,同样重要的是要注意迭代算法的输入基于算法的先前迭代,因此必须按顺序考虑和处理。这会在迭代之间产生依赖关系,从而阻止算法将问题划分为可以递归解决的子问题。例如,在插入排序中,您不能将项目 A[1..j] 分成两个子集,这样在 A[j 的数组中的排序位置] 在所有项目 A[1..j-1] 被放置之前决定,因为 A[j] 的真正正确位置可能会移动,而任何 A[ 1..j-1] 正在被自己放置。

递归算法与递归实现

递归这个术语的普遍误解源于一个普遍的错误假设,即某些任务的递归实现自动意味着问题已经解决使用递归算法。递归算法与递归实现不同,而且从来都不是。

递归实现涉及一个函数或一组函数,它们最终调用自己,以便以与解决整个任务完全相同的方式解决整个任务的子部分。递归 算法(即那些满足分而治之范式的算法)非常适合递归实现。但是,递归算法可以仅使用迭代构造(如 for(...)while(...))来实现,因为所有算法(包括递归算法)最终都会重复执行某些任务以获得结果。

这篇文章的其他贡献者已经完美地证明了迭代算法可以使用递归函数来实现。事实上,对于涉及迭代直到满足某个终止条件的一切,递归实现都是可能的。底层算法中没有 Divide 或 Combine 步骤的递归实现等效于具有标准终止条件的迭代实现。

以插入排序为例,我们已经知道(并且已经证明)插入排序是一种迭代算法。但是,这并不妨碍插入排序的递归实现。实际上,可以很容易地创建递归实现,如下所示:

function insertionSort(array)

    if (length(array) == 1)
        return array
    end

    itemToSort = array[length(array)]
    array = insertionSort(array[1 .. (length(array)-1)])

    find position of itemToSort in array
    insert itemToSort into array

    return array

end

可以看出,实现是递归的。但是,插入排序是一种迭代算法,我们知道这一点。那么,我们怎么知道即使使用上面的递归实现,我们的插入排序算法也没有变成递归的呢?让我们将分而治之范式的三个标准应用于我们的算法并进行检查。

  1. 问题被划分为 [两个或更多] 子问题,其中每个子问题都小于原始问题,但可以以与原始问题类似的方式解决。

    YES:排除长度为 1 的数组,将项 A[j] 插入到数组中适当位置的方法与用于插入的方法相同将所有先前的项目 A[1..j-1] 插入到数组中。

  2. 问题被划分为 [两个或更多] 子问题,其中每个子问题都是独立的,可以递归解决,如果足够小,也可以直接解决。

    :项目 A[j] 的正确放置完全依赖包含 A[1..j 的数组-1] 项和正在排序的项。因此,在处理数组的其余部分之前,不会将 item A[j](称为 itemToSort)放入数组中。

  3. 问题被划分为 [两个或更多] 子问题,这些子问题的结果被组合起来以给出原始问题的解决方案。

    NO:作为一种迭代算法,在任何给定的迭代中只能正确放置一项 A[j]。空间 A[1..j] 没有被划分为 A[1], A[2]...A[j] 都正确的子问题独立放置,然后将所有这些正确放置的元素组合起来得到排序数组。

显然,我们的递归实现并没有使插入排序算法本质上是递归的。实际上,在这种情况下,实现中的递归起到了流控制的作用,允许迭代继续进行,直到满足终止条件。因此,使用递归实现并没有将我们的算法变成递归算法。

在不使用迭代算法的情况下反转数组

既然我们了解了什么使算法具有迭代性,以及什么使算法具有递归性,那么我们如何“不使用迭代”来反转数组呢?

有两种方法可以反转数组。这两种方法都要求您提前知道数组的长度。迭代算法因其效率而受到青睐,其伪代码如下:

function reverse(array)

    for each index i = 0 to (length(array) / 2 - 1)
        swap array[i] with array[length(array) - i]
    next

end

这是一个纯粹的迭代算法。让我们通过将其与决定算法的递归性的分而治之范式进行比较来检验为什么我们可以得出这个结论。

  1. 问题被划分为 [两个或更多] 子问题,其中每个子问题都小于原始问题,但可以以与原始问题类似的方式解决。

    :数组的反转被分解为最细的粒度、元素,并且每个元素的处理与所有其他处理的元素相同。

  2. 问题被划分为 [两个或更多] 子问题,其中每个子问题都是独立的,可以递归解决,如果足够小,也可以直接解决。

    YES:数组中元素 i 的反转是可能的,而不需要元素 (i + 1)(例如)已经反转与否。此外,数组中元素i的反转不需要其他元素反转的结果才能完成。

  3. 问题被划分为 [两个或更多] 子问题,这些子问题的结果被组合起来以给出原始问题的解决方案。

    NO:作为一种迭代算法,每个算法步骤只执行一个计算阶段。它不会将问题划分为子问题,也不会将两个或多个子问题的结果合并得到一个结果。

我们上面第一个算法的上述分析证实它不符合分而治之范式,因此不能被认为是递归算法。然而,由于标准 (1) 和标准 (2) 都得到满足,显然递归算法是可能的。

关键在于我们迭代解决方案中的子问题具有最小可能的粒度(即元素)。通过将问题划分为越来越小的子问题(而不是从一开始就追求最细粒度),然后合并子问题的结果,可以使算法递归。

例如,如果我们有一个包含 16 个元素的数组,其中包含拉丁字母 (A..P) 的前 16 个字母,则递归算法在视觉上看起来如下所示:

                   Original Input

1.                ABCDEFHGIJKLMNOP                   Divide
2.        ABCDEFGH                IJKLMNOP           Divide
3.    ABCD        EFGH        IJKL        MNOP       Divide
4.  AB    CD    EF    GH    IJ    KL    MN    OP     Divide

5. A  B  C  D  E  F  G  H  I  J  K  L  M  N  O  P    Terminate

6.  BA    DC    FE    HG    JI    LK    NM    PO     Conquer (Reverse) and Merge
7.    DCBA        HGFE        LKJI        PONM       Conquer (Reverse) and Merge
8.        HGFEDCBA                PONMLKJI           Conquer (Reverse) and Merge
9.                PONMLKJIHGFEDCBA                   Conquer (Reverse) and Merge

                  Reversed Output

从顶层开始,16 个元素逐渐分解为大小完全相同的较小子问题(级别 1 到 4),直到我们达到子问题的最细粒度;正向排列的单位长度数组(第 5 步,单个元素)。此时,我们的 16 个数组元素看起来仍然是有序的。但是,它们同时也是反转的,因为单个元素数组本身也是反转数组。然后将单元素数组的结果合并得到八个长度为 2 的反向数组(步骤 6),然后再次合并得到四个长度为 4 的反向数组(步骤 7),依此类推,直到我们的原始数组被重构反过来(步骤 6 到 9)。

递归算法反转数组的伪代码如下:

function reverse(array)

    /* check terminating condition. all single elements are also reversed
     * arrays of unit length.
     */
    if (length(array) < 2) then
        return array

    /* divide problem in two equal sub-problems. we process the sub-problems
     * in reverse order so that when combined the array has been reversed.
     */
    return reverse(array[(n/2) .. (n-1)]) + reverse(array[0 .. ((n/2)-1)])

end

如您所见,该算法将问题分解为子问题,直到达到可立即给出结果的子问题的最细粒度。然后在合并结果时反转结果以提供反转的结果数组。虽然我们认为这个算法是递归的,但让我们应用分治算法的三个标准来确认。

  1. 问题被划分为 [两个或更多] 子问题,其中每个子问题都小于原始问题,但可以以与原始问题类似的方式解决。

    :在第 1 层反转数组可以使用与第 2、3、4 或 5 层完全相同的算法来完成。

  2. 问题被划分为 [两个或更多] 子问题,其中每个子问题都是独立的,可以递归解决,如果足够小,也可以直接解决。

    YES:每个不是单位长度的子问题都通过将问题分成两个独立的子数组并递归地反转这些子数组来解决。单位长度数组是可能的最小数组,它们本身是反转的,因此提供了终止条件和保证的第一组组合结果。

  3. 问题被划分为 [两个或更多] 子问题,这些子问题的结果被组合起来以给出原始问题的解决方案。

    :第 6、7、8 和 9 级的每个问题都仅由上一级的结果组成;即他们的子问题。在每个级别反转数组会导致整体反转结果。

可以看出,我们的递归算法通过了分而治之范式的三个标准,因此可以被认为是真正的递归算法。因此,可以在不使用迭代算法的情况下反转数组。

有趣的是,我们原来的数组反转迭代算法可以使用递归函数实现。这种实现的伪代码如下:

function reverse(array)

    if length(array) < 2
        return
    end

    swap array[0] and array[n-1]
    reverse(array[1..(n-1)])

end

这类似于其他海报提出的解决方案。这是一个递归实现,因为定义的函数最终会调用自身以对数组中的所有元素重复执行相同的任务。但是,这不会使算法递归,因为没有将问题划分为子问题,也没有将子问题的结果合并到给出最终结果。在这种情况下,递归只是被用作流控制结构,并且在算法上可以证明总体结果以完全相同的顺序执行相同的步骤序列,与为解决方案。

这就是迭代算法递归算法递归实现之间的区别。

【讨论】:

  • 你真的输入这个吗?!
  • 我立即知道的初始解决方案;这是递归的教科书用法。它的背景花了几天时间来写。最重要的是,原始海报可能被误导并接受了错误的答案。这就是我想要解决的问题。
  • @aps2012 我想你的意思是拉丁字母,因为现在只使用罗马数字
  • -1 尽管递归算法很好,因为递归“算法”和“实现”之间的区别完全是虚构的。迭代算法只是一种递归算法,其中一个子问题始终是基本情况:迭代算法\子集递归算法。让我澄清一下你对插入排序的困惑:我们有 IS(x:rest) = merge(IS(x), IS(rest)),其中 IS(x) = x。将元素 x 合并到列表的其余部分中在将问题一分为二后合并排序所做的合并!
  • @RayToal:谢谢,感谢您为外交而付出的努力,我同意对于递归的确切含义还有争论的余地(例如,如果编译了尾递归函数,它是否仍然是“递归”到迭代循环?),但我必须坚持认为 aps2012 的分类在客观上是错误的(她/他忘记了结合已解决的子问题)并且令人困惑。考虑到他/她的点赞数(我推测基于帖子的长度和 Cormen 等人的提及),这会误导很多人!
【解决方案2】:

正如人们在cmets中所说,这取决于迭代的定义。

案例1.迭代作为一种编程风格,不同于递归

如果将递归(简单地)作为迭代的一种替代方法,那么 Kalai 提出的递归解决方案就是正确的答案。

案例 2. 迭代作为下限线性时间

如果将迭代视为“检查每个元素”,那么问题就变成了数组反转是否需要线性时间还是可以在亚线性时间内完成。

为了说明不存在用于数组反转的次线性算法,请考虑一个包含 n 个元素的数组。假设存在一个不需要读取每个元素的反转算法A。然后在0..n-1 中存在一个元素a[i] 用于某些i,该算法从不读取,但仍然能够正确反转数组。 (编辑:我们必须排除奇数长度数组的中间元素——从这个范围看下面的 cmets——看下面的 cmets——但这不会影响算法是线性的还是在渐近情况下是次线性的。)

由于算法从不读取元素a[i],我们可以更改它的值。说我们这样做。然后,该算法根本没有读取过这个值,它将产生与我们改变它的值之前相同的反转答案。但是这个答案对于a[i] 的新值是不正确的。因此,不存在至少不读取每个输入数组元素(保存一个)的正确反转算法。因此,数组反转的下限为 O(n),因此 需要 迭代(根据此场景的工作定义)。

(请注意,此证明仅适用于数组反转,并未扩展到真正具有亚线性实现的算法,例如二分查找和元素查找。)

案例 3. 作为循环结构的迭代

如果将迭代视为“循环直到满足条件”,那么这将转换为具有条件跳转的机器代码,已知需要一些严重的编译器优化(利用分支预测等)在这种情况下,有人问是否有一种方法可以做一些“没有迭代”的事情,循环展开(到直线代码)。在这种情况下,您原则上可以编写直线(无循环)C 代码。但这种技术并不通用;仅当您事先知道数组的大小时,它才有效。 (很抱歉将这个或多或少轻率的情况添加到答案中,但我这样做是为了完整性,因为我听说过以这种方式使用的术语“迭代”,循环展开是一个重要的编译器优化。)

【讨论】:

  • " 但是这个答案对于 a[i] 的新值是不正确的。" 错误。这不是反向排序。反转与存储值无关。在任何奇数长度的数组中,您根本不需要读取中间元素。
  • @WillNess:奇数数组中间的元素是 only 元素,您可以安全地避免阅读。 IOW 您可以跳过仅读取 O(1) 个 O(n) 个元素。如果 Ray 的证明解决了这个极端情况(并且如果它说“但是我们总是可以为 a[i] 选择一个值,这样这个答案对于 a 的新值是不正确的)会更好[i]") 但他的推理和 O(n) 下限基本上是合理的。
  • @j_random_hacker 我不关注。反转与存储的值无关。想象一下存储的值是复杂的结构体,而数组单元保存着指向这些值的指针;那么 reversal 甚至不必读取任何值,它只交换存储在单元格中的指针。事实上,Q 与数组的实现有关。您可以实现一个数组,以便轻松地进行反转花费 O(1) 时间。只需在上面放一个寻址适配器即可。
  • @WillNess:即使数组元素是复杂的结构,O(n) - 1 = O(n) 指针也需要交换 => O(n) 工作。构建逆向适配器通常是一个好主意,因为它允许 O(1) 查找,但这超出了问题定义的范围——同样,您可以使用使用偏移量的适配器创建数组的旋转视图,但这就是与旋转其元素不同:)
  • @WillNess:尽可能清楚:对于这个问题,先验指定了一个函数 A[i],它返回数组 A 中位置 i 处的值;我们被要求重新排列 A 以使其 相对于该函数 反向结束。出于这个问题的目的,我们不允许构造一个新函数 A'[i] 以使数组与 w.r.t 反转。它。
【解决方案3】:

使用递归函数。

void reverse(int a[],int start,int end)
{
     int temp;
     temp = a[start];
     a[start] = a[end];
     a[end] = temp;


    if(start==end ||start==end-1)
       return;
    reverse(a, start+1, end-1);
}

只需将上述方法调用为reverse(array,0,lengthofarray-1)

【讨论】:

  • 递归是变相的迭代。最后,无论如何,CPU都会迭代地访问内存。只有无用的开销。
  • @aps2012,这一个递归算法:它本质上与rev(a) = (a[n]) + rev(a[2..n-1]) + (a[1])相同(带有适当的停止条件)。
  • @aps2012,争论定义是没有意义的(这就是它的本质),但是,常见的用法表明这个答案是递归的:除了你的答案,alldefinitions of @ 987654323@,包括recursive algorithms,我见过的不仅仅是分而治之。
  • (此外,您的被动攻击性声明“分享您的智慧,像我一样详细”是不必要的,以后请不要这样做;例如“如果您能解释一下自己,我将不胜感激它”会好得多。)
  • @aps2012,我确实备份了我的观点:这个答案满足“递归算法”的传统定义。您没有阅读我提供的 4 个链接吗? (无论如何,我讨厌与权威争论,但是:我在这里的时间比你长(并且每个答案都有更多的“声誉”和更高的平均声誉),所以我可能更好地理解 SO 的动态。:))
【解决方案4】:

实现一个递归函数来反转一个排序的数组。即,给定数组 [ 1, 2, 3, 4, 5] 你的 过程应该返回 [5, 4, 3, 2, 1]。

【讨论】:

  • 没有理由为已回答的问题添加答案。这不会在这里增加任何价值,除非您的答案与已经说明的答案不同,否则您也不会得到任何支持。尝试解决没有任何公认答案的问题。
【解决方案5】:

这是一个在 javascript 函数中使用递归的巧妙解决方案。除了数组本身,它不需要任何参数。

/* Use recursion to reverse an array */
function reverse(a){
    if(a.length==undefined || a.length<2){
        return a;
    }
    b=[];
    b.push(reverse(copyChop(a)));
    b.push(a[0]);
    return b;
    /* Return a copy of an array minus the first element */
    function copyChop(a){ 
        b=a.slice(1); 
        return b;
    }
}

如下调用;

reverse([1,2,3,4]);

请注意,如果您不使用嵌套函数 copyChop 对数组进行切片,您最终会得到一个数组作为最终结果中的第一个元素。不太清楚为什么会这样

【讨论】:

  • 这个问题被标记为 C 那么为什么要给出 Javascript 解决方案?
【解决方案6】:
   #include<stdio.h>


   void rev(int *a,int i,int n)
  {

if(i<n/2)
{
    int temp = a[i];
    a[i]=a[n-i-1];
    a[n-i-1]=temp;
    rev(a,++i,n);
 }
}
int main()
    {
    int array[] = {3,2,4,5,6,7,8};
   int len = (sizeof(array)/sizeof(int));
   rev(array,0,len);    
   for(int i=0;i<len;i++)
   {
    printf("\n array[%d]->%d",i,array[i]);
  }
}

【讨论】:

    【解决方案7】:

    一种解决方案可能是:

    #include <stdio.h>
    #include <stdlib.h>
    
    void swap(int v[], int v_start, int v_middle, int v_end) {
        int *aux = calloc(v_middle - v_start, sizeof(int));
        
        int k = 0;
        for(int i = v_start; i <= v_middle; i++) {
            aux[k] = v[i];
            k = k + 1;
        }
    
        k = v_start;
        for(int i = v_middle + 1; i <= v_end; i++) {
            v[k] = v[i];
            k = k + 1;
        }
    
        for(int i = 0; i <= v_middle - v_start; i++) {
            v[k] = aux[i];
            k = k + 1;
        }
    }
    
    void divide(int v[], int v_start, int v_end) {
        if(v_start < v_end) {
            int v_middle = (v_start + v_start)/2;
            divide(v, v_start, v_middle);
            divide(v, v_middle + 1, v_end);
            swap(v, v_start, v_middle, v_end);
        }
    }
    
    int main() {
        int v[10] = {4, 20, 12, 100, 50, 9}, n = 6;
        
        printf("Array: \n");
        for (int i = 0; i < n; i++) {
            printf("%d ", v[i]);
        }
        printf("\n\n");
    
        divide(v, 0, n - 1);
    
        printf("Reversed: \n");
        for (int i = 0; i < n; i++) {
            printf("%d ", v[i]);
        }
    
        return 0;
    }
    

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2020-02-26
      • 2015-07-09
      • 2011-02-02
      相关资源
      最近更新 更多