2022.4.12(快速上手,从0到1掌握算法面试需要的数据结构)
(1)方括号加元素内容直接创建 const arr = [1, 2, 3, 4]
(2)大部分情况下初始化数组时不知道其中的元素内容,需要用到构造函数创建数组的方法
const arr = new Array()
它不传任何参数,是一个空数组,等价于const arr = []
(3)创造指定长度的数组,需要多长的数组就传多大的参数const arr = new Array(7)
(4)fill方法可以将每个坑都填上同样的值const arr = (new Array(7)).fill(1)
访问数组中的元素直接在中括号中指定索引值即可,从0开始
(1)for循环遍历,可以通过循环数组的下标来以此访问每个值
// 获取数组的长度
const len = arr.length
for(let i=0;i
(2)forEach方法遍历,函数中有两个参数,分别是元素值和当前索引
arr.forEach((item, index)=> { // 输出数组的元素值,输出当前索引 console.log(item, index) })
(3)map方法可以根据传入的函数逻辑对数组中每个元素进行处理、进而返回一个全新的数组。
(1)二维数组初始化
const len = arr.length
for(let i=0;i
(2)二维数组的访问
// 缓存外部数组的长度
const outerLen = arr.length
for(let i=0;i
for(let j=0;j
(1)灵活增删数组
①unshift方法可以添加元素到数组头部
const arr = [1,2] arr.unshift(0) // [0,1,2]
②push方法可以添加元素到数组尾部
const arr = [1,2] arr.push(3) // [1,2,3]
③splice方法添加元素到数组任何位置
const arr = [1,2] arr.splice(1,0,3) // [1,3,2],在索引1的位置删除0个元素并添加数字3
④shift 方法-删除数组头部的元素
const arr = [1,2,3] arr.shift() // [2,3]
⑤pop 方法-删除数组尾部的元素
const arr = [1,2,3] arr.pop() // [1,2]
⑥splice 方法-删除数组任意位置的元素
arr.splice(1,1)
(2)栈(Stack)——只用 pop 和 push 完成增删的“数组”
栈是一种后进先出(LIFO,Last In First Out)的数据结构。只允许从尾部添加或者取出元素
// 初始状态,栈空
const stack = []
// 入栈过程
stack.push('东北大板')
stack.push('可爱多')
stack.push('巧乐兹')
stack.push('冰工厂')
stack.push('光明奶砖')
// 出栈过程,栈不为空时才执行
while(stack.length) {
// 单纯访问栈顶元素(不出栈)
const top = stack[stack.length-1]
console.log('现在取出的冰淇淋是', top)
// 将栈顶元素出栈
stack.pop()
}
// 栈空
stack // []
(3)队列(Queue)——只用 push 和 shift 完成增删的“数组”。先进先出
const queue = []
queue.push('小册一姐')
queue.push('小册二姐')
queue.push('小册三姐')
while(queue.length) {
// 单纯访问队头元素(不出队)
const top = queue[0]
console.log(top,'取餐')
// 将队头元素出队
queue.shift()
}
// 队空
queue // []
链表中,数据单位的名称叫做“结点”,而结点和结点的分布,在内存中可以是离散的。
(1)在链表中,每一个结点的结构都包括了两部分的内容:数据域和指针域。JS 中的链表,是以嵌套的对象的形式来实现的:
{
// 数据域
val: 1,
// 指针域,指向下一个结点
next: {
val:2,
next: ...
}
}
数据域存储的是当前结点所存储的数据值,而指针域则代表下一个结点(后继结点)的引用。
我们有时还会设定一个 head 指针来专门指向链表的开始位置。
(2)链表节点的创建
创建链表结点,咱们需要一个构造函数:
function ListNode(val) {
this.val = val;
this.next = null;
}
在使用构造函数创建结点时,传入 val (数据域对应的值内容)、指定 next (下一个链表结点)即可:
const node = new ListNode(1)
node.next = new ListNode(2)
(3)链表元素的添加
// 如果目标结点本来不存在,那么记得手动创建
const node3 = new ListNode(3)
// 把node3的 next 指针指向 node2(即 node1.next)
node3.next = node1.next
// 把node1的 next 指针指向 node3
node1.next = node3
(4)链表元素的删除
删除的标准是:在链表的遍历过程中,无法再遍历到某个结点的存在。按照这个标准,要想遍历不到 node3,我们直接让它的前驱结点 node1 的 next 指针跳过它、指向 node3 的后继即可:
node1.next = node3.next
在涉及链表删除操作的题目中,重点不是定位目标结点,而是定位目标结点的前驱结点。做题时,完全可以只使用一个指针(引用),这个指针用来定位目标结点的前驱结点。比如说咱们这个题里,其实只要能拿到 node1 就行了:
// 利用 node1 可以定位到 node3
const target = node1.next
node1.next = target.next
(5)链表和数组的辨析
在大多数的计算机语言中,数组都对应着一段连续的内存。如果我们想要在任意位置删除一个元素,那么该位置往后的所有元素,都需要往前挪一个位置;相应地,如果要在任意位置新增一个元素,那么该位置往后的所有元素也都要往后挪一个位置。我们假设数组的长度是 n,那么因增加/删除操作导致需要移动的元素数量,就会随着数组长度 n 的增大而增大,呈一个线性关系。所以说数组增加/删除操作对应的复杂度就是 O(n)。相对于数组来说,链表有一个明显的优点,就是添加和删除元素都不需要挪动多余的元素。链表的插入/删除效率较高,而访问效率较低;数组的访问效率较高,而插入效率较低。这个特性需要大家牢记,可能会作为数据结构选型的依据来单独考察。
(1)二叉树遍历
以一定的顺序规则,逐个访问二叉树的所有结点,这个过程就是二叉树的遍历。按照顺序规则的不同,遍历方式有以下四种:
按照实现方式的不同,遍历方式又可以分为以下两种:
我们此处其实可以穷举一下,假如在保证“左子树一定先于右子树遍历”这个前提,那么遍历的可能顺序也不过三种:
上述三个遍历顺序,就分别对应了二叉树的先序遍历、中序遍历和后序遍历规则。
在这三种顺序中,根结点的遍历分别被安排在了首要位置、中间位置和最后位置。
所谓的“先序”、“中序”和“后序”,“先”、“中”、“后”其实就是指根结点的遍历时机。
①先序遍历 ,根结点 -> 左子树 -> 右子树
const root={
val:'A',
left:{
val:'B',
left:{
val:'D'
},
right:{
val:'E'
}
},
right:{
val:'C',
right:{
val:'F'
}
}
}
function p(a){
if(!a){
return
}
console.log('当前是',a.val)
p(a.left)
p(a.right)
}
p(root)
(1)力扣1,两数求和
map哈希表
var twoSum = function(nums, target) {
const map=new Map()
let len=nums.length
for(let i=0;i
(2)力扣88,合并两个有序数组
双指针
const merge = function(nums1, m, nums2, n) {
let i=m-1,j=n-1,k=m+n-1
while(i>=0&&j>=0){
if(nums2[j]>nums1[i]){
nums1[k]=nums2[j]
k--
j--
}else{
nums1[k]=nums1[i]
i--
k--
}
}
while(j>=0){
nums1[k]=nums2[j]
j--
k--
}
};
(3)力扣15,三数之和
有序和数组,除了双指针就选择指针碰撞
var threeSum = function(nums) {
let res=[]
nums=nums.sort((a,b)=>{
return a-b
})
const len=nums.length
for(let i=0;i0&&nums[i]===nums[i-1]){
continue
}
while(j0){
k--
while(j
2022.4.13(快速上手,从0到1掌握算法面试需要的数据结构)
(1)翻转字符串
在 JS 中,反转字符串我们直接调相关 API 即可,相信不少同学都能手到擒来:
// 定义被反转的字符串
const str = 'juejin'
// 定义反转后的字符串
const res = str.split('').reverse().join('')
console.log(res) // nijeuj
(2)判断一个字符串是否是回文字符串
结合这个定义,我们不难写出一个判定回文字符串的方法:
function isPalindrome(str) {
// 先反转字符串
const reversedStr = str.split('').reverse().join('')
// 判断反转前后是否相等
return reversedStr === str
}
同时,回文字符串还有另一个特性:如果从中间位置“劈开”,那么两边的两个子串在内容上是完全对称的。因此我们也可以结合对称性来做判断:
function isPalindrome(str) {
// 缓存字符串的长度
const len = str.length
// 遍历前半部分,判断和后半部分是否对称
for(let i=0;i
字符串题干中若有“回文”关键字,那么做题时脑海中一定要冒出两个关键字——对称性 和 双指针。这两个工具一起上,足以解决大部分的回文字符串衍生问题。
编码实现
const validPalindrome = function(s) {
// 缓存字符串的长度
const len = s.length
// i、j分别为左右指针
let i=0, j=len-1
// 当左右指针均满足对称时,一起向中间前进
while(i
(3)字符串与数字之间转换问题
①首先来了解一下什么是正则表达式,正则是匹配模式,要么匹配字符串,要么匹配位置
比如/ab{2,5}c/表示:第一个字符是“a”,接下来是2到5个字符“b”,最后是字符“c”。
比如/a[123]b/
可以匹配如下三种字符串:"a1b"、"a2b"、"a3b"。
比如[123456abcdefGHIJKLM]
,可以写成[1-6a-fG-M]
。用连字符-
来省略和简写。
例如[^abc]
,表示是一个除"a"、"b"、"c"之外的任意一个字符。字符组的第一位放^
(脱字符),表示求反的概念。
^
(脱字符)匹配开头,在多行匹配中匹配行开头。
$
(美元符号)匹配结尾,在多行匹配中匹配行结尾。
②直接上代码!
var myAtoi = function(s) {
const reg=/\s*([-\+]?[0-9]*).*/
const groups=s.match(reg)
const max=Math.pow(2,31)-1
const min=-max-1
let targetNum=0
if(groups){
targetNum= groups[1]
if(isNaN(targetNum)){
targetNum=0
}
}
if(targetNum>max){
return max
}else if(targetNum
\s
这个符号,意味着空字符,它可以用来匹配回车、空格、换行等空白区域,这里,它用来被匹配空格。*是修饰符号,
跟在其它符号后面,意味着“前面这个符号可以出现0次或多次。\s*
,这里的意思就是空格出现0次或多次,都可被匹配到。JS 的正则相关方法中, test()
方法返回的是一个布尔值,单纯判断“是否匹配”。要想获取匹配的结果,我们需要调度match()
方法,match()
方法是一个在字符串中执行查找匹配的String方法,它返回一个数组,在未匹配到时会返回 null。
const reg = /\s*([-\+]?[0-9]*).*/
const groups = str.match(reg)
这里注意!如果我们的正则表达式尾部有 g 标志,match()
会返回与完整正则表达式匹配的所有结果,但不会返回捕获组。这里我们没有使用g标志,match()
就会返回第一个完整匹配(作为数组的第0项)及其相关的捕获组(作为数组的第1及第1+项)。这里我们只定义了一个捕获组,因此可以从 groups[1]
里拿到我们捕获的结果,内容是([-\+]?[0-9]*),就是提取到的数字内容部分,可以完成对后续卡口的判断和最终结果的输出。详情见资料图
捕获组就是把正则表达式中子表达式匹配的内容,保存到内存中以数字编号或显式命名的组里,方便后面引用。当然,这种引用既可以是在正则表达式内部,也可以是在正则表达式外部。
转自正则基础之——捕获组(capture group)_josjonah的博客-CSDN博客_正则捕获组
正则捕获组讲的很详细,可以参考,我只把本题需要的内容提取出来。
if(isNaN(targetNum)) {
// 不能进行有效的转换时,即match没有匹配到符合的数组,请返回 0
targetNum = 0 }
最后的卡口判断就很容易理解啦
(4)字符串匹配问题
①前置知识:原型链
·prototype:显式原型
·__隐式原型__
一般,构造函数的prototype和其实例的__proto__是指向同一个方向,这个地方叫做原型对象
构造函数就是可以用来new的函数。
讨论原型链之前,咱们先来聊聊这两个东西
构造函数Person
的原型对象构造函数Function
的原型对象都说了原型对象,原型对象,可以知道其实这两个本质都是对象
那既然是对象
,本质肯定都是通过new Object()
来创建的。既然是通过new Object()
创建的,那就说明Person.prototype 和 Function.prototype
都是构造函数Object
的实例。也就说明了Person.prototype 和 Function.prototype
他们两的__proto__
都指向Object.prototype
/**
* 构造函数
*/
const WordDictionary = function () {
// 初始化一个对象字面量,承担 Map 的角色
this.words = {}
};
/**
添加字符串的方法
*/
WordDictionary.prototype.addWord = function (word) {
// 若该字符串对应长度的数组已经存在,则只做添加
if (this.words[word.length]) {
this.words[word.length].push(word)
} else {
// 若该字符串对应长度的数组还不存在,则先创建
this.words[word.length] = [word]
}
};
/**
搜索方法
*/
WordDictionary.prototype.search = function (word) {
// 若该字符串长度在 Map 中对应的数组根本不存在,则可判断该字符串不存在
if (!this.words[word.length]) {
return false
}
// 缓存目标字符串的长度
const len = word.length
// 如果字符串中不包含‘.’,那么一定是普通字符串
if (!word.includes('.')) {
// 定位到和目标字符串长度一致的字符串数组,在其中查找是否存在该字符串
return this.words[len].includes(word)
}
// 否则是正则表达式,要先创建正则表达式对象
const reg = new RegExp(word)
// 只要数组中有一个匹配正则表达式的字符串,就返回true
return this.words[len].some((item) => {
return reg.test(item)
})
};
2022.4.16
如果说在命题时,数组和字符串的角色往往是“算法思想的载体”,那么链表本身就可以被认为是“命题的目的”。单在真题归纳解读环节,我们能讲的技巧、能做的题目已经有很多。结合实际面试中的命题规律,我把这些题目分为以下三类:
链表的内存空间不是必须连续的,不需要在创建时就确定大小,但是访问任何一个元素都需要从头开始访问。链表的每个元素由一个存储元素本身的节点和一个指向下一个元素的引用组成。也就是说每一个节点自己有一个data,并且有一个指向下一个节点的指针next,next的指向默认为null。处理链表的本质就是处理链表节点之间的指针关系
我们是可以获取我们的下一个节点的值。我们可以把下一个节点的值给要删除的节点,之后删除下一个节点就可以了
通过双指针进行操作,设置中间值tem
声明两个指针,将两个指针对应的数字相加,把对应位置的相加放入新的链表中,当两个链表的长度不等时,没有节点的时候默认值为0。超过10,计入c中并加到下一个节点的计算中
我们只需要比较当前元素是否和下一个元元素的相等,如果相等就删除下一个元素,不相等,就移动指针继续比较。
我们可以声明两个指针,一快一慢,如果是环形,那总会相遇。
涉及反复遍历的题目,题目本身虽然不会直接跟你说“你好,我是一道需要反复遍历的题目”,但只要你尝试用常规的思路分析它,你会发现它一定涉及反复遍历;同时,涉及反复遍历的题目,还有一个更明显的特征,就是它们往往会涉及相对复杂的链表操作,比如反转、指定位置的删除等等。解决这类问题,我们用到的是双指针中的“快慢指针”。快慢指针指的是两个一前一后的指针,两个指针往同一个方向走,只是一个快一个慢。快慢指针严格来说只能有俩,不过实际做题中,可能会出现一前、一中、一后的三个指针,这种超过两个指针的解题方法也叫“多指针法”。
若需要删除链表倒数第n个结点并返回链表头结点,可以用到dummy,所谓 dummy 结点,就是咱们人为制造出来的第一个结点的前驱结点,这样链表中所有的结点都能确保有一个前驱结点,也就都能够用同样的逻辑来处理了。
let dummy=new ListNode()
dummy.next=head
var removeNthFromEnd = function(head, n) {
let dummy=new ListNode()
dummy.next=head
slow=dummy
fast=dummy
while(n!==0){
fast=fast.next
n--
}
while(fast.next){
fast=fast.next
slow=slow.next
}
slow.next=slow.next.next
return dummy.next
};
运用多指针法,定义一个函数,输入一个链表的头结点,反转该链表并输出反转后链表的头结点。
这里重点说一下反转局部,反转从位置 m 到 n 的链表。请使用一趟扫描完成反转。
给定一个链表,返回链表开始入环的第一个结点。 如果链表无环,则返回 null。
2022.4.17
push(element(s))
:入栈 -> 添加一个/多个元素至栈顶
pop()
:出栈 -> 移除栈顶元素,并返回被移除的元素
peek()
:返回栈顶元素
isEmpty()
:该栈是否存在元素
clear()
:移除栈中所有元素
size()
:栈中元素个数
括号问题一般首选用栈来做,括号具有对称性,因为根据栈的先进后出特点,一组数据的入栈和出栈顺序刚好是对称的。
我们的思路就是在遍历字符串的过程中,往栈里 push 括号对应的配对字符。比如如果遍历到了 (
,就往栈里 push )
。如果括号符合要求,则push进去的
栈结构可以帮我们避免重复操作。
避免重复操作的秘诀就是及时地将不必要的数据出栈,避免它对我们后续的遍历产生干扰。
拿这道题来说,我们的思路就是:尝试去维持一个递减栈。
当遍历过的温度,维持的是一个单调递减的态势时,我们就对这些温度的索引下标执行入栈操作;只要出现了一个数字,它打破了这种单调递减的趋势,也就是说它比前一个温度值高,这时我们就对前后两个温度的索引下标求差,得出前一个温度距离第一次升温的目标差值。
/**
* @param {number[]} T
* @return {number[]}
*/
// 入参是温度数组
const dailyTemperatures = function(T) {
const len = T.length // 缓存数组的长度
const stack = [] // 初始化一个栈
const res = (new Array(len)).fill(0) // 初始化结果数组,注意数组定长,占位为0
for(let i=0;i T[stack[stack.length-1]]) {
// 将栈顶温度值对应的索引出栈
const top = stack.pop()
// 计算 当前栈顶温度值与第一个高于它的温度值 的索引差值
res[top] = i - top
}
// 注意栈里存的不是温度值,而是索引值,这是为了后面方便计算
stack.push(i)
}
// 返回结果数组
return res
};
var MinStack = function() {
this.stack=[]
this.stack2=[]
};
/**
* @param {number} val
* @return {void}
*/
MinStack.prototype.push = function(val) {
this.stack.push(val)
if(this.stack2.length===0||this.stack2[this.stack2.length-1]>=val){
this.stack2.push(val)
}
};
/**
* @return {void}
*/
MinStack.prototype.pop = function() {
//this.stack.pop()
if(this.stack.pop() === this.stack2[this.stack2.length-1]){
this.stack2.pop();
}
};
/**
* @return {number}
*/
MinStack.prototype.top = function() {
if(!this.stack||!this.stack.length){
return
}
return this.stack[this.stack.length-1]
};
/**
* @return {number}
*/
MinStack.prototype.getMin = function() {
return this.stack2[this.stack2.length-1]
};
/**
* Your MinStack object will be instantiated and called as such:
* var obj = new MinStack()
* obj.push(val)
* obj.pop()
* var param_3 = obj.top()
* var param_4 = obj.getMin()
*/
pop在判断过程中已经出栈,不需要额外添加语句,增添新栈,对旧栈操作,新栈要同步更新,新栈仅用于储存最小值,其余操作均在旧栈进行,有数据变化需要同步更新即可
2022.4.21
push(x) -- 将一个元素放入队列的尾部。
pop() -- 从队列首部移除元素。
peek() -- 返回队列首部的元素。
empty() -- 返回队列是否为空。
准备新旧两个栈,旧栈储存数据,新站倒序储存旧栈,旧栈进行操作时,新站同步更新,读取数据时不更新
var MyQueue = function() {
this.stack1=[]
this.stack2=[]
};
/**
* @param {number} x
* @return {void}
*/
MyQueue.prototype.push = function(x) {
this.stack1.push(x)
};
/**
* @return {number}
*/
MyQueue.prototype.pop = function() {
if(this.stack2.length<=0){
while(this.stack1.length){
this.stack2.push(this.stack1.pop())
}
}
return this.stack2.pop()
};
/**
* @return {number}
*/
MyQueue.prototype.peek = function() {
if(this.stack2.length<=0){
while(this.stack1.length){
this.stack2.push(this.stack1.pop())
}
}
const s=this.stack2.length
return s&&this.stack2[s-1]
};
/**
* @return {boolean}
*/
MyQueue.prototype.empty = function() {
return !this.stack1.length&&!this.stack2.length
};
/**
* Your MyQueue object will be instantiated and called as such:
* var obj = new MyQueue()
* obj.push(x)
* var param_2 = obj.pop()
* var param_3 = obj.peek()
* var param_4 = obj.empty()
*/
双端队列就是允许在队列的两端进行插入和删除的队列。
体现在编码上,最常见的载体是既允许使用 pop、push 同时又允许使用 shift、unshift 的数组:
const queue = [1,2,3,4] // 定义一个双端队列
queue.push(1) // 双端队列尾部入队
queue.pop() // 双端队列尾部出队
queue.shift() // 双端队列头部出队
queue.unshift(1) // 双端队列头部入队
最好是维护一个有效的递减数列
k
。如果元素个数小于k
,这意味着第一个滑动窗口内的元素都还没遍历完、第一个最大值还没出现,此时我们还不能动结果数组,只能继续更新队列;如果元素个数大于等于k
,这意味着滑动窗口的最大值已经出现了,此时每遍历到一个新元素(也就是滑动窗口每往前走一步)都要及时地往结果数组里添加当前滑动窗口对应的最大值(最大值就是此时此刻双端队列的队头元素)。/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
var maxSlidingWindow = function(nums, k) {
// 缓存数组的长度
const len = nums.length;
// 初始化结果数组
const res = [];
// 初始化双端队列
const deque = [];
// 开始遍历数组
for (let i = 0; i < len; i++) {
// 当队尾元素小于当前元素时
while (deque.length && nums[deque[deque.length - 1]] < nums[i]) {
// 将队尾元素(索引)不断出队,直至队尾元素大于等于当前元素
deque.pop();
}
// 入队当前元素索引(注意是索引)
deque.push(i);
// 当队头元素的索引已经被排除在滑动窗口之外时
while (deque.length && deque[0] <= i - k) {
// 将队头元素索引出队
deque.shift();
}
// 判断滑动窗口的状态,只有在被遍历的元素个数大于 k 的时候,才更新结果数组
if (i >= k - 1) {
res.push(nums[deque[0]]);
}
}
// 返回结果数组
return res;
};
①思想为“不撞南墙不回头”,只要没有碰壁,就决不选择其它的道路,而是坚持向当前道路的深处挖掘——像这样将“深度”作为前进的第一要素的搜索方法,就是所谓的“深度优先搜索”。深度优先搜索的核心思想,是试图穷举所有的完整路径。
②本质为栈结构,搜索时的前进与后退,和栈结构的入栈与出栈十分相似,在DFS中往往使用递归来模拟入栈和出栈的逻辑,在DFS中往往使用递归来模拟入栈和出栈的逻辑
③DFS与二叉树的遍历
首先函数调用的底层仍然是由栈来实现的,js会维护一个叫函数调用栈的东西,preorder每一次调用自己就会被push进函数调用栈中,执行完成后被pop出来。有一类情况会记录每一层递归式里路径的状态,因此需要依赖栈结构。
①广度优先搜索(BFS)并不执着于“一往无前”这件事情。它关心的是眼下自己能够直接到达的所有坐标,其动作有点类似于“扫描”,只会关注下一步的目标,不关心更远的目标。
②每访问完毕一个坐标,这个坐标在后续的遍历中都不会再被用到了,也就是说它可以被丢弃掉。站在某个确定坐标的位置上,我们所观察到的可直接抵达的坐标,是需要被记录下来的,因为后续的遍历还要用到它们。丢弃已访问的坐标,标记新观察到的坐标,符合“先进先出”的规则。
3.二叉树的层序遍历
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number[][]}
*/
var levelOrder = function(root) {
let res=[]
if(!root){
return res
}
let queue=[root]
while(queue.length){
let c=queue.length
let level=[]
for(i=0;i
2022.4.22
只要有重复的过程,都要想起递归
/**
* @param {number[]} nums
* @return {number[][]}
*/
var permute = function(nums) {
const len=nums.length
//用来记录当前的排列
const curr=[]
//用来记录所有的排列
const res=[]
let visited=new Array(len).fill(false)
function dfs(nth){
if(nth===len){
res.push([...curr])
return
}
//检查剩余数字
for(let i=0;i
visited作为记录本次的数字是否使用,标记使用过在结束时需要重置
[...curr]的用法不改变全局本身来赋值
const subsets = function(nums) {
// 初始化结果数组
const res = []
// 缓存数组长度
const len = nums.length
// 初始化组合数组
const subset = []
// 进入 dfs
dfs(0)
// 定义 dfs 函数,入参是 nums 中的数字索引
function dfs(index) {
// 每次进入,都意味着组合内容更新了一次,故直接推入结果数组
res.push([...subset])
// 从当前数字的索引开始,遍历 nums
for(let i=index;i
有时我们会去掉一些不符合题目要求的、没有作用的答案,进而得到正确答案。这个丢掉答案的过程,形似剪掉树的枝叶,所以这一方法被称为“剪枝”。
在这道题中,要做到剪枝,我们需要分别在组合问题的递归式和递归边界上动手脚:
k
个时,才会对组合结果数组进行更新。k
个,就不再继续当前的路径往下遍历,而是直接返回。var combine = function(n, k) {
const res=[]
const subset=[]
dfs(1)
function dfs(index){
if(subset.length===k){
res.push([...subset])
return
}
for(let i=index;i<=n;i++){
subset.push(i)
dfs(i+1)
subset.pop()
}
}
return res
};
看两个特征:
递归与回溯的过程,本身就是穷举的过程。题目中要求我们列举每一个解的内容,解从哪来?解是基于穷举思想、对搜索树进行恰当地剪枝后得来的。
这里需要大家注意到另一种问法:不问解的内容,只问解的个数。这类问题往往不用 DFS 来解,而是用动态规划(我们后面会学)。这里,大家先记下这个辨析,对以后做题会有帮助。
一个模型——树形逻辑模型;两个要点——递归式和递归边界。
树形逻辑模型的构建,关键在于找“坑位”,一个坑位就对应树中的一层,每一层的处理逻辑往往是一样的,这个逻辑就是递归式的内容。至于递归边界,要么在题目中约束得非常清楚、要么默认为“坑位”数量的边界。
function xxx(入参) {
前期的变量定义、缓存等准备工作
// 定义路径栈
const path = []
// 进入 dfs
dfs(起点)
// 定义 dfs
dfs(递归参数) {
if(到达了递归边界) {
结合题意处理边界逻辑,往往和 path 内容有关
return
}
// 注意这里也可能不是 for,视题意决定
for(遍历坑位的可选值) {
path.push(当前选中值)
处理坑位本身的相关逻辑
path.pop()
}
}
}
2022.4.23
经典命题方向:
递归的方式很简单
var preorderTraversal = function(root) {
let res=[]
if(!root){
return res
}
res.push(root.val)
res.push(...preorderTraversal(root.left))
res.push(...preorderTraversal(root.right))
return res
};
下面来看迭代的方式
var preorderTraversal = function(root) {
let res=[]
if(!root){
return res
}
const stack=[]
stack.push(root)
while(stack.length){
const cur=stack.pop()
res.push(cur.val)
if(cur.right){
stack.push(cur.right)
}
if(cur.left){
stack.push(cur.left)
}
}
return res
};
可以看到,前序遍历的规则是,先遍历根结点、然后遍历左孩子、最后遍历右孩子——这正是我们所期望的出栈序列。按道理,入栈序列和出栈序列相反,我们似乎应该按照 右->左->根
这样的顺序将结点入栈。但其实将根结点压入记录后直接出栈,后续先压入右结点在压入左结点即可完成先序遍历
push
进结果数组 从 res
结果数组上入手:我们可以直接把 pop
出来的当前结点 unshift
进 res
的头部,改造后的代码会变成这样:
const postorderTraversal = function(root) {
// 定义结果数组
const res = []
// 处理边界条件
if(!root) {
return res
}
// 初始化栈结构
const stack = []
// 首先将根结点入栈
stack.push(root)
// 若栈不为空,则重复出栈、入栈操作
while(stack.length) {
// 将栈顶结点记为当前结点
const cur = stack.pop()
// 当前结点就是当前子树的根结点,把这个结点放在结果数组的头部
res.unshift(cur.val)
// 若当前子树根结点有左孩子,则将左孩子入栈
if(cur.left) {
stack.push(cur.left)
}
// 若当前子树根结点有右孩子,则将右孩子入栈
if(cur.right) {
stack.push(cur.right)
}
}
// 返回结果数组
return res
};
递归方法同样很简单
var inorderTraversal = function(root) {
const res=[]
if(!root){return res}
res.push(...inorderTraversal(root.left))
res.push(root.val)
res.push(...inorderTraversal(root.right))
return res
};
关键是迭代方法,中序遍历中,根结点不再出现在遍历序列的边界、而是出现在遍历序列的中间。这就意味着无论如何我们不能再将根结点作为第一个被 pop
出来的元素来处理了——出栈的时机被改变了,这意味着入栈的逻辑也需要调整。这一次我们不能再通过对 res
动手脚来解决问题。需要直接对stack操作,中序遍历是左根右,首先要定位到最左的叶子结点,然后回溯父结点,再找兄弟结点。
var inorderTraversal = function(root) {
const res=[]
if(!root){return res}
const stack=[]
let cur=root
while(cur||stack.length){
while(cur){
stack.push(cur)
cur=cur.left
}
cur=stack.pop()
res.push(cur.val)
cur=cur.right
}
return res
};
while
:内层的 while
的作用是在寻找最左叶子结点的过程中,把途径的所有结点都记录到 stack
里。记录工作完成后,才会走到外层 while
的剩余逻辑里——这部分逻辑的作用是从最左的叶子结点开始,一层层回溯遍历左孩子的父结点和右侧兄弟结点,进而完成整个中序遍历任务。 while
的两个条件: cur
的存在性和stack.length
的存在性,各自是为了限制什么?
stack.length
的存在性比较好理解, stack
中存储的是没有被推入结果数组 res
的待遍历元素。只要 stack
不为空,就意味着遍历没有结束, 遍历动作需要继续重复。 cur
的存在性就比较有趣了。它对应以下几种情况:
cur
指向 root
结点,只要 root
不为空, cur
就不为空。此时判断了 cur
存在后,就会开始最左叶子结点的寻找之旅。这趟“一路向左”的旅途中, cur
始终指向当前遍历到的左孩子。 while
循环结束, cur
开始承担中序遍历的遍历游标职责。 cur
始终会指向当前栈的栈顶元素,也就是“一路向左”过程中途径的某个左孩子,然后将这个左孩子作为中序遍历的第一个结果元素纳入结果数组。假如这个左孩子是一个叶子结点,那么尝试取其右孩子时就只能取到 null
,这个 null
的存在,会导致内层循环 while
被跳过,接着就直接回溯到了这个左孩子的父结点,符合 左->根
的序列规则 cur
存在,于是进入内层 while
循环,重复“一路向左”的操作,去寻找这个右孩子对应的子树里最靠左的结点,然后去重复刚刚这个或回溯、或“一路向左”的过程。如果这个右孩子对应的子树里没有左孩子,那么跳出内层 while
循环之后,紧接着被纳入 res
结果数组的就是这个右孩子本身,符合 根->右
的序列规则102是从上到下,107是从下到上
var invertTree = function(root) {
if(!root){
return root
}
let right=invertTree(root.right)
let left=invertTree(root.left)
root.left=right
root.right=left
return root
};
2022.4.24
二叉搜索树强调的是数据域的有序性。也就是说,二叉搜索树上的每一棵子树,都应该满足
左孩子 <= 根结点 <= 右孩子
这样的大小关系。
function search(root, n) {
// 若 root 为空,查找失败,直接返回
if(!root) {
return
}
// 找到目标结点,输出结点对象
if(root.val === n) {
console.log('目标结点是:', root)
} else if(root.val > n) {
// 当前结点数据域大于n,向左查找
search(root.left, n)
} else {
// 当前结点数据域小于n,向右查找
search(root.right, n)
}
}
function insertIntoBST(root, n) {
// 若 root 为空,说明当前是一个可以插入的空位
if(!root) {
// 用一个值为n的结点占据这个空位
root = new TreeNode(n)
return root
}
if(root.val > n) {
// 当前结点数据域大于n,向左查找
root.left = insertIntoBST(root.left, n)
} else {
// 当前结点数据域小于n,向右查找
root.right = insertIntoBST(root.right, n)
}
// 返回插入后二叉搜索树的根结点
return root
}
使用递归:
递归出口:当前节点为空
递归中做的事:
若左子树为空,返回右子树
若右子树为空,返回左子树
左右子树都有值,拿到右子树的最左侧节点
将要删除的节点的左子树,移到右子树的最左边
返回右子树,代替当前节点
将当前节点返回上一级递归
它可以是一棵由根结点、左子树、右子树组成的树,同时左子树和右子树都是二叉搜索树,且左子树上所有结点的数据域都小于等于根结点的数据域,右子树上所有结点的数据域都大于等于根结点的数据域
var isValidBST = function(root) {
function dfs(root,min,max){
if(!root){return true}
if(root.val<=min||root.val>=max) {
return false
}
return dfs(root.left,min,root.val)&&dfs(root.right,root.val,max)
}
return dfs(root,-Infinity,Infinity)
};
var sortedArrayToBST = function(nums) {
if(!nums.length){return null}
const root=buildBST(0,nums.length-1)
function buildBST(low,high){
if(low>high){return null}
const mid=Math.floor(low+(high-low)/2)
const cur=new TreeNode(nums[mid])
cur.left=buildBST(low,mid-1)
cur.right=buildBST(mid+1,high)
return cur
}
return root
};
平衡二叉树(又称 AVL Tree)指的是任意结点的左右子树高度差绝对值都不大于1的二叉搜索树。
平衡二叉树的出现,是为了降低二叉搜索树的查找时间复杂度。平衡二叉树由于利用了二分思想,查找操作的时间复杂度仅为 O(logN)。因此,为了保证二叉搜索树能够确实为查找操作带来效率上的提升,我们有必要在构造二叉搜索树的过程中维持其平衡度,这就是平衡二叉树的来由。
对特性的考察(本节以平衡二叉树的判定为例)
对操作的考察(本节以平衡二叉树的构造为例)
var isBalanced = function(root) {
let flag=true
function dfs(root){
if(!root||!flag){
return 0
}
const left=dfs(root.left)
const right=dfs(root.right)
if(Math.abs(left-right)>1){
flag=false
return 0
}
return Math.max(left,right)+1
}
dfs(root)
return flag
};
var balanceBST = function(root) {
const nums=[]
function inorder(root){
if(!root){return}
inorder(root.left)
nums.push(root.val)
inorder(root.right)
}
function buildAVL(low,high){
if(low>high){return null}
const mid = Math.floor(low+(high-low)/2)
const cur = new TreeNode(nums[mid])
cur.left = buildAVL(low,mid-1)
cur.right = buildAVL(mid+1,high)
return cur
}
inorder(root)
return buildAVL(0,nums.length-1)
};
2022.4.25
完全二叉树中有着这样的索引规律:假如我们从左到右、从上到下依次对完全二叉树中的结点从0开始进行编码:那么对于索引为 n
的结点来说:
(n-1)/2
的结点是它的父结点 2*n+1
的结点是它的左孩子结点 2*n+2
的结点是它的右孩子结点堆是完全二叉树的特例,分为大顶堆和小顶堆。如果一个完全二叉树,他的每个结点的结点值都不小于其左右孩子的结点值就叫做大顶堆,相反就是小顶堆。
// 入参是堆元素在数组里的索引范围,low表示下界,high表示上界
function upHeap(low, high) {
// 初始化 i(当前结点索引)为上界
let i = high
// 初始化 j 为 i 的父结点
let j = Math.floor((i-1)/2)
// 当 j 不逾越下界时,重复向上对比+交换的过程
while(j>=low) {
// 若当前结点比父结点大
if(heap[j]
const findKthLargest = function(nums, k) {
// 初始化一个堆数组
const heap = []
// n表示堆数组里当前最后一个元素的索引
let n = 0
// 缓存 nums 的长度
const len = nums.length
// 初始化大小为 k 的堆
function createHeap() {
for(let i=0;iheap[0]) {
// 用较大数字替换堆顶数字
heap[0] = nums[i]
// 重复向下对比+交换的逻辑
downHeap(0, k)
}
}
}
// 向下对比函数
function downHeap(low, high) {
// 入参是堆元素在数组里的索引范围,low表示下界,high表示上界
let i=low,j=i*2+1
// 当 j 不超过上界时,重复向下对比+交换的操作
while(j<=high) {
// // 如果右孩子比左孩子更小,则用右孩子和根结点比较
if(j+1<=high && heap[j+1] heap[j]) {
// 交换位置
const temp = heap[j]
heap[j] = heap[i]
heap[i] = temp
// i 更新为被交换的孩子结点的索引
i=j
// j 更新为孩子结点的左孩子的索引
j=j*2+1
} else {
break
}
}
}
// 入参是堆元素在数组里的索引范围,low表示下界,high表示上界
function upHeap(low, high) {
// 初始化 i(当前结点索引)为上界
let i = high
// 初始化 j 为 i 的父结点
let j = Math.floor((i-1)/2)
// 当 j 不逾越下界时,重复向上对比+交换的过程
while(j>=low) {
// 若当前结点比父结点小
if(heap[j]>heap[i]) {
// 交换当前结点与父结点,保持父结点是较小的一个
const temp = heap[j]
heap[j] = heap[i]
heap[i] = temp
// i更新为被交换父结点的位置
i=j
// j更新为父结点的父结点
j=Math.floor((i-1)/2)
} else {
break
}
}
}
// 插入操作=将元素添加到堆尾部+向上调整元素的位置
function insert(x) {
heap[n] = x
upHeap(0, n)
n++
}
// 调用createHeap初始化元素个数为k的队
createHeap()
// 调用updateHeap更新堆的内容,确保最后堆里保留的是最大的k个元素
updateHeap()
// 最后堆顶留下的就是最大的k个元素中最小的那个,也就是第k大的元素
return heap[0]
};
912排序题
遍历,遇到比后面大的就交换顺序
function bubbleSort(arr) {
// 缓存数组长度
const len = arr.length
// 外层循环用于控制从头到尾的比较+交换到底有多少轮
for(let i=0;i arr[j+1]) {
// 交换两者
[arr[j], arr[j+1]] = [arr[j+1], arr[j]]
}
}
}
// 返回数组
return arr
}
在范围内选出最小值将其和范围首部互换
function selectSort(arr) {
// 缓存数组长度
const len = arr.length
// 定义 minIndex,缓存当前区间最小值的索引,注意是索引
let minIndex
// i 是当前排序区间的起点
for(let i = 0; i < len - 1; i++) {
// 初始化 minIndex 为当前区间第一个元素
minIndex = i
// i、j分别定义当前区间的上下界,i是左边界,j是右边界
for(let j = i; j < len; j++) {
// 若 j 处的数据项比当前最小值还要小,则更新最小值索引为 j
if(arr[j] < arr[minIndex]) {
minIndex = j
}
}
// 如果 minIndex 对应元素不是目前的头部元素,则交换两者
if(minIndex !== i) {
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]
}
}
return arr
}
插入排序的核心思想是“找到元素在它前面那个序列中的正确位置”。
具体来说,插入排序所有的操作都基于一个这样的前提:当前元素前面的序列是有序的。基于这个前提,从后往前去寻找当前元素在前面那个序列里的正确位置。
function insertSort(arr) {
// 缓存数组长度
const len = arr.length
// temp 用来保存当前需要插入的元素
let temp
// i用于标识每次被插入的元素的索引
for(let i = 1;i < len; i++) {
// j用于帮助 temp 寻找自己应该有的定位
let j = i
temp = arr[i]
// 判断 j 前面一个元素是否比 temp 大
while(j > 0 && arr[j-1] > temp) {
// 如果是,则将 j 前面的一个元素后移一位,为 temp 让出位置
arr[j] = arr[j-1]
j--
}
// 循环让位,最后得到的 j 就是 temp 的正确索引
arr[j] = temp
}
return arr
}
归并排序是对分治思想的典型应用,它按照如下的思路对分治思想“三步走”的框架进行了填充:
快速排序会将原始的数组筛选成较小和较大的两个子数组,然后递归地排序两个子数组。
nums.sort((a,b)=>{
return a-b //从小到大排序
})