LCS实现字符串的diff(dp动态规划)

279 阅读3分钟

先上效果

截屏2023-07-16 21.32.43.png

代码思路

对两个字符串使用 LCS 算法,获取最长子串,再通过这个最长子串找出新旧字符串中的其他字符段,对应为新增字符段、删除字符段,再以新增、删除、原有顺序拼接diff字符串。

代码实现

LCS 算法

// lcs 算法,用于找出两端字符串中最长子串,基本思路为动态规划
export function LcsFn(str1, str2) {
  const n1 = str1.length;
  const n2 = str2.length;

  // 建立二维数组,使用动态规划存储子串长度
  const lcsArr = new Array(n1 + 1).fill(null)
  .map((_, index)=>new Array(n2 + 1).fill(0));

  for (let i = 1; i < n1 + 1; i++) {
    for(let j = 1; j< n2 + 1; j++) {
      if(str1[i - 1] === str2[j - 1]) {
        lcsArr[i][j] = lcsArr[i - 1][j - 1] + 1
      }else {
        lcsArr[i][j] = Math.max(lcsArr[i][j - 1], lcsArr[i - 1][j])
      }
    }
  }
  const sameStr = getStr(str1, str2, lcsArr);
  return {str1, str2, lcsArr, sameStr}
}

// 根据二维数组输出子串具体字符
function getStr(str1, str2, lcsArr) {
  const result = []
  let i = str1.length,
      j = str2.length

  while (i > 0 && j > 0) {
    if (str1[i - 1] === str2[j - 1]) {
      result.unshift(str1[i - 1])
      i--
      j--
    } else if (lcsArr[i][j - 1] > lcsArr[i - 1][j]) {
      j--
    } else {
      i--
    }
  }
  return result;
}
let str1 = 'eleocfvfbg';      //  最长相同子串为:'e,l,o,b,g'   -- 长度9
let str2 = 'ledfdkakglowbgked'; // -- 长度14

输出 LCS算法的二维数组 并 找出最长相同子串结果:

截屏2023-07-16 21.33.50.png

渲染字符串差异

根据最长相同子串与新旧字符串,先分别切割出新旧子串中的非相同部分,把他们标记为删除:‘ - ’、新增:‘ + ’、相同:‘ = ’。再按顺序以删除、新增、相同部分拼接,根据标记设定不同的类名。


  /**
   * @param {str1, str2, sameStr}
   * str1: 旧字符串
   * str2: 新字符串
   * sameStr: 新旧字符串的最长子字符串
   *
   * desc:根据相同子串,获取新旧字符串的非最长相同子串的部分,
   *       旧子串部分前面设置为‘ - ’,新字符部分设置为 ‘ + ’,相同部分设置为 ‘ = ’
   *       根据‘- + =’设置不同className
  */
  function getStrPlace(str1, str2, sameStr) {
    let oldSameItem = 0; // 旧字符串的上一个相同子字符节点
    let newSameItem = 0; // 新字符串的上一个相同子字符节点
    let lastOldItem = 0; // 旧字符串的上一个切割节点
    let lastNewItem = 0; // 新字符串的上一个切割节点
    const deleteOldItems = [];  // 存储对比后的删除字符段
    const addNewItems = []; // 存储对比后的新增的字符段

    // 获取旧字符串中删除的字符段
    for (let i = 0; i < str1.length; i++) {
      if(str1[i] === sameStr[oldSameItem]) {
        deleteOldItems.push(i - lastOldItem > 0 ? str1.slice(lastOldItem, i) : '');
        lastOldItem = i + 1;
        oldSameItem++
        if(oldSameItem == sameStr.length) {
          deleteOldItems.push(i < str1.length -1 ? str1.slice(i + 1, str1.length) : '');
          break;
        }
      }
    }

    // 获取新字符串中新增的字符段
    for (let i = 0; i < str2.length; i++) {
      if(str2[i] === sameStr[newSameItem]) {
        addNewItems.push(i - lastNewItem > 0 ? str2.slice(lastNewItem, i) : '');
        lastNewItem = i + 1;
        newSameItem++
        if(newSameItem == sameStr.length) {
          addNewItems.push(i < str2.length -1 ? str2.slice(i + 1, str2.length) : '');
          break;
        }
      }
    }

    // 依次将删除、新增、相同,标记后插入数组
    let index = 0;
    const resultArr = sameStr.reduce((lastValue, currentValue, currentIndex) =>{
      const newArr = [];
      if (deleteOldItems[index]) {
        newArr.push('-');
        newArr.push(deleteOldItems[index]);
      }
      if (addNewItems[index]) {
        newArr.push('+');
        newArr.push(addNewItems[index]);
      }
      newArr.push('=');
      newArr.push(currentValue);
      index++;
      const returnArr = lastValue.concat(newArr);

      // 判断最末尾有无字符串增删,有则插入
      if(currentIndex === sameStr.length - 1) {
        const lastArr = [];
        if (deleteOldItems[index]) {
          lastArr.push('-');
          lastArr.push(deleteOldItems[index]);
        }
        if (addNewItems[index]) {
          lastArr.push('+');
          lastArr.push(addNewItems[index]);
        }
        return returnArr.concat(lastArr);
      }
      return returnArr
    },[]);
    
    // 渲染结果数组
    renderDiffStr(resultArr);
  }
  // 输出resultArr:
  // ['+', 'l', '=', 'e', '+', 'dfdkakg', '=', 'l', '-', 'e', '=', 'o', '-', 'cfvf', '+', 'w', '=', 'b', '=', 'g', '+', 'ked']

