每个程序员都应该知道的 8 个时间复杂度
所有运行复杂度图
JavaScript 中的数据结构和算法
我们将学习每位开发者都应该熟悉的顶级算法的运行时间。了解这些时间复杂度将有助于评估代码的可扩展性。此外,它还有助于比较同一问题的不同解决方案。最后,您将能够一眼看出不同的实现方式,并知道哪种方式性能更佳。
为了澄清文章其余部分中使用的一些概念:
时间 复杂度 指的不是算法运行所需的时间,而是执行的操作数量。
程序执行的指令数量受输入的大小(以及其元素的排列方式)的影响。
Big O 表示法用于根据输入规模对算法进行分类 n。例如 O(n) 或 O(n 2 )。
在深入探讨之前,这里先提供一份 大O公式速查表 和我们将在本文中介绍的示例。 点击 即可跳转到相应的实现部分。😉
现在,让我们逐一提供代码示例!
O(1) - 常数时间
O(1)描述无论输入规模如何,计算所需时间都相同的算法。
例如,如果一个函数处理 10 个元素和处理 100 万个元素所花费的时间相同,那么我们就说它具有恒定的增长率 O(1)。让我们来看一些例子。
单数还是双数
判断一个数是奇数还是偶数。
function isEvenOrOdd ( n ) {
return n % 2 ? ' Odd ' : ' Even ' ;
}
console . log ( isEvenOrOdd ( 10 )); // => Even
console . log ( isEvenOrOdd ( 10001 )); // => Odd
Enter fullscreen mode
Exit fullscreen mode
进阶提示: 您也可以使用 n % 2位与运算符来代替: n & 1。如果第一个位( 最低有效位 )为 1奇数,则为奇数;否则为偶数。
无论 n 是 0 10还是 1 10,001,它都会执行第 2 行一次。
不要被那些看似简单的承诺所迷惑。它们并不总是能转化为实际效果。你必须了解它们是如何实现的。
如果你有一个类似 `get` 的方法 Array.sort(),或者任何其他数组或对象方法,你必须查看其实现以确定其运行时间。
像加法、乘法、减法、除法、取模、位移等基本运算的运行时间是恒定的。这可能会让你感到惊讶!
如果使用教科书上的长乘法算法, 两个数相乘需要花费一定的时间。然而,大多数编程语言都限制了数字的最大值(例如,在 JavaScript 中,`i` 的值 是 ` max` )。因此,你不能对大于此值的数字进行运算 。所以,原始运算要么需要在固定数量的指令内完成 ,要么会抛出溢出错误(在 JavaScript 中,使用 ` if` 关键字)。 O(n2 )Number.MAX_VALUE1.7976931348623157e+308MAX_VALUEO(1)Infinity
这个例子很简单,我们再来一个。
查找表
给定一个字符串,找出它的词频数据。
const dictionary = { the : 22038615 , be : 12545825 , and : 10741073 , of : 10343885 , a : 10144200 , in : 6996437 , to : 6332195 /* ... */ };
function getWordFrequency ( dictionary , word ) {
return dictionary [ word ];
}
console . log ( getWordFrequency ( dictionary , ' the ' ));
console . log ( getWordFrequency ( dictionary , ' in ' ));
Enter fullscreen mode
Exit fullscreen mode
再次强调,即使字典包含 10 个单词或 100 万个单词,它仍然只会执行一次第 4 行代码来查找单词。但是,如果我们决定将字典存储为数组而不是哈希表,情况就不同了。在下一节中,我们将探讨在数组中查找元素的运行时间。
只有使用完美哈希函数 的哈希表才能实现 O(1) 的最坏情况运行时间 。完美哈希函数并不实用,因此会存在一些冲突,需要通过变通方法来避免,导致最坏情况运行时间变为 O(n) 。尽管如此, 平均 查找时间仍然是 O(1) 。
O(n) - 线性时间
线性运行时间算法非常常见。线性运行时间意味着程序会遍历输入中的每个元素。
线性时间复杂度 O(n)意味着随着输入的增长,算法完成所需的时间也会成比例地延长。
例如:
未排序数组中的最大元素
假设你想从一个未排序的数组中找到最大值。
function findMax ( n ) {
let max ;
let counter = 0 ;
for ( let i = 0 ; i < n . length ; i ++ ) {
counter ++ ;
if ( max === undefined || max < n [ i ]) {
max = n [ i ];
}
}
console . log ( `n: ${ n . length } , counter: ${ counter } ` );
return max ;
}
Enter fullscreen mode
Exit fullscreen mode
该函数将执行多少次运算 findMax?
它会检查输入中的每个元素 n。如果当前元素大于目标元素, max则会执行赋值操作。
请注意,我们添加了一个计数器,以便帮助我们统计内部代码块执行的次数。
如果计算时间复杂度,结果大概是这样的:
第 2-3 行:2 次操作
第 4 行:一个大小为 n 的环
第 6-8 行:for 循环内有 3 个操作。
所以,这就把我们难住了 3(n) + 2。
应用我们在上一篇文章 中学到的大 O 符号 ,我们只需要最高阶项,因此 O(n)。
我们可以使用我们的方法来验证这一点 counter。如果 n它有 3 个元素:
findMax ([ 3 , 1 , 2 ]);
// n: 3, counter: 3
Enter fullscreen mode
Exit fullscreen mode
或者如果 n它有 9 个元素:
findMax ([ 4 , 5 , 6 , 1 , 9 , 2 , 8 , 3 , 7 ])
// n: 9, counter: 9
Enter fullscreen mode
Exit fullscreen mode
现在想象一下,你有一个包含一百万个元素的数组,它将执行一百万次操作。如果我们绘制出元素数量 n 和 findMax运行时间的图像,我们将得到一个类似于线性方程的曲线。
O(n² ) - 二次时间
时间复杂度为二次方的函数,其增长率为 n² 。 如果输入大小为 2,则需要执行 4 次操作。如果输入大小为 8,则需要执行 64 次操作,依此类推。
以下是一些二次算法的代码示例:
有重复项
你想在一个数组中查找重复的单词。一个简单的解决方案如下:
function hasDuplicates ( n ) {
const duplicates = [];
let counter = 0 ;
for ( let outter = 0 ; outter < n . length ; outter ++ ) {
for ( let inner = 0 ; inner < n . length ; inner ++ ) {
counter ++ ;
if ( outter === inner ) continue ;
if ( n [ outter ] === n [ inner ]) {
return true ;
}
}
}
console . log ( `n: ${ n . length } , counter: ${ counter } ` );
return false ;
}
Enter fullscreen mode
Exit fullscreen mode
时间复杂度分析:
第 2-3 行:2 次操作
第 5-6 行:大小为 n 的双环,所以 。 n2
第 7-13 行:在双精度浮点数内部有大约 3 个操作
我们得到 3n^2 + 2。
同样,当我们使用大O表示法时,我们会忽略所有常数,只保留最高有效项: n^2。所以,它应该是 O(n^2)。
我们使用一个计数器变量来帮助我们进行验证。该 hasDuplicates函数有两个循环。如果输入 4 个单词,它将执行内部代码块 16 次。如果输入 9 个单词,它将执行计数器指定的次数 81 次,依此类推。
hasDuplicates ([ 1 , 2 , 3 , 4 ]);
// n: 4, counter: 16
Enter fullscreen mode
Exit fullscreen mode
n 大小为 9:
hasDuplicates ([ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 ]);
// n: 9, counter: 81
Enter fullscreen mode
Exit fullscreen mode
我们来看另一个例子。
冒泡排序
我们想要对数组中的元素进行排序。
function sort ( n ) {
for ( let outer = 0 ; outer < n . length ; outer ++ ) {
let outerElement = n [ outer ];
for ( let inner = outer + 1 ; inner < n . length ; inner ++ ) {
let innerElement = n [ inner ];
if ( outerElement > innerElement ) {
// swap
n [ outer ] = innerElement ;
n [ inner ] = outerElement ;
// update references
outerElement = n [ outer ];
innerElement = n [ inner ];
}
}
}
return n ;
}
Enter fullscreen mode
Exit fullscreen mode
此外,您可能注意到,对于非常大的规模 n,解决问题所需的时间会大幅增加。您能看出嵌套循环与运行时间之间的关系吗?当一个函数只有一个循环时,其运行时间复杂度通常为 O(n)。现在,这个函数有两个嵌套循环,运行时间复杂度为二次方:O(n² ) 。
O(n c ) - 多项式时间
多项式运行的时间复杂度表示为 O(n^ c ),其中 n ≥ c > 11。正如你已经看到的,两个内层循环的时间复杂度几乎为 O(n^ 2 ),因为大多数情况下它需要遍历数组两次。三个嵌套循环的时间复杂度是三次的吗?如果每个循环都访问所有元素,那么是的!
通常情况下,我们会尽量避免多项式运行时间(二次、三次、n- c ……),因为随着输入数据快速增长,它们的计算时间会更长。然而,它们并非最糟糕的选择。
三重嵌套循环
假设你想求解一个如下所示的多元方程:
3x + 9y + 8z = 79
这个简单的程序将给出满足方程的所有解,其中 x, y且 z< n。
function findXYZ ( n ) {
const solutions = [];
for ( let x = 0 ; x < n ; x ++ ) {
for ( let y = 0 ; y < n ; y ++ ) {
for ( let z = 0 ; z < n ; z ++ ) {
if ( 3 * x + 9 * y + 8 * z === 79 ) {
solutions . push ({ x , y , z });
}
}
}
}
return solutions ;
}
console . log ( findXYZ ( 10 )); // => [{x: 0, y: 7, z: 2}, ...]
Enter fullscreen mode
Exit fullscreen mode
该算法的运行时间为立方时间: 。 O(n3 )
注: 我们可以采用更高效的解决方案,但为了展示立方运行时的示例,这已经足够好了。
O(log n) - 对数时间
对数时间复杂度通常适用于每次都将问题分解成两半的算法。例如,假设我们要在一本老式词典中查找一个单词。这本词典中的所有单词都按字母顺序排列。至少有两种方法可以做到这一点:
算法A:
从本书开头开始,按顺序查找,直到找到您要找的联系人。
算法B:
翻开书的中间部分,查看第一个单词。
如果你要找的单词字母比较长,就往右边找;否则,就往左半边找。
哪个算法更快?第一个算法逐字查找,时间复杂度为 O(n) ,而算法 B 在每次迭代中将问题分成两半,时间复杂度为 O(log n) 。第二个算法是二分查找。
二分查找
查找已排序数组中某个元素的索引。
如果我们实现算法 A,遍历数组中的所有元素,则需要 O(n^2)。 O(n)我们能否做得更好?我们可以尝试利用集合已排序这一事实。之后,在查找目标元素时,我们可以将时间复杂度减半。
function indexOf ( array , element , offset = 0 ) {
// split array in half
const half = parseInt ( array . length / 2 );
const current = array [ half ];
if ( current === element ) {
return offset + half ;
} else if ( element > current ) {
const right = array . slice ( half );
return indexOf ( right , element , offset + half );
} else {
const left = array . slice ( 0 , half )
return indexOf ( left , element , offset );
}
}
const directory = [ " Adrian " , " Bella " , " Charlotte " , " Daniel " , " Emma " , " Hanna " , " Isabella " , " Jayden " , " Kaylee " , " Luke " , " Mia " , " Nora " , " Olivia " , " Paisley " , " Riley " , " Thomas " , " Wyatt " , " Xander " , " Zoe " ];
console . log ( indexOf ( directory , ' Hanna ' )); // => 5
console . log ( indexOf ( directory , ' Adrian ' )); // => 0
console . log ( indexOf ( directory , ' Zoe ' )); // => 18
Enter fullscreen mode
Exit fullscreen mode
计算该函数的时间复杂度 indexOf并不像之前的例子那么直接。该函数是递归的。
分析像主方法 这样的递归算法的方法有很多, 但这些方法超出了本文的讨论范围。一般来说,当你看到一个算法将输入分成两半时,它很可能涉及一定的 log n运行时间。由于递归之外的工作量是恒定的,因此我们的运行时间为 O(log n) 。
O(n log n) - 线性对数
线性对数时间复杂度比线性算法稍慢,但仍然比二次算法好得多(您将在帖子的最后看到一个比较所有这些算法的图表)。
归并排序
对数组进行排序的最佳方法是什么?之前,我们提出了一种使用冒泡排序的解决方案,其时间复杂度为 O(n² ) 。我们能否做得更好?
我们可以使用一种叫做算法的方法 mergesort来改进它。 它的工作原理如下:
我们将递归地分割数组,直到元素个数小于等于 2 为止。
我们知道如何对 2 个项目进行排序,所以我们迭代地对它们进行排序(基本情况)。
最后一步是合并:我们从每个数组中逐个取出元素进行合并,使它们按升序排列。
以下是归并排序的代码:
function sort ( n ) {
const length = n . length ;
// base case
if ( length === 1 ) {
return n ;
}
if ( length === 2 ) {
return n [ 0 ] > n [ 1 ] ? [ n [ 1 ], n [ 0 ]] : [ n [ 0 ], n [ 1 ]];
}
// slit and merge
const mid = length / 2 ;
return merge ( sort ( n . slice ( 0 , mid )), sort ( n . slice ( mid )));
}
function merge ( a = [], b = []) {
const merged = [];
// merge elements on a and b in asc order. Run-time O(a + b)
for ( let ai = 0 , bi = 0 ; ai < a . length || bi < b . length ;) {
if ( ai >= a . length || a [ ai ] > b [ bi ]) {
merged . push ( b [ bi ++ ]);
} else {
merged . push ( a [ ai ++ ]);
}
}
return merged ;
}
Enter fullscreen mode
Exit fullscreen mode
如您所见,它有两个函数 sort。 merge合并(Merge)是一个辅助函数,它遍历集合一次 a, b因此其运行时间为 O(n)。排序(Sort)是一个递归函数,每次都将数组分成两半,归并排序的总运行时间为 O(n log n) 。
注:如果您想查看完整的解释,请查看 归并排序的精通方法 。
O(2n ) - 指数时间
指数(以 2 为底)运行时间意味着算法执行的计算量随着输入的增长而翻倍。
集合的子集
找出给定集合的所有不同子集。例如,让我们举几个例子,尝试提出一个解决这个问题的算法:
getSubsets ( '' ) // => ['']
getSubsets ( ' a ' ) // => ['', 'a']
getSubsets ( ' ab ' ) // => ['', 'a', 'b', 'ab']
Enter fullscreen mode
Exit fullscreen mode
你发现什么规律了吗?
第一个返回结果包含一个空元素。
第二种情况返回空元素加上第一个元素。
第 3 个结果与第 2 个结果完全相同,只是在同一个数组中 b添加了第二个元素。
如果要查找 的子集呢 abc?嗯,它恰好是 'ab' 的子集,以及 在每个元素末尾附加 的 ab的子集。 c
正如你所看到的,每次输入长度增加,输出长度都会是前一次的两倍。让我们用代码来实现它:
function getSubsets ( n = '' ) {
const array = Array . from ( n );
const base = [ '' ];
const results = array . reduce (( previous , element ) => {
const previousPlusElement = previous . map ( el => {
return ` ${ el }${ element } ` ;
});
return previous . concat ( previousPlusElement );
}, base );
console . log ( `getSubsets( ${ n } ) // ${ results . slice ( 0 , 15 ). join ( ' , ' )} ... ` );
console . log ( `n: ${ array . length } , counter: ${ results . length } ;` );
return results ;
}
Enter fullscreen mode
Exit fullscreen mode
如果我们对几个案例运行该函数,将会得到:
getSubsets ( '' ) // ...
// n = 0, f(n) = 1;
getSubsets ( ' a ' ) // , a...
// n = 1, f(n) = 2;
getSubsets ( ' ab ' ) // , a, b, ab...
// n = 2, f(n) = 4;
getSubsets ( ' abc ' ) // , a, b, ab, c, ac, bc, abc...
// n = 3, f(n) = 8;
getSubsets ( ' abcd ' ) // , a, b, ab, c, ac, bc, abc, d, ad, bd, abd, cd, acd, bcd...
// n = 4, f(n) = 16;
getSubsets ( ' abcde ' ) // , a, b, ab, c, ac, bc, abc, d, ad, bd, abd, cd, acd, bcd...
// n = 5, f(n) = 32;
Enter fullscreen mode
Exit fullscreen mode
正如预期的那样,如果你绘制 n和 的图像 f(n),你会发现它与函数 完全相同 2^n。该算法的运行时间为 O(2^n)。
注意: 应尽可能避免使用运行时间呈指数级增长的函数,因为它们的扩展性很差。处理输出所需的时间会随着输入规模的增加而翻倍。但指数级运行时间还不是最糟糕的;还有一些函数的运行速度更慢。让我们在下一节中再看一个例子。
O(n!) - 阶乘时间
阶乘是指小于它的所有正整数的乘积。例如:
5! = 5 × 4 × 3 × 2 × 1 = 120
它生长速度很快:
20! = 2,432,902,008,176,640,000
正如你可能猜到的那样,你应该尽可能远离运行时间如此之长的算法!
排列
编写一个函数,计算给定字符串可以组成的所有不同单词。例如
getPermutations ( ' a ' ) // => [ 'a']
getPermutations ( ' ab ' ) // => [ 'ab', 'ba']
getPermutations ( ' abc ' ) // => [ 'abc', 'acb', 'bac', 'bca', 'cab', 'cba' ]
Enter fullscreen mode
Exit fullscreen mode
你会如何解决这个问题?
一个简单的办法是检查字符串的长度是否为 1,如果是,则返回该字符串,因为你无法以不同的方式排列它。
对于长度大于 1 的字符串,我们可以使用递归将问题分解成更小的子问题,直到处理长度为 1 的情况。我们可以取出第一个字符,然后处理字符串的剩余部分,直到字符串长度为 1。
function getPermutations ( string , prefix = '' ) {
if ( string . length <= 1 ) {
return [ prefix + string ];
}
return Array . from ( string ). reduce (( result , char , index ) => {
const reminder = string . slice ( 0 , index ) + string . slice ( index + 1 );
result = result . concat ( getPermutations ( reminder , prefix + char ));
return result ;
}, []);
}
Enter fullscreen mode
Exit fullscreen mode
如果将输出结果打印出来,大概会是这样:
getPermutations ( ' ab ' ) // ab, ba...
// n = 2, f(n) = 2;
getPermutations ( ' abc ' ) // abc, acb, bac, bca, cab, cba...
// n = 3, f(n) = 6;
getPermutations ( ' abcd ' ) // abcd, abdc, acbd, acdb, adbc, adcb, bacd...
// n = 4, f(n) = 24;
getPermutations ( ' abcde ' ) // abcde, abced, abdce, abdec, abecd, abedc, acbde...
// n = 5, f(n) = 120;
Enter fullscreen mode
Exit fullscreen mode
我尝试使用长度为 10 的字符串。大约花了 8 秒!
time node ./lib/permutations.js
# getPermutations('abcdefghij') // => abcdefghij, abcdefghji, abcdefgihj, abcdefgijh, abcdefgjhi, abcdefgjih, abcdefhgij...
# // n = 10, f(n) = 3,628,800;
# ./lib/permutations.js 8.06s user 0.63s system 101% cpu 8.562 total
Enter fullscreen mode
Exit fullscreen mode
我给你布置了一点作业……
你能试试用 11 个字符的排列组合吗? ;) 在下方评论区留言,说说你的电脑发生了什么!
所有运行复杂度图
我们探讨了最常见的算法运行时间,并分别举了一两个例子!这些例子应该能帮助你了解如何在项目开发过程中计算运行时间。下面是一个图表,展示了我们讨论过的所有时间复杂度:
注意时间复杂度!
您可以在Github代码库中找到所有这些示例以及更多内容:
🥞数据结构与算法讲解及JavaScript实现(电子书)
JavaScript 中的数据结构和算法
这是DSA.js 书籍 的代码实现 以及 NPM 包的仓库。
在这个代码库中,你可以找到 JavaScript 中算法和数据结构的实现。这些资料可以作为开发者的参考手册,也可以帮助你在面试前复习特定主题。此外,你还可以从中获得更高效的问题解决方法。
目录
安装
您可以克隆此仓库或从 NPM 安装代码:
npm install dsa.js
Enter fullscreen mode
Exit fullscreen mode
然后您可以将其导入到您的程序或命令行界面中。
const { LinkedList , Queue , Stack } = require ( 'dsa.js' ) ;
Enter fullscreen mode
Exit fullscreen mode
有关所有公开数据结构和算法的完整列表, 请参见 。
特征
算法是……
文章来源:https://dev.to/amejiarosario/8-time-complexities-that-every-programmer-should-know-494m