1、前言
本文是对前面算法系列文章的一些小结和思考。主要阐述最近通过学习二叉树、图论的一些思考,通过融汇贯通,建立起了自己的知识体系;通过阐述深度优先算法和广度优先算法在实际开发中的应用的一些见解,希望能对各位奔跑在算法道路上的读者们一些帮助。
2、图
因为要提到标题中所说的两种算法思想就必须要提到图论。
图这个数据结构是在树和线性数据结构上的推广,图反映的是数据所对应元素之间的几何关系和拓扑关系。
图的知识点复杂而繁多,本文只作为一个引子介绍,有兴趣的读者可自行查阅相关资料。 在学习任何一门数据结构,我们都相当关心的一件事是对这个结构的遍历算法。图,因为其表示了复杂的拓扑关系和几何关系,可以从不同的维度对它进行遍历,因此就形成两大重要的遍历算法:深度优先算法和广度优先算法。
由于图的表示方法各种各样,不同的表示方式,算法的代码差异也比较大,因为,我们在DFS和BFS算法中仅给出伪代码,在后序的的特定问题上,再详细讨论。
2.1、深度优先(DFS:Depth First Search)
深度优先思想有点儿像我们年轻人的处事方式,叫不撞南墙不回头。
从一个节点出发,按照特定的规则(比如我们走迷宫,每次都最先选左侧的路,最后选最右侧的道路),不断的向深度迭代,直到已经走到死胡同了,然后再回退到上一个交叉路口,继续选下一条道路进行遍历的过程;如果当前交叉的所有路口都已经被访问过了,那么,继续回退到上一个交叉路口,如果还是都被访问过了,重复以上的流程,直到所有的节点都已经被访问过了,则遍历结束。
从上述的算法描述过程中,我们已经很明显的知道了,深度优先算法是基于递归或者栈的一种算法。
算法执行流程如下图(笔者在此就偷懒啦,贴了一个早期博文中制作的gif动画,做ppt太累啦,敬请见谅)所示:
深度优先的伪代码如下:
function dfsSearch(节点, 访问标记Map) {
输出当前节点的信息;
在 访问标记Map 上将当前节点设置为已访问过;
for (遍历当前节点的所有临接点) {
取出一个节点作为当前节点;
if (当前顶点还没有被访问过) {
dfsSearch(当前节点, 访问标记Map);
}
}
}
为什么需要一个访问标记Map呢,因为图中的指向关系错综复杂,如果我们已经访问过了一个节点,但是并不对其标记的话,下次处理当前节点的邻接点时候,会重复处理,这就形成了一个环,永远无止境了,会导致递归出现最大调用堆栈的错误发生。
2.2、广度优先 (BFS:Breadth First Search)
如果说深度优先算法思想像一个年轻人,那么广度优先的算法思想则有点儿像一个成熟的长者,从一个开始出发,每次都把当前视野内能处理的事儿都处理好之后,然后再递进处理更深层级的事儿,滴水不漏。我觉得我们在工作中的处理事情的方式就应该采用广度优先的方式比较好,😄。
广度优先算法需要用到我们早期学过的一个数据结构:队列。
算法的执行过程如下:首先选一个顶点作为起始点,从这个节点开始,我们先将其加入到队列中。然后开始不断的迭代,迭代过程中,首先,看队列还有没有未处理的元素,有则从队列的头部取出一个节点,然后将当前节点标记为已访问,然后将这个节点的所有没有被访问过的临接点都加入到队列中(若有),因为队列具有先入先出的性质,这就可以使得我们的节点总是保持当时入队时的相对顺序;若没有了,则遍历完成。
算法执行流程如下图所示:
广度优先算法的伪代码如下:
function bfsSearch(节点){
const map = new Map();
const queue = [];
将节点加入queue中
while(queue不为空) {
const currentNode = queue.shift();
用map将currentNode标记为已访问
for(遍历currentNode的所有临接点) {
取出一个邻接点node
if(邻接点node还没有被访问) {
将邻接点node加入queue中
}
}
}
}
2.3、 回顾与联想,再总结
虽然深度优先算法和广度优先算法是在图论里面才提到的,但是其实它从树的章节就已经出现在我们的视野里面了。
二叉树的递归遍历或非递归遍历就是深度优先算法。树的层序遍历就是广度优先算法。这样,其实又反过来印证了一个道理,树是特殊的图。本文不对树进行过多阐述,不清楚的读者,可以参考作者之前的文章:二叉树
扩展二叉树的知识点:对于N叉树(本文就文件或文件夹目录树的为例),算法实现也是大同小异的。
假设FileSystem对象的定义如下,
interface FileSystem {
// 文件名或者文件夹名
name: number;
// 子文件夹名
directories?: FileSystem[];
}
深度优先递归遍历N叉树:
/**
* N叉树先序递归遍历
* @param {FileSystem[]} list
*/
function dfs(list) {
if (!Array.isArray(list) || list.length === 0) {
console.log("list empty");
return;
}
list.forEach((file) => {
if (Array.isArray(file.directories)) {
dfs2(file.directories);
}
console.log(file.name);
});
}
深度优先非递归遍历N叉树:
/**
* N叉树非递归深度优先遍历(类似二叉树先序遍历)
* @param {TreeNode} list
*/
function dfs(list) {
if (!Array.isArray(list) || list.length === 0) {
console.warn("file list empty");
return;
}
let stack = [];
// 用来记住每个节点的下一个兄弟节点
let nextSiblingMap = new Map();
// 建立下一个兄弟节点的关系
for (let i = 0; i < list.length; i++) {
const curNode = list[i];
const nextNode = list[i + 1] || null;
nextSiblingMap.set(curNode, nextNode);
}
let fileNode = list[0];
while (stack.length || fileNode) {
// 当节点为空时,说明已经迭代到最叶节点了,退出循环
while (fileNode) {
console.log(fileNode.name);
stack.push(fileNode);
let subNodes = Array.isArray(fileNode.directories) ? fileNode.directories : [];
// 每一层都建立下一个兄弟节点的关系
for (let k = 0; k < subNodes.length; k++) {
const curNode = subNodes[k];
const nextNode = subNodes[k + 1] || null;
nextSiblingMap.set(curNode, nextNode);
}
// 下滤节点
fileNode = subNodes[0] || null;
}
if (stack.length) {
fileNode = stack.pop();
// 根据当前节点到map里面找当前节点的下一个兄弟节点
let nextSiblingNode = nextSiblingMap.get(fileNode);
if (nextSiblingNode) {
fileNode = nextSiblingNode;
} else {
// 如果没有下一个兄弟节点了,说明需要回退到父亲节点,父亲节点处理完成之后,准备处理父亲节点的下一个兄弟节点
if (stack.length) {
fileNode = stack.pop();
// 继续切换到父节点的兄弟节点
fileNode = nextSiblingMap.get(fileNode);
} else {
// 已经将所有的节点处理完成,可以功成身退
fileNode = null;
}
}
}
}
}
广度优先遍历N叉树:
/**
* N叉树广度优先遍历
* @param {FileSystem[]} list
*/
function bfs(list) {
if (!Array.isArray(list) || list.length === 0) {
console.log("list empty");
return;
}
const queue = [];
list.forEach((fileNode) => {
queue.push(fileNode);
});
while (queue.length) {
const fileNode = queue.shift();
console.log(fileNode.name);
if (Array.isArray(fileNode.directories)) {
queue.push(...fileNode.directories);
}
}
}
通过我们综合分析已经掌握的知识点,可以看出,深度优先算法和广度优先算法的思想都是一类范式(2.1节和2.2节伪代码)。只不过取决于节点与邻接点通过怎么样的方式建立(二叉树通过left,right指针,本文举例的N叉树以children等)指向关系而已了,实际编码过程中,根据这个关系,简单的调整代码,就可以实现了。
不基于实际情况的话,深度优先算法和广度优先算法没有谁好谁坏的说法,在某些特定情况下,选择对应的算法可能效果会非常好,因此,只要理解到了算法的思路就可以根据实际情况选择对应的算法了。
3、LeetCode两道比较有意思的题
3.1、克隆图
LeetCode第133题 克隆图。 这是一道中等难度的题。
根据题设条件无向连通图,说人话就是说这个图的所有节点都连在一堆的,没几个或一些零散的点存在。
这个题需要考虑一些边界条件,如果给的节点是空的话,说明这个图是空图,直接给出null即可。
另外还有一个最重要的边界条件就是图中会存在循环引用,在前面,我们已经谈到怎么解决循环引用的这个问题了:使用Map标记已访问节点。
在这题中,我们每次克隆节点的时候,Map就把原始节点和克隆节点的关系建立起来,后面,如果谁还想要这个节点,直接从Map中给就好。
深度优先克隆算法是比较容易想到的,我们的递归函数需要带着这个已克隆的映射关系。函数在每次开头的时候,需要判断图节点是否存在或者已经克隆过,如果不存在直接返回,如果已克隆过直接从Map中返回克隆过的节点即可。
使用深度优先的算法实现如下:
/**
* // Definition for a Node.
* function Node(val, neighbors) {
* this.val = val === undefined ? 0 : val;
* this.neighbors = neighbors === undefined ? [] : neighbors;
* };
*/
/**
* @param {Node} node
* @return {Node}
*/
var cloneGraph = function (node, cloneMap = new Map()) {
// 空图,什么都不用做
if(!node) {
return node;
}
// 如果当前节点已经被克隆过了,那么直接就可以返回了,如果还没有,那么就先克隆好了
let cloneNode = cloneMap.get(node);
if (cloneNode) {
return cloneNode;
}
cloneNode = {
val: node.val,
};
// 建立克隆之后,不要忘了建立图的节点映射
cloneMap.set(node, cloneNode);
// 继续克隆图的邻接点
cloneNode.neighbors =
Array.isArray(node.neighbors) && node.neighbors.length
? node.neighbors
.map((nNode) => {
return cloneGraph(nNode, cloneMap);
})
: [];
return cloneNode;
};
广度优先克隆图算法的关键是:每次入队时需要加入成对的待克隆节点和克隆节点(加入时还只是一个空容器),因为引用类型的关系,相当于是先克隆容器,后面再克隆容器里面的内容(补充关系),最终完成克隆。
使用广度优先的算法实现如下:
/**
* @param {Node} node
* @return {Node}
*/
var cloneGraph = function (node) {
if (!node) {
return node;
}
const cloneMap = new Map();
const cloneNode = {
val: node.val,
neighbors: [],
};
cloneMap.set(node, cloneNode);
const queue = [{ source: node, clone: cloneNode }];
while (queue.length > 0) {
const { source, clone } = queue.shift();
if (Array.isArray(source.neighbors)) {
for (let i = 0; i < source.neighbors.length; i++) {
let nextNode = source.neighbors[i];
let nextClone = cloneMap.get(nextNode);
if (typeof nextClone === "undefined") {
nextClone = {
val: nextNode.val,
neighbors: [],
};
// 因为是新克隆的节点,将待下一次clone的关系加入到队列中
queue.push({
source: nextNode,
clone: nextClone,
});
}
// 建立映射关系
cloneMap.set(nextNode, nextClone);
clone.neighbors.push(nextClone);
}
}
}
return cloneNode;
};
因为我们使用的递归方式实现的深度优先克隆算法,需要使用系统的堆栈,因此内存占用会比广度优先占用稍微多一些,但本例选用深度优先算法或广度优先算法均可。
3.2、复制带随机指针的链表
LeetCode第138题 复制带随机指针的链表。
这也是一道中等难度的题。
我最开始乍一看,链表,还带随机指针,什么鬼,感觉一时间无从下手啊。
因为链表的遍历也是一个标准的范式,对于链表不太熟悉的同学可以参考笔者先前的文章:链表,本文不做过多的阐述。
如果先正常的克隆链表,然后把这些个节点关系通过Map维护起来,然后再回过来克隆随机指针,感觉这真的很不专业,哈哈哈,明显正常解法肯定是一个循环拉通,链表遍历完成即拷贝完成。
如果我们跳出链表的思维定式来看的话,它不就是一个图吗?那么,解题思路一下子就变得豁然开朗了。我们用一个Map来建立原始节点和克隆节点的关系,每次都正常的克隆链表的后继节点和随机节点,(因为随机节点又可能有随机节点,这是一个递归的过程。)如果链表的后继节点已经被克隆过,那么我们直接给已克隆的节点即可。然后我们还是按照正常的链表遍历的范式去构建新链表就可以了。
使用深度优先的算法如下:
/**
* // Definition for a Node.
* function Node(val, next, random) {
* this.val = val;
* this.next = next;
* this.random = random;
* };
*/
/**
* @param {Node} head
* @return {Node}
*/
var copyRandomList = function (head) {
if (!head) {
return head;
}
let node = head;
let newHead = null;
let newTail = null;
let copiedRef = new Map();
while (node) {
let copyNode = null;
// 正常遍历连边,构建链表,有可能在克隆随机节点的时候,
// 节点已经被克隆过了,就不用再次克隆了,直接建立指向关系即可。
if (copiedRef.get(node)) {
copyNode = copiedRef.get(node);
} else {
copyNode = {
val: node.val,
random: null,
next: null,
};
copiedRef.set(node, copyNode);
// 无脑克隆随机节点
copyNode.random = copyRandomNode(node.random, copiedRef);
}
if (newHead == null) {
newHead = copyNode;
newTail = copyNode;
} else {
newTail.next = copyNode;
newTail = copyNode;
}
node = node.next;
}
return newHead;
};
/**
* 克隆链表的随机节点
* @param {Node} rndNode 随机节点
* @param {Map<Node, Node>} ref 原始节点和克隆节点的映射关系
* @returns {Node | null}
*/
function copyRandomNode(rndNode, ref) {
if (!rndNode) {
return null;
}
if (ref.get(rndNode)) {
return ref.get(rndNode);
}
let copyNode = {
val: rndNode.val,
next: null,
random: null,
};
ref.set(rndNode, copyNode);
// 如果当前节点还有随机节点,则递归克隆其随机节点
copyNode.random = copyRandomNode(rndNode.random, ref);
return copyNode;
}
在深度优先算法的实现过程中,我们其实还是把这个结构当成链表在做的,但如果我们用广度优先来完成的话,那么就跳出了链表的思维定式了,完全不把链表当链表了,哈哈哈,触类旁通,就是这么豪横啊。
使用广度优先算法实现如下:
/**
* // Definition for a Node.
* function Node(val, next, random) {
* this.val = val;
* this.next = next;
* this.random = random;
* };
*/
/**
* @param {Node} head
* @return {Node}
*/
var copyRandomList = function (head) {
if (!head) {
return head;
}
const copiedRef = new Map();
const newHead = {
val: head.val,
next: null,
random: null,
};
// 初始节点入队
const queue = [
{
source: head,
clone: newHead,
},
];
// 建立映射关系
copiedRef.set(head, newHead);
while (queue.length) {
const { source, clone } = queue.shift();
// 复制next指针
if (source.next) {
let nextNode = copiedRef.get(source.next);
// 如果之前已经复制过了节点,直接给就好
if (typeof nextNode !== "undefined") {
clone.next = nextNode;
} else {
// 否则真正拷贝节点
nextNode = {
val: source.next.val,
next: null,
random: null,
};
clone.next = nextNode;
// 加入到队列中,准备后续的拷贝工作
queue.push({
source: source.next,
clone: nextNode,
});
}
// 建立映射关系
copiedRef.set(source.next, nextNode);
}
// 复制random指针
if (source.random) {
let nextRandom = copiedRef.get(source.random);
// 如果之前已经复制过了节点,直接给就好
if (typeof nextRandom !== "undefined") {
clone.random = nextRandom;
} else {
// 否则真正拷贝节点
nextRandom = {
val: source.random.val,
next: null,
random: null,
};
clone.random = nextRandom;
// 加入到队列中,准备后续的拷贝工作
queue.push({
source: source.random,
clone: nextRandom,
});
}
// 建立映射关系
copiedRef.set(source.random, nextRandom);
}
}
return newHead;
};
从这个广度优先克隆的代码中大家可以再次看到,广度优先就一个范式,代码细节取决于结构之间引用的关系而已,非常简单,哈哈哈。
4、一道前端高频面试题
有些同学可能会说,前文都说的是一些看起来好像跟前端关系不那么大的问题,那些结构我们不太可能用得到啊。
好吧,那就给你们一个无法拒绝的理由吧,😁。
面试官:请分别用深度优先思想和广度优先思想完成一个对象的深拷贝?
这个问题是个老生常谈的问题,我相信各位常年奋战在各种大厂面试中的读者们,一定被面试官问到过吧。因为本文主要阐述的是算法思想,限于篇幅的影响,我们就简化一些问题。
我们不考虑以下情况:
- 1、对象或数组不可枚举的属性;
- 2、原型对象上的属性;
- 3、正则 日期 函数 Map Set bigInt Symbol等类型;
- 4、不考虑 new Number(1)这类的对象;
只考虑基本类型,如[], {}, 1, false, null, undefined, "hello world"这些情况,并且认为我们拷贝的参数一定是一个引用类型。
深度优先算法很容易想到,其思路大致是,根据传入的参数判断是否是一个对象({}还是[]),遍历原对象上所有的属性,如果有对象的话,递归的调用这个拷贝函数,否则直接拷贝值。最终返回新的对象。
在深拷贝的过程中,一个最重要的边界情况就是需要解决循环引用的问题。解决循环引用的办法,已经在上文阐述,此处不再赘述。
深度优先算法实现如下:
/**
* 使用深度优先深克隆对象
* @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;
}
广度优先实现起来不是那么容易,主要是因为需要处理好原始对象和被拷贝对象的关系,所以,每次我们入队的都是原始对象和拷贝的对象。
广度优先算法实现如下:
/**
* 使用广度优先深克隆一个对象
* @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;
}
本文中列举,但没有考虑的情况,有兴趣的同学可以参考lodash深拷贝的实现,欢迎交流。
5、总结
本文是笔者在近期学习图论之后悟出来的一些看法,我的感受就是,数据结构算法这门课的知识点不是孤立存在的,在学习的过程中应该注重理解,举一反三,能得到较好的效果。
我是在实际面试中被问到过深拷贝对象算法的,当时能写出一个最简单的版本来(不能处理循环引用的问题,其实就是似懂非懂,并没有完全的理解这个拷贝过程),但是我在学习图论时体会深度优先算法和广度优先算法的时候,似乎就想明白了这个问题,因此撰写此文向大家分享我的心得体会,希望大家阅读后都能得到提高。
由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。