C++八股-----算法复杂度【时间复杂度与空间复杂度】

目录

一、算法复杂度

二、时间复杂度

2.1 定义 

2.2 符号表示

2.3 常见种类

2.4 计算操作次数

2.5 时间复杂度的示例

2.5.1 常数O(1)

2.5.2 线性 𝑂(𝑁)

2.5.3 平方O(N^2)

2.5.4 指数O(2^N) 

2.5.5 阶乘O(N!)

2.5.6 对数O(logN)

2.5.7 线性对数O(NlogN)

三、空间复杂度

3.1 定义

3.2 空间复杂度符号表示

3.3 常见种类

3.4 示例解析

3.4.1 常数O(1)

3.4.2 线性O(N)

3.4.3 平方O(N^2)

 3.4.4 指数0(2^N)

3.4.5 对数O(logN)


一、算法复杂度

算法复杂度旨在计算在输入数据量 𝑁 的情况下,算法的「时间使用」和「空间使用」情况;体现算法运行使用的时间和空间随「数据大小 𝑁 」而增大的速度。

算法复杂度主要可从 时间 、空间 两个角度评价:

  • 时间: 假设各操作的运行时间为固定常数,统计算法运行的「计算操作的数量」 ,以代表算法运行所需时间;
  • 空间: 统计在最差情况下,算法运行所需使用的「最大空间」;

「输入数据大小 𝑁」指算法处理的输入数据量;根据不同算法,具有不同定义,例如:

  • 排序算法: 𝑁 代表需要排序的元素数量;
  • 搜索算法: 𝑁 代表搜索范围的元素总数,例如数组大小、矩阵大小、二叉树节点数、图节点和边数等;

接下来,本文将分别从概念定义、符号表示、常见种类、时空权衡、示例解析、示例题目等角度入手,介绍「时间复杂度」和「空间复杂度」。

二、时间复杂度

2.1 定义 

定义:时间复杂度(Time Complexity)是计算机科学中用于描述算法运行时间的一个数学函数,它定量地描述了算法的运行时间与输入数据规模之间的关系。时间复杂度用于衡量算法 随着输入规模增长执行时间的增长趋势,通常计算 基本操作的执行次数

  • 统计的是算法的「计算操作数量」,而不是「运行的绝对时间」。计算操作数量和运行绝对时间呈正相关关系,并不相等。算法运行时间受到「编程语言 、计算机处理器速度、运行环境」等多种因素影响。例如,同样的算法使用 Python 或 C++ 实现、使用 CPU 或 GPU 、使用本地 IDE 或力扣平台提交,运行时间都不同。
  • 体现的是计算操作随数据大小N变化时的变化情况。假设算法运行总共需要「1次操作」、「 100 次操作」,此两情况的时间复杂度都为常数级 𝑂(1) ;需要「 𝑁 次操作」、「 100𝑁 次操作」的时间复杂度都为 𝑂(𝑁) 。

一个算法在不同的机器上的执行时间是不同的,但语句执行次数是一样的。因此,可以使用语句执行次数来衡量算法的时间复杂度。

2.2 符号表示

根据输入数据的特点,时间复杂度具有「最差」、「平均」、「最佳」三种情况,分别使用 
𝑂 , Θ , Ω 三种符号表示。以下借助一个查找算法的示例题目帮助理解。

题目: 输入长度为 𝑁 的整数数组 nums ,判断此数组中是否有数字 7 ,若有则返回 true ,否则返回 false 。

解题算法: 线性查找,即遍历整个数组,遇到 7 则返回 true 。

bool findSeven(vector<int>& nums) {
    for (int num : nums) {
        if (num == 7)
            return true;
    }
    return false;
}

大 𝑂 是最常使用的时间复杂度评价渐进符号。

2.3 常见种类

根据从小到大排列,常见的算法时间复杂度主要有:


时间复杂度一般是 对最坏情况的估计,它衡量的是 随着输入规模增大,算法执行时间的增长趋势

2.4 计算操作次数

for 循环的执行流程 

for(i = 1; i <= n; i++) {
    // 循环体
    x++;
}

for 循环由 四部分 组成:

  1. 初始化i = 1(执行 1 次)。
  2. 条件检查i <= n(执行 n+1 次,包括最后一次失败)。
  3. 循环体执行:当 i1 变到 n,循环体执行 n 次。
  4. 自增操作i++(执行 n 次)。

因此上述的for循环执行的次数是(3n+2)次,则上述for循环的时间复杂度为O(3n+2),又可以表示成O(n)(忽略初始化、终止条件检查和自增部分),这是因为随着数据规模n的不断增加,系数、常量对执行时间的增长趋势影响非常的小。

