有向无环图的一个简单布局算法

4,198 阅读7分钟

最近在做一个功能时需要用到类似思维导图的布局方式,为此查了一些资料和开源代码的实现,涨了一波图布局相关的知识。我想实现的功能实际上就是一个有向无环图(DAG,Direct Acyclic Graph),对DAG的布局已有一些较为成熟的算法,如较为常用的Sugiyama, 不过看完这些算法之后,我的状态是这样的:

facemoji

最后还是根据自己的需求,实现了一个相对比较简单,效率也会好很多的一个布局算法。

一、有向无环图布局算法

在此之前先梳理一下现有的布局算法,查阅相关的资料后,大体上一个有向无环图的布局过程经过如下几个阶段:

1. 除环

先分析当前图是否存在环,存在的话根据相应的算法去掉环线,如深度优先遍历图,如果遍历到的一个节点已经在路径上,说明这个边已经成环,对这条边移除或反向(出入度调换)达到除环的目的

2. 分层

完成除环后,接下来是分层,对图中的节点进行整理,按照一定的算法划分层级,每个节点落入到相应的层级上,相关的算法有:

  • 最长路径算法:longest-path
  • 紧凑数算法:tight-tree
  • 网络单纯型算法:network-simplex

3. 节点排序

调整同一层级内的节点顺序,减少交叉边,涉及的算法:

  • 重心算法:barycenter
  • 中心算法:median

4. 计算坐标

确定了节点的层级和序号后,就可以遍历层级和序号分配坐标了

5. 绘制渲染

这一步也比较简单,有了节点的坐标,丢到Canvas中绘制即可

以上的步骤完成后就实现了DAG的自动布局,其中较为复杂的是第2、3两个步骤,相关的感兴趣的同学可自行科普,文末也会提供一些资料链接,总之这块算法的论文看的一头雾水(= =!)。

这方面的开源项目比较出名的是GitHub - dagrejs/dagre,不过这个库已经不维护了,但是源码还是可供学习的,基本实现了上述的布局算法。由于公司开发使用的Flutter ,也调研了下 使用Flutter 开源的项目graphview,项目主页上介绍说也是参考dagre 实现的Flutter 版本。

二、一个简单的布局算法

上面介绍的算法对我来说还不能一下全掌握,光阅读这些资料就花了一天时间,又花了半天时间实现了其中一些算法,实现的过程中总是感觉把事情想的有点复杂了(男人的第六感?),这个想法像野草一样扎根在思想里,没法专注实现那些Expansive的算法,然后停下当前的工作,重新梳理一下需求,得把这根野草拔掉,同时也避免花了大量时间做了无用功。

在重新复盘的时候我发现,我想实现的DAG图有几个特质:

  1. 整个图只有唯一的入度为0的节点;
  2. 图一定是无环的;
  3. 任意节点的出度节点都是有序的;

这几个特点在构造图数据的时候就已经约束好的,那么根据这几个特质能得出什么结论呢?

  1. 入度为0的节点可以作为起始节点,从这个节点开始进行布局;
  2. 图的数据一定是无环的,省去了除环的步骤;
  3. 图的数据节点已经有了顺序,布局时候不能变动这些顺序,所以如果出现了交叉边,也没有关系,省去了节点排序的步骤,因为分层算法主要是对节点排序服务的,同时也因为入度0的节点只有一个,所有的节点都在这后面,已经又了层级关系,所以也省去了分层

实际因为唯一入度0节点的关系,这个图有点像一颗了,不同地方是子节点中可能会有多个入度的情况。

根据以上的特质,重新设计了一套较为简单的算法,算法基础是使用了深度优先遍历(DFS) ,下面以左->右 方向示例算法布局的过程:

1. 将所有的节点布局在起始坐标上

image-20211207134422694

2. 开始深度优先遍历,在遍历路径过程中,同时横向展开节点

image-20211207135337209

第一次遍历的路径是最顶边,遍历到出度0的节点就是整个布局的右上角节点,此时根节点和右上角节点的坐标就已经确定

3. 开始回溯路径遍历节点,同时纵向展开节点

image-20211207135852583

image-20211207135947567

image-20211207135920972

