热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

关于数据结构:数据结构和算法在流程画布中的实际应用

图灵奖的获得者,Pascal之父——NiklausWirth,有个经典说法:“算法+数据结构程序”(Algorithm+DataStructuresPrograms)。咱们以这个说法为思路,看在流程画布这个场景中,如何利用数据结构和算法来解决理论的业务需要。

图灵奖的获得者,Pascal 之父——Niklaus Wirth ,有个经典说法:“算法+数据结构=程序”(Algorithm+Data Structures=Programs)。咱们以这个说法为思路,看在流程画布这个场景中,如何利用数据结构和算法来解决理论的业务需要。

树和图

在数据结构中,对树的形容为:树是n(n≥0)个结点的无限集,它或为空树(n=0);或为非空树,对于非空树T:

  1. 有且仅有一个称之为根的结点;
  2. 除根结点以外的其余结点可分为m(m>0)个互不相交的无限集T1, T2, …, Tm,其中每一个汇合自身又是一棵树,并且称为根的子树(SubTree)。

由上述形容能够看出,树的构造定义是一个递归的定义,即在树的定义中又用到树的定义,它道出了树的固有个性。树罕用的存储构造有:顺序存储、链式存储。

对图的形容为:图G由两个汇合V和E组成,记为G=(V, E),其中V是顶点的有穷非空集合,E是V中顶点偶对的有穷汇合。V(G)为图的顶点汇合,不能够为空;E(G)为图的边汇合,能够为空。如果边集E(G)为有向边的汇合,则称图为有向图;如果边集E(G)为无向边的汇合,则称图为无向图。

图罕用的存储构造有:邻接矩阵、邻接表、十字链表、邻接多重表。

树和图是画布这个场景中关联度最高的两种数据结构(当然,最根底的还是链表构造)。

G6

G6 是一个图可视化引擎。它提供了图的绘制、布局、剖析、交互、动画等图可视化的根底能力,借助 G6 的能力,咱们能够疾速搭建本人的图剖析或图编辑利用。流程画布底层便应用了 antv/g6 来实现图可视化的性能。

依据对其性能应用水平的不同,咱们梳理 G6 的外围概念如下:

G6中对图 Graph 接管的参数定义如下:

 export interface GraphData {
    nodes?: NodeConfig[];
    edges?: EdgeConfig[];
    combos?: ComboConfig[];
    [key: string]: any;
}

官网给出的最简略的疾速开始的 demo 代码片段如下:

 const data = {
  nodes: [
    {
      id: 'node1',
      x: 100,
      y: 200,
    },
    {
      id: 'node2',
      x: 300,
      y: 200,
    },
  ],
  edges: [
    {
      source: 'node1',
      target: 'node2',
    },
  ],
};
 
const graph = new G6.Graph({
  container: 'mountNode',
  width: 800,
  height: 500,
});
graph.data(data);
graph.render();

由下面两段代码能够看出,nodes 和 edges 是对图构造中顶点汇合和边汇合的数据表示,同时通过 combos 字段实现了顶点分组的能力。看到此处,咱们能够得出结论:真正的元素绘制局部其实无需关怀,咱们要做的更多是对顶点集和边集数据的治理。

顶点项中的 x、y 字段是可选的,当数据中没有节点地位信息,或者数据中的地位信息不满足需要时,须要借助一些布局算法对图进行布局。

由G6的外围概念能够看到,框架自身曾经实现了多种经典布局算法,其中不乏以树图为主的脑图布局,但依据需要形容中对UI设计和交互的定义,现有布局算法无奈满足咱们的需要。因而咱们不仅要实现自定义顶点、边,还要实时计算每个顶点的坐标值来实现自定义布局的逻辑。

G6 在提供更高的灵活性的同时,也因解决数据带来了不可避免的复杂性,节点的坐标值计算和节点治理(新增、删除)便是其中的典型场景。

数据结构定义

G6 自身是对图可视化的实现,但流程画布这个场景中,咱们在真正的实现中采纳了链式存储的树结构。

理论开发过程中,对节点的数据类型定义如下:

interface IWorkflowNode {
  id: string;
  type: TWorkflowNodeType;
  params: T;
  parent: string[];
  children: string[];
}

阐明:

  • parent 为父节点援用(以 id 模式存储)
  • children 为子节点援用(以 id 模式存储)

满足二叉数的双向链表构造

算法

咱们须要对数据进行解决的点次要有两个:

  1. 节点坐标计算
  2. 节点删除时,对以后节点、关联边和子树的删除

