C# 列表结构与树结构互转

在编程工作中,树结构的处理比较常见。
image

假如有一个webapi接口给我们返回的数据是下面形式--不带层级结构的:

public class Info
{
    public int id { get; set; }
    public int? pid { get; set; }
    public string label { get; set; }
}

static List<Info> Mock()
{
    List<Info> infos = new List<Info>();
    infos.Add(new Info() { id = 1, label = "一级 1" });
    infos.Add(new Info() { id = 2, label = "一级 2" });
    infos.Add(new Info() { id = 3, label = "一级 3" });
    infos.Add(new Info() { id = 4, label = "二级 1-1", pid = 1 });
    infos.Add(new Info() { id = 5, label = "二级 2-1", pid = 2 });
    infos.Add(new Info() { id = 6, label = "二级 2-2", pid = 2 });
    infos.Add(new Info() { id = 7, label = "二级 3-1", pid = 3 });
    infos.Add(new Info() { id = 8, label = "二级 3-2", pid = 3 });
    infos.Add(new Info() { id = 9, label = "三级 1-1-1", pid = 4 });
    infos.Add(new Info() { id = 10, label = "三级 1-1-2", pid = 4 });
    return infos;
}

我们需要将其转换成下面这种带有层级结构数据,才可以交给ui组件显示:

public class NodeInfo
{
    public int id { get; set; }
    public string label { get; set; }
    public List<NodeInfo> children { get; set; }
}

如何将不带层级结构的数据(后面简称为:平整结构)转换成带层级结构的数据(后面简称为:树结构)?

平整结构转树结构

有几层就写几层for循环

static List<NodeInfo> BuildTree(List<Info> infos)
{
    List<NodeInfo> trees = new List<NodeInfo>();
    var roots = infos.Where(x => x.pid == null).ToList();
    if (!roots.Any())
    {
        return trees;
    }
    foreach (var root in roots)//一级节点
    {
        var rootNode = new NodeInfo() { id = root.id, label = root.label };
        trees.Add(rootNode);
        var seconds = infos.Where(x => x.pid == root.id);
        if (!seconds.Any())
        {
            continue;
        }
        foreach (var second in seconds)//二级节点
        {
            if (rootNode.children == null)
            {
                rootNode.children = new List<NodeInfo>();
            }
            var secondNode = new NodeInfo() { id = second.id, label = second.label };
            rootNode.children.Add(secondNode);
            var thirds = infos.Where(x => x.pid == second.id);
            if (!thirds.Any())
            {
                continue;
            }
            foreach (var third in thirds)//三级节点
            {
                if (secondNode.children == null)
                {
                    secondNode.children = new List<NodeInfo>();
                }
                var thirdNode = new NodeInfo() { id = third.id, label = third.label };
                secondNode.children.Add(thirdNode);
            }
        }
    }
    return trees;
}

这是一种最简便、快捷的方式;缺点很明显:需事先知道最大层级,无扩展性!!!

递归生成树

static List<NodeInfo> BuildTree(List<Info> infos)
{
    List<NodeInfo> trees = new List<NodeInfo>();
    var roots = infos.Where(x => x.pid == null).ToList();
    if (!roots.Any())
    {
        return infos.Where(x => x != null)
        .Select(x => new NodeInfo() { id = x.id, label = x.label }).ToList();
    }
    foreach (var root in roots)
    {
        var node = new NodeInfo() { id = root.id, label = root.label };
        var children = RecursionChildrens(node, infos);
        if (children != null && children.Any())
        {
            node.children = new List<NodeInfo>(children);
        }
        trees.Add(node);
    }
    return trees;
}

static IEnumerable<NodeInfo> RecursionChildrens(NodeInfo currentNode, List<Info> infos)
{
    if (currentNode == null)
    {
        yield break;
    }
    var children = infos.Where(x => x.pid == currentNode.id);
    if (!children.Any())
    {
        yield break;
    }
    foreach (var child in children)
    {
        if (child == null)
        {
            continue;
        }
        var node = new NodeInfo() { id = child.id, label = child.label };
        var childrens = RecursionChildrens(node, infos);
        if (childrens != null && childrens.Any())
        {
            node.children = new List<NodeInfo>(childrens);
        }
        yield return node;
    }
}

这是大多数人选择使用的方式;优点:可处理多层级! 缺点:层级过深会爆栈!!!

非递归生成树之利用字典

