常用树形结构算法分享

2,642 阅读8分钟

image.png

1.树介绍:

树(英语:tree)是一种抽象数据类型(ADT),用来模拟具有树状结构性质的数据集合。 它是由n(n>=1)个有限节点组成一个具有层次关系的集合。 把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。

image.png 二叉树:每一个节点最多只有两个子节点; 完全二叉树:特殊的二叉树,完全二叉树的特点:叶子结点只能出现在最下层和次下层,且最下层的叶子结点集中在树的左部。需要注意的是,满二叉树肯定是完全二叉树,而完全二叉树不一定是满二叉树;

image.png

二叉搜索树(BST、二叉查找树):节点的左子节点小于父节点,节点的右子节点大于父节点。

image.png

红黑树:就是一种平衡二叉树,说它平衡的意思是它不会出现左子树与右子树的高度之差不会大于1,左子树和右子树保持一种平衡的关系。每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色。

image.png

2.递归处理

在开始将树之前必须要先说一下树的递归处理,将从后端的数据转换为统一格式的结构。

function handleTreeData(data) {
  let arr = [];
  data.forEach((item) => {
    arr.push({
      ...item,
      title: item.name,
      key: item.id,
      children:
        item.children && item.children.length
          ? handleTreeData(item.children)
          : [],
    });
  });
  return arr;
}

let treedata = [
  {
    name: "1-1",
    id: 1,
    parentId: 0,
    children: [
      {
        name: "1-1-1",
        id: 2,
        parentId: 1,
      },
    ],
  }
];

3.遍历树:

树结构的常用场景之一就是遍历,而遍历又分为广度优先遍历、深度优先遍历。 其中深度优先遍历是可递归的,而广度优先遍历是非递归的,通常用循环来实现。 深度优先遍历又分为先序遍历、后序遍历,二叉树还有中序遍历,实现方法可以是递归,也可以是循环。

广度优先(BFC):

BFS是从根节点开始,沿着树的宽度遍历树的节点。 如果所有节点均被访问,则算法中止。

let tree = [
  {
    id: "1",
    title: "节点1",
    children: [
      {
        id: "1-1",
        title: "节点1-1",
      },
      {
        id: "1-2",
        title: "节点1-2",
      },
    ],
  },
  {
    id: "2",
    title: "节点2",
    children: [
      {
        id: "2-1",
        title: "节点2-1",
      },
    ],
  },
];

// 广度优先
function treeForeach(tree, func) {
  let node;
  let list = [...tree];
  while ((node = list.shift())) {
    func(node); 
    node.children && list.push(...node.children);
    //每次将节点的children元素push到list后面,不断循环,知道最后一层
  }
}
treeForeach(tree, (node) => {
  console.log(node.title);
});

深度优先(DFC):

image.png 这三种遍历算法可以参考网上详细内容

4.树形数据结构化

一般是运用的知识是函数、递归、数组的常用操作,学习树形结构的基本算法,这样便于以后我们可以更好的处理业务逻辑。

1.列表转换为树:

一个扁平数据列表,我们找到对应的关键字段,比如parentId和id的对应关系;

let list = [
  {
    id: "1",
    title: "节点1",
    parentId: "",
  },
  {
    id: "1-1",
    title: "节点1-1",
    parentId: "1",
  },
  {
    id: "1-2",
    title: "节点1-2",
    parentId: "1",
  },
  {
    id: "2",
    title: "节点2",
    parentId: "",
  },
  {
    id: "2-1",
    title: "节点2-1",
    parentId: "2",
  },
  {
    id: "3-1",
    title: "节点3-1",
    parentId: "2-1",
  },
];

//网上简易版
function listToTree(list) {
  let info = list.reduce(
    (map, node) => ((map[node.id] = node), (node.children = []), map),
    {}
  );
  return list.filter((node) => {
    info[node.parentId] && info[node.parentId].children.push(node);
    return !node.parentId;
  });
}
// console.log(listToTree(list));

//新手理解版
function formatDataTree(list) {
  let parents = list.filter((p) => p.parentId === "");
  let childrens = list.filter((c) => c.parentId !== "");

  dataToTree(parents, childrens);

  return parents;
  function dataToTree(parents, childrens) {
    parents.map((p) => {
      childrens.map((c, i) => {
        if (c.parentId === p.id) {
          let _children = JSON.parse(JSON.stringify(childrens));
          _children.splice(i, 1);
          dataToTree([c], _children);
          if (p.children) {
            //判断父元素是否存在children
            p.children.push(c);
          } else {
            p.children = [c];
          }
        }
      });
    });
  }
}

console.log(formatDataTree(list));

2.树转换为列表:

演示效果:ant.design/components/…

常用在使用表格来展示树,之前是后端帮我们处理好,前端也可以自己做。

