libstdc++ std::inplace_merge (__merge_without_buffer) 的时空复杂度

time and space complexity of libstdc++ std::inplace_merge (__merge_without_buffer)

提问人:pts 提问时间:7/18/2023 最后编辑:pts 更新时间:7/21/2023 访问量:46

问:

libstdc++ std::inplace_merge调用的__merge_without_buffer实现算法的时间和空间复杂度是多少?更具体地说,我对这些感兴趣:

  • 递归深度的上限。是 <= 2 * ceil(log2(n)) 吗?

  • 比较次数的上限。是 <= 2 * ceil(log2(n)) * n 吗?

  • 掉期次数的上限。是 <= 2 * ceil(log2(n)) * n 吗?

理想情况下,该算法是一篇著名论文中描述的著名算法,并且该论文包含这些上限作为证明。但是,libstdc++ 源代码未指定任何源代码。

对于上面的 <= ... 问题,我凭直觉得到了这些公式猜想,运行了 C 程序,对许多输入进行了计数,并检查了这些输入的上限。但是,在一般情况下,我无法证明我的任何猜想。对于递归深度公式,将常数从 2 更改为 1.5 会使其为 false,我发现了一些反例。

请注意,我对 O(...) 类不感兴趣,但我想要一个实际的上限,具有特定的常量(希望是 2 或更少)。我还需要上限公式的证明。引文就足以作为证据。

根据实现的注释和文档,我认为比较次数和交换次数都是 O(log2(n) * n)。但是,没有证据,这不是上限公式,并且缺少递归深度的公式。

很容易证明,在常规(非就地)合并算法中,没有递归,比较次数为 <= n - 1,没有交换,副本数为 n。但是,我对就地合并算法特别感兴趣。

上面的 n 是输入/输出数组中的元素总数 (== n1 + n2 == len1 + len2 == __len1 + __len2)。

在计算 rotate 函数使用的交换次数时,请使用比传递给 rotate 函数的元素总数少 1 作为上限。

以下是此算法的一些实现:

  • C++:libstdc++ 中的__merge_without_buffer函数,由 std::inplace_merge 调用。
  • Java:合并,在 Java 中重新实现 C++ __merge_without_buffer 函数。
  • C:ip_merge_,在 C 中重新实现 C++ __merge_without_buffer 函数。

请注意,上述实现在递归深度、比较次数和交换次数(如上定义)方面是等效的。

这篇博文介绍了一种不同的就地合并排序。该算法仅交换相同大小的相邻内存区域。

实用就地合并一文(也在此处的 C++ 代码注释中进行了解释)解释了一种不同的就地合并算法。

为了完整起见,我在这里复制了 C 实现:

#define SWAP(type, a, b) \
    do { type t=(a);(a)=(b);(b)=t; } while (0)

static void reverse_(int* a, int* b)
{
    for ( --b; a < b; a++, b-- )
       SWAP(int, *a, *b);
}
static int* rotate_(int* a, int* b, int* c)
/* swap the sequence [a,b) with [b,c). */
{
    if (a != b && b != c)
     {
       reverse_(a, b);
       reverse_(b, c);
       reverse_(a, c);
     }
    return a + (c - b);
}

static int* lower_bound_(int* a, int* b, const int key)
/* find first element not less than @p key in sorted sequence or end of
 * sequence (@p b) if not found. */
{
    int i;
    for ( i = b-a; i != 0; i /= 2 )
     {
       int* mid = a + i/2;
       if (*mid < key)
          a = mid + 1, i--;
     }
    return a;
}
static int* upper_bound_(int* a, int* b, const int key)
/* find first element greater than @p key in sorted sequence or end of
 * sequence (@p b) if not found. */
{
    int i;
    for ( i = b-a; i != 0; i /= 2 )
     {
       int* mid = a + i/2;
       if (*mid <= key)
          a = mid + 1, i--;
     }
    return a;
}

static void ip_merge_(int* a, int* b, int* c)
/* inplace merge. */
{
    int n1 = b - a;
    int n2 = c - b;

    if (n1 == 0 || n2 == 0)
       return;
    if (n1 == 1 && n2 == 1)
     {
       if (*b < *a)
          SWAP(int, *a, *b);
     }
    else
     {
       int* p, * q;

       if (n1 <= n2)
          p = upper_bound_(a, b, *(q = b+n2/2));
       else
          q = lower_bound_(b, c, *(p = a+n1/2));
       b = rotate_(p, b, q);

       ip_merge_(a, p, b);
       ip_merge_(b, q, c);
     }
}
合并就

评论

0赞 tgdavies 7/18/2023
为什么这被标记为 Java?关于时间和空间的复杂性,你有什么具体的问题?这看起来像一个家庭作业转储。
0赞 pts 7/18/2023
@tgdavies:这不是家庭作业转储。我们在大学里的算法分析作业要容易得多。我非常具体的问题列在问题开头附近的 3 个要点中(所有 3 个都是上限的公式)。我试图非常具体和清楚地说明它们。你有什么建议我如何使它们更清楚吗?
0赞 tgdavies 7/18/2023
你问的是“是不是 <= 2 * ceil(log2(n)))”。你能说:“我相信它是 <= 2 * ceil(log2(n)),但我不确定,因为 [你对你的证明有具体的怀疑]”?我认为这将是一个进步。
0赞 pts 7/18/2023
@tgdavies:对于这些公式,我所拥有的只是特定测试输入的一些计数输出。我没有一般情况的证据。我在这个问题中添加了更多细节。
0赞 pts 7/21/2023
@tgdavies:到目前为止,我添加了一个包含我分析的答案。它的问题是比较次数的上限是 O(n**2),我需要 O(n * log(n))),就像C++ std::inplace_merge一样。算法足够快,但分析不够精确。