static List<NodeInfo> BuildTree(List<Info> infos)
{
    var dic = new Dictionary<int, NodeInfo>();

    foreach (var info in infos)
    {
        var nodeInfo = new NodeInfo() { id = info.id, label = info.label, pid = info.pid };

        dic.Add(info.id, nodeInfo);
    }

    foreach (var info in dic)
    {
        if (info.Value.pid == null)
        {
            continue;
        }

        //从字典中取出当前数据项的父节点(如果存在,将此节点加入到父节点子节点集合中)
        var parent = dic.ContainsKey(info.Value.pid.Value) ? dic[info.Value.pid.Value] : null;
        if (parent == null)
        {
            continue;
        }

        if (parent.children == null)
        {
            parent.children = new List<NodeInfo>();
        }

        parent.children.Add(info.Value);
    }

    return dic.Values.Where(x => x.pid == null).ToList();
}

将数据装入字典,遍历字典找到父节点id,将此节点加入父级的子节点列表中,最后从字典中筛选中根节点列表就是所需的树列表。
注意:因为没有定义父级,所以树生成后没法从子级找父级;所以树结构比较完备的树结构属性定义应该包含 id、pid、children。

public class NodeInfo
{
    public int id { get; set; }
    
    public int pid { get; set; }//pid也很重要 有了它才能向上查找

    public string label { get; set; }

    public List<NodeInfo> children { get; set; }
    
}

👆这是一种"找父亲"的方式;能否采用“找儿子”的方式🤔️,答案是可以的!接下来我们探索这种方式👇

非递归生成树之利用队列

static List<NodeInfo> BuildTree(List<Info> infos)
{
    List<NodeInfo> trees = new List<NodeInfo>();
    if (infos == null || infos.Count == 0)
    {
        return trees;
    }

    //使用字典维护节点id与直接子节点集合
    Dictionary<int, List<Info>> childDic = new Dictionary<int, List<Info>>();
    var idList = infos.Select(x => x.id).ToList(); //取出所有id
    foreach (var id in idList)
    {
        if (!childDic.ContainsKey(id))
        {
            childDic[id] = new List<Info>();
        }
    }

    //筛选出拥有子节点的元素
    var hasParentList = infos.Where(x => x.pid != null && childDic.ContainsKey(x.pid.Value)).ToList();
    foreach (var childInfo in hasParentList)
    {
        if (childInfo.pid != null) //将此元素加入父id所在的字典项对应的字元素列表中
        {
            childDic[childInfo.pid.Value].Add(childInfo);
        }
    }

    //筛选出无父级的元素(即根节点集合)
    var roots = infos.Where(x => x.pid == null).ToList();
    foreach (var root in roots)
    {
        Queue<NodeInfo> queue = new Queue<NodeInfo>();
        var item = new NodeInfo() { id = root.id, label = root.label };
        queue.Enqueue(item);
        while (queue.Any()) //使用队列进行一层层遍历
        {
            var current = queue.Dequeue();//处理当前节点
            var children = childDic[current.id];
            if (children != null && children.Any())//遍历直接子节点
            {
                foreach (var childInfo in children)
                {
                    var childNodeInfo = new NodeInfo() { id = childInfo.id, label = childInfo.label };
                    if (current.children == null)
                    {
                        current.children = new List<NodeInfo>();
                    }

                    current.children.Add(childNodeInfo); //填充子节点集合

                    queue.Enqueue(childNodeInfo); //子节点入队 推动遍历
                }
            }
        }

        trees.Add(item);
    }

    return trees;
}

小结:以上介绍了:知道层数就进行几层for循环,它虽简单易懂,但不具备可扩展性;所以本文探讨了:递归探测子元素的方式,树的层级如果过深,递归会引发栈溢出异常;所以我们又探讨了:构建字典,遍历查找父id,装填此节点到父级的子节点列表的方式,此方式虽比较好用,其使用了向上查找;最后我们探讨了,使用维护了<节点id,子节点列表>的字典,遍历根节点,在每趟遍历中使用队列规避递归的方式实现了树结构的构建。

树结构转列表结构

有将列表结构转成树结构,那就一定有将树结构转成列表结构的需求;这其实算是一种“树的遍历”;
关于树的遍历,根据其形式可分为:深度优先和广度优先

广度优先遍历

由于我比较容易理解的是广度优先,所以下面先介绍一下 广度优先遍历。
image
可以看到这和我们构建树的时候很相像,即处理完本层所以节点后再向下一层进发!!!所以我们要用到队列。--不爱用递归。

static List<NodeInfo> Flatten(List<NodeInfo> trees)
{
    if (trees == null || trees.Count == 0)
    {
        return null;
    }

    var list = new List<NodeInfo>();
    Queue<NodeInfo> queue = new Queue<NodeInfo>();
    foreach (var tree in trees)
    {
        queue.Enqueue(tree);
    }

    while (queue.Count > 0)
    {
        var node = queue.Dequeue();

        if (node.children != null && node.children.Count > 0)
        {
            foreach (var child in node.children)
            {
                queue.Enqueue(child);
            }
        }

        list.Add(node);
    }

    return list;
}