基于上述的数据结构定义,实现上述两点性能均用到同一个算法:二叉树的深度优先、前序遍历算法(采纳递归解法)。

如上图,如果采纳深度优先、前序遍历算法,则拜访节点的程序应为:1、2、4、8、5、9、3、6、7。

到此为止,咱们的准备常识曾经全副给出,接下来是具体的实现细节。

实现

假如当初有如下图所示的一张画布:

nodes 数据细节如下(疏忽了局部与坐标计算无关的字段)。

[
  {
    id: 'root-1630463843227',
    type: 'start',
    parent: [],
    children: ['root-1630463843227-0'],
  },
  {
    id: 'root-1630463843227-0',
    type: 'strategy',
    parent: ['root-1630463843227'],
    children: ['root-1630463843227-0-0', 'root-1630463843227-0-1'],
  },
  {
    id: 'root-1630463843227-0-0',
    type: 'strategy',
    parent: ['root-1630463843227-0'],
    children: ['root-1630463843227-0-0-0', 'root-1630463843227-0-0-1'],
  },
  {
    id: 'root-1630463843227-0-0-0',
    type: 'action',
    parent: ['root-1630463843227-0-0'],
    children: [],
  },
  {
    id: 'root-1630463843227-0-0-1',
    type: 'trigger',
    parent: ['root-1630463843227-0-0'],
    children: ['root-1630463843227-0-0-1-0'],
  },
  {
    id: 'root-1630463843227-0-0-1-0',
    type: 'action',
    parent: ['root-1630463843227-0-0-1'],
    children: [],
  },
  {
    id: 'root-1630463843227-0-1',
    type: 'action',
    parent: ['root-1630463843227-0'],
    children: [],
  },
]

坐标计算
1、数据预处理

  • 从 nodes 和 edges 的数组列表模式生成依据 id 为 key 的对象类型,不便后续取值
  • y累计偏移量 MaxOffsetY 置为 0
  • 选定起始节点并设定起始坐标为(0, 0)
  • 开始递归求解每个节点的坐标
/**
* 计算依赖的相干变量
* indentX:x 方向固定偏移量
* gapVertical: y 方向固定偏移量
* MaxOffsetY: 以后节点计算时 y 方向的累计偏移量
*/
const indentX = 370;
const gapVertical = 50;
const MaxOffsetY = 0;
 
const parseWorkflowToDraw = (
  workflow: IWorkflow,
  resolveNode: ResolveNode
): [GraphData, IWorkflowNodeMap] => {
  const nodes = workflow.nodes;
  const edges = workflow.edges;
  const nodeMap: IWorkflowNodeMap = {};
  const edgeMap: IWorkflowEdgeMap = {};
  let rootId = '';
 
  for (const item of nodes) {
    if (item.type === 'start') {
      rootId = item.id;
    }
    nodeMap[item.id] = { ...item };
  }
 
  for (const edge of edges) {
    edgeMap[edge.target] = { ...edge };
  }
 
  if (!rootId) {
    throw new Error('Workflow 节点类型谬误,无起始节点!');
  }
  MaxOffsetY = 0;
  const graphData: GraphData = {
    nodes: [],
    edges,
  };
 
  parseWorkflowToGraphData(
    nodeMap[rootId],
    0,
    0,
    null,
    [0, 0],
    nodeMap,
    edgeMap,
    resolveNode,
    graphData
  );
 
  return [graphData, nodeMap];
};

2、坐标计算

  • 数据预处理(更多的是注入后续自定义节点时draw函数所需的数据)
  • 计算本身节点尺寸
  • 依据父节点地位、是否为分支流程及分支流程中的地位索引计算以后地位
  • 依据系列条件判断是否须要在 y 方向上进行偏移量累加
  • 如果有 children,则循环递归求解每一个 children 节点地位