答:

0赞 pts 7/21/2023 #1

递归深度的上限。

这是 <= 2 * ceil(log2(m)) 的证明,其中 m 是较长输入列表的大小(即 m = max(b - a, c - b) = max(n1, n2) 对于问题中的 ip_merge_ C 函数。

如果 m <= 1,则没有递归调用,因此递归深度为 0。

如果我们能证明每个深度 2 递归调用的较长列表的大小为 <= ceil(m/2),那么通过归纳,我们得到在 2 * ceil(log2(m)) 递归调用后,较长列表的大小变为 <= 1,因此不再递归,因此我们完成了。

因此,还需要证明每个深度 2 递归调用的较长列表的大小为 <= ceil(m/2)。让我们看一下原始调用、深度 1 递归调用(0、1 或 2)和深度 2 递归调用(0、1、2、3 或 4):

  • 原始调用:K(较短列表),M(较长列表)。m >= k
  • 每个深度 1 递归调用:a(由较短列表中的 upper_bound_ 或 lower_bound_ 返回值确定)和 b = ceil(m/2)(因为较长的列表被拆分为两半)。a <= k(因为较短的列表在 a 处拆分)。
  • 如果 A <= ceil(m/2):c(由较短列表中的 upper_bound_ 或 lower_bound_ 的返回值确定)和 d(较长列表分成两半),则每个深度 2 递归调用。a <= ceil(m/2) = b,因此 a <= b,因此较长列表的长度为 b。c <= a(因为较短的列表在 c 处拆分)。d = ceil(b/2) (因为较长的列表被分成两半)。我们需要证明 max(c, d) <= ceil(m/2)。这是从 c <= a <= b = ceil(m/2) 和 d = ceil(b/2) = ceil(ceil(m/2)/2) <= ceil(m/2) 得出的。
  • 每个深度 2 递归调用,如果 A > ceil(m/2):c(由较短列表中 upper_bound_lower_bound_ 的返回值确定)和 d(较长的列表分成两半)。a > ceil(m/2) = b,因此 a > b,因此较长的列表具有长度 a。c <= b(因为较短的列表在 c 处拆分)。d = ceil(a/2) (因为较长的列表被分成两半)。我们需要证明 max(c, d) <= ceil(m/2)。这是从 c <= b = ceil(m/2) 和 d = ceil(a/2) <= ceil(k/2) <= ceil(m/2) 得出的。

比较次数的上限。

这是部分答案。

让我们将 bsl(r) 定义为大小为 r 的列表中由二进制搜索(upper_bound_lower_bound_)完成的比较次数。bsl(0) = 0,对于任何 r >= 1,bsl(r) = floor(log2(r)) + 1。请注意:bsl(1) = 1, bsl(2) = 2, bsl(3) = 2, bsl(4) = 3

让我们看一下原始调用和深度 1 递归调用(0、1 或 2):

  • 原始调用:K(较短列表),M(较长列表)。m >= k。如果 k = 0,则有 0 个比较。否则,如果 k = m = 1,则有 1 个比较。否则,二进制搜索是在较短的列表中完成的,因此比较次数(在上述任何情况下)为 <= bsl(k) <= bsl(m)。
  • 每个深度 1 递归调用:a(由较短列表中的 upper_bound_ 或 lower_bound_ 返回值确定)和 b = ceil(m/2)(因为较长的列表被拆分为两半)。a <= k(因为较短的列表在 a 处拆分)。二进制搜索是在较短的列表中完成的,因此比较次数为 <= bsl(min(a, b)) <= bsl(min(k, ceil(m/2))) <= bsl(ceil(m/2)) <= bsl(m)。此外,它是 <= bsl(k)。如果未进行二叉搜索(因为 a = 0 或 b = 0a = b = 1),则这些上限也成立。

因此,涵盖原始调用 (1) 和深度 1 递归调用(0、1 或 2)的比较总数为 <= 3 * bsl(m)。对于深度 2 递归调用(0、1、2、3、4),我们可以使用前面的结果,即较长列表的大小为 <= ceil(m/2)。

tnc(m) 是较长列表大小为 m 的调用的比较总数。我们有 tnc(m) <= 3 * bsl(m) + 4 * tnc(ceil(m/2))。此外,对于 m <= 1,我们有 tnc(m) <= m此外,通过对 mk 进行更详细的分析,我们得到 tnc(2) <= 8。(泛型公式中的 tnc(2) 限制为 10。

对于任何 q >= 1,此递归的闭合公式为 tnc(2**q) <= 1/18 * (5 * 4 ** q - 6 * q - 14)。 由此我们得到 tnc(m) < 5/18 * m ** 2 对于任何 m >= 2。这个上限太多了,我们需要像 O(m * log2(m)) 这样的东西。算法足够快;我们分析中的界限不够精确。

掉期次数的上限。

还没有公式。