图形算法(邻接矩阵)

23,350 阅读12分钟

前言

最近有些小伙伴在写表单联动关系是觉得非常复杂,不知道从何下手。大多数情况下会要求后端同学给到嵌套结构,但是这种结构有个致命的缺点,无法向上联动而且存在大量多余数据。推荐了一下邻接矩阵发现有好多同学不了解,写个简单的科普文章解释一下。


什么是邻接矩阵

别问惯性开头没有为什么

邻接矩阵是一个用来描绘顶点与边关系的数据结构。它的本质是一个二维数组,适合用来处理最小数据单元之间的关联关系。邻接矩阵有两种模式:无向图以及有向图。无向图主要的特点是不表示方向点与点之间可以双向流通,有向图则包含方向两点间可单向亦可双向。他们主要应用在迷宫、简单地图、级联表单等等图形化场景


无向图

描述

无向图:指的是点与点之间的关联边没有方向限制。两点间可以来回往返


举个例子:


上图就是一个简单边与点的关联关系:

V0 相接的顶线是 v2 v3

V1 相接的顶线是 v3 v4

V2 相接的顶线是 v0 v3 v4

V3 相接的顶线是 v0 v1 v2

V4 相接的顶线是 v1 v2


以上图形被邻接矩阵数据化以后是怎么样的格式呢?


const arr = [
// v0  v1  v2  v3  v4
   0,  0,  1,  1,  0, // v0
   0,  0,  0,  1,  1, // v1
   1,  0,  0,  1,  1, // v2
   1,  1,  1,  0,  0, // v3
   0,  1,  1,  0,  0, // v4
]


特点

无向图的几个特点:

  • 矩阵的length必然是顶点个数的平方 lengt^2
  • 矩阵斜边必然无值
  • 矩阵依据斜边对称



简单实现

简单介绍后,我们通过JS简单实现一个邻接矩阵无向图


class Adjoin {
  constructor(vertex) {
    this.vertex = vertex;
    this.quantity = vertex.length;
    this.init();
  }

  init() {
    this.adjoinArray = Array.from({ length: this.quantity * this.quantity });
  }

  getVertexRow(id) {
    const index = this.vertex.indexOf(id);
    const col = [];
    this.vertex.forEach((item, pIndex) => {
      col.push(this.adjoinArray[index + this.quantity * pIndex]);
    });
    return col;
  }

  getAdjoinVertexs(id) {
    return this.getVertexRow(id).map((item, index) => (item ? this.vertex[index] : '')).filter(Boolean);
  }

  setAdjoinVertexs(id, sides) {
    const pIndex = this.vertex.indexOf(id);
    sides.forEach((item) => {
      const index = this.vertex.indexOf(item);
      this.adjoinArray[pIndex * this.quantity + index] = 1;
    });
  }
}

// 创建矩阵
const demo = new Adjoin(['v0', 'v1', 'v2', 'v3', 'v4'])

// 注册邻接点
demo.setAdjoinVertexs('v0', ['v2', 'v3']);
demo.setAdjoinVertexs('v1', ['v3', 'v4']);
demo.setAdjoinVertexs('v2', ['v0', 'v3', 'v4']);
demo.setAdjoinVertexs('v3', ['v0', 'v1', 'v2']);
demo.setAdjoinVertexs('v4', ['v1', 'v2']);

// 打印
console.log(demo.getAdjoinVertexs('v0'));
// ['v2', 'v3']


应用场景:联动表单

经过上面的简单介绍我们应该对邻接矩阵无向图有一个基本概念。那么我们怎么应用到实际的项目中呢。下面是一个我们再熟悉不过的天猫下单规格选择页面,这个销售页面的订单页面包含颜色、套餐类型、储存容量等三个关键因子。而且三个关键因子间存在相互关联关系。




分析交互
  • 当用户选择颜色的规格时,所有黑色相关的可选项均亮起
  • 同规格可选项也为亮起状态
  • 当用户选择套餐类型时,在颜色以及套餐的公共作用下,部分规格亮起

如果让后端下发一个递归的树结构1:会产生非常多的数据囤余。2:导致传输的数据量级变大。3:计算放在服务器加大服务器开销。

约定数据
const data = [
  { id: '1', specs: [ '紫色', '套餐一', '64G' ] },
  { id: '2', specs: [ '紫色', '套餐一', '128G' ] },
  { id: '3', specs: [ '紫色', '套餐二', '128G' ] },
  { id: '4', specs: [ '黑色', '套餐三', '256G' ] },
];
const commoditySpecs = [
  { title: '颜色', list: [ '红色', '紫色', '白色', '黑色' ] },
  { title: '套餐', list: [ '套餐一', '套餐二', '套餐三', '套餐四' ]},
  { title: '内存', list: [ '64G', '128G', '256G' ] }
];