const parseWorkflowToGraphData = (
  node: IWorkflowNode,
  indexInSibling: number,
  parentMatrixX: number,
  parentNodeType: TWorkflowNodeType,
  parentNodeSize: number[],
  nodeMap: IWorkflowNodeMap,
  edgeMap: IWorkflowEdgeMap,
  resolveNode: ResolveNode,
  graphData: GraphData
): IWorkflowDrawNode => {
  const nodeType = node.type;
  const cOndition= nodeType === 'start' ? true : edgeMap[node.id]?.condition;
  let newNode = {
    ...resolveNode(node),
    nodeType: nodeType,
    type: tagRenderNodeType(nodeType),
    condition,
  };
  // 计算节点尺寸
  const size = nodeSize(newNode);
  newNode.size = size;
  newNode.domSize = isBranchedNodeType(nodeType)
    ? [size[0] - padding - 130, size[1] - padding - 75]
    : size;
 
  // 计算节点坐标地位
  const matrixX = parentNodeType === null ? 0 : parentMatrixX + indentX;
  const matrixY =
    !condition && indexInSibling === 0 ? MaxOffsetY + parentNodeSize[1] + gapVertical : MaxOffsetY;
  newNode.x = matrixX;
  newNode.y = matrixY;
 
  if (!condition && indexInSibling === 0) {
    MaxOffsetY += parentNodeSize[1] + gapVertical;
  }
 
  const children = [];
  if (node.children.length > 0) {
    node.children.forEach((childId, index) => {
      const childNode = parseWorkflowToGraphData(
        nodeMap[childId],
        index,
        matrixX,
        nodeType,
        size,
        nodeMap,
        edgeMap,
        resolveNode,
        graphData
      );
      children.push(childNode);
    });
  } else {
    MaxOffsetY += size[1] + gapVertical;
  }
 
  newNode.children = children;
  graphData.nodes.push(newNode);
   
  return newNode;
};

节点删除

节点删除时,不仅须要删除以后节点,而且以后节点的子树、以后节点的入度边也应该一并删除。此时用到的仍然是 N 叉树的递归解法,同时借助函数援用传参能够扭转参数的个性,实现了对源数据进行间接变更的操作。

/**
 * 删除任意节点、子树及其入度边
 * @param node
 * @param nodes
 * @param edges
 */
const removeNode = (
  node: IWorkflowNode,
  nodes: IWorkflowNode[],
  edges: IWorkflowEdge[]
) => {
  const { children } = node;
 
  if (children?.length > 0) {
    children.forEach((child) =>
      removeNode(
        nodes.find((n) => n.id === child),
        nodes,
        edges
      )
    );
  }
  handleNodeDelete(node, nodes, edges);
};
 
const handleNodeDelete = (
  node: IWorkflowNode,
  nodes: IWorkflowNode[],
  edges: IWorkflowEdge[]
) => {
  // 定位元素
  const nodeIndex = nodes.findIndex((n) => n.id === node.id);
  const foundEdges = edges.filter((edge) => edge.target === node.id);
 
  // 革除父节点指向该节点的指针
  const parentNode = nodes.find((n) => nodes[nodeIndex].parent[0] === n.id);
  if (parentNode.children?.length) {
    parentNode.children = parentNode.children.filter((child) => child !== node.id);
  }
 
  // 删除元素
  nodes.splice(nodeIndex, 1);
  if (foundEdges?.length > 0) {
    foundEdges.forEach((v) => {
      const edgeIndex = edges.findIndex((_) => _.id === v.id);
      edges.splice(edgeIndex, 1);
    });
  }
};

ABTest

在未拓展 ABTest 类型节点前,整个画布的数据结构满足二叉树的定义。但扩大之后,因为子节点数量存在大于二的场景,所以不再满足二叉树的定义,但整体不影响链表+树的构造定义,是由二叉到N叉的扩大。

但 ABTest 的特殊性不仅体现在以后节点的后继数量不限,更重要的是他的后继节点能够作为一个容器,再蕴含一个非凡的小型画布。

因而,在本来的数据结构根底上,咱们扩大了节点的类型定义,新增 childNodes 字段。childNodes 次要为了存储 ABTest 节点之后跟的 combo 组的数据,从这里作为入口,在不打断本来树结构的前提下,外部又能够插入一棵树的构造。其实到此处,曾经不再是单纯的双向链表模式,又多了一丝狭义表的滋味。

interface IWorkflowNode {
  comboId?: string;
  id: string;
  type: TWorkflowNodeType;
  params: T;
  parent: string[];
  children: string[];
  childNodes?: string[];
}

通过扩大后的数据结构对原有的算法逻辑其实不会影响,咱们要做的是须要解决两头狭义表构造带来的子树逻辑(包含子树的坐标计算、子树对后继节点地位坐标的影响等)。

如果有如上的一棵树,依照扩大后的类型定义及 N 叉数的遍历算法,则遍历程序应该为:1、2、3、5、4、6、10、11、13、12、14、7、8、9。

