数据结构----队列的实现

0 阅读9分钟

队列

队列也是一种常见的线性结构,队列的特点就是先进先出 后进后出,同时只能操作队首与队尾的两个元素,和栈相同不能操作 访问中间的元素.这个数据结构和日常生活中排队是一样的,先来的人从队首出队,后来的人从队尾入队.

顺序存储的队列

单向队列

#include<stdio.h>
#include<stdlib.h>
#define maxx 10
//单向队列
typedef struct {
	int* data;
	int f, r;
}Queue;

Queue InitQueue() {
	Queue q;
	q.data = (int*)malloc(sizeof(int) * maxx);
	if (q.data == NULL) {
		printf_s("内存申请失败\n");
		return q;
	}
	q.f = 0;
	q.r = -1;//队尾指针指向队列最后的元素
	return q;
}

void EnQueue(Queue* q, int k) {
	if (q->r == maxx - 1) {
		printf_s("队列满无法入队\n");
		return;
	}
	q->r++;
	q->data[q->r] = k;
	return;
}

int IsEmpty(Queue* q) {
	if (q->f > q->r) {
		return 1;
	}
	return 0;
}

void DeQueue(Queue* q) {
	if (IsEmpty(q)) {
		printf_s("队空无法删除\n");
		return;
	}
	q->f++;
	return;
}

int GetFront(Queue* q) {
	if (IsEmpty(q)) {
		printf_s("队空无法获取\n");
		return -1;
	}
	return q->data[q->f];
}

int main() {
	Queue q = InitQueue();

	EnQueue(&q, 10);
	EnQueue(&q, 20);
	EnQueue(&q, 30);

	printf("当前队头:%d\n", GetFront(&q)); // 10

	DeQueue(&q);
	printf("出队后队头:%d\n", GetFront(&q)); // 20

	DeQueue(&q);
	DeQueue(&q);
	DeQueue(&q); // 提示队空

	return 0;
}

单向队列的操作非常的简单只有初始化 入队 判空 出队,为了主函数的调试我加入了一个获取队首元素的函数.单向队列由结构体包装,包含数据域还有队首"指针"队尾"指针",在顺序结构中"指针"只是类比这两个指针实际上时int类型.只有在队首指针和队尾指针之间的元素我们才认为在队列中.

1.队列的初始化,用malloc给队列q的数据域申请一块连续的内存空间,进行判空操作.队首与队尾指针的初始化需要注意,队首指针初始化为0,但队尾指针可以有两种不同的含义,可以直接指向队尾元素,也可以是指队尾的后一个位置,也就是下一个元素入队要插入的位置.前者得把队尾指针初始化为-1,因为一开始空队列是没有元素的.而后者得初始化为0,因为空队列要入队的第一个元素的下标就是0.我的这段代码以前者为例.

2.入队操作,我们得先对队列判满,如果队尾指针指向了这段连续内存的最后一个位置那么队列就满了.同时我们也能认识到一个新问题,单向队列在不断的入队出队过程中,队列的最大容量是在变小的,而当队尾指针指向了这段连续内存的最后一个位置时,队列其实并没有maxx个元素,却无法再入队了.这种情况我们叫做假溢出,这种情况我们等下用循环队列来解决.因为我们的队尾指针是指向队列的最后一个元素,所以我们要入队新元素得先让队尾指针后移,再更新数据域.这样才完成了一次入队操作.而如果队尾指针是指向队尾的后一个位置那么我们就应该先更新数据域然后再将指针后移.

3.判空操作直接比较两个指针大小即可,如果队尾指针比队首指针还小那么说明队列为空.

4.出队操作需要先判空,然后直接将队首指针后移即可,因为只有在两指针之间的元素才属于队列.

单向队列的弊端就是存在假溢出的情况,接下来我们用循环队列来解决.

循环队列

循环队列的物理内存还是一块连续的内存,只不过我们在对两指针操作的时候加入取余这个数学方法来将队列的头尾连接起来,这样可以很好的避免内存的浪费,也就解决了假溢出的情况.由于在逻辑上循环队列是类似一个圆环,所以我们的队尾指针就不能置为-1.我们的队尾指针也只能有一种含义就是指向队尾元素的下一个位置的指针.

#include<stdio.h>
#include<stdlib.h>
#define maxx 10
//循环队列
typedef struct {
	int* data;
	int f, r;
}Queue;

Queue InitQueue() {
	Queue q;
	q.data = (int*)malloc(sizeof(int) * maxx);
	if (q.data == NULL) {
		printf_s("内存申请失败\n");
		return q;
	}
	q.f = q.r = 0;
	return q;
}

//入队
void EnQueue(Queue* q, int k) {
	//判满
	if (q->f == (q->r + 1) % maxx) {
		printf_s("队满无法入队\n");
		return;
	}
	q->data[q->r] = k;
	q->r = (q->r + 1) % maxx;
	return;
}

int IsEmpty(Queue* q) {
	if (q->f == q->r) {
		return 1;
	}
	return 0;
}

//出队
void DeQueue(Queue* q) {
	if (IsEmpty(q)) {
		printf_s("队列为空,无法出队\n");
		return;
	}
	printf_s("%d出队\n", q->data[q->f]);
	q->f = (q->f + 1) % maxx;
	return;
}

