C++ 實現銀行排隊服務模擬

Wayne Shi發表於2016-07-24

教程簡介:使用 C++對銀行排隊服務進行模擬,以事件驅動為核心思想,手動實現模板鏈式佇列、隨機數產生器等內容,進而學習概率程式設計等知識。作為可選進階,這個模型同時還能稍加修改的應用到 CPU 資源爭奪模型中。

一、概述

實驗所需的前置知識

  • C++ 基本語法知識

實驗所鞏固並運用的知識

  • OOP 程式設計思想
  • std::rand() 函式原理
  • 概率程式設計
  • 排隊理論
  • 鏈式佇列資料結構及其模板實現
  • 事件驅動的設計
  • 蒙特卡洛方法
  • C++ 動態記憶體管理和設計理念
  • CPU 資源爭奪模型
  • 時間片輪轉排程

要解決的問題

蒙特卡洛方法這個名字聽起來很高大上,但它的本質其實是使用計算機的方法對問題進行模擬和復現。本次實驗將使用蒙特卡洛方法來模擬銀行排隊這個問題:

端午節當天,某個銀行從早上八點開始服務並只服務到中午十二點就停止營業。假設當天銀行只提供了 w 個服務視窗進行服務,問:

  1. 平均每分鐘有多少個顧客抵達銀行?
  2. 平均每個顧客佔用服務視窗的時間是多少?

我們先來分析一下這個業務的邏輯:

首先我們要分析銀行提供服務的邏輯。在銀行服務中,所有顧客都是通過取號排隊的方式等待服務的,這和火車站買票有所不同,在火車站買票時,顧客必須在某一個視窗所排的佇列下進行排隊,且無法變更自己所屬的視窗,否則只能從隊尾重新排隊。換句話說,對於銀行提供的服務來說,所有使用者都是位於同一個佇列上的,當某個服務視窗可用時,才會從排隊佇列的隊首取出一個新的使用者來辦理銀行業務。即程式碼實現過程中,服務視窗可以建立 w 個,但只需要實現一個顧客佇列即可。

其次,對於顧客而言,有兩個屬性是能夠被抽象出來的:

  1. 到達銀行的時間;
  2. 需要服務的時間。

並且,這兩個屬性是隨機的。到此,我們整個的排隊模型就變成了:

下面我們來詳細對這個問題的實現邏輯進行分析,讓我們的程式能夠給出類似下面的結果:

第一部分、銀行排隊系統的設計與建模

一、計算機中的隨機

std::rand() 函式的原理

C++ 中的 std::rand() 函式產生的隨機數並不是真正意義上的隨機數,它並不服從數學上的均勻分佈。為了使我們的模擬系統變得更加真實,我們需要知道 std::rand() 函式的原理。

std::rand() 生成的是一個隨機的二進位制序列(在硬體底層更好實現),這個序列的每一位0或者1的概率都是相等的。而對於 std::rand()%n 這個運算,會在 [0, n-1] 之間生成隨機數,所以,如果 n-1 的二進位制表示的值不都是由 1 組成,那麼這裡面的數是不會從均勻分佈了(因為某些位可能不能為 1)。

所以,當且僅當 [0, n-1] 中的隨機數可以用這個序列的子序列表示時,才能滿足均勻分佈。換句話說,僅當 n-1 的二進位制數全為1 時,0,1出現的概率才是均等的。

我們先來實現隨機這個類:

//
//  Random.hpp
//  QueueSystem
//

#ifndef Random_hpp
#define Random_hpp

#include <cstdlib>
#include <cmath>

class Random {
public:
    // [0, 1) 之間的服從均勻分佈的隨機值
    static double uniform(double max = 1) {
        return ((double)std::rand() / (RAND_MAX))*max;
    }
};
#endif /* Random_hpp */

這樣的話,當我們呼叫 Random::uniform() 時,便能獲得真正的服從均勻分佈的隨機數了。當指定引數後,便能夠生成 [0, max) 之間的隨機值了。

