01、时间空间复杂度分析

厨子大约 8 分钟数据结构算法算法基地面试基础知识

前言

在刷算法题中,我们主要有两个目标

  1. 通过所有测试用例
  2. 寻求效率最高的解法

第一个目标可以采用自己能想到的任何方法,也可以是暴力解法

第二个目标,则需要找到效率最高的解法,即运行时间最短,使用内存最小的写法。

最优解也是笔试时考察的点,所以我们在日常学习中,要尽可能的学习某个问题的最优解。所以能够会分析时间空间复杂度,是非常重要的一项技能

时间复杂度

先看一个例子

// 执行一次
int sum_a = 0;
sum_a = n * 1;

// 执行 n 次
int sum_b = 0;
for (int i = 1; i <= n; ++i) {
    sum_b += 1;
}

// 执行 n*n 次
int sum_c = 0for (int i = 1; i <= n; ++i) {
    for (int j = 1; j <= n; ++j) {
        sum_c += 1;
    }
}

上面的例子来看,sum_a 经过了 1 次运算,而 sum_b 经过了 n 次计算 ,而 sum_c 经过了 n*n 次计算,我们则可以写成

f(n) = 1;
f(n) = n;
f(n) = n*n;

其计算次数,随 n 的变化情况如下

根据上图可知,随着 n 的值增大,他们的计算次数的差异也就越大。

那么下面我们来看一个表格

次数f(n)=n+3f'(n)=ng(n)=2n+1g'(n)=2nk(n)=3n+1k'(n)=3n
n = 1413243
n = 2525476
n = 36376109
n = 10131021203130
n = 100103100201200301300

我们发现,随着 f(n) = n+3,在 n < 2 时,计算次数大于 g(n) = 2n+1,在 n = 2 时,两者计算次数相同,当 n > 2时,2n + 1 的计算次数恒大于 n+3

定义:对于两个函数 f(n) 和 g(n),如果存在一个整数 N,使得对于 所有的 n > N,总是 f(n) > g(n),那么我们说 f(n) 的增长渐进快于 g(n)。这个定义可以结合上面的例子理解

另外我们发现,f(n) 和 f'(n) 对比,随着 n 的增大,后面相加常数,是不重要的

下面再来看一个例子

nf(n) = 6n+1f'(n)=ng(n) = 3n^2+1g'(n)=n^2
17142
2132134
3193289
106110301100
1006011003000110000
10006001100030000011000000

我们发现 当 n = 10 时,g(n) 的计算次数,就大于了 f(n),而且去掉其相加的常数项,也不会影响其结果,而且就算去掉与 n 相乘的常数项,得到 g'(n), 对结果也没有什么影响,这时我们可以得到结论,与最高次数相乘的常数,也不重要

继续来看一个例子

n2n^2+5n+52n^3+5n+5
11212
22331
33874
102552055
100205052000505
100020050052000005005

最高项的,指数越大,其增长速率越快

那根据上面的例子,对增长速度,影响最大的是,最高次项的指数,指数越大,增长速度越快。

算法的时间复杂度,就是算法的时间量度,记作 T(n) = O(f(n)), 也就是随着 n 增大,算法执行时间的变化情况,在上面的例子中有提到,对增长速度影响最大的指标,根据时间复杂度的概念,上面的几个例子的时间复杂度,可以写成

O(1),O(n), O(n^2)

下面详细说一下如何推导时间复杂度

  1. 忽略常数项
  2. 在运行函数中,只保留最高阶
  3. 忽略最高阶的系数

下面举个例子

f(n) = 4n^2 + 5n + 6

那按照上面提到的三条,则该计算函数的时间复杂度为 O(n^2)

对数阶

while (left < n) {
    left = left * 2;
}

上面的代码中,我们发现, left 是成倍速接近 n 的,那么经历 2^x = n,则时间复杂度为 O(logn)

下面我们来看几个题目,看一下具体的时间复杂度是多少

class Solution {
public:
    void sortColors(vector<int>& nums) {
        int n = nums.size();
        int ptr = 0;
        for (int i = 0; i < n; ++i) {
            if (nums[i] == 0) {
                swap(nums[i], nums[ptr]);
                ++ptr;
            }
        }
        for (int i = ptr; i < n; ++i) {
            if (nums[i] == 1) {
                swap(nums[i], nums[ptr]);
                ++ptr;
            }
        }
    }
};

可以理解为,遍历两遍数组,时间复杂度为 O(n)

class Solution {
public:
    int minDepth(TreeNode *root) {
        if (root == nullptr) {
            return 0;
        }

        if (root->left == nullptr && root->right == nullptr) {
            return 1;
        }

        int min_depth = INT_MAX;
        if (root->left != nullptr) {
            min_depth = min(minDepth(root->left), min_depth);
        }
        if (root->right != nullptr) {
            min_depth = min(minDepth(root->right), min_depth);
        }

        return min_depth + 1;
    }
};

这里我们需要对所有节点访问一次,故时间复杂度为 O(n)

