白话算法:时间复杂度和大O表示法

每一个优秀的开发者脑中都有时间概念。他们想给用户更多的时间让用户做他们想做的事情。他们通过最小化时间复杂度来实现这一目的。

在你能理解程序的时间复杂度之前,你需要了解最常使用它的地方:算法设计。

所以究竟什么是算法?

简单来说,算法就是一系列被控制的步骤,你通过按序执行这些步骤可以实现一些目标或者产生一些输出。让我们以你祖母烘烤蛋糕的菜单为例子。等等,这也可以算作算法?当然算!

function 烘烤蛋糕(风味, 加冰){

"
 1. 烤箱预热到350度(华氏)
 2. 混合面粉、酵母粉、盐
 3. 打发黄油和糖直到蓬松

 4. 添加鸡蛋
 5. 将黄油和鸡蛋混到面粉中
 6. 添加牛奶和 " + 风味 + "
 7. 继续搅拌
 8. 放入盘中
 9. 烘烤30分钟
10." 如果要 加冰 ,加点冰 "
10. 大快朵颐
"
}
BakeCake('香草味', 要加冰) => 美味

算法在检查时间复杂度时非常有用,因为它可以以各种形态和大小出现。

就犹如你可以用100中不同的方式来切开一个派一样,你可以用多种不同的算法来解决同一个问题。有的解决方法效率更高,比其他的方法需要更少的时间和更少的空间。

所以问题就是:我们怎么分析确认哪一个算法效率最高?

数学!程序的时间复杂度分析仅是一个极其简单的数学方法,这种方法就是分析一个算法对于给定数量的输入需要多长时间来完成任务。这通常定义为大O表示法

你会问,什么是大 O 表示法?

如果你答应不会放弃并且继续阅读,我会告诉你。

大O表示法就是将算法的所有步骤转换为代数项,然后排除不会对问题的整体复杂度产生较大影响的较低阶常数和系数。

数学家可能会对我的“整体影响”假设有点沮丧,但是开发人员为了节省时间,这种方式更容易简化问题:

规律       Big-O

2             O(1)   --> 就是一个常数

2n + 10       O(n)   --> n 对整体结果会产生最大影响

5n^2         O(n^2) --> n^2 具有最大影响

简而言之,这个例子所要表达的就是:我们只关注表达式中对表达式最终结果会产生最大影响的因子。(当常数非常大而n很小的时候并不是这样的,但是我们现在不要担心这种情况)。

下面是一些常用的时间复杂度以及简单的定义。更完整的定义可以翻阅维基百科

  • **O(1)— **常量时间:给定一个大小为n的输入,概算法只需要一步就可以完成任务。

  • **O(log n)— **对数时间:给定大小为n的输入,该算法每执行一步,它完成任务所需要的步骤数目会以一定的因子减少。

  • **O(n)— **线性时间:给定大小为n的输入,该算法完成任务所需要的步骤直接和n相关(1对1的关系)。

  • O(n²)—二次方时间:给定大小为n的输入,完成任务所需要的步骤是n的平方。

  • **O(C^n)— **指数时间:给定大小为n的输入,完成任务所需要的步骤是一个常数的n次方(非常大的数字)。

有了这些概念,我们一起来看看每一个复杂度完成任务所需要的步骤数:

let n = 16;

O (1) = 1 step "(awesome!)"

O (log n) = 4 steps "(awesome!)" -- assumed base 2

O (n) = 16 steps "(pretty good!)"
O(n^2) = 256 steps "(uhh..we can work with this?)"
O(2^n) = 65,536 steps "(...)"

如你所见,随着算法复杂度的提高,事情的复杂度也会以数量级增长。幸运的是,计算机足够强悍能够相对快速的处理非常复杂的问题。

所以我们怎样使用大O表示法来分析我们的代码?

这里有一些简便的例子向你展示怎么将这些知识运用到已有的或者你自己写的代码。

在我们的例子中将使用如下的数据结构:

var friends = {

 'Mark' : true,
 'Amy' : true,
 'Carl' : false,
 'Ray' : true,
'Laura' : false,
}
var sortedAges = [22, 24, 27, 29, 31]

O(1)— 常量时间

当你知道key(objects)或者index(arrays)时进行查找只需要一步就可以完成,所用时间就是一个常量。

//If I know the persons name, I only have to take one step to check:

function isFriend(name){ //similar to knowing the index in an Array 
 return friends[name]; 
};
isFriend('Mark') // returns True and only took one step
function add(num1,num2){ // I have two numbers, takes one step to return the value
 return num1 + num2
}

O (log n)— 对数时间

如果你知道在一个数组的哪一侧进行查找一个指定值时,你可以排除掉另外一半进而节约时间。

//You decrease the amount of work you have to do with each step

function thisOld(num, array){
 var midPoint = Math.floor( array.length /2 );
 if( array[midPoint] === num) return true;
 if( array[midPoint] < num ) --> only look at second half of the array
 if( array[midpoint] > num ) --> only look at first half of the array
 //recursively repeat until you arrive at your solution
 
}
thisOld(29, sortedAges) // returns true 
//Notes
 //There are a bunch of other checks that should go into this example for it to be truly functional, but not necessary for this explanation.
 //This solution works because our Array is sorted
 //Recursive solutions are often logarithmic
 //We'll get into recursion in another post!
