萌新学算法 - 简易 diff 算法

680 阅读7分钟

什么是 diff 算法

diff算法主要是用于对比 2个数据的差异,在框架中,都有对其的应用,主要用于对比新旧节点,从而决定具体更新的内容。当然本文并不会涉及到框架中的具体 diff 实现。

当然 diff 算法相对于大部分场景来讲是用不到的,但是对于在工作中,我们有一个新增和编辑的复杂表单,我们这时候,就需要去对比 新旧数据差异,从而找到更新的内容。

实现思路

diff算法过程中,主要有几种情况,需要考虑:

  • 新旧节点相同,并且位置也一样,这种直接跳过就行
  • 新旧节点不同,这里的不同主要有 2 种情况,
    • 新旧节点不同,并且新旧节点内都不存在
    • 新旧节点不同,但是在新旧节点里面都有,只是位置发生了变化
  • 只有新节点
  • 只有旧节点

这里我们假设所有数据在数组中唯一,这样的话实现起来反而会更简单,如果具有重复的话,要考虑的要复杂的很多(这也是 Vue、React 需要传 key 的原因)。

先定义一个函数

interface A {
  [key: string]: unknown
}

type ArrayNode = A[] | [] | ArrayNode[] | Array<number | string | boolean>

interface replace {
  newIndex: number | undefined
  oldIndex: number | undefined
  val: number | string | A | undefined
}

interface Result {
  add: ArrayNode
  remove: ArrayNode
  replace: replace[] | []
}

/**
 *
 * @param {*} newNode 新节点
 * @param {*} oldNode 旧节点
 * @param {*} key 如果是数组对象的话,应该传 key,用于判断是否相等
 * @returns
 *  add 表示新增 如果没有新增 为 空数组
 *  remove 表示删除 如果没有删除 为空数组
 *  replace 表示 如果只是位置变化,则说明之前的位置 和之后的位置,以及索引
 */

const diff = (newNode: ArrayNode, oldNode: ArrayNode, key?: string): Result => {
  // show me your code
  
  return {
    // 新增的
    add,
    // 删除的
    remove,
    // 如果 2个数组值一样,只是位置发生了改变,那应该返回之前的和之后的结果
    replace
  }
}

测试用例

/**
    1 在新旧节点都存在,并且位置并没发生变化,所以我们不做任何操作
    2 的话,因为新节点存在,而旧节点并没 2,所以我们认为他是新增的,这时候要放到 add 里面
    3 因为 新节点有,并且旧节点也存在,我们需要记录 新旧节点的位置,并放到 replace 里面
    4 7 因为 只有旧节点有,而且新节点没有,所以我们需要把这 2 个放到 remove 里面
    {
        add: [2],
        remove: [4, 7],
        replace: [{
            newIndex: 2,
            oldIndex: 1,
            val: 3
        }]
    }
*/
diff([1, 2, 3], [1, 3, 4, 7])
/**
    这里 因为 每个值都没发生变化,所以 add remove replace 都为 空数组
    {
        add: [],
        remove: [],
        replace: []
    }
*/
diff([2, 4], [2, 4])
// 同理,这个新旧节点值都一样,只用记录新旧节点位置即可,既不新增也不删除
diff([1, 2, 3], [3, 2, 1])
diff([2, 4], [1, 2, 6])
diff(
  [
    {
      id: 1,
      name: '第一个'
    },
    {
      id: 3,
      name: '第三个'
    }
  ],
  [
    {
      id: 5,
      name: '第五个'
    }
  ],
  'id'
)

具体实现

现在我们来实现下这个函数

const diff = (newNode: ArrayNode, oldNode: ArrayNode, key?: string): Result => {
   // 新节点的长度
  const newLen = newNode.length
  // 旧节点的长度
  const oldLen = oldNode.length
  // 新增的节点
  const add = []
  // 删除的节点
  const remove = []
  // 值一样,位置发生变化的节点
  const replace = []
  
  // 这里其实用的是双指针的思路,定义 2 个变量,用来维护当前的索引
  let n1 = 0 // 新节点的索引
  let n2 = 0 // 旧节点的索引
  
  // 循环判断 如果 n1 < 新节点的长度 或者 n2 小于旧节点的长度,我们就开始循环
  while (n1 < newLen || n2 < oldLen) {
  }
}

第一种情况:

  • 新旧节点相同,并且位置也一样,这种直接跳过就行
  • 新旧节点不同,这里的不同主要有 2 种情况,
    • 新旧节点不同,并且新旧节点内都不存在
    • 新旧节点不同,但是在新旧节点里面都有,只是位置发生了变化