二、主函式邏輯設計

對於一個銀行而言,對外界來說只需要提供兩個引數:

  1. 總共的服務時間
  2. 服務視窗的數量

所以我們希望實現這樣的程式碼:

//
// main.cpp
// QueueSystem
//

#include "QueueSystem.hpp"

#include <iostream>
#include <cstdlib>

int main() {

    std::srand((unsigned)std::time(0)); // 使用當前時間作為隨機數種子

    int total_service_time = 240;       // 按分鐘計算
    int window_num         = 4;
    int simulate_num       = 100000;    // 模擬次數

    QueueSystem system(total_service_time, window_num);
    system.simulate(simulate_num);

    std::cout << "The average time of customer stay in bank: "
              << system.getAvgStayTime() << std::endl;
    std::cout << "The number of customer arrive bank per minute: "
              << system.getAvgCustomers() << std::endl;

    return 0;
}

三、物件及邏輯設計

總結一下,現在我們需要實現的東西有:

  1. 服務視窗類(會被建立 w 個)
  2. 顧客佇列類(只會被建立一個)
  3. 顧客結構(包含兩個隨機屬性: 到達時間, 服務時間)

為了更好練習 C++,我們會棄用諸如 vector 這些快捷編碼的標準庫來進行『過度編碼』,自行編寫模板類。

根據前面的問題描述,我們可以初步確定這樣一些類的設計需求:

  1. QueueSystem 類: 負責整個佇列系統的模擬;
  2. ServiceWindow 類: 佇列系統的服務視窗物件,每當一個銀行建立時,服務視窗會被建立,為了讓整個問題更加靈活,我們假設需要建立 window_num 個視窗;
  3. Queue 類: 銀行佇列系統的顧客排隊的佇列;
  4. Random 類: 在第二節中已經討論過。

然而,在設計 ServiceWindow 之前,我們要考慮 ServiceWindow 類到底要放置什麼成員,首先,對於一個服務視窗,會有一個顧客屬性,用於存放顧客。另一方面,一個視窗只會有兩種狀態:要麼正在服務(被佔用),要麼空閒。因此 ServiceWindow 中首先會有下面的列舉:

//
// ServiceWindow.hpp
// QueueSystem
//

enum WindowStatus {
    SERVICE,
    IDLE,
};

既然我們要在 ServiceWindow 中存放顧客,由於顧客本身並不需要提供什麼方法,因此可以直接將顧客設計為一個結構體 Customer,同時,顧客也會成為等待佇列中的一員。所以,Customer 也可以被稱之為佇列的一個 Node,此外,每個顧客說需要的服務時間是隨機的,但是到達時間並不應該由顧客自身確定(我們在下一節再討論為什麼),所以Customer結構的預設構造應該被設計出來:

//
//  Node.h
//  QueueSystem
//

#ifndef Node_h
#define Node_h

#include "Random.hpp"

#define RANDOM_PARAMETER 100

struct Node {
    int arrive_time;
    int duration;
    struct Node *next;

    // 預設到達事件為0,需要服務的事件是隨機的
    Node(int arrive_time = 0,
         int duration = Random::uniform(RANDOM_PARAMETER)):
        arrive_time(arrive_time),
        duration(duration),
        next(nullptr) {}
};

typedef struct Node Node;
typedef struct Node Customer;

#endif /* Node_h */

那麼,結合前面的 WindowStatus列舉和 Customer結構,我們的 ServiceWindow 類可以這樣設計,因為視窗本身涉及的操作還算是比較簡單,比如設定視窗狀態是否繁忙,獲取當前服務顧客的到達時間來方便後續計算等等,因此我們直接將其設計成類內的 inline 函式:

//
//  ServiceWindow.hpp
//  QueueSystem
//

#ifndef ServiceWindow_hpp
#define ServiceWindow_hpp

#include "Node.hpp"

enum WindowStatus {
    SERVICE,
    IDLE,
};

