论思维的重要性
从3道面试题讲起:
1.求和
求1-100的数字的总和
光看题目很简单,最简单的方法暴力破解,从1累加到100不就行了么。
function calc(n){
var sum = 0
for(let i=1;i<n+1;i++){
sum+=i
}
return sum
}
那如果将数字扩大到10000000001呢,结果发现计算机半天算不出来。我上学那会还有个算法,说是1+99,2+98,3+97…这样可以将复杂度缩减一半,可貌似计算量依然很大。如果数字更大呢,再乘以10呢,又会陷入半天算不出来的情况,这时候我想到了数学的解决方法:
你没看错,就是数列的前n项和公式,转化为代码:
function calc(n){
var sum = n/2*(1+n)
return sum
}
> calc(100)
> 5050
> calc(100000000000000000000001)
> 5.0000000000000006e+45
> calc(1000000000000000000000000000000000000000000000000001)
> 5e+101
这时候,你可以计算任意大的值,计算机都能给你瞬间算出来。
2.斐波那契数列
学计算机的应该都知道这东西,样子如下:
1 1 2 3 5 8 13 ...
求第n项的值
通过观察,很容易发现规律, 第n项的值=前一项+前前项的值:
f(n) = f(n-1) + f(n-2)
转为代码:
function getN(n){
if(n==0){
return 0
}
if(n==1){
return 1
}
return getN(n-1) + getN(n-2)
}
该函数用了递归,我试着求前面7项,都得出正确结果,速度很快,但是,当我求99项的时候,计算机没有给出结果,直接卡住了。递归有其缺陷性,当递归调用次数大的时候,会跟循环一样,计算结果半天出不来,当我尝试着求第999999项的时候,直接给我报了个错:
> getN(999999)
> Uncaught RangeError: Maximum call stack size exceeded
这个错的意思是,超出了最大调用栈的限制,我们都知道,每个函数执行的时候都会推入执行栈中,而递归求值,每次都依赖前一个函数求值,前一个函数求值仍然依赖前一个函数求值,这样的结果就是,临时缓存所有函数调用,直到达到函数执行栈的最大尺寸限制,抛出错误。
这时候我想到,把递归改成循环把,循环的话就不会保留这么多函数调用了,也不会存在爆栈问题:
function getNByLoop(n){
let n1 = 0
let n2 = 1
let count = 0
if(n==0) return 0
if(n==1) return 1
let i = 2
while(i <= n){
count = n1+n2
n1=n2
n2=count
i++
}
return count
}
这时候我再尝试之前的计算:
> getNByLoop(99)
> 218922995834555200000
> getNByLoop(99999)
> Infinity
这些计算也是瞬间得出结果,尽管99999没有给出具体值,但不是因为算不出来,而是因为超过计算机能表示的最大数值,索性用趋近于无穷表示。
但是我们思考一个问题,如果这个数仍然扩大十倍百倍,甚至万倍,计算机还能算出结果吗。恐怕同样和之前示例一样卡住。
因此我去查了下数学的通项公式:
转化为计算机语言:
function calc(n){
return parseFloat(1/Math.sqrt(5)*(Math.pow((1+Math.sqrt(5))/2,n) - Math.pow((1-Math.sqrt(5))/2,n))).toFixed(0)
}
由于去掉了循环,计算速度大幅度提升,只要数值在计算机表示范围内都可以瞬间得出结果。
3.求最小硬币数
有面值1块、4块、5块的硬币,求组成n块的最小硬币数
拿到题目首先分析题目,怎样才能得到最小硬币数,经过一番思考不难想到,只要先加较大面值的硬币,自然就能尽可能减少总的硬币数,面值越大,加的硬币数越少。于是很容易得出:
function min(n){
let count = 0 // 最小硬币数
while(n>0){
count++
if(n-5>=0){ // 先求5的面值最大数量
n-=5;
continue
}
if(n-4>=0){ // 剩下的再求4和1面值的数量
n-=4;
continue
}
if(n-1>=0){
n-=1;
continue
}
}
return count
}
我网上搜索了下别人的解法,俗称暴力解法,遍历每一种可能性:
function getNum(N){
let n1 = 1;
let n2 = 4;
let n3 = 5;
let last=N;
for(let i=0;i<=N;i++){ // 面值1可能的硬币数
for(let j=0;j<=N/4;j++){ // 面值4可能的硬币数
for(let k=0;k<=N/5;k++){ // 面值5可能的硬币数
if(n1*i+n2*j+n3*k===N){ // 各种面值加起来等于n的所有可能
if(i+j+k<last){ // 小于上一次硬币数就覆盖
last = i+j+k;
}
}
}
}
}
return last; // 最终算出的last就是最小的硬币数
}
这个方法的效率,我看到的时候是头皮发麻的,于是我实验了一下
> getNum(100)
> 20
> getNum(1000)
> 200
> getNum(10000)
> 2000 # 这里卡了半天才出来
暴力解法只适合解决数值较小的情况,根本无法推广到任意值解法。当然当面值足够大的时候,循环似乎也不是最佳解决思路,尽管,已经可以解决大部分问题了。
这时候我认真思考了下,从数学的角度来说,可以简化吗?
循环的时候先累加5的硬币数,再累加4的硬币数,最后累加1的硬币数,可我为什么要累加呢?为什么不能跳过这个过程,直接计算呢?于是我再次精简计算:
function min(n){
let n1,n4,n5 // 面值1块,4块,5块的硬币数
n1=n4=n5=0
let rest = n%5 // 余数
if(rest>=0){ // 对5求余有余数,证明数值大于等于5可以取得面值5的硬币数
n5 = parseInt(n/5)
}
if(rest%4>=0){
n4 = parseInt(rest/4)
rest = rest%4
}
n1 = rest // 剩下的余数就是面值1的硬币数
return n1 + n4 + n5 // 所有面值最小硬币数
}
这时候的算法,也把循环给去掉了,基本可以推导任意值的计算了,当然parseInt计算大数仍有问题,理论上这个思路是没问题,
> min(1000000000000000000000)
> 200000000000000000000 # 瞬间得出值
如果是循环版的算法,当计算到10000000000的时候,都很难得出答案了。
结论
通过上面的3道面试题,能够总结出的思维,也就是算法的重要性,而算法很多时候又跟数学有点关系,这告诉我们,有时候,思考算法的时候,其实也可以从数学的角度去思考,而不要过于依赖计算机的算力,如果能通过动手计算得出的算法,远好过依赖计算机算力的暴力解法,枚举所有可能性永远是下下策。