commoditySpecs是偷懒的,常规操作应该是各个商品有库存的概念。全量下发前端自己捞取所有规格

套用无向图

从上述数据可以看出所有产品数据都是单一的产品ID,关联了不同的规格。那这些规格可以是作为各个顶点。而单一产品的各个规格的组合可以视作为各个规格的的关联关系。这边需要注意的一个点是,在不同场景下


忽略同级可选



同级可选



我们来尝试一下用无向图解释上述交互行为

  • 未选取状态,所有可达顶点均可点击。(图一)
  • 选取某个顶点后,当前顶点所有可选项均被找出(图二)
  • 选取多个顶点时,可选项是各个顶点邻接点的并集 (图三)



图一:



图二:


图三:


可达次数 >= 顶点个数


代码实现
class Adjoin {
  ********
  ****
  **
  getRowToatl(params) {
    params = params.map(id => this.getVertexRow(id));
    const adjoinNames = [];
    this.vertex.forEach((item, index) => {
      const rowtotal = params.map(value => value[index]).reduce((total, current) => {
        total += current || 0;
        return total;
      }, 0);
      adjoinNames.push(rowtotal);
    });
    return adjoinNames;
  }

  // 交集
  getUnions(params) {
    const row = this.getRowToatl(params);
    return row.map((item, index) => item >= params.length && this.vertex[index]).filter(Boolean);
  }

  // 并集
  getCollection(params) {
    params = this.getRowToatl(params);
    return params.map((item, index) => item && this.vertex[index]).filter(Boolean);
  }
}

class ShopAdjoin extends Adjoin {
  constructor(commoditySpecs, data) {
    super(commoditySpecs.reduce((total, current) => [
      ...total,
      ...current.list,
    ], []));
    this.commoditySpecs = commoditySpecs;
    this.data = data;
    // 单规格矩阵创建
    this.initCommodity();
    // 同类顶点创建
    this.initSimilar();
  }

  initCommodity() {
    this.data.forEach((item) => {
      this.applyCommodity(item.specs);
    });
  }

  initSimilar() {
    // 获得所有可选项
    const specsOption = this.getCollection(this.vertex);
    this.commoditySpecs.forEach((item) => {
      const params = [];
      item.list.forEach((value) => {
        if (specsOption.indexOf(value) > -1) params.push(value);
      });
      // 同级点位创建
      this.applyCommodity(params);
    });
  }

  querySpecsOptions(params) {
    // 判断是否存在选项填一个
    if (params.some(Boolean)) {
      // 过滤一下选项
      params = this.getUnions(params.filter(Boolean));
    } else {
      // 兜底选一个
      params = this.getCollection(this.vertex);
    }
    return params;
  }

  applyCommodity(params) {
    params.forEach((param) => {
      this.setAdjoinVertexs(param, params);
    });
  }
}


export default function App({ data, commoditySpecs }) {
  const [specsS, setSpecsS] = useState(Array.from({ length: commoditySpecs.length }));
  // 创建一个购物矩阵
  const shopAdjoin = useMemo(() => new ShopAdjoin(commoditySpecs, data), [
    commoditySpecs,
    data,
  ]);
  // 获得可选项表
  const optionSpecs = shopAdjoin.querySpecsOptions(specsS);

  const handleClick = function (bool, text, index) {
    if (specsS[index] !== text && !bool) return;
    specsS[index] = specsS[index] === text ? '' : text;
    setSpecsS(specsS.slice());
  };

  return (
    <div className="container">
      {
        commoditySpecs.map(({ title, list }, index) => (
          <div key={index}>
            <h1>{title}</h1>
            <Flex wrap="wrap">
              {
                list.map((value, i) => (
                  <span
                    key={i}
                    className={classnames({
                      option: optionSpecs.indexOf(value) > -1,
                      active: specsS.indexOf(value) > -1,
                    })}
                    onClick={() => handleClick(optionSpecs.indexOf(value) > -1, value, index)}
                  >{value}
                  </span>
                ))
              }
            </Flex>
          </div>
        ))
      }
    </div>
  );
}


有向图

描述

有向图:指的是两点间的连接有方向。它在无向图的基础上赋予了方向的概念。它的主要应用场景是地图


举个例子:


上图就是一个简单边与点的关联关系

V0 可通往的顶点是 v2

V1 可通往的顶点是 v4

V2 可通往的顶点是 v3

V3 可通往的顶点是 v0 v1

V4 可通往的顶点是 v2 v3

以上图形被邻接矩阵数据化以后是怎么样的格式呢?

