varch/doc/deque.en.md

8.7 KiB

Introduction

A double-ended queue (deque) has two entrances and exits and combines the characteristics of both a queue and a stack. Data can be entered and exited from either of the two ports. The implementation principles of varch's double-ended queue, queue, and stack are the same. They all use continuous address spaces that are connected in a ring shape, enabling efficient data entry and exit at both ends and also supporting random access.

  • Capacity: Capacity refers to the maximum number of queue items that can be stored during use. For example, for a queue with a capacity of 10, it can store at most 10 queue items. Once it's full, no more items can be enqueued. The queue storage in varch uses continuous addresses and is a queue with a limited capacity.
  • Access Mechanism: Generally, a queue has only two ways of operation, which are enqueueing and dequeueing. It can be quickly traversed and accessed based on continuous addresses.

Interface

Creating and Deleting deque Objects

deque_t deque_create(int dsize, int capacity, void *base);
void deque_delete(deque_t deque);
#define deque(type, capacity) // For more convenient use, a macro definition is wrapped around deque_create
#define _deque(deque) // A macro definition is wrapped around deque_delete, and the deque is set to NULL after deletion

Here, deque_t is the structure of deque. The creation method will return a deque object if successful, or NULL if it fails. The parameter dsize is used to pass in the size of the data, capacity is used to pass in the queue capacity, and *base is used to pass in the buffer address (it can be omitted, and if omitted, a space with the size of capacity will be automatically allocated to store the queue data). The deletion method is used to delete the input deque object. The creation and deletion methods should be used in pairs. Once created, it should be deleted when it's no longer in use.

void test(void)
{
    deque_t deque = deque(int, 10); // Define and create a deque of type int with a capacity of 10
    _deque(deque); // Use them in pairs and delete it after use
}

Enqueueing and Dequeueing of deque

int deque_push_front(deque_t deque, void* data);
int deque_push_back(deque_t deque, void* data);
int deque_pop_front(deque_t deque, void* data);
int deque_pop_back(deque_t deque, void* data);

These four methods can conveniently add data to the queue and pop data from the queue. For the push methods, the data parameter is used to pass in the address of the data to be enqueued. For the pop methods, the data parameter is used to pass in the address that will receive the dequeued data. For both types of methods, data can be set to NULL, which just serves as a placeholder. The methods return 1 if the operation is successful and 0 if it fails.

void test(void)
{
    deque_t deque = deque(int, 10);
	int i = 0;

	for (i = 0; i < deque_capacity(deque); i++)
	{
		deque_push_back(deque, &i);
	}
	deque_pop_front(deque, NULL);
	deque_pop_back(deque, NULL);

    _deque(deque); // Use them in pairs and delete it after use
}

Size, Capacity, and Data Size of deque

int deque_size(deque_t deque);
int deque_capacity(deque_t deque);
int deque_dsize(deque_t deque);

The capacity of deque is the capacity specified during creation, which indicates how many queue elements it can store. The size represents the number of elements in the queue, and dsize is the size of the data passed in during creation. For example, if it's int, dsize is sizeof(int).

void test(void)
{
    deque_t deque = deque(int, 10);
	int i = 0;

	for (i = 0; i < deque_capacity(deque); i++)
	{
		deque_push_back(deque, &i);
	}
	deque_pop_front(deque, NULL);
	deque_pop_back(deque, NULL);
	printf("deque capacity=%d, size=%d, dsize=%d\r\n", deque_capacity(deque), deque_size(deque), deque_dsize(deque));

	_deque(deque);
}

Result:

deque capacity=10, size=8, dsize=4

Reading and Writing of deque Data

void* deque_data(deque_t deque, int index);
#define deque_at(deque, type, i)

The deque_data method is used to obtain the address of the data according to the index, and it returns the address of the specified data. NULL will be returned if the operation fails. The deque_at method adds a type on the basis of deque_data.

