【问题标题】:Time Complexity of String Subsequence Recursion字符串子序列递归的时间复杂度
【发布时间】:2016-04-20 13:10:00
【问题描述】:

如何计算下面算法的时间复杂度?谁能给我简单解释一下:

public static void print(String prefix, String remaining, int k) {
    if (k == 0) {
        StdOut.println(prefix);
        return;
    }
    if (remaining.length() == 0) return;
    print(prefix + remaining.charAt(0), remaining.substring(1), k-1);
    print(prefix, remaining.substring(1), k);
}


public static void main(String[] args) { 
    String s = "abcdef";
    int k = 3;
    print("", s, k);
}

【问题讨论】:

  • 注意:此代码打印字符串的所有长度为 k 的子序列。例如,("abcdef", 6) 产生“abc”、“abd”、“abe”、“abf”、“acd”、“ace”等,“def”。我们通过二项式定理知道有 (n 选择 k) 个这样的输出字符串。
  • 另请注意:此算法的时间复杂度取决于您使用的 Java 版本,因为旧版本使用索引操作(O(1) 时间)执行substring(),而新版本执行此操作通过复制(O(n)时间)。
  • @NayukiMinase:那么这里的实现并不重要 :-) n choose k 具有 O(n^2) 复杂度(stackoverflow.com/a/19416046/216248),因此,考虑任何较低程度的复杂度无关紧要。
  • @NayukiMinase 在你的例子中,那是("abcdef", 3)
  • @ringø 感谢指正

标签: string algorithm recursion big-o time-complexity


【解决方案1】:

简介

由于 body 是 O(1),或者至少可以重写为 O(1),我们只需要查看函数被调用了多少次。所以这个算法的时间复杂度将是函数被调用的次数与输入词的长度和输出前缀的长度有关。
n - 输入词的长度
k - 正在搜索的前缀长度

我以前从未做过类似的事情,并且我知道的用于查找递归方法的时间复杂度的常用方法在这种情况下不起作用。我首先查看根据 n 和 k 对函数进行了多少次调用,看看我是否能发现任何可能对我有帮助的模式。

收集数据

使用这段代码sn-p(对不起,丑陋的代码):

public static String word = "abcdefghij";
public static int wordLength = word.length();
public static int limit = 10;
public static int access = 0;

System.out.printf("Word length      : %6d %6d %6d %6d %6d %6d %6d %6d %6d %6d %6d\n",0,1,2,3,4,5,6,7,8,9,10);
System.out.printf("-----------------------------------------------------------------------------------------------\n");
for(int k = 0; k <= limit; k++) {
    System.out.printf("k : %2d - results :", k);
        for(int i = 0; i <= limit; i++) {
        print("", word.substring(0,i), k);
        System.out.printf(", %5d", access);
        access=0;
    }
        System.out.print("\n");
}
print(prefix, remaining, k) {
    access++;
    ... rest of code...
}

从这里我得到了:

Word length      :      0      1      2      3      4      5      6      7      8      9     10
-----------------------------------------------------------------------------------------------
k :  0 - results :,     1,     1,     1,     1,     1,     1,     1,     1,     1,     1,     1
k :  1 - results :,     1,     3,     5,     7,     9,    11,    13,    15,    17,    19,    21
k :  2 - results :,     1,     3,     7,    13,    21,    31,    43,    57,    73,    91,   111
k :  3 - results :,     1,     3,     7,    15,    29,    51,    83,   127,   185,   259,   351
k :  4 - results :,     1,     3,     7,    15,    31,    61,   113,   197,   325,   511,   771
k :  5 - results :,     1,     3,     7,    15,    31,    63,   125,   239,   437,   763,  1275
k :  6 - results :,     1,     3,     7,    15,    31,    63,   127,   253,   493,   931,  1695
k :  7 - results :,     1,     3,     7,    15,    31,    63,   127,   255,   509,  1003,  1935
k :  8 - results :,     1,     3,     7,    15,    31,    63,   127,   255,   511,  1021,  2025
k :  9 - results :,     1,     3,     7,    15,    31,    63,   127,   255,   511,  1023,  2045
k : 10 - results :,     1,     3,     7,    15,    31,    63,   127,   255,   511,  1023,  2047

