又是一年轰轰烈烈金三银四——让算法和数据结构不再是你的软肋(中)

前言

接上文:又是一年轰轰烈烈金三银四------让算法和数据结构不再是你的软肋(上) - 掘金 (juejin.cn)

因为算法考察的知识点特别的广,一篇文章难以涵盖(所以将其分为上中下三篇文章进行阐述),在上文中已经阐述数组(二分,双指针)、链表、队列、栈相关的算法考察点,在这一篇文章中我们继续阐述树和堆相关的算法考察点,以及深度优先遍历,广度优先遍历和图的一些算法及排序算法的应用。

本文主要涵盖的范围是二叉树的遍历,树的构建常见考点,考察二叉搜索树性质的考点,堆的常见考点,排序算法常见的考点,以及图论篇可能的考点,本文的内容相当的长,大家可以直接根据目录索引关注您感兴趣的内容即可,不可能在短时间内掌握,在复习的时候针对其阐述的知识点各个击破就好。

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️

数组和链表能够处理的业务场景是有限的,实际开发中我们经常会遇到权限树,设备树,等级制度树,部门树等业务场景,所以越来越多的公司也喜欢考察树相关的知识点。

总的来说,前端需要掌握的树的知识点不难(树有很多,难的前端不会接触到,如AVL树(平衡二叉树),红黑树234树伸展树,外排序里面的败者树,数据库里面的B树B+树B-树)等),前端能够熟练的能够对树进行遍历和在树节点上进行一些操作对于面试和实际开发就已经足够了。

二叉树

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️

二叉树的遍历是面试中常考的终点,和二分查找拥有一个程度重要指数。

先序:根左右

中序:左根右

后序:左右根

递归遍历

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️

因为递归遍历特别简单,所以面试官不会给你这样简单的空子钻,一般会结合一些树上的操作进行。

先序递归:

js 复制代码
/**
 * 先序递归遍历二叉树
 * @param {TreeNode<number>} tree
 */
function treePreOrderRecursion(tree) {
  // 如果树空,则完成遍历
  if (!tree) {
    console.log("tree empty!");
    return;
  }
  // 打印当前节点的值
  console.log(tree.data);
  // 如果左子树存在,递归遍历左子树
  tree.left && treePreOrderRecursion(tree.left);
  // 如果右子树存在,递归遍历右子树
  tree.right && treePreOrderRecursion(tree.right);
}

中序递归:

js 复制代码
/**
 * 中序递归遍历二叉树
 * @param {TreeNode<number>} tree 树的根节点
 */
function treeInOrderTraverseRecursion(tree) {
  if (!tree) {
    console.log("empty tree");
    return;
  }
  // 和先序递归遍历区别仅体现在输出的时机不同,因为代码的顺序会导致调用堆栈的循序的改变,因此可以完成中序遍历
  tree.left && treeInOrderTraverseRecursion(tree.left);
  console.log(tree.data);
  tree.right && treeInOrderTraverseRecursion(tree.right);
}

后续递归:

js 复制代码
/**
 * 后序递归遍历二叉树
 * @param {TreeNode} tree 树的根节点
 */
function treePostTraverseRecursion(tree) {
  if (!tree) {
    console.log("empty tree");
    return;
  }
  // 和先序递归遍历区别仅体现在输出的时机不同,因为代码的顺序会导致调用堆栈的循序的改变,因此可以完成后序遍历
  tree.left && treePostTraverseRecursion(tree.left);
  tree.right && treePostTraverseRecursion(tree.right);
  console.log(tree.data);
}

非递归遍历

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️

采用DFS的非递归遍历方式目前我没有在实际的面试中遇到过,不过还是可以掌握,因为难度较低,可以备不时之需。

二叉树的前序遍历(DFS,非递归) ⭐️⭐️

js 复制代码
/**
 * 先序非递归遍历二叉树
 * @param {TreeNode<number>} tree
 */
function treePreOrder(tree) {
  if (!tree) {
    console.log("empty tree!");
    return;
  }
  // 定义一个栈用于模拟系统提供的堆栈
  let stack = [];
  // 让node指向树的跟节点,准备开始遍历
  let node = tree;
  // 如果树不空,或者栈中还有内容,则应该继续进行遍历
  while (stack.length > 0 || node) {
    // 如果node节点不为空的话,不断的向左压栈,直到为空
    while (node) {
      stack.push(node);
      console.log(node.data);
      node = node.left;
    }
    // 向左走到头了,若当前栈中还有内容,则从栈中取出一个内容,从当前内容的右子树继续遍历
    if (stack.length > 0) {
      node = stack.pop();
      node = node.right;
    }
  }
}

二叉树的中序遍历(DFS,非递归) ⭐️⭐️

js 复制代码
/**
 * 二叉树非递归中序遍历
 * @param {TreeNode<number>} tree 树的根节点
 */
function treeInOrderTraverse(tree) {
  if (!tree) {
    console.log("empty tree");
    return;
  }
  let stack = [];
  let node = tree;
  while (stack.length > 0 || node) {
    // 压栈的时候不能立即输出节点
    while (node) {
      stack.push(node);
      node = node.left;
    }
    if (stack.length > 0) {
      // 当从栈中取出节点时,方可以输出节点,接着再从当前节点的右子树进行遍历
      node = stack.pop();
      console.log(node.data);
      node = node.right;
    }
  }
}

二叉树的后序遍历(DFS,非递归,双栈法) ⭐️⭐️⭐️

js 复制代码
/**
 * 二叉树非递归后序遍历
 * @param {TreeNode} tree 树的根节点
 */
function treePostTraverse(tree) {
  if (!tree) {
    console.log("empty tree");
    return;
  }
  // 栈1用于遍历
  let stack1 = [];
  // 栈2用于保持输出顺序
  let stack2 = [];
  let node = tree;
  stack1.push(node);
  while (stack1.length > 0) {
    node = stack1.pop();
    // 将根节点加入栈2,先加入的后输出
    stack2.push(node);
    // 如果左子树存在,将左子树节点加入到栈1中
    if (node.left != null) {
      stack1.push(node.left);
    }
    // 如果右子树存在,将右子树节点加入到栈1中
    if (node.right != null) {
      stack1.push(node.right);
    }
    /**
     * 因为先加入栈1的节点,会后输出,但是再加入栈2,又会先输出,所以这儿要先处理左子树,才能处理右子树
     */
  }
  while (stack2.length > 0) {
    let tempNode = stack2.pop();
    console.log(tempNode.data);
  }
}