两层for循环-----在下面的代码中,x++; 语句一共执行了多少次? 

for(i=1;i<=n;i++){
   for(j=1;j<=n;j++){
        x++;
  }
}

我们来逐步分解:
先来看下面第1个代码段,for循环会执行多少次?

在这里我们不考虑循环体的执行次数、不考虑自增操作执行次数,同时也不考虑初始化执行次数。下面的例子同理。这样的话,只剩下条件检查i <= n(执行 n+1 次,包括最后一次失败)。因此这条for语句一共执行n+1次。

再来看第2个代码段中的x++语句执行多少次?

x++;会执行n次。(x++属于循环体内)

再来看第三个代码段中的内层for循环会执行多少次?

对于外层for循环可以进入到外层for循环的循环体n次,内层for循环自己执行n+1次(内层for循环执行次数可参考代码段1中for循环执行次数),因此内层for循环共执行n(n+1)次。

接下来我们来看第四段代码中x++共执行多少次?

x++会执行n×n次。

 例题:假设n是描述问题规模的非负整数,下面的程序片段的时间复杂度是什么?

//第五段代码:
for (i = 1; i <= n; i++) {        // (1) 外层循环:i 从 1 到 n
    for (j = 1; j <= i; j++) {    // (2) 中层循环:j 从 1 到 i
        for (k = 1; k <= j; k++) {// (3) 内层循环:k 从 1 到 j
            x++;                  // (4) 计数操作
        }
    }
}

我们 从内到外 逐层计算 x++ 的执行次数。

最内层 for(k=1; k<=j; k++)

  • k1j,因此x++语句执行 j

中层 for(j=1; j<=i; j++)

  • j 取值为 1, 2, ..., i,所以x++语句在中层for循环中执行了:次。

最外层 for(i=1; i<=n; i++)

  • i 取值为 1, 2, ..., n,所以总的 x++ 执行次数为:次。

综上,x++执行的总次数为:,接下来我们计算这个式子。

先计算:

接下来计算:

最终得:

这便是x++语句执行的总次数。

当然,根据x++执行的次数,我们也可以知道该代码片段的时间复杂度为O(N^3)。

计算时间复杂度时只需考虑执行次数最多的语句即可。

例题:求下面代码段中x++的执行次数?

推导循环终止条件:

我们的目标是找到i=2^k<=n时的最大整数k值即可。对式2^k<=n同取以2为底的自然对数,则有k<=log2(n)时满足条件,由于k为最大整数,因此k取|_log2(n)_|。举例:当n=8(n的值是2的幂次)时,log2(n)=3,|_log2(n)_|=3,那么k取3满足2=^3<=n;当n=10时(n的值不是2的幂次)时,log2(n)=3.322,|_log2(n)_|=3,那么k取3满足2^3<=n。因此k=|_log2(n)_|时符合条件。|_x_|表示向下取整。

接下来我们计算x++执行了多少次?从i=2^0,2^1,2^2,....,2^k均符合条件,因此x++执行k+1次,即:|_log2(n)_|+1次。


假设n=8,我们来看i的变化:

i=2^0=1,1<=n,进入循环体,x++执行;

i=2^1=2,2<=n,进入循环体,x++执行;

i=2^2=4,4<=n,进入循环体,x++执行;

i=2^3=8,8<=n,进入循环体,x++执行;

i=2^4=16,16>n,退出循环,x++不执行。

也就是说x++执行了四次。

也就是说,当n=2^k,k=log2(n),x++执行的次数应该为:k+1=log2(n)+1次。

假设n=10(n不是2的幂次方时),我们来看i的变化:

i=2^0=1,1<=n,进入循环体,x++执行;

i=2^1=2,2<=n,进入循环体,x++执行;

i=2^2=4,4<=n,进入循环体,x++执行;

i=2^3=8,8<=n,进入循环体,x++执行;

i=2^4=16,16>n,退出循环,x++不执行。

也就是说x++执行了四次。

也就是说,当n=2^k,k=log2(n),x++执行的次数应该为:k+1=|_log2(n)_|+1次。

时间复杂度为O(log2(n))。

参考学习视频:

时间复杂度、空间复杂度求解|数据结构_哔哩哔哩_bilibili

2.5 时间复杂度的示例

对于以下所有示例,设输入数据大小为 𝑁 ,计算操作数量为 𝑐𝑜𝑢𝑛𝑡 。图中每个「蓝色方块」代表一个单元计算操作。

2.5.1 常数O(1)