class ServiceWindow {
public:
    inline ServiceWindow() {
        window_status = IDLE;
    };
    inline bool isIdle() const {
        if (window_status == IDLE) {
            return true;
        } else {
            return false;
        }
    }
    inline void serveCustomer(Customer &customer) {
        this->customer = customer;
    }
    inline void setBusy() {
        window_status = SERVICE;
    }
    inline void setIdle() {
        window_status = IDLE;
    }
    inline int getCustomerArriveTime() const {
        return customer.arrive_time;
    }
    inline int getCustomerDuration() const {
        return customer.duration;
    }
private:
    Customer customer;
    WindowStatus window_status;
};

#endif /* ServiceWindow_hpp */

三、事件驅動的設計

有了上面的這些設計,似乎我們只要編寫好使用者排隊佇列,就已經足夠描述整個排隊的系統了,然而,在上面的設計中,還有一個很大的問題,那就是:整個系統還處於靜止狀態。當顧客位於等待佇列時,視窗什麼時候服務下一個顧客,如何處理這裡面的邏輯,到目前為止,我們都沒有思考過。

為了讓整個系統『執行』起來,我們還要考慮整個系統的執行時間線。這裡我們給出一種事件驅動的設計。

在前面的分析中,我們知道整個系統中,無非出現兩種事件:

  1. 有顧客到達
  2. 有顧客離開

其中,第二種顧客離開的事件,同時還包含了視窗服務等待佇列中的下一個顧客這個事件。所以,我們如果能夠維護一個事件列表,那麼就能夠驅動整個佇列系統的執行了。因為,當事件發生時,我們通知這個佇列系統更新他自身的狀態即可。

綜上所述,我們可以先設計事件表中的事件結構:

//
//  Event.hpp
//  QueueSystem
//

#ifndef Event_hpp
#define Event_hpp

#include "Random.hpp"
#define RANDOM_PARAMETER 100

struct Event {
    int occur_time;

    // 使用 -1 表示到達事件, >=0 表示離開事件, 同時數值表示所離開的服務視窗
    int event_type;

    Event* next;

    // 預設為到達事件,發生事件隨機
    Event(int occur_time = Random::uniform(RANDOM_PARAMETER),
          int event_type = -1):
        occur_time(occur_time),
        event_type(event_type),
        next(nullptr) {}
};

#endif /* Event_hpp */

這裡我們使用了一個小小的 trick,那就是用整數來表示事件的型別,而不是簡單的使用列舉。

這是因為,對於 ServiceWindow 來說,我們可以使用陣列來管理多個 ServiceWindow,那麼對應的事件型別如果涉及為整數,事件型別就可以同時作為 ServiceWindow 的索引下標了,當 event_type 大於等於 0 時,數值還表示離開的服務視窗。

又因為事件列表、顧客佇列,本質上可以歸類為同一個結構,那就是佇列:只不過他們的入隊方式有所差異,對於事件列表而言,入隊方式必須按發生事件的時間順序入隊,而對於顧客,則是直接新增到隊尾。考慮到了這一點,我們便能很容易的利用模板來設計佇列的基本需求了:

//
//  Queue.hpp
//  QueueSystem
//

#ifndef Queue_hpp
#define Queue_hpp

#include <iostream>
#include <cstdlib>

#include "Event.hpp"

// 帶頭結點的佇列
template <typename T>
class Queue
{
public:
    Queue();
    ~Queue();
    void clearQueue();             // 清空佇列
    T* enqueue(T &node);
    T* dequeue();
    T* orderEnqueue(Event &event); // 只適用於事件入隊
    int  length();
private:
    T *front;  // 頭結點
    T *rear;   // 隊尾
};
#endif /* Queue_hpp */

四、QueueSystem

經過前面的討論,我們已經完成了對所有基本結構的設計,根據這些設計,我們能夠初步確定我們要實現的佇列系統的基本結構。

首先,根據對主函式的設計,初始化整個佇列系統我們需要兩個引數:

  1. 銀行的總服務時間(分鐘) int total_service_time
  2. 銀行開放的服務視窗數 int window_num

