性能,大O表示法,递归和非递归算法

时间:2015-07-12 10:49:32

标签: java algorithm performance recursion big-o

想象一下,我必须检查一个字符串的所有字母是否在另一个字符串中。我想比较两个实现,一个是尾递归,另一个是使用hashMap。以下是两种实现:

private boolean isPossible(final String left, final String right) {
    boolean toReturn = false;
    if (left.isEmpty()) {
        toReturn = true;
    } else {
        char charAt = left.charAt(0);
        final int index = right.indexOf(charAt);
        toReturn = index != -1 ? isPossible(left.substring(1), stringWithoutIndex(right, index)) : false;
    }
    return toReturn;
}

和hashMap解决方案:

public boolean isPossible(String left, String right) {
    HashMap<Character, Integer> occurrencesMap = createOccurrenceMapFor(left);
    HashMap<Character, Integer> withTheLettersInRightRemoved = removeLettersFoundIn(right, occurrencesMap);
    return checkThatWeCanWriteTheMessage(withTheLettersInRightRemoved);
}

private HashMap<Character, Integer> removeLettersFoundIn(final String string, final HashMap<Character, Integer> occurrencesMap) {
    HashMap<Character, Integer> lettersRemoved = new HashMap<>(occurrencesMap);
    for (char c : string.toCharArray()) {
        if (lettersRemoved.containsKey(c)) 
            lettersRemoved.put(c, lettersRemoved.get(c).intValue() - 1); 
    }
    return lettersRemoved;
}

private HashMap<Character, Integer> createOccurrenceMapFor(String string) {
    HashMap<Character, Integer> occurrencesMap = new HashMap<>();
    for (char c : string.toCharArray()) {

        if (occurrencesMap.containsKey(c)) 
            occurrencesMap.put(c, occurrencesMap.get(c).intValue() + 1); 
        else 
            occurrencesMap.put(c, 1);
    }
    return occurrencesMap;
}

private boolean checkThatWeCanWriteTheMessage(HashMap<Character, Integer> occurrencesMap) {
    for (char c : occurrencesMap.keySet()){
        if (withTheLettersInMagazineRemoved.get(c) > 0) {
            return false;
        }
    }
    return true;
}

我认为这两种解决方案都具有O(n)性能,因为它们都没有for循环等。但是,一旦我比较时间,我得到的hashMap解决方案比递归解决方案快得多。当然,这是有道理的,但我想知道为什么从理论上讲,两者都有O(n)。我是对的吗?

3 个答案:

答案 0 :(得分:3)

第一个解决方案遍历第一个字符串中的每个字符,即O(N),但是对于每个字符,它搜索第二个字符串中的匹配字符,它给出另一个内部/嵌套O(N)和 O(N ^ 2) 总计。

第二个解决方案迭代第一个字符串O(N),然后迭代第二个字符串O(N),最后它遍历只包含有限范围的字符(某些常量)的hashmap。总数为O(N)+ O(N)+ C = O(N)

答案 1 :(得分:2)

使用源字符串中的字符循环实现

aString.indexOf(aChar);因此它是O(aString.length)。并且stringWithoutIndex(aString, anIndex)无法比O(aString.length)更好地实现。在最糟糕的情况下(当left中的所有字符都显示在right中)时,您将执行O(left.length * right.length)操作。

您的第一个代码片段相当于:

private boolean isPossible(String left, String right) {
  // loop will repeat left.length times at most
  while (true) {
    if (left.isEmpty()) {
      return true;
    } else {
      char first = left.charAt(0);
      left = left.substring(1);

      // indexOf: O(right.length)
      int index = -1;
      for (int i=0; i<right.length; i++) {
        if (right.charAt(i) == first) { 
          index = i; 
          break; 
        }
      }

      if (index >= 0) {
        // stringWithoutIndex: concatenating strings is O(size of result)
        right = right.substring(0, index) + right.substring(index+1);
      } else {
        return false;
      }
    }
  }
}

我只是将递归转换为迭代,并扩展了indexOfstringWithoutIndex - 这使得复杂性更容易计算,因为循环很容易看到。

请注意,从递归到迭代(或反之亦然)的机械转换不会改变复杂性类;虽然,当代码可以写为尾递归时(如本例isPossible),迭代代码可能会更快,并且不能耗尽堆栈(因为它不使用它)。因此,许多编译器将尾递归转换为幕后迭代。

可以对内联函数进行类似的论证(内联通常更快,但仍保留在相同的大复杂类中),尽管存在额外的大小权衡:内联使得编译的程序在代码存在时更大在许多地方使用过。

答案 2 :(得分:1)

第一种算法:O(n * m)

第二种算法:O(n) - 您可以在此处看到m的大小。