二叉树的层序遍历(BFS) ⭐️⭐️⭐️⭐️⭐️

这个算法我面试遇到过好几次,大家一定要会写。

js 复制代码
/**
 * 二叉树的层序遍历
 * @param {TreeNode} tree 树的根节点
 */
function treeLevelTraverse(tree) {
  if (!tree) {
    console.log("empty tree");
    return;
  }
  let node = tree;
  let list = [];
  // 将跟节点入队
  list.push(node);
  // 如果队列不为空,则进行遍历
  while (list.length > 0) {
    // 从队首取出一个元素用以处理
    const curNode = list.shift();
    console.log(curNode.data);
    // 如果存在左子树,将左子树入队
    if (curNode.left) {
      list.push(curNode.left);
    }
    // 如果存在右子树,将右子树入队
    if (curNode.right) {
      list.push(curNode.right);
    }
  }
  /**
   * 因为队列先入先出的特性,所以最后的打印顺序总是按层从上至下,每层从左到右的顺序输出
   */
}

Morris遍历法

重要指数:⭐️

难度指数:⭐️⭐️⭐️⭐️⭐️

在之前聊到的二叉树的遍历方式,都会有一个额外的空间复杂度,不管是递归还是非递归的变量,始终都会有一个O(H)的空间复杂度消耗,H为树的高度,如果二叉树退化至链表,那么空间复杂度就是O(N)

Morris遍历法是一种完全不同于传统二叉树遍历法的算法,Morris遍历不需要消耗额外的空间复杂度,它通过修改了二叉树节点的空指针,能够聪明的知道左子树遍历完成之后下一步的遍历节点,然后遍历完成之后又将空指针还原成最初的样子,有兴趣的同学可以掌握,如果对于仅仅是准备算法面试的同学了解即可。

Morris先序 ⭐️⭐️⭐️⭐️⭐️

js 复制代码
function morrisPreOrderTraversal(root) {
    let current = root;
    let result = [];

    while (current != null) {
        if (current.left == null) {
            result.push(current.value);
            current = current.right;
        } else {
            let predecessor = current.left;
            while (predecessor.right != null && predecessor.right != current) {
                predecessor = predecessor.right;
            }

            if (predecessor.right == null) {
                result.push(current.value);
                predecessor.right = current;
                current = current.left;
            } else {
                predecessor.right = null;
                current = current.right;
            }
        }
    }

    return result;
}

Morris中序 ⭐️⭐️⭐️⭐️⭐️

js 复制代码
function morrisInOrderTraversal(root) {
    let current = root;
    let result = [];

    while (current != null) {
        if (current.left == null) {
            result.push(current.value);
            current = current.right;
        } else {
            let predecessor = current.left;
            while (predecessor.right != null && predecessor.right != current) {
                predecessor = predecessor.right;
            }

            if (predecessor.right == null) {
                predecessor.right = current;
                current = current.left;
            } else {
                predecessor.right = null;
                result.push(current.value);
                current = current.right;
            }
        }
    }

    return result;
}

Morris后序 ⭐️⭐️⭐️⭐️⭐️

js 复制代码
function reversePath(start, end) {
    let prev = null;
    let current = start;
    let next;

    while (current != end) {
        next = current.right;
        current.right = prev;
        prev = current;
        current = next;
    }

    return prev;
}

function morrisPostOrderTraversal(root) {
    let dummy = { left: root };
    let current = dummy;
    let result = [];

    while (current != null) {
        if (current.left == null) {
            current = current.right;
        } else {
            let predecessor = current.left;
            while (predecessor.right != null && predecessor.right != current) {
                predecessor = predecessor.right;
            }

            if (predecessor.right == null) {
                predecessor.right = current;
                current = current.left;
            } else {
                let start = current;
                let end = current.left;
                reversePath(end, null);
                while (true) {
                    result.push(end.value);
                    if (end == start) break;
                    end = end.right;
                }
                reversePath(start, null);
                predecessor.right = null;
                current = current.right;
            }
        }
    }

    return result;
}

二叉搜索树

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️

二叉搜索树利用了二分查找的思想,其规定左子树的所有节点均小于根节点,右子树的所有节点值均大于根节点。这样我们在查找的时候,就可以实现仅仅在一边的子树查找节点值,从而实现高效的搜索。

关于二叉搜索树的基本操作,前几年我写过关于它的文章,我在本文不再赘述,有兴趣的同学可以参考我之前的文章:滴水穿石,非一日之功------理解二叉搜索树(JavaScript实现) - 掘金 (juejin.cn)

本文主要阐述一些关于二叉搜索树常见的题目供大家积累。

二叉搜索树一个重要的性质是其中序遍历的结果是一个有序的序列(至于是不是严格有序,取决于二叉搜索树里面有没有重复的值),在很多题目中,我们都可以利用这个性质来解题。

验证二叉搜索树 ⭐️⭐️⭐️⭐️

这题的提交通过率相当低,因为很多同学可能都没有找对方法,我也是提交5次才过的,如果一眼知道利用其中序序列有序的性质的话,直接秒了,哈哈哈。

98. 验证二叉搜索树 - 力扣(LeetCode)

二叉搜索树中第K小的元素 ⭐️⭐️⭐️⭐️

跟之前提到的方法一样,中序遍历生成序列之后,直接找第K大的值。

230. 二叉搜索树中第K小的元素 - 力扣(LeetCode)

恢复二叉搜索树 ⭐️⭐️⭐️⭐️

跟之前提到的方法一样,中序遍历生成节点序列之后,去找导致序列非严格升序的节点,然后仅仅交换节点的值即可完成二叉搜索树的恢复。

99. 恢复二叉搜索树 - 力扣(LeetCode)

N叉树

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️

对于N叉树的知识点,我个人觉得掌握其递归遍历和层序遍历即可,非递归DFS遍历也不是很复杂,如果你理解了二叉树的非递归DFS遍历的话。

所谓N叉树,就是在原来的二叉树节点的基础上,去掉了left和right指针,增加了children指针域来保存儿子们节点的引用。

ts 复制代码
interface NTreeNode<T> {
    value: T;
    children:  NTreeNode<T>[]
}

递归遍历

重要指数:⭐️⭐️⭐️

难度指数:⭐️⭐️

js 复制代码
/**
 * N叉树深度优先递归遍历
 * @param {NTreeNode<number>[]} treeNodes
 */
