小蜗

电梯算法漫谈:一台电梯是如何学会调度的?

目录
Image

不知道你有没有遇到过这样的情况。

明明只是等一台电梯,却发现它迟迟不肯下来。好不容易看着显示屏上的数字逐渐接近自己所在的楼层,它却突然停住,甚至转头去了另一个方向。

有时候难免让人有些焦躁:这台电梯到底在干什么?

其实,我一直对电梯背后的运行逻辑非常好奇。每当站在电梯门口,看着楼层数字不断变化时,我总会忍不住思考:它究竟是如何决定下一站的?为什么有时会在中途停靠,有时却对某一层的呼叫视而不见?

看似简单的上下移动,背后其实隐藏着一个很有意思的问题:调度。

不妨尝试从零开始设计一套电梯调度算法,看看我们能否从最朴素的规则出发,一步步推导出更合理的运行方式。

这篇文章计划分为上下两篇:

  • 上篇:只考虑一台电梯,探讨最基础的调度问题。
  • 下篇:当多台电梯同时运行时,它们又该如何协作?

本篇先从最简单的单台电梯开始。

一、先来先服务,真的公平吗?

假设有一栋六层楼的建筑,只有一台电梯,运行范围为 1~6 楼。

为了让问题尽可能简单,我们暂时忽略载重、开关门耗时、加减速等因素,只关注电梯应该如何决定自己的运行顺序。

现在,电梯停在 1 楼。

3 楼的乘客先按下了上行按钮,紧接着,2 楼的乘客也按下了上行按钮。假设两位乘客最终都准备前往 6 楼,而且两个请求都在电梯出发前产生。

那么,电梯应该先去哪里?

最符合直觉的答案可能是:既然 3 楼先按下按钮,那就先服务 3 楼的乘客。

这实际上是一种很常见的调度思想,叫作先来先服务(First Come, First Served,简称 FCFS)。

如果严格遵循这条规则,并且每次都先完成一位乘客的行程,电梯就会这样运行:

纯文本
先来先服务(FCFS)

1 → 3 → 6 → 2 → 6
    └─ ① ─┘
            └─ ② ─┘

① 接 3 楼乘客,送往 6 楼
② 返回 2 楼,接人后再次前往 6 楼

累计运行距离:13 层

乍看之下,这套规则并没有什么问题。

毕竟先按按钮的人先得到服务,似乎十分公平。

但仔细观察这条路线,就会发现一个明显的问题。

电梯从 1 楼前往 3 楼时,明明会经过 2 楼,却因为 2 楼的乘客稍晚按下按钮,就直接忽略了他的请求。

随后,电梯又不得不从 6 楼返回 2 楼,最后重新上行至 6 楼。

如果只是为了遵守请求的时间顺序,让电梯反复往返,显然不是一个高效的方案。

那么,我们能不能稍微改变一下规则?

既然电梯本来就需要上行,而且两位乘客都准备前往 6 楼,为什么不顺路把他们一起接上呢?

于是,路线变成了:

纯文本
顺路接人

1 → 2 → 3 → 6
    ↑   ↑
   接人 接人

累计运行距离:5 层

原本需要经过 13 层的运行距离,现在只需要经过 5 层。

当然,这不代表真实的乘梯时间也会按照同样的比例缩短。毕竟每次停靠还需要减速、开门和关门,但至少我们避免了大量不必要的移动。

有意思的是,新方案打破了最初的规则。

3 楼的乘客虽然先按下按钮,电梯却先接到了 2 楼的乘客。

为了提高整体效率,我们允许服务顺序发生变化。

这也引出了电梯调度中的第一个问题:

能不能在兼顾公平的同时,让电梯运行得更加高效?

二、电梯究竟知道些什么?

在继续优化之前,我们需要先弄清楚一件事情:电梯究竟能够获得哪些信息?

仔细观察就会发现,传统电梯通常有两类按钮。

一类位于电梯外部,也就是我们经常使用的上行和下行按钮;另一类位于电梯内部,用来选择具体的目的楼层。

两者看起来都只是按钮,但传递给调度系统的信息却不一样。

我们可以简单画出它们的关系:

纯文本
                 电梯调度器
                     │
          ┌──────────┴──────────┐
          │                     │
       厅外呼叫               轿内指令
          │                     │
        ↑ / ↓                 1~6 楼
          │                     │
     想往哪个方向?           想去几楼?
          │                     │
          └──────────┬──────────┘
                     │
              决定运行与停靠

