这是我学习慕课网波波老师的算法课程时记录的笔记,原视频在 这里
在所有的算法面试中,有一个问题几乎是逃不掉的 — 你的算法时间复杂度是多少?况且有时候,面试官会直接让我们设计一个复杂度为 xxx 级别的算法。所以,复杂度估算是学习算法(通过算法面试)必须要了解的东西。 很多同学一提起复杂度分析就头疼,马上想起了《算法导论》中复杂的数学推导。但其实在一般的企业面试中,对复杂度的分析要求并没有那么高,但也是绕不过去的坎儿。
什么是大 O
在计算复杂度时,我们通常会用 O(n) / O(nlogn) 来表示。 那么究竟什么是大O呢?
-
n 表示数据规模
-
O(f(n)) 表示运行算法所需要执行的指令数,和 f(n) 成正比。
举个 🌰
-
二分查找法:O(logn) — 表示所需要执行指令数:a*logn
-
寻找数组中的最大/最小值O(n) — b*n
-
归并排序算法 O(nlogn) — c*nlogn
-
选择排序法 O(n^2) — d*n^2
其中 a、b、c、d 表示常数,是固定不变的。换句话说,随着数据规模 n 的不断增大,算法效率的改变和 a、b、c、d 常数的关系是不大的。而和 log n、n、nlogn 、n^2 关系巨大,所以我们通常省略常数项。
比如下面这个例子,算法A O(n) 所需执行指令数是 10000 * n,而算法B O(n^2) 所需执行指令数是 10 * n^2。
| n | A指令数10000n | B指令数10n^2 | 倍数 |
|---|---|---|---|
| 10 | 10^5 | 10^3 | 100 |
| 100 | 10^6 | 10^5 | 10 |
| 1000 | 10^7 | 10^7 | 1 |
| 10000 | 10^8 | 10^9 | 0.1 |
| 10^5 | 10^9 | 10^11 | 0.01 |
| 10^6 | 10^10 | 10^13 | 0.001 |
可以看出,随着数据量的增加,时间复杂度高的算法一定比时间复杂度低的算法运行时间更长。 这个例子,我们要处理1000w的数据。使用算法 A 要用一天的时间,而算法B要用一年的时间才能完成。 所以说,这就是我们要尽量减少算法复杂度的原因。
而且通过这张图我们可以很好的理解,随着输入数据量的增大,他们的增长速度是完全不同的。
O(n) 的严格的定义
相信现在大家对 O(n) 已经有了初步的认识,那么我们来看O(n)更严格的定义。 在学术界,严格来讲,O(f(n))表示算法执行的上界。什么叫「算法执行的上界」呢?
🌰 归并排序算法的时间复杂度是 O(nlogn)的,同时也是O(n^2)的。
但是在面试中,我们就使用 O 来表示算法执行的最低上界。虽然严谨地讲,「归并排序算法的时间复杂度是O(n^2)」这句话是对的。但是在面试中,我们一般不会这么说。
时间复杂度相加
如果有多个时间复杂度,怎么计算? 比如我们设计了一个算法,它由多个算法相加,时间复杂度是 O(nlogn + n)。那么整个算法将以时间复杂度高的作为主导。也就是说,一个算法时间复杂度是 O(nlogn + n),我们可以把它看作 O(nlogn)。因为随着数据规模的增加,O(nlogn) 这一项起到了主导作用,而 O(n) 的作用是不显著的。
同理,我们设计了 O(nlogn+n^2) 复杂度的算法,我们就说它的时间复杂度是 O(n^2)。但这个计算方法的前提是,两部分的数据规模一定是一致的,也就是说两个n是相同的。
而在实际情况中,也存在这种情况的复杂度:O(AlogA+B),而A和B是没有关系的。这个时候,我们就不能合并算法复杂度。
一个思考题
有一个字符串数组,将数组中的每一个字符串按照字母序排序;之后再将整个字符串数组按照字典序排序。 整个操作的时间复杂度?
你可能会想,这个问题还不简单 😎
1、每一个字符串按照字母序排序:每一次排序都是 nlogn,一共有 n 个字符串,所以是 n * nlogn 2、之后再将整个字符串数组按照字典序排序 nlogn
把他们相加起来,O(n * nlogn + nlogn) = O(n^2logn)
很遗憾,这个想法是错的❌。
首先,「这个字符串」的长度和「整个字符串数组」混在了一起,他们是没有关系的。所以,我们需要将这两个参数拆开。 假设最长的字符串长度为s;数组中有n个字符串。对每个字符串排序:O(slogs)。 将数组中的每一个字符串按照字母序排序:O(n*slog(s))
然后,字符串数组按照字典序排序的复杂度计算为 nlogn 也是不对的。 排序算法中,时间复杂度 O(nlogn) 表示的是比较的次数,通常对整型数组进行排序,只需要进行 O(nlogn) 次比较。因为两个整数进行比较,在计算机中是 O(1) 级别的。 但是,两个字符串进行比较可不一样了,为了得到两个字符串在字典序中谁在前谁在后,这个比较的过程会耗费 O(s),也就是字符串长度的性能消耗。因此,我们将字符串数组按照字典序排序的复杂度应该为 O(s*nlog(n))
综合来看,对于这两部分相加算法时间复杂度为: O(nslog(s)) + O(snlog(n)) = O(ns(logs + logn))
复杂度是和用例相关的
影响算法时间复杂度的还有测试用例。 比如插入排序算法,最差的情况下是 O(n^2),但是在最好情况下是 O(n)。但我们把排序算法的复杂度约定为 O(n^2),因为对于平均情况来说,是O(n^2)。
快速排序算法,最差的情况是 O(n^2),最好的情况是 O(nlogn)。但我们把排序算法的复杂度约定为 O(nlogn),因为对于平均情况来说,他的复杂度就在这个。
数据规模
为了让大家对对数据规模有一个概念,在这里我们用程序来说明。运行一个 O(n)的程序,n的取值从 1、10……10^9。
for (let i = 0; i <= 9; i++) {
const n = Math.pow(10, i);
console.time("someFunction");
let sum = 0;
for (let j = 0; j < n; j++) {
sum += j;
}
console.log("10^" + i + "s");
console.timeEnd("someFunction");
}
/*
10^0s someFunction: 7.882ms
10^1s someFunction: 0.052ms
10^2s someFunction: 0.044ms
10^3s someFunction: 0.076ms
10^4s someFunction: 0.25ms
10^5s someFunction: 9.683ms
10^6s someFunction: 1.446ms
10^7s someFunction: 13.859ms
10^8s someFunction: 125.905ms
10^9s someFunction: 1.026s
*/
在数据量比较小的时候,还没什么代表的意义。 但是,从数据规模达到1000开始,每上升10倍,相应地时间也基本提升了10倍。
如果你想要在 1 s 之内解决问题:
O(n^2)的算法可以处理大约 10^4 级别的数据, O(n)的算法可以处理大约 10^8 级别的数据, O(nlogn)的算法可以处理大约 10^7 级别的数据,因为 lg10^7 约等于 7。
那么在面试中,我们就可以根据数据规模大致估算算法的复杂度。 如果面试管说,我的数据规模是 10^8 级别。 那就可以确定,算法复杂度基本是 O(n)。 (当然有的时候 O(nlogn) 也是可以的,这是因为 logn 是复杂度非常低的一个级别,处理速度也是非常快的。)
如果面试官提示说,我们这个算法只需要处理 10^3 的数据。 这个时候给我们留下了足够的空间,设计一个 O(n^2) 的复杂度就可以满足要求。
空间复杂度
和时间复杂度相比,空间复杂度是很容易计算出来的。 总体来讲就是,看开了多大的辅助空间,就说用了多大的空间复杂度。 比如说我们的数据规模是 n,相应地,我们开通了长度为 n 的辅助数组,来帮助执行算法,那么空间复杂度就是 O(n) 级别的。 如果多开一个辅助的二维数组,那么我们的空间复杂度就是 O(n^2)。当然了,对于很多算法,我们并不需要开辟很大的数组空间,比如原地排序。 只需要开一些临时的变量,来存储这些数据。此时,我们说这个算法的空间复杂度是 O(1)。 最后,对于空间复杂度,我们需要特别注意:递归是有空间代价的。 在递归中,计算机会把递归调用的函数依次压入系统栈中,相当于占据了一定的空间。 写一个算法计算从0到n的和。
const sum = (n) => {
let result = 0;
for (let i = 0; i < n; i++) {
result += i
}
return result
}
用循环遍历的方法写出来,它的空间复杂度是 O(1),如果用递归呢?
const sum = (n) => {
if(n === 0){
return 0;
}
return n + sum(n-1)
}
空间复杂度:O(n) 递归调用的深度是n,我们的系统栈中需要压入n个状态。 换句话说,在整个递归调用中,递归的深度是多少,我们的空间复杂度就是多少。
常见的算法复杂度分析
O(1)
function swapTwoNumbers(a, b) {
let temp = a;
a = b;
b = temp;
}
在这个算法中没有数据规模的变化,也就是常数级别的算法,所以算法复杂度是 O(1)
O(n)
function sum (n) {
let result = 0;
for(let i = 0;i <= n;i++){
result += i
}
return result;
}
这个函数计算从0到n整数的和,里面有一个循环,并且循环的次数是和 n 相关的,这就是非常典型的O(n)算法。 换句话说,这个循环执行的次数应该是 cn 次。其中 c 是一个常数,它可以不是一个大于 1 的数。 比如这个算法:
function reverse (s) {
let n = s.length;
for(let i = 0;i<n/2;i++) {
[s[i],s[n-1-i]] = [s[n-1-i], s[i]]
}
}
进行了 (1/2)*n 次 交换操作,它的算法时间复杂度 O(n) 也就是随着字符串长度的增加,算法所消耗的时间是线性增加的。
O(n^2)
function selectionSort(arr, n) {
for (let i = 0; i < n; i++) {
let minIndex = i;
for (let j = i+1; j < n; j++) {
if(arr[j] < arr[minIndex]) {
minIndex = j
}
}
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]
}
}
这个算法中包含了一个双重循环,所以一般都是 O(n^2) 时间复杂度的算法。
严格地讲,这个算法执行的指令数和数据复杂度是成 n^2 比例关系的。
来看看 minIndex = j 执行了多少次。
-
当 i = 0 时, j 是 1 到 n,一共执行 n - 1 次;
-
当 i = 1 时, j 是 2 到 n,一共执行 n - 2 次;
-
……
-
当 i = n - 1 时, j 是 n 到 n,一共执行 0 次;
-
(n-1)+(n-2)+(n-3)+ …… +0 =(0+n-1) n-2 = 1/2 n*(n-1) = O(n^2)
当然了,我们也不能一看到双重循环,就认为时间复杂度是 O(n^2)。 举个例子:
function printInformation (n) {
for (let i = 1; i <=n ;i++){
for(let j = 1;j<=30;j++) {
console.log(i, j)
}
}
}
虽然这里是一个双重循环,但是 console.log 操作执行的次数是 30n 次, 所以算法复杂度是O(n)。
O(logn)
比如大家都很熟悉的二分查找法:
function binarySearch(arr, n, target) {
let l = 0; r = n-1;
while(l<=r){
let mid = l + (r-l)/2;
if(arr[mid] === target) return mid;
if(arr[mid] > target) r = mid - 1;
else l = mid + 1;
}
return -1;
}
我们先回顾下二分查找法的思路,对于一个有序数组。 先找到数组中间的元素,将数组中间的元素和选中的target进行比较。 如果找到了,就返回该元素; 否则的话,就根据大小关系来判断是在数组左侧、右侧继续寻找。 第一次,我们在 n 个元素中查找 target。 如果没找到,我们在 n/2 个元素中查找 n/4 ... 1 那这个过程一共执行了几次呢? 相当于求解,n 经过几次「除以2」操作后,等于1? (下面一张图) 答案是 log以2为底n。所以时间复杂度是 O(logn)
// 把 number 转换为 string 的操作:
function numberToString (num) {
let s = ""
while( num ) {
s += '0' + num%10;
num /= 10;
}
reverse(s)
return s;
}
仔细看这个算法,它的复杂度相当于:n 经过几次“除以10”操作后,等于 0? 经过上面的学习,我们很快可以得到答案 log(10)n = O(logn)
上面我们说过,看到双重循环,基本可以断定为 O(n^2) 级别的算法。 但是也有特例,比如下面这个:
function hello(n){
for (let sz = 1; sz < n; sz+=sz) {
for (let i = 1; i < n; i++) {
console.log("hello")
}
}
}
第一重循环每次 size += size,就相当于每次是乘以 2 的,直到超过了 n O(logn) 第二重循环是 1 到 n O(n) 所以时间复杂度是 O(nlogn)
复杂度实验
在前面的文章中,我们大概了解了程序与复杂度的关系。但即使如此,我们在真正设计程序的时候,也不能完全保证算法的时间复杂度是我们想要的。
为了解决这个问题,我们可以做实验,观察随着数据规模的增大,程序允许时间的变化。比如我们可以每次将数据规模提高两倍,来看它们之间时间的变化。
在 JavaScript 可以使用 console.time("someFunction");console.timeEnd("someFunction") 来观察时间变化,在这就不过多介绍了。
递归算法的复杂度分析
无论是归并排序还是快速排序,都用到了递归算法。
由于这两个算法复杂度都是 O(nlogn) 级别的,刚接触算法的时候,你可能会认为递归算法的复杂度就是 O(nlogn) 。
当然这个结论是不对的,一定要具体问题具体分析。
第一种情况很简单,我们只会进行一次递归调用。
二分查找
最典型的例子就是二分查找:
function binarySearch(arr, l, r, target) {
if(l > r) {
return -1;
}
let mid = l + (r-l)/2
if (arr[mid] === target) {
return mid;
}
else if (arr[mid] > target) {
return binarySearch(arr, l, mid - 1)
}
else {
return binarySearch(arr, mid + 1, r)
}
}
对于这个函数,每次只进行一次递归调用,所以我们只需要计算递归的深度是多少。 下次查找的数组范围是当前数组的一半,递归调用的深度是 logn,处理问题的复杂度是 O(1),所以时间复杂度是 O(logn)。 所以,如果递归函数中,只进行一次递归调用,递归深度为 depth, 在每个递归函数中,时间复杂度为 T, 则总体的时间复杂度为 O(T * depth)。
计算 0 - n 的和
再比如: 计算 0 - n 的和
function sum(n) {
if (n === 0) {
return 0
}
return n + sum(n-1)
}
这个函数的递归深度:n 在每个递归函数中,时间复杂度为:O(1) 则总体的时间复杂度为 O(n)
此时我们应该关系计算调用的次数。
斐波那契数列
function f(n) {
if (n === 0){
return 0
}
return f(n-1) + f(n-1)
}
通常,我们可以画一颗递归树,比如 f(3) 时:
所以,我们要想知道 f(3) 调用了多少次,只需要数树上的节点就可以了。
1 + 2 + 4 + 8 = 15
2^0 + 2^1 + 2^2 + 2^3 + …… + 2^n = 2^(n+1) - 1
复杂度 O(2^n)
这其实是一个等比数列的求和公式。经过计算之后,它的结果是 2 的 N 次方级别的数字。换句话说,这个算法,它每次都两次自己调用,自己调用,两次递归函数。最终,我们得到了一个指数级的算法。
请注意,指数级的算法是一种非常慢的算法。如果你设计出了一个指数级的算法,就一定要小心。比如说 N 在 20 左右,就是百万级的计算量了。如果 N 在 30 的时候,普通的计算机转起来就已经非常慢了。其实你是可以对这种进行优化的,比如说进行剪枝操作,再比如说有些指数级算法,可以转化为动态规划等等其他的方法。这样我们就把一个指数级的算法问题,转换成一个多项式级别的算法问题。
它本质其实是计算机在任务中,建立了一颗搜索树, 这个搜索树的大小同时也是我们要计算的时间复杂度, 这是一个非常重要的概念。
归并排序、快速排序 O(nlogn)
但是可能有些同学就会问了,我们说的这些高级的排序算法,归并排序算法或者快速排序算法,它们的时间复杂度不是 2 的 N 次方,而是 O(nlog n) 。
function mergeSort(arr, l, r) {
if (l >= r) {
return;
}
let mid = (l+r)/2
mergeSort(arr, l, mid)
mergeSort(arr, mid+1, r)
merge(arr, l, mid, r)
}
这是因为在我们之前所举的例子中,我们整棵树的深度是N,而在这些排序搜索中,我们这些树的深度其实是 logn 的。
那么归并排序、快速排序的算法复杂度是怎么计算的呢?
在这些排序算法中,我们在每个节点中处理的数据规模是逐渐缩小的。而我们之前的例子,每一个节点所处理的数据规模都是一样的,虽然他们都是1,那么大家可以看在右侧,我就画出了。对于归并排序算法,我们要排序 8 个数字的时候,递归树的形状是怎么样的。
当我们的数组长度为 8 时,节点树如下: 一共 logn 层, 对于每一层的节点,又是 O(n) 级别的算法。 所以,总的算法复杂度是 O(nlogn)
在根的地方,我们要处理 8 个元素,之后,一分为二,每个节点处理四个元素,再一分为二,每个节点处理二个因素,最后一分为二,每个节点处理一个元素,一个元素的时候,我们就不用处理了,因为 1 个元素不用排序。
那么在我们有 8 个元素的时候,首先这棵树它的层数不是八层,而是三层。而且每一个节点所处理的数据规模是越来越小的。
那么,这个 n log n 怎么来的呢?我们一共有 logn 这么多层。
-
第一层,只有一个节点放了这个元素。
-
第二层,虽然分成了两个节点,但每个节点只有 4 个元素,总体还是8。
-
第三层,其实是 4 个2,总体还是吧,而每个节点又是一个 O(n) 级别的算法。
所以总体来说,在每一层上,我们处理的数据量也是 O(n) 这个级别一共有 logn 层,把它们相乘就得出了 o n log n。
事实上,这样的一颗递归树其实是分治算法。也正是因为如此,通常我们设计出的分治算法,时间复杂度是 n log n 级别的。
我们这里所分析的递归的情况,基本涵盖了大部分递归的时间、复杂度分析中所使用的技术。如果有一些同学对更加复杂的情况感兴趣的话,可以看看主定理。主定理是一个式子,这个式子归纳了递归函数所计算时间、复杂度的所有的情况。一个递归函数把整个数据分成几份进行的,归每一份里面的时间,复杂度又是多少。对于不同的情况,主定理都给出了答案。(但是通常在面试中也不会考到主定理这个概念。)