function dfsVisitRecursion(treeNodes) {
  if (!Array.isArray(treeNodes) || treeNodes.length === 0) {
    console.log("treeNodes empty");
    return;
  }

  treeNodes.forEach((treeNode) => {
    if (Array.isArray(treeNode.children)) {
      dfsVisitRecursion(treeNode.children);
    }
    console.log(treeNode.data);
  });
}

层序遍历

重要指数:⭐️⭐️⭐️

难度指数:⭐️⭐️

js 复制代码
/**
 * N叉树广度优先遍历
 * @param {NTreeNode<number>[]} treeNodes
 */
function bfs(treeNodes) {
  if (!Array.isArray(treeNodes) || treeNodes.length === 0) {
    console.log("treeNodes empty");
    return;
  }
  const queue = [];
  treeNodes.forEach((treeNode) => {
    queue.push(treeNode);
  });
  while (queue.length) {
    const treeNode = queue.shift();
    console.log(treeNode.data);
    if (Array.isArray(treeNode.children)) {
      queue.push(...treeNode.children);
    }
  }
}

树的构建

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️

树的构建也是我遇到过的一道面试题,我也是常常作为面试题来考察求职者,为什么面试官喜欢用这题来筛选求职者呢,一是因为它不难,能够和实际的业务进行结合,避免求职者在论坛上喷公司的招聘不友好,二是能够筛选出求职者的编程技巧,所有大家一定要掌握。

根据文件列表重新构建文件夹的关系 ⭐️⭐️⭐️⭐️⭐️

文件的定义如下:

ts 复制代码
/**
 * 文件信息
 */
interface File {
  /**
   * 文件的ID,需要使用string类型,若使用number类型,当id特别大的时候,前端解析的结果将不正确
   */
  id: string;

  /**
   * 文件的父级ID, 可能不存在
   */
  pid: string | null;

  /**
   * 文件名
   */
  filename: string;

  /**
   * 文件类型,比如是文件还是文件夹
   */
  type: number;

  /**
   * 子文件列表
   */
  children?: File[];
}

根目录的PID为null,非根目录文件指向其父级的ID,根据后端返回的文件数据构建文件夹信息,保证题目的数据是合法的数据。

方法一,使用递归:

js 复制代码
/**
 * 构建文件树
 * @param file 文件信息
 * @param file 文件列表信息
 */
function buildTree(file: File, files: File[]) {
  // 找到当前文件的子文件列表
  let children = files.filter((fileEle: File) => {
    return fileEle.pid === file.id;
  });
  // 递归的处理当前文件子文件列表的子文件
  file.children =
    children.length === 0
      ? undefined
      : children.map((subFile: File) => buildTree(subFile, files));
  return file;
}

/**
 * 将文件列表转为文件树,并且返回根节点
 * @param files 文件列表
 */
function build(files: File[]) {
  // 构建结果
  const roots = files
    .filter((file) => {
      // 这一步操作是为了找到所有的根节点
      return file.pid === null;
    })
    .map((file) => {
      // 对根节点的数据进行构建
      return buildTree(file, files);
    });
  return roots;
}

方法二,使用哈希表:

js 复制代码
/**
 * 将文件列表转换成为哈希表
 * @param {File[]} files
 */
function makeHashMap(files) {
  const map = new Map();
  files.forEach((file) => {
    // 以ID为主键建立哈希映射
    map.set(file.id, file);
  });
  return map;
}

function buildTree(files) {
  // 将文件构建成哈希表,主要是为了后续的查找方便
  const fileMap = makeHashMap(files);
  const roots = [];
  // 逐个的对每个文件增加子元素
  files.forEach((file) => {
    // 找父级文件,如果找不到的话,说明是根节点
    const parentFile = fileMap.get(file.pid);
    if (parentFile) {
      if (!Array.isArray(parentFile.children)) {
        parentFile.children = [file];
      } else {
        parentFile.children.push(file);
      }
    } else {
      roots.push(file);
    }
  });
  // 最后只需要找出根节点的文件列表即可完成构建
  return roots;
}

推荐使用哈希表法解此题

从二叉树的序列化重建二叉树

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️

关于二叉树,有一个结论(假定二叉树的值不重复):

  • 中序序列+后序序列能唯一确定一棵二叉树;
  • 先序序列+中序序列能唯一确定一棵二叉树;
  • 先序序列+后序序列不能唯一确定一棵二叉树

为什么是这样呢?

首先,我们来看中序序列+后序序列是怎么唯一确定二叉树的,首先,根节点一定是在后序序列的最后一个节点上(左右根),那么,我们拿到了根节点,通过这个节点的值,我们就能在中序序列里面找到它所在的位置,它就能够把中序序列划分为左子序列和右子序列(左根右),重复这个过程,我们就能将这棵二叉树恢复。

接着,我们来看先序序列+中序序列是怎么唯一确定二叉树的,首先,根节点一定是在先序序列的第一个节点上(根左右),那么,我们拿到了根节点,通过这个节点的值,我们就能在中序序列里面找到它所在的位置,它就能够把中序序列划分为左子序列和右子序列(左根右),重复这个过程,我们就能将这棵二叉树恢复。

最后,我们再看一下为什么先序+后序序列为什么不能唯一确定一棵二叉树,还是之前的思考方式,根节点在先序序列的第一个节点上,在后序序列的最后一个节点上,左右子树的长度怎么确定?无法确定,因此无法唯一确定一棵二叉树。

这种建树方法是分治算法的思想,在下一篇文章中我们再聊分治算法。

105. 从前序与中序遍历序列构造二叉树 - 力扣(LeetCode)

106. 从中序与后序遍历序列构造二叉树 - 力扣(LeetCode)

重要指数:⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️⭐️

堆(Heap)是一棵特殊的树,且是完全二叉树 ,我们前端掌握二叉堆即可,有些资料也将堆称为优先队列