因而在具体实现中,数据预处理逻辑根本不变,只是新增了 MaxOffsetYSnapshot 变量,作为计算狭义表之前y 偏移量的快照。当两头为了计算狭义表子树而影响到后继节点的 y 偏移量时,能够将后继节点计算时的 y 偏移量重置为快照值,以此保障不影响原有的树结构坐标计算。

坐标计算逻辑新增对 childNodes 带来的狭义表数据的解决:

const parseWorkflowToGraphData = (
  node: IWorkflowNode,
  indexInSibling: number,
  parentMatrixX: number,
  parentNodeType: TWorkflowNodeType,
  parentNodeSize: number[],
  nodeMap: IWorkflowNodeMap,
  edgeMap: IWorkflowEdgeMap,
  resolveNode: ResolveNode,
  graphData: GraphData
): IWorkflowDrawNode => {
  const nodeType = node.type;
  const cOndition= ['start', 'combo'].includes(nodeType) ? true : edgeMap[node.id]?.condition;
  let newNode = {
    ...resolveNode(node),
    nodeType: nodeType,
    type: tagRenderNodeType(nodeType),
    condition,
  };
  // 计算节点尺寸
  const size = nodeSize(newNode);
  newNode.size = size;
  newNode.domSize = isBranchedNodeType(nodeType)
    ? [size[0] - padding - 130, size[1] - padding - 75]
    : size;
 
  // 计算节点坐标地位
  let matrixX =
    parentNodeType === null && !node.id.startsWith('combo') ? 0 : parentMatrixX + indentX;
  const matrixY =
    !condition && indexInSibling === 0 ? MaxOffsetY + parentNodeSize[1] + gapVertical : MaxOffsetY;
  newNode.x = matrixX;
  newNode.y = matrixY;
 
  if (!condition && indexInSibling === 0) {
    MaxOffsetY += parentNodeSize[1] + gapVertical;
  }
 
  // 解决combo类型节点中的小画布数据
  if (nodeType === 'combo') {
    if (node.childNodes?.length > 0) {
      MaxOffsetY -= gapVertical;
    }
    const comboGraphData: GraphData = {
      nodes: [],
      edges: [],
    };
    if (node.childNodes?.length) {
      parseWorkflowToGraphData(
        nodeMap[node.id.replace('root', 'combo')],
        0,
        matrixX - size[0] - 90,
        null,
        [0, 0],
        nodeMap,
        edgeMap,
        resolveNode,
        comboGraphData
      );
    }
    let maxXNode = null;
    let maxYNode = null;
    comboGraphData.nodes.forEach((node) => {
      if (!maxXNode || node.x > maxXNode.x) maxXNode = node;
      if (!maxYNode || node.y > maxYNode.y) maxYNode = node;
    });
    const width = maxXNode && maxXNode.x > 0 ? maxXNode.x + maxXNode.size[0] - matrixX : size[0];
    const height = maxYNode && maxYNode.y > 0 ? maxYNode.y + maxYNode.size[1] - matrixY : size[1];
    newNode.size = [width, height];
 
    graphData.nodes.push(...comboGraphData.nodes);
    graphData.edges.push(...comboGraphData.edges);
 
    // 计算 combo 组最大偏移量
    if (indexInSibling === 0) {
      MaxOffsetYSnapshot = matrixY + newNode.size[1] + gapVertical;
      const nodeIds = nodeMap[node.parent[0]].children.map((id) => id.replace('root', 'combo'));
      const maxWidth = computeComboMaxWidth(nodeIds, nodeMap, edgeMap, resolveNode);
      matrixX = matrixX + maxWidth;
    }
 
    MaxOffsetY = matrixY;
  }
 
  const children = [];
  if (node.children.length > 0) {
    node.children.forEach((childId, index) => {
      const childNode = parseWorkflowToGraphData(
        nodeMap[childId],
        index,
        matrixX,
        nodeType,
        size,
        nodeMap,
        edgeMap,
        resolveNode,
        graphData
      );
      children.push(childNode);
    });
    MaxOffsetY = Math.max(MaxOffsetYSnapshot, MaxOffsetY);
  } else {
    MaxOffsetY += newNode.size[1] + gapVertical;
  }
 
  newNode.children = children;
  graphData.nodes.push(newNode);
 
  return newNode;
};
节点删除,也是新增对 childNodes 的解决逻辑即可:
/**
 * 删除任意节点、子树及其入度边
 * @param node
 * @param nodes
 * @param edges
 */
