返回介绍

12.7 队列模拟

发布于 2024-10-08 23:14:10 字数 12826 浏览 0 评论 0 收藏 0

进一步了解类后,可将这方面的知识用于解决编程问题。Heather 银行打算在 Food Heap 超市开设一个自动柜员机(ATM)。Food Heap 超市的管理者担心排队等待使用 ATM 的人流会干扰超市的交通,希望限制排队等待的人数。Heather 银行希望对顾客排队等待的时间进行估测。要编写一个程序来模拟这种情况,让超市的管理者可以了解 ATM 可能造成的影响。

对于这种问题,最自然的方法是使用顾客队列。队列是一种抽象的数据类型(Abstract Data Type,ADT),可以存储有序的项目序列。新项目被添加在队尾,并可以删除队首的项目。队列有点像栈,但栈在同一端进行添加和删除。这使得栈是一种后进先出(LIFO,last-in,first-out)的结构,而队列是先进先出(FIFO,first-in,first-out)的。从概念上说,队列就好比是收款台或 ATM 前面排的队,所以对于上述问题,队列非常合适。因此,工程的任务之一是定义一个 Queue 类(第 16 章将介绍标准模板库类 queue,也将介绍如何开发自己的类)。

队列中的项目是顾客。Heather 银行的代表介绍:通常,三分之一的顾客只需要一分钟便可获得服务,三分之一的顾客需要两分钟,另外三分之一的顾客需要三分钟。另外,顾客到达的时间是随机的,但每个小时使用自动柜员机的顾客数量相当稳定。工程的另外两项任务是:设计一个表示顾客的类;编写一个程序来模拟顾客和队列之间的交互(参见图 12.7)。

图 12.7 队列

12.7.1 队列类

首先需要设计一个 Queue 类。这里先列出队列的特征:

  • 队列存储有序的项目序列;
  • 队列所能容纳的项目数有一定的限制;
  • 应当能够创建空队列;
  • 应当能够检查队列是否为空;
  • 应当能够检查队列是否是满的;
  • 应当能够在队尾添加项目;
  • 应当能够从队首删除项目;
  • 应当能够确定队列中项目数。

设计类时,需要开发公有接口和私有实现。

1.Queue 类的接口

从队列的特征可知,Queue 类的公有接口应该如下:

构造函数创建一个空队列。默认情况下,队列最多可存储 10 个项目,但是可以用显式初始化参数覆盖该默认值:

使用队列时,可以使用 typedef 来定义 Item(第 14 章将介绍如何使用类模板)。

2.Queue 类的实现

确定接口后,便可以实现它。首先,需要确定如何表示队列数据。一种方法是使用 new 动态分配一个数组,它包含所需的元素数。然而,对于队列操作而言,数组并不太合适。例如,删除数组的第一个元素后,需要将余下的所有元素向前移动一位;否则需要作一些更费力的工作,如将数组视为是循环的。然而,链表能够很好地满足队列的要求。链表由节点序列构成。每一个节点中都包含要保存到链表中的信息以及一个指向下一个节点的指针。对于这里的队列来说,数据部分都是一个 Item 类型的值,因此可以使用下面的结构来表示节点:

图 12.8 说明了链表。

如图 12.8 所示是一个单向链表,因为每个节点都只包含一个指向其他节点的指针。知道第一个节点的地址后,就可以沿指针找到后面的每一个节点。通常,链表最后一个节点中的指针被设置为 NULL(或 0),以指出后面没有节点了。在 C++11 中,应使用新增的关键字 nullptr。要跟踪链表,必须知道第一个节点的地址。可以让 Queue 类的一个数据成员指向链表的起始位置。具体地说,这是所需要的全部信息,有了这种信息后,就可以沿节点链找到任何节点。然而,由于队列总是将新项目添加到队尾,因此包含一个指向最后一个节点的数据成员将非常方便(参见图 12.9)。此外,还可以使用数据成员来跟踪队列可存储的最大项目数以及当前的项目数。所以,类声明的私有部分与下面类似:

图 12.8 链表

图 12.9 Queue 对象

上述声明使用了 C++的一项特性:在类中嵌套结构或类声明。通过将 Node 声明放在 Queue 类中,可以使其作用域为整个类。也就是说,Node 是这样一种类型:可以使用它来声明类成员,也可以将它作为类方法中的类型名称,但只能在类中使用。这样,就不必担心该 Node 声明与某些全局声明或其他类中声明的 Node 发生冲突。有些较老的编译器不支持嵌套的结构和类,如果您的编译器是这样的,则必须将 Node 结构定义为全局的,将其作用域设置为整个文件。