堆,我们为了方便处理一般用数组存储,然后用数组的下标关系来表示树节点的儿子节点(对于一个索引为k的节点,那么其左右儿子的索引则分别为2k+1和2k+2,假设这个节点存在儿子节点),堆中有一条最重要的性质,不管怎么样,从根节点出发到叶节点的每条路径总是保持有序的性质 (非严格升序或非严格降序取决于堆是大顶堆还是小顶堆,大顶堆从大到小,小顶堆从小到大

关于堆的基本操作,我在之前的文章中也详细的阐述过了,本文就不再详细阐述堆的操作了,有兴趣的同学可以参考之前的文章:新年新气象------理解堆与堆排序(基于JavaScript实现) - 掘金 (juejin.cn)

堆的使用场景一般是这样的,如果我们不想整个的对数组进行排序,但是又想保持部分有序,已知最快的排序算法是O(N*LogN),而使用堆的话,我们可以在线性的时间内将数组调整成堆,这时的效率是要比直接排序好很多的,这个时候即可用堆来处理,比如在求TopK的时候,比如在迪杰斯特拉算法求最短路径的时候,就可以用堆来加快每次查找最小未收录节点的效率。

堆排序

重要指数:⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️⭐️

堆排序的思路很简单,但是如果真的是面试的话,估计没有几个同学写的出来,它的难度不在排序上,而是很多同学在将数组调整成堆的过程不会写(我过了很久如果不复习的话去面试,别人让我手写,我也一样写不出来,这很正常,因为边界条件考虑不完全),如果掌握不到的同学,理解它的原理即可。

堆排序的原理是这样的,首先将数组调整成堆,这个时间是线性的,然后我们弹出堆顶的的元素,这时,剩余的部分需要重新调整成堆,然后我们接着将剩余的部分调整成堆,重复这个过程,直到剩余部分内容为空。

堆排序的实现如下:

js 复制代码
/**
 * 将长度为length的数组片段以p为根节点构建最大堆
 * @param {Array<number>} arr 需要进行排序的数组
 * @param {number} p 根节点
 * @param {number} length 数组片段的长度
 */
function percDown(arr, p, length) {
  let temp = arr[p];
  let child, parent;
  // 从p节点开始,如果parent*2等于length的话,说明堆已经越界,无需进行循环
  for (parent = p; parent * 2 < length; parent = child) {
    // 找到左儿子节点所在的索引
    child = parent * 2;
    // 右儿子存在,并且右儿子比左儿子大,则取右儿子
    if (child + 1 < length && arr[child] < arr[child + 1]) {
      child++;
    }
    // 如果待插入的值比当前这个位置大或者相等,则说明这个位置就是可以插入的位置,不能再继续下滤了,因此退出循环
    if (temp >= arr[child]) {
      break;
    } else {
      // 把大的值向上提
      arr[parent] = arr[child];
    }
    // 节点下滤
  }
  // 把元素放在合适的位置
  arr[parent] = temp;
}

/**
 * 对数组进行堆排序
 * @param {Array<number>} arr 需要进行排序的数组
 */
function heapSort(arr) {
  // 因为在没有哨兵时,对于父节点为i的节点,其左右儿子分别是 2i+1, 2i+2,那我们可以根据最后一个元素算出,最后一个元素的父节点是 Math.floor(arr.length / 2)
  // 从最后一个元素的父元素开始,在线性的时间内将数组调整成最大堆,
  for (let i = Math.floor(arr.length / 2); i >= 0; i--) {
    percDown(arr, i, arr.length);
  }
  for (let i = arr.length - 1; i >= 0; i--) {
    // 取出堆顶的第一个元素
    let temp = arr[0];
    // 将无序片段最后一个元素交换到堆顶
    arr[0] = arr[i];
    arr[i] = temp;
    // 继续将长度为i的元素片段,以0为根节点,调整成最大堆
    percDown(arr, 0, i);
    // 最后无序片段的规模递减
  }
}

求TopK(中位数)

重要指数:⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️⭐️

这道题是一个范式,基本上能覆盖很多堆的面试题。它的思维方式是这样的,刚来的前K个元素,我们先往堆中插入满K个元素,接着,我们能够知道的是,堆顶上面的元素是一个标准,它能够反应堆中目前的元素的大小情况(里面的元素什么顺序暂且不用管,因为我们并不是要求这K个元素是有序的)。

我们来思考一下该用大顶堆还是小顶堆,假设我们使用大顶堆,那里面的元素都是比堆顶元素小的,如果再来一个元素,我们不知道堆里面的元素是不是还存在比当前元素小的,反过来,我们换成小顶堆,堆顶的元素是最小的,堆里面的元素都是比这个元素大的,堆顶的元素就起到了一个分界的效果 ,如果此刻,我们再接着遍历下一个元素时,发现堆顶的元素比下一个元素小,那么,我们就需要把堆顶的元素弹出来,用当前这个较大的元素去替换它,然后重新调整堆,最终,堆中存下来的总是前K个较大的元素,但是我们并没有去排序,所以性能要比直接排序要好一些。

这题经常在脉脉刷到,理解到它原理的同学就积累下来吧,万一压中了面试题呢,从此出任CEO,迎娶白富美,走向人生巅峰,哈哈哈。

347. 前 K 个高频元素 - 力扣(LeetCode)

对于这题,还有另外一种解题思路,叫做快速选择,在下文的分治算法小节还会聊到它。

关于堆的知识点我个人觉得掌握这些就够了,虽然我们写的代码运行的时候每时每刻可能都在用到堆(引用类型的内存分配),可是在实际项目开发中却很少用到堆。

排序

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️

之前看了一篇文章说,在十年前,面试百度只需要考一道冒泡排序就好了,现在的面试上来动不动就是快速排序,足以见得十年时间计算机在中国得到了长足的发展,说明了行业变的越来越卷了,也成长起来了越来越多的开发者。

排序算法有十种,不过我们只需要其中场景的5-6种即可。

各位同学需要对排序算法的稳定性有一定的认识,所谓稳定性是指的是在多权重排序的时候,先以一个维度进行排序以后,再以另外一个维度进行排序不会影响到第一个维度的排序结果

如果这个解释你不太好理解的话,我举个生活中的例子就明白了,比如我们打扑克,需要根据点数和花色进行排序,我们第一步先根据花色进行排序,第二步再根据点数进行排序,稳定的排序算法在第二次排序的时候是不会把之前第一次排好序的花色打乱掉的。

简单排序算法都是稳定的排序算法,不过,也有可能由于你的写法把本来稳定的排序算法写成了不稳定的排序算法,这取决于你自己的实现了。

复杂排序(即平均算法复杂度是O(N*logN)的排序算法)里面,归并排序是唯一一个稳定的排序算法

简单排序

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️

我理解的简单排序就是平均时间复杂度为O(N^2)的排序算法,这类排序算法简单直接,都是稳定的排序算法。有选择排序,冒泡排序,插入排序。

对于选择排序和冒泡排序,同学需要注意他们两者的区别。

选择排序

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️

选择排序的思路非常简单,我们不需要专门的记忆代码实现,只需要记忆思维方式就能写的出来。

首先,选择排序分为有序部分和无序部分,有序部分长度最开始长度是0,从第一个节点开始,我们从无序部分选一个最值出来,将第一个节点的值和最值节点进行交换,有序部分的长度增加,无序部分的长度减少,然后第二个节点重复这个过程,直到无序部门的长度为0,从而完成排序。

以下是它的代码实现:

js 复制代码
/**
 * 对数组进行选择排序
 * @param {Array<number>} arr 需要进行排序的数组
 */
function selectionSort(arr) {
  let temp = null;
  for (let i = 0; i < arr.length; i++) {
    // 假设无序片段的第一个元素是最值,从后面的序列中找一个最值与其交换
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[j] < arr[i]) {
        temp = arr[j];
        arr[j] = arr[i];
        arr[i] = temp;
      }
    }
  }
}

