栈与队列两兄弟--小册专项练习笔记

209 阅读5分钟

这是我参与更文挑战的第4天,活动详情查看: 更文挑战

栈与队列

有效括号问题

代码实现

const leftToRight = {
  "(": ")",
  "[": "]",
  "{": "}"
};
function isValid(s) {
  // 如果s为空直接返回
  if(!s) return
  // 定义一个空栈
  const stack = []
  // 判断一下总的长度
  const len = s.length
  // 遍历
  for(let i = 0; i < len; i++) {
    // 缓存单个字符
    const ch = s[i]
    // 判断当前这个字符是否是左侧括号
    if(ch === "(" || ch === "{" || ch === "[" ) {
      // 如果是,放入栈中
      stack.push(leftToRight[ch])
    }else {
      // 如果栈为空,或者栈不为空但当前元素不和栈顶部的元素匹配,直接返回false
      if(!stack.length || stack.pop() !== ch) { 
        // stack.pop(),其实就是上一次放入的leftToRight[ch]
        return false
      }
    }
  }
  // 如果遍历结束,那么stack的length应该为0
  return !stack.length
}
const flag = isValid("({[]]})")
console.log(flag); // true

每日温度问题

这道题目理解起来有些难度,我一开始想的是不太一样,所以思路上出了些问题,那么最简单直观的办法就是画图,所以大家自己也可以试试用自己的方法去理解

temperature.png

代码实现:

function temperaturesDays(arr) {
    if(!arr) return []
    const stack = []
    let higherDays = (new Array(arr.length)).fill(0) // 初始化一个全为0的数组对应升温天数
    for (let i = 0; i < arr.length; i++) {
      // 栈里面存放的是一个递减的趋势,如果当前栈不为空,
      // 并且当前元素比栈顶的元素还高,那么就打破了趋势,
      // 就可以一次算出各个温度被打破的天数,并且这是一个
      // while循环,会将当天的温度和每次pop后的stack的
      // 栈顶温度比较,具体的大家看我画的图就能明白了
      while(stack.length && arr[i] > arr[stack[stack.length-1]]){
        const top = stack.pop()
        higherDays[top] = i - top
      }
      stack.push(i)
    }
    return higherDays
}
let arr = [73, 74, 75, 71, 69, 72, 76, 73]
let arr1 = temperaturesDays(arr)
console.log(arr1);

最小栈的问题

  • 传统解法

时间复杂度O(n)

// 定义一个构造函数
const MinStack = function() {
    this.stack = []
}
// 往prototype上面增加方法,这几个方法之前都说过
MinStack.prototype.push = function(x) {
    this.stack.push(x)
}

MinStack.prototype.pop = function(x) {
    this.stack.pop(x)
}

MinStack.prototype.top = function() {
    // 边界判断
    if(!this.stack || !this.stack.length) {
          return
    }
    return this.stack[this.stack.length - 1]
}

MinStack.prototype.getMin = function() {
    // 定义一个最小值,然后遍历比较最后返回
    const minValue = Infinity
    const len = this.stack.length
    for(let i = 0; i< len; i++) {
      if(this.stack[i] < minValue) {
        minValue = this.stack[i]
      }
    }
    return minValue
}
  • 用空间换时间 时间复杂度O(1)

解题思路:我们用一个辅助栈stack2来帮助我们解决最小值的问题,每次我们push元素的时候,假如stack2为空或者该元素小于等于stack2中的栈顶元素,那么我们就把这个元素放入stack2中,这样就形成了stack2中的元素是一个递减的关系,并且始终存在stack1中的最小的元素,然后pop的时候,假如stack2中的栈顶元素与stack1中一致,那么也要pop出去,否则不必,最后我们获取最小元素的时候,只需要返回stack2中的栈顶元素即可,非常方便无需遍历

const MinStack = function() {
    this.stack = []
    this.stack2 = []
}

MinStack.prototype.push = function(x) {
    this.stack.push(x)
    // push的时候将这个数与栈顶的数相比,如果小就可以入栈2,如果大就能入
    if(!this.stack2) {
      this.stack2.push(x)
    }else {
      if(x <= this.stack2[this.stack2.length-1]) {
        this.stack2.push(x)
      }
    }
}

MinStack.prototype.pop = function(x) {
    this.stack.pop(x)
    if(x === this.stack2[this.stack2.length -1]) {
      this.stack2.pop(x)
    }
}

MinStack.prototype.top = function() {
    if(!this.stack || !this.stack.length) {
      return
    }
    return this.stack[this.stack.length - 1]
}

MinStack.prototype.getMin = function() {
    // 因为在push和pop的时候,我们已经对stack2进行了一个递减栈的处理,
    // 也就是说目前stack2中的元素是递减的并且不存在比stack2的栈底元素更大的元素
    // 那么我们获取最小值的时候,只需要返回stack2的栈顶元素就行了
    return this.stack2[this.stack2.length-1]
}