实现:

// 查找指定区间的结果
const findRange = (
  arr: unknown[],
  start: number,
  end: number,
  callback: (current: unknown, index: number) => boolean
) => {
  let result = undefined

  for (let i = start; i < end; i++) {
    const find = callback(arr[i], i)

    if (find) {
      result = {
        data: arr[i],
        index: i
      }
      break
    }
  }

  return result
}

const diff = (newNode: ArrayNode, oldNode: ArrayNode, key?: string): Result => {
  // ...
  // 循环判断 如果 n1 < 新节点的长度 或者 n2 小于旧节点的长度,我们就开始循环
  while (n1 < newLen || n2 < oldLen) {
      // 如果 新旧节点相等, 并且位置相等,则不做处理 直接跳过,新旧节点索引 + 1
      if (newNode[n1] === oldNode[n2] && n1 === n2) {
        n1++
        n2++
        continue
      } else {
        /**
           节点 查找 新节点的值在旧节点是否存在,没有则说明不存在
           这里 实现了一个 findRange 方法用于在数组中,查找指定区间的值
           开始区间为 n2 是因为 在 n2 之前的值,都已经被处理过了,
           所以我们不需要在处理之前的结果
        */
        const result = findRange(
          oldNode,
          n2,
          oldLen,
          current => newNode[n1] === current
        )
           
        // 存在 记录新旧节点的索引,添加到 replace 数组里面,调整新节点的索引
        if (result) {
          replace.push({
            newIndex: n1,
            oldIndex: result.index,
            val: result.data
          } as never)
          n1++
          /**
              这里只调整了新节点的索引是因为,我们当前旧节点索引是 1,
              而我们 在旧数组查找到存在的值的索引是 3,
              这时候我们调整 n2 的话,在 3 之前的值可能就不会被处理
              如果这时候,刚好 n1 当前的索引 和我们查找的索引相同的话,
              就不会有问题,这样我们依然可以保证之后的值都能被处理到
          */
          if (oldNode[n2] === oldNode[result.index]) {
            n2++
          }
          continue
        } else {
          // 不存在说明新增,添加到 add 数组里面,并 n1++
          add.push(newNode[n1] as never)
          n1++
          continue
        }
      }
  }
}

现在 新旧节点都存在的情况,我们已经处理过了,目前还剩下:

  • 只有新节点
  • 只有旧节点

对于这 2 种,情况,其实也很简单,对于 新节点,我们直接添加到 add 数组内 即可,对于 旧节点的情况,我们需要判断当前节点 是否在 replace 内存在,因为我们可能在之前就已经处理过了,如果不存在 则 添加到 remove 里面 即可

const diff = (newNode: ArrayNode, oldNode: ArrayNode, key?: string): Result => {
  // ...
  // 循环判断 如果 n1 < 新节点的长度 或者 n2 小于旧节点的长度,我们就开始循环
  while (n1 < newLen || n2 < oldLen) {
     // ...
    // 只有新节点存在
      if (newNode[n1]) {
        add.push(newNode[n1] as never)
        n1++
        continue
      }
      // 只有旧节点存在
      if (oldNode[n2]) {
        // 判断旧节点的值,在 replace 内是否存在,如果存在 则不做处理
        // 这里 其实可以做个优化,因为我们 数据是唯一的,所以我们可以实现个二分查找
        // 通过 二分查找的形式,可以更快的查找到结果,不过我们 需要先对 数组排个序才可以
        // 因为 二分 要求 数据必须有序才行
        // 不存在 则记录 当前的值,并 n2++
        const some = replace.some(
          (item: replace) => item.val[key] === oldNode[n2][key]
        )
        if (!some) {
          remove.push(oldNode[n2] as never)
        }
        n2++
        continue
      }
  }
}

这样的话,我们对于整个 diff 算法就算完成了一半,目前还剩数组是对象的情况并没处理,思路和之前一样,我们通过 key 来判断数据是否唯一,因为在实际场景中,我们并不需要去判断对象的每个属性是否发生了变化,把之前的代码 在CV 一份即可,并加上 key 的处理。

