递归(recursion)是函数式编程思想的产物,它使用数学函数的思想进行运算,只要在数学逻辑上是合理的,即代码中的函数一定合理。使用递归时,无须深究其运行过程!
栈的本质是递归。所以可以借助栈理解递归;先进后出,栈顶是递归终止条件,栈的每一层是递归的方法体。
斐波拉契数列的特点是:第1位和第2位固定为1,后面的位,其数字等于前两位之和,比如: [1, 1, 2, 3, 5, 8, 13, 21, …]
求斐波拉契数列第n位的值,n>0
如果使用函数f(n)
来表示斐波拉契数列第n位的值,通过数学分析,可以轻松得到:
f(1) = 1 f(2) = 1 f(n) = f(n-1) + f(n-2)
以上等式考虑到了所有情况,并且在数学逻辑上是合理的,因此,可以轻松书写到代码中:
// 求斐波拉契数列第n位的值 function f(n){ if(n === 1 || n === 2){ return 1; } return f(n-1) + f(n-2); }
线性结构
线性结构是数据结构中的一种分类,用于表示一系列(一个接着一个的数据)的元素形成的有序集合。
常见的线性结构包括:数组、链表、栈、队列
数组
特别注意:这里所说的数组是数据结构中的数组,和JS中的数组不一样
数组是一整块连续的内存空间,它由固定数量的元素组成,数组具有以下基本特征:
-
整个数组占用的内存空间是连续的
-
数组中元素的数量是固定的(不可增加也不可减少),创建数组时就必须指定其长度
-
每个元素占用的内存大小是完全一样的。
根据数组的基本特征,我们可以推导出数组具有以下特点:
- 通过下标寻找对应的元素效率极高,因此遍历速度快(如果起始内存块地址为1000,每块内存的大小为20,那么下标为n的内存块的内存地址为1000+20*n),这样的话很方便根据下标进行查询。
- 无法添加和删除数据,虽然可以通过某种算法完成类似操作,但会增加额外的内存开销或时间开销
- 如果数组需要的空间很大,可能一时无法找到足够大的连续内存
JS中的数组
在ES6之前,JS没有真正意义的数组,所谓的Array,实际上是一个对象。可以进行增删改查操作
ES6之后,出现真正的数组(类型化数组),但是由于只能存储数字,因此功能有限
目前来讲,JS语言只具备不完善的数组(类型化数组)
链表
为弥补数组的缺陷而出现的一种数据结构,它具有以下基本特征:
-
每个元素除了存储数据,需要有额外的内存存储一个引用(地址),来指向下一个元素
-
每个元素占用的内存空间并不要求是连续的
-
往往使用链表的第一个节点(根节点)来代表整个链表
根据链表的基本特征,我们可以推导出它具有以下特点: -
长度是可变的,随时可以增加和删除元素
-
插入和删除元素的效率极高
-
由于要存储下一个元素的地址,会增加额外的内存开销
-
通过下标查询链表中的某个节点,效率很低,因此链表的下标遍历效率低
练习1: 打印整个链表的数据
/**
* 打印整个链表的数据
* print(node) = 如果自己没有值,则不打印
* print(node) = 打印自己 + 打印下一个
* @param {*} node
*/
function print(node){
if(!node){
return;
}
console.log(node.value);
print(node.next)
}
/**
* 链表节点
* @param {*} value
*/
function Node(value){
this.value=value;
this.next=null;
}
var a=new Node('aaa');
var b=new Node('bbb');
var c=new Node('ccc');
a.next=b;
b.next=c;
print(a);//aaa bbb ccc
二: 获取链表的长度
/**
* 获取链表的长度
* @param {*} node
*/
function len(node){
if(!node){
return 0;//如果没有节点,长度为0.
//这里不能是return; 递归回去之后和前面的1相加有问题
}
return 1+len(node.next);
}
/**
* 链表节点
* @param {*} value
*/
function Node(value){
this.value=value;
this.next=null;
}
var a=new Node('aaa');
var b=new Node('bbb');
var c=new Node('ccc');
a.next=b;
b.next=c;
console.log(len(a));
三:根据下标,获取链表中某个位置的数据
/**
* 根据链表、寻找的下标、当前的下标,找到对应的数据
* @param {*} node 根据链表
* @param {*} index 寻找的下标
* @param {*} curIndex 当前的下标
* @param {*} node
*/
function find(node,index){
var currentIndex=0;
//因为缺少一个参数,表示当前的下标,所以在里面重建一个函数。
function findinner(node,index,currentIndex)
{
if (!node) {
// 超出了链表的范围
throw new Error("超出了下标范围");
}
if(index===currentIndex){
return node.value;
}else{
currentIndex++;
return findinner(node.next,index,currentIndex);
}
}
return findinner(node,index,currentIndex)
}
/**
* 链表节点
* @param {*} value
*/
function Node(value){
this.value=value;
this.next=null;
}
var a=new Node('aaa');
var b=new Node('bbb');
var c=new Node('ccc');
a.next=b;
b.next=c;
console.log(find(a,2));
四:根据链表、寻找的下标、当前的下标,设置对应的数据
/**
通过下标设置链表中的某个数据
* @param {*} node
*/
function set(node,index,value){
var currentIndex=0;
function setinner(node,index,currentIndex)
{
if (!node) {
// 超出了链表的范围
throw new Error("超出了下标范围");
}
if(index===currentIndex){
node.value=value;
return ;
}else{
currentIndex++;
return setinner(node.next,index,currentIndex);
}
}
return setinner(node,index,currentIndex);
}
/**
* 链表节点
* @param {*} value
*/
function Node(value){
this.value=value;
this.next=null;
}
var a=new Node('aaa');
var b=new Node('bbb');
var c=new Node('ccc');
a.next=b;
b.next=c;
console.log(set(a,2,'新的ccc'));
五:在链表node的末尾,加入一个新的节点,值为value
/**
* 在链表node的末尾,加入一个新的节点,值为value
* @param {*} node
* @param {*} value 新节点的值
*/
function push(node, value) {
if (node.next) {
push(node.next, value); //继续往后看
} else {
// 到了链表的末尾
var newNode = new Node(value);
node.next = newNode;
}
}
/**
* 链表节点
* @param {*} value
*/
function Node(value){
this.value=value;
this.next=null;
}
var a=new Node('aaa');
var b=new Node('bbb');
var c=new Node('ccc');
a.next=b;
b.next=c;
console.log( push(a, 'ddd') );
六:在链表node中,寻找beforeNode,在其之后,加入一个新节点,新节点的值为value
/**
* 在链表node中,寻找beforeNode,在其之后,加入一个新节点,新节点的值为value
* @param {*} node
* @param {*} beforeNode
* @param {*} value
*/
function insert(node,beforeNode,value){
if(node===beforeNode){
var newnode=new Node(value);
//想要形成a->b->g->c->d的链表,如果先让b.next指向新节点,那么就失去了对c节点的引用(没有节点指向它,c节点表示不出来了),后面新节点.next指向c也无法实现
//所以要将新节点.next指向c节点,此时b节点.next也是指向c节点的,然后让b节点.next指向新节点。
//newNode.next = node.next;
//node.next = newNode;
newnode.next=beforeNode.next;
beforeNode.next=newnode;
}
else{
insert(node.next,beforeNode,value)
}
}
/**
* 链表节点
* @param {*} value
*/
function Node(value){
this.value=value;
this.next=null;
}
var a=new Node('aaa');
var b=new Node('bbb');
var c=new Node('ccc');
var d=new Node('ddd');
a.next=b;
b.next=c;
c.next=d;
console.log(insert(a,b, 'ggg') );
七:在链表node中,寻找removeNode,并删除它
/**
* 在链表node中,寻找removeNode,并删除它
* @param {*} node
* @param {*} removeNode
*/
function remove(node,removeNode){
if(node.next===removeNode){
//找删除的节点位置的前一个节点,并改变它的指向,指向删除节点的下一个节点。
node.next=removeNode.next;
}
else{
remove(node.next,removeNode)
}
}
/**
* 链表节点
* @param {*} value
*/
function Node(value){
this.value=value;
this.next=null;
}
var a=new Node('aaa');
var b=new Node('bbb');
var c=new Node('ccc');
var d=new Node('ddd');
a.next=b;
b.next=c;
c.next=d;
console.log( remove(a,c) );
八、反转链表
1.双指针迭代
我们可以申请两个指针,第一个指针叫 pre,最初是指向 null 的。
第二个指针 cur 指向 head,然后不断遍历 cur。把cur.next先保存起来,以防后面引用不到它。
每次迭代到 cur,都将 cur 的 next 指向 pre,然后 pre 和 cur 前进一位。
都迭代完了(cur 变成 null 了),pre 就是最后一个节点了。
进入下一次操作
图来源自leetcode大佬
var reverseList = function(head) {
var pre=null;
var cur=head;
while(cur!=null){
var temp=cur.next;
cur.next=pre;
pre=cur;
cur=temp;
}
return pre;
};
补充:
如果将pre设置为head,cur设置为head.next就会超时,这是为什么?
递归的思路:
其他节点的操作同理,最终:
递归的两个条件:
终止条件是当前节点或者下一个节点==null
在函数内部,改变节点的指向,也就是 head 的下一个节点指向 head 递归函数那句head.next.next = head,很不好理解,其实就是 head 的下一个节点指向head,从而实现了反转。
递归函数中每次返回的 cur 其实只最后一个节点,在递归函数内部,改变的是当前节点的指向。(看完之后再理解)。
var reverseList = function(head) {
if(head == null || head.next == null){
return head
}
const cur= reverseList(head.next);
//层层递归,返回最后一个节点
//从节点4开始进行反转操作
//1.实现head和head.next之间的反转
head.next.next = head;
//2.把head.next设置为null,切断head和head.next正方向的指针
head.next = null
//每层递归返回最后一个节点,因为每个递归相对于cur而言,没有操作cur,cur的值不变,保留其作为反转链表的开始节点。
return cur;
};
两个tip帮助理解:
1.当指针指向节点5时,根据函数终止条件会直接返回
于是返回到上一层,这时候节点就指向了4。
cur指向的是返回后的节点所以就是5,head指向当前节点所以就是4。
2.
3.递归的思路:
定义了 reverseLis t函数的功能可以把⼀个单链表反转,在里面去递归调用reverseList(head.next),实现了对 2->3->4反转。
我们把 2->3->4 递归成 4->3->2。不过,1 这个节点我们并没有去碰它,所以 1 的 next 节点仍然是连接这 2。接下来就简单了,我们接下来只需要把节点 2 的 next 指向 1,然后把 1 的 next 指向 null,不就⾏了?,即通过改变 newList 链表之后的结果如下:
也就是说,reverseList(head) 等价于 ** reverseList(head.next)** + 改变⼀下1,2两个节点的指向。
var reverseList = function(head) {
if((head===null)||head.next==null){
return head;
}
var end=reverseList(head.next);
通过 head.next获取节点2
var node2=head.next;
// 让 2 的 next 指向 1
node2.next=head;
// 1 的 next 指向 null.
head.next=null;
把调整之后的链表返回。
return end;
};
九、合并两个有序链表:
套路:
1.哑节点
**创建 哑节点 作为 结果链表 的开头,刚开始指向的是一个空节点,返回结果是这个节点的下一个位置。**目的是:在未遍历之前,我们不知道构建的结果中,开头元素到底是 l1 还是 l2, 为了让代码整齐,创建哑节点。
2.使用 move 游标
哑节点标记了 结果链表 的开头,因此是不能移动的。为了把两个链表 merge 的结果放到结果链表的最后,就需要使用一个 move 游标指向 结果链表 的最后一个元素。初始时,move 指向 哑节点,之后随着结果链表的增加而不停地向后移动,始终保持其指向 结果链表 的最后一个元素。
while 遍历两个元素
涉及到两个元素的遍历题,使用 while l1 and/or l2 的方式。即两个元素都没有遍历完或者至少有一个没遍历完,具体使用 and 还是 or 要根据场景进行选择。
没用完的元素仍需拼接
当 while 循环结束之后,l1 和 l2 至少遍历完了一个,另一个链表可能没有用完,因此需要拼接到 结果链表 的结尾。
至于本题只需要判断两个链表头部元素的大小,把小的那个链表节点放到 结果链表 的结尾即可。
var mergeTwoLists = function(l1, l2) {
//创建 哑节点 作为 结果链表 的开头,返回结果是这个节点的下一个位置。
//目的是:在未遍历之前,我们不知道构建的结果中,开头元素到底是 l1 还是 l2, 为了让代码整齐,创建哑节点。
var curr= new ListNode();
var pre=curr;//指向空节点的首结点
while(l1!=null&&l2!=null){
if(l1.val<=l2.val)
{
curr.next=l1;
l1=l1.next;
//相当于result.push(l2),l2++的操作
}
else
{
curr.next=l2;
l2=l2.next;
}
//每次操作之后curr也往后移动
curr=curr.next;
}
if(l1===null){
curr.next=l2;//将l2后面的链表关系保留放在curr.next的后面
}
if(l2===null){
curr.next=l1;
}
return pre.next;
};
2.递归法:
注意l1和l2的指针式不断变化的,谁小谁就要往后移一位。以此类推
来自大佬的图
function mergeTwoLists(l1,l2){
if(l1===null){
return l2;
}
else if(l2===null){
return l1;
}
else if(l1.val<l2.val){
l1.next=mergeTwoLists(l1.next,l2);
return l1;
}
else{
l2.next=mergeTwoLists(l1,l2.next);
return l2;
}
}
两者代码一样,但是理解思路可以不一样,我也不明白为什么
总结:
1.写递归前,想好逻辑,看下哪个逻辑是重复使用的,这部分可以使用递归。
2.如果函数的参数不够用,需要再添加参数,可在函数里面再定义一个子函数。
3.写增加删除时要核实这个节点是否可以使用,被删除的节点(没有节点指向它)会被垃圾回收器自动回首。