半岛·体育中国官方网 经常用美团app买电影票,不禁对它的推荐选座功能产生了好奇,于是打算自己实现一个类似的算法,美团app的推荐选座界面如下
介绍
我经常用美团app买电影票,不禁好奇它的推荐选座功能,所以我打算自己实现一个类似的算法,美团app的推荐选座界面如下
您最多可以选择 5 个席位,此演示的席位选择屏幕如下所示
这
上图是点击5人推荐选座后选择的座位(绿色),这个演示和美团app的区别在于可以持续推荐选座,而美团app点击推荐选座,必须买票才能继续选座。
此 demo 使用 Vue-cli 构建,点击这里 github 地址,克隆后可以直接通过 npm start 进行具体操作
算法思维过程
对于这个推荐的座位算法半岛·BOB官方网站,我尝试了不同的电影放映来推荐座位选择,总结了以下几点
(1) 推荐算法首先从 theater 中间行最后一行的中间开始搜索
如下图所示
可以确定这个逻辑是一样的,在尝试了其他几个会话之后也是一样的
(2) 先向后排方向搜索,后排搜索完成后再从中间起始位置搜索前排如下图所示,
这在大多数情况下都是正确的,偶尔会有所不同
(3) 后排搜索完成后,每行都会有一个结果(每行的结果是离中轴线最近的一组座位),这些结果中离中轴线距离最小的结果会作为最终结果,而不是离屏幕越近的
大多数时候也是这样,有些事情不对劲,很奇怪
(4) 只考虑并排和相邻的座位,不要在一排或一排中间分开,例如过道
这个可以理解,毕竟坐一排绝对比观影体验好很多
电影院座位数据结构
可以肯定的是,使用了二维数组 seatArray 来表示剧场座位,并且需要注意剧场座位的分布是不规则的,因此需要确定 seatRow 和 seatCol 来确定剧场座位的数组大小,分别代表行数和列数,对于那些没有座位的地方半岛·BOB官方网站, seatArray 对应的位置填入 -1,下面是 seat 的具体值和代表的含义
-1 非座位
0 可选座位 (白色)
1 已选座位 (绿色)
2 已购票座位 (红色)
然后初始化 seats in mounted,初始值全部为 0(可选 seats),如下代码所示
//初始座位数组
initSeatArray: function(){
let seatArray = Array(this.seatRow).fill(0).map(()=>Array(this.seatCol).fill(0));
this.seatArray = seatArray;
//均分父容器宽度作为座位的宽度
this.seatSize = this.$refs.innerSeatWrapper
? parseInt(parseInt(window.getComputedStyle(this.$refs.innerSeatWrapper).width,10) / this.seatCol,10)
:0;
//初始化不是座位的地方
this.initNonSeatPlace();
},
//初始化不是座位的地方
initNonSeatPlace: function(){
for(let i=0;i<9;i++){
this.seatArray[i][0]=-1;
}
for(let i=0;i<8;i++){
this.seatArray[i][this.seatArray[0].length-1]=-1;
this.seatArray[i][this.seatArray[0].length-2]=-1;
}
for(let i=0;i<9;i++){
this.seatArray[i][this.seatArray[0].length-3]=-1;
}
for(let i=0;i
初始化后,使用一个 double 循环构建 HTML 结构,两个 v-for 嵌套循环整个结构,如下面的代码所示
<div class="inner-seat-wrapper" ref="innerSeatWrapper" >
<div v-for="row in seatRow">
<div v-for="col in seatCol"
v-if="seatArray.length>0"
class="seat"
:style="{width:seatSize+'px',height:seatSize+'px'}">
<div class="inner-seat"
@click="handleChooseSeat(row-1,col-1)"
v-if="seatArray[row-1][col-1]!==-1"
:class="seatArray[row-1][col-1]===2?'bought-seat':(seatArray[row-1][col-1]===1?'selected-seat':'unselected-seat')">
div>
div>
div>
div>
上面的 inner-seat class 的 div 是具体的 seat div,v-if 表示如果是 -1,也就是 aisle 之类的,就不会被渲染了,那么:class a sentence 控制了 seat 状态对应的 class 的值,嵌套的三目运算符控制, 对于每个座位绑定,单击事件 handleChooseSeat(row-1, col-1) 以切换状态
//处理座位选择逻辑
handleChooseSeat: function(row,col){
let seatValue = this.seatArray[row][col];
let newArray = this.seatArray;
//如果是已购座位,直接返回
if(seatValue===2) return
//如果是已选座位点击后变未选
if(seatValue === 1){
newArray[row][col]=0
}else if(seatValue === 0){
newArray[row][col]=1
}
//必须整体更新二维数组,Vue无法检测到数组某一项更新,必须slice复制一个数组才行
this.seatArray = newArray.slice();
},
这里需要注意的是,要改变 data 中的二维数组,必须先缓存二维数组,修改后,会重新分配二维数组,否则修改不会生效,因为 vue 无法检测到数组的变化。
推荐座位的具体代码
首先,将事件 smartChoose 绑定到每个推荐座位的按钮
代码如下
//推荐选座,参数是推荐座位数目
smartChoose: function(num){
//找到影院座位水平垂直中间位置的后一排
let rowStart = parseInt((this.seatRow-1)/2,10)+1;
//先从中间排往后排搜索
let backResult = this.searchSeatByDirection(rowStart,this.seatRow-1,num);
if(backResult.length>0){
this.chooseSeat(backResult);
return
}
//再从中间排往前排搜索
let forwardResult = this.searchSeatByDirection(rowStart-1,0,num);
if(forwardResult.length>0) {
this.chooseSeat(forwardResult);
return
}
//提示用户无合法位置可选
alert('无合法位置可选!')
},
第一步,找到剧院座位水平和垂直中间位置的后排,然后调用 this.searchSeatByDirection 朝那个方向搜索,先从中间行到后排,再从中间行到前排。如果任意方向找到搜索结果,都会直接返回,否则会提示用户没有合法位置,使用 chooseSeat 来更改座位的状态
重点介绍 searchSeatByDirection 的实现,代码如下
//向前后某个方向进行搜索的函数,参数是起始行,终止行,推荐座位个数
searchSeatByDirection: function(fromRow,toRow,num){
/*
* 推荐座位规则
* (1)初始状态从座位行数的一半处的后一排的中间开始向左右分别搜索,取离中间最近的,如果满足条件,
* 记录下该结果离座位中轴线的距离,后排搜索完成后取距离最小的那个结果作为最终结果,优先向后排进行搜索,
* 后排都没有才往前排搜,前排逻辑同上
* (2)只考虑并排且连续的座位,不能不在一排或者一排中间有分隔
* */
/*
* 保存当前方向搜索结果的数组,元素是对象,result是结果数组,offset代表与中轴线的偏移距离
* {
* result:Array([x,y])
* offset:Number
* }
*/
let currentDirectionSearchResult = [];
//确定行数的大小关系,从小到大进行遍历
let largeRow = fromRow>toRow?fromRow:toRow,
smallRow = fromRow>toRow?toRow:fromRow;
//逐行搜索
for(let i=smallRow;i<=largeRow;i++){
//每一排的搜索,找出该排里中轴线最近的一组座位
let tempRowResult = [],
minDistanceToMidLine=Infinity;
for(let j=0;j<=this.seatCol - num;j++){
//如果有合法位置
if(this.checkRowSeatContinusAndEmpty(i,j,j+num-1)){
//计算该组位置距离中轴线的距离:该组位置的中间位置到中轴线的距离
let resultMidPos = parseInt((j+num/2),10);
let distance = Math.abs(parseInt(this.seatCol/2) - resultMidPos);
//如果距离较短则更新
if(distance//该行的最终结果
tempRowResult = this.generateRowResult(i,j,j+num-1)
}
}
}
//保存该行的最终结果
currentDirectionSearchResult.push({
result:tempRowResult,
offset:minDistanceToMidLine
})
}
//处理后排的搜索结果:找到距离中轴线最短的一个
//注意这里的逻辑需要区分前后排,对于后排是从前往后,前排则是从后往前找
let isBackDir = fromRow < toRow;
let finalReuslt = [],minDistanceToMid = Infinity;
if(isBackDir){
//后排情况,从前往后
currentDirectionSearchResult.forEach((item)=>{
if(item.offset < minDistanceToMid){
finalReuslt = item.result;
minDistanceToMid = item.offset;
}
});
}else{
//前排情况,从后往前找
currentDirectionSearchResult.reverse().forEach((item)=>{
if(item.offset < minDistanceToMid){
finalReuslt = item.result;
minDistanceToMid = item.offset;
}
})
}
//直接返回结果
return finalReuslt
},
代码有点长,但逻辑并不难,就是执行前面的规则,对于每一行搜索,都可能有多个合理的座位结果
如果建议有 5 个座位,首先判断 1-5 的位置是否合理,如果合理,则记录从中间位置(3 号)到中心轴的距离和座位结果数组,然后向右移动一个位置,检查 2-6 的位置是否合理,如果合理, 比较 2-6 号位置的中间位置(4 号)与前一个轴的距离,取最短的那个,更新座位结果数组。在此迭代之后,将确定该行的最终结果,并将每行的最佳结果存储在 currentDirectionSearchResult 数组中
然后,遍历完后排方向的所有行后,得到一个由每行的最佳结果组成的数组:currentDirectionSearchResult半岛·体育网站平台登陆,然后按照规则遍历数组,取最靠近中心轴的那个作为最终结果
这个算法可以优化,直接从中间到 2 边搜索,找到然后返回,虽然写起来有点麻烦,但效率绝对很高。应该注意的是,在前排的情况下,currentDirectionSearchResult.reverse() 应该反转为数组,因为对于前排,前排的后半部分是首选的(没有人愿意坐在第一行!),这与后排相反
最后
这个算法有点问题,如下所示
最左边的 2 个绿色座位是最后一次点击推荐 2 个座位的结果,但位置不如另一个箭头处的 2 个位置合理,说明算法其实并不完美,可能是上面的分析不到位,其实美团的算法也存在问题, 如下图所示
这
这个推荐的合理位置应该是 4 个位置向左移动一个方格,这是中心位置,这个推荐有偏移量,不知道为什么,网上没有具体的算法逻辑,只能靠猜想和实验
我要评论