嵌套结构和类

在类声明中声明的结构、类或枚举被称为是被嵌套在类中,其作用域为整个类。这种声明不会创建数据对象,而只是指定了可以在类中使用的类型。如果声明是在类的私有部分进行的,则只能在这个类使用被声明的类型;如果声明是在公有部分进行的,则可以从类的外部通过作用域解析运算符使用被声明的类型。例如,如果 Node 是在 Queue 类的公有部分声明的,则可以在类的外面声明 Queue::Node 类型的变量。

设计好数据的表示方式后,接下来需要编写类方法。

3.类方法

类构造函数应提供类成员的值。由于在这个例子中,队列最初是空的,因此队首和队尾指针都设置为 NULL(0 或 nullptr),并将 items 设置为 0。另外,还应将队列的最大长度 qsize 设置为构造函数参数 qs 的值。下面的实现方法无法正常运行:

问题在于 qsize 是常量,所以可以对它进行初始化,但不能给它赋值。从概念上说,调用构造函数时,对象将在括号中的代码执行之前被创建。因此,调用 Queue(int qs)构造函数将导致程序首先给 4 个成员变量分配内存。然后,程序流程进入到括号中,使用常规的赋值方式将值存储到内存中。因此,对于 const 数据成员,必须在执行到构造函数体之前,即创建对象时进行初始化。C++提供了一种特殊的语法来完成上述工作,它叫做成员初始化列表(member initializer list)。成员初始化列表由逗号分隔的初始化列表组成(前面带冒号)。它位于参数列表的右括号之后、函数体左括号之前。如果数据成员的名称为 mdata,并需要将它初始化为 val,则初始化器为 mdata(val)。使用这种表示法,可以这样编写 Queue 的构造函数:

通常,初值可以是常量或构造函数的参数列表中的参数。这种方法并不限于初始化常量,可以将 Queue 构造函数写成如下所示:

只有构造函数可以使用这种初始化列表语法。如上所示,对于 const 类成员,必须使用这种语法。另外,对于被声明为引用的类成员,也必须使用这种语法:

这是因为引用与 const 数据类似,只能在被创建时进行初始化。对于简单数据成员(例如 front 和 items),使用成员初始化列表和在函数体中使用赋值没有什么区别。然而,正如第 14 章将介绍的,对于本身就是类对象的成员来说,使用成员初始化列表的效率更高。

成员初始化列表的语法

如果 Classy 是一个类,而 mem1、mem2 和 mem3 都是这个类的数据成员,则类构造函数可以使用如下的语法来初始化数据成员:

上述代码将 mem1 初始化为 n,将 mem2 初始化为 0,将 mem3 初始化为 n*m + 2。从概念上说,这些初始化工作是在对象创建时完成的,此时还未执行括号中的任何代码。请注意以下几点:

  • 这种格式只能用于构造函数;

  • 必须用这种格式来初始化非静态 const 数据成员(至少在 C++11 之前是这样的);

  • 必须用这种格式来初始化引用数据成员。

数据成员被初始化的顺序与它们出现在类声明中的顺序相同,与初始化器中的排列顺序无关。

警告:

不能将成员初始化列表语法用于构造函数之外的其他类方法。

成员初始化列表使用的括号方式也可用于常规初始化。也就是说,如果愿意,可以将下述代码:

替换为:

这使得初始化内置类型就像初始化类对象一样。

C++11 的类内初始化

C++11 允许您以更直观的方式进行初始化:

这与在构造函数中使用成员初始化列表等价:

成员 mem1 和 mem2 将分别被初始化为 10 和 20,除非调用了使用成员初始化列表的构造函数,在这种情况下,实际列表将覆盖这些默认初始值:

在这里,构造函数将使用 n 来初始化 mem1,但 mem2 仍被设置为 20。

isempty()、isfull() 和 queuecount() 的代码都非常简单。如果 items 为 0,则队列是空的;如果 items 等于 qsize,则队列是满的。要知道队列中的项目数,只需返回 items 的值。后面的程序清单 12.11 列出了这些代码。

将项目添加到队尾(入队)比较麻烦。下面是一种方法:

总之,方法需要经过下面几个阶段(见图 12.10)。

图 12.10 将项目入队

