聊聊算法思路
思路
在世上,人们解决问题的方式归为两种
- 人类思路:根据生活归纳出来的,人们根据生活经验,总结提炼
- 数学思路:根据数学推理归纳,通过对应的数学公式/定理,得出的结论
人类思路
把人类的思考流程翻译成代码,就是算法
人类思路求解最大值,一般利用遍历,从左到右扫描寻找最大值。
但是实际上,一堆数字,人们也可能直接看出最大值,比如有一个9999鹤立鸡群
const array = [23,99,17,28,84]
function max(array){
let result = array[0]
for(let i = 1; i< array.length; i++){
if(array[i] > result){
result = array[i]
}
}
}
max(array) // 99
如何证明算法是对的?
- 经验
按照一般人的逻辑和经验,这个算法是对的
无法给出反例,说明这个算法是对的
通过大量的测试,发现这个算法可以满足需求
大部分程序员只需要能满足需求的代码,而不是正确的代码,非科班的程序员一般使用的解题思想
有时候,这种人类思维,因为逻辑或者认知不够严谨,在未来也会被郑面是错误的。比如最早以前人们认为地球是平的,直到后来又有了日心说。人类思路就是在不断的利用经验得出结论,再推翻再结论的过程
数学思路
完全归纳法:数学家们喜欢利用公式和定理,一步步推理得出解法,而这样的解法是具有说服力的,只要公式定理以及推理过程是没有瑕疵的,那么结论一定是对的。
但是数学思路带来的弊端也是很明显,就是需要很强的逻辑能力。回忆一下高中数学物理老师最爱说的就是:显然,我们可以得出这个结论。而如果我显然不出来,那基本是凉凉了。
还是这个例子,选出最大值,通过完全归纳法我们可以得出下面的数学公式:
n1,(k=1)
max(n1,n2,...,nk){
nk>max(n1,n2,...,nk-1)? nk:max(n1,n2,...,nk-1),(k=2,3,...)
一旦有了数学公式,我们发现可以很轻易的转化成代码,但仔细一看会觉得,这什么鬼也太难懂了吧。没错,这就是数学思想。难懂,但好像的确是对的。
const array = [23,99,17,28,84]
function max(array){
if(array.length === 1) { return array[0] }
const otherMax = max(array.slice(1))
return array[0] > otherMax ? array[0] : otherMax
}
max(array) // 99
可以发现,其实这就是递归,数学家们擅用递归来解决问题,我们也只是将定理公式代码化了。
如何证明数学算法是对的?
- 首先证明公式是对的(较难)
回忆一下高中数学,我们经常用代入法和举例法证明公式是对的,其实
max([23,99,17,28,84])
= m(23,max([99,17,28,84]))
= m(23,m(99,max([17,28,84])))
= m(23,m(99,m(17,max([28,84]))))
= m(23,m(99,m(17,m(28,max([84]))))
= m(23,m(99,m(17,m(28,84)))
= m(23,m(99,m(17,84)
= m(23,m(99,84)
= m(23,99)
= 99
通过代入法,你就会发现这就是递归,先递进,再回归的感觉,>型回调地狱
- 然后证明代码和公式等价
这一步就是用代码翻译一下了(简单)
- 总结
数学方法更容易通过形式化证明保证代码的正确性
但数学方法效率不一定高(但可以优化)
数学方法往往不够直观,普通人没有什么数学知识
一般不对变量进行二次赋值,因为数学里没有
对比
数学思路更注重形式(结构)
- 往往更加优雅/简单
- 更容易优化
- 投身于数学,有无线广阔的可能性
人类思维更注重过程(命令)
- 往往更容易执行、被理解
- 对人脑的负担更重
- 被人类的经验所局限
总结
- 复杂度守恒:复杂度不会因为任何原因降低
- 取决于把复杂度放在人脑这边,还是机器那边
- 实际上,我们可以结合两种思路,各取所长
遍历/递归/迭代
现在我们似乎可以简单的归纳
- 人类思路 ~ 遍历
- 数学思路 ~ 递归
遍历这边就不说了,因为大多数同学在写代码时,用的最多的就是for循环,所以人类思想不需要去特意修炼,顺着感觉写就可以。
接着借汉诺塔问题说说递归,汉诺塔是什么问题呢
image相传在古印度圣庙中,有一种被称为汉诺塔(Hanoi)的游戏。该游戏是在一块铜板装置上,有三根杆(编号A、B、C),在A杆自下而上、由大到小按顺序放置64个金盘(如下图)。游戏的目标:把A杆上的金盘全部移到C杆上,并仍保持原有顺序叠好。操作规则:每次只能移动一个盘子,并且在移动过程中三根杆上都始终保持大盘在下,小盘在上,操作过程中盘子可以置于A、B、C任一杆上。(据说当都移完了以后,已经世界末日了,因为实在太久了)
那这个问题,靠直觉或者经验求解(人类思路)简直就是太难了,一般我们遇到这种问题,都会看一下答案,哦!然后恍然大悟。
简单理解为,把A上面的N-1个移动到B,然后把最大的移动到C,最后把B上面的N-1个移动到C,就结束了...
但是为了做到上面这一步,你需要先把A上面的N-2个移动到C,然后把最大的移动到B,最后把C上面的N-1个移动到B...
然后你就发现你的人类思维已经想不过来了,因为实在太多步了。
数学归纳法
- 证明当n=1时命题成立
- 证明如果n=k时命题成立,那么n=k+1时命题也成立(k=1,2,3...)
看着答案我们得出公式:
AC,(n=1)
h(n,A,B,C){
h(n-1,A,C,B) + AC + h(n-1,B,A,C)
通过一堆高中数学的应用题经验,我们其实也可以推出上面这个答案,不过显然,数学没有学好。
好在可以轻易的转为代码,然后我们验证一下啊,果然似乎是对的。
h = (n, from, cache, to) =>
n === 1 ? `${from}${to}` :
h(n-1, from, to, cache) + ',' + `${from}${to},` +
h(n-1, cache, from, to)
通过汉诺塔问题,我们可以得出一些结论,在有些场景,巧妙的使用数学思想,是可以巧妙的解决一些似乎常规思想想象不到的问题,于是可以理解了为什么数学家那么少那么伟大,确实太烧脑了
现在我们尝试研究一下斐波那契数列
既然数学思想牛逼,那直接来递归搞起
// 数学思路:递归
f = n =>
n === 0 ? 0 :
n === 1 ? 1 :
f(n-1) + f(n-2)
看起来的确简单也好理解,但是当我用浏览器控制台尝试跑一下f(45)/f(46)/f(47),似乎感觉不太好了,因为实在是太慢了,你可以试试f(100),我反正是没有勇气的
那现在再来看看人类思路,循环
// 人类思路:循环
f = n => {
const array = [0,1]
for(let i=2; i<=n; i++){
array[i] = array[i-1] + array[i-2]
}
return array[n]
}
不必说,人类的白话形式本身在理解层面就有优势,那执行速度呢,f(45)/f(46)/f(47)直接都是秒出的,完全没感觉到计算时间
斐波那契问题也巧妙了解释了,不是数学思想就一定是最棒的思想,有些问题,数学家想的过于复杂,设计的过于巧妙,导致产生了更多的时间复杂度。而用人类思想,反而解的很轻松。
为什么递归的时间效率会这么慢呢,这是为什么呢?
栈
-
递归需要使用栈
前面说到递归是先递进再回归,所以他可以巧妙用到栈这个数据结构,压栈时,把当前函数所在的环境变量压入,待回归时,把对应的环境弹出来
-
栈有长度限制
栈其实是有长度限制的,如果溢出了,我们通常称之为stackoverflow,每个引擎每种语言,栈的大小都是不定的。正常代码中也是需要考虑到爆栈问题
-
压栈和弹栈
提供了pop和push API的list就可以称之为栈
回到刚才的问题,为什么递归计算汉诺塔就特别慢,我们就可以从栈的思维推敲一下,如何有效减小压栈?
-
不用递归,所有的递归都可以写成循环
就是转成人类思维,所有的数学思维都是可以转人类思维的,只是你想不想的来的问题
// 循环代替递归
// 递归
f = n =>
n === 1 ? 1 :
n * f(n-1)
// 循环
f = n => {
let result = 1
for(let i = 1; i <= n; i++){
result = result * i
}
return result
}
- 尾递归
尾递归的意思就是递归只出现在return语句且return语句里只有递归
//普通递归
f = n =>
n === 1 ? 1 :
n * f(n-1)
// 这里的4就是环境变量,每次压栈,导致弹栈的时候需要找到这个变量
f(4)
= 4 * f(3)
= 4 * (3 * f(2))
= 4 * (3 * (2 * f(1)))
= 4 * (3 * 2 * 1))
= 4 * (3 * 2)
= 4 * 6
= 24
// 尾递归,没有旧状态
f(4)
= f(1,4,1)
= f(2,4,2)
= f(3,4,6)
= f(4,4,24)
= 24
递归需要归,尾递归不需要归
尾递归 -> 迭代
在这里,为方便理解,我们简单的给尾递归一个新定义,迭代。当然迭代还分好几种,如循环迭代,尾递归迭代。目前只讨论尾递归。
顾名思义,迭代可以理解为一种自我进化,带着旧的状态进化成了一种新的状态,进化后,就不再保留旧状态。也不再需要所谓的“归”
尾递归压栈: 因为不同语言不同客户端表现不一致,所以要提一下
形如:function a() { return b() } 这样就是一种尾递归或者说是尾递归迭代,由a函数完全到了b函数
在b()算出结果后,不需要任何其他操作,直接去a改返回的地方,所以为什么其实可以干脆省掉中间的压栈,在某些语言中,的确是这样的
不过缺点也有,平时我们都关注函数调用栈,如果以为这种尾递归调用而丢失了调用历史,那么debug将是一种灾难
这里查了一些资料,不同语言不同客户端在优化上表现不一致,就JavaScript来说
- Chrome的v8没有实现尾调用优化
- safari却实现了
缓存
尾递归是优化递归的一个方法,另一个方法就是缓存了
在刚才例子中,相信大家都看出来了,为什么人类思想的计算时间那么快,并不是因为本身多先进,只是因为人类会自然而然的使用缓存来加速计算,而数学公式却不会
就斐波那契问题,我们试试f(6)的计算次数:
f(5)一次,f(4)两次,f(3)三次,f(2)五次,f(1)和f(0)共11次,左右相加11次,总共33次操作。而这仅仅只是f(6),所以我们刚才试图运行f(45),想象一下是多么的灾难
f = n =>
n === 0 ? 0 : n === 1 ? 1 : mf(n-1) + mf(n-2)
memorize = fn => {
cache = {}
return (first, ...args) => {
if(!(first in cache)){
cache[first] = fn(first, ...args)
}
return cache[first]
}
}
mf = memorize(f)
这次我们使用一个cache增强一下f函数,再试试你就会发现计算速度大大加快了,而且这个增强函数是无痛的,是不是就有点像平时我们用的缓存AOP原型了
但是,再往深层的想,你用了cache,那无非又回到了那个深奥的结论,时间换空间罢了~
总结
聊了这么多,人类思路或数学思路也好。其实写代码的时候,都是我们避不开的解法。作为人类,我们往往还是会使用直觉最优解。只是在这里,强行将两种解法抽象开了,并做了对比。一旦拨开云雾,就豁然开朗,感受着代码的魔力,感受着一切有因果。无非时间换空间,空间换时间罢了。