​```

###O(n)— 线性时间
你必须通过遍历整个数组(或者list)来完成这一任务。单一**for循环**的时间复杂度通常都是线性的。此外数组方法比如**indexOf**的复杂度也是线性的。你不过是使用已经抽象了的循环过程。

//The number of steps you take is directly correlated to the your input size

function addAges(array){
var sum = 0;
for (let i=0 ; i < array.length; i++){ //has to go through each value
sum += array[i]
}
return sum;
}
addAges(sortedAges) //133
​```

O()— 二次方时间

for循环嵌套的复杂度就是二次方的,因为你在一个线性操作里执行另外一个线性操作(或者说: n*n =n² )

//The number of steps you take is your input size squared

function addedAges(array){
 var addedAge = [];
   for (let i=0 ; i < array.length; i++){ //has to go through each value
     for(let j=i+1 ; j < array.length ; j++){ //and go through them again
       addedAge.push(array[i] + array[j]);
     }
   }
 return addedAge;
}
addedAges(sortedAges); //[ 46, 49, 51, 53, 51, 53, 55, 56, 58, 60 ]
//Notes
 //Nested for loops. If one for loop is linear time (n)
 //Then two nested for loops are (n * n) or (n^2) Quadratic!

O(2^n)—指数时间

指数复杂度通常出现在这种情况:你对数据了解甚少,但是你必须尝试其所有的排列或者组合。

//The number of steps it takes to accomplish a task is a constant to the n power

//实例

 //尝试找出长度为n的密码中所以字符的组合

每当你在编写需要快速运行的代码时都应该做代码时间复杂度分析。

当你有不同的方法来解决一个问题时,比较明智的做法是先实现一个能够工作的解决方案。但是从长远来看,你需要一个尽可能快速且高效的解决方案。

为了帮助你完成解决问题的过程,可以问这些简单的问题:

1、这个可以解决问题吗? =>

2、你有时间考虑时间复杂度吗
=>进入第三步
=>稍后再回来,现在就跳转到第六步

3、它覆盖了所有的边界条件? =>

4、我的复杂度已经尽可能低?
=>重写或者修改解决方案 -> 回到步骤1
=>进入步骤5

5、我的代码D.R.Y(Don't Repeat Yourself) =>

6、欢呼吧!
=>将代码重构到D.R.Y,然后再欢呼!

在任何(和所有)尝试解决问题的时候都应该进行时间复杂度分析。长此以往将你成为优秀的开发者。你的同事和用户都会因此而喜欢你。

再次说明,作为一个程序员你所面临的绝大多数问题-不管是算法问题还是编程问题-没有几百个也有几十个中解决办法。他们在怎么解决问题的方法上会不一样,但是它们都能解决那个问题。

你可以在使用集合还是图来存储数据之间做出选择。你也可以决定是否在团队项目中使用Angular、React或者Backbone。所有这些解决方案以不同的方式解决同一个问题。

鉴于这一点,很难说某一个答案对于这些问题是“正确的”或者“最好的”。但是可以说对于给定的问题存在“更好”或者“更糟糕”的答案。

就我们前面的一个例子,如果你的团队中一半的人都有使用React的经验,那么使用React是一个更佳的答案,这个方案可以花更少的时间就能搭建起来并运行起来。

描述一个更好的解决方案的能力通常源于一些类似的时间复杂度分析。
简而言之,如果你尝试解决一个问题,那就解决的完美一些。并且使用一些大O表示法来帮助你指出怎么做。

最终总结:

  • **O(1)— **常数时间:该算法仅仅使用一步就可以完成任务

  • **O(log n)— **对数时间:该算法每执行一步,它完成任务所需要的步骤数目会以一定的因子减少。

  • **O(n)— **线性时间:该算法完成任务所需要的步骤直接和n相关(1对1的关系)。

  • **O(n²)— **二次方时间:完成任务所需要的步骤是n的平方。

  • **O(C^n)— **指数时间:完成任务所需要的步骤是一个常数的n次方(非常大的数字)。

这里还有一些有用的资源,可以了解更多信息:

  • 维基百科

  • 大O小抄是一个非常棒的资源,它提供了常用的算法时间复杂度,并以图表的形式呈现。

本文译自 Algorithms in plain English: time complexity and Big-O notation

推荐阅读更多精彩内容

  • 通常,对于一个给定的算法,我们要做 两项分析。第一是从数学上证明算法的正确性,这一步主要用到形式化证明的方法及相关...
    刚哥说阅读 823评论 0 11
  • 算法复杂度 时间复杂度 空间复杂度 什么是时间复杂度 算法执行时间需通过依据该算法编制的程序在计算机上运行时所消耗...
    KODIE阅读 1,221评论 0 9
  • 算法是计算机处理信息的本质,因为计算机程序本质上是一个算法来告诉计算机确切的步骤来执行一个指定的任务。算法是独立存...
    LittlePy阅读 680评论 0 0
  • 算法的时间复杂度和空间复杂度-总结通常,对于一个给定的算法,我们要做 两项分析。第一是从数学上证明算法的正确性,这...
    Explorer_Mi阅读 492评论 0 1
  • 文案写手是坐在键盘后面的销售人员。 销售才是广告的目标,将100%的重心,放在如何让读者现身跟你交易。 想要写出有...
    童方民阅读 93评论 0 1