运行次数与 𝑁 大小呈常数关系,即不随输入数据大小 𝑁 的变化而变化。【表示算法的运行时间与输入规模无关,如访问数组中的某个元素。】

int algorithm(int N) {
    int a = 1;
    int b = 2;
    int x = a * b + N;
    return 1;
}

 对于以下代码,无论 𝑎 取多大,都与输入数据大小 𝑁 无关,因此时间复杂度仍为 𝑂(1) 。

int algorithm(int N) {
    int count = 0;
    int a = 10000;
    for (int i = 0; i < a; i++) {
        count++;
    }
    return count;
}

 

2.5.2 线性 𝑂(𝑁)

循环运行次数与 𝑁 大小呈线性关系,时间复杂度为 𝑂(𝑁) 。【表示算法的运行时间与输入规模成正比,如遍历数组。】

int algorithm(int N) {
    int count = 0;
    for (int i = 0; i < N; i++)
        count++;
    return count;
}

对于以下代码,虽然是两层循环,但第二层与 𝑁 大小无关,因此整体仍与 𝑁 呈线性关系。 

int algorithm(int N) {
    int count = 0;
    int a = 10000;
    for (int i = 0; i < N; i++) {
        for (int j = 0; j < a; j++) {
            count++;
        }
    }
    return count;
}

2.5.3 平方O(N^2)

两层循环相互独立,都与 𝑁 呈线性关系,因此总体与 𝑁 呈平方关系,时间复杂度为O(N^2)。

int algorithm(int N) {
    int count = 0;
    for (int i = 0; i < N; i++) {
        for (int j = 0; j < N; j++) {
            count++;
        }
    }
    return count;
}

 

vector<int> bubbleSort(vector<int>& nums) {
    int N = nums.size();
    for (int i = 0; i < N - 1; i++) {
        for (int j = 0; j < N - 1 - i; j++) {
            if (nums[j] > nums[j + 1]) {
                swap(nums[j], nums[j + 1]);
            }
        }
    }
    return nums;
}

2.5.4 指数O(2^N) 

int algorithm(int N) {
    if (N <= 0) return 1;
    int count_1 = algorithm(N - 1);
    int count_2 = algorithm(N - 1);
    return count_1 + count_2;
}

 

2.5.5 阶乘O(N!)

int algorithm(int N) {
    if (N <= 0) return 1;
    int count = 0;
    for (int i = 0; i < N; i++) {
        count += algorithm(N - 1);
    }
    return count;
}

 

2.5.6 对数O(logN)

int algorithm(int N) {
    int count = 0;
    float i = N;
    while (i > 1) {
        i = i / 2;
        count++;
    }
    return count;
}

int algorithm(int N) {
    int count = 0;
    float i = N;
    int a = 3;
    while (i > 1) {
        i = i / a;
        count++;
    }
    return count;
}

 如下图所示,为二分查找的时间复杂度示意图,每次二分将搜索区间缩小一半。

2.5.7 线性对数O(NlogN)

int algorithm(int N) {
    int count = 0;
    float i = N;
    while (i > 1) {
        i = i / 2;
        for (int j = 0; j < N; j++)
            count++;
    }
    return count;
}

线性对数阶常出现于排序算法,例如「快速排序」、「归并排序」、「堆排序」等,其时间复杂度原理如下图所示。 

三、空间复杂度

3.1 定义

定义:空间复杂度(Space Complexity) 是算法分析中的一个重要概念,用于衡量算法在运行过程中所需的额外存储空间(除了输入数据本身占用的空间)随输入规模增长的变化趋势。它通常用大 O 符号(O)表示。空间复杂度描述的是算法在运行过程中 临时占用的存储空间 与输入规模 n 之间的关系。

它包括:

  • 算法使用的变量(如循环变量、临时变量等)。

  • 数据结构占用的空间(如数组、栈、队列、哈希表等)。

  • 递归调用栈的空间(如果算法是递归的)。

注意:空间复杂度 不包括输入数据本身占用的空间,因为输入数据是问题本身固有的,而不是算法额外需要的。

空间复杂度涉及的空间类型有:

输入空间: 存储输入数据所需的空间大小;
暂存空间: 算法运行过程中,存储所有中间变量和对象等数据所需的空间大小;
输出空间: 算法运行返回时,存储输出数据所需的空间大小;
通常情况下,空间复杂度指在输入数据大小为 𝑁 时,算法运行所使用的「暂存空间」+「输出空间」的总体大小。

