前言
解决问题思维方式
假设我们有一整套螺丝刀,要进行笔记本清灰操作,我们主要的思维逻辑如下:
- 若要清灰,必须先取出风扇
- 若要取出风扇,必须先把从外壳到风扇的螺丝全部拆下
那么清灰问题就变成了拆一堆不同规格的螺丝,当我们看到不同规格的螺丝,就会比较螺丝口大小、形状和螺丝刀规格,从而选取对应的螺丝刀。
可以看出,当我们遇到一个复杂问题,下意识的思维方式就是将一个复杂问题,转移成我们熟知的一些子问题
问题模型的遍历
选择对应螺丝刀的过程,也有不同的选择方式:
- 较笨的方式:每遇到一个螺丝,遍历每个螺丝刀,选择合适的螺丝刀
- 更好的方式:将螺丝刀分类(一字头,十字头),遇到对应的螺丝口(如十字头),遍历对应的(十字头)螺丝刀,选择合适的螺丝刀
- 熟练工的选择方式:熟练工甚至记住了每个螺丝口对应的螺丝刀,看到螺丝口就能立马反应出应该使用哪个螺丝刀
可以看出,当我们遇到一个问题时,会遍历脑中的工具箱 去寻找合适的解决工具,先将工具分类,遇到对应的问题时,可以根据问题特征快速找到对应分类,从而搜索对应的工具。
当熟练度更高时,我们能更细化的了解每个工具的特征,遇到对应的细节,能更快反应去用哪种工具解决问题。
小结
为了解决一个复杂问题,我们的总体思路如下(和在网络上与人对线思路一致):
- 将问题简化成我们所熟知的多个子问题(将对方智商拉到和自己一个水平)
- 用现有的工具去匹配这些子问题,并加以解决(利用自己丰富的经验打败对方)
了解到这个过程后,为了具备解决更多问题的能力,我们所要丰富的知识主要有两方面:
- 熟悉更多的问题模型(双指针、树、图....)
- 掌握更多解决问题的工具(二分查找、深度/广度优先遍历、滑动窗口、dp...)
排序
学排序首先都是从冒泡排序开始,冒泡排序的写法自不必说,众所周知,冒泡的复杂度是O(n^2)
,若细分这n*n
的复杂度,可以发现每次获取最大值需要比较n
次,并且一共需要进行n
次这样的最大值比较。
我们知道一个合格的排序算法,复杂度应该是O(nlogn)
,那么这logn
的优化,应该可能来源于两方面:
- 获取最大值的比较时,只需要比较
logn
次 - 一共只需要进行
logn
次这样的获取最大值的比较
因为我们已经知道这些排序算法复杂度是O(nlogn)
,这也就意味着两种优化不可能同时进行,否则排序算法的复杂度是O((logn)^2)
了。
现在,我们将排序算法的优化问题,转换成两个稍微简单点的问题了:
- 如何在
logn
的复杂度下,获取数组的最大值 - 如果只进行
logn
次获取最大值的比较
接下来,需要寻找或者学习某样工具,来让我们知道解决方案
二分查找
见文档二分查找及各类变种题模板
通过二分查找这个工具的学习,我们知道了存在logn
的复杂度下,获取有向 数组某个值的方法,那么如何得到有向 数组呢?我们很容易想到插入排序。
插入排序会始终维护一个有向数组,那么将这个有向数组,结合二分查找,便能得到一个O(nlogn)
复杂度的算法。
树结构的应用
分治法
分治法在日常生活和工作中经常会用到,公司高管不会和基层员工直接对接,我们平时面对复杂业务需求时,也习惯将一个复杂需求分解成多个更为简单的子需求。
分治的效果是,公司的CEO只用听几个高管的汇报,项目管理只需要监督几个主要需求的进度,更多细节由下一层的管理者/需求进行同样的管理操作,作为一个完成最小任务的员工,我们明显能感觉到,相较于一个大的复杂业务,逐个解决最小任务明显心智负担更小,事实上这么拆分任务,确实也常常能在更短工期内将任务解决。
排序中分治法最典型的应用是归并排序,其代码很简单:
js
function mergeSort(ar) {
const combine = (arr, l, half, r) => {
const lArr = arr.slice(l, half + 1).concat(Infinity); // 哨兵
const rArr = arr.slice(half + 1, r + 1).concat(Infinity);
for (let i = l, lp = 0, rp = 0; i <= r; i++) {
arr[i] = lArr[lp] <= rArr[rp] ? lArr[lp++] : rArr[rp++];
}
};
const splitCombine = (arr, l, r) => {
if (l >= r) return;
const half = Math.floor((l + r) / 2);
splitCombine(arr, l, half);
splitCombine(arr, half + 1, r);
combine(arr, l, half, r);
};
splitCombine(ar, 0, ar.length - 1);
return ar;
}
归并排序代码逻辑暂时不做赘述,网上相关内容很多,这里需要明确一个问题:分治降低了问题的复杂度,具体是降低了哪个方面的复杂度?
先从分析归并排序复杂度入手,可以发现:
- 每一次合并,复杂度仍是
O(n)
- 二分法进行数组拆分,总合并次数为
O(logn)
之前提到,排序的优化可以从两方面入手(每次比较大复杂度,比较次数),和结合了二分查找的插入排序不同,归并排序的优化体现在比较次数上。
结合了二分查找的插入排序能优化每次比较大复杂度,这个很好理解,但为什么分治法能减少比较次数?
这个主要体现在两方面:
- 子问题规模的减小
- 问题的复杂度往往是随着规模增大,而呈现指数型的增长,以解决复杂问题为例:一个复杂中假设有
n
个全局变量,我们调整某个全局变量的值时,往往需要考虑到这n
个全局变量,但如果理清了业务逻辑,将复杂任务拆分成多个子任务,调整一个全局变量后,只需要考虑这个变量对于其父节点的影响,需要考虑的变量数量就变成了logn
- 问题的复杂度往往是随着规模增大,而呈现指数型的增长,以解决复杂问题为例:一个复杂中假设有
- 避免重复计算
- 回到归并排序,归并排序的
combine
操作需要将两个有序的子数组合并成一个更大的有序数组,注意这里子数组也是有序的,这一次合并时,无需再重新对子数组进行排序,这就相当于递进式的完成任务,上一次的工作能为这一次的工作节省时间,而冒泡排序中,上一次最大值的比较对下一次比较基本没有太大帮助,每次比较都需要重复计算一部分内容
- 回到归并排序,归并排序的
回头看我们在工作中,将一个复杂任务拆分成多个子模块,子模块再进一步进行拆分,其实最后在完成项目时,代码量并不会减少,但每一项子任务的复杂度都降低了,复杂度降低同时也意味着维护成本低降低,每次定位问题就像是在二叉搜索树中查找某个值一样,相较于屎山,复杂度直接从O(n)
降低到O(logn)
同样采用了分治法的还有快速排序,快排的代码和复杂度分析这里也不做赘述,直接总结它的特点:
- 其平均复杂度为
O(nlogn)
,但最坏情况复杂度为O(n^2)
- 归并、优化后的插排都需要开辟额外内存空间,快排是原地交换,无需开辟额外内存
将数组当作树来用
这里章节其实有点不好划分,因为二分查找本质上也是将数组当作树来用(类似于二叉搜索树),但平时我们使用二分法时,更多是用数组的方式去理解,所以这里将它和数组当作树的方法区分开来。
对于二叉搜索树来说,找到某个值只需要log(n)
,因为我们可以根据查找值和节点值的大小,判断下一步应该从节点左侧还是右侧查找,那么这个特性如果用在数组中,是否也能达到将算法优化到O(nlogn)
的效果?
首先分析一下数组排序 这件事,排序不涉及到数组长度的变化,且由于数组的特性,我们可以在O(1)
时间内找到数组中的第n
项,结合完全二叉 树的特性,如果给节点进行编号,我们可以轻松的知道编号n
的节点,其左节点编号为2*n+1
,如果将数组构建成一个平衡二叉树 (不是真的将数组转换成树,而是根据index
在逻辑上将数组当作树),那么我们就能在O(logn)
的时间内找到数组中的某个值。
这种方式用于数组排序就是堆排序,其思路如下:
- 构建大顶堆(根节点永远比左右节点大)
- 将最大值和队尾数字交换,再重新构建大顶堆,再次构建时,我们可以清楚知道顶部节点是和左节点交换还是右节点交换,因此只需
O(logn)
次交换
代码如下:
js
/**
* 共分为三部:
* 1. 创建大顶堆
* 2. 顶部和尾部换位置,缩小heapSize, 重复大顶堆化
* 3. heapSize缩小至0即全部排序完毕
* @param {Array} arr
* @returns arr
*/
function heapSort(arr) {
let heapSize = arr.length - 1;
const swap = (ar, i, j) =>{[ar[i], ar[j]] = [ar[j], ar[i]]};
const maxHeapify = (ar, i) => {
const left = 2*i + 1;
const right = 2*i + 2;
let largest = i;
if(left<= heapSize && ar[left]>ar[largest]) {
largest = left
}
if(right<=heapSize && ar[right]>ar[largest]) {
largest = right
}
if(largest!==i) {
swap(ar, i, largest)
maxHeapify(ar, largest)
}
}
const buildMaxHeap = (ar)=>{
for(let i=~~((ar.length-1)/2);i>=0;i--) {
maxHeapify(ar, i)
}
}
buildMaxHeap(arr);
while(heapSize>0) {
swap(arr, 0, heapSize);
heapSize--
maxHeapify(arr, 0)
}
return arr
}
由于堆排序是一次次寻找第n大的元素,因此遇到数组中第n大元素这类题目时,能更快得到答案,同时堆排序也是原地替换,空间复杂度低。
排序相关内容小结
- 优化方向
- 减少每次获取最大值时大复杂度
- 减少比较次数
- 优化思路
- 分治(归并)
- 结合树结构(二分查找、堆排)
- 启发
- 工作中遇到复杂问题时,采用分治法将其分解成多个子任务,能有效降低复杂度
- 获得技能
- 二分查找
- 关键字"数组中第n大"这类问题的解决方案(堆排序,其实快排也能解决这类问题,不过快排写起来容易出错,有兴趣看网上相关资料)
树与图
抽象认知
树相关的问题其实如果递归理解得好,无论是各种遍历,还是剪枝、回溯等操作,实现起来都很简单。
图相对于树而言,最大的区别就是存在环 ,如果直接遍历,很容易在环中鬼打墙,现实中走迷宫时,我们都知道在走过的路线上做标记(记住走过路线的特征也是在脑中做标记),避免重复绕圈,图也是一样,如果将走过的路线存起来,每次选择下一条路线时,只选择没走过的路线,就能避免鬼打墙的情况。
因此解决树和图的问题之前,我们先要学会两件事:
- 解决晕递归
- 学会缓存法
晕递归问题
这里先不做经验性的总结,感性认知虽然更容易接受,但遇到更复杂的情况,比如双递归,或者是在工作中遇到实现webpack插件、eslint插件的需求时,有时需要操作AST树的节点,复杂情况甚至需要操作n重的环形引用(A方法引用方法B,B引用C,C又引用A),这时如果仅仅是对递归有感性认知,很容易出错或者出现死循环。
首先我们需要知道递归是什么。
递归是站在人的角度上解释代码,电脑只知道带着怎么样的上下文,跳到哪一行代码,其本质上也是一种循环。可以参考youtube上这个视频(作者PPT地址),视频讲述了如何采用模拟栈的方式,将单递归和尾递归转换成循环的写法,编译器用模拟栈的方式,将人理解的递归转换成机器可以理解的循环。
循环和递归其实有些像用初中高中的数学知识和用线性代数解方程,对于二元一次方程,往往用初高中的数学解法更为容易,但当问题变成m元n次方程,就必须借用线性代数相关知识,但本质上初高中数学的解法也能用线性代数去解释。
言归正传,我们用while
循环类比递归,常规while
循环结构如下:
js
while(循环条件) {
问题的降解
}
while
循环需包括两个部分:
- 每次执行while都是一步将问题降解的过程
- 拥有跳出循环的条件,否则将变成死循环
这两个特征套用在递归中,递归的模板应该如下:
js
function recursion(n) {
if(不满足条件) return;
问题的降解
recursion(n-1); // 降解后的递归,这里n-1是为了让下一次递归知道问题的规模已经缩小,类似于for循环中的i
}
知道通用模板之后,我们应该怎么去分析问题?以查找数组的最大值为例,其实可以用流程图辅助分析
js
const findMaxVal = (arr) => {
let max;
const findMaxValInnerFunc = (idx) => {
// 检查是否超出限制,是则返回max
if(idx>=arr.length) return max;
// 比较当前值和最大值
max = Math.max(arr[idx], max||0);
// 进入下一次比较
return findMaxValInnerFunc(idx+1)
}
return findMaxValInnerFunc(0)
}
console.log(findMaxVal([1,2,3,4,8,2,1])) // 8
画好流程图后,很容易实现一一对应的递归代码,当面对复杂问题时,先画流程图,再按照流程图进行代码实现或许是个不错的方式。
同样的流程,尝试一下树的剪枝操作,我们将对象看作树,需要剪除对象{a: 1, b: {c: null, d: {e: undefined}, f: undefined}}
对象上,所有值为undefined
、null
或者空对象{}
的节点,剪枝后的对象应为{a: 1}
(这里b被整个剪除,因为其所有子节点都被剪除了),流程图如下:
js
const prune = (obj) => {
const isObj = (val) => Object.prototype.toString.call(val) === '[object Object]';
const needToPrune = (val) => [null, undefined].includes(val) || (isObj(val) && !Object.keys(val).length);
// 边界处理
if(!isObj(obj)) return obj;
// 此处开始,按照流程图操作
const pruneFunc = (key, pNode) => {
const val = pNode[key];
// 是否有子项
if(isObj(val)) {
// 遍历子项
Object.keys(val).forEach(k=>pruneFunc(k, val))
}
if(needToPrune(val)) {
// 对于满足条件的项进行剪枝
delete pNode[key]
}
}
// 封装传入对象,使其符合函数参数定义
pruneFunc("root",{root: obj});
return obj
}
console.log(prune({a: 1, b: {c: null, d: {e: undefined}, f: undefined}}))
之前的项目中有用同样的方式,梳理函数引用是否形成环的判断逻辑(见:eslint内存泄漏排查工具开发)
树的回溯(递归概念的练习)
我们知道了递归过程中模拟栈 的概念(对应的就是前端的闭包),它保证了函数体中的变量都有独立的内存空间存储,那么函数外的变量呢?如果外部定义了一个数组,执行递归函数时会往这个数组中push
了一个值,必然会对下一次递归调用产生影响,这就是回溯操作利用的特性。
直接上n叉树
的回溯的通用模板:
js
const visitCallback = (node, parentPath) => {
// do someting...
}
const traverse = (root, visit) => {
const path = []
const traverseNode = (node) => {
visit(node, path);
path.push(node); // 重点
(node?.children??[]).forEach(traverseNode)
path.pop(); // 重点
}
traverseNode(root)
}
traverse(node, visitCallback)
注意这里的path.push(node)
和path.pop()
,这就像是给一个公司的人买奶茶,规定大家喝完之后将空杯放回原处,如果每个人都严格执行,那么最后可以得到位置原封不动的空杯。
回溯的本质就是用一个递归外部的数据结构存放我们需要的变量,每次递归操作完成后将变量复原,就像买奶茶的例子一样,每个人都喝到了奶茶,但大家都将空杯放回了原处,所以杯子位置不会有变化。
缓存法
前端需求开发过程中,如果没开发维护过树组件,或许很少用树结构,但Object
对象的各种操作肯定没法避免,Object
对象可以看作多叉树,而循环引用的对象就是典型的图结构。 前端八股文中无法避免的一题就是深拷贝,这里不考虑日期、函数等特殊类型,仅贴一段普通对象深拷贝的代码:
js
function cloneDeep(data) {
const isReference = (val) => val instanceof Object;
// 防止循环引用,利用weakMap缓存已拷贝的对象
const cache = new WeakMap();
const cloneData = (val) => {
if (!isReference(val)) return val;
if (cache.has(val)) return cache.get(val);
// val类型未知,可能是class,因此根据constructor new一个对象
const ref = new val.constructor();
// 先将创建的ref放入cache,若先递归再放,会陷入无限递归
cache.set(val, ref);
// Object.getOwnPropertyDescriptors获取val所有项,包括enumerable为false,以及原型链上自己定义的对象
for (const key in Object.getOwnPropertyDescriptors(val)) {
ref[key] = cloneData(val[key]);
}
return ref;
};
return cloneData(data);
}
为了防止循环引用,代码中定义了一个cache,用于记录已经复制过的对象,这里需要注意的是,我们应该在执行下一次递归之前将复制过的对象放入cache,否则将陷入死循环。 利用同样的思路,可以尝试一下经典的迷宫问题。
缓存法与动态规划
如果想在最短的时间内了解动态规划,缓存法无疑是第一选择
前面我们提到过,当遇到一个复杂问题时,可以通过将其分解成多个子问题,从而降低思维难度。那么应该如何分解?
动态规划本质上是思路的转变。和高中学习数学归纳法类似,面对一个问题,思路变成了如果要解决这个问题,我需要证明什么,这些需要证明的内容,就是这个问题的子问题。
以最经典的爬楼梯问题为例,正向思考往往比较复杂,如果反过来想,如果想跳到最后一级台阶,那么最后一次跳跃应该是从哪一级台阶开始?不难得出只能从第n-1
级和第n-2
级开始跳,那么我们可以得到最基础的代码:
js
var climbStairs = function(n) {
// ...
return climbStairs(n-1) + climbStairs(n-2)
};
但以递归的角度来看,这种写法明显是有问题的,根据我们前面总结的内容,递归需要包含两项特点:
- 每次执行都降解了问题的复杂度
- 有跳出递归的判定
显然这短代码缺少了跳出递归的判断,因此加上判定后,代码如下:
js
var climbStairs = function(n) {
if(n===1) return 1
if(n===2) return 2
return climbStairs(n-1) + climbStairs(n-2)
};
执行测试用例,可以发现这个函数已经可以得到正确结果了,但又会发现一个新的问题:当n
的数值较大时,运算将变得特别慢。
这种多重递归,如果画图表示的话,可以发现它呈树形展开,也就是复杂度会随n
的增加呈现指数增长。但仔细看会发现这些计算很多都是重复的,例如计算climbStairs(n-1) + climbStairs(n-2)
时,计算climbStairs(n-1)
必须先算climbStairs(n-2)
,而后面又要再算一遍climbStairs(n-2)
,那么为什么不直接将每次的计算结果缓存起来?
js
var climbStairs = function(n) {
const cache = {};
const calc = (m) => {
if(m===1) return 1;
if(m===2) return 2;
if(m in cache) return cache[m];
const result = calc(m-1) + calc(m-2);
cache[m] = result;
return result;
}
return calc(n);
};
当然,如果为了优化空间复杂度,也可以采用循环的方式,用两个变量分别记录第m
、m+1
级台阶的结果,直至m==n
,但缓存法作为动态规划的入门更为直观。
动态规划的本质其实就和做项目一样,将一个复杂问题转换成多个子问题(解决晕递归问题后,这部分的代码实现将会很简单),再利用缓存法解决重复的计算过程,理清整个思路后,可以进一步用循环的方式优化空间复杂度。
树与图相关知识点小结
获得技能
- 晕递归的辅助解决方案
- 回溯操作
- 缓存法和动态规划的入门知识
启发
- 学会反向思考,遇到问题时,拆解子问题的思路变成了:为了解决这个问题,我需要解决哪几个子问题
- 前一章节我们知道了分治法可以降低问题的复杂度,这一章知道了遇到复杂问题,如何进行分治
工具的积累
前两章内容解决了思路问题,后面需要掌握的是具体的工具,如滑动窗口、位运算、字典树、单调栈......
后续将会陆续总结更新.