let tree = [
  {
    id: "1",
    title: "节点1",
    children: [
      {
        id: "1-1",
        title: "节点1-1",
      },
      {
        id: "1-2",
        title: "节点1-2",
      },
    ],
  },
  {
    id: "2",
    title: "节点2",
    children: [
      {
        id: "2-1",
        title: "节点2-1",
      },
    ],
  },
];
//方法一:递归实现
function treeToList(tree, result = [], level = 0) {
  tree.forEach((node) => {
    result.push(node);
    node.level = level + 1;
    node.children && treeToList(node.children, result, level + 1);
  });
  return result;
}
console.log(treeToList(tree))

// 方法二:循环实现
//   function treeToList (tree) {
//     let node, result = tree.map(node => (node.level = 1, node))
//     for (let i = 0; i < result.length; i++) {
//       if (!result[i].children) continue
//       let list = result[i].children.map(node => (node.level = result[i].level + 1, node))
//       result.splice(i+1, 0, ...list)
//     }
//     return result
//   }

//方法三:网上简易版
function treeToList(tree) {
  return (tree || []).reduce((total, current) => {
    const { children, ...obj } = current;
    return total.concat(obj, treeToList(children));
  }, []);
}

5.树结构查找

查找节点:

查找节点其实就是一个遍历的过程,遍历到满足条件的节点则返回,遍历完成未找到则返回null。类似数组的>find方法,传入一个函数用于判断节点是否符合条件,这里我们使用的是深度优先,代码如下: 应用场景:目录树搜索功能。

下面是从:深度优先和广度优先两个方面讲。

let tree = [
  {
    id: "1",
    title: "节点1",
    children: [
      {
        id: "1-1",
        title: "节点1-1",
      },
      {
        id: "1-2",
        title: "节点1-2",
      },
    ],
  },
  {
    id: "2",
    title: "节点2",
    children: [
      {
        id: "2-1",
        title: "节点2-1",
      },
    ],
  },
];

//深度优先
function treeFind(tree, func) {
  for (const data of tree) {
    if (func(data)) return data; //有就直接返回
    if (data.children) { //没有就进去子节点查找,递归调用
      const res = treeFind(data.children, func);
      if (res) return res;
    }
  }
  return null; //都没找到,返回null
}
function findsingle(data) {
  if (data.title === "节点1-2") {
    return true;
  }
}

console.log(treeFind(tree,findsingle)) //{ id: '1-2', title: '节点1-2' }
//广度优先
function treeFind(tree, func) {
  for (const data of tree) {
    if (func(data)) return data //直接循环第一层的所有节点
  }

  const chidrens = tree.reduce((total, current) => {
    return total.concat(current.children || []);
  }, []);
 
  const res =  treeFind(chidrens, func);
  if (res) return res;
  return null;
}

查找节点路径:

略微复杂一点,因为不知道符合条件的节点在哪个子树,要用到回溯法的思想。查找路径要使用先序遍历,维护>一个队列存储路径上每个节点的id,假设节点就在当前分支,如果当前分支查不到,则回溯。

let tree = [
  {
    id: "1",
    title: "节点1",
    children: [
      {
        id: "1-1",
        title: "节点1-1",
      },
      {
        id: "1-2",
        title: "节点1-2",
      },
    ],
  },
  {
    id: "2",
    title: "节点2",
    children: [
      {
        id: "2-1",
        title: "节点2-1",
      },
    ],
  },
];
function treeFindPath(tree, func, path = []) {
  if (!tree) return []; //空判断
  for (const data of tree) {
    path.push(data.id);
    if (func(data)) return path;
    if (data.children) {
      const findChildren = treeFindPath(data.children, func, path);
      if (findChildren.length) return findChildren;
    }
    path.pop();
  }
  return [];
}

function findsingle(data) {
  if (data.title === "节点1-2") {
    return true;
  }
}

console.log(treeFindPath(tree, findsingle, (path = []))) //[ '1', '1-2' ]

查找多条节点路径

思路与查找节点路径相似,不过代码却更加简单:

let tree = [
  {
    id: "1",
    title: "节点1",
    children: [
      {
        id: "1-1",
        title: "节点1-1",
      },
      {
        id: "1-2",
        title: "节点1-2",
      },
    ],
  },
  {
    id: "2",
    title: "节点2",
    children: [
      {
        id: "2-1",
        title: "节点2-1",
      },
    ],
  },
];
function treeFindPath(tree, func, path = [], result = []) {
  for (const data of tree) {
    path.push(data.id);
    func(data) && result.push([...path]);
    data.children && treeFindPath(data.children, func, path, result);
    path.pop();
  }
  return result;
}

function findsingle(data) {
  if (data.title === "节点1-2") {
    return true;
  }
}
console.log(treeFindPath(tree, findsingle)) //[ [ '1', '1-2' ] ]

