簡介
經常用美團app買電影票,不禁對它的推薦選座功能產生了好奇,於是打算自己實現一個類似的演算法,美團app的推薦選座介面如下
最多可以選5個座位,本demo的選座介面如下圖 上圖是點選推薦選座5人後選出的座位(綠色),這個demo和美團app不同的地方在於可以連續進行推薦選座,美團app點選了推薦選座就必須買票才能繼續選擇。本demo採用Vue-cli搭建,github地址點此,clone後直接npm start即可進行具體操作
演算法思考過程
對於這個推薦座位演算法,我嘗試了不同場次的電影進行推薦選座,總結出以下幾點
(1)推薦演算法首先從影院中間排數的後一排的正中間開始搜尋
如下圖
(2)優先向後排方向進行搜尋,後排搜尋完成後再從中間起始位置向前排搜尋
這個大多數情況是對的,如下圖,偶爾會出現不同
(3)後排搜尋完成後,每一行都會有一個結果(每一行的結果是最靠近中軸線的那一組座位),取這些結果中距離中軸線最小的那個結果作為最終結果,而不是距離螢幕越近的
這一點也是大多數情況是對的,有些情況不對,很奇怪
(4)只考慮並排且連續的座位,不能不在一排或者一排中間有分隔,比如過道之類的
這一條可以理解,畢竟坐一排肯定觀影體驗好得多
影院座位資料結構
可以肯定的是用一個二維陣列seatArray
代表影院座位,注意到影院座位分佈是不規則的,因此需要確定一個seatRow
和seatCol
來確定影院座位的陣列尺寸,分別代表行列數,對於那些沒有座位的地方,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個位置往左移動一格,這才是正中央位置,這個推薦的有偏移量,不知道是為啥,網上也沒有搜到具體的演算法邏輯,只能靠猜想和實驗