冒泡排序

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️

冒泡排序与选择排序在写的时候是最容易产生混淆的,选择排序是无序片段的节点交换到有序片段的后面,而冒泡排序是两两比较,每一轮排序,要么把最重的沉到下面(要么把最轻的浮到上面,取决于升序还是降序)。

以下是它的代码实现:

js 复制代码
/**
 * 对数组进行冒泡排序
 * @param {Array<number>} arr 需要进行排序的数组
 */
function bubbleSort(arr) {
  let temp = null;
  // 外层循环变量i 用于控制参与排序数据的规模
  for (let i = arr.length - 1; i >= 0; i--) {
    // 定义标记,用于判断本轮是否参与交换
    let flag = true;
    // 内层循环用于把最"重"的元素下沉至非有序片段的最后一位
    for (let j = 0; j < i; j++) {
      // 注意冒泡排序是两两相邻的比较
      if (arr[j] > arr[j + 1]) {
        temp = arr[j];
        arr[j] = arr[j + 1];
        arr[j + 1] = temp;
        // 如果交换了元素,还需要设置标记,若数组已经有序,可以提前终止排序,提升性能
        flag = false;
      }
    }
    // 如果说没有参与交换,则认为数组已经有序,则可以完成排序
    if (flag) {
      break;
    }
  }
}

冒泡排序是两两比较,因此if条件是arr[j] > arr[j + 1],而选择排序不是,因此if条件是arr[j] < arr[i],各位注意一下这个区别。

可以看到,冒泡排序每次都是把最值要么下沉,要么上浮,之前我们聊到过求TopK,如果我们需要求数组的TopK,在这个K非常小的话,冒泡排序来解,也是非常合算的。

插入排序

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️

插入排序的思维方式也非常简单,只需要记忆算法的处理流程就能写的出来,如果同学玩儿过扑克游戏,我们摸牌然后整理手牌的的时候就是插入排序的过程,首先我们手里面一张扑克都没有,摸上来就直接拿在手里面(所以插入排序从第二个元素开始),第二张牌摸上来,有两种可能,一种是直接放在第二个位置,另外一种可能是向前寻找插入牌的位置,向前寻找插入位置有两种可能,一种是直接找到头了,那就直接插在最开头,另外一种可能是在牌堆中找到了合适的插入位置,从而完成插入。

以下是插入排序的实现方式:

js 复制代码
/**
 * 对数组进行插入排序
 * @param {Array<number>} arr 需要进行排序的数组
 */
function insertionSort(arr) {
  // 默认认为第一个元素已经有序
  for (let i = 1; i < arr.length; i++) {
    let j = i;
    let cur = arr[i];
    //向前找合适的插入位置,退出条件有两种可能,1、找到了合适的插入位置;2、找到了头了
    while (j > 0 && arr[j - 1] > cur) {
      // 在每次查找插入位置的时候,都将当前元素向后挪一位。
      arr[j] = arr[j - 1];
      j--;
    }
    arr[j] = cur;
  }
}

复杂排序

我理解的复杂排序就是平均时间复杂度为O(N*LogN)的排序算法,这类排序算法处理流程较为复杂,除了归并排序,其余排序算法都是稳定的不排序算法。

快速排序

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️⭐️

关于快速排序的原理介绍,我在之前的文章有详细的流程展示,有兴趣的同学可以查看我之前的文章:都2022了听说你还不懂快速排序?必须安排! - 掘金 (juejin.cn)

快速排序之所以快,就在于它比较的次数比较少,元素不会像插入排序那样多次移动,一旦确定了某次插入的位置,其最终在数组中的位置就确定下来了。快速排序是面试最容易考察的一个排序算法,各位同学一定要掌握。

js 复制代码
/**
 * 对数组片段进行快速排序
 * @param {Array<number>} arr 需要进行排序的数组
 * @param {number} left 待排序数组片段的开始索引
 * @param {number} right 待排序数组片段的结束索引
 */
function _quickSort(arr, left, right) {
  // 如果数组片段的长度小于或者等于1,无需进行排序
  if (left >= right) {
    return;
  }
  // 随机取一个元素作为主元
  let pivot = arr[left];
  let i = left;
  let j = right;
  while (i < j) {
    // 从数组片段右侧找比主元小的元素
    while (i < j && arr[j] > pivot) {
      j--;
    }
    // 说明此刻已经找到了比主元小的元素
    if (i < j) {
      arr[i] = arr[j];
      // 缩小规模
      i++;
    }
    // 从数组片段左侧找比主元大的元素
    while (i < j && arr[i] < pivot) {
      i++;
    }
    // 说明找到了比主元大的元素
    if (i < j) {
      arr[j] = arr[i];
      j--;
    }
  }
  // 当退出循环的时候,i == j, 此刻这个位置之前所有的元素都比主元小,这个位置之后的所有元素都比主元大,这个位置就是我们存放主元的位置
  arr[i] = pivot;
  // 递归的对左半部分元素进行快速排序
  _quickSort(arr, left, i - 1);
  // 递归的对右半部分元素进行快速排序
  _quickSort(arr, i + 1, right);
}

/**
 * 对数组进行快速排序
 * @param {Array<number>} arr 需要进行排序的数组
 */
function quickSort(arr) {
  _quickSort(arr, 0, arr.length - 1);
}

堆排序

重要指数:⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️⭐️

对于堆排序的原理,以及算法实现我在之前堆的小节就已经给出了,本节将不再赘述,关于堆排序,掌握堆排序的处理流程即可。

归并排序

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️

归并排序的设计思路要比快速排序稍微简单一些,归并排序的实现基础就是我们在上文中提到过的采用双指针的方式合并两个有序数组。