const diff = (newNode: ArrayNode, oldNode: ArrayNode, key?: string): Result => {
  // show me your code
  const newLen = newNode.length
  const oldLen = oldNode.length
  const add = []
  const remove = []
  const replace = []

  let n1 = 0
  let n2 = 0

  if (key) {
    while (n1 < newLen || n2 < oldLen) {
      // 新旧节点都存在
      if (newNode[n1] && oldNode[n2]) {
        // 如果 新 旧 节点相等,则不做处理
        if (newNode[n1][key] === oldNode[n2][key] && n1 === n2) {
          n1++
          n2++
          continue
        } else {
          // 在旧节点 查找 新节点是否存在,没有则说明不存在
          const result = findRange(
            oldNode,
            n2,
            oldLen,
            current => newNode[n1][key] === current[key]
          )

          if (result) {
            replace.push({
              newIndex: n1,
              oldIndex: result.index,
              val: result.data
            } as never)
            n1++
            if (oldNode[n2][key] === oldNode[result.index][key]) {
              n2++
            }

            continue
          } else {
            add.push(newNode[n1] as never)
            n1++
            continue
          }
        }
      }
      // 只有新节点存在
      if (newNode[n1]) {
        add.push(newNode[n1] as never)
        n1++
        continue
      }
      // 只有旧节点存在
      if (oldNode[n2]) {
        const some = replace.some(
          (item: replace) => item.val[key] === oldNode[n2][key]
        )
        if (!some) {
          remove.push(oldNode[n2] as never)
        }
        n2++
        continue
      }
    }
  } else {
    while (n1 < newLen || n2 < oldLen) {
      // 新旧节点都存在
      if (newNode[n1] && oldNode[n2]) {
        // 如果 新 旧 节点相等,则不做处理
        if (newNode[n1] === oldNode[n2] && n1 === n2) {
          n1++
          n2++
          continue
        } else {
          // 在旧节点 查找 新节点是否存在,没有则说明不存在
          const result = findRange(
            oldNode,
            n2,
            oldLen,
            current => newNode[n1] === current
          )

          if (result) {
            replace.push({
              newIndex: n1,
              oldIndex: result.index,
              val: result.data
            } as never)
            n1++
            if (oldNode[n2] === oldNode[result.index]) {
              n2++
            }
            continue
          } else {
            add.push(newNode[n1] as never)
            n1++
            continue
          }
        }
      }
      // 只有新节点存在
      if (newNode[n1]) {
        add.push(newNode[n1] as never)
        n1++
        continue
      }
      // 只有旧节点存在
      if (oldNode[n2]) {
        const some = replace.some((item: replace) => item.val === oldNode[n2])
        if (!some) {
          remove.push(oldNode[n2] as never)
        }
        n2++
        continue
      }
    }
  }

  return {
    add,
    remove,
    // 如果 2个数组一样,只是位置发生了改变,那应该返回之前的和之后的结果
    replace
  }
}

最终效果

image.png

至此,我们就算是实现了一个简易的 diff 算法,现在来分析下 时间复杂度和空间复杂度。

  • 时间复杂度
    • 因为我们用了一个 while 循环,并且 循环次数取决于 新旧数组的长度,所以时间复杂度为O(m + n) 其中,m 是新数组的长度,n 是旧数组的长度
  • 空间复杂度
    • 因为我们只使用了常量级别的数组和变量,用于记录 保存的数据,所以 空间复杂度 为 O(m + n)

总结

目前接触算法断断续续 2-3 个月了,目前刷了 85 题的样,至少就我目前的想法,算法也不算很难,绝大部分不需要太高深的知识,相对在学习算法的过程中,也了解了很多有趣的算法。如回溯、DFS、BFS、BST(二叉搜索树)、二分等。

  • 二分
    • 我们每次只用查找一半的数据,就可以拿到结果
  • 双指针
    • 这个常见的题比如两数相加,暴力查找的话,双重循环搞定,但是如果用双指针,我们一层循环就可以做到
  • DFS 深度优先遍历
    • 这个主要应用在二叉树和岛屿问题,通过 DFS 我们可以在递归的过程中,标记当前已经处理过的数据,从而在下次递归的过程中,不在处理数据,岛屿基本这种方案,
    • 二叉树 DFS 主要分为前中后序遍历,我们可以一路到底,或者从底部开始处理。
  • BFS 广度优先遍历
    • 这个其实也算是二叉树的一种遍历方式,在处理二叉树层级的过程中,我们通常采用这种方式,相比 DFS 来讲,写起来更简单。
  • 回溯
    • 这个目前还在学习(头疼的一笔),算是 DFS 的加强版,每次做出选择后,可以回退之前选择的结果,并在此重试,知道找出所有解
  • DP 动态规划 (目前还没看,有空再说)

学习算法可以有效提高编码质量,通过各种算法可以解决一些相对复杂的场景,还可以加薪,从而给自己一个更好的选择。