接下来渲染 resultArr


  function renderDiffStr(resultArr) {
    const diffStr = document.getElementById("diffStr");

    // 清除子节点,防止多次diff插入的子节点重复
    while (diffStr.firstChild) {
      diffStr.firstChild.remove();
    }

    // 寻找
    for (let i = 0; i < resultArr.length; i++) {
      if(i % 2 == 1) {
        const spanElement = document.createElement('span');
        spanElement.innerHTML = resultArr[i];
        spanElement.className = getElementClass(resultArr[i - 1])
        diffStr.appendChild(spanElement);
      }

    }
  }
  
  function getElementClass(type) {
    console.log(type);
    switch (type) {
      case "-" : return DELETE_ELEMENT;
      case "+" : return ADD_ELEMENT;
      default : return OLD_ELEMENT;
    }
  }

渲染效果:

截屏2023-07-16 21.34.25.png

最终文件(相同文件夹)

Lcs.mjs


    // lcs 算法,用于找出两端字符串中最长子串,基本思路为动态规划
    export function LcsFn(str1, str2) {
      const n1 = str1.length;
      const n2 = str2.length;

      // 建立二维数组,使用动态规划存储子串长度
      const lcsArr = new Array(n1 + 1).fill(null)
      .map((_, index)=>new Array(n2 + 1).fill(0));

      for (let i = 1; i < n1 + 1; i++) {
        for(let j = 1; j< n2 + 1; j++) {
          if(str1[i - 1] === str2[j - 1]) {
            lcsArr[i][j] = lcsArr[i - 1][j - 1] + 1
          }else {
            lcsArr[i][j] = Math.max(lcsArr[i][j - 1], lcsArr[i - 1][j])
          }
        }
      }
      const sameStr = getStr(str1, str2, lcsArr);
      return {str1, str2, lcsArr, sameStr}
    }

    // 根据二维数组输出子串具体字符
    function getStr(str1, str2, lcsArr) {
      const result = []
      let i = str1.length,
          j = str2.length

      while (i > 0 && j > 0) {
        if (str1[i - 1] === str2[j - 1]) {
          result.unshift(str1[i - 1])
          i--
          j--
        } else if (lcsArr[i][j - 1] > lcsArr[i - 1][j]) {
          j--
        } else {
          i--
        }
      }
      return result;
    }

