在讲线性表常见的数据结构,里面涉及到一些算法的复杂度分析,这是很重要的一步,如果对复杂度分析不是很明确的同学可以先去看这篇文章: 算法复杂度分析
线性表(Linear List),顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。
线性表结构包含数组、链表、栈、队列这几种,如下图所示:
下面详细来说明下这几种线性表类型的数据结构。
1 数组
它用一组连续的内存空间,来存储一组具有相同类型的数据。
1.1 随机访问
数组是如何进行随机访问的?
比如声明一个大小为10的数组,int[] a = new int[10],分配了一块连续内存空间1000~1039,如下图所示:
内存块首地址为1000,计算机是通过地址来访问内存数据的,以这个数组为例,计算机是如何得到每个数组元素对应的内存地址的呢?
- a[0]就是数组起始地址1000;
- a[1]的地址就等于1000+4*1=1004(int类型占4个字节);
- …
- a[9]的地址可以推算出位1000+4*9=1036。
从上面推算可以得出,数组中元素的访问是通过寻址公式计算出该元素存储的内存地址,进而进行访问的,寻址公式如下:
a[i]_address = base_address + i * data_type_size
这也是为什么数组下标从0开始,如果从1开始寻址公式就变成了下面这样:
a[k]_address = base_address + (k-1)*type_size
相当于多了一次减法操作。
数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。
你从网上可能看到很多对数组的描述是"数组适合查找,查找时间复杂度为O(1)",这种表述其实不太准确,数组是适合查找操作,但是查找的时间复杂度并不为O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是O(logn)。
正确的表述是"数组支持随机访问,根据下标随机访问的时间复杂度为O(1)"。
1.2 插入和删除元素
数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。
1.2.1 插入操作
我们先来分析下对一个长度为n的数组在某个位置上插入一个元素最好情况、最坏情况以及平均时间复杂度各是多少?
因为数组必须保证内存数据的连续性(不然怎么支持随机访问),所以在某个位置插入一个元素,这个位置后面的元素都得往后移动一位。
-
最好情况时间复杂度:直接在数组末尾插入一个元素,数组中元素都不需要移动,这样时间复杂度就为O(1)。
-
最坏情况时间复杂度:在数组为0的位置插入元素(数组的第一个元素),那么从位置1开始,一直到n-1的元素都得往后移动一步,这样时间复杂度就是O(n)。
-
平均时间复杂度:元素在插入数组每个位置的概率是一样的,都是1/n,插入第0的位置一直到第n-1的位置元素移动次数位n、n-1、…、1,所以可以得出:
O ( n ) = O ( n + . . . + 1 n ) = O ( n ) O(n) = O(\frac{n+...+1}{n})=O(n) O(n)=O(nn+...+1)=O(n)
可以发现,数组有序时,在某个位置插入一个新的元素时,就必须搬移插入位置后面所有的元素;但如果数组是没有任何规律可言,有什么方式可以减少这种大规模的搬移吗?
举个例子,假设数组a[10]中存储了如下5个元素:a,b,c,d,e,在需要将元素x插入到第3个位置。我们只需要将c放入到a[5],将a[2]赋值为x即可。最后,数组中的元素如下: a,b,x,d,e,c。
利用这种特定的技巧,在第k个位置插入一个元素的时间复杂度就会降为O(1)。
1.2.2 删除操作
跟插入数据类似,如果我们要删除第k个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了。
分析时间复杂度与插入操作类似
- 最好情况时间复杂度:删除数组末尾的数据,O(1);
- 最坏情况时间复杂度:删除开头的数据,O(n);
- 平均时间复杂度:O(n)。
实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。我们可以通过"伪删除,积攒一波再统一移动元素"的方式提高删除效率。数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。
删除a,b,c元素为了避免对d,e,f移动3次,我们可以每次删除时"不真正删除",只是标记元素已经被删除了,当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。JVM 标记清除垃圾回收算法的核心思想就是通过这种方式。
1.2.3 数组和容器的选择
容器比如Java 中的 ArrayList,它的最大优势就是可以将很多数组操作的细节封装起来,比如插入和删除操作,同时还支持动态扩容。
那数组什么场景下可以考虑使用呢?
- Java ArrayList 无法存储基本类型,比如 int、long,可以选择数组存储;
- 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组;
- 当表示多维数组时,Object[][] array往往比ArrayListarray看着直接。(取决于个人习惯)。
2 链表
并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用。
链表和数组在内存上很大不同时链表不需要连续的存储空间,下图是数组和链表在内存分配上的区别:
可以看出链表的内存是非连续的,通过一个个"指针"将不同内存块串起来。
2.1 常见的链表结构
常见的链表结构主要包括:
- 单链表;
- 循环链表;
- 双向链表。
链表和数组的性能优劣之分:
-
插入、删除、随机访问操作的时间复杂度正好相反;
-
内存分配方式不同导致性能有所不同;
- 数组使用连续的内存空间,可借助CPU缓存机制预读数组中的数据,访问效率高;链表因为是非连续的内存空间,没法做预读。
- 数组分配的内存是固定的,若声明数组过大,系统没有足够的连续内存空间,出现"out of memory",若声明数组过小,可能不够使用,这时只能再申请一个更大的内存空间,将原数组拷贝过去,这个过程是很耗时的;链表可以申请比较零散的内存,天然支持动态扩容。
2.1.1 单链表
最简单、最常用
如图所示,链表通过指针将一组零散的内存块串联在一起,我们把内存块称为链表的“结点”,为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。
- 我们把记录下一结点地址的指针叫做后继指针next;
- 第一个结点称为头结点,用来记录链表的基地址,可以通过它来遍历得到整个链表;
- 最后一个结点称为尾节点;它的next指针是指向一个空地址NULL。
插入和删除操作
链表不像数组那样,插入和删除操作需要大量移动元素,因为有了后继指针next的存在,插入和删除操作的时间复杂度都是O(1),如下图所示,我们只需要改变next指针的指向就可以轻松做到插入和删除。
随机访问操作
链表要想随机访问第k个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,只能通过next指针一个个的遍历结点往后寻找。
可以把链表想象成一个队伍,队伍中的每个人都只知道自己后面的人是谁,所以当我们希望知道排在第k位的人是谁的时候,我们就需要从第一个人开始,一个一个地往下数。
所以它的随机访问的时间复杂度为O(n)。
循环链表
循环链表是一种特殊的单链表。
循环链表如下图所示:
可以看出和单链表明显的区别是:循环链表尾结点的指针指向的是链表的头结点。
循环链表最大的优点是:链尾到链头比较方便,适合处理具有环形结构特点的数据,比如约瑟夫问题。
双向链表
空间换时间的设计思想,实际软件开发,比较常用的链表结构。
双向链表如下图所示:
可以看出,双向链表多了一个pre指针,用于存储前驱结点地址,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。
但它比单链表更为灵活,支持双向遍历,而且双向链表在删除给定指针指向的结点比单链表更有优势。
下面看下两者在删除给定指针指向结点有啥区别:
- 要删除某个结点,单链表必须找到这个结点的前驱结点,所以只能从头结点开始循环遍历,直到p->next=q,说明p是q的前驱结点,所以时间复杂度为O(n);
- 对于双向链表,因为结点已经保存了前驱结点的指针,不需要像单链表那样遍历,所以时间复杂度为O(1)。
2.2 写链表代码的一些技巧
2.2.1 掌握指针或引用的概念
指针或引用都是存储所指对象的内存地址。
- 将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针;
- 指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。
深刻理解上面两句话的含义,我们看下面两个常见链表代码表示的意思:
- p->next=q;表示p结点中的next指针存储了q结点的内存地址;
- p->next=p->next->next;表示p结点的next指针存储了p结点的下下一个结点的内存地址。
2.2.2 注意指针丢失和内存泄漏
写链表代码的时候,指针指来指去,要特别注意别把指针给弄丢了。
比如在一个单链表做插入操作,如下图所示:
现在是在a,b结点之间插入x,当前指针p指向的是a,初学者可能会考虑这样写:
p->next = x; // 将p的next指针指向x结点;
x->next = p->next; // 将x的结点的next指针指向b结点;
先操作p的后继next指针指向x后,你会发现,a结点后面的链表你无法再定位到了,实际上第二行代码含义就变成了x自己指向自己了,整个链表划分为了两个部分,后面那个部分无法再访问了,对于一些语言,比如C语言,没有手动释放内存空间就会导致内存泄露,删除链表同样也需要注意内存的释放。(Java有自动管理内存的虚拟机,就不用考虑这些)
插入结点正确的书写应该如下:
x->next = p->next;
p->next = x;
其实这种写法还有问题,下面哨兵结点会给出分析。
2.2.3 增加不存储值的头结点简化链表操作
-
插入操作时,如果是空链表就需要增加一段特殊逻辑处理这种情况:
if (head == null) { head = new_node; }
-
删除操作,如果在处理最后一个结点我们也需要特殊处理:
if (head->next == null) { head = null; }
你会发现链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。有没有什么方式简化操作呢?
可以通过引入一个哨兵结点,不存储任何值,不管链表是否为空,head指针一直指向这个哨兵结点。把这种带有哨兵结点的链表称为带头链表。如下图所示:
因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑了,就不需要做特殊的判断处理了。
哨兵简化编码的技巧在很多算法中都有用到,比如插入排序,归并排序。
2.2.4 注意处理边界条件
可以尝试造以下几种case来确定边界条件是否正确:
- 如果链表为空时,代码是否能正常工作?
- 如果链表只包含一个结点时,代码是否能正常工作?
- 如果链表只包含两个结点时,代码是否能正常工作?
- 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
2.2.5 举例画图辅助思考
当涉及到链表指针变化很复杂时,可以尝试通过举例法和画图法找出各种情况的变化规则。
2.2.5 多多练习,孰能生巧
可以尝试反复练习下以下几种链表的操作,达到孰能生巧的地步,通过下面联系,你以后应该不会再害怕写链表代码,这里我贴出了力扣的跳转链接:
3 栈
一种操作受限的线性表数据结构,特点是后进者先出,先进者后出。
举个简单的例子先了解下栈,如下图所示:
叠盘子的过程就和栈的思想有异曲同工之妙,放盘子是从下往上一个个放,取是从上往下一个个取。这就是典型的栈结构,先进后出,后进先出。
什么场景可以考虑用栈?
当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,我们就应该首选“栈”这种数据结构。
3.1 栈的实现
- 通过数组实现,叫做顺序栈;
- 通过链表实现,叫做链式栈。
栈的实现空间复杂度为O(1),入栈和出栈只涉及栈顶的个别数据,时间复杂度也是O(1)。
3.1.1 固定容量的顺序栈实现
特点:容量首次初始化就固定了,不支持动态扩容。
public class ArrayStack {
// 存储栈元素的容器
private String[] items;
// 当前栈中元素个数
private int count;
// 栈的容量大小
private int n;
// 初始化栈,实际上是申请一个大小为n的数组空间
public ArrayStack(int n) {
this.items = new String[n];
this.n = n;
this.count = 0;
}
/**
* 入栈操作
* @param item 要入栈的元素
* @return true->操作成功 false->操作失败
*/
public boolean push(String item) {
// 先判断栈的容量是否足够入栈,不够直接返回false,入栈失败
if (n == count) {
return false;
}
// 将item放到下标为count的位置,并且count加一
items[count] = item;
++count;
return true;
}
/**
* 出栈操作
* @return 出栈的元素
*/
public String pop() {
// 栈中不存在元素,直接返回null
if (0 == count) {
return null;
}
// 弹出栈顶元素,并且将栈内元素数量减一
String tmp = items[count-1];
--count;
return tmp;
}
}
3.1.2 链式栈的实现
特点:栈的大小不受限制,但需要存储next指针,内存消耗过多。
public class LinkedListStack {
/**
* 初始化栈的顶部结点->对应链表的头部结点
* 需要保证top一直指向最新插入的结点
*/
private Node top = null;
/**
* 入栈操作
* @param value 入栈的元素
*/
public void push(int value) {
// 初始化要入栈的结点
Node newNode = new Node(value, null);
// 判断栈是否为空
if (null == top) {
top = newNode;
} else {
// 将新结点插入到头部
newNode.next = top;
top = newNode;
}
}
/**
* 出栈操作
* @return 出栈元素,-1表示栈中没有数据
*/
public int pop() {
if (null == top) {
return -1;
}
// 获取到栈顶元素的值
int val = top.data;
// 将top指向top的下一个结点,下一个结点升级为栈顶元素
top = top.next;
return val;
}
/**
* 打印栈中元素,从栈顶向栈底遍历
*/
public void printAll() {
Node p = top;
while (p != null) {
System.out.print(p.data + " ");
p = p.next;
}
System.out.println();
}
/**
* 定义链表的结点
*/
private static class Node {
// 结点存储的数据
private int data;
// 后继指针next
private Node next;
public Node(int data, Node next) {
this.data = data;
this.next = next;
}
public int getData() {
return data;
}
}
}
3.1.3 支持动态扩容的顺序栈实现
支持动态扩容,和链式栈比,同时不需要消耗额外的next指针内存
顺序栈的底层是数组实现的,想要实现动态扩容,只需要底层数组支持动态扩容。
前面数组扩容说过,想要实现数组扩容只需要先申请一个更大的数组,将原来的数据搬到新数组中就可以了,如下图所示,元素入栈数组容量不够涉及扩容操作:
来分析下入栈、出栈操作的时间复杂度。
出栈操作时间复杂度
出栈不会涉及内存的重新申请和数据的搬移,所以出栈的时间复杂度仍然是O(1)。
入栈操作的时间复杂度
-
最好情况时间复杂度:当栈中有空闲空间,入栈的时间复杂度仍未O(1);
-
最坏情况时间复杂度:当空间不够时,就需要重新申请内存和数据搬移,复杂度这时为O(n);
-
平均时间复杂度:
假设栈的大小为k,每次扩容都是原数组长度的2倍,现在有2k个元素需要入栈并且整个过程只涉及入栈操作,每次入栈操作的时间复杂度变化如下:
可以很清晰发现,每次扩容时O(k)的时间复杂度可以均摊到后面k-1个元素入栈的O(1)时间复杂度上,符合均摊时间复杂度的特征,所以平均时间复杂度接近O(1)。
3.2 栈的应用
栈作为一个比较基础的数据结构,应用场景还是蛮多的。
有下面几种常见的应用场景:
- 函数调用栈;
- 表达式求值;
- 括号匹配问题。
3.2.1 函数调用栈
操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。
比如下面这段代码:
int main() {
int a = 1;
int ret = 0;
int res = 0;
ret = add(3, 5);
res = a + ret;
printf("%d", res);
reuturn 0;
}
int add(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}
执行到add()函数时,函数调用栈情况如下图所示:
3.2.2 表达式求值
一个只包含加减乘除四则运算算术表达式,比如34+13*9+44-12/3,计算机是怎样计算的呢?
通过两个栈实现,一个保存操作数栈,一个保存运算符栈;
从左向右遍历:
- 当遇到数字,我们就直接压入操作数栈;
- 当遇到运算符,与运算符栈的栈顶元素进行比较:
- 如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;
- 如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取2个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。
具体计算思路如下图所示:
代码参考示例如下:
public class ArithmeticExpression {
/**
* 34+13*9+44-12/3
* @param expression 算术表达式
* @return
*/
public int expressionCompute(String expression) {
Stack<Integer> figureStack = new Stack<>();
Stack<Character> operateStack = new Stack<>();
char[] chars = expression.toCharArray();
for (int i = 0; i < chars.length;) {
char ch = chars[i];
// 操作符处理
if (ch < '0' || ch > '9') {
// 当前操作符优先级若小于或等于操作符栈顶操作符
while (!operateStack.isEmpty() && !operatePriorityCompare(ch, operateStack.peek())) {
int num2 = figureStack.pop();
int num1 = figureStack.pop();
Character operate = operateStack.pop();
figureStack.push(compute(num1, num2, operate));
}
operateStack.push(ch);
i++;
} else {
// 如果是数值需要判断连续的字符是否都是数字
StringBuilder temp = new StringBuilder(String.valueOf(ch));
while (++i < chars.length) {
char chTmp = chars[i];
if (chTmp < '0' || chTmp > '9') {
break;
}
temp.append(chTmp);
}
int a = Integer.parseInt(temp.toString());
figureStack.push(a);
}
}
while (!operateStack.isEmpty()) {
int num2 = figureStack.pop();
int num1 = figureStack.pop();
Character operate = operateStack.pop();
figureStack.push(compute(num1, num2, operate));
}
return figureStack.pop();
}
/**
* 操作优先级比较
* @param operateA 操作A
* @param operateB 操作B
* @return 优先级 operateA大于operateB返回true 小于或等于返回false
*/
private boolean operatePriorityCompare(char operateA, char operateB) {
int aPriority = operatePriorityCompute(operateA);
int bPriority = operatePriorityCompute(operateB);
return aPriority > bPriority;
}
private int operatePriorityCompute(char operate) {
switch (operate) {
case '+':
case '-':
return 0;
case '*':
case '/':
return 1;
default:
throw new UnsupportedOperationException("暂不支持该操作");
}
}
private int compute(int num1, int num2, char operate) {
switch (operate) {
case '+':
return num1 + num2;
case '-':
return num1 - num2;
case '*':
return num1 * num2;
case '/':
return num1 / num2;
}
throw new UnsupportedOperationException("暂不支持该操作");
}
3.2.3 括号匹配问题
借助栈来检查表达式中的括号是否匹配。
比如,{[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式。
可以用栈来解决,从左到右依次扫描字符串:
-
当扫描到左括号时,则将其压入栈中;
-
当扫描到右括号时,从栈顶取出一个左括号,
如果能够匹配,则继续扫描剩下的字符串。
遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
-
当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。
代码参考示例:
public class BracketMatching {
/**
* 遇左括号入栈,遇右括号判断是否和栈顶括号是一套,
* 是则弹出栈顶元素,最好弹完栈顶元素若栈为空则是匹配的
* @param expression
* @return
*/
public boolean bracketMatching(String expression) {
Stack<Character> stack = new Stack<>();
for (int index = 0; index < expression.length(); index++) {
switch (expression.charAt(index)) {
case '(':
case '{':
case '[':
stack.push(expression.charAt(index));
break;
case ')':
if (!stack.isEmpty() && stack.peek() == '(') {
stack.pop();
}
break;
case '}':
if (!stack.isEmpty() && stack.peek() == '{') {
stack.pop();
}
break;
case ']':
if (!stack.isEmpty() && stack.peek() == '[') {
stack.pop();
}
break;
default:
throw new UnsupportedOperationException("暂不支持这种括号");
}
}
return stack.isEmpty();
}
}
4 队列
一种操作受限的线性表数据结构,特点是先进先出,后进后出。
最基本的两个操作:入队enqueue()和出队dequeue()
4.1 队列的实现
队列也可以通过数组和链表实现,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。
4.1.1 顺序队列的实现
代码如下所示:
public class ArrayQueue {
// 队列底层通过数组实现
private String[] items;
// 队列的大小,默认为0
private int n = 0;
// 队头下标
private int head = 0;
// 队列尾结点的位置
private int tail = 0;
// 初始化队列大小
public ArrayQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
/**
* 入队操作
* @param item 元素
* @return true:操作成功 false:操作失败
*/
public boolean enqueue(String item) {
// 队列满了,不能继续入队了
if (n == tail) {
return false;
}
items[tail] = item;
++tail;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) return null;
String ret = items[head];
++head;
return ret;
}
}
实现顺序队列需要两个指针,一个是head指针,指向队头;一个是tail指针,指向队尾。
如下图所示,a,b,c,d依次入队后头尾指针的位置:
当调用2次出队后,头尾指针变化如下图所示:
可以很清楚看到,数组空间的利用率其实并不高,当head指针移动到数组最右边,即使数组有空闲的空间,也无法继续做入队操作了。
可以通过数据搬移的方式提高内存利用率,当在元素入队空间不够时,可以尝试触发一次数据的搬移操作。
入队操作代码修改后如下所示:
/**
* 入队操作
* @param item
* @return
*/
public boolean enqueueV2(String item) {
// tail == n表示队列末尾没有空间了
if (tail == n) {
// tail ==n && head==0,表示整个队列都占满了
if (head == 0) {
return false;
}
// 数据搬移
for (int i = head; i < tail; ++i) {
items[i-head] = items[i];
}
// 搬移完之后重新更新head和tail
tail -= head;
head = 0;
}
items[tail] = item;
++tail;
return true;
}
当队列的tail指针移动到数组的最右边后,如果有新的数据入队,我们可以将head到tail之间的数据,整体搬移到数组中0到tail-head的位置。出队操作的时间复杂度是O(1),入队的时间复杂度采用均还分析法仍是O(1)。
4.1.2 链式队列的实现
基于链表的实现,我们同样需要两个指针:head指针和tail指针。分别指向链表的第一个结点和最后一个结点。如下图所示:
代码实现如下:
public class LinkedListQueue {
// 队列的队首
private Node head = null;
// 队列的队尾
private Node tail = null;
// 入队操作
public void enqueue(String value) {
if (null == tail) {
Node newNode = new Node(value, null);
head = newNode;
tail = newNode;
} else {
tail.next = new Node(value, null);
tail = tail.next;
}
}
// 出队
public String dequeue() {
if (null == head) {
return null;
}
String value = head.data;
head = head.next;
if (head == null) {
tail = null;
}
return value;
}
// 从队头打印到队尾
public void printAll() {
Node p = head;
while (p != null) {
System.out.print(p.data + " ");
p = p.next;
}
System.out.println();
}
// 结点的定义
private static class Node {
// 结点存储的数据
private String data;
// 结点的后继next指针
private Node next;
public Node(String data, Node next) {
this.data = data;
this.next = next;
}
public String getData() {
return data;
}
}
}
4.1.3 循环队列
上面在说顺序队列时,当tail==n,也就是队列满时,会有数据搬移,这样入队的性能很受影响,我们考虑下循环队列看能否解决这个问题?
循环队列如下图所示:
在a,b依次入队之后,循环队列中的元素就变成了下面的样子:
这样成功避免了数据搬移操作。
队空和队满条件确定
队空判断条件还是head==tail。
队满条件:
当tail=3,head=4,n=8,是一种队满的情况,得出规律:(3+1)%8=4,很容易得出队满的条件:(tail+1)%n=head。
当队列满的时候,tail指向的位置是没有存储数据的,所以,循环队列会浪费一个数组的存储空间。
代码如下所示:
public class CircularQueue {
// 队列底层容器
private String[] items;
// 队列容量
private int n = 0;
// 队头下标
private int head = 0;
// 队尾下标
private int tail = 0;
// 申请一个大小为capacity的数组
public CircularQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队操作
public boolean enqueue(String item) {
// 队列满了
if ((tail + 1) % n == head) {
return false;
}
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出队操作
public String dequeue() {
// 队列为空
if (head == tail) {
return null;
}
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
4.1.4 阻塞队列和并发队列
阻塞队列:在队列基础上增加了阻塞操作。
- 队列为空时,从队头取数据会被阻塞,直到队列中有数据才返回;
- 队列已经满了,那么入队操作会被阻塞,直到队列中有空闲位置再入队,然后返回。
单消费者
阻塞队列实现的“生产者 - 消费者模型”,可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。
多消费者
可以配置多消费者来提高数据处理能力,但因为是多线程操作队列,这时会存在线程安全问题。
线程安全的队列被称为并发队列。如何实现呢?
-
直接在入队和出队操作上加锁;
锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。
-
通过基于数组的循环队列+CAS
4.2 线程池中的队列思想
实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。
线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理?
- 非阻塞的处理方式,直接拒绝任务请求;
- 阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。公平考虑,可以使用队列来存储排队的请求。
- 基于链表的实现,相当于支持无限排队的无界队列(unbounded queue),可能导致请求过多,处理的响应时间增长;
- 基于数组的实现,有界队列(bounded queue),基于数组实现的有界队列(bounded queue)。