具体处理流程,大家也可以参考我早期的文章:归并排序(使用JavaScript)的递归实现

归并排序是有空间复杂度的复杂排序,空间复杂度是O(N),因为我们需要一个对应数组长度的空间用来临时合并有序数组。

归并排序是稳定的复杂排序,因为它的稳定性的原因,我们在多权重排序的时候经常会用到归并排序。

另外,我们截至目前提到的排序,都是内排序,即数据全部加载到内存的排序算法归并排序在外排序也有相当的用途 ,所谓外排序就是需要排序的数据量级非常大,无法全部加载到磁盘中的排序算法

所以,归并排序也是在面试中必须要掌握的一个排序算法。

之前的博客中给出的是归并排序的递归实现,以下是一版非递归实现:

js 复制代码
/**
 * 合并两个有序片段,存到tmpArr中
 * @param {Array<number>} arr
 * @param {number} leftStart
 * @param {number} rightStart
 * @param {number} rightEnd
 * @param {Array<number>} tmpArr
 */
function _merge(arr, leftStart, rightStart, rightEnd, tmpArr) {
  let pos = leftStart;
  let leftEnd = rightStart - 1;
  while (leftStart <= leftEnd && rightStart <= rightEnd) {
    if (arr[leftStart] >= arr[rightStart]) {
      tmpArr[pos++] = arr[rightStart++];
    } else {
      tmpArr[pos++] = arr[leftStart++];
    }
  }
  while (leftStart <= leftEnd) {
    tmpArr[pos++] = arr[leftStart++];
  }
  while (rightStart <= rightEnd) {
    tmpArr[pos++] = arr[rightStart++];
  }
}

/**
 * 一次归并两个有序片段
 * @param {Array<number>} arr 待排序数组
 * @param {number} sliceSize 每个块的长度
 * @param {Array<number>} tmpArr 临时数组
 */
function _mergePass(arr, sliceSize, tmpArr) {
  let i = 0;
  // 只合并到倒数第二或者倒数第一个块之前的块
  while (i <= arr.length - 2 * sliceSize) {
    /* 两两归并相邻有序子列 */
    _merge(arr, i, i + sliceSize, i + 2 * sliceSize - 1, tmpArr);
    // 每次跨2个序列块
    i += 2 * sliceSize;
  }
  if (i + sliceSize < arr.length) {
    /* 说明刚好,当前chunk数是 2倍块数的整数倍 归并最后2个子列*/
    _merge(arr, i, i + sliceSize, arr.length - 1, tmpArr);
  } else {
    /* 还差点儿,直接把最后只剩1个子序列导到临时数组即可 */
    for (let j = i; j < arr.length; j++) {
      tmpArr[j] = arr[j];
    }
  }
}

/**
 * 归并排序 非递归实现
 * @param {Array<number>} arr
 */
function mergeSort(arr) {
  /* 初始化子序列长度*/
  let slice = 1;
  let tmpArr = [];
  /**
   * 块的size从1增长到length
   */
  while (slice < arr.length) {
    // 一轮归并
    _mergePass(arr, slice, tmpArr);
    slice *= 2;
    /* 翻滚两次,这个地方不仅在排序,还完成了把tmpArr的内容导回至arr的事儿, 如果原数据已经有序,仅完成导回操作。*/
    _mergePass(tmpArr, slice, arr);
    slice *= 2;
  }
}

希尔排序

重要指数:⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️⭐️

希尔排序的思路是消除数组中的逆序对,是在插入排序算法上的改良,其根据一定的规则选取增量序列 D(k)·D(k-1)·D(k-2)···1 ,增量序列的最后一项必须是 1,分别以对 D(k)至 1 为间距对数组进行插入排序(因为采取了对 D(k-1)为间距的插入排序之后并不会使得 D(k)为间距进行插入排序之后的结果变坏这是希尔排序的理论基础),此刻数组会变得大致有序,最后再进行一次(间距 D 为 1)插入排序,从而使得数组有序。

用通俗易懂的语言描述就是,举个例子,我先对数组使用一次 4 (D(k))为间距的插入排序,得到一个结果,在这个结果上以 2(即 D(k-1), 这个间距完成之后,并不会让之前 4 为间距的插入排序的结果变坏) 为间距再进行一次插入排序,又的到一个结果,最后,我对数组使用一次纯粹(因为间隔是 1,所以说它纯粹)的插入排序。

我个人认为,希尔排序也是一个了解其原理就可以的排序算法。

以下是希尔排序的简单实现:(为什么说是简单实现呢,因为我的增量序列选取就是足够的简单)

js 复制代码
/**
 * 对数组进行希尔排序
 * @param {Array<number>} arr 需要进行排序的数组
 */
function shellSort(arr) {
  // 选取 N/2->N/4->N/8->···->1的增量序列
  for (let D = Math.floor(arr.length / 2); D >= 1; D = Math.floor(D / 2)) {
    // 以间距D对数组进行插入排序
    for (let i = D; i < arr.length; i++) {
      let cur = arr[i];
      let j = i;
      // 注意这儿需要取到等于
      while (j >= D && arr[j - D] > cur) {
        arr[j] = arr[j - D];
        j -= D;
      }
      arr[j] = cur;
    }
  }
}

其它排序算法

重要指数:⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️

除了上面基于比较的排序算法,还有两个不是基于比较的排序算法,分别是基数排序和桶排序,对于这部分知识点了解即可,有兴趣的同学可以查看我的个人博客。

关于排序算法,我个人的观点是前端必须要掌握的排序算法(即达到能够理解,并能不借助搜索引擎写出来)是快速排序和归并排序,简单的排序算法,必须要掌握冒泡排序、插入排序,选择排序(不用背,因为理解它的处理流程,非常好写)。

重要指数:⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️⭐️

图(Graph),表示的是很复杂的逻辑关系,图中的知识点非常多,但是对于前端来说,我们需要掌握的内容并不是那么多。 链表和树都能算的上是特殊的图。关于图,在前端有实际意义的场景除了深度优先遍历和广度优先遍历,还有一个拓扑排序的知识点有用,主要体现在处理Monorepo的项目依赖处理的过程中,所以也有可能成为面试考察的一个方面。

这儿我们就不整大学教材上的什么邻接矩阵或邻接表的表示方法了,那些东西真的太太太理论化了,我们是在准备面试题,不是在准备考研复试,哈哈哈。只要用我们熟悉的表示办法能够描述清楚逻辑关系即可,不用在意那么多细节。

一些基本概念

顶点(Vertex),表示图中的一个有意义的节点。