例如,3 楼有人按下上行按钮。

此时,电梯只知道这位乘客想往上走,却不知道他准备前往 4 楼、5 楼还是 6 楼。

只有当乘客进入轿厢,按下具体的楼层按钮后,电梯才能知道他的目的地。

因此,我们可以把请求分成两类:

  • 厅外呼叫:由所在楼层和期望方向组成,例如「3 楼,上行」。
  • 轿内指令:表示乘客的目的楼层,例如「前往 6 楼」。

除此之外,电梯还需要知道自己的当前位置、当前运行方向,以及有哪些请求尚未处理。

如果用一种类似程序状态的方式来描述,大概是这样:

纯文本
电梯状态

当前位置:1 楼
运行方向:待机

厅外上行请求:[2]
厅外下行请求:[3]

轿内目的楼层:[]

这里有个容易忽略的细节:同一楼层可能同时有人想上楼,也有人想下楼。因此,不能只用一个楼层数字来代表所有厅外呼叫。

而对于轿内的楼层按钮,我们只需要知道有哪些目的楼层需要停靠。

至此,最基础的调度信息就已经齐全了。

接下来,我们可以尝试让电梯根据这些信息,做出更加合理的决策。

三、顺路接人,但不是见人就停

回到最初的六层楼。

这一次,我们换一个场景:

  • 电梯停在 1 楼。
  • 2 楼有人按下上行按钮,准备前往 6 楼。
  • 3 楼有人按下下行按钮,准备前往 1 楼。

按照前面得出的经验,我们应该尽量顺路接人。

那么,当电梯从 2 楼前往 6 楼时,经过 3 楼是不是也应该停下来?

答案并没有那么简单。

因为 3 楼的乘客想要下楼,而电梯当前正在上行。

如果立即接上这位乘客,就可能出现两种情况。

第一种是电梯直接掉头,先将 3 楼的乘客送回 1 楼。但这样一来,已经进入电梯、准备前往 6 楼的乘客就不得不跟着返回。

第二种是先让这位乘客跟着电梯前往 6 楼,再一起下行。虽然电梯没有立即改变方向,但这位乘客反而多走了一段完全没有必要的路。

两种方式都不够理想。

这时,我们就可以尝试引入一条新的规则:

电梯沿某个方向运行时,优先处理该方向上的请求,尽量避免中途反复掉头。

也就是说,当电梯正在上行时:

  • 遇到轿内乘客选择的目的楼层,可以停靠。
  • 遇到同样希望上行的厅外乘客,可以顺路接人。
  • 遇到希望下行的厅外乘客,通常先记录请求,等待返程时处理。

于是,整个运行过程就变成了:

纯文本
6 楼  ● 乘客下电梯,准备返程
      │
5 楼  │
      │
4 楼  │
      │
3 楼  ○ 有人想下楼,先不接
      │
2 楼  ● 接上行乘客
      │
1 楼  ● 电梯出发

上行:1 → 2 → 6
下行:6 → 3 → 1

● 当前行程的停靠楼层
○ 等待反方向服务的楼层

这样一来,电梯就不需要因为每一个新请求而频繁改变方向。

当然,这里还存在一种特殊情况。

假设电梯停在 1 楼,而整栋楼只有 3 楼的一位乘客按下了下行按钮。

难道电梯就不去接他了吗?

显然不是。

它仍然可以从 1 楼上行至 3 楼,然后改变方向,接上这位准备下行的乘客。

换句话说,方向优先并不意味着拒绝反方向请求,而是尽可能将同方向的服务组织在一起。

实际上,这已经非常接近现实电梯中常见的选择性集选控制(Selective Collective Control)思想。

它的核心不是严格按照按钮按下的时间顺序处理请求,而是结合运行方向和楼层位置,决定停靠的先后次序。

四、电梯一定要运行到最高层吗?

到这里,我们已经解决了一个明显的问题:电梯不再因为新的请求而频繁折返。

但新的问题又出现了。

假设电梯正在向上运行,它是不是一定要到达 6 楼,才能改变方向?

我们继续调整刚才的例子。

现在,2 楼的乘客不再前往 6 楼,而是准备去 4 楼。

3 楼依然有人等待下行,准备返回 1 楼。

如果电梯坚持运行到建筑物的最高层,路线就会变成:

纯文本
1 → 2 → 4 → 6 → 3 → 1
            ↑
        没有必要的移动

