时间复杂度一些基本知识

什么是时间复杂度

  • 时间复杂度是一个函数,它定性描述了算法的运行时间

  • 开发者通过时间复杂度估算程序的运行时间。通常以算法的操作单元数量来代表程序消耗的时间,这里默认算法的每个操作单元运行所消耗的时间都是相同的

  • 假设算法的数据规模为n,操作单元数量用函数f[n]表示,随着数据规模n的增大,算法执行时间的增长率和f[n]的增长率相同,这个增长趋势称为算法的渐近时间复杂度,简称时间复杂度。

O是什么

  • 大家都知道O(n),O(n^2),却说不清什么是O。

  • 《算法导论》给出的解释是:O用来表示上界,当用它作为算法在最坏情况下运行时间的上界时,就是对任意数据输入的运行时间的上界。

  • 《算法导论》给出了例子

    • 以插入排序为例,插入排序的时间复杂度是O(n^2)。

      • 输入数据的形式对程序运行时间有很大影晌,在数据有序的情况下,时间复杂度是O(n)。如果
        数据是逆序的,那么插入排序的时间复杂度就是O(n^2), 也就是对于所有输入情况来说,最差情况下的时间复杂度是O(n^2), 所以称插入排序的时间复杂度为O(n^2)

      • 补充插入排序相关内容

        • 插入排序原理如下:

          • 从第一个元素开始,该元素可以认为已经被排序;

          • 取出下一个元素,在已经排序的元素序列中从后向前扫描;

          • 如果该元素(已排序)大于新元素,将该元素移到下一位置;

          • 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;

        • 插入排序代码如下:

          void InsertSort(int A[], int n)
          {
              for(int i = 1; i < n; i++)
              {
                  int key = A[i];
                  int j = i - 1;
                  while(j >= 0 && A[j] > key)
                  {
                      A[j + 1] = A[j];
                      j--;
                  }
                  A[j + 1] = key;
              }
          }
          
        • 附上Acwing的题目链接,5333 插入排序

    • 快速排序的时间复杂度是O(nlogn),但是在数据有序的情况下,快速排序的时间复杂度是O(n^2), 所以从O的定义来说,快速排序的时间复杂度应该是O(n^2)。但是我们依然说快速排序的时间复杂度是O(nlogn),这就是业内的一个默认规定,这里的O代表的就是一般情况,而不是严格意义上的上界。

      • 补充快速排序相关内容:

        • 快速排序原理如下:

          • 从数列中挑出一个元素,称为 “基准”(pivot);

          • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;

          • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;

        • 快速排序代码如下:

          void QuickSort(int A[], int left, int right)
          {
              if(left >= right)
                  return;
              int i = left, j = right;
              int key = A[left];
              while(i < j)
              {
                  while(i < j && A[j] >= key)
                      j--;
                  A[i] = A[j];
                  while(i < j && A[i] <= key)
                      i++;
                  A[j] = A[i];
              }
              A[i] = key;
              QuickSort(A, left, i - 1);
              QuickSort(A, i + 1, right);
          }
          
        • 附上Acwing的题目链接,785 快速排序

    • 给出图片总结:
      img

  • 我们主要关心的是一般情况下的数据形式,面试中涉及的算法的时间复杂度指的都是—般情况。如果面试宫和我们深入探讨一个算法的实现及性能的时候,就要时刻想着数据用例不—样,时间复杂度也是不同的,这一点一定要注意。

不同数据规模的差异

  • 给出一张图:
    img

    • 如图2-2所示,我们可以看出不同算法的时间复杂度在不同数据输入规模下的差异。

    • 图2-2中,O(5n^2) 和 O(100n)在n为20之前,时间复杂度为O(5n^2)的算法是更优的,所花费的时间也是更少的。

  • 此图更全面:
    img

  • 在决定使用哪些算法时,不是时间复杂度越低越好(因为简化后的时间复杂度忽略了常数顶等),还要考虑数据规模,如果数据规模很小,那么可能出现时间复杂度为0(n^2)的算法比时间复杂度为O(n)的算法更合适的情况(在有常数项的时候)。

为什么在计算时间复杂度的时候要忽略常数项系数

  • 比如O(100n) 就是 O(n),O(5n^2) 就是 O(n^2),而且默认O(n) 要优于 O(n^2)。

  • 这涉及到O的定义,因为O就是在数据量级突破一个点且数据量级非常大的情况下所表现出的时间复杂度!这个数据量就是常数项系数已经不起决定性作用的数据量。

  • 例如,图2-2中的20就是那个点! n只要大于20,常数项系数就不起决定性作用了。所以我们说的时间复杂度都是省略常数项系数的,一般情况下默认数据规模足够大。

算法时间复杂度的排行

基于上述事实,给出算法时间复杂度的排行:

\[O(1)常数阶 < O(logn)对数阶 < O(n)线性阶 < O(n^2)平方阶 < O(n^3)立方阶 < O(2^n)指数阶 \]

但也要注意大常数,如果这个常数非常大,比如\(10^7\)\(10^9\),那么常数就是不得不考虑的因素了。