在每种情况下至少进行一次调用,因为它是初始调用。从这里我们看到主对角线上下的所有东西都被称为 2min(n, k) + 1 - 1 次,就像其他人提到的那样。那是二叉树中的节点数。 每次调用 print 方法时,都会调用两个新方法 - 生成二叉树。

不过,主对角线上方的情况变得更加混乱,我没有看到任何常见的模式。

算法的图形表示

为了使其更直观,我使用了 graphviz (online version)。

这里的代码 sn-p 为给定的 n 和 k 生成用于 graphviz 的代码(绿色节点是从中找到解决方案的节点):

public static String word = "abcde";
public static int wordLength = word.length();
public static int limit = 3;

public static void main(String[] args) {

    String rootNode = "\"prefix|remaining|k\"";
    StringBuilder graph = new StringBuilder("digraph G { \n node [style=filled];");
    print("", word, limit, graph, rootNode);
    graph.append("\n\"prefix|remaining|k\" [shape=Mdiamond];\n}");
    System.out.println(graph);
}

public static void print(String prefix, String remaining, int k, StringBuilder sb, String parent) {

    String currentNode =  "\"" + prefix + "|" + (remaining.isEmpty() ? 0 : remaining) +  "|" + k + "\"";
    sb.append("\n " + parent + "->" + currentNode + ";");

    if(k == 0) {
        sb.append("\n " + currentNode + "[color=darkolivegreen3];");
        return;
    }

    if (remaining.length() == 0)return;

    print(prefix + remaining.charAt(0), remaining.substring(1), k - 1, sb,currentNode);
    print(prefix, remaining.substring(1), k, sb, currentNode);
}

图表示例(n=5,k=3):

digraph G { 
 node [style=filled];
 "prefix|remaining|k"->"|abcde|3";
 "|abcde|3"->"a|bcde|2";
 "a|bcde|2"->"ab|cde|1";
 "ab|cde|1"->"abc|de|0";
 "abc|de|0"[color=darkolivegreen3];
 "ab|cde|1"->"ab|de|1";
 "ab|de|1"->"abd|e|0";
 "abd|e|0"[color=darkolivegreen3];
 "ab|de|1"->"ab|e|1";
 "ab|e|1"->"abe|0|0";
 "abe|0|0"[color=darkolivegreen3];
 "ab|e|1"->"ab|0|1";
 "a|bcde|2"->"a|cde|2";
 "a|cde|2"->"ac|de|1";
 "ac|de|1"->"acd|e|0";
 "acd|e|0"[color=darkolivegreen3];
 "ac|de|1"->"ac|e|1";
 "ac|e|1"->"ace|0|0";
 "ace|0|0"[color=darkolivegreen3];
 "ac|e|1"->"ac|0|1";
 "a|cde|2"->"a|de|2";
 "a|de|2"->"ad|e|1";
 "ad|e|1"->"ade|0|0";
 "ade|0|0"[color=darkolivegreen3];
 "ad|e|1"->"ad|0|1";
 "a|de|2"->"a|e|2";
 "a|e|2"->"ae|0|1";
 "a|e|2"->"a|0|2";
 "|abcde|3"->"|bcde|3";
 "|bcde|3"->"b|cde|2";
 "b|cde|2"->"bc|de|1";
 "bc|de|1"->"bcd|e|0";
 "bcd|e|0"[color=darkolivegreen3];
 "bc|de|1"->"bc|e|1";
 "bc|e|1"->"bce|0|0";
 "bce|0|0"[color=darkolivegreen3];
 "bc|e|1"->"bc|0|1";
 "b|cde|2"->"b|de|2";
 "b|de|2"->"bd|e|1";
 "bd|e|1"->"bde|0|0";
 "bde|0|0"[color=darkolivegreen3];
 "bd|e|1"->"bd|0|1";
 "b|de|2"->"b|e|2";
 "b|e|2"->"be|0|1";
 "b|e|2"->"b|0|2";
 "|bcde|3"->"|cde|3";
 "|cde|3"->"c|de|2";
 "c|de|2"->"cd|e|1";
 "cd|e|1"->"cde|0|0";
 "cde|0|0"[color=darkolivegreen3];
 "cd|e|1"->"cd|0|1";
 "c|de|2"->"c|e|2";
 "c|e|2"->"ce|0|1";
 "c|e|2"->"c|0|2";
 "|cde|3"->"|de|3";
 "|de|3"->"d|e|2";
 "d|e|2"->"de|0|1";
 "d|e|2"->"d|0|2";
 "|de|3"->"|e|3";
 "|e|3"->"e|0|2";
 "|e|3"->"|0|3";
"prefix|remaining|k" [shape=Mdiamond];
}