Lcs.html


    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      <div class="input-container">
        <div class="input-old-string">
          <span>请输入旧字符串:</span>
          <input class="inputString" type="text" value="eleocfvfbg">
        </div>
        <div class="input-new-string">
          <span>请输入新字符串:</span>
          <input class="inputString" type="text" value="ledfdkakglowbgked">
        </div>
        <button id="strDiff">字符串diff</button>
      </div>
      <div>
        <h1 class="str1"></h1>
        <h1 class="str2"></h1>
      </div>
      <div class="container">
      </div>
      <div>
        <span>最长相同子串:</span>
        <h1 class="sameStr">
        </h1>
      </div>
      <div>
        <span>diff结果:</span>
        <h1 id="diffStr"></h1>
      </div>
    </body>
    </html>
    <script type="module">
      import { LcsFn } from './Lcs.mjs'

      const DELETE_ELEMENT = 'delete-element';
      const ADD_ELEMENT = 'add-element';
      const OLD_ELEMENT = 'old-element';

      const strDiff = document.getElementById('strDiff');
      const inputString = document.getElementsByClassName('inputString');
      window.onload = () => {
        inputString[0].focus();
      }
      strDiff.onclick = () => update(inputString[0].value, inputString[1].value);

      function update(strOld, strNew) {
        const LcsData = LcsFn(strOld, strNew);
        const {str1, str2, lcsArr, sameStr} = LcsData

        // lcs二维数组展示赋值
        let divEle = document.getElementsByClassName('container');
        while (divEle[0].firstChild) {
          divEle[0].firstChild.remove();
        }
        for (let i = 0; i < lcsArr.length; i++) {
          const divElement = document.createElement('div');
          for (let j = 0; j < lcsArr[0].length; j++) {
            const spanElement = document.createElement('span');
            spanElement.innerHTML = i == 0 ? j : ( j == 0 ? i : lcsArr[i][j]);
            spanElement.className = i == 0 ? 'head' : ( j == 0 ? 'head' : '')
            divElement.appendChild(spanElement);
          }
          divEle[0].appendChild(divElement);
        }

        // lcs前后两字符串展示以及最长相同子串
        const str1Ele = document.getElementsByClassName('str1')[0];
        const str2Ele = document.getElementsByClassName('str2')[0];
        const sameStrEle = document.getElementsByClassName('sameStr')[0];

        str1Ele.innerHTML = str1
        str2Ele.innerHTML = str2
        sameStrEle.innerHTML = sameStr.toString();
        getStrPlace(str1, str2, sameStr);
      }

      /**
       * @param {str1, str2, sameStr}
       * str1: 旧字符串
       * str2: 新字符串
       * sameStr: 新旧字符串的最长子字符串
       *
       * desc:根据相同子串,获取新旧字符串的非最长相同子串的部分,
       *       旧子串部分前面设置为‘ - ’,新字符部分设置为 ‘ + ’,相同部分设置为 ‘ = ’
       *       根据‘- + =’设置不同className
      */
      function getStrPlace(str1, str2, sameStr) {
        let oldSameItem = 0; // 旧字符串的上一个相同子字符节点
        let newSameItem = 0; // 新字符串的上一个相同子字符节点
        let lastOldItem = 0; // 旧字符串的上一个切割节点
        let lastNewItem = 0; // 新字符串的上一个切割节点
        const deleteOldItems = [];  // 存储对比后的删除字符段
        const addNewItems = []; // 存储对比后的新增的字符段

        // 获取旧字符串中删除的字符段
        for (let i = 0; i < str1.length; i++) {
          if(str1[i] === sameStr[oldSameItem]) {
            deleteOldItems.push(i - lastOldItem > 0 ? str1.slice(lastOldItem, i) : '');
            lastOldItem = i + 1;
            oldSameItem++
            if(oldSameItem == sameStr.length) {
              deleteOldItems.push(i < str1.length -1 ? str1.slice(i + 1, str1.length) : '');
              break;
            }
          }
        }

        // 获取新字符串中新增的字符段
        for (let i = 0; i < str2.length; i++) {
          if(str2[i] === sameStr[newSameItem]) {
            addNewItems.push(i - lastNewItem > 0 ? str2.slice(lastNewItem, i) : '');
            lastNewItem = i + 1;
            newSameItem++
            if(newSameItem == sameStr.length) {
              addNewItems.push(i < str2.length -1 ? str2.slice(i + 1, str2.length) : '');
              break;
            }
          }
        }

        // 依次将删除、新增、相同,标记后插入数组
        let index = 0;
        const resultArr = sameStr.reduce((lastValue, currentValue, currentIndex) =>{
          const newArr = [];
          if (deleteOldItems[index]) {
            newArr.push('-');
            newArr.push(deleteOldItems[index]);
          }
          if (addNewItems[index]) {
            newArr.push('+');
            newArr.push(addNewItems[index]);
          }
          newArr.push('=');
          newArr.push(currentValue);
          index++;
          const returnArr = lastValue.concat(newArr);

          // 判断最末尾有无字符串增删,有则插入
          if(currentIndex === sameStr.length - 1) {
            const lastArr = [];
            if (deleteOldItems[index]) {
              lastArr.push('-');
              lastArr.push(deleteOldItems[index]);
            }
            if (addNewItems[index]) {
              lastArr.push('+');
              lastArr.push(addNewItems[index]);
            }
            return returnArr.concat(lastArr);
          }
          return returnArr
        },[]);
        console.log(resultArr);
        // 渲染结果数组
        renderDiffStr(resultArr);
      }

      function renderDiffStr(resultArr) {
        const diffStr = document.getElementById("diffStr");

        // 清除子节点,防止多次diff插入的子节点重复
        while (diffStr.firstChild) {
          diffStr.firstChild.remove();
        }

        // 寻找
        for (let i = 0; i < resultArr.length; i++) {
          if(i % 2 == 1) {
            const spanElement = document.createElement('span');
            spanElement.innerHTML = resultArr[i];
            spanElement.className = getElementClass(resultArr[i - 1])
            diffStr.appendChild(spanElement);
          }

        }
      }

      function getElementClass(type) {
        console.log(type);
        switch (type) {
          case "-" : return DELETE_ELEMENT;
          case "+" : return ADD_ELEMENT;
          default : return OLD_ELEMENT;
        }
      }
    </script>
    <style>
      .container span {
        display: inline-block;
        width: 30px;
        height: 30px;
        box-sizing: border-box;
        border: 1px solid #33333315;
        line-height: 30px;
        text-align: center;
        background-color: #a1f28672;
      }
      .head {
        background-color: #f5c3c3;
        color: #0000003d;
      }
      input {
        border: none;
        outline: none;
      }
      .delete-element {
        color: red;
        text-decoration-line: line-through;
      }
      .add-element {
        color: rgb(9, 235, 9);
      }
      .old-element {
        color: black;
      }
    </style>