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<char, int> 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 函数本身的时间复杂度
递归算法的空间复杂度 = 递归堆栈的深度 + 算法申请的存储空间