const arr = [
// v0  v1  v2  v3  v4
   0,  0,  1,  0,  0, // v0
   0,  0,  0,  0,  1, // v1
   0,  0,  0,  1,  0, // v2
   1,  1,  0,  0,  0, // v3
   0,  0,  1,  1,  0, // v4
]

特点

第i个顶点的度为第i行与第i列非零元素个数之和

  • 矩阵的length必然是顶点个数的平方 lengt^2
  • 矩阵斜边必然无值
  • 第i行非零元素的个数为第i个顶点的出度
  • 第i列非零元素的个数为第i个顶点的入度


领接矩阵算法

上面我们介绍无向图的一个应用场景非常的实用,但是有向图的地图应用场景没有举例。解决地图的应用场景我们先来,看一下领接矩阵的两个关键算法。


深度优先算法

深度优先算法是由一点出发开始遍历全图,优先访问其最近节点并沿着单边路径继续访问到最后一个未被访问的节点,然后再原路退回并探索下一个路径(先进后出)

具体步骤:

  • 从图中某个顶点v出发,开始访问。
  • 找到其顶点的第一个未被访问的邻接点,访问该顶点。
  • 以该顶点为新顶点,重复此步骤,直至当前顶点没有未被访问的邻接点为止。
  • 返回前一个访问过得且扔有未被访问的邻接点的顶点,找到该顶点的下一个未被访问的邻接点,访问该顶点。
  • 重复步骤2,3,4,直至图中所有顶点都被访问过。


对于我们之前创建的矩阵如下图访问路径:由v0出发



下面我们实现一下上述功能。同时多加一个功能,可以约定截止节点


******
  // 深度优先遍历
  dfs(startId, endID) {
    const nodes = [];

    if (startId != null) {
      const stack = [];
      stack.push([startId]);
      while (stack.length !== 0) {
        const sides = stack.pop();
        const side = sides[0];

        if (nodes.every(item => item[0] !== side)) {
          // 注册节点
          nodes.push(sides);
          // 结束点退出
          if (side === endID) break;
          const children = this.getAdjoinVertexs(side);
          children.slice().reverse().forEach((item) => {
            stack.push([item, side]);
          });
        }
      }
    }
    return nodes;
  }
  // 搜寻链路
  lookupLink(params) {
    return params.reduceRight((total, current) => {
      if (total[total.length - 1] === current[0] && current[1]) {
        total.push(current[1]);
      }
      return total;
    }, params[params.length - 1]).reverse();
  }

*******
 console.log(demo.lookupLink(demo.dfs('v0', 'v4')));
 console.log(demo.lookupLink(demo.dfs('v3', 'v4')));

// ["v0", "v2", "v3", "v1", "v4"]
// ["v3", "v0", "v2", "v4"]


这种方式可以快速寻找两点间的通路。但不确保是最优解


广度优先算法

广度优先算法是由一点出发,开始遍历全图,优先遍历其所有的相邻节点向外扩散。类似水波纹的扩散方式

具体步骤:

  • 从图中某个顶点v出发,开始访问。
  • 找到当前顶点的所有未被访问的邻接点,访问所有节点。
  • 由可访问节点继续第2步骤,直至图中所有顶点都被访问过。


对于我们之前创建的矩阵如下图访问路径:由v0出发




下面我们实现一下上述功能,同时多加一个功能。可以约定截止节点


  // 广度优先遍历
  bfs(startId, endID) {
    const nodes = [];
    if (startId != null) {
      const stack = [];
      stack.unshift([startId]);
      while (stack.length !== 0) {
        const sides = stack.shift();
        const side = sides[0];

        if (nodes.every(item => item[0] !== side)) {
          nodes.push(sides);
          // 结束点退出
          if (side === endID) break;
          const children = this.getAdjoinVertexs(side);
          children.forEach((item) => {
            stack.push([item, side]);
          });
        }
      }
    }
    return nodes;
  }

console.log(demo.lookupLink(demo.dfs('v0', 'v3')));
console.log(demo.lookupLink(demo.bfs('v0', 'v3')));
// ["v0", "v2", "v3"]
// ["v0", "v3"]


这种方式可以快速寻找两点间最优解的通路。


地图的应用

简单地图

简单地图在计算时只考虑步数,并不考虑步长。

上面介绍了深度优先算法以及广度优先算法,并且支持我们指定终止节点。下面我们来测试一下一下图形



demo.setAdjoinVertexs('v0', ['v2']);
demo.setAdjoinVertexs('v1', ['v4']);
demo.setAdjoinVertexs('v2', ['v3']);
demo.setAdjoinVertexs('v3', ['v0', 'v1']);
demo.setAdjoinVertexs('v4', ['v2', 'v3']);

