前言

解决问题思维方式

假设我们有一整套螺丝刀,要进行笔记本清灰操作,我们主要的思维逻辑如下:

  1. 若要清灰,必须先取出风扇
  2. 若要取出风扇,必须先把从外壳到风扇的螺丝全部拆下

那么清灰问题就变成了拆一堆不同规格的螺丝,当我们看到不同规格的螺丝,就会比较螺丝口大小、形状和螺丝刀规格,从而选取对应的螺丝刀。

可以看出,当我们遇到一个复杂问题,下意识的思维方式就是将一个复杂问题,转移成我们熟知的一些子问题

问题模型的遍历

选择对应螺丝刀的过程,也有不同的选择方式:

  • 较笨的方式:每遇到一个螺丝,遍历每个螺丝刀,选择合适的螺丝刀
  • 更好的方式:将螺丝刀分类(一字头,十字头),遇到对应的螺丝口(如十字头),遍历对应的(十字头)螺丝刀,选择合适的螺丝刀
  • 熟练工的选择方式:熟练工甚至记住了每个螺丝口对应的螺丝刀,看到螺丝口就能立马反应出应该使用哪个螺丝刀

可以看出,当我们遇到一个问题时,会遍历脑中的工具箱去寻找合适的解决工具,先将工具分类,遇到对应的问题时,可以根据问题特征快速找到对应分类,从而搜索对应的工具。

当熟练度更高时,我们能更细化的了解每个工具的特征,遇到对应的细节,能更快反应去用哪种工具解决问题。

小结

为了解决一个复杂问题,我们的总体思路如下(和在网络上与人对线思路一致):

  1. 将问题简化成我们所熟知的多个子问题(将对方智商拉到和自己一个水平)
  2. 用现有的工具去匹配这些子问题,并加以解决(利用自己丰富的经验打败对方)

了解到这个过程后,为了具备解决更多问题的能力,我们所要丰富的知识主要有两方面:

  1. 熟悉更多的问题模型(双指针、树、图....)
  2. 掌握更多解决问题的工具(二分查找、深度/广度优先遍历、滑动窗口、dp...)

排序

学排序首先都是从冒泡排序开始,冒泡排序的写法自不必说,众所周知,冒泡的复杂度是O(n^2),若细分这n*n的复杂度,可以发现每次获取最大值需要比较n次,并且一共需要进行n次这样的最大值比较。

我们知道一个合格的排序算法,复杂度应该是O(nlogn),那么这logn的优化,应该可能来源于两方面:

  1. 获取最大值的比较时,只需要比较logn
  2. 一共只需要进行logn次这样的获取最大值的比较

因为我们已经知道这些排序算法复杂度是O(nlogn),这也就意味着两种优化不可能同时进行,否则排序算法的复杂度是O((logn)^2)了。

现在,我们将排序算法的优化问题,转换成两个稍微简单点的问题了:

  1. 如何在logn的复杂度下,获取数组的最大值
  2. 如果只进行logn次获取最大值的比较

接下来,需要寻找或者学习某样工具,来让我们知道解决方案

二分查找

见文档二分查找及各类变种题模板

通过二分查找这个工具的学习,我们知道了存在logn的复杂度下,获取有向数组某个值的方法,那么如何得到有向数组呢?我们很容易想到插入排序

插入排序会始终维护一个有向数组,那么将这个有向数组,结合二分查找,便能得到一个O(nlogn)复杂度的算法。

树结构的应用

分治法

分治法在日常生活和工作中经常会用到,公司高管不会和基层员工直接对接,我们平时面对复杂业务需求时,也习惯将一个复杂需求分解成多个更为简单的子需求。

分治的效果是,公司的CEO只用听几个高管的汇报,项目管理只需要监督几个主要需求的进度,更多细节由下一层的管理者/需求进行同样的管理操作,作为一个完成最小任务的员工,我们明显能感觉到,相较于一个大的复杂业务,逐个解决最小任务明显心智负担更小,事实上这么拆分任务,确实也常常能在更短工期内将任务解决。

排序中分治法最典型的应用是归并排序,其代码很简单:

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)