请参阅下面的反馈。

第一个程序

递归函数不是尾递归的。没有尾巴电话。 但我认为您可以通过分离return语句将其转换为尾递归函数。例如。 (我缩短了你的代码,但请尽快验证)

private boolean isPossible(final String left, final String right) {
    if (left.isEmpty()) return true;
    char firstChar = left.charAt(0);
    int index = right.indexOf(charAt);
    if (index == -1) return false;
    return isPossible(left.substring(1), stringWithoutIndex(right, index));
}

不确定stringWithoutIndex(right, index)究竟做了什么,但是知道时间复杂度会很好。我假设它只是返回正确的字符串而没有给定索引处的字符,即O(m)m是正确字符串的长度)。

我用数字(1, 2, 3, 4 and 5)表示函数内部的代码行。我假设左侧字符串的长度为n,而右侧字符串的长度为m

  1. O(1)
  2. O(1)
  3. O(m),因为在最糟糕的情况下,您会在最后一个索引处找到该字符。
  4. O(1)
  5. O(m),删除了正确字符串的字符
  6. 将这些添加到一起,您将获得O(m)一次迭代。由于您迭代的次数与左侧字符串中的字符数一样多,因此该算法的时间复杂度为O(m*n)

    • 如果m < n,则小于O(n^2)
    • 如果m = n,则为O(n^2)
    • 如果m > n,则为O(n * m)

    最后,此算法的复杂性类别为O(n*m)

    关于尾递归的侧注

    它与时间复杂性无关,但在特定于实现的级别上,您可以提升性能。 从理论上讲,这只会提高算法复杂度等级的常数。

    尾部递归函数(您可能已经知道)可以进行优化并提高性能。如果速度对您很重要,您可能有兴趣将其转换为尾递归函数。 See Wikipedia for information. 通常,您可以期望迭代函数比递归函数更快。 但是,使用 memoization 的递归函数可以击败迭代函数。 See here for example with Fibonacci numbers.

    第二个程序

    阅读并考虑本解释末尾的注释(将讨论用于分析的hashmap的复杂性)并注意这是一种纯理论但严谨的方法。与前一个示例中类似的行号:

    isPossible(...)

    1. O(n * k') - 取决于createOccurrenceMapFor(...)
    2. O(m * k + n) - 取决于removeLettersFoundIn(...)
    3. O((n-m) * l) - 取决于checkThatWeCanWriteTheMessage(...)
    4. removeLettersFoundIn(...)

      1. O(n) - 基于左侧字符串
      2. 创建哈希映射
      3. O(m) - 循环右字符串中的字符数
        1. O(k) - 理论上插入hashmap的最坏情况是O(k)
      4. 您可以看到此函数的复杂度为O(m*k + n)

        createOccurrenceMapFor(...)

        1. O(n) - 循环左侧字符串中的字符数
          1. O(k') - 理论上插入hashmap的最坏情况是O(k')
        2. 此功能具有复杂性O(n*k')

          checkThatWeCanWriteTheMessage(...)

          不确定withTheLettersInMagazineRemoved到底是什么,但我认为它是一个大小为k''的散列图。

          1. O(n-m) - 循环左侧字符串中的剩余字符数
            1. O(k'') - 在hashmap中检索的最坏情况是O(k'')
          2. 注意此功能在您的算法上下文中没有意义。至少我无法理解这一点。

            最后,算法具有子函数总和的复杂性:

            O(n*k' + m*k+n + (n-m)*l)
            

            使用hashmap和预期的最坏情况

            的后果

            摘自Java documentation

              

            此实现为基本提供了恒定时间性能   操作(获取和放置),假设散列函数分散了   在桶之间正确的元素。迭代集合视图   要求时间与容量成比例&#34; HashMap实例的   (桶数)加上其大小(键值的数量   映射)。因此,不设置初始容量非常重要   如果迭代性能是太高(或负载因子太低)   重要的。

                 

            HashMap的一个实例有两个影响其性能的参数:   初始容量和负载系数。容量是数量   哈希表中的桶,而初始容量就是   创建哈希表时的容量。负载系数是a   衡量哈希表在其之前可以获得多长的度量   容量自动增加。当中的条目数   哈希表超出了加载因子和当前的乘积   容量,哈希表重新哈希(即内部数据   重建结构),以便哈希表大约两次   桶的数量。

            你的密钥是字符,因此我认为构建一个恒定时间哈希函数并不困难。因此,让我们假设您的散列图(putgetcontainsKey)的操作在固定时间O(1)而不是O(k)O(k')运行}或O(k'')

            最后,该算法的复杂性类别为O(n + m+n + (n-m)) = O(3*n) = O(n) ,假设哈希函数在O(1)中运行,您可以很好地假设它。