复杂表达式的简化

  • 如果计算时间复杂度的时候发现不是一个简单的O(n)或者O(n^2),而是一个复杂的表达式,如

    O(2*n^2 + 10*n + 1000)
    

    那么如何描述这个算法的时间复杂度呢?介绍其中的一种方法——简化法

    • 去掉表达式中的加法常数项(因为常数项并不会因为n的增大而增加计算机的操作次数):

      O(2*n^2 + 10*n)
      
    • 去掉常数系数 (上文部分已经讲了为什么可以去掉常数系数的原因)

      O(n^2 + n)
      
    • 只保留最高项去掉数量级小一级的n(因为n^2的数据规模远大于n),最终简化为:

      O(n^2)
      
    • 如果这一步理解有困难,那也可以做提取n的操作,变成O(n(n+1)) ,省略加法常数项后也就别变成了:

      O(n^2)
      
  • 也可以用另一种简化的思路,其实当n大于40的时候, 这个复杂度会恒小于O(3 × n^2), O(2 × n^2 + 10 × n + 1000) < O(3 × n^2), 所以说最后省略掉常数项系数最终时间复杂度也是O(n^2)。

O(logn)中的log以什么为底

  • 我们平时说这个算法的时间复杂度是logn的,那么一定是log以2为底n的对数么(即\(\log_2 n\))?
    其实不然,也可以是以10为底n的对数,也可以是以20为底n的对数,但我们统一说 logn,也就是忽略底数的描述

  • 为什么可以这样做?如下图:
    img

    假如有两个算法的时间复杂度,分别是log以2为底n的对数和log以10为底n的对数,那么这里如果还记得高中数学的话,应该不难理解以2为底n的对数 = 以2为底10的对数 * 以10为底n的对数。(补充,换底公式:\(\frac{log_c b}{log_c a} = log_a b\)

    而以2为底10的对数是一个常数,在上文已经讲述了我们计算时间复杂度是忽略常数项系数的。

    抽象一下就是在时间复杂度的计算过程中,log以i为底n的对数等于log 以j为底n的对数,所以忽略了i,直接说是logn。

    这样就应该不难理解为什么忽略底数了。

面试题:找出n个字符串中相同的两个字符串

通过下面这道面试题,我们分析一下时间复杂度。

题目描述:找出n个字符串中相同的两个字符串(假设这里只有两个相同的字符串)。

  • 如果是暴力枚举的话,时间复杂度是多少呢,是O(n^2)么?

    这里一些同学会忽略了字符串比较的时间消耗,这里并不像int 型数字做比较那么简单,除了n^2 次的遍历次数外,字符串比较依然要消耗m次操作(m也就是字母串的长度),所以时间复杂度是O(m × n × n)。

  • 接下来再想一下其他解题思路。

    先将n个字符串按字典序来排序,排序后n个字符串就是有序的,意味着两个相同的字符串就是挨在一起,然后在遍历一遍n个字符串,这样就找到两个相同的字符串了。

    那看看这种算法的时间复杂度,快速排序时间复杂度为O(nlogn),依然要考虑字符串的长度是m,那么快速排序每次的比较都要有m次的字符比较的操作,就是O(m × n × log n) 。

    之后还要遍历一遍这n个字符串找出两个相同的字符串,别忘了遍历的时候依然要比较字符串,所以总共的时间复杂度是 O(m × n × logn + n × m)。

    我们对O(m × n × log n + n × m) 进行简化操作,把m × n提取出来变成 O(m × n × (logn + 1)),再省略常数项最后的时间复杂度是 O(m × n × log n)。

  • 最后很明显O(m × n × logn) 要优于O(m × n × n)!

    所以先把字符串集合排序再遍历一遍找到两个相同字符串的方法要比直接暴力枚举的方式更快。

这就是我们通过分析两种算法的时间复杂度得来的。

当然这不是这道题目的最优解,仅仅是用这道题目来讲解一下时间复杂度

补充:对于上述面试题,用chatgpt生成的代码(供参考)

  • 使用哈希表
#include <iostream>
#include <vector>
#include <unordered_map>

std::string findDuplicate(const std::vector<std::string>& strings) {
    std::unordered_map<std::string, int> stringCount;
    
    for (const auto& str : strings) {
        stringCount[str]++;
        if (stringCount[str] == 2) {
            return str; // 找到第二次出现的字符串
        }
    }
    
    return ""; // 如果没有找到
}

int main() {
    std::vector<std::string> strings = {"apple", "banana", "orange", "apple"};
    std::string duplicate = findDuplicate(strings);
    
    if (!duplicate.empty()) {
        std::cout << "找到重复字符串: " << duplicate << std::endl;
    } else {
        std::cout << "没有找到重复字符串" << std::endl;
    }
    
    return 0;
}

  • 先快排,再遍历
#include <iostream>
#include <vector>
#include <algorithm>

std::string findDuplicate(std::vector<std::string>& strings) {
    std::sort(strings.begin(), strings.end()); // 快速排序
    
    for (size_t i = 1; i < strings.size(); ++i) {
        if (strings[i] == strings[i - 1]) {
            return strings[i]; // 找到重复字符串
        }
    }
    
    return ""; // 如果没有找到
}

int main() {
    std::vector<std::string> strings = {"apple", "banana", "orange", "apple"};
    std::string duplicate = findDuplicate(strings);
    
    if (!duplicate.empty()) {
        std::cout << "找到重复字符串: " << duplicate << std::endl;
    } else {
        std::cout << "没有找到重复字符串" << std::endl;
    }
    
    return 0;
}

posted @ 2024-10-09 17:31  hisun9  阅读(12)  评论(0编辑  收藏  举报