边(Edge),用于连接图中的两个顶点,对于有向图,边有方向,即单向的。对于带权图,便有权重。

连通图:假设我们不考虑方向,对于一个连通图来说,从一个节点出发,能够到任意的节点(即,没有一组(或几组)节点跟图的主体是割裂的)。

连通分量:对于非连通图,假设我们不考虑方向,从一个节点能够到达的所有节点组成的集合,称为一个连通分量,一个图至少有一个连通分量。

与图相关的内容,还有很多概念,比如在处理图的连通分量时,可以使用一个叫做并查集的数据结构,能够对解决一些图的问题形成降维打击的效果;比如最小生成树,比如我们需要铺设村村通的宽带,如何做到最低成本的铺设;比如最短路径问题,我们每天都在用到的导航软件一输入起点和终点,就把最短路径给我们规划出来了,这些都是图的应用场景,这些问题都是具有一定的业务性质,如果你面试的岗位与这些方面相关,可能会遇到这类的面试题,那你需要额外的补充一些这些方面的知识点,面试一般都前端岗位的话,面试官不会那么无聊,就比如我在上文提到的,有个人在脉脉说面试了一堆人没有一个人没有一个人能写出来迪杰斯特拉算法。这样的面试官是不成熟的,如果你的运气不太差的话,基本上是不会遇到这样的情形的。

深度优先遍历

重要指数:⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️

深度优先遍历有一套标准范式:

js 复制代码
function DFS(v, visited) {
  visited[v] = true;
  for(v 的每个邻接点 w ) {
    if(!visited[w]) {
      DFS(w, visited);
    }
  }
}

至于v的每个邻接点w怎么解释,这取决于实际你的问题,比如在拍平数组时,邻接点就是当前数组的每个元素;对于深拷贝对象时,邻接点就是当前对象的每个字段。

深度优先遍历有太多的题目,本文举前端比较有代表性的题目拍平数组和深克隆数组(考虑循环引用)。

拍平数组

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️

这是这两年的一道高频面试题

使用for循环迭代实现:

js 复制代码
/**
 * 拍平数组
 * @param {any[]} arr 源数据
 * @param {number} depth 指定拍平深度
 * @param {number} curDepth 当前的深度
 */
function flat(arr/*,*depth = Infinity, curDepth = 1*/) {
  const results = [];
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i]) /*&& curDepth < depth*/) {
      results.push(...flat(arr[i]/*, depth, curDepth + 1)*/);
    } else {
      results.push(arr[i]);
    }
  }
  return results;
}

使用reduce实现:

js 复制代码
/**
 * 拍平数组
 * @param {any[]} arr
 * @returns
 */
function flat(arr) {
  return arr.reduce((preVal, curVal) => {
    return Array.isArray(curVal)
      ? preVal.concat(flat(curVal))
      : preVal.concat(curVal);
  }, []);
}

深拷贝对象

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️

深拷贝也是前端常见的面试题之一,一般我们会采用DFS的实现方式,它的难点就是在循环引用的处理上。

js 复制代码
/**
 * 使用深度优先深拷贝对象
 * @param {Array<any> | object} obj
 * @param { Map<Array<any> | object, Array<any> | object> } map
 * @returns
 */
function deepClone(obj, map = new Map()) {
  // 如果已经拷贝过,则可以直接返回拷贝过的值,主要是为了防止循环引用
  let cloneObj = map.get(obj);
  if (typeof cloneObj !== "undefined") {
    return cloneObj;
  }
  // 初始化拷贝的对象
  cloneObj = Array.isArray(obj) ? [] : {};
  // 建立已经拷贝的引用,不能再开始拷贝属性了再建立拷贝引用,否则将会导致递归最大调用栈的问题发生
  map.set(obj, cloneObj);
  // 对拷贝对象挨个赋值
  for (let prop in obj) {
    // 遇到对象,则递归拷贝
    if (obj[prop] instanceof Object) {
      cloneObj[prop] = deepClone(obj[prop], map);
      // 拷贝完成后,还要将其加入引用Map中去
      map.set(obj[prop], cloneObj[obj]);
    } else {
      cloneObj[prop] = obj[prop];
    }
  }
  return cloneObj;
}

广度优先遍历

重要指数:⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️

广度优先遍历的方式在求最短路径问题往往有着较多的应用,但是最短路径问题不是我们普通前端需要准备的面试重点(除非你面试的岗位与其强相关)

因此,在广度优先遍历这一小节,我们最主要的还是就掌握树或者图的层序遍历即可。

同样的,广度优先遍历也是有着一套标准的范式:

js 复制代码
function bfs(v, visited) {
  // 将当前节点标记为已处理
  visited[v] = true;
  // 将当前节点入队
  enqueue(v, Q);
  // 当队列中存在元素
  while (!isEmpty(Q)) {
    // 从队列中取出一个节点
    v = dequeue(v, Q);
    for(v 的每个邻接点 W) {
      // 若当前邻接点没有处理过,则将其标记为处理过,并且加入队列中去
      if(!visited[W]) {
        visited[W] = true;
        enqueue(v, w);
      }
    }
  }
}

二叉树的广度优先遍历

重要指数:⭐️⭐️⭐️⭐️⭐️

难度指数:⭐️⭐️⭐️

即层序遍历,请参考二叉树的层序遍历的实现小节,考虑到篇幅,此处就不再赘述。

拍平数组

重要指数:⭐️

难度指数:⭐️⭐️⭐️

广度优先拍平数组之后,无法保持其相对的顺序,所以实际在处理的时候,还是选择深度优先比较好。

js 复制代码
/**
 * 以广度优先的方式拍平数组
 * @param {any[]} arr
 */
function flat(arr) {
  const results = [];
  // 将数据加入到队列中
  const queue = [];
  if (Array.isArray(arr)) {
    queue.push(arr);
  }
  // 当队列内容不为空时,继续拍平数据
  while (queue.length) {
    // 从队列中取出一个元素,并且这个出队的一定是数组
    const ele = queue.shift();
    ele.forEach((subEle) => {
      if (Array.isArray(subEle)) {
        queue.push(subEle);
      } else {
        results.push(subEle);
      }
    });
  }
  // 因为无法保持相对顺序,所以加了一个排序
  return results.sort((a, b) => a - b);
}

深拷贝对象

重要指数:⭐️

难度指数:⭐️⭐️⭐️⭐️

