算法时空复杂度分析笔记

265 阅读3分钟

Big O 表示法

1、只保留增长速率最快的项,其他的项可以省略

大O表示法,只表示最坏的情况,一些额外的项可以省略掉。

乘法和加法中的常量可以省略忽略

o(2N + 100) ==> O(N)
O(2^(N+1)) ==> O(2 * 2^N) = O(2^N)
O(M + 3N + 99) ==> O(M + N)

同时当多个表达式组合时,可以省略增长速率缓慢的项,保留增长速率快的项。

O(N^3 + N^2 + N) ==> O(N^3)
O(N*2^N + 2^N) ==> O(N*2^N)

2、Big O 记号表示复杂度的「上界」 这个上界表示最坏情况,但是你可以大于最坏情况,大于实际上界也是上界,也是有效的。

比如一个for循环,它的算法复杂度是O(N),但是你写成O(N^2),也是可以,它包含O(N)。

有时候你自己估算出来的时间复杂度和别人估算的复杂度不同,并不一定代表谁算错了,可能你俩都是对的,只是是估算的精度不同,一般来说只要数量级(线性/指数级/对数级/平方级等)能对上就没问题。

非递归算法分析

非递归的大部分场景下,只需把每一层的复杂度相乘就是总的时间复杂度

// 1*W + 1*W + 1*W + ... + 1*W这样有N个,所以时间复杂度是o(W*N)
for (int i = 1; i <= N; i++) {
    for (int w = 1; w <= W; w++) {
        ...;
    }
}

// 1*1 + 1*2 + 1*3 + ... + 1*N 所以时间复杂度是N/2*(N+1),简化就是N^2+N/2,再简化就是O(N^2)
for (int i = 0; i < n; i++) {
    for (int j = i; j >= 0; j--) {
        ...;
    }
}

复杂一点的循环算法, 左右指针或者快慢指针

// 左右双指针框架
int lo = 0, hi = nums.length;
while (lo < hi) {
    int sum = nums[lo] + nums[hi];
    int left = nums[lo], right = nums[hi];
    if (sum < target) {
        while (lo < hi && nums[lo] == left) lo++;
    } else if (sum > target) {
        while (lo < hi && nums[hi] == right) hi--;
    } else {
        while (lo < hi && nums[lo] == left) lo++;
        while (lo < hi && nums[hi] == right) hi--;
    }
}

滑动窗口

/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
    unordered_map<charint> need, window;
    for (char c : t) need[c]++;
    // 双指针,维护 [left, right) 为窗口
    int left = 0, right = 0;
    while (right < s.size()) {
        // 增大窗口
        right++;
        // 判断左侧窗口是否要收缩
        while (window needs shrink) {
            // 缩小窗口
            left++;
        }
    }
}

这些算法其实初看是循环套循环,但其实质是一个简单的指针移动,所以它的时间复杂度是O(N)

数据结构分析

如果想衡量数据结构类中的某个方法的时间复杂度,不能简单地看最坏时间复杂度,而应该看摊还(平均)时间复杂度

/* 单调队列的实现 */
class MonotonicQueue {
    LinkedList<Integer> q = new LinkedList<>();

    public void push(int e) {
        // 将小于 e 的元素全部删除
        while (!q.isEmpty() && q.getLast() < e) {
            q.pollLast();
        }
        q.addLast(e);
    }

    public void pop(int e) {
        // e 可能已经在 push 的时候被删掉了
        // 所以需要额外判断一下
        if (e == q.getFirst()) {
            q.pollFirst();
        }
    }
}

这里push,pop的算法复杂度都是O(1),虽然push里有一个循环,但是这个循环并不是每次都要执行N次,它是N次添加后的累计循环(比如第一次不需要执行N次循环,它是在执行添加N次后才需要在N+1次那执行N次循环,他把循环分摊到多步),平均下来,还是O(1),引用其它作者的解释:

给你一个空的 MonotonicQueue,然后请你执行 N 个 push, pop 组成的操作序列,请问这 N 个操作所需的总时间复杂度是多少?

因为这 N 个操作最多就是让 O(N) 个元素入队再出队,每个元素只会入队和出队一次,所以这 N 个操作的总时间复杂度是 O(N)

那么平均下来,一次操作的时间复杂度就是 O(N)/N = O(1),也就是说 push 和 pop 方法的平均时间复杂度都是 O(1)

类似的,想想之前说的数据结构扩容的场景,也许 N 次操作中的某一次操作恰好触发了扩容,导致时间复杂度提高,但不可能每次操作都触发扩容吧?所以总的时间复杂度依然保持在 O(N),均摊到每一次操作上,其平均时间复杂度依然是 O(1)

递归算法分析

所有递归算法的本质是树的遍历;递归算法做的事情就是遍历一棵递归树,在树上的每个节点所做一些事情罢了。

递归算法的时间复杂度 = 递归的次数 x 函数本身的时间复杂度

递归算法的空间复杂度 = 递归堆栈的深度 + 算法申请的存储空间