1.如果队列已满,则结束(在这里的实现中,队列的最大长度由用户通过构造函数指定)。

2.创建一个新节点。如果 new 无法创建新节点,它将引发异常,这个主题将在第 15 章介绍。最终的结果是,除非提供了处理异常的代码,否则程序将终止。

3.在节点中放入正确的值。在这个例子中,代码将 Item 值复制到节点的数据部分,并将节点的 next 指针设置为 NULL(0 或 C++11 新增的 nullptr)。这样就为将节点作为队列中的最后一个项目做好了准备。

4.将项目计数(items)加 1。

5.将节点附加到队尾。这包括两个部分。首先,将节点与列表中的另一个节点连接起来。这是通过将当前队尾节点的 next 指针指向新的队尾节点来完成的。第二部分是将 Queue 的成员指针 rear 设置为指向新节点,使队列可以直接访问最后一个节点。如果队列为空,则还必须将 front 指针设置成指向新节点(如果只有一个节点,则它既是队首节点,也是队尾节点)。

删除队首项目(出队)也需要多个步骤才能完成。下面是一种方式:

总之,需要经过下面几个阶段(参见图 12.11):

图 12.11 将项目出队

1.如果队列为空,则结束。

2.将队列的第一个项目提供给调用函数,这是通过将当前 front 节点中的数据部分复制到传递给方法的引用变量中来实现。

3.将项目计数(items)减 1。

4.保存 front 节点的位置,供以后删除。

5.让节点出队。这是通过将 Queue 成员指针 front 设置成指向下一个节点来完成的,该节点的位置由 front->next 提供。

6.为节省内存,删除以前的第一个节点。

7.如果链表为空,则将 rear 设置为 NULL(在这个例子中,将 front 指针设置成 front->next 后,它已经是 NULL 了)。同样,可使用 0 而不是 NULL,也可使用 C++11 新增的 nullptr。

第 4 步是必不可少的,这是因为第 5 步将删除关于先前第一个节点位置的信息。

4.是否需要其他类方法

是否需要其他方法呢?类构造函数没有使用 new,所以乍一看,好像不用理会由于在构造函数中使用 new 给类带来的特殊要求。当然,这种印象是错误的,因为向队列中添加对象将调用 new 来创建新的节点。通过删除节点的方式,dequeue( ) 方法确实可以清除节点,但这并不能保证队列在到期时为空。因此,类需要一个显式析构函数——该函数删除剩余的所有节点。下面是一种实现,它从链表头开始,依次删除其中的每个节点:

您知道,使用 new 的类通常需要包含显式复制构造函数和执行深度复制的赋值运算符,这个例子也是如此吗?首先要回答的问题是,默认的成员复制是否合适?答案是否定的。复制 Queue 对象的成员将生成一个新的对象,该对象指向链表原来的头和尾。因此,将项目添加到复制的 Queue 对象中,将修改共享的链表。这样做将造成非常严重的后果。更糟的是,只有副本的尾指针得到了更新,从原始对象的角度看,这将损坏链表。显然,要克隆或复制队列,必须提供复制构造函数和执行深度复制的赋值构造函数。

当然,这提出了这样一个问题:为什么要复制队列呢?也许是希望在模拟的不同阶段保存队列的瞬像,也可能是希望为两个不同的策略提供相同的输入。实际上,拥有拆分队列的操作是非常有用的,超市在开设额外的收款台时经常这样做。同样,也可能希望将两个队列结合成一个或者截短一个队列。

但假设这里的模拟不实现上述功能。难道不能忽略这些问题,而使用已有的方法吗?当然可以。然而,在将来的某个时候,可能需要再次使用队列且需要复制。另外,您可能会忘记没有为复制提供适当的代码。在这种情况下,程序将能编译和运行,但结果却是混乱的,甚至会崩溃。因此,最好还是提供复制构造函数和赋值运算符,尽管目前并不需要它们。

幸运的是,有一种小小的技巧可以避免这些额外的工作,并确保程序不会崩溃。这就是将所需的方法定义为伪私有方法:

这样做有两个作用:第一,它避免了本来将自动生成的默认方法定义。第二,因为这些方法是私有的,所以不能被广泛使用。也就是说,如果 nip 和 tuck 是 Queue 对象,则编译器就不允许这样做:

所以,与其将来面对无法预料的运行故障,不如得到一个易于跟踪的编译错误,指出这些方法是不可访问的。另外,在定义其对象不允许被复制的类时,这种方法也很有用。