其次,我們需要 QueueSystem 發開放至少三個介面:

  1. 模擬 simulate()
  2. 獲得顧客平均逗留時間 getAvgStayTime()
  3. 獲得平均每分鐘顧客數 getAvgCustomers()

第三,內部需要實現的內容包括:

  1. 系統執行前的初始化 init()
  2. 讓系統執行的 run()
  3. 系統結束一次執行的清理工作 end()

第四,整個系統需要管理的核心成員有:

  1. 可供服務的視窗 ServiceWindow* windows
  2. 顧客等待佇列 Queue<Customer> customer_list
  3. 事件列表 Queue<Event> event_list
  4. 當前的系統事件 Event* current_event

第五,處理事件的方法:

  1. 處理顧客到達事件 void customerArrived()
  2. 處理顧客離開事件 void customerDeparture()

最後,我們所希望的平均顧客逗留時間和平均每分鐘的顧客數涉及的四個變數:

  1. 顧客的總逗留時間 int total_customer_stay_time
  2. 一次執行中系統服務的中顧客數量 int total_customer_num
  3. 每分鐘平均顧客數 double avg_customers
  4. 顧客平均逗留時間 double avg_stay_time

事實上,可以預見的是,在處理顧客服務邏輯的時候,我們還需要一個方法getIdleServiceWindow 來獲取當前服務視窗的狀態,從而增加程式碼的複用度。

所以,整個 QueueSystem 類的程式碼設計為:

//
//  QueueSystem.hpp
//  QueueSystem
//

#ifndef QueueSystem_hpp
#define QueueSystem_hpp

#include "Event.hpp"
#include "Queue.hpp"
#include "ServiceWindow.hpp"

class QueueSystem {

public:
    // 初始化佇列系統
    QueueSystem(int total_service_time, int window_num);

    // 銷燬
    ~QueueSystem();

    // 啟動模擬
    void simulate(int simulate_num);

    inline double getAvgStayTime() {
        return avg_stay_time;
    }
    inline double getAvgCustomers() {
        return avg_customers;
    }

private:
    // 讓佇列系統執行一次
    double run();

    // 初始化各種引數
    void init();

    // 清空各種引數
    void end();

    // 獲得空閒視窗索引
    int getIdleServiceWindow();

    // 處理顧客到達事件
    void customerArrived();

    // 處理顧客離開事件
    void customerDeparture();

    // 服務視窗的總數
    int window_num;

    // 總的營業時間
    int total_service_time;

    // 顧客的逗留總時間
    int customer_stay_time;

    // 總顧客數
    int total_customer_num;

    // 核心成員
    ServiceWindow*  windows;
    Queue<Customer> customer_list;
    Queue<Event>       event_list;
    Event*          current_event;

    // 給外部呼叫的結果
    double avg_customers;
    double avg_stay_time;

};

#endif /* QueueSystem_hpp */

本節回顧

在這一節中,我們設計了整個銀行排隊系統的基本邏輯,並借鑑了事件驅動的思想設計了驅動佇列系統的事件類。本節中我們一共建立了:

  1. Event.hpp
  2. Node.hpp
  3. Queue.hpp
  4. Random.hpp
  5. ServiceWindow.hpp
  6. QueueSystem.hpp
  7. main.cpp

現在我們的程式碼還不能夠直接執行,本節我們先關注理清我們的業務邏輯。在下一節中,我們將實現這些程式碼的詳細邏輯,這包括:

  1. Queue.hpp 中模板鏈式佇列的具體實現
  2. QueueSystem.cpp 中的詳細服務邏輯
  3. Random.hpp 中更復雜的隨機概率分佈

在這些實現中,我們將進一步鞏固下面的知識的運用:

  1. C++ 類别範本
  2. 鏈式佇列的資料結構
  3. 概率程式設計
  4. C++ 中記憶體管理和設計理念

相關文章