而根据不同来源,算法使用的内存空间分为三类:
指令空间:编译后,程序指令所使用的内存空间。
数据空间:算法中的各项变量使用的空间,包括:声明的常量、变量、动态数组、动态对象等使用的内存空间。

struct Node {
    int val;
    Node *next;
    Node(int x) : val(x), next(NULL) {}
};

void algorithm(int N) {
    int num = N;              // 变量
    int nums[N];              // 动态数组
    Node* node = new Node(N); // 动态对象
}

栈帧空间:程序调用函数是基于栈实现的,函数在调用期间,占用常量大小的栈帧空间,直至返回后释放。如以下代码所示,在循环中调用函数,每轮调用 test() 返回后,栈帧空间已被释放,因此空间复杂度仍为 O(1) 。

int test() {
    return 0;
}

void algorithm(int N) {
    for (int i = 0; i < N; i++) {
        test();
    }
}

算法中,栈帧空间的累计常出现于递归调用。如以下代码所示,通过递归调用,会同时存在 
N 个未返回的函数 algorithm() ,此时累计使用 O(N) 大小的栈帧空间。

int algorithm(int N) {
    if (N <= 1) return 1;
    return algorithm(N - 1) + 1;
}

3.2 空间复杂度符号表示

 

void algorithm(int N) {
    int num = 5;           // O(1)
    vector<int> nums(10);  // O(1)
    if (N > 10) {
        nums.resize(N);    // O(N)
    }
}

3.3 常见种类

根据从小到大排列,常见的算法空间复杂度有:

 

3.4 示例解析

对于以下所有示例,设输入数据大小为正整数 𝑁,节点类 Node 、函数 test() 如以下代码所示。

// 节点类 Node
struct Node {
    int val;
    Node *next;
    Node(int x) : val(x), next(NULL) {}
};

// 函数 test()
int test() {
    return 0;
}

3.4.1 常数O(1)

 普通常量、变量、对象、元素数量与输入数据大小 𝑁 无关的集合,皆使用常数大小的空间。

void algorithm(int N) {
    int num = 0;
    int nums[10000];
    Node* node = new Node(0);
    unordered_map<int, string> dic;
    dic.emplace(0, "0");
}

如以下代码所示,虽然函数 test() 调用了 𝑁 次,但每轮调用后 test() 已返回,无累计栈帧空间使用,因此空间复杂度仍为 O(1) 。

void algorithm(int N) {
    for (int i = 0; i < N; i++) {
        test();
    }
}

3.4.2 线性O(N)

元素数量与 𝑁 呈线性关系的任意类型集合(常见于一维数组、链表、哈希表等),皆使用线性大小的空间。 

void algorithm(int N) {
    int nums_1[N];
    int nums_2[N / 2 + 1];

    vector<Node*> nodes;
    for (int i = 0; i < N; i++) {
        nodes.push_back(new Node(i));
    }

    unordered_map<int, string> dic;
    for (int i = 0; i < N; i++) {
        dic.emplace(i, to_string(i));
    }
}

如下图与代码所示,此递归调用期间,会同时存在 𝑁 个未返回的 algorithm() 函数,因此使用 𝑂(𝑁) 大小的栈帧空间。

int algorithm(int N) {
    if (N <= 1) return 1;
    return algorithm(N - 1) + 1;
}

3.4.3 平方O(N^2)

 元素数量与 𝑁 呈平方关系的任意类型集合(常见于矩阵),皆使用平方大小的空间。

void algorithm(int N) {
    vector<vector<int>> num_matrix;
    for (int i = 0; i < N; i++) {
        vector<int> nums;
        for (int j = 0; j < N; j++) {
            nums.push_back(0);
        }
        num_matrix.push_back(nums);
    }

    vector<vector<Node*>> node_matrix;
    for (int i = 0; i < N; i++) {
        vector<Node*> nodes;
        for (int j = 0; j < N; j++) {
            nodes.push_back(new Node(j));
        }
        node_matrix.push_back(nodes);
    }
}

如下图与代码所示,递归调用时同时存在 𝑁 个未返回的 algorithm() 函数,使用 O(N) 栈帧空间;每层递归函数中声明了数组,平均长度为 𝑁/2 ,使用 O(N) 空间;因此总体空间复杂度为 O(N^2)。

int algorithm(int N) {
    if (N <= 0) return 0;
    int nums[N];
    return algorithm(N - 1);
}

 3.4.4 指数0(2^N)

3.4.5 对数O(logN)

------------------------------------------------------------------------------------------------------------------------------- 

声明:

本文章大部分参考自leetcode中一篇博客:链接:https://leetcode.cn/circle/discuss/rGdNq1/
 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值