void test(void)
{
    deque_t deque = deque(int, 10);
	int i = 0;

	for (i = 0; i < deque_capacity(deque); i++)
	{
		deque_push_back(deque, &i);
	}
	deque_pop_front(deque, NULL);
	deque_pop_back(deque, NULL);
	for (i = 0; i < deque_size(deque); i++)
	{
		printf("deque[%d] = %d\r\n", i, deque_at(deque, int, i));
	}

	_deque(deque);
}

Result:

deque[0] = 1
deque[1] = 2
deque[2] = 3
deque[3] = 4
deque[4] = 5
deque[5] = 6
deque[6] = 7
deque[7] = 8

Storage Index of deque Data

int deque_index(deque_t deque, int index);

The queue storage structure is a circular queue, which means that the continuous address storage space is connected end to end to form a ring, and the entry and exit of queue data occur within this ring. Therefore, the index of the queue is not directly the index of the buffer. The deque_index method is used to map the index of the queue to the index of the buffer. -1 will be returned if the operation fails. Generally, this method is not used frequently. It's mainly used when the base is passed in during the deque_create method, and then the deque_index method is used on the base address to obtain the queue data.

Checking if deque is Empty or Full

int deque_empty(deque_t deque);
int deque_full(deque_t deque);

These two methods are actually related to the size of deque's size. If it equals 0, the deque is empty. If it equals the capacity, the deque is full.

Source Code Analysis

deque Structure

All the structures of the deque container are implicit, which means that the structure members cannot be directly accessed. This way ensures the independence and security of the module and prevents external calls from modifying the structure members and thus destroying the deque storage structure. Therefore, the deque parser only leaves a single deque declaration in the header file, and the definitions of the structures are in the source file. Only the methods provided by the deque container can be used to operate on deque objects. The deque type declaration:

typedef struct DEQUE *deque_t;

When using it, just use deque_t.

typedef struct DEQUE
{
	void* base;						/* base address of data */
	int cst;						/* base const */
	int dsize;						/* size of deque data */
	int capacity;					/* capacity of deque */
	int size;						/* size of deque */
	int head;						/* index of deque head */
	int tail;						/* index of deque tail */
} DEQUE;

The DEQUE structure contains seven members. base is the base address of the data buffer for the queue structure, cst indicates whether the space of base is passed in during the create method, size represents the size (length) of deque, dsize is the size of each data, capacity is the capacity of the queue, and head and tail are the indices pointed to by the head and tail of the circular buffer respectively.

The main problem that the deque container needs to solve is the issue of the circular queue and the first-in-first-out characteristic of data. Other operations like creation and deletion are for basic initialization such as space initialization. The operations of deque_push_back and deque_pop_front are actually the same as those of a queue and won't be elaborated here. The main focus is on deque_push_front and deque_pop_back.

int deque_push_front(deque_t deque, void* data)
{
	if (!deque) return 0;
	if (deque_full(deque)) return 0; // Check if the queue is full before enqueueing
	// Here, it's not simply subtracting 1 from head. Considering that when head is 0, subtracting 1 would result in -1, which is out of the capacity range.
	// What we want is that when head is 0 and we push to the head, head should go back to the end of the buffer to ensure the continuity of the ring.
	// So the approach here is to add capacity to head first, then subtract 1, and finally take the remainder of capacity to ensure the circularity.
	deque->head = (deque->head + deque->capacity - 1) % deque->capacity; 
	if (data) memcpy(at(deque->head), data, deque->dsize);
	deque->size++;
	return 1;
}
int deque_pop_back(deque_t deque, void* data)
{
	if (!deque) return 0;
	if (deque_empty(deque)) return 0; // Check if the queue is empty before dequeueing
	// The operation on tail here is similar to the operation on head in the deque_push_front method.
	deque->tail = (deque->tail + deque->capacity - 1) % deque->capacity;
	if (data) memcpy(data, at(deque->tail), deque->dsize);
	deque->size--;
	return 1;
}