数据结构算法

153 阅读8分钟

二叉树

前序遍历

const root = {
  val: "A",
  left: {
    val: "B",
    left: {
      val: "D"
    },
    right: {
      val: "E"
    }
  },
  right: {
    val: "C",
    right: {
      val: "F"
    }
  }
};

递归

function preorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
    // 递归遍历左子树 
    preorder(root.left)  
    // 递归遍历右子树  
    preorder(root.right)
}

image.png

顺序 a b d e c f

中序遍历

image.png

function inorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 递归遍历左子树 
    inorder(root.left)  
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
    // 递归遍历右子树  
    inorder(root.right)
}

后序遍历

image.png

function inorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 递归遍历左子树 
    inorder(root.left)     
    // 递归遍历右子树  
    inorder(root.right)
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  

}

深度优先(DFS)和广度优先(BFS)

图中蓝色的是入口,灰色的是岔路口,黑色的是死胡同,绿色的是出口。

基于眼前的这个迷宫结构,我们来一步一步模拟一下深度优先搜索的具体过程:

  1. 从 A 出发,沿着唯一的一条道路往下走,遇到了第1个岔路口B。眼前有三个选择:CDE。这里我按照从上到下的顺序来走(你也可以按照其它顺序),先走C
  2. 发现 C是死胡同,后退到最近的岔路口 B,尝试往D方向走。
  3. 发现D 是死胡同,,后退到最近的岔路口 B,尝试往E方向走。
  4. E 是一个岔路口,眼前有两个选择:F 和 G。按照从上到下的顺序来走,先走F
  5. 发现F 是死胡同,后退到最近的岔路口 E,尝试往G方向走。
  6. G 是一个岔路口,眼前有两个选择:H 和 I。按照从上到下的顺序来走,先走H
  7. 发现 H 是死胡同,后退到最近的岔路口 G,尝试往I方向走。
  8. I 就是出口,成功走出迷宫。 在这个递归函数中,递归式用来先后遍历左子树、右子树(分别探索不同的道路),递归边界在识别到结点为空时会直接返回(撞到了南墙)。因此,我们可以认为,递归式就是我们选择道路的过程,而递归边界就是死胡同。二叉树的先序遍历正是深度优先搜索思想的递归实现。可以说深度优先搜索过程就类似于树的先序遍历、是树的先序遍历的推广

与深度优先搜索不同的是,广度优先搜索(BFS)并不执着于“一往无前”这件事情。它关心的是眼下自己能够直接到达的所有坐标,其动作有点类似于“扫描” ——比如说站在 B 这个岔路口,它会只关注 CDE 三个坐标,至于 FGHI这些遥远的坐标,现在不在它的关心范围内:

只有在走到了 E处时,它发现此时可以触达的坐标变成了 FG,此时才会去扫描FG

按照这个思路,广度优先搜索每次以“广度”为第一要务、雨露均沾,一层一层地扫描,最后也能够将所有的坐标扫描完全:

当扫描到 I 的时候,发现 I 是出口,照样能够找到答案。

按照 BFS 的遍历规则,具体的访问步骤会变成下面这样:

  1. 站在入口A处(第一层),发现直接能抵达的坐标只有B,于是接下来需要访问的就是 B
  2. 入口A访问完毕,走到 B 处(第二层),发现直接能抵达的坐标变成了CDE,于是把这三个坐标记为下一层的访问对象。
  3. B访问完毕,访问第三层。这里我按照从上到下的顺序(你也可以按照其它顺序),先访问 CD,然后访问E。站在C处和D处都没有见到新的可以直接抵达的坐标,所以不做额外的动作。但是在E处见到了可以直接抵达的FG,因此把FG记为下一层(第四层)需要访问的对象。
  4. 第三层访问完毕,访问第四层。第四层按照从上到下的顺序,先访问的是 F。从F出发没有可以直接触达的坐标,因此不做额外的操作。接着访问G,发现从G出发可以直接抵达HI,因此把HI记为下一层(第五层)需要访问的对象。
  5. 第四层访问完毕,访问第五层。第五层按照从上到下的顺序,先访问的是H,发现从H出发没有可以直接抵达的坐标,因此不作额外的操作。接着访问I,发现I就是出口,问题得解。

BFS实战:二叉树的层序遍历

image.png

    const queue = [] // 初始化队列queue
    // 根结点首先入队
    queue.push(root)
    // 队列不为空,说明没有遍历完全
    while(queue.length) {
        const top = queue[0] // 取出队头元素  
        // 访问 top
        console.log(top.val)
        // 如果左子树存在,左子树入队
        if(top.left) {
            queue.push(top.left)
        }
        // 如果右子树存在,右子树入队
        if(top.right) {
            queue.push(top.right)
        }
        queue.shift() // 访问完毕,队头元素出队
    }
}

代码思路,先放根节点进队列,然后放left,right,这样队列顺序为头,左右,这是两层,然后root出队列,此时的top就是第二层左了,重复上述操作后top就是第二层右了

二叉树真题解读

关于二叉树的输入

题目一般这样给输入

输入: [1,null,2,3] 输入看似是一个数组,实则不是。大家谨记,二叉树题目的输入只要没有额外强调,那么一般来说它都是基于这样的一个对象结构嵌套而来的:   

function TreeNode(val) {
    this.val = val;
    this.left = this.right = null;
}

这其实是一种简化的写法,性质跟咱们写伪代码差不多。它的作用主要是描述二叉树的值,至于二叉树的结构,我们以题中给出的树形结构为准:  

1
  \
   2
  /
3

OK,了解了输入内容,现在再来看输出:  

[1,2,3]

这里这个输出就简单多了,它是一个真数组。为什么可以判断它是一个真数组呢?因为题目中要求你输出的是一个遍历序列,而不是一个二叉树。因此大家最后需要塞入结果数组的不是结点对象,而是结点的值

栈结构前序遍历(DFS)

前序遍历的规则是,先遍历根结点、然后遍历左孩子、最后遍历右孩子——这正是我们所期望的出栈序列。按道理,入栈序列和出栈序列相反,我们似乎应该按照 右->左->根 这样的顺序将结点入栈。不过需要注意的是,我们遍历的起点就是根结点,难道我们要假装没看到这个根结点、一鼓作气找到最右侧结点之后才开始进行入栈操作吗?答案当然是否定的,我们的出入栈顺序应该是这样的:  

  1. 将根结点入栈 
  2. 取出栈顶结点,将结点值 push 进结果数组 
  3. 若栈顶结点有右孩子,则将右孩子入栈
  4. 若栈顶结点有左孩子,则将左孩子入栈


这整个过程,本质上是将当前子树的根结点入栈、出栈,随后再将其对应左右子树入栈、出栈的过程。

重复 2、3、4 步骤,直至栈空,我们就能得到一个先序遍历序列。    

编码实现  

/**
 * @param {TreeNode} root
 * @return {number[]}
 */
const preorderTraversal = function(root) {
  // 定义结果数组
  const 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
};

动态规划

题目描述:给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例1:
输入: coins = [1, 2, 5], amount = 11

输出: 3
解释: 11 = 5 + 5 + 1

示例2:
输入: coins = [2], amount = 3

输出: -1

提示:最值问题是动态规划的常见对口题型,见到最值问题,应该想到动态规划