console.log(demo.lookupLink(demo.dfs('v4', 'v3')));
console.log(demo.lookupLink(demo.bfs('v4', 'v3')));
// ["v4", "v2", "v3"]
// ["v4", "v3"]


从上图运行结果也论证了广度优先算法计算的路径更加接近出发点,在地图场景下更加试用。


加权有向图

少年别走还有终极挑战

描述

在之前的demo中我们通过有向图领接矩阵解决了简单地图的问题,在高德地图里面对应的是最少换乘。但在高德地图导航路径时还存在其他模式:最短距离,最短时间。这类导航模式其实是在有向图领接矩阵上又赋予了步长(时间,距离等)的概念,这种图形我们统称为加权有向图领接矩阵。举例下图:


上图在之前介绍的有向图上附加了距离的概念,我们来创建二维矩阵。由于添加权重的概念我们不能再使用0表示无法到达的路径了。无法到达的路径使用∞表示


const arr = [
// v0  v1  v2  v3  v4
   0,  ∞,  1,  ∞,  ∞, // v0
   ∞,  0,  ∞,  ∞,  4, // v1
   ∞,  ∞,  0,  3,  ∞, // v2
   5,  3,  ∞,  0,  ∞, // v3
   ∞,  ∞,  1,  6,  0, // v4
]


由于当前节点与本身的是存在存在关联关系的,所以权重为0


面对有权重的图形我们如何解决最短边的问题?来看下面的算法

迪科斯彻算法

Dijkstra算法采用的是广度优先算法的实现思想,以起始顶点为中心由中心点延最短边向外层拓展。

具体步骤:

  • 从图中某个顶点v出发,开始访问。
  • 找到当前顶点的所有未被访问的邻接点,并根据加权排序,形成数组A。
  • 访问最短边,并访问所有未被访问的邻接节点。
  • 当前顶点加权 + 若邻接顶点加权 < 出发顶点至邻接节点的加权。 则同步最短路径至A
  • 重复2-4步骤,直至所有顶点被访问


例如:从V0开始到各个顶点的最短路径是什么?


1: 构造加权领接矩阵

图一



2:将v0到所有顶点领接数组取出,并根据顶点加权排序

3:顶点V0入列,加权为了0,进入下一顶点

图二




4:未访问顶点重新排序

5:顶点V2入列,访问未被访问的所有领接顶点

6:v0v2加权 + v2v3加权 < v0v3加权。 调整原队列,重新排序。

图三


图四


图五


图六


// 迪科斯彻最短路径  
dijkstra(startId, endID) {
    const stack = this.getVertexRow(startId).map((item, index) => [
      item,
      this.vertex[index],
      startId,
    ]).sort((a, b) => b[0] - a[0]);
    const nodes = [];

    while (stack.length) {
      // 删除最后节点
      const node = stack.pop();
      const [weights, side] = node;

      nodes.push(node);
      if (side === endID) break;

      if (weights) {
        const children = this.getVertexRow(side).map((item, index) => [item, this.vertex[index]]);
        children.forEach((item) => {
          let single = [];
          stack.some((value) => {
            if (value[1] === item[1]) {
              single = value;
              return true;
            }
            return false;
          });

          const [nodeWeights, id] = single;
          // const index
          if (id && weights + item[0] < nodeWeights) {
            single[0] = weights + item[0];
            single[2] = side;
          }
        });
      }
      stack.sort((a, b) => b[0] - a[0]);
    }

    return nodes;
  }

const router = demo2.dijkstra('v4', 'v3');
console.log(`距离:${router[router.length - 1][0]}, 路线:${demo.lookupLink(router.map(item => [item[1], item[2]]))}`);
// 距离:4, 路线:v4,v2,v3


总结

本文只是做个抛砖引玉,解决这类图形化的网状关联的算法还有很多比如:Floyd、Johnson、SPFA、A*、B*等等。有兴趣可以继续深入的研究一下。

下面才是关键:👇👇👇👇👇

关于我们:

我们是蚂蚁保险体验技术团队,来自蚂蚁金服保险事业群。我们是一个年轻的团队(没有历史技术栈包袱),目前平均年龄92年(去除一个最高分8x年-团队leader,去除一个最低分97年-实习小老弟)。我们支持了阿里集团几乎所有的保险业务。18年我们产出的相互宝轰动保险界,19年我们更有多个重量级项目筹备动员中。现伴随着事业群的高速发展,团队也在迅速扩张,欢迎各位前端高手加入我们~

我们希望你是:技术上基础扎实、某领域深入(Node/互动营销/数据可视化等);学习上善于沉淀、持续学习;性格上乐观开朗、活泼外向。

如有兴趣加入我们,欢迎发送简历至邮箱:boling.hb@antfin.com