每个节点除了包含自身的position,还包含一个familyPosition ,定义了该节点所有子节点所占据的区域位置,如:

image-20211207141214389

蓝色虚线展示了节点Node1 的自身position位置,红色虚线展示了节点Node1familyPosition的位置。有了familyPosition的概念后,所有的节点都要根据其他节点的familyPosition来定位自身的坐标,否则就会出现重叠的情况。

4. 所有节点的坐标确定好后,连线就可以了

image-20211207141502628

如图所示,如果某个节点有多个入度,该节点的坐标计算根据第一个入度来计算,默认把第一个入度作为父节点

三、代码实现

1. 深度优先遍历计算坐标

   void _dfsSpreadNodes(GraphNodeElement root) {
     //遍历路径
     var walked = <GraphNodeElement>[root];
     //已遍历的节点
     var visited = <GraphNodeElement>[];
     while (walked.isNotEmpty) {
       var currentElement = walked.last;
       var canVisit = true;
       //初始family尾坐标,在遍历过程中不断更新这个值,最后确定最终的familyPosition
       double familyMainEnd = 0, familyCrossEnd = 0;
       if (direction == Axis.horizontal) {
         familyMainEnd = currentElement.familyPosition.right;
         familyCrossEnd = currentElement.familyPosition.bottom;
       } else if (direction == Axis.vertical) {
         familyMainEnd = currentElement.familyPosition.bottom;
         familyCrossEnd = currentElement.familyPosition.right;
       }
       if (currentElement.node.nextList.isNotEmpty) {
         //初始出度节点的尾坐标,更新这个值纵向展开出度节点
         var currentCrossEnd = .0;
         if (direction == Axis.horizontal) {
           currentCrossEnd = currentElement.familyPosition.top;
         } else if (direction == Axis.vertical) {
           currentCrossEnd = currentElement.familyPosition.left;
         }
         //遍历出度节点
         for (var node in currentElement.node.nextList) {
           //if node is in currentNode's family
           //判断当前节点是否为该出度节点的"父节点",
           if (node.prevList.first == currentElement.node) {
             var element = node.element;
             if (!visited.contains(element)) {
               if (direction == Axis.horizontal) {
                 //horizontal spread
                 //横向展开节点
                 var left = currentElement.familyPosition.right + kMainAxisSpace;
                 //vertical spread
                 //纵向展开节点
                 var top = currentCrossEnd;
                 var size = element.size;
                 //更新当前遍历的familyPosition
                 element.familyPosition = RelativeRect.fromLTRB(
                     left, top, left + size.width, top + size.height);
               } else if (direction == Axis.vertical) {
                 //horizontal spread
                 var left = currentCrossEnd;
                 //vertical spread
                 var top = currentElement.familyPosition.top + kMainAxisSpace;
                 var size = element.size;
                 element.familyPosition = RelativeRect.fromLTRB(
                     left, top, left + size.width, top + size.height);
               }
               walked.add(element);
               canVisit = false;
               break;
             } else {
               //已遍历过的节点,更新familyPostion 的区域
               if (direction == Axis.horizontal) {
                 familyCrossEnd =
                     math.max(familyCrossEnd, element.familyPosition.bottom);
                 familyMainEnd =
                     math.max(familyMainEnd, element.familyPosition.right);
                 //update vertical size
                 currentCrossEnd =
                     element.familyPosition.bottom + kCrossAxisSpace;
               } else if (direction == Axis.vertical) {
                 familyCrossEnd =
                     math.max(familyCrossEnd, element.familyPosition.right);
                 familyMainEnd =
                     math.max(familyMainEnd, element.familyPosition.bottom);
                 //update horizontal size
                 currentCrossEnd =
                     element.familyPosition.right + kCrossAxisSpace;
               }
             }
           }
         }
       }
       if (canVisit) {
         //update current node position & family position
         //当前节点的出度节点都遍历完成后,根据更新后的familyPosition,重新计算当前节点的自身position
         var nodeSize = currentElement.size;
         var familyPosition = currentElement.familyPosition;
         if (direction == Axis.horizontal) {
           familyPosition = currentElement.familyPosition
               .copyWith(right: familyMainEnd, bottom: familyCrossEnd);
         } else if (direction == Axis.vertical) {
           familyPosition = currentElement.familyPosition
               .copyWith(right: familyCrossEnd, bottom: familyMainEnd);
         }
         currentElement.position = RelativeRect.fromLTRB(
             familyPosition.left,
             familyPosition.top,
             familyPosition.left + nodeSize.width,
             familyPosition.top + nodeSize.height);
         currentElement.familyPosition = familyPosition;
 ​
         //visit node
         visited.add(currentElement);
 ​
         walked.removeLast();
       }
     }
   }