队列

如何用栈实现一个队列

栈和队列很相似,但是又完全相反,一个是先进后出,一个是先进先出,所以如果我们试图用一个栈去实现一个队列,那是不可能的,那么如果用两个栈呢?

使用栈实现队列的下列操作:
push(x) -- 将一个元素放入队列的尾部。
pop() -- 从队列首部移除元素。
peek() -- 返回队列首部的元素。
empty() -- 返回队列是否为空。
说明:
 你只能使用标准的栈操作 -- 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。
 你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。
 假设所有操作都是有效的 (例如,一个空的队列不会调用 pop 或者 peek 操作)。

根据上述我们所说的思路,用两个栈去实现一个队列,关键就在于我们怎么把已经入队列的元素,能够pop出去,那就是借用辅助栈,把元素先pop然后在push到辅助栈中,这样里面元素的顺序也就是相反了,即使原来的栈有新元素进去,那也不影响操作,因为如果要pop的话,目前辅助栈中的元素肯定是先出去的,才会轮到后来进入原栈的元素,其他的操作都是在一个栈的基础上可以完成,那我们来看下代码:

const MyQueue = function() {
    this.stack1 = []
    this.stack2 = []
}
// push
MyQueue.prototype.push = function(x) {
    this.stack1.push(x)
}
//pop
MyQueue.prototype.pop = function() {
    // 如果stack2为空
    if(!this.stack2.length) {
      // 就把stack1中的元素pop出去并且push到stack2中
      while(this.stack1.length) {
        this.stack2.push(this.stack1.pop())
      }
    }
    // 返回stack的栈顶元素
    return this.stack2.pop()
}
// peek
MyQueue.prototype.peek = function() {
    // 这里有两种思路,
    // 1:就是我下面写的,如果2中还有元素,那么就返回2的栈顶元素,如果2中已经空了,就返回1中的栈底元素
    // 2.就是和pop一样,把所有1中的元素全部挪到2中去,返回2中的栈顶元素就可以了
    if(this.stack2.length) {
      return this.stack2[this.stack2.length -1]
    }else {
      return this.stack1[0]
    }
}
// empty
MyQueue.prototype.empty = function() {
    // 空的时候只要返回两者的长度都为0即可
    return !this.stack1.length && !this.stack2.length
}
const queue = new MyQueue()
queue.push(1)
queue.push(2)
queue.push(5)
console.log(queue.pop()); // 1
console.log(queue.pop()); // 2
console.log(queue.peek()); // 5
queue.push(7)
console.log(queue.pop()); // 5
queue.push(6)
queue.push(1)
queue.push(4)
console.log(queue.peek()); // 7

滑动窗口的问题

这里首先也要做一个知识铺垫,我们要认识一个新的队列名词,双端队列----双端队列顾名思义就是允许在队列两端进行插入和删除的队列,最常见的表现就是既能使用pop和push,又支持shift和unshift的数组。

了解完之后,我们开始进入真题:

题目描述:

示例:
输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3 输出: [3,3,5,5,6,7]
解释: 滑动窗口的位置
[1 3 -1] -3 5 3 6 7
1 [3 -1 -3] 5 3 6 7
1 3 [-1 -3 5] 3 6 7
1 3 -1 [-3 5 3] 6 7
1 3 -1 -3 [5 3 6] 7
1 3 -1 -3 5 [3 6 7]
最大值分别对应:
3 3 5 5 6 7
提示:你可以假设 k 总是有效的,在输入数组不为空的情况下,1 ≤ k ≤ 输入数组的大小

题目看完了,其实我们可以这样理解,就是k就是窗口的大小,然后遍历整个数组,即然用到遍历的话,不能忘了双指针,所以我们直接上代码:

const maxSlidingWindow = function (nums, k) {
  // 缓存数组的长度
  const len = nums.length;
  // 定义结果数组
  const res = [];
  // 初始化左指针
  let left = 0;
  // 初始化右指针
  let right = k - 1;
  // 当数组没有被遍历完时,执行循环体内的逻辑
  while (right < len) {
    // 计算当前窗口内的最大值
    const max = calMax(nums, left, right);
    // 将最大值推入结果数组
    res.push(max);
    // 左指针前进一步
    left++;
    // 右指针前进一步
    right++;
  }
  // 返回结果数组
  return res;
};

// 这个函数用来计算最大值
function calMax(arr, left, right) {
  // 处理数组为空的边界情况
  if (!arr || !arr.length) {
    return;
  }
  // 初始化 maxNum 的值为窗口内第一个元素
  let maxNum = arr[left];
  // 遍历窗口内所有元素,更新 maxNum 的值
  for (let i = left; i <= right; i++) {
    if (arr[i] > maxNum) {
      maxNum = arr[i];
    }
  }
  // 返回最大值
  return maxNum;
}