之前提到,排序的优化可以从两方面入手(每次比较大复杂度,比较次数),和结合了二分查找的插入排序不同,归并排序的优化体现在比较次数上。

结合了二分查找的插入排序能优化每次比较大复杂度,这个很好理解,但为什么分治法能减少比较次数

这个主要体现在两方面:

  1. 子问题规模的减小

    • 问题的复杂度往往是随着规模增大,而呈现指数型的增长,以解决复杂问题为例:一个复杂中假设有n个全局变量,我们调整某个全局变量的值时,往往需要考虑到这n个全局变量,但如果理清了业务逻辑,将复杂任务拆分成多个子任务,调整一个全局变量后,只需要考虑这个变量对于其父节点的影响,需要考虑的变量数量就变成了logn
  2. 避免重复计算

    • 回到归并排序,归并排序的combine操作需要将两个有序的子数组合并成一个更大的有序数组,注意这里子数组也是有序的,这一次合并时,无需再重新对子数组进行排序,这就相当于递进式的完成任务,上一次的工作能为这一次的工作节省时间,而冒泡排序中,上一次最大值的比较对下一次比较基本没有太大帮助,每次比较都需要重复计算一部分内容

回头看我们在工作中,将一个复杂任务拆分成多个子模块,子模块再进一步进行拆分,其实最后在完成项目时,代码量并不会减少,但每一项子任务的复杂度都降低了,复杂度降低同时也意味着维护成本低降低,每次定位问题就像是在二叉搜索树中查找某个值一样,相较于屎山,复杂度直接从O(n)降低到O(logn)

同样采用了分治法的还有快速排序,快排的代码和复杂度分析这里也不做赘述,直接总结它的特点:

  1. 其平均复杂度为O(nlogn),但最坏情况复杂度为O(n^2)
  2. 归并、优化后的插排都需要开辟额外内存空间,快排是原地交换,无需开辟额外内存
将数组当作树来用
这里章节其实有点不好划分,因为二分查找本质上也是将数组当作树来用(类似于二叉搜索树),但平时我们使用二分法时,更多是用数组的方式去理解,所以这里将它和数组当作树的方法区分开来。

对于二叉搜索树来说,找到某个值只需要log(n),因为我们可以根据查找值和节点值的大小,判断下一步应该从节点左侧还是右侧查找,那么这个特性如果用在数组中,是否也能达到将算法优化到O(nlogn)的效果?

首先分析一下数组排序这件事,排序不涉及到数组长度的变化,且由于数组的特性,我们可以在O(1)时间内找到数组中的第n项,结合完全二叉树的特性,如果给节点进行编号,我们可以轻松的知道编号n的节点,其左节点编号为2*n+1,如果将数组构建成一个平衡二叉树(不是真的将数组转换成树,而是根据index在逻辑上将数组当作树),那么我们就能在O(logn)的时间内找到数组中的某个值。

这种方式用于数组排序就是堆排序,其思路如下:

  1. 构建大顶堆(根节点永远比左右节点大)
  2. 将最大值和队尾数字交换,再重新构建大顶堆,再次构建时,我们可以清楚知道顶部节点是和左节点交换还是右节点交换,因此只需O(logn)次交换

代码如下:

/**
 * 共分为三部:
 * 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大元素这类题目时,能更快得到答案,同时堆排序也是原地替换,空间复杂度低。

排序相关内容小结

  1. 优化方向

    • 减少每次获取最大值时大复杂度
    • 减少比较次数
  2. 优化思路

    • 分治(归并)
    • 结合树结构(二分查找、堆排)
  3. 启发

    • 工作中遇到复杂问题时,采用分治法将其分解成多个子任务,能有效降低复杂度
  4. 获得技能

    • 二分查找
    • 关键字"数组中第n大"这类问题的解决方案(堆排序,其实快排也能解决这类问题,不过快排写起来容易出错,有兴趣看网上相关资料)

树与图

抽象认知

树相关的问题其实如果递归理解得好,无论是各种遍历,还是剪枝、回溯等操作,实现起来都很简单。

图相对于树而言,最大的区别就是存在,如果直接遍历,很容易在环中鬼打墙,现实中走迷宫时,我们都知道在走过的路线上做标记(记住走过路线的特征也是在脑中做标记),避免重复绕圈,图也是一样,如果将走过的路线存起来,每次选择下一条路线时,只选择没走过的路线,就能避免鬼打墙的情况。

因此解决树和图的问题之前,我们先要学会两件事:

  1. 解决晕递归
  2. 学会缓存法

晕递归问题

这里先不做经验性的总结,感性认知虽然更容易接受,但遇到更复杂的情况,比如双递归,或者是在工作中遇到实现webpack插件、eslint插件的需求时,有时需要操作AST树的节点,复杂情况甚至需要操作n重的环形引用(A方法引用方法B,B引用C,C又引用A),这时如果仅仅是对递归有感性认知,很容易出错或者出现死循环。

首先我们需要知道递归是什么。

递归是站在人的角度上解释代码,电脑只知道带着怎么样的上下文,跳到哪一行代码,其本质上也是一种循环。可以参考youtube上这个视频作者PPT地址),视频讲述了如何采用模拟栈的方式,将单递归和尾递归转换成循环的写法,编译器用模拟栈的方式,将人理解的递归转换成机器可以理解的循环。

循环和递归其实有些像用初中高中的数学知识和用线性代数解方程,对于二元一次方程,往往用初高中的数学解法更为容易,但当问题变成m元n次方程,就必须借用线性代数相关知识,但本质上初高中数学的解法也能用线性代数去解释。

言归正传,我们用while循环类比递归,常规while循环结构如下:

while(循环条件) {
  问题的降解
}

while循环需包括两个部分:

  1. 每次执行while都是一步将问题降解的过程
  2. 拥有跳出循环的条件,否则将变成死循环

这两个特征套用在递归中,递归的模板应该如下:

function recursion(n) {
  if(不满足条件) return;
  问题的降解
  recursion(n-1); // 降解后的递归,这里n-1是为了让下一次递归知道问题的规模已经缩小,类似于for循环中的i
}

知道通用模板之后,我们应该怎么去分析问题?其实可以用流程图辅助分析

找最大值.png

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}}对象上,所有值为undefinednull或者空对象{}的节点,剪枝后的对象应为{a: 1}(这里b被整个剪除,因为其所有子节点都被剪除了),流程图如下:
剪枝.png

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叉树的回溯的通用模板:

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对象可以看作多叉树,而循环引用的对象就是典型的图结构。
前端八股文中无法避免的一题就是深拷贝,这里不考虑日期、函数等特殊类型,仅贴一段普通对象深拷贝的代码:

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级开始跳,那么我们可以得到最基础的代码:

var climbStairs = function(n) {
    // ...
    return climbStairs(n-1) + climbStairs(n-2)
};

但以递归的角度来看,这种写法明显是有问题的,根据我们前面总结的内容,递归需要包含两项特点:

  1. 每次执行都降解了问题的复杂度
  2. 有跳出递归的判定

显然这短代码缺少了跳出递归的判断,因此加上判定后,代码如下:

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),那么为什么不直接将每次的计算结果缓存起来?

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);
};

当然,如果为了优化空间复杂度,也可以采用循环的方式,用两个变量分别记录第mm+1级台阶的结果,直至m==n,但缓存法作为动态规划的入门更为直观。

动态规划的本质其实就和做项目一样,将一个复杂问题转换成多个子问题(解决晕递归问题后,这部分的代码实现将会很简单),再利用缓存法解决重复的计算过程,理清整个思路后,可以进一步用循环的方式优化空间复杂度。

树与图相关知识点小结

获得技能
  1. 晕递归的辅助解决方案
  2. 回溯操作
  3. 缓存法和动态规划的入门知识
启发
  1. 学会反向思考,遇到问题时,拆解子问题的思路变成了:为了解决这个问题,我需要解决哪几个子问题
  2. 前一章节我们知道了分治法可以降低问题的复杂度,这一章知道了遇到复杂问题,如何进行分治

工具的积累

前两章内容解决了思路问题,后面需要掌握的是具体的工具,如滑动窗口、位运算、字典树、单调栈......

后续将会陆续总结更新.


goblin_pitcher
590 声望30 粉丝

道阻且长