队列模拟

ATM问题

Heapther银行在Food Heap超市开设一个ATM。Food Heap超市担心排队等待使用ATM的人流会干扰超市的交通,希望限制排队等待的人数。Heapther银行希望对顾客排队等待的时间进行估测。

队列是一种抽象的数据类型,可以存储有序的项目序列。新项目被添加在队尾,并可以删除队首的项目。栈在同一端进行添加和删除,后进先出。队列是先进先出。

队列中的项目是顾客。1/3的顾客只需要一分钟便可获得服务,1/3的顾客需要两分钟,另外1/3的顾客需要三分钟。顾客到达的时间是随机的,但每小时使用自动ATM的顾客数量相当稳定。

设计一个类表示顾客,模拟顾客和队列之间的交互。

队列类

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

Queue类的接口

class Queue
{
	enum { Q_SIZE = 10 };
private:
	//私有成员定义后面来做
public:
	Queue(int qs = Q_SIZE); //创建一个qs大小队列
	~Queue();
	bool isempty() const;
	bool isfull() const;
	int queuecount() const;
	bool enqueue(const Item &item); //在队尾添加元素
	bool dequeue(Item &item); //在队首删除元素
};

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

Queue line1; //10个元素限制的队列
Queue line2(20); //20个元素限制的队列

使用队列时,可以使用typedef来定义Item。

Queue类的实现

确定如何表示队列数据。一种方法是使用new动态分配一个数组,它包含所需的元素数。然而,对于队列操作,数组并不太合适。删除数组第一个元素后,需要将余下的所有元素向前移动一位,将数组视为循环。链表可以很好满足队列的要求。链表由节点序列构成。每个节点中都包含保存到链表中的信息以及一个指向下一个节点的指针。

对于这里的队列来说,数据部分都是一个Item类型的值,因此可以使用下面的结构来表示节点:

struct Node
{
    Item item; //节点保存的数据
    struct Node * next; //指向下一个节点的指针
};

在这里插入图片描述
单向链表,每个节点都只包含一个指向其他节点的指针。知道第一个节点的地址后,就可以沿指针找到后面的每一个节点。链表最后一个节点中的指针被设置为NULL(nullptr),以指出后面没有节点了。要跟踪链表,必须知道第一个节点的地址。可以让Queue类的一个数据成员指向链表的起始位置。这是所需要的全部信息,有了这个信息后,可以沿节点链找到任何节点。由于队列总是将新项目添加到队尾,因此包含一个指向最后一个节点的数据成员将非常方便。还可以使用数据成员来跟踪队列可存储的最大项目数以及当前的项目数。
在这里插入图片描述
在类中嵌套结构或类声明。通过将Node声明放在Queue类中,可以使其作用域为整个类。Node是这样一种类型:可以使用它来声明类成员,也可以将它作为类方法中的类型名称,但只能在类中使用。这样就不必担心该Node声明与某些全局声明或其他类中声明的Node发生冲突。

class Queue
{
private:
	//类的作用域
	struct Node { Item item; struct Node * next; }; //类中嵌套结构声明
	enum { Q_SIZE = 10 };
	//私有成员
	Node * front; //队首指针
	Node * rear; //队尾指针
	int items; //队列中当前元素个数
	const int qsize; //队列中最大元素个数
	...
public:
	...
};

嵌套结构和类

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

成员初始化列表的语法

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

Classy::Classy(int n, int m):mem1(n), mem2(0), mem3(n*m+2)
{
	//...
}

上述代码将mem1初始化为n,将mem2初始化为0,将mem3初始化为n*m+2。这些初始化工作是在对象创建时完成的,此时还未执行括号中的任何代码。
● 这种格式只能用于构造函数。
● 必须用这种格式来初始化非静态const数据成员。
● 必须用这种格式来初始化引用数据成员。
● 数据成员被初始化的顺序与它们出现在类声明中的顺序相同,与初始化器中的排列顺序无关。
警告:不能将成员初始化列表语法用于构造函数之外的其他类方法。

成员初始化列表使用的括号方式也可用于常规初始化。

int games = 162;
double talk = 2.71828;

替换为:

int games(162);
double talk(2.71828);

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

C++11的类内初始化

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

class Classy
{
	int mem1 = 10;
	const int mem2 = 20;
	//...
}

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

Classy::Classy():mem1(10), mem2(20) {...}

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

Classy::Classy(int n):mem1(n) {...}

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

类方法

类构造函数应提供类成员的值。队列最初是空的,队首和队尾指针都设置为NULL,并将items设置为0。另外,将队列的最大长度qsize设置为构造函数参数qs的值。

