用Vue实现一个美团app的影院推荐选座功能

16,734 阅读8分钟

简介

经常用美团app买电影票,不禁对它的推荐选座功能产生了好奇,于是打算自己实现一个类似的算法,美团app的推荐选座界面如下

最多可以选5个座位,本demo的选座界面如下图

上图是点击推荐选座5人后选出的座位(绿色),这个demo和美团app不同的地方在于可以连续进行推荐选座,美团app点击了推荐选座就必须买票才能继续选择。

本demo采用Vue-cli搭建,github地址点此,clone后直接npm start即可进行具体操作

算法思考过程

对于这个推荐座位算法,我尝试了不同场次的电影进行推荐选座,总结出以下几点
(1)推荐算法首先从影院中间排数的后一排的正中间开始搜索
如下图

可以确定是这个逻辑,试了其他几个场次也是一样

(2)优先向后排方向进行搜索,后排搜索完成后再从中间起始位置向前排搜索
这个大多数情况是对的,如下图,偶尔会出现不同

(3)后排搜索完成后,每一行都会有一个结果(每一行的结果是最靠近中轴线的那一组座位),取这些结果中距离中轴线最小的那个结果作为最终结果,而不是距离屏幕越近的

这一点也是大多数情况是对的,有些情况不对,很奇怪

(4)只考虑并排且连续的座位,不能不在一排或者一排中间有分隔,比如过道之类的
这一条可以理解,毕竟坐一排肯定观影体验好得多

影院座位数据结构

可以肯定的是用一个二维数组seatArray代表影院座位,注意到影院座位分布是不规则的,因此需要确定一个seatRowseatCol来确定影院座位的数组尺寸,分别代表行列数,对于那些没有座位的地方,seatArray对应的位置填-1,下面是座位具体的值和代表的含义

-1 非座位
0  可选座位   (白色)
1  已选座位   (绿色)
2  已购票座位 (红色)

然后在mounted中初始化座位,初始值都为0(可选座位),如下代码

    //初始座位数组
      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<this.seatArray[0].length;i++){
        	this.seatArray[2][i]=-1;
        }
      }

初始化好之后用一个二重循环来构建html结构,2个v-for嵌套循环出整个结构,如下代码

 <div class="inner-seat-wrapper" ref="innerSeatWrapper" >
      <div v-for="row in seatRow">
        <!--这里的v-if很重要,如果没有则会导致报错,因为seatArray初始状态为空-->
        <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类的div就是具体的座位div,v-if说明了如果是-1也就是是过道之类的就不渲染,然后: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();
  },

这里注意vue中改变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<minDistanceToMidLine){
            minDistanceToMidLine = 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个位置往左移动一格,这才是正中央位置,这个推荐的有偏移量,不知道是为啥,网上也没有搜到具体的算法逻辑,只能靠猜想和实验