斐波那契与二分查找

·  阅读 147

引子

今天我们来探讨一个有趣的问题,如果兔子遇到莱昂纳多·斐波那契,那明年有几只兔子呢?

我们知道意大利数学家莱昂纳多·斐波那契在《算法全书》中提出了 斐波那契兔子问题(Fibonacci rabbit problem) 一道著名数列难题~

注意,我们的兔永远不会去世~而且一个月后就成年,成年之后就继续生一对

  • 第一个月:一小兔1
  • 第二个月:小兔1长成大兔1
  • 第三个月:大兔1生了一只小兔2
  • 第四个月:大兔1又生了一直小兔3,小兔2长成了大兔2
  • ......

我们画个图看看,兔是怎么繁衍的~

image.png

那我们的问题就是第十二个月的时候,有多少只兔呢?

斐波那契兔子数列问题建模

对上述的斐波那契“兔”问题进行建模可以得到斐波那契数列

1,1,2,3,5,8,13,21,34,55,89

数列从第三项开始,每一项等于前两项之和,我们要求明年月亮上拥有多少兔,也就是数列的第12项~

我们将数学问题转换成计算机编程问题:

写一个函数,输入 nnn ,求斐波那契(Fibonacci)数列的第 nnn 项(即 F(N)F(N)F(N))。

斐波那契数列的定义如下: F(0)=0,F(1)=1F(0) = 0, F(1) = 1F(0)=0,F(1)=1 F(N)=F(N−1)+F(N−2)F(N) = F(N - 1) + F(N - 2)F(N)=F(N−1)+F(N−2), 其中 N>1N > 1N>1.

斐波那契数列由 第三项 开始,之后的斐波那契数就是由之前的两数相加而得出。

解析

法一:暴力递归

就直接暴力递归,就可以算出我们需要的答案

创造一个函数,这个函数的作用就是查找当月的兔子数量,而当月的兔子数量又是前两个月的兔子之和,所以我们可以在返回值里面递归调用这个函数,输入n-1与n-2,也就是求前两个月的兔子,但是这个递归要有一个退出条件,如果没有的话,就会无限调用堆栈,造成死循环。而在这个方法里面退出的条件就是找到第0个月或者第一个月的时候,直接返回1,这里返回一的意思是已经找到最初的兔子数量,然后这里递归到最后的fib函数已经有返回值了,会把这个返回值直接返回上一层fib的调用,这样一层层逆着将返回值全部返回来,就求出目前月的兔子数量

function fib(n) {
  if( n === 0) return 0
  if(n === 1) return 1
  return fib(n-1) + fib(n-2)
};

let result = fib(12)
console.log(result) // 144
复制代码

这样暴力起来效率实在太低,我们仔细思考一下,它的可优化空间还是很大!

首先一个问题就是这里重复递归的次数实在是太多了,疯狂的调用堆栈,导致计算机的性能急剧下降,比如说我要求fib(5), 如图可视,我们重复求解了多次fib(2)和fib(3)其实这是没有必要的,我们可以用一个缓存cache,来将我们求过了的fib(n)保存在缓存中,下次用到他的时候直接读取他的值就可以了,而不是再重新递归求值。

image.png

递归 + 缓存

image.png

//首先创建一个对象用来保存已经计算出的月数的兔子
let obj = {};
const fun = (e) => {
  //如果这个月的兔子已经计算过了,那么直接返回,不再进行下一级的递归调用
  if (obj[n]) {
    return obj[n];
  } else {
    //如果找到第0个月或者第一个月,那么直接返回一只兔
    if (e == 0 || e == 1) {
      return 1;
    }
    //将递归求的每个月的兔子数量都保存下来
    obj[n] = fun(e - 1) + fun(e - 2);
    //返回当月的兔子
    return obj[n];
  }
};
复制代码

法二:循环三指针

由于从第三个月开始,当月兔子等于前两个月兔子之和,那么我们可以创造三个指针,第a个指针指向第一个月,第b个指针指向第二个月,第c个指针指向第三个月,第三个月的兔子等于一二月兔子加起来,就是c指针等于ab指针之和。当月数增加的时候,只需让abc三个指针向后移动一位即可,随后三个指针发生相应的变化,a指针到了之前b指针的位置,所以就是a=b,b指针到了c指针的位置,所以是b=c,c指针还是等于ab之和,所以c=a+b,这样将其带入之后,就可以得到第n个月之后的兔子,同时因为循环不会疯狂调用堆栈,多以也不会产生效率底下的问题。

