算法和流程控制
文章目录
目录
- 循环
- 条件语句
- 递归
代码的整体结构是影响运行速度的主要因素之一。
代码数量少并不意味着运行速度就快,代码数量多也并不意味着运行速度就一定慢。
循环
循环处理是最常见的编程模式之一,也是提升性能必须关注的要点之一。
理解js中循环对性能的影响至关重要,因为死循环或长时间运行的循环会严重影响用户体验。
问题
- 循环有几种
- 哪种性能好
- 怎么去优化
循环的类型
for循环
for(let i = 0; i < 10; i++) {
// 循环主体
}
for循环的组成:初始化,前测条件,后执行体,循环体。
在for循环初始化中var语句会创建一个函数级别的变量,而不是循环级。
es6推出的let是块作用域。
while循环
let i = 0
while( i < 10) {
// 循环主体
i++
}
do-while循环
let i = 0
do{
// 循环主体
} while(i++ < 10)
for-in循环
for(let prop in object) { // 循环返回的属性包括了对象实例属性和原型链中继承的属性
// 循环主体
}
例子:
function Animal(){
this.name = 'animal'
this.eat = function() {
console.log('food')
}
}
Animal.prototype.say = function() {
return 'a'
}
let cat = new Animal()
for(let prop in cat) {
console.log(prop)
}
小结
- for、while 前测循环
- do while 后测循环,至少执行一次。
- for in 枚举
性能
引入
for in 最慢,每次迭代都要搜索实例和原型属性。
优化点1:过滤掉不属于自己的属性
function Animal(){
this.name = 'animal'
this.eat = function() {
console.log('food')
}
}
Animal.prototype.say = function() {
return 'a'
}
let cat = new Animal()
for(let prop in cat) {
if(cat.hasOwnProperty(prop)) {
console.log(prop)
}
}
优化点2:遍历已知的属性的列表,提取已知属性,使用其他循环代替。
function Animal(){
this.name = 'animal'
this.eat = function() {
console.log('food')
}
}
Animal.prototype.say = function() {
return 'a'
}
let cat = new Animal()
let props = ['eat', 'name']
let i = 0
while(i < props.length) {
console.log(cat[props[i++]])
}
相对于查找该对象的每个属性,该代码只关注给定的属性,减少了开销,也节省了时间。
tip:不要使用for in 遍历数组成员。为什么?
小结:除了for-in循环外,其他循环类型的性能都差不多,深究哪种循环最快没有什么意义。
循环的选择基于需求而不是性能。
如果循环类型与性能无关,那么如下选择:
1.每次迭代处理的事务
2.迭代的次数
通过减少这两者之一或全部的时间开销,提升整体的循环性能。
循环分析
根据条件一,很明显,如果一次循环迭代要花很多的时间去执行,那么多次循环意味着需要更多的时间。
for的一般写法:
for(let i = 0; i < arr.length; i++) {
process(arr[i])
}
分析:
在上面的循环中,每次运行循环体都会产生如下操作:
- 在控制条件中查找一次属性(
arr.length
) - 在控制条件中执行一次数值比较(
i < arr.length
) - 一次比较操作查看控制条件的计算结果是否为true(
i < arr.length === true
) - 一次自增操作(
i++
) - 一次数组查找(
arr[i]
) - 一次函数调用(
process(arr[i]
))
在这些简单的循环中,即使代码不多,每次迭代也要进行许多操作。
代码运行速度很大程度取决于函数(process()
)对每个数组成员项的操作,
即使如此,减少每次迭代中的操作总数能大幅提高循环的总体性能。
优化
优化循环的第一步是减少对象成员及数组项的查找次数。在大多数浏览器中,这些操作比使用局部变量和字面量需要花费更大时间。
举个例子:当我们操作一个元素或者赋值的时候,有机会出现下面的写法。
例子:赋值1
let res = await this.$get(url, params)
if (res && res.code === '00000') {
res.data = res.data.reverse()
res.data.map(item => {
item.selected = true
return item
})
this.data = res.data
}
例子:赋值2
handleRefuse() {
this.$refs.dialog.dialogTitle = '操作驳回xx'
this.$refs.dialog.auditResult = '1'
this.$refs.dialog.dialogShow = false
this.$refs.dialog.afferentArr = this.selectedData
}
每一行代码都要先去查找this->$refs->dialog->对应的属性
,这里执行了四次查找。
赋值优化1:
let res = await this.$get(url, params)
if (res && res.code === '00000') {
let list = res.data
list.map(item => {
item.selected = true
return item
}).reverse()
this.data = list
}
赋值优化2:
handleRefuse() {
let obj = {
dialogTitle : '操作驳回xx',
auditResult : '1',
dialogShow : false,
afferentArr : this.selectedData
}
Object.assign(this.$refs.dialog, obj)
}
分析:前面的例子中,每次循环都需要查找item.length
,然而该值在运行过程中却并没有什么改变,因此产生了不必要的性能损失。
我们可以将该值存在一个局部变量中,然后在控制条件中使用这个变量。
1.限制循环中耗时操作的数量
1.1减少每次arr.length的查找(根据条件一,从初始值入手优化)
// 最小化属性查找
for(let i = 0, l = arr.length; i < l; i++) {
...
}
因为这是优化每次读取的数组的长度,所以该优化的效果由数组的长度决定。
1.2颠倒数组(根据条件一,从判断条件入手优化)
通常数组项的顺序和执行的任务无关,因此从最后一项开始向前处理是其中一个可选方案。
这是编程语言中一种通用的性能优化方法。它减少了属性查找。
// 倒序
for(let i = l.arr.length; i > 0; i--) {
...
}
// 再优化,合并>0的判断。条件判断会自动转为true
for(let i = l.arr.length; i--;) {
...
}
分析:使用了倒序循环,并把减法操作放在控制条件中。
现在每个控制条件只是简单的与0比较,即控制条件和true值比较时,任何非0都会自动转换为true,而0等同于false。
控制条件从两次比较(迭代数少于总数吗?它是否为true?i < l; i < l === true
)减少到一次比较(它是true吗?i-- === true
)
对比原版本,我们这里减少了两次操作。
当复杂度为
O(n)
时,减少每次迭代的工作量最有效。
当复杂度大于O(n)
时,着重减少迭代次数。
算法复杂度比较
O(1)<O(logn)<O(n)<O(nlogn)<O(n²)<O(n³)<O(2ⁿ)<O(n!)
2.减少迭代次数
即使循环体中执行最快的代码,累计迭代上千次也会慢下来。
此外,循环体运行时会带来一次小的性能开销,这增加了总体运行时间。
减少迭代次数能获得更加显著的性能提升。
最广为认知度一种限制循环次数的模式被称为:达夫设备模式-“Duff Device”
[例子演示][https://andrew.hedges.name/experiments/javascript_optimization/]
let arr = [1,2,3,4,5,6,7,8,9,10]
let iterations = Math.floor(arr.length / 8)
let startAt = arr.length % 8
let i = 0
do {
switch(startAt) { // 12 % 8 = 4
case 0: process(arr[i++]);
case 7: process(arr[i++]);
case 6: process(arr[i++]);
case 5: process(arr[i++]);
case 4: process(arr[i++]); // 跳到这里开始执行,process执行四次。
case 3: process(arr[i++]);
case 2: process(arr[i++]);
case 1: process(arr[i++]);
}
startAt = 0; // 置零后从头开始循环,一会八次。
} while(--iterations)
function process(v) {
for(let i = 100; i--;) {
arr.push(v)
}
}
分析:每次循环中最多可调用8次process()
,循环的迭代次数为总数除以8。
startAt存放余数,表示第一次循环中应调用多少次process()
。
假设原本是12次循环,那么第一次循环会调用4次,第二次调用8次,以此类推。
再次优化,对余数分离处理
let arr = [1,2,3,4,5,6,7,8,9,10]
let startAt = arr.length % 8
if(startAt) {
while(startAt) {
process(arr[startAt--])
}
}
let i = Math.floor(arr.length / 8)
while(i) {
process(arr[i--])
process(arr[i--])
process(arr[i--])
process(arr[i--])
process(arr[i--])
process(arr[i--])
process(arr[i--])
process(arr[i--])
}
function process(v) {
for(let i = 100; i--;) {
arr.push(v)
}
}
分析:一次循环改为两次,但由于移除了switch语句,速度更快。
小结:
- 用多次循环代替原来的循环次数。
- 适用于迭代次数超过1000的情况
- 但随之浏览器对循环迭代的优化,该算法的优势没有那么明显,然其经典的思想可以借鉴。
3.函数迭代
forEach 基于函数迭代,比 循环的迭代 慢很多(接近8倍),主要原因是对每个数组成员调用额外的外部方法带来了开销。
// 随机内容
var chars = '0123456789abcdef'
function getRandomString() {
var len = Math.ceil(Math.random() * 7) + 3 // 4-10 随机个数
var result = ''
while (len--) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
var len = 1000 // 数组子项数量
var count = 100 // 循环次数
var ary = []
while (len--) {
ary.push('' + getRandomString() + '')
}
function process(item) {
var isInt = /(^[0-9]$)|(^[1-9][0-9]+$)/
if (isInt.test(item)) {
item += 1
}
}
function for_normal_forEach() {
ary.forEach(function (value, index) {
// process(ary[index]); // 函数调用
ary[index]++ // 过程代码
})
}
function for_normal() {
for (var i = 0, len = ary.length; i < len; i++) {
// process(ary[i]) // 函数调用
ary[i]++ // 过程代码
}
}
console.log('数组长度:' + ary.length)
console.log('Profiling will begin in 2 seconds...')
setTimeout(function () {
console.log('普通for循环测试:')
var currTime = new Date()
console.profile()
for (var i = 0; i < count; i++) {
for_normal()
}
console.profileEnd()
console.log('用时:' + (new Date() - currTime) + 'ms')
console.log('forEach循环测试:')
currTime = new Date()
console.profile()
for (var i = 0; i < count; i++) {
for_normal_forEach()
}
console.profileEnd()
console.log('用时:' + (new Date() - currTime) + 'ms')
}, 2000)
小结:
循环函数时, 随着函数迭代尾调用优化,该方法的速度比普通的for循环还快。
循环过程式代码时,该方法比普通的for循环还慢。
条件语句
由于各个浏览器对流程控制进行了不同的优化,所以使用哪种技术没有更好的定论。
使用if-else还是switch
一般的例子:
if(color === 'red') {
// 代码处理
} else {
// 代码处理
}
switch(color) {
case 'red':
// 代码处理
break;
default:
// 代码处理
}
数量多的时候的例子:
if(color === 'red') {
// 代码处理
} else if(color === 'green'){
// 代码处理
} else if(color === 'blue'){
// 代码处理
} else if(color === 'yellow'){
// 代码处理
}
switch(color) {
case 'red':
// 代码处理
break;
case 'green':
// 代码处理
break;
case 'blue':
// 代码处理
break;
case 'yellow':
// 代码处理
break;
default:
// 代码处理
}
小结:switch-case
可读性好于if-else
一般情况下,switch速度更快,但只有数量大时才明显。
由于当条件增加时,if-else性能负担增加的程度比switch要多。
所以,最流行的方法是基于测试条件的数量来判断:
- 条件少时,if-else易读
- 条件多时,switch易读(主要是看代码的易读性而定,而后才是性能。)
通常来说,当if-else适用于判断两个离散值或者几个不同的值域。
当判断多于两个离散值时,switch更佳。
tip: switch比较值时,使用全等判断,不会发生类型转换的损耗。
优化if-else
目标:最小化到达正确分支所需判断的条件数量。
例子1:
if ( value < 5 ) {
// coding
} else if (value > 5 && value < 10) {
// coding
}
分析:该代码只有 value 小于 5时是最优,其他则要经过两次判断,最终增加了消耗的平均时间。
一般而言,if-else 语句应该按照从大概率到小概率的顺序来排列,确保速度最快。
例子2:
if (value === 0) {
return value0
} else if(value === 1) {
return value1
} else if(value === 2) {
return value2
} else if(value === 3) {
return value3
} else if(value === 4) {
return value4
} else if(value === 5) {
return value5
} else if(value === 6) {
return value6
} else if(value === 7) {
return value7
} else if(value === 8) {
return value8
} else if(value === 9) {
return value9
} else {
return value10
}
优化:二分法,改为嵌套
if(value < 6 ) {
if(value < 3) {
if (value === 0) {
return value0
} else if(value === 1) {
return value1
} else {
return value2
}
} else {
if (value === 3) {
return value3
} else if(value === 4) {
return value4
} else {
return value5
}
}
} else {
if(value < 8) {
if (value === 6) {
return value6
} else {
return value7
}
} else {
if (value === 8) {
return value8
if (value === 9) {
return value0
} else {
return value10
}
}
}
二分法将值域分成一系列的区间,然后逐步缩小范围。
当值均匀分布在0-10区间的时候,二分法大约是原来if-else嵌套语句执行的平均时间的一半。
二分法适合多个值域(非离散值)需要测试的时候。
小结:
- 最可能的情况放在首位。
- 确保最大概念到最小概念的顺序排列
- 使用if-else嵌套语句比单独使用更优
查找表
当有大量离散值时,使用查找表。
例子:
switch(value) {
case 0:
return result0;
break;
case 1:
return result1;
break;
case 2:
return result2;
break;
case 3:
return result3;
break;
case 4:
return result4;
break;
case 5:
return result5;
break;
case 6:
return result6;
break;
case 7:
return result7;
break;
case 8:
return result8;
break;
case 9:
return result9;
break;
default:
return result10;
}
// 使用数组表示的情况,这得是数据有按照顺序,不重复并有规律。
let result = arr[result0,result1,result2,result3,result4,result5,result6,result7,result8,result9,result10]
return result[value]
展开来讲,我们具体点可以这么写:
const actions = new Map([
['admin_1', ()=>{/*do sth*/}],
['admin_2', ()=>{/*do sth*/}],
['admin_3', ()=>{/*do sth*/}],
['admin_4', ()=>{/*do sth*/}],
['admin_5', ()=>{/*do sth*/}],
['user_1', ()=>{/*do sth*/}],
['user_2', ()=>{/*do sth*/}],
['user_3', ()=>{/*do sth*/}],
['user_4', ()=>{/*do sth*/}],
['user_5', ()=>{/*do sth*/}],
['default', ()=>{/*do sth*/}],
])
const handle = (identity,status)=>{
let action = actions.get(`${identity}_${status}`) || actions.get('default')
action.call(this)
}
查找表可以使用数组或者对象来构建。
查找表的优点:不用书写任务条件语句,即便候选值增加时,也几乎不会产生额外的消耗。
查找表速度更快,可读性更好
但项目中一般我们仅仅用来存储常量。
查找表和switch-case的区别:
查找表适合于键值映射的情况
而switch更适合一个键对应一个独特动作或者一系列行为的场合。
递归
引入
例子,阶乘算法:
function factorial(n) {
if(n === 0) {
return 1;
} else {
return n * factorial(n - 1)
}
}
递归是为了简单化算法
但递归存在以下潜在问题:
- 终止条件不明确或缺少终止条件导致函数长时间执行
- 可能遇到浏览器的“调用栈大小限制”
各浏览器有各自的限制,但不应该发布有问题的递归。
递归模式
出问题的两种类型
- 递归调用函数本身(如上例子)
- 两个函数相互调用
function first() {
second()
}
function second () {
first()
}
first()
最常见的是终止条件的问题,所以验证终止条件是解决问题的关键。
迭代
为了能在浏览器中安全的工作,一般将递归改为迭代,或者两者结合使用。
例子:递归排序算法
// 归并排序算法
// 是第一个可以被实际使用的排序算法
// 其复杂度为O(nlogn)。
// 归并排序是一种分治算法。其思想是将原始数组切分成较小的数组,直到每个小数组只有一
// 个位置,接着将小数组归并成较大的数组,直到最后只有一个排序完毕的大数组。
// 由于是分治法,归并排序也是递归的:
let mergeSort = function (arr) {
let merge = function (left, right) {
let res = []
let il = 0
let ir = 0
// 切分到最小分组,左右分别只有一个值,按照从小到大比较后排序。
// 返回迭代上一层,继续比较,直到回到顶层。
while (il < left.length && ir < right.length) {
if (left[il] < right[ir]) {
res.push(left[il++])
} else {
res.push(right[ir++])
}
}
// 收入剩余的
while (il < left.length) {
res.push(left[il++])
}
while (ir < right.length) {
res.push(right[ir++])
}
return res
}
let mergeSortRec = function (arr) {
let l = arr.length
// 合并不断递归进入时,直到只有一层,返回到merge去比较
// 合并返回时,merge合并后只有一层, 返回上一层作为的上一层的merge参数进入比较合并。
// 最后递归回来,直到合并成一个数组
if (l === 1) {
return arr
}
// 切分为两组
let mid = Math.floor(l / 2)
let left = arr.slice(0, mid)
let right = arr.slice(mid)
return merge(mergeSortRec(left), mergeSortRec(right))
}
return mergeSortRec(arr)
}
分析:mergeSort
函数会导致很频繁的自身调用。一个长度为n的数组最终会调用mergeSortRec()
2*n-1次。
如果数组长度超过1500,则会在火狐浏览器发生栈溢出。
优化:迭代实现递归排序算法:
function mergeSortRec(items) {
if (items.length === 1) {
return items
}
let work = []
for (let i = 0, len = items.length; i < len; i++) {
work.push([item[i]])
}
work.push([]) // 如果数组长度为奇数
for (let lim = len; lim > 1; lim = (lim + 1)/2) {
for (let j = 0, k = 0; k < lim; j++, k += 2) {
work[j] = merge(work[k], work[k+1])
}
work[j] = []
}
return work[0]
}
这个版本稍微慢一些,但不会受到溢出栈的限制。
小结:
迭代算法一般包含几个不同的循环,分别对应计算过程的不同方面,
虽然会引入一些问题,但优化后的循环替代长时间运行的递归函数是可以提升性能的。
因为运行一个循环比反复调用一个函数的开销要少很多。
任何递归能实现的算法都可以使用迭代实现。
使用优化后的循环比递归函数更具性能,开销更少。
将递归算法改为迭代实现,避免调用栈限制。
Memorization
减少工作量就是最好的性能优化技术。
Memorization正是一种避免重复工作的方法,它缓存前一个计算结果供后续计算使用。
当代码中多次调用递归函数时,大量重复的工作不可避免。
例子:
let fact6 = factorial(6)
let fact5 = factorial(5)
let fact4 = factorial(4)
分析:这段代码有3次阶乘计算,导致该函数一共被调用18次。
而且,所有必要的计算在第一行代码里就处理掉了:6的阶乘等于6乘以5的阶乘,因此5的阶乘被计算了两次。
4的阶乘被计算了3次。我们应该保存并重用他们的计算结果,而不是每次都重新计算整个函数。
优化:
function memFactorial(n) {
if(!memfactorial.cache) {
memfactorial.cache = {
'0': 1,
'1': 1
}
}
if(!memfactorial.cache.hasOwnProperty(n)) {
memfacotrial.cache[n] = n * memfacotrial(n - 1)
}
return memfacotrial.cache[n]
}
这个优化的版本创建了一个缓存对象,这个对象存储在对象自身内部,并预设两个最简单的阶乘。
在计算一个阶乘前,会先检测这个缓存对象看看是否已经存在相应的计算结果。没有对应的缓存值则意味着这是第一次计算。
计算完成后,结果被存储在缓存中为以后使用。
let fact6 = memFactorial(6)
let fact5 = memFactorial(5)
let fact4 = memFactorial(4)
该代码返回了3个不同的阶乘,但只调用了该函数8次。因为所有必要的计算都在第一行代码完成并缓存了,
接下来的两行代码不会发生递归运算,而是直接返回缓存中的值。
封装一个具备基础功能的通用的缓存函数
function mem(func, cache) {
cache = cache || {}
let shell = function(arg) {
if (!cache.hasOwnProperty(arg)) {
cache[arg] = func(arg)
}
return cache[arg]
}
return shell
}
mem接收两个参数,一个是需要增加缓存功能的函数,一个是可选的缓存对象。
创建了一个封装了原始函数的外壳函数,只有当一个结果值之前从未被计算过时才会产生新的计算。
// 缓存该函数
let memFactorial = mem(factorial, {'0': 1, '1': 1})
// 调用
let fact6 = memFactorial(6)
let fact5 = memFactorial(5)
let fact4 = memFactorial(4)
因为该mem函数会缓存特定参数的函数调用结果,该方法比上一个版本优化效果稍差。
当代码以相同的参数多次调用外壳函数时才能节省时间。
因此,如果mem函数存在显著性能问题时,最好是针对性的手工实现,而不是直接使用mem方案。
续上面登录用户权限的问题,项目中我们还可能遇到这样的情况:
在admin情况下,status1-4的处理逻辑都一样:
const actions = new Map([
['admin_1', ()=>{/*function A*/}],
['admin_2', ()=>{/*function A*/}],
['admin_3', ()=>{/*function A*/}],
['admin_4', ()=>{/*function A*/}],
['admin_5', ()=>{/*function B*/}],
])
那么,我们可以缓存FnA和FnB
const actions = ()=>{
const functionA = ()=>{/*do sth*/}
const functionB = ()=>{/*do sth*/}
return new Map([
['admin_1', functionA],
['admin_2', functionA],
['admin_3', functionA],
['admin_4', functionA],
['admin_5', functionB],
//...
])
}
如果status超过10条或者更多,代码同样很臃肿,那么我们可以在以上代码加上正则匹配:
const actions = ()=>{
const functionA = ()=>{/*do sth*/}
const functionB = ()=>{/*do sth*/}
return new Map([
[/^admin_[1-4]$/,functionA],
[/^admin_5$/,functionB],
//...
])
}
const handle = (identity,status)=>{
let action = [...actions()].filter(([key,value])=>(key.test(`${identity}_${status}`)))
action.forEach(([key,value])=>value.call(this))
}
更多的应用比如react的mem函数,vue的computed,key,keep-alive等。
总结:
- for,while,do-while性能都差不多。
- 避免使用for-in循环
- forEach函数不适合用来循环过程式代码,更适合循环函数
- 改善循环性能的最佳方式是减少迭代运算量和迭代次数
- 通常来说,switch比if-else快
- 条件较多时,查找表更快
- 递归算法有的问题,现在已经通过es6的尾调用改善了。
- 函数遇到栈溢出错误可改写为迭代
- 函数可以使用memorization来避免重复计算以提升性能