const removeNode = (
  node: IWorkflowNode,
  nodes: IWorkflowNode[],
  edges: IWorkflowEdge[]
) => {
  const { children, childNodes } = node;
 
  if (childNodes?.length > 0) {
    childNodes.forEach((child) =>
      removeNode(
        nodes.find((n) => n.id === child),
        nodes,
        edges
      )
    );
  }
  if (children?.length > 0) {
    children.forEach((child) =>
      removeNode(
        nodes.find((n) => n.id === child),
        nodes,
        edges
      )
    );
  }
  handleNodeDelete(node, nodes, edges);
};

以上,咱们可实现的简单策略配置如下:

结语

“软件的复杂性是一个基本特征,而不是偶尔如此”。但数据结构和算法拉平了程序开发人员对程序的认知,是管制复杂度的卓有成效的伎俩。


推荐阅读
  • 本文详细介绍了如何使用 Yii2 的 GridView 组件在列表页面实现数据的直接编辑功能。通过具体的代码示例和步骤,帮助开发者快速掌握这一实用技巧。 ... [详细]
  • 优化ListView性能
    本文深入探讨了如何通过多种技术手段优化ListView的性能,包括视图复用、ViewHolder模式、分批加载数据、图片优化及内存管理等。这些方法能够显著提升应用的响应速度和用户体验。 ... [详细]
  • 本文详细介绍了Java编程语言中的核心概念和常见面试问题,包括集合类、数据结构、线程处理、Java虚拟机(JVM)、HTTP协议以及Git操作等方面的内容。通过深入分析每个主题,帮助读者更好地理解Java的关键特性和最佳实践。 ... [详细]
  • 非公版RTX 3080显卡的革新与亮点
    本文深入探讨了图形显卡的进化历程,重点介绍了非公版RTX 3080显卡的技术特点和创新设计。 ... [详细]
  • PHP 编程疑难解析与知识点汇总
    本文详细解答了 PHP 编程中的常见问题,并提供了丰富的代码示例和解决方案,帮助开发者更好地理解和应用 PHP 知识。 ... [详细]
  • Søren Kierkegaard famously stated that life can only be understood in retrospect but must be lived moving forward. This perspective delves into the intricate relationship between our lived experiences and our reflections on them. ... [详细]
  • 本文将介绍如何编写一些有趣的VBScript脚本,这些脚本可以在朋友之间进行无害的恶作剧。通过简单的代码示例,帮助您了解VBScript的基本语法和功能。 ... [详细]
  • 技术分享:从动态网站提取站点密钥的解决方案
    本文探讨了如何从动态网站中提取站点密钥,特别是针对验证码(reCAPTCHA)的处理方法。通过结合Selenium和requests库,提供了详细的代码示例和优化建议。 ... [详细]
  • 本文探讨了Hive中内部表和外部表的区别及其在HDFS上的路径映射,详细解释了两者的创建、加载及删除操作,并提供了查看表详细信息的方法。通过对比这两种表类型,帮助读者理解如何更好地管理和保护数据。 ... [详细]
  • 数据库内核开发入门 | 搭建研发环境的初步指南
    本课程将带你从零开始,逐步掌握数据库内核开发的基础知识和实践技能,重点介绍如何搭建OceanBase的开发环境。 ... [详细]
  • 本文深入探讨 MyBatis 中动态 SQL 的使用方法,包括 if/where、trim 自定义字符串截取规则、choose 分支选择、封装查询和修改条件的 where/set 标签、批量处理的 foreach 标签以及内置参数和 bind 的用法。 ... [详细]
  • 本文详细解析了Python中的os和sys模块,介绍了它们的功能、常用方法及其在实际编程中的应用。 ... [详细]
  • 解读MySQL查询执行计划的详细指南
    本文旨在帮助开发者和数据库管理员深入了解如何解读MySQL查询执行计划。通过详细的解析,您将掌握优化查询性能的关键技巧,了解各种访问类型和额外信息的含义。 ... [详细]
  • 本文将深入探讨如何在不依赖第三方库的情况下,使用 React 处理表单输入和验证。我们将介绍一种高效且灵活的方法,涵盖表单提交、输入验证及错误处理等关键功能。 ... [详细]
  • 在使用 Flutter 进行开发时,可能会遇到热更新功能无法正常工作的问题。本文将探讨一种常见的错误:无法连接到 Dart 观察站,并提供详细的解决方法。 ... [详细]
author-avatar
手机用户2602918063
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有