显然,5 楼和 6 楼根本没有需要处理的请求。

既然如此,电梯为什么还要继续上行?

更合理的路线应该是:

纯文本
1 → 2 → 4 → 3 → 1
        ↑
    上行任务完成
      开始返程

这样,我们又得出了一条新的规则:

当当前方向已经没有更远的待处理请求时,电梯就可以考虑改变方向,而不必每次都到达建筑物的最高层或最低层。

不过,这里的「待处理请求」并不只是轿内按钮。

比如,在刚才的例子中,如果 5 楼还有一位等待下行的乘客,电梯就不能只因为 4 楼是轿内乘客的最高目的地,而直接掉头返回。

它还需要前往 5 楼,响应那里的下行呼叫。

所以,决定电梯何时折返的,不是建筑物的高度,也不只是轿内乘客的目的地,而是整个系统中尚未完成的请求。

那么,如果所有请求都已经处理完毕,电梯又该怎么办?

最简单的方式,就是停在当前楼层,等待下一次呼叫。

当然,现实中有些电梯会在空闲一段时间后,主动返回预设的待机楼层,比如 1 楼。这属于进一步的调度策略,我们暂时不展开。

五、原来,电梯调度可以这么简单

回顾前面的推导,其实我们只做了几次很自然的调整。

最初,我们采用先来先服务的规则,发现这种方式虽然直观,却可能造成大量不必要的往返。

于是,我们开始尝试顺路接人。

随后又发现,顺路并不代表经过任何楼层都应该停靠,还需要考虑乘客希望前往的方向。

最后,我们意识到电梯也没有必要运行到建筑物的尽头,而应该根据尚未完成的请求,决定何时改变方向。

如果把这些规则组合起来,就形成了一套相对简单的调度流程:

纯文本
               收到新的请求
                    │
                    ▼
               记录请求信息
                    │
                    ▼
               确定运行方向
                    │
                    ▼
              沿当前方向运行
                    │
                    ▼
             是否需要在此停靠?
               │         │
              是         否
               │         │
            开门服务     继续运行
               └────┬────┘
                    │
                    ▼
           前方还有待处理请求吗?
               │         │
              有         没有
               │         │
            继续前进    考虑掉头
                         │
                         ▼
                   还有其他请求吗?
                      │      │
                     有      没有
                      │      │
                    继续调度  待机

上面是一张简化的流程图。如果将其中的主要决策进一步转换成伪代码,大概是这样:

纯文本
当电梯准备进行下一步调度时:

  如果当前楼层有需要服务的请求:
      停靠,处理对应请求

  如果当前方向的前方还有请求:
      继续朝当前方向运行

  否则:
      如果还有其他尚未处理的请求:
          根据请求位置改变方向
          继续提供服务
      否则:
          进入待机状态

当然,这里的「需要服务」,还要考虑厅外请求的方向。例如,上行过程中遇到下行呼叫,通常不会立即停靠。

而当电梯恰好到达最远的反方向呼叫楼层时,则可以先切换方向,再提供服务。

虽然我们并没有引入复杂的数学公式,但已经能够理解一套基础的电梯调度机制。

六、这和计算机中的电梯算法有什么关系?

如果你接触过操作系统,可能会觉得前面的调度思路似曾相识。

事实上,在传统磁盘调度中,也存在一种被称为「电梯算法」的调度方式。

它的名字叫作 SCAN。

传统机械硬盘需要移动磁头,才能读取不同位置的数据。如果每次都按照请求到达的先后顺序移动磁头,就可能出现大量来回寻道。

于是,SCAN 采用了一种类似电梯的思路:磁头沿一个方向移动,依次处理途经位置的请求,到达边界后再反向移动。

听起来是不是十分熟悉?

不过,按照经典定义,SCAN 通常会一直移动到磁盘的物理边界,而我们前面设计的电梯,并不一定需要运行到最高层或最低层。

这就涉及另一种磁盘调度算法:LOOK。

LOOK 会观察当前方向上是否还有未完成的请求。如果已经没有,就不必继续移动到磁盘边界,而是可以提前改变方向。

这与我们在第四节推导出的优化思路十分相似。

但这里需要区分两个概念:

一方面,SCAN 和 LOOK 是经典的磁盘调度算法。

另一方面,真实电梯还需要考虑厅外上下行方向、轿内目的楼层、载重、乘客等待时间以及安全控制等因素。