下面的实现无法正常运行:

Queue::Queue(int qs)
{
	front = rear = NULL;
	items = 0;
	qisize = qs; //错误,不被运行
}

问题在于qsize是常量,所以可以对它进行初始化,但不能给它赋值。调用构造函数时,对象将在括号中的代码执行之前被创建。因此,调用Queue(int qs)构造函数将导致程序首先给4个成员变量分片内存。然后,程序流程进入到括号中,使用常规的赋值方式将值存储到内存中。因此,对于const数据成员,必须在执行到构造函数体之前,即创建对象时进行初始化。

C++提供了成员初始化列表的,由逗号分隔的初始化列表组成(前面带冒号)。它位于参数列表的右括号之后、函数体左括号之前。如果数据成员的名称为mdata,并需要将它初始化为val,则初始化器为mdata(val)。编写Queue的构造函数:

Queue::Queue(int qs) : qsize(qs) //初始化,qsize给qs
{
	front = rear = NULL;
	items = 0;
}

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

Queue::Queue(int qs) : qsize(qs), front(NULL), rear(NULL), items(0)
{
}

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

class Agency {...};
class Agent
{
private:
    Agency & belong; //必须使用成员初始化列表来进行初始化
    ...
};
Agency::Agent(Agency & a):belong(a) {...}

这是因为引用与const数据类似,只能在被创建时进行初始化。对于简单数据成员,使用成员初始化列表和在函数体中使用赋值没有什么区别。对于本身就是类对象的成员来说,使用成员初始化列表的效率更高。

isempty()、isfull()、queuecount()的代码都非常简单。如果items为0,则队列为空;如果items等于qsize,则队列为满。要知道队列中的项目数,只需返回items的值。

将项目添加到队尾(入队)比较麻烦:

bool Queue::enqueue(const Item & item)
{
	if (isfull())
		return false;
	Node * add = new Node; //创建节点
	//如果失败,new将抛出std::bad_alloc的异常
	add->item = item; //设置节点指针
	add->next = NULL;
	items++;
	if (front == NULL) //如果队列为空
		front = add; //将元素放在队首
	else
		rear->next = add; //否则放在队尾
	rear = add; //设置尾指针指向新节点
	return true;
}

方法需要经过下面几个阶段:

1)如果队列已满,则结束。
2)创建一个新节点。如果new无法创建新节点,它将引发异常,除非提供了处理异常的代码,否则程序终止。
3)在节点中放入正确的值。代码将Item值复制到节点的数据部分,并将节点的next指针设置为NULL。这样为将节点作为队列中的最后一个项目做好了准备。
4)将项目计数(items)加1。
5)将节点附加到队尾。这包含两个部分。首先,将节点与列表中的另一个节点连接起来。这是通过将当前队尾节点的next指针指向新的队尾节点来完成的。第二部分是将Queue的成员指针rear设置为指向新节点,使队列可以直接访问最后一个节点。如果队列为空,则还必须将front指针设置成指向新节点(如果只有一个节点,则它既是队首节点,也是队尾节点)。

删除队首项目(出队)也需要多个步骤完成:

bool Queue::dequeue(Item & item)
{
	if (front == NULL)
		return false;
	item = front->item; //将队首元素赋值给item
	items--;
	Node * temp = front; //保存队首元素位置
	front = front->next; //将front设置成指向下一个节点
	delete temp; //删除以前的第一个节点
	if (items == 0)
		rear = NULL;
	return true;
}

需要经过下面几个阶段:

1)如果队列为空,则结束
2)将队列的第一个项目提供给调用函数,这是通过将当前front节点中的数据部分复制到传递给方法的引用变量中来实现。
3)将项目计数(items)减1。
4)保存front节点的位置,供以后删除。
5)让节点出队。这是通过将Queue成员指针front设置成指向下一个节点来完成的,该节点的位置由front->next提供。
6)为节省内存,删除以前的第一个节点。
7)如果链表为空,则将rear设置为NULL。
第4步是必不可少的,因为第5步将删除关于先前第一个节点位置的信息。

是否需要其他类方法

类构造函数没有使用new,所以乍一看,好像不用理会由于在构造函数中使用new给类带来的特殊要求。因为向队列中添加对象将调用new来创建新的节点。通过删除节点的方式,dequeue()方法确实可以清除节点,但这并不能保证队列在到期为空。因此,类需要一个显式析构函数——该函数删除剩余的所有节点。

下面一种实现,它从链表头部开始,依次删除其中的每个节点:

