算法笔记 04
1 c语言创建单链表
#include <stdio.h>
#include <stdlib.h>// 定义链表节点结构体
struct ListNode {int val;struct ListNode* next;
};// 创建单链表的函数(简化版)
struct ListNode* createLinkedList(int* arr, int size) {if (size == 0) return NULL;struct ListNode* head = (struct ListNode*)malloc(sizeof(struct ListNode));head->val = arr[0];head->next = NULL;struct ListNode* cur = head;for (int i = 1; i < size; i++) {cur->next = (struct ListNode*)malloc(sizeof(struct ListNode));cur->next->val = arr[i];cur->next->next = NULL;cur = cur->next;}return head;
}int main() {int arr[] = {1, 2, 3, 4, 5};int size = sizeof(arr) / sizeof(arr[0]);struct ListNode* head = createLinkedList(arr, size);// 遍历单链表for (struct ListNode* p = head; p != NULL; p = p->next) {printf("%d\n", p->val);}// 释放链表内存(此处省略,实际使用中需添加)return 0;
}
2 双链表
创建双链表
双链表(双向链表)是链表的一种扩展形式,它的每个节点除了存储自身数据外,还包含两个指针(或引用):
- 一个指向下一个节点(
next
) - 一个指向上一个节点(
prev
)
这种结构相比单链表(只有next
指针)的优势是:可以双向遍历链表(既能从头部遍历到尾部,也能从尾部遍历到头部),并且在已知节点的情况下,删除或插入节点的操作更高效(无需像单链表那样从头遍历寻找前驱节点)。
用 C 语言实现双链表的示例代码:
#include <stdio.h>
#include <stdlib.h>// 定义双链表节点结构体
struct DoublyListNode {int val; // 节点存储的数据struct DoublyListNode* next; // 指向后一个节点的指针struct DoublyListNode* prev; // 指向前一个节点的指针
};// 创建双链表的函数
struct DoublyListNode* createDoublyLinkedList(int* arr, int size) {if (arr == NULL || size == 0) {return NULL;}// 创建头节点struct DoublyListNode* head = (struct DoublyListNode*)malloc(sizeof(struct DoublyListNode));head->val = arr[0];head->prev = NULL; // 头节点的prev为NULLhead->next = NULL;struct DoublyListNode* cur = head; // 当前节点指针,初始指向头节点// 循环创建后续节点for (int i = 1; i < size; i++) {// 创建新节点struct DoublyListNode* newNode = (struct DoublyListNode*)malloc(sizeof(struct DoublyListNode));newNode->val = arr[i];newNode->next = NULL; // 新节点暂时没有后驱// 连接当前节点和新节点cur->next = newNode; // 当前节点的next指向新节点newNode->prev = cur; // 新节点的prev指向当前节点// 移动当前节点指针到新节点cur = cur->next;}return head;
}// 测试:遍历双链表(正序和逆序)
int main() {int arr[] = {1, 2, 3, 4, 5};int size = sizeof(arr) / sizeof(arr[0]);struct DoublyListNode* head = createDoublyLinkedList(arr, size);// 正序遍历(从头部到尾部)printf("正序遍历: ");struct DoublyListNode* p = head;while (p != NULL) {printf("%d ", p->val);p = p->next;}printf("\n");// 逆序遍历(从尾部到头部,先找到尾节点)printf("逆序遍历: ");struct DoublyListNode* tail = head;while (tail->next != NULL) {tail = tail->next;}while (tail != NULL) {printf("%d ", tail->val);tail = tail->prev;}printf("\n");// 注意:实际使用中需释放链表内存,避免内存泄漏(此处省略)return 0;
}
双链表的核心特点:
- 双向遍历:通过
next
指针可以正向遍历,通过prev
指针可以反向遍历(如示例中的正序和逆序输出)。 - 节点关系明确:每个节点都能直接找到自己的前驱和后继,操作更灵活。
- 空间开销略大:相比单链表,每个节点多一个
prev
指针,会占用更多内存。
双链表在需要频繁双向操作的场景中非常实用(例如实现双向队列、浏览器的前进 / 后退功能等)。
双链表的增删改查
以下是 C 语言实现双链表的完整代码,包含创建、插入(增)、删除(删)、修改(改)、查询(查) 等核心操作,并附带示例测试:
双链表的定义与核心操作
#include <stdio.h>
#include <stdlib.h>// 定义双链表节点结构
struct DoublyListNode {int val; // 节点值struct DoublyListNode* prev; // 前驱节点指针struct DoublyListNode* next; // 后继节点指针
};// 1. 创建双链表(从数组初始化)
struct DoublyListNode* createDoublyList(int* arr, int size) {if (arr == NULL || size <= 0) return NULL;// 创建头节点struct DoublyListNode* head = (struct DoublyListNode*)malloc(sizeof(struct DoublyListNode));head->val = arr[0];head->prev = NULL;head->next = NULL;struct DoublyListNode* cur = head;for (int i = 1; i < size; i++) {struct DoublyListNode* newNode = (struct DoublyListNode*)malloc(sizeof(struct DoublyListNode));newNode->val = arr[i];newNode->prev = cur; // 新节点的前驱指向当前节点newNode->next = NULL;cur->next = newNode; // 当前节点的后继指向新节点cur = cur->next; // 移动到新节点}return head;
}// 2. 查找节点(根据值查找第一个匹配的节点)
struct DoublyListNode* findNode(struct DoublyListNode* head, int target) {struct DoublyListNode* cur = head;while (cur != NULL) {if (cur->val == target) {return cur; // 找到返回节点指针}cur = cur->next;}return NULL; // 未找到返回NULL
}// 3. 修改节点值(根据值修改第一个匹配的节点)
int updateNode(struct DoublyListNode* head, int oldVal, int newVal) {struct DoublyListNode* node = findNode(head, oldVal);if (node == NULL) {return 0; // 未找到节点,修改失败}node->val = newVal;return 1; // 修改成功
}// 4. 插入节点(在指定值的节点后插入新节点)
int insertAfter(struct DoublyListNode* head, int target, int newVal) {struct DoublyListNode* targetNode = findNode(head, target);if (targetNode == NULL) {return 0; // 未找到目标节点,插入失败}// 创建新节点struct DoublyListNode* newNode = (struct DoublyListNode*)malloc(sizeof(struct DoublyListNode));newNode->val = newVal;// 连接新节点与前后节点newNode->next = targetNode->next; // 新节点的后继指向目标节点的原后继newNode->prev = targetNode; // 新节点的前驱指向目标节点if (targetNode->next != NULL) { // 如果目标节点不是尾节点targetNode->next->prev = newNode; // 原后继节点的前驱指向新节点}targetNode->next = newNode; // 目标节点的后继指向新节点return 1; // 插入成功
}// 5. 删除节点(删除第一个匹配值的节点)
int deleteNode(struct DoublyListNode** head, int target) {if (head == NULL || *head == NULL) {return 0; // 链表为空,删除失败}struct DoublyListNode* cur = *head;// 查找目标节点while (cur != NULL && cur->val != target) {cur = cur->next;}if (cur == NULL) {return 0; // 未找到目标节点}// 处理前驱节点的后继指针if (cur->prev != NULL) {cur->prev->next = cur->next;} else {// 若删除的是头节点,更新头指针*head = cur->next;}// 处理后继节点的前驱指针if (cur->next != NULL) {cur->next->prev = cur->prev;}free(cur); // 释放节点内存return 1; // 删除成功
}// 辅助函数:打印链表(正序)
void printList(struct DoublyListNode* head) {struct DoublyListNode* cur = head;while (cur != NULL) {printf("%d ", cur->val);cur = cur->next;}printf("\n");
}// 辅助函数:释放链表内存
void freeList(struct DoublyListNode* head) {struct DoublyListNode* cur = head;while (cur != NULL) {struct DoublyListNode* temp = cur;cur = cur->next;free(temp);}
}// 测试示例
int main() {int arr[] = {1, 2, 3, 4, 5};int size = sizeof(arr) / sizeof(arr[0]);// 创建双链表struct DoublyListNode* head = createDoublyList(arr, size);printf("初始链表: ");printList(head); // 输出:1 2 3 4 5// 查找节点struct DoublyListNode* node = findNode(head, 3);if (node != NULL) {printf("找到节点: %d\n", node->val); // 输出:找到节点: 3}// 修改节点(将3改为30)updateNode(head, 3, 30);printf("修改后链表: ");printList(head); // 输出:1 2 30 4 5// 插入节点(在2后插入25)insertAfter(head, 2, 25);printf("插入后链表: ");printList(head); // 输出:1 2 25 30 4 5// 删除节点(删除4)deleteNode(&head, 4);printf("删除后链表: ");printList(head); // 输出:1 2 25 30 5// 释放内存freeList(head);return 0;
}
核心操作说明
创建(
createDoublyList
):从数组初始化双链表,每个节点的prev
和next
指针正确关联。查询(
findNode
):遍历链表,返回第一个值为target
的节点指针(未找到返回NULL
)。修改(
updateNode
):先通过findNode
找到目标节点,再修改其val
值(返回 1 表示成功,0 表示失败)。插入(
insertAfter
):在值为target
的节点后插入新节点,需同时处理前驱和后继的指针关联(若目标节点是尾节点,只需处理前驱)。删除(
deleteNode
):删除值为target
的节点,需注意:- 若删除头节点,需更新头指针(因此函数参数为
**head
)。 - 需同时断开前驱和后继节点与当前节点的关联,避免链表断裂。
- 若删除头节点,需更新头指针(因此函数参数为
运行结果
plaintext
初始链表: 1 2 3 4 5
找到节点: 3
修改后链表: 1 2 30 4 5
插入后链表: 1 2 25 30 4 5
删除后链表: 1 2 25 30 5
通过以上代码,可以完整实现双链表的增删改查功能,且处理了边界情况(如空链表、头节点操作、尾节点操作等)。
3 环形数组
在 C 语言中,环形数组(Circular Array) 是一种特殊的数组结构,它通过逻辑上的 “首尾相连” 来模拟环形特性。
其核心思想是:当数组的尾指针到达物理末尾时,会绕回数组的起始位置,从而实现高效的循环利用空间。环形数组常用于实现队列(尤其是循环队列)、缓冲区等场景,相比普通数组,它能更高效地利用内存(避免 “假溢出”)。
一、环形数组的核心概念
环形数组的实现依赖两个关键指针(索引):
- 头指针(front):指向数组中有效元素的起始位置。
- 尾指针(rear):指向数组中有效元素的下一个位置(即下一个待插入元素的位置)。
为了区分 “数组为空” 和 “数组满” 的状态,通常有两种处理方式:
- 预留一个空位置(最常用):当
(rear + 1) % capacity == front
时,表示数组已满。 - 用计数器记录元素个数:通过额外变量
size
直接判断空(size == 0
)或满(size == capacity
)。
本文以 “预留空位置” 为例讲解。
二、环形数组的创建(初始化)
环形数组的创建需要定义:
- 存储数据的数组(
data
)。 - 容量(
capacity
):数组的最大元素个数(实际可用capacity - 1
个,因预留一个空位置)。 - 头指针(
front
)和尾指针(rear
):初始值均为0
(表示数组为空)。
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>// 环形数组结构体
typedef struct {int* data; // 存储数据的数组int capacity; // 容量(最大可存储 capacity-1 个元素)int front; // 头指针(指向首元素)int rear; // 尾指针(指向尾元素的下一个位置)
} CircularArray;// 初始化环形数组(创建)
CircularArray* createCircularArray(int capacity) {// 容量至少为1,预留一个空位置,实际可用 capacity-1 个if (capacity <= 1) {printf("容量必须大于1\n");return NULL;}CircularArray* arr = (CircularArray*)malloc(sizeof(CircularArray));arr->data = (int*)malloc(sizeof(int) * capacity);arr->capacity = capacity;arr->front = 0; // 初始头指针在0arr->rear = 0; // 初始尾指针在0(数组为空)return arr;
}
三、环形数组的核心操作(增删改查)
1. 判断空 / 满状态(辅助函数)
// 判断数组是否为空
bool isEmpty(CircularArray* arr) {return arr->front == arr->rear;
}// 判断数组是否已满
bool isFull(CircularArray* arr) {// 尾指针的下一个位置等于头指针时,数组满return (arr->rear + 1) % arr->capacity == arr->front;
}
2. 增加元素(插入,从尾部添加)
在环形数组中,插入操作通常从尾部(rear
位置)添加元素,然后移动 rear
指针(绕回处理)。
// 向环形数组添加元素(从尾部插入)
bool insert(CircularArray* arr, int val) {if (isFull(arr)) {printf("数组已满,无法插入\n");return false;}arr->data[arr->rear] = val; // 在尾指针位置存入值arr->rear = (arr->rear + 1) % arr->capacity; // 尾指针后移(绕回)return true;
}
3. 删除元素(从头部删除)
删除操作通常从头部(front
位置)移除元素,然后移动 front
指针(绕回处理)。
// 从环形数组删除元素(从头部删除)
bool delete(CircularArray* arr, int* val) {if (isEmpty(arr)) {printf("数组为空,无法删除\n");return false;}*val = arr->data[arr->front]; // 取出头指针位置的值arr->front = (arr->front + 1) % arr->capacity; // 头指针后移(绕回)return true;
}
4. 修改元素(根据位置修改)
环形数组的 “位置” 是逻辑位置(从 front
开始计数),需先将逻辑位置转换为物理索引(数组下标)。
// 计算逻辑位置对应的物理索引
int getPhysicalIndex(CircularArray* arr, int logicPos) {int size = (arr->rear - arr->front + arr->capacity) % arr->capacity;if (logicPos < 0 || logicPos >= size) {return -1; // 逻辑位置无效}return (arr->front + logicPos) % arr->capacity;
}// 修改指定逻辑位置的元素
bool update(CircularArray* arr, int logicPos, int newVal) {int physPos = getPhysicalIndex(arr, logicPos);if (physPos == -1) {printf("位置无效,无法修改\n");return false;}arr->data[physPos] = newVal;return true;
}
5. 查询元素(根据位置查询或查找值)
- 按位置查询:将逻辑位置转换为物理索引后取值。
- 按值查询:遍历有效元素,返回第一个匹配值的逻辑位置。
// 按逻辑位置查询元素
bool get(CircularArray* arr, int logicPos, int* val) {int physPos = getPhysicalIndex(arr, logicPos);if (physPos == -1) {printf("位置无效,无法查询\n");return false;}*val = arr->data[physPos];return true;
}// 查找值对应的逻辑位置(返回第一个匹配值的位置,未找到返回-1)
int find(CircularArray* arr, int target) {if (isEmpty(arr)) {return -1;}int size = (arr->rear - arr->front + arr->capacity) % arr->capacity;for (int i = 0; i < size; i++) {int physPos = (arr->front + i) % arr->capacity;if (arr->data[physPos] == target) {return i; // 返回逻辑位置}}return -1;
}
四、辅助操作(打印、销毁)
// 打印环形数组的所有元素
void printCircularArray(CircularArray* arr) {if (isEmpty(arr)) {printf("数组为空\n");return;}printf("环形数组元素: ");int cur = arr->front;while (cur != arr->rear) {printf("%d ", arr->data[cur]);cur = (cur + 1) % arr->capacity;}printf("\n");
}// 销毁环形数组(释放内存)
void destroyCircularArray(CircularArray* arr) {if (arr != NULL) {free(arr->data); // 释放数据数组free(arr); // 释放结构体}
}
五、完整测试示例
int main() {// 创建容量为5的环形数组(实际可存4个元素)CircularArray* arr = createCircularArray(5);if (arr == NULL) return 1;// 插入元素insert(arr, 10);insert(arr, 20);insert(arr, 30);insert(arr, 40);printCircularArray(arr); // 输出:10 20 30 40// 测试数组满的情况insert(arr, 50); // 提示:数组已满,无法插入// 查询元素int val;get(arr, 2, &val); // 查询逻辑位置2的元素printf("位置2的元素: %d\n", val); // 输出:30// 修改元素(将逻辑位置1的元素改为200)update(arr, 1, 200);printCircularArray(arr); // 输出:10 200 30 40// 查找元素int pos = find(arr, 30);printf("值30的位置: %d\n", pos); // 输出:2// 删除元素(从头部删除)delete(arr, &val);printf("删除的元素: %d\n", val); // 输出:10printCircularArray(arr); // 输出:200 30 40// 再插入一个元素(测试绕回)insert(arr, 50);printCircularArray(arr); // 输出:200 30 40 50// 销毁数组destroyCircularArray(arr);return 0;
}
六、环形数组的特点与适用场景
特点:
- 空间高效:避免普通数组 “删除头部元素后,前面空间浪费” 的问题(通过指针绕回复用空间)。
- 操作高效:增删操作时间复杂度为
O(1)
(只需移动指针)。 - 固定容量:创建时需指定容量,无法动态扩容(如需扩容需手动实现)。
适用场景:
- 实现循环队列(如生产者 - 消费者模型的缓冲区)。
- 需要循环遍历数据的场景(如轮询任务调度)。
- 限制最大容量的缓存设计。
通过上述实现,环形数组的创建、增删改查操作已完整覆盖,核心在于理解 front
和 rear
指针的绕回逻辑,以及空 / 满状态的判断。
4 双端队列
双端队列(Double-Ended Queue,简称 Deque)是一种特殊的线性数据结构,它允许在队列的头部和尾部同时进行元素的插入(Add)和删除(Remove)操作,相当于融合了栈(只能尾操作)和普通队列(头删尾插)的功能。
一、双端队列的核心特性
- 双向操作:这是 Deque 最核心的特点
- 头部(Front):支持插入元素(
addFirst
)和删除元素(removeFirst
)。 - 尾部(Rear):支持插入元素(
addLast
)和删除元素(removeLast
)。
- 头部(Front):支持插入元素(
- 兼容栈与队列:
- 若只使用尾部的插入 / 删除(
addLast
/removeLast
),Deque 就是一个栈(后进先出,LIFO)。 - 若只使用尾部插入(
addLast
)和头部删除(removeFirst
),Deque 就是一个普通队列(先进先出,FIFO)。
- 若只使用尾部的插入 / 删除(
- 随机访问?不一定:
- 基于数组实现的 Deque(如 Java 的
ArrayDeque
)支持 O (1) 随机访问(需计算真实索引); - 基于链表实现的 Deque(如 Java 的
LinkedList
)随机访问需 O (N) 时间(需遍历)。
- 基于数组实现的 Deque(如 Java 的
二、双端队列的常用操作(以 Java Deque
接口为例)
所有操作的时间复杂度,数组实现为 O (1),链表实现也为 O (1)(无需搬移元素,只需修改指针)。
操作类型 | 头部操作(Front) | 尾部操作(Rear) | 说明 |
---|---|---|---|
插入元素 | addFirst(E e) / offerFirst(E e) | addLast(E e) / offerLast(E e) | add 满时抛异常,offer 满时返回 false |
删除元素 | removeFirst() / pollFirst() | removeLast() / pollLast() | remove 空时抛异常,poll 空时返回 null |
获取元素(不删) | getFirst() / peekFirst() | getLast() / peekLast() | get 空时抛异常,peek 空时返回 null |
判断空 / 满 | isEmpty() | size() | 无直接 isFull() ,数组实现需自己判断容量 |
三、双端队列的两种实现方式对比
Deque 主要有 “数组实现” 和 “链表实现” 两种,各有优劣,适配不同场景。
实现方式 | 代表(Java 标准库) | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
环形数组 | ArrayDeque | 1. 随机访问快(O (1))2. 尾操作性能极高 | 1. 容量固定(需动态扩容)2. 中间增删慢(O (N)) | 高频头尾操作、需随机访问的场景 |
双向链表 | LinkedList | 1. 容量动态(无需扩容)2. 中间增删灵活 | 1. 随机访问慢(O (N))2. 内存开销大(每个节点存前后指针) | 元素数量不确定、需中间操作的场景 |
四、双端队列的典型应用场景
- 实现栈或队列:
- 用
ArrayDeque
实现栈(比Stack
类高效):push()
=addLast()
,pop()
=removeLast()
。 - 用
ArrayDeque
实现队列(比LinkedList
高效):offer()
=addLast()
,poll()
=removeFirst()
。
- 用
- 滑动窗口问题:算法题中常见(如 “滑动窗口最大值”),用 Deque 维护窗口内的元素,头部存窗口的 “候选最大值”,尾部处理新加入的元素,可将时间复杂度从 O (N²) 降至 O (N)。
- 浏览器前进 / 后退功能:用两个 Deque 分别存储 “历史记录” 和 “前进记录”,点击前进 / 后退时从对应队列取元素。
- 任务调度:如线程池的任务队列,支持从头部取紧急任务,从尾部加普通任务。
五、简单代码示例(Java ArrayDeque
)
import java.util.ArrayDeque;
import java.util.Deque;public class DequeDemo {public static void main(String[] args) {// 创建一个 ArrayDeque(基于环形数组的 Deque)Deque<Integer> deque = new ArrayDeque<>();// 尾部插入(普通队列操作)deque.addLast(10);deque.addLast(20);deque.addLast(30);System.out.println("初始队列:" + deque); // 输出:[10, 20, 30]// 头部插入(Deque 特有)deque.addFirst(5);System.out.println("头部插入5后:" + deque); // 输出:[5, 10, 20, 30]// 头部删除(普通队列操作)int first = deque.removeFirst();System.out.println("删除的头部元素:" + first); // 输出:5System.out.println("删除后队列:" + deque); // 输出:[10, 20, 30]// 尾部删除(栈操作)int last = deque.removeLast();System.out.println("删除的尾部元素:" + last); // 输出:30System.out.println("删除后队列:" + deque); // 输出:[10, 20]// 获取头部/尾部元素(不删除)System.out.println("当前头部元素:" + deque.getFirst()); // 输出:10System.out.println("当前尾部元素:" + deque.getLast()); // 输出:20}
}
总结
双端队列的本质是 “双向可操作的线性容器”,它既解决了普通队列 “只能尾插头删” 和栈 “只能尾操作” 的局限性,又通过不同的实现方式(环形数组 / 链表)适配了不同的性能需求。标准库选择用环形数组实现 ArrayDeque
,正是因为它在 “头尾高频操作” 场景下,能最大化发挥 O (1) 性能的优势,同时避免了普通数组头部操作的 O (N) 损耗。
c语言实现:
用 C 语言实现双端队列(Deque)的完整示例,基于环形数组实现(兼顾效率和简洁性),包含核心的增删查操作,并附带测试代码。
一、双端队列的定义与实现
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>// 双端队列结构体(基于环形数组)
typedef struct {int* data; // 存储元素的数组int capacity; // 容量(最大可存储元素数)int front; // 头指针(指向队头元素)int rear; // 尾指针(指向队尾元素的下一个位置)int size; // 当前元素个数(简化空/满判断)
} Deque;// 1. 初始化双端队列(创建)
Deque* dequeCreate(int capacity) {if (capacity <= 0) {printf("容量必须大于0\n");return NULL;}Deque* deque = (Deque*)malloc(sizeof(Deque));deque->data = (int*)malloc(sizeof(int) * capacity);deque->capacity = capacity;deque->front = 0; // 初始头指针在0deque->rear = 0; // 初始尾指针在0deque->size = 0; // 初始元素数为0return deque;
}// 2. 判断队列是否为空
bool dequeIsEmpty(Deque* deque) {return deque->size == 0;
}// 3. 判断队列是否已满
bool dequeIsFull(Deque* deque) {return deque->size == deque->capacity;
}// 4. 头部插入元素(addFirst)
bool dequeAddFirst(Deque* deque, int val) {if (dequeIsFull(deque)) {printf("队列已满,无法头部插入\n");return false;}// 头指针向前移动(环形处理:若front为0,绕回至capacity-1)deque->front = (deque->front - 1 + deque->capacity) % deque->capacity;deque->data[deque->front] = val; // 存入新元素deque->size++;return true;
}// 5. 尾部插入元素(addLast)
bool dequeAddLast(Deque* deque, int val) {if (dequeIsFull(deque)) {printf("队列已满,无法尾部插入\n");return false;}deque->data[deque->rear] = val; // 尾指针位置存入新元素// 尾指针向后移动(环形处理)deque->rear = (deque->rear + 1) % deque->capacity;deque->size++;return true;
}// 6. 头部删除元素(removeFirst)
bool dequeRemoveFirst(Deque* deque, int* val) {if (dequeIsEmpty(deque)) {printf("队列为空,无法头部删除\n");return false;}*val = deque->data[deque->front]; // 取出头部元素// 头指针向后移动(环形处理)deque->front = (deque->front + 1) % deque->capacity;deque->size--;return true;
}// 7. 尾部删除元素(removeLast)
bool dequeRemoveLast(Deque* deque, int* val) {if (dequeIsEmpty(deque)) {printf("队列为空,无法尾部删除\n");return false;}// 尾指针向前移动(环形处理)deque->rear = (deque->rear - 1 + deque->capacity) % deque->capacity;*val = deque->data[deque->rear]; // 取出尾部元素deque->size--;return true;
}// 8. 获取头部元素(不删除,getFirst)
bool dequeGetFirst(Deque* deque, int* val) {if (dequeIsEmpty(deque)) {printf("队列为空,无法获取头部元素\n");return false;}*val = deque->data[deque->front];return true;
}// 9. 获取尾部元素(不删除,getLast)
bool dequeGetLast(Deque* deque, int* val) {if (dequeIsEmpty(deque)) {printf("队列为空,无法获取尾部元素\n");return false;}// 尾部元素在 rear 的前一个位置(环形处理)int lastIdx = (deque->rear - 1 + deque->capacity) % deque->capacity;*val = deque->data[lastIdx];return true;
}// 10. 打印队列元素(从头部到尾部)
void dequePrint(Deque* deque) {if (dequeIsEmpty(deque)) {printf("队列为空\n");return;}printf("双端队列元素: ");int cur = deque->front;for (int i = 0; i < deque->size; i++) {printf("%d ", deque->data[cur]);cur = (cur + 1) % deque->capacity; // 环形遍历}printf("\n");
}// 11. 销毁队列(释放内存)
void dequeDestroy(Deque* deque) {if (deque != NULL) {free(deque->data);free(deque);}
}
二、测试代码
int main() {// 创建容量为5的双端队列Deque* deque = dequeCreate(5);if (deque == NULL) return 1;// 尾部插入元素(模拟普通队列)dequeAddLast(deque, 10);dequeAddLast(deque, 20);dequeAddLast(deque, 30);dequePrint(deque); // 输出:双端队列元素: 10 20 30 // 头部插入元素(双端队列特有)dequeAddFirst(deque, 5);dequePrint(deque); // 输出:双端队列元素: 5 10 20 30 // 再头部插入一个元素dequeAddFirst(deque, 1);dequePrint(deque); // 输出:双端队列元素: 1 5 10 20 30 // 测试队列满的情况dequeAddFirst(deque, 0); // 提示:队列已满,无法头部插入// 获取头部和尾部元素int val;dequeGetFirst(deque, &val);printf("头部元素: %d\n", val); // 输出:1dequeGetLast(deque, &val);printf("尾部元素: %d\n", val); // 输出:30// 头部删除元素(模拟普通队列)dequeRemoveFirst(deque, &val);printf("删除的头部元素: %d\n", val); // 输出:1dequePrint(deque); // 输出:双端队列元素: 5 10 20 30 // 尾部删除元素(模拟栈)dequeRemoveLast(deque, &val);printf("删除的尾部元素: %d\n", val); // 输出:30dequePrint(deque); // 输出:双端队列元素: 5 10 20 // 销毁队列dequeDestroy(deque);return 0;
}
三、代码说明
核心设计:用环形数组实现,通过
front
(头指针)、rear
(尾指针)和size
(元素个数)管理队列。size
简化了空 / 满判断(避免了复杂的取模逻辑)。关键操作:
- 头部插入(
dequeAddFirst
):头指针先向前移动(绕回处理),再存入元素。 - 尾部插入(
dequeAddLast
):尾指针位置存入元素,再向后移动(绕回处理)。 - 头部删除(
dequeRemoveFirst
):取出头部元素,头指针向后移动(绕回处理)。 - 尾部删除(
dequeRemoveLast
):尾指针先向前移动(绕回处理),再取出元素。
- 头部插入(
时间复杂度:所有操作(增删查)均为 O(1),因为仅涉及指针移动和取模运算,无需搬移元素。
适用场景:适合需要高频头部 / 尾部操作的场景(如实现栈、队列、滑动窗口等),性能优于链表实现的双端队列。
运行结果
plaintext
双端队列元素: 10 20 30
双端队列元素: 5 10 20 30
双端队列元素: 1 5 10 20 30
队列已满,无法头部插入
头部元素: 1
尾部元素: 30
删除的头部元素: 1
双端队列元素: 5 10 20 30
删除的尾部元素: 30
双端队列元素: 5 10 20
这个示例完整展示了双端队列的双向操作能力,你可以根据需要扩展功能(如动态扩容、支持泛型等)。
5 为什么编程语言标准库的动态数组(如 Java 的 ArrayList
、C++ 的 vector
、Python 的 list
)底层不使用环形数组?
一、先明确核心前提:环形数组的「O (1) 头尾操作」有严格限制
环形数组确实能实现 O (1) 时间复杂度的头部 / 尾部增删,但这依赖一个关键条件:容量固定。环形数组的核心是通过 front
(头指针)和 rear
(尾指针)的 “绕回” 逻辑复用空间,而指针的计算依赖 固定的容量大小(capacity
)。如果要支持动态扩容(这是标准库动态数组的核心特性),环形数组的 “绕回” 逻辑会被打破:
- 当数组满时,需要申请新的更大容量的内存,并将原有元素复制到新空间。此时,
front
和rear
的相对位置需要重新计算,本质上和普通数组的扩容操作完全一致(没有优势)。 - 扩容后,原有的 “环形” 特性在新数组中暂时失效(因为新数组有大量空闲空间,指针无需绕回),直到元素再次填满新数组。
二、动态数组的核心需求:随机访问优先,且需动态扩容
标准库的动态数组(如 ArrayList
)的设计目标是 “提供类似数组的随机访问能力,同时支持动态增减元素”。其核心需求包括:
- O (1) 随机访问:通过索引直接定位元素(
arr[i]
),这是数组最核心的优势。 - 动态扩容:无需预先指定容量,元素数量可灵活增长(通常按 1.5 倍或 2 倍扩容)。
- 尽可能高效的增删:尤其是尾部操作(日常使用最频繁)需 O (1) 时间,中间 / 头部操作允许 O (N) 时间(因为场景较少)。
环形数组虽然能满足 “随机访问”(通过计算真实索引)和 “头尾 O (1) 操作”,但在 “动态扩容” 和 “工程实现复杂度” 上存在难以解决的问题。
三、环形数组不被采用的具体原因
1. 动态扩容时,环形特性失去意义,且实现更复杂
普通动态数组扩容时,直接将元素按顺序复制到新数组即可,逻辑简单:
// 普通数组扩容(简化)
new_data[i] = old_data[i]; // 直接按索引复制
而环形数组扩容时,由于元素在物理空间上可能是 “断裂” 的(front
在后,rear
在前),复制逻辑必须分两段处理:
// 环形数组扩容(简化)
int idx = front;
int i = 0;
// 第一段:从 front 到旧数组末尾
while (idx < capacity) {new_data[i++] = old_data[idx++];
}
// 第二段:从旧数组开头到 rear-1
idx = 0;
while (idx < rear) {new_data[i++] = old_data[idx++];
}
这种分段复制不仅 没有性能优势(同样是 O (N) 时间),还增加了代码复杂度(需要处理边界条件)。
更重要的是:扩容后的新数组有大量空闲空间,front
会被重置为 0,rear
指向元素末尾,此时环形数组退化为普通数组(直到再次填满)。也就是说,环形数组的 “环形” 特性在扩容后会暂时失效,其优势仅在 “接近满容量” 时体现,但动态数组的日常使用中 “接近满容量” 是少数情况。
2. 随机访问的实现更复杂,且存在隐性成本
普通数组的随机访问直接通过索引计算地址(硬件级支持):
// 普通数组访问:直接映射
value = data[i]; // 地址 = data + i * sizeof(int)
环形数组的随机访问需要先计算 “真实索引”:
// 环形数组访问:需额外计算
int size = (rear - front + capacity) % capacity; // 先算当前元素个数
if (i < 0 || i >= size) { ... } // 检查索引有效性
int real_idx = (front + i) % capacity; // 计算真实索引
value = data[real_idx];
虽然计算本身是 O (1) 时间,但相比普通数组多了 两次取模运算 和 一次边界检查(取模运算在硬件层面比直接加法更耗时)。对于高频的随机访问操作(动态数组的核心场景),这种隐性成本会被放大。
3. 中间元素的增删操作无优势,且逻辑更复杂
环形数组对 中间元素的增删(如 add(index, val)
、remove(index)
)和普通数组一样需要搬移元素,时间复杂度都是 O (N),但实现更复杂:
- 普通数组搬移时,直接从索引
i
开始整体移动(data[j+1] = data[j]
)。 - 环形数组搬移时,需要先判断索引在
front
的左侧还是右侧,再决定搬移方向(向左或向右),并处理可能的 “绕回” 情况(例如,搬移到数组末尾时需要绕回开头)。
这种复杂性会导致代码更难维护,且容易引入 bugs(尤其是边界条件处理)。
4. 内存空间的 “碎片化” 感知问题
环形数组的 “环形” 是逻辑上的,其物理内存依然是连续的(和普通数组一样)。但当 front
不为 0 时,数组头部会有一段空闲空间([0, front-1]
),这段空间在逻辑上属于 “已使用”(被环形复用),但在物理上是空闲的。
这种 “物理空闲但逻辑占用” 的状态会给开发者带来困扰:例如,调试时查看数组的原始内存会看到不连续的元素(前半部分空闲,后半部分有值,中间被 front
分割),不如普通数组的 “元素连续存储” 直观。标准库需要兼顾易用性和可调试性,环形数组的这种 “不直观” 是减分项。
5. 与 “动态数组” 的设计定位冲突
动态数组的核心是 “以数组为基础,提供动态能力”,其设计目标是 “尽可能保留数组的简单性和高效性”。环形数组的 “环形” 特性本质上是对数组的 “复杂化改造”,但这种改造带来的收益(头尾 O (1) 操作)在动态数组的使用场景中并不突出:
- 头部增删(
addFirst
、removeFirst
)在实际开发中远不如尾部操作(addLast
、removeLast
)频繁。 - 如果需要高频的头尾操作,开发者会选择链表(如 Java 的
LinkedList
)或双端队列(如ArrayDeque
),而非动态数组。
标准库通常会提供多种数据结构(数组、链表、队列)以满足不同场景,动态数组的定位是 “优化随机访问和尾部操作”,而非 “兼顾所有场景”。环形数组的 “全能” 反而使其失去了动态数组的核心定位。
四、反例:为什么 ArrayDeque
用了环形数组?
Java 的 ArrayDeque
(双端队列)底层使用了环形数组,但它 不是动态数组,而是专门优化头尾操作的容器。其设计目标与动态数组完全不同:
ArrayDeque
不支持随机访问(没有get(index)
方法),避免了环形数组随机访问的复杂性。- 它的核心场景是头尾增删(
addFirst
、addLast
等),刚好匹配环形数组的优势。 - 虽然支持动态扩容,但扩容逻辑是为了 “双端队列” 的场景设计的,而非 “随机访问”。
这恰恰说明:环形数组适合特定场景(如双端队列),但不适合动态数组的核心场景(随机访问 + 动态扩容)。
总结
编程语言标准库的动态数组不使用环形数组,核心原因是 “收益有限,成本过高”:
- 收益:仅在 “容量固定且接近满” 时,头尾操作能保持 O (1) 时间,但动态数组的扩容特性会让这种收益大部分时间失效。
- 成本:随机访问更复杂、扩容逻辑更繁琐、中间操作无优势、代码维护难度高。
动态数组的设计哲学是 “以最简单的方式满足核心需求”(随机访问 + 动态扩容),而环形数组的 “环形” 特性与这一哲学冲突。因此,标准库选择了更简单、更符合核心场景的普通数组作为底层实现。
东哥的理解:
如果用环形数组,增删查改的的所有操作都会涉及 % 求模运算,这个操作是比较消耗性能的。
尤其像数组的 get 方法,调用频率会非常非常高,如果每次调用都多一步 % 运算,加起来的性能损耗远大于环形数组带来的收益,因为数组很少在头部增删元素。
如果你非要在头部增删,应该使用更合适的其他数据结构。 所以一般只会在双端队列这种场景下使用环形数组,标准的动态数组并没有使用这个技巧。
不是不能用,而是算总账不划算。
-------
环形数组的 “绕回” 优势,抵不过其日常高频操作(如 get)的性能损耗和场景错位。