因此,我们可以借助 SCAN 和 LOOK 理解方向调度的思想,却不能直接将它们等同于真实电梯的完整控制程序。

这也正是算法有趣的地方。

两个看似完全不同的问题,可能隐藏着十分相似的解决思路。

七、效率提高了,公平问题就解决了吗?

到这里,我们似乎已经得到了一套相对合理的调度方案。

但如果再仔细思考,就会发现新的问题。

假设一台电梯正在上行,而你恰好在它刚刚经过的楼层按下上行按钮。

此时,即使电梯距离你只有一层楼,你也可能不得不等它处理完前方的请求,再折返回来。

从你的角度来看,这似乎并不公平。

可如果电梯立即掉头,又可能影响已经进入电梯的乘客。

这让我们不得不重新思考:究竟什么才叫公平?

是严格按照按钮按下的时间先后提供服务,还是尽量缩短所有乘客的平均等待时间?

又或者,我们应该优先照顾那些已经等待很久的人?

事实上,这些目标并不总能同时实现。

尤其是当新的请求不断出现时,调度器不仅需要提高整体效率,还需要避免某些请求长期得不到响应。

因此,真实的电梯系统往往还会考虑更多因素,例如等待时间、载重、客流量以及不同时段的运行策略。

本文推导出的,只是理解单台电梯调度的一种基础模型,并不意味着所有电梯都会严格遵循完全相同的规则。

但至少现在,当我们再次站在电梯门口,看到它经过自己所在的楼层却没有停下时,就能够从调度的角度,尝试理解它的行为了。

八、动手实践:用 TypeScript 模拟电梯调度

前面的内容已经足以理解电梯调度的基本思想。

如果你对编程感兴趣,我们还可以继续尝试,把刚才推导出的规则写成代码。

这里仍然只考虑一台运行于 1~6 楼的电梯。

我们不模拟开门、关门、载重和加减速,而是让程序每执行一次 step(),就进行一次运行决策。

首先,定义电梯的状态:

TypeScript
type Direction = -1 | 0 | 1;

// -1:下行
//  0:待机
//  1:上行

class Elevator {
  floor = 1;
  direction: Direction = 0;

  // 厅外请求:按方向分别记录
  private up = new Set<number>();
  private down = new Set<number>();

  // 轿内请求:具体目的楼层
  private inside = new Set<number>();
}

之所以使用 Set,是因为同一个楼层、同一方向的呼叫,只需要记录一次。

例如,即使 2 楼有三位乘客同时按下上行按钮,也不意味着电梯需要在 2 楼连续停靠三次。

接下来,我们将请求记录、方向判断以及停靠规则组合起来。

下面是一份完整的简化实现:

TypeScript
type Direction = -1 | 0 | 1;

class Elevator {
  floor = 1;
  direction: Direction = 0;

  private up = new Set<number>();
  private down = new Set<number>();
  private inside = new Set<number>();

  private validate(floor: number) {
    if (!Number.isInteger(floor) || floor < 1 || floor > 6) {
      throw new Error("楼层必须在 1~6 之间");
    }
  }

  // 厅外乘客呼叫电梯
  call(floor: number, direction: "up" | "down") {
    this.validate(floor);

    if (
      (floor === 1 && direction === "down") ||
      (floor === 6 && direction === "up")
    ) {
      throw new Error("该楼层没有对应方向的按钮");
    }

    const calls = direction === "up" ? this.up : this.down;
    calls.add(floor);
  }

  // 乘客进入电梯后选择目的楼层
  select(floor: number) {
    this.validate(floor);
    this.inside.add(floor);
  }

  // 合并所有尚未处理的请求楼层
  private pending(): number[] {
    return [...new Set([
      ...this.up,
      ...this.down,
      ...this.inside,
    ])];
  }

  // 当前楼层是否需要停靠
  private shouldStop(): boolean {
    return (
      this.inside.has(this.floor) ||
      (this.direction === 1 && this.up.has(this.floor)) ||
      (this.direction === -1 && this.down.has(this.floor))
    );
  }

  // 当前方向的前方是否还有请求
  private hasAhead(): boolean {
    return this.pending().some(floor =>
      this.direction === 1
        ? floor > this.floor
        : floor < this.floor
    );
  }

  // 完成当前楼层的对应请求
  private stop(): string {
    this.inside.delete(this.floor);

    if (this.direction === 1) {
      this.up.delete(this.floor);
    } else if (this.direction === -1) {
      this.down.delete(this.floor);
    }

    return `${this.floor} 楼停靠`;
  }