6.其他常用算法

树的最大深度

求树的最大深度是写拖拽的时候,用到的;使用场景,实现最多5层拖拽树,当拖动节点到当前节点下的时候, 需要判断拖拽之后会不会超过5层,这是时候需要计算拖拽节点的深度和当前节点的深度,不能超过5.

let treedata = {
  name: "1-1",
  id: 1,
  parentId: 0,
  children: [
    {
      name: "1-1-1",
      id: 2,
      parentId: 1,
      children: [],
    },
    {
      name: "1-1-3",
      id: 4,
      parentId: 1,
      children: [
        {
          name: "1-1-3-1",
          id: 5,
          parentId: 4,
          children: [],
        },
        {
          name: "1-1-3-2",
          id: 6,
          parentId: 4,
          children: [],
        },
      ],
    },
  ],
};
maxDepth = (root) => {
  if (root === null) {
    return 0;
  }
  var max = 0;
  for (var i = 0; i < root.children.length; i++) {
    max = Math.max(max, maxDepth(root.children[i]));
  }
  return max + 1;
};

树的总节点个数

应用场景:主要针对树的展开层级,如果节点比较多的话,全部展开的话 页面效果不好,根据当前页面的高度 和节点个数,看展开的层级。

let treedata = [
  {
    name: "1-1",
    id: 1,
    parentId: 0,
    children: [
      {
        name: "1-1-1",
        id: 2,
        parentId: 1,
        children: [],
      },
      {
        name: "1-1-3",
        id: 4,
        parentId: 1,
        children: [
          {
            name: "1-1-3-1",
            id: 5,
            parentId: 4,
            children: [],
          },
          {
            name: "1-1-3-2",
            id: 6,
            parentId: 4,
            children: [],
          },
        ],
      },
    ],
  },
];

let leafcount = 0;
function treeNodecount(data) {
  let arr = [];
  data.forEach((item) => {
    leafcount++;
    arr.push({
      children:
        item.children && item.children.length
          ? treeNodecount(item.children)
          : [],
    });
  });
  return arr;
}

treeNodecount(treedata)
console.log(leafcount)

树结构筛选

树结构过滤即保留某些符合条件的节点,剪裁掉其它节点。一个节点是否保留在过滤后的树结构中,取决于它以>及后代节点中是否有符合条件的节点。可以传入一个函数描述符合条件的节点:

let tree = [
  {
    id: "1",
    title: "节点1",
    children: [
      {
        id: "1-1",
        title: "节点1-1",
      },
      {
        id: "1-2",
        title: "节点1-2",
      },
    ],
  },
  {
    id: "2",
    title: "节点2",
    children: [
      {
        id: "2-1",
        title: "节点2-1",
      },
    ],
  },
];

function treeFilter(tree, func) {
  // 使用map复制一下节点,避免修改到原树
  return tree
    .map((node) => ({ ...node }))
    .filter((node) => {
      node.children = node.children && treeFilter(node.children, func);
      return func(node) || (node.children && node.children.length);
    });
}

function findsingle(data) {
  if (data.title === "节点1") {
    return true;
  }
}
console.log(treeFilter(tree,findsingle)) 
// {
//     id: "1"
//     title: "节点1",
//     children:[]
// }

根据条件从树形结构中解析数据:

应用场景:在一堆的产品客户层级中,找到我们想要的类型的数据,返回的数据是平级的,这样可以使用表格或者其他来展示出我们想要看的结果。

let tree = [
  {
    name: "1-1",
    id: 1,
    parentId: 0,
    type: "user",
    children: [
      {
        name: "1-1-1",
        id: 2,
        parentId: 1,
        children: [],
        type: "manager",
      },
      {
        name: "1-1-3",
        id: 4,
        parentId: 2,
        type: "user",
        children: [
          {
            name: "1-1-3-1",
            id: 5,
            parentId: 4,
            children: [],
            type: "manager",
          },
          {
            name: "1-1-3-2",
            id: 5,
            parentId: 4,
            children: [],
            type: "manager",
          }
        ],
      },
    ],
  },
];

function parseTreeData(tree) {
  return (tree || []).reduce((total, current) => {
    const { type, children, ...obj } = current;
    if (type === "manager") {
      return total.concat(obj);
    }
    const paserChildren = parseTreeData(children);

    return total.concat(paserChildren);
  }, []);
}

console.log(parseTreeData(tree));

往树中增、删、改、查节点:

这个相对比较简单,例如插入思路:获取当前节点,然后将被插入的节点作为当前节点的children数据就可以了。 其他删、改、查都是运用递归处理树数据,在递归的过程中,做一些操作。

最后:

如果你对树的算法很感兴趣,可以点开下面的地址,学习更多的算法知识。 树-leetcode 刷题链接leetcode-cn.com/tag/tree/