//第一个指针
let a = 0;
//第二个指针
let b = 0;
//第三个指针
let c = 0;
//月数
let n = 11;
for (let i = 0; i < n; i++) {
  if (i === 0) {
    a = 1;
    b = 0;
  } else if (i === 1) {
    a = 1;
    b = 1;
  } else {
    a = b;
    b = c;
  }
  c = a + b;
}
console.log(c);
复制代码

二分查找原理

二分查找(Binary Search)算法,也叫折半查找算法。二分查找的思想非常简单,有点类似分治的思想。二分查找针对的是一个有序的数据集合,每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。

为了方便理解,我们以数组1, 2, 4, 5, 6, 7, 9, 12, 15, 19, 23, 26, 29, 34, 39,在数组中查找26为例,制作了一张查找过程图,其中low标示左下标,high标示右下标,mid标示中间值下标

image.png

二分查找的过程就像上图一样,如果中间值大于查找值,则往数组的左边继续查找,如果小于查找值这往右边继续查找。二分查找的思想虽然非常简单,但是查找速度非常长,二分查找的时间复杂度为O(logn)。虽然二分查找的时间复杂度为O(logn)但是比很多O(1)的速度都要快,因为O(1)可能标示一个非常大的数值,比例O(1000)。

局限性

二分查找依赖数组结构

二分查找需要利用下标随机访问元素,如果我们想使用链表等其他数据结构则无法实现二分查找。

二分查找针对的是有序数据

二分查找需要的数据必须是有序的。如果数据没有序,我们需要先排序,排序的时间复杂度最低是 O(nlogn)。所以,如果我们针对的是一组静态的数据,没有频繁地插入、删除,我们可以进行一次排序,多次二分查找。这样排序的成本可被均摊,二分查找的边际成本就会比较低。

但是,如果我们的数据集合有频繁的插入和删除操作,要想用二分查找,要么每次插入、删除操作之后保证数据仍然有序,要么在每次二分查找之前都先进行排序。针对这种动态数据集合,无论哪种方法,维护有序的成本都是很高的。

所以,二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用

数据量太小不适合二分查找

如果要处理的数据量很小,完全没有必要用二分查找,顺序遍历就足够了。比如我们在一个大小为 10 的数组中查找一个元素,不管用二分查找还是顺序遍历,查找速度都差不多,只有数据量比较大的时候,二分查找的优势才会比较明显。

数据量太大不适合二分查找

二分查找底层依赖的是数组,数组需要的是一段连续的存储空间,所以我们的数据比较大时,比如1GB,这时候可能不太适合使用二分查找,因为我们的内存都是离散的,可能电脑没有这么多的内存。

代码实现

//二分查找算法
let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
//创造一个方法,传入需要查找的数组,开始查找的下标,结束查找的下标,查找的内容
const find = (arr, start, end, name) => {
  //当下面的算法算出的开始下标大于结束下标的时候,证明查找不存在,直接返回-1
  if (start > end) return -1;
  //将查找的数组一分为二,求出中间数的位置
  let mid = Math.floor((start + end) / 2);
  if (arr[mid] === name) {
    //如果这个中间数与查找的数相等,那么直接将这个位置返回
    return mid;
  } else if (arr[mid] > name) {
    //如果这个中间数比查找的数大,那么说明这个数在中间数的左侧,就将左边的数组递归调用到这个函数里面,将结束下标改为中间下标减一
    end = mid - 1;
    return find(arr, start, end, name);
  } else {
    //如果这个中间数比查找的数小,那么说明这个数在中间数的右侧,就将右边的数组递归调用到这个函数里面,将结束下标改为中间下标加一
    start = mid + 1;
    return find(arr, start, end, name);
  }
};
console.log(find(arr, 0, arr.length, 10));
复制代码

递归

递归的概念

在程序中函数直接或间接调用自己

  1. 直接调用自己
  2. 间接调用自己 跳出结构,有了跳出才有结果

递归的思想

递归的调用,最终还是要转换为自己这个函数

  1. 如果有个函数foo,如果他是递归函数,到最后问题还是转换为函数foo的形式
  2. 递归的思想就是将一个未知问题转换为一个已解决的问题来实现

递归的步骤(技巧)

  1. 假设递归函数已经写好
  2. 寻找递推关系
  3. 将递推关系的结构转换为递归体
  4. 将临界条件加入到递归体中
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改