进行一次DFS遍历后,图中各节点的坐标已经可以渲染出来:

image-20211207143412344

2.接下来根据节点的坐标和边,绘制连线

   void _drawTriArrow(Canvas canvas, Path path, Paint paint) {
     double triHeight = triangleArrowHeight;
     var lastPathMetric = path.computeMetrics().last;
     var t = lastPathMetric.getTangentForOffset(lastPathMetric.length);
     //按当前的向量角度位移,
     var offset =
         Offset(triHeight * math.cos(-t!.angle), triHeight * math.sin(-t.angle));
     //计算三角形指向的顶点
     var tan = Tangent(t.position + offset, t.vector);
 ​
     var triPath = Path()..moveTo(tan.position.dx, tan.position.dy - 0.5);
     var angle = math.pi - 0.463; //(math.atan2(1,2)的值)
     var triHypotenuse = (triHeight / math.cos(0.463)); //边长
     //旋转放大
     var tipVector = _rotateVector(tan.vector, angle) * triHypotenuse;
     var p1 = tan.position + tipVector; //底边顶点
     tipVector = _rotateVector(tan.vector, -angle) * triHypotenuse;
     var p2 = tan.position + tipVector; //底边顶点
     triPath.lineTo(p1.dx, p1.dy);
     triPath.lineTo(p2.dx, p2.dy);
     triPath.close();
     canvas.drawPath(triPath, paint);
   }
 ​
   void render({required Canvas canvas, required Graph graph}) {
     graph.nodes.forEach((node) {
       var nodeElement = node.element;
       node.nextList.forEach((child) {
         _linePath.reset();
         var childElement = child.element;
         if (graph.direction == Axis.horizontal) {
           var start = Offset(nodeElement.position.right,
               nodeElement.position.top + _connectPointOffset);
           var end = Offset(childElement.position.left,
               childElement.position.top + _connectPointOffset);
           _linePath.moveTo(start.dx, start.dy);
           _linePath.cubicTo(
               start.dx + kMainAxisSpace / 2,
               start.dy,
               end.dx - kMainAxisSpace / 2,
               end.dy,
               end.dx - triangleArrowHeight,
               end.dy);
         } else if (graph.direction == Axis.vertical) {}
         canvas.drawPath(_linePath, _paint);
         _drawTriArrow(canvas, _linePath, _trianglePaint);
       });
     });
   }

image-20211207143552114

目前这个布局是顶部对齐,如果想居中对齐也很简单,将上面DFS算法中计算自身节点坐标的部分改成中间节点即可:

Before

    currentElement.position = RelativeRect.fromLTRB(
             familyPosition.left,
             familyPosition.top,
             familyPosition.left + nodeSize.width,
             familyPosition.top + nodeSize.height);

After

  currentElement.position = RelativeRect.fromLTRB(
             familyPosition.left,
             familyPosition.top +
                 (familyPosition.bottom - familyPosition.top - nodeSize.height) /
                     2,
             familyPosition.left + nodeSize.width,
             familyPosition.top +
                 (familyPosition.bottom - familyPosition.top + nodeSize.height) /
                     2);

image-20211207144222911

整个算法过程只使用了一次DFS,并且是有向图,所以时间复杂度为O(n+e)O(n+e),即节点数+边数的线性时间。

项目源码:flow_graph: 基于Flutter实现的流程图布局

Refrence

[0]  Improbable Emancipation: The Sugiyama Layout Algorithm (Hierarchical Algorithm) for dummies )

[1]  BarthMutzelJuenger2004.8.2.dvi

[2]  Fast and Simple Horizontal Coordinate Assignment

[3]  一种画有向图的技术

[4]  深入解读Dagre布局算法

[5]  浅谈图的层次布局