引言
每逢假日人流高峰,12306便会成为一个热门的话题,2016的春节也毫无例外。大年初二,我和汤雪华(NetFocus)一干人等,在QQ群里围绕12306的购票问题展开了热烈的讨论。最后,由于购票问题远比想象中复杂,所以最终还是公说公有理、婆说婆有理,没有得到一个较为清晰和明确的结果。于是决定动动笔,谈一谈自己的理解,重点是解决面对购票请求时能不能出票的问题。如果我的方法有漏洞,请一定指出,谢谢!
需要说明的是,我的离散数学和组合数学的知识,都早早地还给了我的体育老师,所以下面的这个算法可能会粗糙得让各位无法直视,只希望能完整地表达出我的想法。
出票规则臆测
由于已久未坐过火车,也从未在12306上买过票,所以我根据相关文章假想出以下的出票规则:
假设一列火车K110,从a始发,途经bcd三个站点,最终驶往终点e。那么旅客可以购买的将包括{ab,ac,ad,ae,bc,bd,be,cd,ce,de}这10种『区段』的客票。
假设列车承载人数上限为500人,那么如果有500名旅客购买了ae全程客票,那么之后无论哪个区间的客票都不能再多卖一张了。如果有499名旅客购买了ae全程客票,那么还有的可能,一是再有人买到一张ae全程客票,使列车从始发到终点都满载;二是ae包含的ab/ac/ad/bc/bd/be/cd/ce/de每个区段都最多只能售出1张客票,此时就象先下后上原则,区段间彼此不能有交叉,前一个区段有人下车,后面的区段才能再有人上车。比如我们还可以同时出票1张ab,1张bc、1张cd和1张de的组合,或者1张ac、1张ce的组合。反之,此时若同时有ac、bd、cd各出票1张,则列车到b站点时仍是满载,持bd者将无法上车,而到c站点时持ac者下车,故持cd者可上车。
正因如此,每当我们售出一张某个区段的客票,就会引发其他区段可售票数量产生相应变化。所以,即使是同一编号的列车,在不同日期出行班次内售出的客票总数,都会因为旅客购票区段、购票数量的不同而完全不一样。在仔细观察后我发现,在列车从始发到终点的整个过程中,唯一不变的是列车最多能容纳的人数——『承载人数』。
承载人数
引入『承载人数』的概念,是为了理清座位与车票之间的关系。
以我数年前坐火车的经历,如果列车还有空余座位,那么我们在购票后将得到一张打印了车厢号、座位号的车票。如果座位已经售罄,那么只要列车承载人数还在其负荷内,那么我们仍能购得一张车票,并凭此进站上车,尽管这张票只载明了车次,而无具体的车厢号与座位号。这便是『有座票』与『无座票』的由来。
由此衍生出的,售出的座号是否由系统回收后再行出售给后续区段旅客,成为干扰我们讨论的问题之一。而我当时假设系统不回收座号。即假设甲买到a到b的有座票,并且是最后一个座位:15车厢102号座。而乙随后买到b到d的票,系统并不因此将这个座位15-102再次出售给乙。而是当甲在b下车后,由车厢里没有座位的其他旅客自行占据使用。
经过讨论,我认识到纠结于是否回收实际的座号是个大大的错误。如果将无座号也视为一种虚拟的座位,那么对列车而言只需要区分满载与未满载两种情况:满载时不能再售票,未满载时可售后续区段的票。而整个列车所能承载的人数,或者说是负荷,都将始终是个常量。
在这样的设定下,能否出票的问题就变成了判断列车在特定的区段是否会超载的问题了。至于『有座票』与『无座票』,就自然退化为次要问题了。
至于高铁和动车,我还没有坐过,所以只能揣测由于其车载设备的同步能力远比现有火车强大,因此借由列车员或其他机制实现空闲座号回收与更新,应该不是难题。
总体思路
在我当初大学的《数据结构》课程里,曾经有一个关于银行排队的算法模拟,我的灵感便正得于此。
我的方法建立在这样的一个前提上:『任意时刻列车均不得超载——列车承载的旅客人数不得超过其预定的承载人数。』
以此为准则,我将模拟列车的运行,让列车从始发站出发,在每个站点上下旅客,最后驶到终点结束。期间,我会跟踪列车在每个站点停靠时的载客人数,如果发现有超载的现象,则说明出票发生了问题,卖了不该卖的票。由此,我可以推断出这样一个结论:『如果要出一张票,就必须保证列车不会因此超载。』
模拟列车运行
因为Scala的简洁高效,所以我选择了它。另一方面也是因为我最近正陶醉其中,日后也希望有时间用Scala来模拟12306的客票并发。不过我自知此处的代码粗陋不堪,甚至站点的到达时间和发车时间我都用Float
模拟了,因为我还不知道Scala里用什么表示C#里的Timespan
,哈哈。
第一步:定义站点Station,区间Interval,线路Line
case class Station(val name: String, val arrival: Float, val depart: Float) {
require(arrival <= depart)
def prior(that: Station): Boolean = this.depart < that.arrival
def after(that: Station): Boolean = this.arrival > that.depart
}
case class Interval(val begin: Station, val end: Station) {
require(begin prior end)
}
case class Line(val stations: List[Station]) {
require(stations.length >= 2)
}
第二步:定义列车Train
列车有2个很简单的方法:上客board与下客land。它们都会在超载时抛出异常。
class Train(val capacity: Int) {
var load: Int = 0;
def land(amount: Int): Unit = {
load -= amount
if (load < 0)
throw new IllegalArgumentException("Train loading should not be negative.")
}
def board(amount: Int): Unit = {
load += amount
if (load > capacity)
throw new IllegalArgumentException("Train is overloading.")
}
}
第三步:定义订票中心Booking
订票中心是负责出票的地方。它先根据线路的站点列表生成一个包含所有区段的列表intervals
,然后将每个区段的已售票sold
全部初始为0。然后定义了一个售票方法sell(interval: Interval, amount: Int)
,它会在区段interval卖出amount张票后,更新该区段已售票的数量。
class Booking(val line: Line, val train: Train) {
val intervals: IndexedSeq[Interval] = for {
begin <- line.stations.indices dropRight 1
end <- line.stations.indices drop begin + 1
} yield Interval(line.stations(begin), line.stations(end))
val sold: mutable.Map[Interval, Int] = intervals.map(i => i -> 0)(collection.breakOut)
override def toString(): String = {
(for (i <- intervals) yield s"${i.begin.name}${i.end.name}[${sold(i)}]").mkString(" ")
}
def sell(interval: Interval, amount: Int) = {
sold(interval) += amount
}
}
第四步:定义模拟的驱动器Watcher
Watcher的核心方法是runTrain()
,它让列车从始发站点出发,驶往终点。期间到达每个站点时,会模拟『先下后上』的原则,更新列车承载的旅客数量。而每个站点的上下人数,则是根据售出的客票得出:下车的是所有以该站点为终点的客票总和,上车的是所有以该站点为起点的客票总和。
class Watcher(val booking: Booking) {
def runTrain() = {
for (
s <- booking.line.stations
) {
print(s"Train arrived [${s.name}]: ")
booking.train land land(s)
booking.train board board(s)
printf("-[%3d] +[%3d] ", land(s), board(s))
println(s"= ${booking.train.load}")
}
}
def land(station: Station): Int = {
var amount: Int = 0
for {i <- booking.intervals
if i.end == station
} amount += booking.sold(i)
amount
}
def board(station: Station): Int = {
var amount: Int = 0
for {i <- booking.intervals
if i.begin == station
} amount += booking.sold(i)
amount
}
}
第五步:让列车跑一会儿
最后,我模拟了ABCDE一共5个站点,一列满载为100人的列车,然后卖了5个区段的票。
object Railway {
def main(args: Array[String]) = {
val line = new Line(List(
Station("A", 1.0f, 1.0f),
Station("B", 2.0f, 2.1f),
Station("C", 3.0f, 3.1f),
Station("D", 4.0f, 4.2f),
Station("E", 5.0f, 5.0f)
))
val train = new Train(100)
val booking = new Booking(line, train)
val watcher = new Watcher(booking)
print("Line route[interval]: ")
print((for (s <- line.stations) yield s.name).mkString(" -> "))
println(s"\t[${booking.intervals.length}]")
try {
booking.sell(booking.intervals(0), 3)
booking.sell(booking.intervals(4), 20)
booking.sell(booking.intervals(3), 30)
booking.sell(booking.intervals(6), 35)
booking.sell(booking.intervals(9), 35)
println(booking)
watcher.runTrain()
} catch {
case e: IllegalArgumentException => println(e.getMessage)
}
}
}
得到如下输出:
- 第一行是线路图,中括号里是包括的区段总数;
- 第二行是已售出的客票情况,区段[票数];
- 后面几行是模拟列车运行时上下旅客的情况
Line route[interval]: A -> B -> C -> D -> E [10]
AB[3] AC[0] AD[0] AE[30] BC[20] BD[0] BE[35] CD[0] CE[0] DE[35]
Train arrived [A]: -[ 0] +[ 33] = 33
Train arrived [B]: -[ 3] +[ 55] = 85
Train arrived [C]: -[ 20] +[ 0] = 65
Train arrived [D]: -[ 0] +[ 35] = 100
Train arrived [E]: -[100] +[ 0] = 0
如果卖的票有问题,就会象下面这样:
AB[3] AC[0] AD[0] AE[50] BC[20] BD[0] BE[35] CD[0] CE[0] DE[35]
Train arrived [A]: -[ 0] +[ 53] = 53
Train arrived [B]: Train is overloading.
我还没写完
这部分,我会根据上面的模拟程序,反其道而行之,得到一个售票的具体算法。先把目前这稿提交了吧,也好请群里的兄弟们指正一二。有空我再补充完整。
写在最后
我的方法从本质上说是一种类似于迷宫问题的递归或者穷举。在迭代过程中若发现会『超载』,则证明此路不通,此票不得出售。
作为对比,老汤在他的文章中用了一个很简单的出票方法,该方法的前提是『原子区间的可用票数,由工作人员在初始化车次时预先设定。』,具体内容参见浅谈个人对12306的核心用户诉求的核心模型设计思路和架构设计
出票时扣减库存的逻辑是:根据订单信息,拿到出发地和目的地,然后获取这段区间里的所有的原子区间。然后尝试将每个原子区间的可用票数减1,如果所有的原子区间都够减,则购票成功;否则购票失败,提示用户该票已经卖完了。