本笔记记录王争专栏数据结构与算法之美的学习记录,以便自己复习回顾,代码部分均已经过验证,可直接使用
数据结构与算法是相辅相成的。数据结构是为算法服务的,算法要作用在特定数据结构之上。
比如,因为数组具有随机访问的特点,常用的二分查找算法要用数组来存储数据,但如果我们选择链表这种数据结构,二分查找算法就无法工作,因为链表不支持随机访问。
数据结构是静态的,是组织数据的一种方式,如果不在它的基础上操作、构建算法,孤立存在的数据结构就是没用的。
不太恰当的比方:列车需要铁轨,汽车需要公路
复杂度分析
10个数据结构:
数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie树
10个算法
递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法
关注内容:它的来历、自身的特点、适合解决的问题、实际的应用场景。
怎么学?
代码跑一遍,通过统计、监控,得到算法执行的时间和占用的内存大小
需要一个不用具体的测试数据来测试,就可以粗略估计算法的执行效率的方法
T(n) = O(f(n))
T(n)代表代码执行的时间,n代表数据规模的大小;f(n)代表每行代码执行的次数总和
大O时间复杂度:不具体表示代码真正的执行时间,而是表示代码执行时间随着数据规模增长的变化趋势,也叫渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
三种方法
常量阶 | O(1) |
---|---|
对数阶 | O(logn) |
线性阶 | o(n) |
线性对数阶 | o(nlogn) |
平方阶、立方阶、n次方阶 | o(n²) o(nⁿ) |
指数阶(非多项式量级) | o(2ⁿ) |
阶乘阶(非多项式量级) | o(n!) |
非多项式量级的算法问题叫NP(Non-Deterministic Polynomial,非确定多项式)问题
数据规模n越大,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间无限增长。非常低效,不关注。
伪代码:
i = 1;
while (i<=n) {
i = i * 2;
}
第三行执行次数最多,变量i的值从1开始取,每循环一次乘以2,最终2^x = n; x=log₂n,复杂度为O(log₂n),只要log为底,都可以视为O(logn)
伪代码
int cal(int m, int n) {
int sum_1 = 0;
int i = 1;
for (; i < m; ++i) {
sum_1 = sum_1 + i;
}
int sum_2 = 0;
int j = 1;
for (; j < n; ++j) {
sum_2 = sum_2 + j;
}
return sum_1 + sum_2;
}
复杂度为O(m+n)
全称 渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系
伪代码
void print(int n) {
int i = 0;
int[] a = new int[n];
for (i;i=0;--i){
print out a[i]
}
}
跟时间复杂度分析一样,第二行申请了一个空间存储变量i,常量阶,可以忽略;第三行申请了大小为n的int类型数组,此外,剩余代码没有占用更多的空间,空间复杂度为O(n)
best case time complexity,在最理想的情况下,执行这段代码的时间复杂度。
int find(int[] array,int n,int x){
int i=0;
int pos = -1;
for(;i
要查找的变量x可能出现在数组的任意位置,如果数组中第一个元素整好是x,时间复杂度为O(1),如果数组不存在该变量,需要把整个数组遍历一遍,时间复杂度为O(n),不同情况,时间复杂度不一样。
在最糟糕的情况下,执行这段代码的时间复杂度。
上段代码,要查找变量x在数组中的位置,有n+1种情况,在数组的0~n-1位置中和不在数组中。每种情况下,查找需要遍历的元素个数累加,除以n+1,得到需要遍历的元素个数的平均值
公式简化,平均时间复杂度为O(n)
然而,这n+1种情况,出现的概率并不一样,要查找的x,假设出现在数组中和不在数组中的概率都为1/2,另外,要查找的数据出现在0n-1这n个位置的概率也是一样的,所以,根据概率乘法法则,出现在0n-1中任意位置的概率为1/2n
这个值是概率论中的加权平均值,也叫期望值。所以平均时间复杂度的全称应叫加权平均时间复杂度或期望时间复杂度。仍为O(n)
大部分情况,只需要使用一个复杂度就可以满足需求,只有在同一块代码在不同情况下,时间复杂度有量级的差距,才会使用三种复杂度表示法区分。
均摊时间复杂度应用场景比平均复杂度更特殊,更有限
// array表示一个长度为n的数组
// 代码中的array.length等于n
int[] array = new int[n];
int count = 0;
void insert(int val) {
if (count == array.length) {
int sum = 0;
for (int i = 0; i < array.length; ++i){
sum = sum+array[i];
}
array[0] = sum;
count = 1;
}
array[count] = val;
++count;
}
这段代码实现了一个往数组中插入数据的功能。当数组满了之后,用for循环遍历数组求和,清空数组,将求和之后的sum值放到数组的第一个位置,再把新的数据插入。如果数组一开始就有空闲空间,直接将数据插入数组。
求平均时间复杂度。假设数组长度n,根据插入位置,分为n种情况,每种情况复杂度为O(1);此外,额外情况为数组没有空闲空间时插入一个数据,复杂度为O(n),这n+1种情况发生概率都是1/(n+1),平均时间复杂度为
其实并不需要这么复杂,对比这个insert()和前面的find()的例子,find()在极端情况下,复杂度才是O(1),但是insert()在大部分情况下都是O(1),此外,insert()的O(1)时间复杂度的插入和O(n)时间复杂度的插入,出现的频率非常有规律,也有前后时序关系,一个O(n)插入后,紧跟着n-1个O(1)插入,循环往复。
针对这种情况,引入更简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度叫均摊时间复杂度
如何使用?每一次O(n)的插入,都会跟着n-1次O(1)的插入,把耗时多的那次操作均摊到n-1次耗时少的操作,这一组连续操作的均摊时间复杂度就是O(1)。均摊时间复杂度就是一种特殊的平均时间复杂度
数组
Array,是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
线性表(Linear List)
数据排成像一条线一样的结构,每条线性表最多只有前和后两个方向。
数组长度为n,将数据插入到第k个位置,需要将k~n的元素都顺序的往后挪一位。最坏时间复杂度为O(n),平均时间复杂度为(1+2+…+n)/n=O(n)
改进:如果数组的数据没什么规律,只是被当做存储数据的集合,为避免大规模的数据搬移,可以直接将第k位的数据搬移到数组元素的最后,把新元素直接放到第k个位置,时间复杂度降为O(1)
类似插入,如果要删除第k个位置的数据,为了内存的连续性,也要搬移数据。
改进办法:
在某些特殊场景下,不一定要追求数组中数据的连续性,先记录已经删除的数据,每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。多次删除集中在一起执行,大大减少了删除操作导致的数据搬移。
java的JVM标记清除垃圾回收算法的核心思想就是这种,当数组没有更多空间存储数据时,再真正的删除操作,减少删除操作导致的数据搬移。
具体:大多数主流虚拟机采用可达性分析算法来判断对象是否存活,在标记阶段,会遍历所有 GC ROOTS,将所有 GC ROOTS 可达的对象标记为存活。只有当标记工作完成后,清理工作才会开始。
java的ArrayList,容器类,最大的优势就是把很多数组操作的细节封装起来,此外,就是支持动态扩容,空间不够时,自动扩容为1.5倍大小。扩容耗时,如果事先确定存储的数据大小,最好在创建ArrayList时就事先指定数据大小。如从数据库中取出10000条数据。
ArrayList<User> users = new ArrayList(10000);
for(int i=0;i<10000;i++) {
users.add(xxx);
}
什么时候用数组更合适呢?
从数组存储的内存模型来看,“下标”最确切的定义应该是“偏移”(offset),a[0]就是偏移为0的位置,也就是首地址,a[k]就表示偏移k个type_size的位置。计算a[k]的内存地址公式
a[k]_address = base_address+k*type_size
如果从1开始,计算公式为
a[k]_address = base_address + (k-1)*type_size
每次随机访问数组元素都多了一次减法运算,多了一次减法指令。
public class GenericArray<T> {
private T[] data;
private int size;
// 根据传入容量,构造Array
public GenericArray(int capacity){
data = (T[]) new Object[capacity];
size = 0;
}
// 无参构造方法,默认数组容量10
public GenericArray(){
this(10);
}
// 获取数组容量
public int getCapacity(){
return data.length;
}
// 获取当前元素个数
public int count(){
return size;
}
// 判断数组是否为空
public boolean isEmpty(){
return size==0;
}
// 修改index位置的元素
public void set(int index,T e){
checkIndex(index);
data[index] = e;
}
// 获取对应index位置的元素
public T get(int index){
checkIndex(index);
return data[index];
}
// 查看数组是否包含元素e
public boolean contains(T e){
for (int i = 0; i < size; i++) {
if(data[i].equals(e)){
return true;
}
}
return false;
}
// 获取对应元素的下标,未找到,返回-1
public int find(T e){
for (int i = 0; i < size; i++) {
if(data[i].equals(e)){
return i;
}
}
return -1;
}
// 在index位置,插入元素e,时间复杂度O(m+n)
public void add(int index,T e){
checkIndex(index);
// 如果当前元素个数等于数组容量,则将数组扩容至原来的2倍
if(size == data.length){
resize(2 * data.length);
}
for(int i=size -1; i>=index;i--){
data[i+1] = data[i];
}
data[index] = e;
size++;
}
// 向数组头插入元素
public void addFirst(T e){
add(0,e);
}
// 向数组尾插元素
public void addLast(T e){
add(size,e);
}
// 删除index位置的元素,并返回
public T remove(int index){
checkIndexForRemove(index);
T ret = data[index];
for(int i=index+1;i<size;i++){
data[i-1]=data[i];
}
size--;
data[size]=null;
// 缩容
if(size==data.length/4 && data.length/2!=0){
resize(data.length/2);
}
return ret;
}
// 删除第一个元素
public T removeFirst(){
return remove(0);
}
// 删除末尾元素
public T removeLast(){
return remove(size-1);
}
// 从数组中删除指定元素
public void removeElement(T e){
int index=find(e);
if(index !=-1){
remove(index);
}
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append(String.format("Array size=%d, capacity=%d \n",size,data.length));
builder.append('[');
for (int i = 0; i < size; i++) {
builder.append(data[i]);
if(i!=size -1){
builder.append(", ");
}
}
builder.append(']');
return builder.toString();
}
// 扩容方法,时间复杂度O(n)
private void resize(int capacity){
T[] newData = (T[]) new Object[capacity];
for (int i = 0; i < size; i++) {
newData[i] = data[i];
}
data = newData;
}
private void checkIndex(int index){
if(index <0 || index >size){
throw new IllegalArgumentException("remove failed! Require index>=0 and index<=size.");
}
}
private void checkIndexForRemove(int index){
if(index <0 || index >=size){
throw new IllegalArgumentException("remove failed! Require index >=0 and index);
}
}
}
实践
public class Array {
// 定义整型数据data保存数据
public int data[];
// 定义数组长度
private int n;
// 定义实际个数
private int count;
// 构造方法,定义数组大小
public Array(int capacity){
this.data = new int[capacity];
this.n = capacity;
this.count = 0;//一开始一个数都没有存
}
// 根据索引,找到数组汇总的元素并返回
public int find(int index){
if(index<0 || index>=count )
return -1;
return data[index];
}
// 插入元素,头插和尾插
public boolean insert(int index,int value){
// 数组空间已满
if(count ==n){
System.out.println("没有可插入的位置");
return false;
}
// 如果count没满,既可以插入数据到数组
// 1.位置不合法
if(index<0 || index >count){
System.out.println("位置不合法");
return false;
}
// 2. 位置合法
for (int i=count;i>index;--i){
data[i] = data[i-1];
}
data[index]=value;
++count;
return true;
}
// 根据索引,删除数组中元素
public boolean delete(int index){
if(index<0 || index >=count)
return false;
// 从删除位置开始,将后面的元素向前移动一位
for(int i=index +1;i<count; ++i){
data[i-1] = data[i];
}
--count;
return true;
}
public void printAll(){
for (int i = 0; i < count; i++) {
System.out.println(data[i]+" ");
}
System.out.println();
}
public static void main(String[] args) {
Array array = new Array(5);
array.printAll();
array.insert(0,3);
array.insert(0,4);
array.insert(1,5);
array.insert(3,9);
array.insert(3,10);
array.printAll();//4 5 3 10 9
}
}
链表:linked list,由一系列结点node(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。我们常说的链表结构有单向链表与双向链表,那么这里给大家介绍的是单向链表。
有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点。
弊端:链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。链表随机访问的性能没有数组好,**需要O(n)**的时间复杂度。
采用该结构的集合,对元素的存取的特点:
支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。
双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。
循环链表是一种特殊的单链表。跟单链表唯一的区别就在尾节点。单链表的尾节点指向空地址,而循环链表的尾节点指向链表的头结点。像“环”一样首尾相连。
循环链表的优点是从链尾到链头比较方便,当要处理的数据具有环形结构特点,就适合采用单链表,如约瑟夫问题。
底层的存储结构:
维护一个有序单链表,越靠近链表尾部的节点是越早访问的,新的数据被访问,从链表头开始顺序遍历链表。
这样我们就用链表实现了一个LRU缓存
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有非常广泛的应用,如CPU缓存、数据库缓存、浏览器缓存等。
缓存的大小有限,缓存用满后如何淘汰?常见策略:先进先出FIFO、最少使用策略LFU和最近最少使用策略LRU 书房的书
对于第一种,都要从头节点遍历,再通过指针操作删除。时间复杂度为O(n)
对于第二种,删除节点前需要知道前驱节点,单链表不支持直接获得前驱节点,从头遍历;双向链表已经保存前驱节点的指针,不需要遍历,时间复杂度O(1)
也是。
此外,对于有序链表,双向链表的按值查询的效率也比单链表高,平均只需要查一半。比如LinkedHashMap的实现。
更加抽象的设计思想:空间换时间的设计思想。如果我们追求代码的执行速度,可以选择空间复杂度相对较高,而时间复杂度相对较低的算法或数据结构。消耗更多内存进行优化。
c语言的指针,也就是java的引用
对于指针的理解:
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。
p->next=q
p结点中的next指针存储了q结点的内存地址。
p->next=p->next->next
p结点的next指针存储了p结点的下下一个结点的内存地址。
我们希望在结点a和相邻的结点b之间插入结点x,假设当前指针p指向结点a。如果我们将代码实现变成下面这个样子,就会发生指针丢失和内存泄露。
p->next = x; // 将p的next指针指向x结点;
x->next = p->next; // 将x的结点的next指针指向b结点;
p->next指针在完成第一步操作之后,已经不再指向结点b了,而是指向结点x。第2行代码相当于将x赋值给x->next,自己指向自己。因此,整个链表也就断成了两半,从结点b往后的所有结点都无法访问到了。
对于有些语言来说,比如C语言,内存管理是由程序员负责的,如果没有手动释放结点对应的内存空间,就会产生内存泄露。所以,我们插入结点时,一定要注意操作的顺序。要先将结点x的next指针指向结点b,再把结点a的next指针指向结点x,这样才不会丢失指针,导致内存泄漏。
同理,删除链表结点时,也一定要记得手动释放内存空间,否则,也会出现内存泄漏的问题。当然,对于像Java这种虚拟机自动管理内存的编程语言来说,就不需要考虑这么多了。
如果我们在结点p后面插入一个新的结点,只需要下面两行代码就可以搞定。
new_node->next = p->next;
p->next = new_node;
但是,当我们要向一个空链表中插入第一个结点,刚刚的逻辑就不能用了。我们需要进行下面这样的特殊处理,其中head表示链表的头结点。所以,从这段代码,我们可以发现,对于单链表的插入操作,第一个结点和其他结点的插入逻辑是不一样的。
if (head == null) {
head = new_node;
}
我们再来看单链表结点删除操作。如果要删除结点p的后继结点,我们只需要一行代码就可以搞定。
p->next = p->next->next;
但是,如果我们要删除链表中的最后一个结点,前面的删除代码就不work了。跟插入类似,我们也需要对于这种情况特殊处理。写成代码是这样子的:
if (head->next == null) {
head = null;
}
这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。如何来解决这个问题呢?
哨兵,解决的是国家之间的边界问题。同理,这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑。
如何表示一个空链表吗?head=null表示链表中没有结点了。其中head表示头结点指针,指向链表中的第一个结点。
如果我们引入哨兵结点,在任何时候,不管链表是不是空,head指针都会一直指向这个哨兵结点。我们也把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表。
哨兵结点是不存储数据的。因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑了。
用来检查链表代码是否正确的边界条件有这样几个:
找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考。当我们写完代码之后,也可以举几个例子,画在纸上,照着代码走一遍,很容易就能发现代码中的Bug。
5个常见的链表操作。
stack,又称堆栈,它是运算受限的线性表,其限制是仅允许在标的一端进行插入和删除操作,不允许在其他任何位置进行添加、查找、删除等操作。
“操作受限”的线性表
存取元素特点: 先进后出 ,入口和出口都是栈的顶端
栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈。
// 基于数组实现的顺序栈
public class ArrayStack {
private String[] items; // 数组
private int count; // 栈中元素个数
private int n; // 栈的大小
// 初始化数组,申请一个大小为n的数组空间
public ArrayStack(int n){
this.items = new String[n];
this.n = n;
this.count = 0;
}
// 入栈操作
public boolean push(String item){
// 如果数组空间不够,直接返回false,入栈失败
if (count==n) return false;
// 将item放到下标为count的位置,并且count+1
items[count] = item;
count++;
return true;
}
// 出栈操作
public String pop(){
// 栈为空,直接返回null
if (count==0) return null;
// 返回下标为count-1的数组元素,并且栈中元素个数count-1
String tmp = items[count-1];
count--;
return tmp;
}
}
当某个数据集合只涉及在一端插入和删除数据,且满足先进后出的特性,就首选“栈”这种数据结构。
操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将其中的临时变量作为栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。
利用两个栈,其中一个用来保存操作数,另一个用来保存运算符。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较,若比运算符栈顶元素优先级高,就将当前运算符压入栈,若比运算符栈顶元素的优先级低或者相同,从运算符栈中取出栈顶运算符,从操作数栈顶取出2个操作数,然后进行计算,把计算完的结果压入操作数栈,继续比较。
用栈保存为匹配的左括号,从左到右一次扫描字符串,当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号,如果能匹配上,则继续扫描剩下的字符串。如果扫描过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明未匹配的左括号为非法格式。
我们使用两个栈X和Y,我们把首次浏览的页面依次压入栈X,当点击后退按钮时,再依次从栈X中出栈,并将出栈的数据一次放入Y栈。当点击前进按钮时,我们依次从栈Y中取出数据,放入栈X中。当栈X中没有数据时,说明没有页面可以继续后退浏览了。当Y栈没有数据,那就说明没有页面可以点击前进浏览了。
内存中的堆栈和数据结构堆栈不是一个概念,可以说内存中的堆栈是真实存在的物理区,数据结构中的堆栈是抽象的数据存储结构。
内存空间在逻辑上分为三部分:代码区、静态数据区和动态数据区,动态数据区又分为栈区和堆区。
代码区:存储方法体的二进制代码。高级调度(作业调度)、中级调度(内存调度)、低级调度(进程调度)控制代码区执行代码的切换。
静态数据区:存储全局变量、静态变量、常量,常量包括final修饰的常量和String常量。系统自动分配和回收。
栈区:存储运行方法的形参、局部变量、返回值。由系统自动分配和回收。
堆区:new一个对象的引用或地址存储在栈区,指向该对象存储在堆区中的真实数据。
队列:queue,简称队,它同堆栈一样,也是一种运算受限的线性表,其限制是仅允许在表的一端进行插入,而在表的另一端进行删除。
数组实现
public class ArrayQueue {
// 数组:items 数组大小:n
private String[] items;
private int n=0;
// head 表示队头下标,tail队尾下标
private int head=0;
private int tail=0;
// 申请大小为capacity的数组
public ArrayQueue(int capacity){
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item){
// 如果tail==n表示队列已满
if(tail==n) return false;
items[tail] = item;
tail++;
return true;
}
// 出队
public String dequeue(){
// 如果head==tail表示队列为空
if(head==tail) return null;
String ret = items[head];
head++;
return ret;
}
}
随着不停的入队、出队操作,head和tail都会持续的往后操作,当tail移动到最右边,无法再往队列中添加数据了,如何解决该问题?
在出队时不用搬移数据,如果没有空闲空间,只需要在入队时,再集中触发一次数据搬移,出队保持不变,入队改造代码如下
// 入队,数据搬移
public boolean enqueue(String item){
// 如果tail==n表示队列已满
if(tail==n) {
if (head==0) return false;
// 数据搬移
for(int i=head;i<tail;i++){
items[i-head] = items[i];
}
// 搬移后重新更新head和tail
tail -= head;
head = 0;
}
items[tail] = item;
tail++;
return true;
}
按照字面意思理解,递就是去的过程,归就是回来的过程,想象课程中的内存图。
f(1)=1
最关键的是写出递推公式,找到终止条件。
遇到递归,把他抽象成一个递推公式,不用想一层层的调用关系,用人脑分解递归的每个步骤
警惕栈内存溢出。
案例:
/*
题目:n个台阶,每次下台阶有两种方法,下一级和下二级。请问10级台阶多少个方法
思路:递归,根据第一步的走法将所有走法分为两类
第一类是第一步走了一个台阶,另一类是第一步走了两个台阶
n个台阶的走法就是先走1阶后n-1个台阶的走法,加上
先走2阶后n-2个台阶的走法
f(n) = f(n-1)+f(n-2)
终止条件: 有1个台阶时,只有f(1)=1,用n=2,n=3来验证
n=2时,f(2)=f(1)+f(0),显然f(0)没有意义,不合常理
可以把f(2)=2作为条件,f(2)两个台阶有两种走法
递归终止条件为f(2)=2,f(1)=1
*/
public class Demo01 {
public static void main(String[] args) {
int i = getTaijie(10);
System.out.println(i);
}
public static int getTaijie(int n){
if(n==1){
return 1;
}else if (n==2){
return 2;
}else{
return getTaijie(n-1)+getTaijie(n-2);
}
}
}
在上述案例中,就进行了重复计算,想要计算f(5),就需要计算f(4)和f(3),计算f(4)还需要计算f(3),如何避免?可以通过数据结构如hash表保存已经求解过的f(k)。
public class Demo02 {
public static void main(String[] args) {
int i = getTaijie(10);
System.out.println(i);
}
public static int getTaijie(int n){
if(n==1){
return 1;
}
if (n==2){
return 2;
}
// hasSolvedList可以理解为一个Map,key是n,value是f(n)
Map hasSolvedList = new HashMap<>();
int ret = getTaijie(n-1) + getTaijie(n-2);
hasSolvedList.put(n,ret);
return ret;
}
}
在时间效率上,递归代码多了很多函数调用,调用数量较大时,积聚成一个可观的时间成本;空间复杂度上,调用一次就会在栈内存保存一次现场数据,需要额外考虑这部分,空间复杂度不是O(1),而是O(n);存在栈内存溢出风险,存在重复计算。
表达力强,写起来简洁
public class Demo03 {
public static void main(String[] args) {
int i = getTaijie(10);
System.out.println(i);
}
public static int getTaijie(int n){
if(n==1){
return 1;
}
if (n==2){
return 2;
}
int ret = 0;// 返回值
int pre = 2;// n=2的情况
int prepre = 1;// n=1的情况
for(int i=3;i <= n; i++){
ret = pre + prepre;
prepre = pre;
pre = ret;
}
return ret;
}
}