从二叉树切割的节点数

从 n = 5 和 k = 3 的示例中,我们可以看到高度为 3 的树和高度为 2 的三棵树被砍掉了。当我们访问这些树根节点中的每一个时,我们得到从完整二叉树切割的节点数为 1*(23 - 2) + 3*(22 - 2) = 12
如果它是一棵完整的二叉树:25 + 1 - 1 = 63
节点数(对函数“print”的调用)然后达到 63 - 12 = 51
结果与我们通过计算 n = 5 和 k = 3 时对函数的调用次数得到的结果相匹配。

现在我们必须找出每个 n 和 k 被砍掉的树的数量和大小。

从这里开始,我将方法调用 print(prefix + remaining.charAt(0), remaining.substring(1), k-1); 称为左路径或左节点(就像在 graphviz 图中一样),将 print(prefix, remaining.substring(1), k); 称为右路径或右节点。

我们可以看到第一棵最大的树,当我们向左走 k 次时,树的高度将是 n - k + 1。( + 1 因为我们访问了我们切割的树的根)。

我们可以看到,无论我们之前走了多少条正确的路径(或以什么顺序),每次我们走 k 次左路径都会得到结果。这是除非在我们得到 k 个左路径之前单词用完字母。所以我们最多可以做 n - k 次右转。

让我们仔细看看 n = 5 和 k = 3 的示例:

L - 左路径
R - 正确的路径

我们走的第一棵树:
LLL

将被切割的下一个最高的树将是我们只取一个正确节点的树,可能的组合是:
RLLL、LRLL、LLRL、LLLR -> 三棵高度为 2 的树
在这里我们必须注意,LLLR 已经被切割,因为 LLL 在上一步中给出了解决方案。

为了获得下一棵树的数量(高度 1 -> 0 个节点被切割),我们将计算两个右和三个左的可能组合减去已经访问过的路径。 组合 (5,3) - 组合 (4,3) = 10 - 4 = 6 个高度为 1 的节点

我们可以看到数字与示例图中的绿色节点匹配。

C(n,k) - n 中 k 的组合
f(n,k) - 算法未访问的二叉树节点数

f(n,k) = (2n-k+1-2) + Σnki=1(2nk-i+1-2)(C(k+i,k) - C(k+i-1,k))

解释:

  • (2n-k+1-2) - 最高的树切割,必须将其从求和中取出,否则我们将不得不采用负阶乘
  • Σn-ki=1 - 所有节点的总和,不包括已添加的最高树。 (我们首先开始添加更大的树)
  • (2n-k-i+1-2) - 每棵树切割的节点数。 n-k+1 是最大的树,然后从那里向下直到高度为“n-k-(n-k)+1 = 1”的树
  • (C(k+i,k) - C(k+i-1,k)) - 找出给定高度的树的数量。首先找到所有可能的路径(左和右),然后减去已经访问过的路径(在前面的步骤中)。

看起来很糟糕,但是如果我们假设 k != 0 可以简化它(如果我们不假设会有负数的阶乘 - 这是未定义的)

简化功能:
f(n,k) = Σnki=0(2nk-i+1-2)*C(k+i- 1,k-1)

评估准确的时间复杂度

函数的时间复杂度:
O(2nnki=0(2nk-i+1-2)*C (k+i-1,k-1))

现在这看起来很糟糕,并且没有提供太多信息。我不知道如何进一步简化它。我已经问过了here。不过目前还没有答案。

但是否值得考虑 f(n,k) 部分?可能取决于应用它的特定应用程序。从数据表中我们可以看出,根据 n 和 k 的选择,它可以显着影响算法调用。