Queue::~Queue()
{
	Node * temp;
	while (front != NULL) //确定queue不为空
	{
		temp = front; //保存前一个元素
		front = front->next; //重置下一个元素
		delete temp; //删除前一个元素
	}
}

使用new的类通常需要包含显式复制构造函数和执行深度复制的赋值运算符。复制Queue对象的成员将生成一个新的对象,该对象指向链表原来的头和尾。因此,将项目添加到复制的Queue对象中,将修改共享的链表。这样做会造成严重的后果。只有副本的尾指针得到更新,从原始对象的角度看,这将损坏链表。要复制队列,必须提供复制构造函数和执行深度复制的赋值构造函数。

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

在将来某个时候,可能需要再次使用队列且需要复制,另外,可能忘记没有为复制提供适当的代码。这种情况下,程序能编译和运行,但结果却是混乱的,甚至会崩溃。因此,最好还是提供复制构造函数和赋值运算符,尽管目前不需要他们。

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

class Queue
{
private:
	Queue(const Queue &q) : qsize(0) { } //先发制人的定义 
	Queue & operator=(const Queue &q) { return *this; }
	...
};

这样做有两个作用:1)它避免了本来将自动生成的默认方法定义。2)因为这些方法是私有的,所以不能被广泛使用。

如果nip和tuck是Queue对象,则编译器就不允许这样做:

Queue snick(nip); //错误,不被允许
tuck = nip; //错误,不被允许

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

C++11提供了另一种禁用方法的方式——使用关键字delete。

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

Customer类

ATM客户属性:姓名、账户、账户结余。这里的模拟需要使用的唯一一个属性是客户何时进入队列以及客户交易所需的时间。当模拟生成新客户时,程序将创建一个新的客户对象,并在其中存储客户的到达时间以及一个随机生成的交易时间。当客户到达队首时,程序将记录此时的时间,并将其与进入队列的时间相减,得到客户的等候时间。

下面定义和实现Customer类:

class Customer
{
private:
	long arrive; //顾客到达时间
	int processtime; //顾客进行时间
public:
	Customer() { arrive = processtime = 0; }
	void set(long when);
	long when() const { return arrive; }
	int ptime() const { return processtime; }
};
void Customer::set(long when)
{
	processtime = std::rand() % 3 + 1;
	arrive = when;
}

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

queue.h

#ifndef QUEUE_H_
#define QUEUE_H_
//这个队列包含Customer元素
class Customer
{
private:
	long arrive; //顾客到达时间
	int processtime; //顾客进行时间
public:
	Customer() { arrive = processtime = 0; }
	void set(long when);
	long when() const { return arrive; }
	int ptime() const { return processtime; }
};
typedef Customer Item;

class Queue
{
private:
	//类中嵌套结构声明
	struct Node { Item item; struct Node * next; };
	enum { Q_SIZE = 10 };
	//私有成员
	Node * front; //队首指针
	Node * rear; //队尾指针
	int items; //队列中当前元素个数
	const int qsize; //队列中最大元素个数
	//避免本来自动生成的默认方法定义
	Queue(const Queue &q) : qsize(0) { }
	Queue & operator=(const Queue &q) { return *this; }
public:
	Queue(int qs = Q_SIZE); //创建一个qs大小队列
	~Queue();
	bool isempty() const;
	bool isfull() const;
	int queuecount() const;
	bool enqueue(const Item &item); //在队尾添加元素
	bool dequeue(Item &item); //在队首删除元素
};
#endif // !QUEUE_H_

queue.cpp

#include "queue.h"
#include <cstdlib>

Queue::Queue(int qs) : qsize(qs)
{
	front = rear = NULL;
	items = 0;
}

Queue::~Queue()
{
	Node * temp;
	while (front != NULL) //确定queue不为空
	{
		temp = front; //保存前一个元素
		front = front->next; //重置下一个元素
		delete temp; //删除前一个元素
	}
}

bool Queue::isempty() const
{
	return items == 0;
}

bool Queue::isfull() const
{
	return items == qsize;
}

int Queue::queuecount() const
{
	return items;
}

//入队
bool Queue::enqueue(const Item & item)
{
	if (isfull())
		return false;
	Node * add = new Node; //创建节点
	//如果失败,new将抛出std::bad_alloc的异常
	add->item = item; //设置节点指针
	add->next = NULL;
	items++;
	if (front == NULL) //如果队列为空
		front = add; //将元素放在队首
	else
		rear->next = add; //否则放在队尾
	rear = add; //设置尾指针指向新节点
	return true;
}

