数据结构算法知识小结

前言

解决问题思维方式

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

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

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

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)

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

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

这个主要体现在两方面:

  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)次交换

代码如下:

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

排序相关内容小结

  1. 优化方向
    • 减少每次获取最大值时大复杂度
    • 减少比较次数
  2. 优化思路
    • 分治(归并)
    • 结合树结构(二分查找、堆排)
  3. 启发
    • 工作中遇到复杂问题时,采用分治法将其分解成多个子任务,能有效降低复杂度
  4. 获得技能
    • 二分查找
    • 关键字"数组中第n大"这类问题的解决方案(堆排序,其实快排也能解决这类问题,不过快排写起来容易出错,有兴趣看网上相关资料)

树与图

抽象认知

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

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

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

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

晕递归问题

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

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

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

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

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

js 复制代码
while(循环条件) {
  问题的降解
}

while循环需包括两个部分:

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

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

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}}对象上,所有值为undefinednull或者空对象{}的节点,剪枝后的对象应为{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)
};

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

  1. 每次执行都降解了问题的复杂度
  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);
};

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

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

树与图相关知识点小结

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

工具的积累

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

后续将会陆续总结更新.

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端