本文的网课内容学习自B站左程云老师的算法详解课程,旨在对其中的知识进行整理和分享~
一、队列
定义
队列(Queue)是一种先进先出(FIFO, First In First Out)的数据结构。它允许在队尾进行插入操作,并在队头进行删除操作。队列的常见操作包括入队(enqueue)和出队(dequeue)。
操作
1. 入队(Enqueue)
- 作用:将一个元素插入到队列的末尾(队尾)。
- 示例:
- 队列初始:
[A, B, C]
- 执行入队操作插入
D
:[A, B, C, D]
- 队列初始:
2. 出队(Dequeue)
- 作用:从队列的前端(队头)移除并返回第一个元素。
- 示例:
- 队列初始:
[A, B, C]
- 执行出队操作:移除
A
,队列变为[B, C]
。
- 队列初始:
3. 查看队头元素(Peek 或 Front)
- 作用:查看队列的前端元素,但不移除它。
- 示例:
- 队列初始:
[A, B, C]
- 执行
Peek
操作:返回A
,队列保持不变[A, B, C]
。
- 队列初始:
4. 获取队列长度(Size)
- 作用:返回队列中当前元素的个数。
- 示例:
- 队列初始:
[A, B, C]
- 执行
Size
操作:返回3
。
- 队列初始:
5. 判断队列是否为空(IsEmpty)
- 作用:检查队列是否为空,返回布尔值(
true
或false
)。 - 示例:
- 队列初始:
[]
(空队列) - 执行
IsEmpty
操作:返回true
。
- 队列初始:
6. 清空队列(Clear)
- 作用:移除队列中的所有元素,使其变为空队列。
- 示例:
- 队列初始:
[A, B, C]
- 执行
Clear
操作:队列变为[]
。
- 队列初始:
7. 遍历队列(Traverse)
- 作用:按顺序访问队列中的每个元素。
- 示例:
- 队列初始:
[A, B, C]
- 遍历操作:依次访问
A
、B
、C
。
- 队列初始:
应用场景
1.任务调度与排队系统
- 操作系统任务调度:操作系统使用队列来管理等待执行的任务或进程,确保按先来后到的顺序执行。
- 打印任务队列:打印机通常使用队列来管理多个打印任务,按顺序处理每个任务。
- 银行叫号系统:银行使用队列来管理客户排队顺序,确保先到先服务。
2.广度优先搜索(BFS)
- 在图遍历算法中,BFS利用队列来记录待访问的节点,按照层次顺序访问节点,确保每一层的所有节点都被访问后,再访问下一层。
3.多线程与并发处理
- 在多线程环境中,队列用于协调任务的分配和执行顺序,确保线程间有序共享资源,避免竞争条件和死锁。
4.实时系统与资源管理
- 交通信号灯控制:利用队列管理车辆通行顺序,确保交通流畅。
- CPU调度:在实时系统中,使用队列来管理任务的执行顺序,确保关键任务按时完成。
5.消息传递与通信系统
- 消息队列:在分布式系统中,消息队列用于异步传递消息,确保消息按顺序处理,提高系统可靠性和可扩展性。
- 网络数据包处理:网络设备使用队列来管理接收到的数据包,按顺序处理每个数据包,确保数据传输的正确性和及时性。
6.缓存与内存管理
- 在缓存替换策略中,使用队列来管理缓存项的访问顺序,确保最久未使用的项被优先替换,提高缓存命中率。
7.图形处理与动画
- 在图形处理中,队列用于管理绘制命令的执行顺序,确保图形元素按正确顺序呈现,生成平滑的动画效果。
8.编译器与解释器
- 在语法分析阶段,队列用于管理输入的符号,确保分析过程按顺序进行,正确解析语句结构。
代码实现
-
普通队列(链表和数组实现)
-
算法原理
一、使用Java内部LinkedList
实现队列(Queue1
)
- 数据结构基础
- 利用
LinkedList
来实现队列。LinkedList
是一个双向链表结构。虽然单向链表对于队列操作 (先进先出)来说已经足够,但Java中的LinkedList
是双向的。
- 利用
- 操作原理
isEmpty
方法:- 直接调用
LinkedList
的isEmpty
方法,用于判断队列是否为空。其原理是检查链表中是否有节点,时间复杂度为O(1)。
- 直接调用
offer
方法:- 调用
LinkedList
的offer
方法,在队列尾部添加元素。在LinkedList
中,这是一个常数时间操作(如果不考虑链表可能的扩容等情况),时间复杂度为O(1)。
- 调用
poll
方法:- 调用
LinkedList
的poll
方法,从队列头部移除并返回元素。在LinkedList
中,这也是一个常数时间操作(不考虑链表的特殊情况),时间复杂度为O(1)。
- 调用
peek
方法:- 调用
LinkedList
的peek
方法,返回队列头部的元素但不移除它。时间复杂度为O(1)。
- 调用
size
方法:- 调用
LinkedList
的size
方法,返回队列中元素的个数。时间复杂度为O(1)。
- 调用
二、使用自定义单向链表实现队列(Queue2
)
- 数据结构基础
- 自定义了一个单向链表结构,其中包含节点类
ListNode
,每个节点有一个值val
和指向下一个节点的引用next
。 - 队列有两个指针,
front
指向队列头部,tail
指向队列尾部。
- 自定义了一个单向链表结构,其中包含节点类
- 操作原理
enqueue
方法(入队操作):- 如果队列为空(
isEmpty
为true
),则新节点既是头部也是尾部(front = newNode; tail = newNode;
)。 - 如果队列不为空,则将新节点添加到尾部(
tail.next = newNode; tail = newNode;
),并且队列大小size
加1。
- 如果队列为空(
dequeue
方法(出队操作):- 取出头部节点的值(
int value = front.val;
),然后将头部指针指向下一个节点(front = front.next;
)。 - 如果出队后队列为空(
front == null
),则将尾部指针也设为null
(tail = null;
),并且队列大小size
减1。
- 取出头部节点的值(
isEmpty
方法:- 通过检查队列大小
size
是否为0来判断队列是否为空,时间复杂度为O(1)。
- 通过检查队列大小
size
方法:- 直接返回队列大小
size
,时间复杂度为O(1)。
- 直接返回队列大小
peek
方法:- 返回队列头部节点的值(
front.val
),时间复杂度为O(1)。
- 返回队列头部节点的值(
三、使用数组实现队列(Queue3
)
- 数据结构基础
- 使用一个数组
queue
来存储队列元素,有两个指针l
(表示队列头部的索引)和r
(表示队列尾部的索引)。
- 使用一个数组
- 操作原理
isEmpty
方法:- 通过比较
l
和r
是否相等来判断队列是否为空。如果相等,表示队列为空,时间复杂度为O(1)。
- 通过比较
offer
方法- 将元素添加到数组中,索引为
r
的位置,然后r
指针后移一位(queue[r++] = num;
)。
- 将元素添加到数组中,索引为
poll
方法- 取出索引为
l
的元素(return queue[l++];
),然后l
指针后移一位。
- 取出索引为
head
方法- 返回索引为
l
的元素,即队列头部元素,时间复杂度为O(1)。
- 返回索引为
tail
方法- 返回索引为
r - 1
的元素,即队列尾部元素,时间复杂度为O(1)。
- 返回索引为
size
方法- 通过计算
r - l
来得到队列中元素的个数,时间复杂度为O(1)。
- 通过计算
package queuestackandcircularqueue;
import java.util.LinkedList;
import java.util.Queue;
public class QueueDemo {
// 直接用java内部的实现
// 其实内部就是双向链表,常数操作慢
public static class Queue1 {
// java中的双向链表LinkedList
// 单向链表就足够了
public Queue<Integer> queue = new LinkedList<>();
// 调用任何方法之前,先调用这个方法来判断队列内是否有东西
public boolean isEmpty() {
return queue.isEmpty();
}
// 向队列中加入num,加到尾巴
public void offer(int num) {
queue.offer(num);
}
// 从队列拿,从头拿
public int poll() {
return queue.poll();
}
// 返回队列头的元素但是不弹出
public int peek() {
return queue.peek();
}
// 返回目前队列里有几个数
public int size() {
return queue.size();
}
//使用自己定义的单向链表来实现队列
//定义一个单向链表
public static class ListNode {
public int val;
public ListNode next;
public ListNode(int val) {
this.val = val;
}
public ListNode(int val, ListNode next) {
this.val = val;
this.next = next;
}
}
public static class Queue2 {
ListNode front = null;
ListNode tail = null;
int size = 0;
//入队操作
public void enqueue(int value) {
ListNode newNode = new ListNode(value);
if(isEmpty()) {
front = newNode;
tail = newNode;
}else {
tail.next = newNode;
tail = newNode;
}
size++;
}
//出队操作
public int dequeue() {
int value = front.val;
front = front.next;
if(front == null) {
tail = null;
}
size--;
return value;
}
//判断队列是否为空
public boolean isEmpty() {
return size == 0;
}
//获取队列大小
public int size() {
return size;
}
//获取队列头部元素,但是不弹出
public int peek() {
return front.val;
}
}
}
// 使用数组实现队列
// 实际刷题时更常见的写法,常数时间好
// 如果可以确定加入操作的总次数不超过n,那么可以用
// 一般笔试、面试都会有一个明确数据量,所以这是最常用的方式
public static class Queue3 {
public int[] queue;
public int l;
public int r;
// 加入操作的总次数上限是多少,一定要明确
public Queue3(int n) {
queue = new int[n];
l = 0;
r = 0;
}
// 调用任何方法之前,先调用这个方法来判断队列内是否有东西
public boolean isEmpty() {
return l == r;
}
public void offer(int num) {
queue[r++] = num;
}
public int poll() {
return queue[l++];
}
public int head() {
return queue[l];
}
public int tail() {
return queue[r - 1];
}
public int size() {
return r - l;
}
}
}
-
环形队列(数组实现)
-
算法原理
-
数据结构基础
- 数组表示
- 使用一个数组
queue
来存储循环队列中的元素。
- 使用一个数组
- 指针定义
- 定义了两个指针
l
(表示队列头部的索引)和r
(表示队列尾部的索引),以及一个变量size
(表示队列中元素的个数)和limit
(表示队列的容量上限)。
- 定义了两个指针
- 数组表示
-
操作原理
- 构造函数:
- 在构造函数
CircularQueue(int k)
中,初始化数组queue
的大小为k
,并将l
、r
、size
都初始化为0,同时记录队列容量上限limit = k
。
- 在构造函数
enQueue
方法(入队操作):- 首先判断队列是否已满(
isFull
方法)。 - 如果队列已满(
size == limit
),则返回false
,不进行任何操作。 - 如果队列未满,则将元素
value
放入队列尾部(queue[r] = value
)。 - 然后更新
r
指针,如果r
已经到达数组的末尾(r == limit - 1
),则将r
设置为0,否则r
指针后移一位(r = r == limit - 1 ? 0 : (r + 1);
)。 - 最后队列大小
size
加1,并返回true
。
- 首先判断队列是否已满(
deQueue
方法(出队操作):- 首先判断队列是否为空(
isEmpty
方法)。 - 如果队列为空(
size == 0
),则返回false
,不进行任何操作。 - 如果队列不为空,则更新
l
指针,如果l
已经到达数组的末尾(l == limit - 1
),则将l
设置为0,否则l
指针后移一位(l = l == limit - 1? 0 : (l + 1);
)。 - 队列大小
size
减1,并返回true
。
- 首先判断队列是否为空(
Front
方法(获取队首元素)- 首先判断队列是否为空(
isEmpty
方法)。 - 如果队列为空,则返回 -1,表示没有元素。
- 如果队列不为空,则返回索引为
l
的元素,即队列头部元素(return queue[l];
)。
- 首先判断队列是否为空(
Rear
方法(获取队尾元素):- 首先判断队列是否为空(
isEmpty
方法)。 - 如果队列为空,则返回 -1,表示没有元素。
- 如果队列不为空,则需要根据
r
指针的位置来确定队尾元素。 - 如果
r
在0位置,说明r
已经将整个数组遍历一遍后又跳回0位置,那么上一个添加的数在数组的最后一个位置(limit - 1
),即返回queue[limit - 1]
。 - 如果
r
不在0位置,说明数组还没有完全遍历完成,那么上一个添加的数在r
的前一个位置(r - 1
),即返回queue[r - 1]
。
- 首先判断队列是否为空(
isEmpty
方法:- 通过比较
size
是否为0来判断队列是否为空,时间复杂度为O(1)。
- 通过比较
isFull
方法:- 通过比较
size
是否等于limit
来判断队列是否已满,时间复杂度为O(1)。
- 通过比较
- 构造函数:
-
package queuestackandcircularqueue;
// 设计循环队列
// 测试链接 : https://leetcode.cn/problems/design-circular-queue/
class CircularQueue {
public int[] queue;
public int l, r, size, limit;
// 同时在队列里的数字个数,不要超过k
public CircularQueue(int k) {
queue = new int[k];
l = r = size = 0;
limit = k;
}
// 如果队列满了,什么也不做,返回false
// 如果队列没满,加入value,返回true
public boolean enQueue(int value) {
if (isFull()) {
return false;
} else {
queue[r] = value;
// r++, 结束了,跳回0
r = r == limit - 1 ? 0 : (r + 1);
size++;
return true;
}
}
// 如果队列空了,什么也不做,返回false
// 如果队列没空,弹出头部的数字,返回true
public boolean deQueue() {
if (isEmpty()) {
return false;
} else {
// l++, 结束了,跳回0
l = l == limit - 1 ? 0 : (l + 1);
size--;
return true;
}
}
// 返回队列头部的数字(不弹出),如果没有数返回-1
public int Front() {
if (isEmpty()) {
return -1;
} else {
return queue[l];
}
}
//返回最后添加的数字
public int Rear() {
if (isEmpty()) {
return -1;
} else {
//如果r在0位置,说明r已经将整个数组遍历一遍后又跳回0位置,那就说明上一个添加的数在数组的最后一个位置
//如果r不在0位置,说明数组还没有完全遍历完成,那么就说明上一个添加的数在r的前一个位置
int last = r == 0 ? (limit - 1) : (r - 1);
return queue[last];
}
}
public boolean isEmpty() {
return size == 0;
}
public boolean isFull() {
return size == limit;
}
}
二.栈
定义
栈(Stack)是一种先进后出(LIFO, Last In First Out)的数据结构。它只允许在栈顶进行插入和删除操作,具有“后进先出”的特性。栈在程序设计和算法实现中有着广泛的应用。
操作
1. 压栈(Push)
- 作用:将一个元素添加到栈的顶端(栈顶)。
- 示例:
- 栈初始:
[A, B, C]
- 执行压栈操作插入
D
:[A, B, C, D]
- 栈初始:
2. 弹栈(Pop)
- 作用:从栈顶移除并返回第一个元素。
- 示例:
- 栈初始:
[A, B, C]
- 执行弹栈操作:移除
C
,栈变为[A, B]
。
- 栈初始:
3. 查看栈顶元素(Peek 或 Top)
- 作用:查看栈顶元素,但不移除它。
- 示例:
- 栈初始:
[A, B, C]
- 执行
Peek
操作:返回C
,栈保持不变[A, B, C]
。
- 栈初始:
4. 获取栈的大小(Size)
- 作用:返回栈中当前元素的个数。
- 示例:
- 栈初始:
[A, B, C]
- 执行
Size
操作:返回3
。
- 栈初始:
5. 判断栈是否为空(IsEmpty)
- 作用:检查栈是否为空,返回布尔值(
true
或false
)。 - 示例:
- 栈初始:
[]
(空栈) - 执行
IsEmpty
操作:返回true
。
- 栈初始:
6. 清空栈(Clear)
- 作用:移除栈中的所有元素,使其变为空栈。
- 示例:
- 栈初始:
[A, B, C]
- 执行
Clear
操作:栈变为[]
。
- 栈初始:
7. 遍历栈(Traverse)
- 作用:按顺序访问栈中的每个元素。
- 示例:
- 栈初始:
[A, B, C]
- 遍历操作:依次访问
A
、B
、C
。
- 栈初始:
应用场景
1. 函数调用管理
- 作用:操作系统使用栈来管理函数调用,确保函数返回时能够正确恢复上下文。
- 示例:
- 在程序运行过程中,当一个函数调用另一个函数时,当前函数的执行状态(如返回地址、局部变量等)会被压入栈中。
- 当被调用的函数执行完毕后,栈顶的执行状态被弹出,程序恢复到调用前的状态,继续执行下一个函数。
- 这种机制确保了函数调用的正确性和顺序性。
2. 表达式求值与括号匹配
- 作用:栈常用于判断表达式的括号是否匹配,确保语法正确。
- 示例:
- 在计算表达式时,栈用于暂时存储运算符和操作数,按照运算规则进行计算。
- 括号匹配:遍历表达式,遇到左括号压入栈,遇到右括号弹出栈顶的左括号。如果在遍历过程中发现右括号没有对应的左括号,或者遍历结束后栈不为空,说明括号不匹配。
3. 递归实现
- 作用:递归函数通过栈保存每次调用的返回地址和局部变量,确保能够正确返回。
- 示例:
- 递归函数每次调用时,当前函数的执行状态被压入栈中。
- 递归终止条件满足时,栈顶的状态被弹出,程序返回上一层递归调用,继续执行。
- 这种机制使得递归函数可以正确地层层返回,完成计算。
4. 回溯算法
- 作用:在深度优先搜索(DFS)中,栈用于保存路径,帮助回溯到之前的节点,尝试其他路径。
- 示例:
- 在迷宫求解或图的遍历中,栈记录当前路径。
- 当前路径无法到达目标节点时,弹出栈顶节点,回溯到前一个节点,尝试其他可能的路径。
5. 网页浏览器的前进和后退功能
- 作用:浏览器使用栈来记录用户访问的页面顺序,实现前进和后退功能。
- 示例:
- 当用户访问一个新页面时,当前页面的URL被压入栈中。
- 点击“后退”按钮时,栈顶的URL被弹出,浏览器跳转到上一个页面。
- 点击“前进”按钮时,如果存在被后退的页面,可以再次压入栈中,实现前进功能。
6. 图形编辑器的撤销与重做功能
- 作用:使用栈来记录用户的操作步骤,支持撤销和重做功能。
- 示例:
- 每次用户执行一个操作时,操作的状态被压入栈中。
- 点击“撤销”按钮时,栈顶的操作被弹出,恢复到上一个状态。
- 点击“重做”按钮时,需要另一个栈来记录撤销的操作,将其重新压入主栈,执行重做。
7. 排序算法
- 作用:某些排序算法(如快速排序)利用栈来辅助实现递归过程。
- 示例:
- 快速排序是一种基于分治法的排序算法,通过递归实现。
- 每次递归调用时,当前区间的起始和结束索引被压入栈中。
- 递归终止条件满足时,栈顶的区间被弹出,继续处理下一个区间。
8. 计算器的表达式求值
- 作用:计算器使用栈来处理中缀表达式,将其转换为后缀表达式进行计算。
- 示例:
- 将中缀表达式转换为后缀表达式时,栈用于存储运算符。
- 根据运算符的优先级,决定运算符的压栈和弹栈顺序,生成正确的后缀表达式。
- 然后,使用栈对后缀表达式进行求值,确保运算顺序正确。
9. 内存管理
- 作用:在某些内存管理策略中,栈用于管理动态内存分配和释放。
- 示例:
- 动态内存分配(如C语言中的
malloc
和free
函数)可能使用栈来记录已分配内存块的信息。 - 当内存被释放时,栈顶的内存块信息被弹出,内存被归还给操作系统。
- 动态内存分配(如C语言中的
10. 编译器与解释器
- 作用:编译器和解释器使用栈来进行语法分析和表达式求值。
- 示例:
- 语法分析阶段,栈用于存储预期的语法元素,确保输入的语法结构正确。
- 表达式求值阶段,栈用于存储操作数和运算符,确保按正确的顺序进行计算。
代码实现
-
算法原理
一.Stack1
(基于Java内部Stack
实现)的算法原理
-
数据结构基础
- 它基于Java内部的
Stack
类实现栈功能。Stack
类在Java中是一种特殊的Vector
,而Vector
是一种动态数组结构。
- 它基于Java内部的
- 操作原理
isEmpty
操作- 直接调用
Stack
类的isEmpty
方法。这个方法会检查内部数组中元素的数量是否为0。时间复杂度为O(1),因为它只是简单地检查一个变量(记录元素数量)是否为0。
- 直接调用
push
操作- 调用
Stack
的push
方法。在Stack
(基于Vector
)中,push
操作将元素添加到数组的末尾。如果数组已满,会进行扩容操作(这可能涉及到创建一个新的更大的数组,复制旧数组元素到新数组,是一个相对耗时的操作,但在不考虑扩容的情况下,时间复杂度为O(1))。
- 调用
pop
操作- 调用
Stack
的pop
方法。它会移除并返回数组末尾的元素。在Stack
中,这个操作通常是简单地调整数组末尾指针(在不考虑动态数组调整的特殊情况时),时间复杂度为O(1)。
- 调用
peek
操作- 调用
Stack
的peek
方法。该方法返回数组末尾的元素但不移除它。操作简单地获取数组末尾元素的值,时间复杂度为O(1)。
- 调用
size
操作- 调用
Stack
的size
方法。它直接返回记录栈中元素数量的变量的值,时间复杂度为O(1)。
- 调用
二.Stack2
(基于数组实现)的算法原理
- 数据结构基础
- 使用一个固定大小为
n
的整数数组stack
来存储栈元素,同时有一个变量size
来记录当前栈中元素的数量。
- 使用一个固定大小为
- 操作原理
isEmpty
操作- 通过检查
size
是否等于0来判断栈是否为空。这是一个简单的比较操作,时间复杂度为O(1)。
- 通过检查
push
操作- 将元素
num
放入数组stack
中索引为size
的位置,然后将size
的值加1。这个操作直接对数组进行写入操作,时间复杂度为O(1)。
- 将元素
pop
操作- 返回数组
stack
中索引为size - 1
的元素,然后将size
的值减1。这个操作直接从数组中读取并调整size
变量,时间复杂度为O(1)。
- 返回数组
peek
操作- 返回数组
stack
中索引为size - 1
的元素,也就是栈顶元素。这是一个简单的数组读取操作,时间复杂度为O(1)。
- 返回数组
size
操作- 直接返回
size
的值,时间复杂度为O(1)。
- 直接返回
package queuestackandcircularqueue;
import java.util.Stack;
public class StackDemo {
// 直接用java内部的实现
// 其实就是动态数组,不过常数时间并不好
public static class Stack1 {
public Stack<Integer> stack = new Stack<>();
// 调用任何方法之前,先调用这个方法来判断栈内是否有东西
public boolean isEmpty() {
return stack.isEmpty();
}
public void push(int num) {
stack.push(num);
}
public int pop() {
return stack.pop();
}
public int peek() {
return stack.peek();
}
public int size() {
return stack.size();
}
}
// 实际刷题时更常见的写法,常数时间好
// 如果可以保证同时在栈里的元素个数不会超过n,那么可以用
// 也就是发生弹出操作之后,空间可以复用
// 一般笔试、面试都会有一个明确数据量,所以这是最常用的方式
public static class Stack2 {
public int[] stack;
public int size;
// 同时在栈里的元素个数不会超过n
public Stack2(int n) {
stack = new int[n];
size = 0;
}
// 调用任何方法之前,先调用这个方法来判断栈内是否有东西
public boolean isEmpty() {
return size == 0;
}
public void push(int num) {
stack[size++] = num;
}
public int pop() {
return stack[--size];
}
public int peek() {
return stack[size - 1];
}
public int size() {
return size;
}
}
}