class Solution {
public:
    vector<vector<int>> threeSum(vector<int>& nums) {
        int n = nums.size();
        sort(nums.begin(), nums.end());
        vector<vector<int>> ans;
 
        for (int first = 0; first < n; ++first) {

            if (first > 0 && nums[first] == nums[first - 1]) {
                continue;
            }
         
            int third = n - 1;
            int target = -nums[first];
         
            for (int second = first + 1; second < n; ++second) {
              
                if (second > first + 1 && nums[second] == nums[second - 1]) {
                    continue;
                }
           
                while (second < third && nums[second] + nums[third] > target) {
                    --third;
                }
             
                if (second == third) {
                    break;
                }
                if (nums[second] + nums[third] == target) {
                    ans.push_back({nums[first], nums[second], nums[third]});
                }
            }
        }
        return ans;
    }
};

这里我们确定 first 之后,还需要确定 second ,故时间复杂度为 O(n^2)

时间复杂度分析,需要多多练习,遇到题目时,先自己分析,理清自己的思考,然后再和答案对比,是一个不断总结的过程。

空间复杂度

算法的空间复杂度,用于衡量算法占用内存空间,随着数据量变大时的增长趋势。其实和咱们上面提到的时间一致,只需要将时间换成空间就好

算法执行时,分为,输入空间,暂存空间,输出空间

暂存空间又可以分为三个部分

  • 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
  • 栈帧空间:保存调用函数的上下文数据
  • 指令空间:保存编译后的程序指令(忽略不计)

具体表现

暂时无法在飞书文档外展示此内容

空间复杂度统计时,主要考虑暂存数据,栈帧空间,输出空间

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


int func() {
    cout << 123456 << " ";
    return 0;
}

int test(int n) {                       // 输入数据,传参
    const int temp_a = 0;               // 暂存数据(常量)
    int temp_b = 0;                     // 暂存数据(变量)
    LinkNode* node = new LinkNode(0);   // 暂存数据(对象)
    int temp_c = func();                // 栈帧空间(调用函数)
    return temp_a + temp_b + temp_c;    // 输出数据
}

空间复杂度推算和时间复杂度推送一致,只不过是将操作次数,变为使用空间的大小。

下面我们来简要分析几个例子

int sum = 0;
void func(){
    return 0;
}
for (int i = 0; i < n; ++i) {
    func();
}

上面的例子,时间复杂度为 O(n), 空间复杂度为 O(1) 这是因为每轮中的 func()返回并释放了栈帧空间,因此空间复杂度仍为 O(1) 。

下面我们再看一个例子

void test(int n) {
    if (n == 0) {
        return;
    }
    return test(n - 1);
}

以上代码的时间复杂度为 O(n),空间复杂度也为 O(n), 该程序在跳出递归前,会不断压栈,会同时存在 n 个未返回的 test ,从而占用 O(n) 的栈帧空间。

下面继续看个示例

#include <vector>
#include <unordered_map>
#include <string>

// 定义链表节点类
class ListNode {
public:
    int val;
    ListNode* next;
    ListNode(int x) : val(x), next(nullptr) {}
};

// 函数用于展示线性空间复杂度
void test_linear(int n) {
    // 长度为 n 的向量 nums,占用 O(n) 空间
    std::vector<int> nums(n);

    // 长度为 n 的向量 nodes,存储 n 个 ListNode 对象,占用 O(n) 空间
    std::vector<ListNode> nodes;
    for (int i = 0; i < n; i++) {
        nodes.push_back(ListNode(i));
    }

    // 长度为 n 的哈希表 map,占用 O(n) 空间
    std::unordered_map<int, std::string> map;
    for (int i = 0; i < n; i++) {
        map[i] = std::to_string(i);
    }
}

在这个函数中,numsnodesmap 分别占用 O(n) 的空间。根据空间复杂度的加法规则,总的空间复杂度为 O(n)。

下面继续看一个空间复杂度为 O(n^2) 的示例

#include <iostream>
#include <vector>

// 生成一个 n x n 的二维矩阵
std::vector<std::vector<int>> generateMatrix(int n) {
    // 创建一个 n x n 的二维向量 matrix
    std::vector<std::vector<int>> matrix(n, std::vector<int>(n, 0));

    // 遍历二维矩阵的每一个元素
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < n; ++j) {
            // 将矩阵元素初始化为其行索引和列索引的乘积
            matrix[i][j] = i * j;
        }
    }
    return matrix;
}

int main() {
    int n = 3;
    std::vector<std::vector<int>> result = generateMatrix(n);

    // 输出二维矩阵
    for (const auto& row : result) {
        for (int val : row) {
            std::cout << val << " ";
        }
        std::cout << std::endl;
    }

    return 0;
}

std::vector<std::vector<int>> matrix(n, std::vector<int>(n, 0)); 创建了一个 n×n 的二维向量 matrix,这个二维向量需要存储 n×n 个整数元素,因此它所占用的空间大小与 n2 成正比。

使用两层嵌套的 for 循环来遍历二维矩阵的每一个元素,并将其初始化为行索引和列索引的乘积。虽然循环内部的操作本身不占用额外的大量空间,但它是为了填充已经分配好的 n×n 大小的二维向量。