广度优先的方式,需要知道当前拷贝的对象是谁,所以,我在处理的时候,直接将其跟源对象成对加入队列了,如果你觉得啰嗦,用Map记录也可以的。这种方式的代码可读性不是特别好,所以还是推荐以深度优先的方式处理较为直接。

js 复制代码
/**
 * 使用广度优先深拷贝一个对象
 * @param {Array<any> | object} obj
 * @returns
 */
function deepClone(obj) {
  // 根据目标对象确定拷贝是数组还是对象
  let cloneObj = Array.isArray(obj) ? [] : {};
  // 用一个map用以记住被拷贝过的内容
  const map = new Map();
  // 记住当前对象已经被拷贝过了
  map.set(obj, cloneObj);
  // 把原始内容和拷贝的内容追加到队列中去,准备开始以广度优先的方式进行深拷贝
  const queue = [
    {
      source: obj,
      clone: cloneObj,
    },
  ];
  while (queue.length > 0) {
    const { source, clone } = queue.shift();
    for (let prop in source) {
      if (source[prop] instanceof Object) {
        // 如果已经拷贝过,则直接将内容复制到目标对象上去
        if (map.get(source[prop])) {
          clone[prop] = map.get(source[prop]);
        } else {
          // 把当前对象和拷贝的空对象加入到队列中去,准备后序的深拷贝
          const nextClone = Array.isArray(source[prop]) ? [] : {};
          queue.push({
            source: source[prop],
            clone: nextClone,
          });
          // 建立拷贝关系,本轮还是空内容(可以理解为拷贝一个容器),待下一轮循环才拷贝值
          clone[prop] = nextClone;
          // 将已经拷贝的内容加入到map中去,防止循环拷贝
          map.set(source[prop], nextClone);
        }
      } else {
        // 基本类型,可直接拷贝
        clone[prop] = source[prop];
      }
    }
  }
  return cloneObj;
}

拓扑排序

重要指数:⭐️⭐️

难度指数:⭐️⭐️⭐️⭐️

拓扑排序(Topological Sorting)是一种图论算法,用于解决有向无环图(DAGDirected Acyclic Graph)中的节点排序问题。拓扑排序的目标是将图中的所有节点按照一种线性顺序排列,使得对于任何有向边 (u, v),节点 u 在排列中都出现在节点 v 的前面。

拓扑排序常常用于解决涉及依赖关系的问题,如编译顺序、任务调度、课程选修等。

所以,在现在的Monorepo的仓库组织下,对于依赖的科学管理就有一些要求了,因此,我猜测可能负责团队基建这方面得岗位对这部分知识可能会有一定程度的考察。

我所经历过的实际开发中,像NestJS的依赖注入就可以使用拓扑排序来解决(虽然它不是用的拓扑排序实现的),像自动生成Monorepo仓库各个包的构建先后顺序,这些场景都能用到拓扑排序。

假设,我用这样的结构表示图:

ts 复制代码
/**
 * 图中的顶点
 */
export interface Vertex {
  /**
   * 入度
   */
  inDegree: Edge[];
  /**
   * 出度
   */
  outDegree: Edge[];
  /**
   * 节点名称
   */
  name: string;
}

/**
 * 图中的边
 */
export interface Edge {
  /**
   * 权重
   */
  weight?: number;
  /**
   * 前驱节点
   */
  prev: Vertex;
  /**
   * 后继节点
   */
  next: Vertex;
}

/**
 * 有向无环图
 */
export interface Graph {
  /**
   * 图的节点数组
   */
  nodes: Vertex[];
  /**
   * 图的节点的个数
   */
  get count(): number;
}

根据上述的定义,则可以编写出对应的拓扑排序的处理代码:

js 复制代码
/**
 * 拓扑排序
 */
export function topologicalSort(g: Graph) {
  // 用于记住入度数
  const inDegreeMap: Map<Vertex, number> = new Map();
  // 队列,用于处理入度是0的点
  const queue: Vertex[] = [];
  const topSortResults: Vertex[] = [];
  let count = 0;
  // 初始化的时候,用map记住每个节点的入度数
  g.nodes.forEach((v) => {
    if (v.inDegree.length === 0) {
      queue.push(v);
    } else {
      inDegreeMap.set(v, v.inDegree.length);
    }
  });
  // 开始进行拓扑排序
  while (queue.length) {
    // 出队一个节点进行处理
    const vertex = queue.shift()!;
    // 将其加入到结果里面去
    topSortResults.push(vertex);
    // 处理的个数加1
    count++;
    // 处理出度
    vertex.outDegree.forEach((edge) => {
      const nextVertex = edge.next;
      // 获取后继节点的入度
      const inDegree = inDegreeMap.get(nextVertex)!;
      // 设置节点新的入度
      inDegreeMap.set(nextVertex, inDegree - 1);
      // 如果除开这个节点的话,下个节点的入度将会是0,说明经过这个操作之后它已经没有入度了,可以进行操作了
      if (inDegree === 1) {
        queue.push(nextVertex);
      }
    });
  }
  // 如果把所有的入度为0的节点都找过了,凡是发现不够总的节点个数,于是可以得出一个结论,某些节点的入度无论如何不可能是0,于是可以推导出图中存在回路的结论。
  if (count < g.count) {
    throw new Error("图中存在回路,无法进行拓扑排序~");
  }

  return topSortResults;
}

在我之前的博客中,有一篇关于拓扑排序的应用的文章,其展示了一种自动生成构建子项目顺序的能力,有兴趣的同学可以查看我之前的博客:拓扑排序在前端开发中的应用场景

结语

因为算法考察的知识点特别的广,一篇文章难以涵盖(所以准备将其分为上中下三篇文章进行阐述),在上一篇文章中阐述了数组(二分,双指针)、链表、队列、栈相关的算法考察点,在这一篇文章中阐述树和堆相关的算法考察点,以及DFS,BFS和图的一些算法及排序算法的应用,在最后一篇文章我们将继续阐述哈希表,递归,分治,动态规划相关的算法考察点。

本文的很多题目我是没有给出示例算法的,有兴趣的同学可以在评论区一起讨论,对于这些题目的实现有困难的也可以在评论区留言。

对于本文阐述的内容有任何疑问的同学可以在评论区留言或私信我。

如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。

相关推荐
小飞猪Jay41 分钟前
C++面试速通宝典——13
jvm·c++·面试
_.Switch1 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光1 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   1 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   1 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web1 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常1 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇2 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr2 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
Tiffany_Ho3 小时前
【TypeScript】知识点梳理(三)
前端·typescript