为了更直观地了解额外部分对复杂度的影响程度,我在图表上绘制了最佳时间复杂度和实际复杂度。

O(2nnki=0(2nk-i+1-2)*C (k+i-1,k-1)) 是彩色表面。
B(2min(n,k)) 是绿色表面。

我们可以看到 B(2min(n,k)) 高估了函数的复杂度(告诉它比实际做得好得多)。查看算法的最坏情况复杂度通常很有用,即 W(2max(n,k))

O(2nnki=0(2nk-i+1-2)*C (k+i-1,k-1)) 是彩色表面。
B(2min(n,k)) 是绿色表面。
W(2max(n,k)) 是黄色表面。

结论

  • 最佳情况复杂度:B(2min(n,k))
  • 精确复杂度:O(2n- Σnki=0(2nk-i+1-2)*C(k+i-1,k-1))
  • 最坏情况复杂度:W(2max(n,k)) -> 通常记为 O(2max(n,k))

在我看来,应该使用最坏情况的复杂性来评估函数,因为准确度太复杂,无法在不进一步分析的情况下理解它的含义。我不会使用最佳案例复杂性,因为它留下了太多机会。不幸的是,我无法为此计算平均复杂度。根据算法的应用,使用平均值可能更适合算法评估。

【讨论】:

    【解决方案2】:

    假设mprefix的长度,nremaining的长度。那么复杂度由下式给出

    T(m, n, k) = Θ(m + n) + T(m + 1, n - 1, k - 1) + T(m, n - 1, k).

    Θ(m + n) 一词源于

    prefix + remaining.charAt(0), remaining.substring(1)
    

    这通常需要创建两个长度分别为 mn 的新字符串(这在不同的实现中可能有所不同)。


    除此之外,除了一些非常简单的界限之外,它很难解决(至少对我而言)。例如,很明显,复杂度至少是前缀长度和 k 的最小值的指数,因为

    T(m, n, k) ≥ 2 T(m, n - 1, k - 1) &Rarr; T(m, n, k) = Ω(2min(n, k)).

    【讨论】:

    • 第一个方程中应该是 T(m, n-1, k) 而不是 T(m, n, k - 1)
    • @AmiTavory 你能解释一下什么是 Θ(m + n) 吗?
    • @AmiTavory 谢谢 :) 所以我们也需要考虑进行子串和连接所花费的时间......现在明白了。
    【解决方案3】:

    假设m是prefix的长度,n是remaining的长度。那么复杂度由下式给出

    T(m, n, k) = 1 + n + 1 + T(m + 1, n - 1, k - 1) + T(m, n - 1, k)

    显然,函数在 n=0 或 k=0 时停止。所以,

    T(r, n, 0) = 1 + r

    T(m, 0, k) = 1 + 1 + 1 = 3

    修改等式1,我们得到

    T(m, n, k) - T(m, n - 1, k) = 2 + n + T(m + 1, n - 1, k - 1)

    将等式 1 中的 n 替换为 n-1

    T(m, n - 1, k) - T(m, n - 2, k) = 2 + (n - 1) + T(m + 1, n - 2, k - 1)

    ...继续...

    T(m, 1, k) - T(m, 0, k) = 2 + (1) + T(m + 1, 0, k - 1)

    总结一下

    T(m, n, k) - T(m, 0, k) = 2(n) + (n-1)(n)/2 + {T上从0到n-1的a的总和( m + 1, a, k - 1)}

    改革

    T(m, n, k) = n2/2 + 3n/2 +3 + {T(m + 1, a, k)上a从0到n - 1的总和- 1)}


    我想我们可以通过使用最后一个方程求解求和来得到答案,方程的主导因素类似于 nk+1

    【讨论】:

    • 我理解这些: T(m + 1, n - 1, k - 1) + T(m, n - 1, k) 但什么是 1 + n + 1 ?这里 1+ 1 = 2 如果条件?请你给我解释一下
    • @Sarah 1 + n + 1 是 (k==0) + extract remaining.length() + (remaining.length() == 0) 所用的时间。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-11
    • 1970-01-01
    • 2011-07-18
    • 2020-09-19
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多