int main()
{
	Queue q = InitQueue();
	EnQueue(&q, 1);
	EnQueue(&q, 2);
	EnQueue(&q, 3);
	DeQueue(&q);
	DeQueue(&q);




	return 0;
}

在结构体的封装上循环队列与单向队列无异,最大的区别就在对两指针的操作上.我们通过在队尾指针后移时让他对maxx取余让队列在逻辑上形成一个闭环.例如我们的maxx是4,0 1 2 3 0 1 2 3.......避免了出队时指针后移导致的内存浪费.

1.初始化队首队尾指针均只能置为0.

2.入队操作中关键点在于判满操作,我们可以在纸上画一个循环队列的示意图,可以很明显的察觉到当队列空和满的时候,队首与队尾指针都相等.对于这个问题我们有三个解决方法.

(1)我们在封装结构体的时候加入一个计数器cnt,每次入队或出队一个元素时就更新cnt,当需要判满时我们直接看cnt的大小就能判断队列是空还是满.

(2)看队列是空还是满,我们可以观察前一次操作,如果前一次是入队那么此时队列满,如果前一次是出队那么此时队列空.我们可以在封装结构体是加入一个flag状态变量,flag初始化为0,如果前一次是入队那么更新flag为1,如果前一次是出队那么不用更新.

(3)前两种方法都需要在结构体封装时加入一个新的变量,同时在入队出队操作时还得更新这个新的变量.第三个方法我们可以直接牺牲一块内存.也就是我们直接认定当队尾指针指向这块内存的最后一个位置的时候这个队列已经满了.要注意这个被牺牲的内存并不是固定的,随着我们的出入队列这一块内存也会不同.

当队尾指针指向最后一块内存时我们再后移一次,此时让队尾指针对maxx取余如果和队首指针相等那么说明队列已满(q->f == (q->r + 1) % maxx).这就是循环队列中判满的基本逻辑.判满后我们应该先更新数据域,再更新队尾指针,更新时同样应该先加一再对maxx取余,保持循环队列的基本逻辑.

3.经过我们对判满逻辑的限定,现在判空只需要判断队首队尾指针是否相等即可.\

4.出队操作先判空,然后直接按照基本逻辑更新队首指针即可.

循环队列巧妙地运用了数学方法避免了内存的浪费,但是必须要理解取余在逻辑中运用的意义.可以通过画图来辅助理解.

链式存储的队列

#include<stdio.h>
#include<stdlib.h>
//单链表的结点结构
typedef struct Node {
	int data;
	struct Node* next;
}QNode;

//队列结构
typedef struct {
	QNode* f;
	QNode* r;
}Queue;

Queue InitQueue() {
	Queue q;
	QNode* head = (QNode*)malloc(sizeof(QNode));
	if (head == NULL) {
		printf_s("内存申请失败\n");
		return q;
	}
	head->next = NULL;
	q.f = head;
	q.r = head;
	return q;
}

//入队
void EnQueue(Queue* q, int k) {
	//无需判满
	QNode* s = (QNode*)malloc(sizeof(QNode));
	s->data = k;//更新
	s->next = NULL;
	q->r->next = s;
	q->r = s;//更新尾指针 指向新的尾节点
	return;
}

//出队
void DeQueue(Queue* q) {
	//判空 也可以判断头节点后继是否为空 q->f->next == NULL
	if (q->f == q->r) {
		printf_s("队空无法入队\n");
		return;
	}
	QNode* p = q->f->next;
	if (q->r == p) {
		q->r = q->f;//防止r成为野指针
	}
	q->f->next = p->next;
	free(p);
	p = NULL;
}

int main()
{
	Queue q = InitQueue();
	EnQueue(&q, 1);
	EnQueue(&q, 2);
	EnQueue(&q, 3);
	DeQueue(&q);
	DeQueue(&q);
	DeQueue(&q);
	EnQueue(&q, 7);
	DeQueue(&q);
	return 0;
}

我们把队列中的节点同链表节点一样声明,队列的结构只需要封装进队首队尾指针即可.

1.初始化队列需要malloc一个新的节点head作为头节点,我们应该把头节点的后继置空,让队列的头尾指针都指向这个头节点即可.

2.入队操作即是尾插,因为是链式结构所以我们无需担心队列会满也就无需判满.直接malloc一个新的节点然后更新数据域.我们是已知队尾指针的所以无需遍历寻找尾节点.尾插时需要把新节点的后继置空,再把新节点接到尾节点的后面,最后不要忘记把尾指针更新到新插入的节点上.

3.出队操作需要对队列进行判空,判空的方法有两个.第一种直接判断队尾指针和队首指针相不相等,第二种判断队首指针后继是否为空.出队操作先用p来记录要出队的首元节点,以便后续释放内存.这里有一种特殊情况,当队列中只有一个节点的时候(队尾指针指向p时),我们得先让队尾指针等于队首指针(出队后队列为空),以免队尾指针成为野指针,然后再去除这个节点p,释放p的内存并将p置空.

链式储存结构可以突破队列容量对队列操作的限制,代码逻辑上会更加的简洁.