算法
算法的定义是这样的:解题方案的准确而完善的描述,是一系列解决问题的清晰指令,就是解决一个问题的完整性描述。
如何衡量一个算法的好坏,可以通过空间复杂度和时间复杂度两个方面来进行衡量。
-
空间复杂度 评估执行程序所需的存储空间。可以估算出程序对计算机内存的使用程度。
-
时间复杂度 评估执行程序所需的时间。可以估算出程序对处理器的使用程度。
设计算法时,时间复杂度要比空间复杂度更容易出问题,所以一般情况下我们只对时间复杂度进行研究。
时间复杂度
时间频度
如果一个算法所花费的时间与算法中代码语句执行次数成正比,那么那个算法执行语句越多,它的花费时间也就越多。我们把一个算法中的语句执行次数称为时间频度。通常用T(n)表示。n用来表示问题的规模。
一般情况下,算法中基本操作重复执行的次数是n的某个函数,用T(n)表示,f(n)用来描述T(n) 函数中增长最快的部分,。记作T(n)=O(f(n)),称O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。
该表示方法被成为大O表示法。
大O表示法
时间复杂度常用大O符号——O(f(n))表述,不包括这个函数的低阶项和首项系数。
推导大O阶有一下三种规则:
- 用常数1取代运行时间中的所有加法常数
- 只保留最高阶项
- 去除最高阶的常数
大O阶的推导方法
大O表示法O(f(n))中的f(n)的值可以为1、n、logn、n^2 等,所以我们将O(1)、O(n)、O(logn)、O( n^2 )分别称为常数阶、线性阶、对数阶和平方阶。下面我们来看看推导大O阶的方法:
常数阶
例:段代码的大O是多少?
let sum = 0, n = 100;
第一条就说明了所有加法常数给他个O(1)即可
线性阶
一般含有非嵌套循环涉及线性阶,线性阶就是随着问题规模n的扩大,对应计算次数呈直线增长。
let i , n = 100, sum = 0;
for( i=0; i < n; i++ )
{
sum = sum + i;
}
上面这段代码,它的循环的时间复杂度为O(n),因为循环体中的代码需要执行n次。
平方阶
let i, j, n = 100;
for( i=0; i < n; i++ )
{
for( j=0; j < n; j++ )
{
console.log('hi')
}
}
n等于100,也就是说外层循环每执行一次,内层循环就执行100次,那总共程序想要从这两个循环出来,需要执行100*100次,也就是n的平方。所以这段代码的时间复杂度为O(n^2)。
总结:如果有三个这样的嵌套循环就是n^3。所以总结得出,循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数。
对数阶
let i = 1, n = 100;
while( i < n )
{
i = i * 2;
}
由于每次i*2之后,就距离n更近一步,假设有x个2相乘后大于或等于n,则会退出循环。
于是由2^x = n得到x = log(2)n,所以这个循环的时间复杂度为O(logn)。
举例:冒泡排序
假设冒泡排序存在三种情况,1.数组全部正序排列;2.数组全部反向排列;3.数组混乱排序;
第一种情况下,数组只需遍历一遍就可以完成排序,假设数组长度为n,需要遍历n-1次,此时:
T(n) = n-1 = T(O(n));
第二种情况,数组全部反向排列,那么就需要一次次遍历数组直到顺序正确,第一次排序需要执行n-1步,第二次排序可以排除排序正确的数,只需要执行(n-1)-1)次,以此类推,直到最后执行1次完成排序,该过程次数为:
T(n)=n(n-1)/2 = T(O(n^2);
第三种情况,数组混乱排序,可能不需要执行到最后即可完成排序,假设到第i次即可完成排序,该过程执行次数:
T(n)=n(n-i)/2 = T(O(n^2);
综上,冒泡排序的平均时间复杂度为
O(n^2)
规则解析
推导大O阶有一下三种规则:
- 用常数1取代运行时间中的所有加法常数
- 只保留最高阶项
- 去除最高阶的常数
当n增大到一定程度时,f(n)中最高阶的部分占据了主导地位,低阶变量和常数对结果对影响几乎可以忽略不计。
工具地址:函数生成器
低阶变量影响
图中的两个函数分别为
y=3x^2
y=3x^2+2x
随着X的变大,两个函数曲线几乎重合,常数10000的影响微乎其微,可忽略不计。
常量的影响
图中的两个函数分别为
y=2x^2
y=2x^2+10000
随着X的变大,两个函数曲线几乎重合,常数10000的影响微乎其微,可忽略不计。
同理,高阶变量前的常量也可忽略不计
综合
综上,低阶变量和常量对最终结果影响不大
常见时间复杂度的比较
O(1)<O(logn)<O(n)<O(nlogn)<O(n²)<O(n³)<O(2ⁿ)<O(n!)
大O阶的应用
数组去重
方法1:
var arr = [1,2,2,4,3,4,1,3,2,7,5,6,1]
var newArr = new Set(arr); //n
T(n)=n=T(O(n));
该算法时间复杂度为
O(n);
方法2:
function fn(arr){
let newArr = [] // 1
arr.sort((a,b)=>{
return a-b
}) // O(n)=n^2
arr.forEach((val,index)=>{ // n
if(val != arr[index+1]){ // 1
newArr.push(val) // 1
}
})
return newArr //1
}
T(n) =T(O(n^2)) +n+2=T(O(n^2));
该算法时间复杂度为
O(n^2);
方法3:
for(var i=0;i<arr.length;i++){ //n
for(var j=i+1;j<arr.length;j++){ // n*n
if(arr[i]==arr[j]){ // n*n
arr.splice(j,1) // n*n*n
}
}
}
T(n)=n^3+2n^2+n=T(O(n^3));
该算法时间复杂度为
O(n^3);
算法对比
O(n)表示了算法的复杂程度,但是并不代表复杂程度越大的算法,消耗时间越长,具体需要根据n的值来判断。以上面的3种数组去重算法为例,分析n不同的情况下,不同算法的消耗时间。
for(var i = 0,arr =[];i<n;i++){
arr[arr.length]=parseInt(Math.random()*n);
}
function fn1(arr){
let newArr = [] // 1
arr.sort((a,b)=>{
return a-b
}) // O(n)=n^2
arr.forEach((val,index)=>{ // n
if(val != arr[index+1]){ // 1
newArr.push(val) // 1
}
})
return newArr //1
}
function fn2(arr){
for(var i=0;i<arr.length;i++){ //n
for(var j=i+1;j<arr.length;j++){ // n*n
if(arr[i]==arr[j]){ // n*n
arr.splice(j,1) // n*n*n
}
}
}
}
var newArr = new Set(arr)
console.time("newArr");
new Set(arr);
console.timeEnd("newArr");
console.time("fn1");
fn1(arr);
console.timeEnd("fn1");
console.time("fn2");
fn2(arr);
console.timeEnd("fn2");
n=500
n=1000
n=2000
n=5000
n=10000
综合以上的对比结果可以得知,当n<1000时,算法3的时间小于算法2的时间,尽管算法2的时间复杂度小于算法3。
当一个算法当T(n)可以获知时,可以通过对比T(n)来决定使用哪种算法。
综合上文大O阶表示法的推导规则,当n的数值绝对大时,低阶表达式和常数的影响微乎其微,而上述的应用也验证了这一观点,当n超过1000时,O(n^3)的算法耗时大大超过O(n^2)的算法。
因此,当算法应用的情况较为复杂时,利用时间复杂度——O(n)来判断是行之有效的方法。时间复杂度可以是评价一个算法的相对条件,但不是绝对条件。