  // 模拟一次运行决策
  step(): string {
    const requests = this.pending();

    if (requests.length === 0) {
      this.direction = 0;
      return `${this.floor} 楼待机`;
    }

    // 待机时,先选择最近的请求楼层
    if (this.direction === 0) {
      const nearest = requests.reduce((a, b) =>
        Math.abs(a - this.floor) <= Math.abs(b - this.floor)
          ? a
          : b
      );

      this.direction =
        nearest > this.floor ? 1 :
        nearest < this.floor ? -1 :
        this.up.has(this.floor) ? 1 : -1;
    }

    // 前方没有请求时,考虑反向
    if (!this.shouldStop() && !this.hasAhead()) {
      this.direction = this.direction === 1 ? -1 : 1;
    }

    if (this.shouldStop()) {
      return this.stop();
    }

    // 每次向当前方向移动一层
    this.floor += this.direction;

    // 到达折返点后,允许先掉头再接人
    if (!this.shouldStop() && !this.hasAhead()) {
      this.direction = this.direction === 1 ? -1 : 1;
    }

    if (this.shouldStop()) {
      return this.stop();
    }

    return `${this.floor} 楼经过`;
  }
}

这段代码最核心的部分,其实只有三个判断:

  1. 当前楼层是否应该停靠?
  2. 当前方向的前方是否还有请求?
  3. 如果没有,应该改变方向还是进入待机?

我们用第四节的例子简单验证一下。

TypeScript
const elevator = new Elevator();

// 2 楼想上行,3 楼想下行
elevator.call(2, "up");
elevator.call(3, "down");

console.log(elevator.step()); // 2 楼停靠

// 2 楼乘客进入电梯,选择 4 楼
elevator.select(4);

console.log(elevator.step()); // 3 楼经过
console.log(elevator.step()); // 4 楼停靠

// 上行任务完成,准备处理下行请求
console.log(elevator.step()); // 3 楼停靠

// 3 楼乘客进入电梯,选择 1 楼
elevator.select(1);

console.log(elevator.step()); // 2 楼经过
console.log(elevator.step()); // 1 楼停靠

最终得到的停靠顺序:

纯文本
1 → 2 → 4 → 3 → 1

正好符合前面推导出的调度规则。

需要说明的是,这段代码只是一个教学模型,重点在于表达请求、方向与停靠之间的关系。它采用同步、逐层执行的方式,没有模拟真实电梯的运行时间和开关门过程。

另外,乘客上下车也需要由外部示例代码配合完成。比如 select(4),是我们在电梯停靠 2 楼后,模拟乘客进入轿厢并按下目的楼层。

在真正的电梯系统中,还需要处理异步事件、开关门状态、满载运行、故障保护以及更复杂的调度边界。

不过,对这篇文章来说,我们并不需要实现一个完整的电梯控制器。

只要能够通过几十行核心逻辑,把前面的调度思想表达出来,就已经达到了实践的目的。

九、写在最后

回过头看,一台电梯的运行逻辑,其实没有想象中那么神秘。

从最初的先来先服务,到顺路接人,再到依据方向集中处理请求,我们并没有引入复杂的数学公式,却已经能够逐步改善电梯的运行方式。

而这一切,最初不过是从一个很简单的问题开始:

为什么电梯明明经过了我的楼层,却没有停下来?

在尝试回答这个问题的过程中,我们逐渐发现,电梯并不是只需要考虑眼前的某位乘客,而是需要协调所有正在发生的请求。

有时候,暂时不为某个人停靠,反而可能是为了让整体运行更加高效。

当然,这还只是单台电梯的情况。

如果一栋建筑拥有三台、五台甚至更多电梯,它们又应该如何分配任务?

例如,三台电梯分别停在 1 楼、3 楼和 6 楼,此时 4 楼有人按下了上行按钮。

系统究竟应该派哪一台电梯前去接人?

距离最近的电梯,就一定是最合适的吗?

如果最近的电梯已经满载,又该怎么办?

这些问题就不再只是单台电梯内部的调度,而涉及多台电梯之间的协作。

下一篇,我们继续聊聊电梯群控调度,以及多台电梯如何分工,才能让整栋建筑的乘客等得更少。


延伸阅读

本文采用 CC BY-NC-SA 4.0 协议许可,转载请保留原文链接及作者。