输出:
image

可以看到,广度优先遍历转出来的列表数据顺序与上上一张图我们画的预期一致。

对于深度优先遍历,本应有三种形式 “根、左、右”、“左,根、右”、“左、右、根”;总结就是:根节点出现在前、中、后的叫法。由于本文设计的“树结构”没有左、右子树;故针对深度遍历,本文就不涉及“中序遍历”了!!!
另外本文只介绍先序遍历,后序遍历不再介绍。

深度优先--先序

从根节点出发,先访问第一个子节点,若该子节点还有子节点则继续访问第一个孙子节点;直到访问到的后代节点没有子节点--折返到父级,继续按照上面套路编辑另一个兄弟节点。
image

static List<NodeInfo> Flatten(List<NodeInfo> trees)
{
    if (trees == null || trees.Count == 0)
    {
        return null;
    }

    var list = new List<NodeInfo>();

    Stack<NodeInfo> stack = new Stack<NodeInfo>();

    foreach (var currentTree in trees)
    {
        stack.Push(currentTree);
        while (stack.Count > 0)
        {
            var nodeInfo = stack.Pop();
            list.Add(nodeInfo);
            if (nodeInfo.children != null && nodeInfo.children.Count > 0)
            {
                nodeInfo.children.Reverse(); //这里要逆序一下 否则下一轮出栈的不是第一个字节点
                foreach (var childInfo in nodeInfo.children)
                {
                    stack.Push(childInfo);
                }
            }
        }
    }

    return list;
}

输出:
image

可以看到,输出出来的与我们画的顺序一致。
小结:由树结构转为平整结构我们了解了两个树的遍历方式:深度优先和广度优先遍历。


推荐两个轮子

关于树结构的处理,我找到了两个写的很不错的轮子与大家分享,两者都通过将业务数据转成其定义的具有层级结构的类型,进而实现了树结构的相关操作;对你项目中已有的数据类型入侵性很小!

C# 通用树形数据结构

该博主提供了一个通用的树形数据结构,并提供了很多树结构操作的工具方法,在该博主文章末尾放了工具类源码github地址IHierarchical工具类
如:获取兄弟节点、获取祖先节点、获取后代节点、平整结构转树、树结构转平整结构等操作。
使用示例:

var originNodes = infos.Select(x => new NodeInfo()
{
    id = x.id, pid = x.pid.HasValue?x.pid.Value:-1, label = x.label
}).ToList();
#region 使用Ihierarchical

//使用IHierarchical工具类

List<IHierarchical<NodeInfo>> treeNodes1 = new List<IHierarchical<NodeInfo>>();
//列表结构转树结构
foreach (var rootNode in originNodes.Where(x=>x.pid==-1))
{
    var treeNode=rootNode.AsHierarchical(SelectorChildren);
    treeNodes1.Add(treeNode);
}

//树结构转列表结构
var flattenList1 = new List<NodeInfo>();
foreach (var nodeInfo in treeNodes1)
{
    flattenList1.AddRange(nodeInfo.AsEnumerable(EnumerateType.Bfs).Select(x=>x.Current));
}

IEnumerable<NodeInfo> SelectorChildren(NodeInfo nodeInfo)
{
    return originNodes.Where(x => x.pid == nodeInfo.id);
}

#endregion

优点:对你的业务数据侵入性不大,树结构定义清晰,树结构操作该有的都有了;性能上:几乎所有集合结果都是“懒加载”类型,内存友好。

Masuit.Tools工具库

类库--作者:ldqk(懒得勤快)提供了很多轮子,其中就包括树结构的处理

var originNodes = infos.Select(x => new NodeInfo()
{
    id = x.id, pid = x.pid.HasValue?x.pid.Value:-1, label = x.label
}).ToList();

#region 使用ITree
//列表结构转树结构
var treeNode2=  originNodes.ToTreeGeneral(_=>_.id, _=>_.pid,-1);
//树结构转列表结构
var flattenListBfs2 = treeNode2.Flatten()?.Select(x=>x.Value).ToList();
#endregion

优点:一行搞定树和列表结构互转;对业务实体侵入性不高,你的实体类,可以实现ITree、ITreeEntity等接口,也可以不实现。

总结

本文上半部分介绍了手动实现树结构与平整结构的互转,从新手到老鸟大概都会使用哪种方法;最后,推荐给大家两个轮子。
本文示例代码放到github 列表结构与树结构互转

posted @ 2025-06-07 22:37  BigBosscyb  阅读(28)  评论(0)    收藏  举报