//出队
bool Queue::dequeue(Item & item)
{
	if (front == NULL)
		return false;
	item = front->item; //将队首元素赋值给item
	items--;
	Node * temp = front; //保存队首元素位置
	front = front->next; //将front设置成指向下一个节点
	delete temp; //删除以前的第一个节点
	if (items == 0)
		rear = NULL;
	return true;
}

//设置处理时间为1~3的随机值
void Customer::set(long when)
{
	processtime = std::rand() % 3 + 1;
	arrive = when;
}

ATM模拟

现在已经拥有模拟ATM所需的工具。程序允许用户输入3个数:队列的最大长度、程序模拟的持续时间(单位为小时)以及平均每个小时的客户数。程序将使用循环——每次循环代表一分钟。在每分钟的循环中,程序将完成下面的工作:
1)判断是否来了新的客户。如果来了,并且此时队列未满,则将它添加到队列中,否则拒绝客户入队。
2)如果没有客户在进行交易,则选取队列的第一个客户。确定该客户的已等待时间,并将wait_time计数器设置为新客户所需的处理时间。
3)如果客户正在处理中,则将wait_time计数器减1。
4)记录各种数据,如获得服务的客户数目、被拒绝的客户数目、排队等候的累计时间以及累积的队列长度等。

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

程序如何确定是否有新的客户到来:假设平均每小时有10名客户到达,则相当于每6分钟有一名客户。程序将计算这个值,并将它保存在min_per_cust变量中。然而,刚好每6分钟来一名客户不太现实,我们真正希望的是一个更随机的过程——但平均每6分钟来一个客户。

程序使用下面的函数来确定是否在循环期间有客户到来:

bool newcustomer(double x)
{
	return (std::rand() * x / RAND_MAX < 1); 
}

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

main.cpp

长时间运行该模拟程序,可以知道长期的平均值;短时间运行该模拟程序,将只能知道短期的变化。

#include <iostream>
#include <cstdlib>
#include <ctime>
#include "queue.h"
const int MIN_PER_HR = 60;

bool newcustomer(double x);

int main()
{
	using std::cin;
	using std::cout;
	using std::endl;
	using std::ios_base;
	
	std::srand(std::time(0)); //随机初始化

	cout << "Case Study: Bank of Heather Automatic Teller\n";
	cout << "Enter maximum size of queue: ";
	int qs;
	cin >> qs;
	Queue line(qs); //队列中能装下的人数

	cout << "Enter the number of simulation hours: ";
	int hours; //模拟小时
	cin >> hours;
	//模拟将每分钟运行 1 个周期 
	long cyclelimit = MIN_PER_HR * hours;

	cout << "Enter the average number of customer per hour: ";
	double perhour; //每小时顾客到达平均个数
	cin >> perhour;
	double min_per_cust; //平均间隔时间
	min_per_cust = MIN_PER_HR;

	Item temp; //创建一个customer对象
	long turnaways = 0; //队满,拒绝入队
	long customers = 0; //加入队列
	long served = 0; //
	long sum_line = 0; //排队等候累积的队列长度
	int wait_time = 0; //等待ATM空闲时间
	long line_wait = 0; //排队等候累积的时间
	//运行这个模拟
	for (int cycle = 0; cycle < cyclelimit; cycle++)
	{
		if (newcustomer(min_per_cust))
		{
			if (line.isfull())
				turnaways++;
			else
			{
				customers++;
				temp.set(cycle); //cycle = time of arrival
				line.enqueue(temp); //加入新顾客
			}
		}
		if (wait_time <= 0 && !line.isempty())
		{
			line.dequeue(temp); //下一个顾客
			wait_time = temp.ptime(); //等待时间
			line_wait += cycle - temp.when();
			served++;
		}
		if (wait_time > 0)
			wait_time--;
		sum_line += line.queuecount();
	}
	//打印结果
	if (customers > 0)
	{
		cout << "customers accepted: " << customers << endl;
		cout << "  customers served: " << served << endl;
		cout << "        turnaways: " << turnaways << endl;
		cout << "average queue size: ";
		cout.precision(2);
		cout.setf(ios_base::fixed, ios_base::floatfield);
		cout << (double)sum_line / cyclelimit << endl;
		cout << " average wait time: "
			<< (double)line_wait / served << " minutes\n";
	}
	else
		cout << "No customers!\n";
	cout << "Done!\n";
	return 0;
}

//x = 客户到达的平均间隔时间
bool newcustomer(double x)
{
	return (std::rand() * x / RAND_MAX < 1); //如果顾客到达的平均时间间隔少于1分钟,则返回真
}

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

即使平均每小时到达的客户数不变,也会出现短期变化。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阳光开朗男孩

你的鼓励是我最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值