但是大家有没有发现一个问题,就是目前的时间复杂度为O(kn),而且我们目前在什么分类下,可不就是双端队列么?那我们可不得用双端队列来解这道题目,让面试官好好看看!

那既然我们说到了双端队列,那在这道题目中,怎么用的,我们首先来看一下我们的思路:

  • 首先在队列没有元素的情况下,我们将数组元素的下标放入队列

  • 在队列中有元素的情况下,我们每遍历一次就将当前元素和队列中的尾部对应的元素相比较,如果当前元素更大,则pop掉队列中的尾部元素,再进行比较,直到队列为空或者尾部元素对应的数组元素大于当前元素,然后将当前元素的下标push进队列中,我们的目的就是保持队列的递减,这样我们始终可以得到队列的头部元素所对应的数组元素就是当前窗口的最大值

  • 处理完元素,我们再对头部进行一个判断,当前队列头部的下标是不是还在范围内,这个范围就是i-k,如果头部下标等于这个范围了,证明他已经不在当前窗口了,此时需要将它shift掉,才能始终保证队列中存着的是当前窗口元素的下标

  • 最后,当我们遍历超过k的次数之后,我们才能得到第一个窗口,将队列头部对应的数组元素push到结果数组中,之后每一次循环都要重复这个动作,遍历结束之后,得到的结果数组就是最终的滑动窗口结果

当然啦,纯靠文字好像还蛮抽象的,老规矩,上图👇!

slideWin.png

看完了思路,我们用代码来实现一下:

const maxSlidingWindow = function(arr, k) {
    // 定义数组长度
    const len = arr.length
    // 定义返回结果数组
    let res = []
    // 定义一个队列
    let queue = []
    // 对数组进行循环遍历
    for(let i = 0; i < len; i++) {
      // 1.对尾部添加的元素进行判断
      // 对数组的元素进行比较,如果后一个元素大于前一个元素,就将队列中现有元素的下标pop出来
      // 直到后一个元素小于队列中现有的下标对应的元素
      // 目的是为了维持队列中下标对应的元素是一个递减趋势
      while(queue.length && arr[queue[queue.length-1]] < arr[i]) {
        queue.pop()
      }
      // 然后将当前的下标放入队列中
      queue.push(i)
      // 2.对头部要及时剔除的前一个窗口的元素判断
      if(queue[0] <= i - k) {
        queue.shift()
      }

      // 判断滑动窗口的状态,只有在被遍历的元素个数大于k的时候,才更新结果数组
      // 即只有超过k的遍历次数才能形成第一个窗口,往后每次遍历都能增加一个元素
      if(i >= k -1) {
        res.push(nums[queue[0]])
      }
    }

    return res
}
let arr = [1,3,-1,-3,5,3,6,7]
let res = maxSlidingWindow(arr, 3)
console.log(res) // [3,3,5,5,6,7]

两种基本算法思想

深度优先搜索(Depth First Search--DFS)

不撞南墙不回头的“迷宫游戏”

这个游戏其实我们都玩过,以前我们认为这就是一个运气游戏,因为我们也不知道哪一条路能走到最后,其实这个游戏的本质是一个穷举的游戏,我们每次选择一条路除非碰壁,否则坚决不往回走,这也是这个游戏的原则:坚持向当前道路的深处挖掘——像这样将“深度”作为前进的第一要素的搜索方法,就是所谓的“深度优先搜索”

而作为深度优先的本质,其实就是我们所熟悉的栈结构,怎么理解呢?---我们将每一个岔路口都作为结点,我们学过二叉树的遍历,我们只有对当前结点没有下一个子结点的时候,我们才算走完了当前的树,然后在去遍历他最近的兄弟结点,是不是和我们只有当前岔路口的路走到碰壁了,才退回到最近的岔路口,然后重新走是一样的思想。

有的同学说了,这不是二叉树吗,又和栈有什么关系呢?这不是用递归来解决问题吗,又和栈思想有什么关系呢?

  • 首先函数调用的底层就是由栈来实现的,js又一个“函数调用栈”的东西,递归函数每一次调用自己,相关的调用上下文就会被push进函数调用栈,执行完毕后又会被pop出来。

  • DFS作为一种思想,虽然在树的遍历上面有用到,但是并不完全相等,他在很多要求记录每一层递归式里路径的状态的题目中,就会抢以来栈结构

广度优先搜索(Breadth First Search--BFS)

二叉树的层序遍历

说完DFS,我们在来说说广度优先搜索BFS,其实也不难理解,DFS是一条路走到黑,那BFS呢?其实是每次层都走遍,实际上和队列有着密不可分的关系,怎么理解呢?队列中,你先将当前结点入队,访问完A之后,将A出队,然后再将B入队,之后每次便利完一个岔路口,就将其子岔路口放入队列中,其是和我们之前学的二叉树的第四种遍历方式---层序遍历很像,其实就是运用了BFS的这种思想,忘了的同学,自行回去复习一下。