1. 背景
基于业务场景下需要有一个展示图片搜索结果(根据关键词,比如"星空"、"自然风光"等)的页面,搜索结果分页展示,并且对图片的排版布局有一定的要求:
- 保持图片的宽高比不变
- 图片之间不能有空白间隔
- 图片必须铺满父容器,不允许出现某一行上的图片没有占满容器的宽度
基于以上诉求,大家可能没有一个直观的感受,这里我先给出一张最终实现后的效果图:
结合上面提到的布局要求以及这张效果图,大家也可以思考下通过css是否能实现这样的效果(最初我也尝试通过css来实现,但均以失败告终,如各位有办法实现,还望告之)
这样的布局效果是如何实现的呢?文章的标题虽已剧透,但我相信初看标题不少人可能无法将最短路径算法与最终的这个布局效果联系到一起,且听我慢慢道来。
2. 最短路径算法
首先我们看一下什么是最短路径算法,答案摘自ChatGPT:
最短路径算法是一类重要的图论算法,它们的目标是在一个图(可能有向,可能带权)中找到两个节点之间的最短路径。常见的最短路径算法有Dijkstra算法、Bellman-Ford算法、Floyd-Warshall算法、A*搜索算法等。每种算法都有其适用的情况和优势。
- Dijkstra算法:这是最常用的最短路径算法,它能在非负权图中找到单源最短路径,即从指定的源节点到图中所有其他节点的最短路径。Dijkstra算法使用了贪心的策略,它在每一步都选择距离源节点最近的节点进行探索。
- ...
基于我们的场景,此处省略了关于其它算法的介绍,有兴趣的同学可以自行去研究,这里我们只介绍Dijkstra算法,我们结合一个简单的例子来描述该算法是如何在图中找到从源节点到各节点的最短路径的,假设有这样一张图:
这是一个有向带权图,其中节点0到节点1之间带箭头的连线表示从节点0可以直接到达节点1,而连线上的数字则代表权重,可以理解为成本、距离、耗时等概念,而Dijistra算法要解决的问题就是从图中找到源节点(0号节点)到各节点的最短路径(权重最小)
算法步骤(by ChatGPT):
- 创建两个节点集合:已访问节点集合和未访问节点集合。开始时,已访问节点集合为空,未访问节点集合包含所有节点。
- 设定源节点的最短路径距离为0。对于所有其他节点,设定其最短路径距离为无穷大(这代表我们现在还不知道到达它们的路径)。
- 选择未访问节点集合中最短路径距离最小的节点,把它加入到已访问节点集合,同时从未访问节点集合中移除它。
- 对于新加入已访问节点集合的节点的所有邻居,如果通过这个新节点到达它们比之前记录的路径更短,那么更新这些邻居的最短路径距离。
- 重复步骤3和4,直到所有节点都已加入到已访问节点集合,或者未访问节点集合中没有可以到达的节点。
下面我们结合上面的例子来图解算法的执行过程,首先我们需要建立一个数据结构来表示这个有向带权图,这里我们选择的是邻接表:
左侧一例对应图中的各节点,每个节点对应的链表用来表示与之相邻的节点以及对应的权重,这样任意选中一个节点就可以遍历到与之相邻的其它节点及对应的权重了,下面我们就按GPT给出的算法步骤来执行:
-
初始状态,右侧的表格为节点0到其余各节点的最短距离的记录
-
选中权重最小的节点0,将其加入到已访问节点,并在邻接表中找到它的邻节点,计算权重并更新记录
-
选中权重最小的节点1,将其加入到已访问节点,根据邻接表中的邻节点,计算权重并更新记录,节点1自身的权重是1,它到节点2点的权重是2,所以更新的后的记录如下
-
此次选中的是节点3,参照上一步的更新步骤,更新后的记录如下
-
由于节点3没有邻节点,所以最后的记录与上图一致,即节点0到各节点的最短距离分别为1、3、4
-
补充,如果在算法的步骤4中,更新最短距离记录的同时,记录一下来源节点,最后向前回溯,就可以得到对应的最短路径,如下图:到节点3的最短是4,其来源节点是节点2,节点2的来源节点是节点1,节点1的来源节点是节点0,从而得到一个完整的路径
关于Dijistra算法的介绍就到这,想进一步了解这块内容的同学可以自行查阅相关的书籍资料
3. 建立模型
接下来要做的就是将我们的照片布局问题转化为求最短路径的问题,其中最关键的其实就是关于"节点"以及"权重"如何定义
回想前文关于布局的要求:
- 保持图片的宽高比不变
- 图片之间不能有空白间隔
- 图片必须铺满父容器,不允许出现某一行上的图片没有占满容器的宽度
我们可以将照片布局的过程想像成这样:有一个宽度和高度固定容器,现在我们把图片一张张按容器的高度等比缩放后塞进容器,理想的情况是这几张图片刚好塞满容器,假设有多个这样的容器,每个容器都刚好被照片塞满,我们将这一个个容器叠起来,是不是就实现了我们想要的布局:
但在实际操作过程中,较大可能出现的是如下两种情况之一(不足一行以及超出一行):
此时如果还想要填充满容器,就不得不对图片的整体宽度进行放大或缩小,因为要求图片的宽高比不变,所以图片的高度也要相应的进行调整(此时我们不再限制容器的高度),以上图中第一个容器为例:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> r a d i o 缩放比 = w i d t h 容器 w i d t h 图片 1 + w i d t h 图片 2 + w i d t h 图片 3 radio_{缩放比} = \frac{width_{容器}}{width_{图片1} + width_{图片2} + width_{图片3}} </math>radio缩放比=width图片1+width图片2+width图片3width容器
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> h e i g h t 新 = h e i g h t 旧 × r a d i o 缩放比 height_{新} = height_{旧} \times radio_{缩放比} </math>height新=height旧×radio缩放比
可以看出,为了填满容器,容器中的图片是需要进行一定的缩放的,而这个缩放比的取值范围则是有一定讲究的,首先,它决定了有几种填充图片至当前容器的方案,比如,在缩放比允许的取值范围内,可以分别填充2~5张图片(各方案的区别主要体现在最终呈现出来的图片高度上,填充的图片越多,高度可能低小,反之亦然);其次,它会影响后续图片填充方案,比如第一个容器的填充方案用掉了3张图片,那当前容器就是从第4张图片开始填充了,假设只有7张图片,就有可能会产生如下这种树状图(仅示意):
如果将每一种填充方案看成一个节点,并且补充上初始节点与终止结点,就形成了如上这样的一张有向图,而我们所要做的就是计算从初始节点到终止节点的最短路径,但现在节点有了,权重又该如何设计呢?
我们最终的目的是为了实现近似前文所提到的"理想"状况下的布局效果,理想状况下,容器中填充的图片不用进行缩放,而现实的方案中图片或多或少会进行缩放,我们可以将这个缩放的比例作为权重计算的一个因子,缩放比越大,计算出来的权重就越高,与理想状况的差距就越大,这样最终我们计算出来的最短路径就必然是与理想状况最接近的一种布局方案了
至此就完成了模型的建立,将布局方案问题转化成了计算最短路径问题
4. 具体实现
整个功能的实现主要分为三个部分
- 图片数据预处理,需要知道图片的宽、高,方便后续的缩放处理(这个在示例代码中已处理好了)
- 根据上一节中描述的逻辑,来进行建模,遍历所有的图片,构建出一个有向带权图
- 根据Dijistra算法逻辑,找到图中的最短路径
- 根据步骤3中得到的最短路径中的节点,进行图片的布局,一个节点对应最终布局中的一行图片数据
这里有一份代码实现,逻辑尚不完备,只适配了PC端,clone项目后,安装完依赖,运行pnpm dev
即可访问查看效果,由于未做resize事件的处理,所以如果调整了浏览器大小需要刷新一下页面,代码中所使用的图片来源讯飞图库站点上的缩略图,仅供学习研究该算法的应用及布局效果查看。
步骤2和3相对简单结,关于步骤1这里补充几点细节:
-
引入了"理想高度"、最大/小缩放比例的概念
图片在缩放时是保持宽高比的,加之产品需求或UI设计稿中会提供一个最佳视觉高度,我们就是以此高度作为"理想高度",而缩放则是基于此高度进行操作,并且限制一下缩放比的取值范围,这一方面是出于视觉效果考虑,不希望最终布局效果中某一行高度过大或过小,导致用户体验不佳;另一方面是出于性能优化考虑,缩放比的取值范围越大,产生的节点数越多,计算的复杂度就越高,耗时越久,但收益并不与之成正比
-
在处理节点时,可以根据当前节点所包含的各图片的唯一标识(我这里每张图片会有一个id)建立一个该节点的内容唯一标识,用于标记当前节点是否已被访问过,这样可以避免当节点过多的时会重复处理同一个节点,导致循环无法结束
5. 总结
对于一些看似没有实现头绪的需求,调整一下思维模式,建立一个合适的问题模型,并找到与之对应的解题算法,很多问题都能迎刃而解。