C++11 提供了另一种禁用方法的方式——使用关键字 delete,这将在第 18 章介绍。

还有没有其他影响需要注意呢?当然有。当对象被按值传递(或返回)时,复制构造函数将被调用。然而,如果遵循优先采用按引用传递对象的惯例,将不会有任何问题。另外,复制构造函数还被用于创建其他的临时对象,但 Queue 定义中并没有导致创建临时对象的操作,例如重载加法运算符。

12.7.2 Customer 类

接下来需要设计客户类。通常,ATM 客户有很多属性,例如姓名、账户和账户结余。然而,这里的模拟需要使用的唯一一个属性是客户何时进入队列以及客户交易所需的时间。当模拟生成新客户时,程序将创建一个新的客户对象,并在其中存储客户的到达时间以及一个随机生成的交易时间。当客户到达队首时,程序将记录此时的时间,并将其与进入队列的时间相减,得到客户的等候时间。下面的代码演示了如何定义和实现 Customer 类:

默认构造函数创建一个空客户。set() 成员函数将到达时间设置为参数,并将处理时间设置为 1~3 中的一个随机值。

程序清单 12.10 将 Queue 和 Customer 类声明放到一起,而程序清单 12.11 列出了方法。

程序清单 12.10 queue.h

程序清单 12.11 queue.cpp

12.7.3 ATM 模拟

现在已经拥有模拟 ATM 所需的工具。程序允许用户输入 3 个数:队列的最大长度、程序模拟的持续时间(单位为小时)以及平均每小时的客户数。程序将使用循环——每次循环代表一分钟。在每分钟的循环中,程序将完成下面的工作。

1.判断是否来了新的客户。如果来了,并且此时队列未满,则将它添加到队列中,否则拒绝客户入队。

2.如果没有客户在进行交易,则选取队列的第一个客户。确定该客户的已等候时间,并将 wait_time 计数器设置为新客户所需的处理时间。

3.如果客户正在处理中,则将 wait_time 计数器减 1。

4.记录各种数据,如获得服务的客户数目、被拒绝的客户数目、排队等候的累积时间以及累积的队列长度等。

当模拟循环结束时,程序将报告各种统计结果。

一个有趣的问题是,程序如何确定是否有新的客户到来。假设平均每小时有 10 名客户到达,则相当于每 6 分钟有一名客户。程序将计算这个值,并将它保存在 min_per_cust 变量中。然而,刚好每 6 分钟来一名客户不太现实,我们真正(至少在大部分时间内)希望的是一个更随机的过程——但平均每 6 分钟来一名客户。程序将使用下面的函数来确定是否在循环期间有客户到来:

其工作原理如下:值 RAND_MAX 是在 cstdlib 文件(以前是 stdlib.h)中定义的,是 rand( ) 函数可能返回的最大值(0 是最小值)。假设客户到达的平均间隔时间 x 为 6,则 rand( )* x /RAND_MAX 的值将位于 0 到 6 之间。具体地说,平均每隔 6 次,这个值会有 1 次小于 1。然而,这个函数可能会导致客户到达的时间间隔有时为 1 分钟,有时为 20 分钟。这种方法虽然很笨拙,但可使实际情况不同于有规则地每 6 分钟到来一个客户。如果客户到达的平均时间间隔少于 1 分钟,则上述方法将无效,但模拟并不是针对这种情况设计的。如果确实需要处理这种情况,最好提高时间分辨率,比如每次循环代表 10 秒钟。

程序清单 12.12 给出了模拟的细节。长时间运行该模拟程序,可以知道长期的平均值;短时间运行该模拟程序,将只能知道短期的变化。

程序清单 12.12 bank.cpp

注意:

编译器如果没有实现 bool,可以用 int 代替 bool,用 0 代替 false,用 1 代替 true;还可能必须使用 stdlib.h 和 time.h 代替较新的 cstdlib 和 ctime;另外可能必须自己来定义 RAND_MAX。

下面是程序清单 12.10-12.12 组成的程序长时间运行的几个例子:

注意,每小时到达的客户从 15 名增加到 30 名时,等候时间并不是加倍,而是增加了 15 倍。如果允许队列更长,情况将更糟。然而,模拟没有考虑到这个事实——许多客户由于不愿意排很长的队而离开了。

下面是该程序的另外几个运行示例。从中可知,即使平均每小时到达的客户数不变,也会出现短期变化。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文