1:理解什么是线性表
2:掌握数组数据结构,理解 ArrayList 的源码
3:掌握链表数据结构,理解 LinkedList 的源码
4:掌握栈这种数据结构,理解 Stack 的部分源码
5:掌握队列这种数据结构
很多教材或者教程在开篇的时候都会来介绍这两个概念,但是概念毕竟是抽象的,所以我们不需要死扣定义,毕竟我们不是为了考试而学的,但这并不是说我们不需要理解其概念,我们只是说不要陷入概念的怪圈。下面我们来介绍一下相关概念:
1.数据结构包括数据对象集以及它们在计算机中的组织方式,即它们的逻辑结构和物理存储结构,一般我们可以认为数据结构指的是一组数据的存储结构。
2.算法就是操作数据的方法,即如何操作数据效率更高,更节省资源。
这只是抽象的定义,我们来举一个例子,你有一批货物需要运走,你是找小轿车来运还是找卡车来运?这就是数据结构的范畴,选取什么样的结构来存储;至于你货物装车的时候是把货物堆放在一起还是分开放这就是算法放到范畴了,如何放置货物更有效率更节省空间。
数据结构和算法看起来是两个东西,但是我们为什么要放在一起来说呢?那是因为数据结构和算法是相辅相成的,数据结构是为算法服务的,而算法要作用在特定的数据结构之上,因此,我们无法孤立数据结构来讲算法,也无法孤立算法来讲数据结构。
在学习算法之前我们先来学习几个最基本的数据结构,这是后续学习其他数据结构和算法的基础,也就是我们今天要来学习的:数组,链表,栈,队列。这个四个数据结构又可称之为线性表数据结构。
所谓的线性表,就是将数据排成像一条长线一样的结构,数组,链表,栈,队列都是线性表结构,线性表上的每个数据最多只有前后两个方向,下面以一幅图的形式来展现一下线性表结构
与这种线性结构对应的就是非线性结构,比如后续要学习的数,堆,图等,在这些非线性数据结构中,数据之间并不是简单的前后关系,如下图:
数组是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据。这里我们要抽取出三个跟数组相关的关键词:线性表,连续内存空间,相同数据类型;数组具有连续的内存空间,存储相同类型的数据,正是该特性使得数组具有一个特性:随机访问。但是有利有弊,这个特性虽然使得访问数组边得非常容易但是也使得数组插入和删除操作会变得很低效,插入和删除数据后为了保证连续性,要做很多数据搬迁工作。
所谓的数组的逻辑结构指的是我们可以用什么的表示方式来描述数组元素,比如有一个数组 a,数组中有 n 个元素,我们可以用(a1,a2,a3,.....an)来描述数组中的每个元素,当然后面我们会将具体如何访问数组中的每个元素。数组的物理结构指的是数组元素实际的存储形式,当然了从概念我们可以看出数组的物理存储空间是一块连续的内存单元,为了说明白这个事情,这里给大家准备了一副图。
注:int 类型占4个字节,一个字节即一个内存单元;
我们知道,计算机给每个内存单元都分配了一个地址,通过地址来访问其数据,因此要访问数组中的某个元素时,首先要经过一个寻址公式计算要访问的元素在内存中的地址: a[i] = baseAddress + i * dataTypeSize 。
其中 dataTypeSize 代表数组中元素类型的大小,在这个例子中,存储的是 int 型的数据,因此 dataTypeSize=4 个字节。
那数组插入有没有相对优化的方案呢?
如果数组中的数据是有序的,我们在某个位置插入一个新的元素时,就必须按照刚才的方法搬移 k 之后的数据。但是,如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。在这种情况下,如果要将某个数组插入到第 k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。这种处理思想会在快排中用到。
如果我们要删除第 k 个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了。
实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?
举个例子,数组 a[6] 中存储了 6 个元素:a1,a2,a3,a4,a5,a6。现在,我们要依次删除 a1,a2 这两个元素。
这里有一段非常简短的代码
public static void main(String[] args) {
//初始化集合
List list = new ArrayList();
//向集合中添加元素
list.add("itcast");
}
下面我们以断点调试的方式查看一下,当我们创建集合对象的时候发生了什么事
通过分析可以知道:
1 :创建 ArrayList 时采用默认的构造函数创建集合然后往里添加元素,第一次添 加时将数组扩容到 10 的大小,之后添加元素都不会在扩容,直到第 11 次添加然 后扩容 1.5 倍取整,此后如果需要扩容都是 1.5 倍取整,但是扩容到的最大值是 Integer.MAX_VALUE2 :每次扩容时都会创建一个新的数组,然后将原数组中的数据搬移到新的数组中,所以回到开始我们所说的: 如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小
我们可以查看 ArrayList 的带参构造函数:
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
//直接使用传递的容量大小创建数组,这样的话只会在不够存储的时候才会需要扩容
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
}
}
比如我们要从数据库中取出 10000 条数据放入 ArrayList。我们看下面这几行代码,你会发现,相比之下,事先指定数据大小可以省掉很多次内存申请和数据搬移操作。
ArrayList users = new ArrayList(10000);
for (int i = 0; i < 10000; ++i) {
users.add(xxx);
}
接下来我们写一段代码来分析一下从集合中获取元素
public static void main(String[] args) {
//初始化集合
List list = new ArrayList(1);
//向集合中添加元素
list.add("itcast");
//获取刚刚存入的元素
String ele = list.get(0);
}
此时我们在来回顾我们刚刚提到的链表的概念:物理存储单元上非连续、非顺序,元素的逻辑顺序是通过链表中的指针链接次序实现的,链表由一系列结点组成,每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
从我画的单链表图中,你应该可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址,有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点。
与数组一样,链表也支持数据的查找、插入和删除操作。
从图中可以看出来,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。
⑴ LinkedList 和 ArrayList 的比较
前面我们分析过ArrayList 的源码,现在又分析了LinkedList 源码,接下来将二者进行比较:
1.ArrayList 的实现基于数组,LinkedList 的实现基于双向链表
2.对于随机访问,ArrayList 优于LinkedList,ArrayList 可以根据下标对元素进行随机访问。而LinkedList 的每一个元素都依靠地址指针和它后一个元素连接在一起,在这种情况下,查找某个元素只能从链表头开始查询直到找到为止。
3.对于插入和删除操作,LinkedList 优于ArrayList ,因为当元素被添加到 LinkedList 任意位置的时候,不需要像ArrayList 那样重新计算大小或者是更新索引。
4.LinkedList 比 ArrayList 更占内存,因为LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。
⑵反转单链表
上一小节我们分析了LinkedList 的底层源码,那对于数据结构和算法来说我们不仅仅要掌握理论知识点,还要能够根据我们掌握的理论知识点去解题,达到对我们思维上的训练,接下来我们就以一道跟链表相关的面试来训练一下我们的思维及代码编写能力。
反转单链表:
英文版:https://leetcode.com/problems/reverse-linked-list/
中文版:https://leetcode-cn.com/problems/reverse-linked-list/
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) { val = x; }
* }
*/
class Solution {
public ListNode reverseList(ListNode head) {
ListNode prev = null; //前指针节点
ListNode curr = head; //当前指针节点
//每次循环,都将当前节点指向它前面的节点,然后当前节点和前节点后移
while (curr != null) {
ListNode nextTemp = curr.next; //临时节点,暂存当前节点的下一节点,用于后移
curr.next = prev; //将当前节点指向它前面的节点
prev = curr; //前指针后移
curr = nextTemp; //当前指针后移
}
return prev;
}
}
至此,链表部分的知识就学习完成了,接下来我们学习“栈”这种数据结构。
关于“栈”这种数据结构,它有一个典型的特点: 先进后出,后进先出;只要满足这种特点的数据结构我们就可以说这是典型的“栈”数据结构,我们一般将这个特点归纳为一个:后进先出,英文表示为: Last In First Out 即 LIFO。为了更好的理解栈这种数据结构,我们以一幅图的形式来表示,如下:
我们从栈的操作特点上来看,似乎受到了限制,的确,栈就是一种操作受限的线性表,只允许在栈的一端进行数据的插入和删除,这两种操作分别叫做入栈和出栈。当某个数据集合如果只涉及到在其一端进行数据的插入和删除操作,并且满足先进后出,后进先出的特性时,我们应该首选栈这种数据结构来进行数据的存储。
从对栈的定义中我们发现栈主要包含两个操作,入栈和出栈,也就是在栈顶插入一个元素和在栈底删除一个元素,那理解了这个定义之后,我们来思考一个问题,如何来手动实现一个栈呢?
实际上,栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈叫顺序栈,用链表实现的叫链式栈。接下来我们就这两种形式分别去实现一下。
以下是基于数组的顺序栈的实现代码
class ArrayStack {
// 栈大小
private int size;
// 默认栈容量
private int DEFAULT_CAPACITY=10;
// 栈数据
private Object[] elements;
private int MAX_ARRAY_SIZE = Integer.MAX_VALUE-8;
/**
* 默认构造创建大小为 10 的栈
*/
public ArrayStack(){
elements = new Object[DEFAULT_CAPACITY];
}
/**
* 通过指定大小创建栈
* @param capacity
*/
public ArrayStack(int capacity){
elements = new Object[capacity];
}
/**
* 入栈
* @param element
* @return
*/
public boolean push(Object element){
try {
checkCapacity(size+1);
elements[size++]=element;
return true;
}catch (RuntimeException e){
return false;
}
}
/**
* 检查栈容量是否还够
*/
private void checkCapacity(int minCapacity) {
if(elements.length - minCapacity < 0 ){
throw new RuntimeException("栈容量不够!");
}
}
/**
* 出栈
* @return
*/
public Object pop(){
if(size<=0){
return null;//栈为空则直接返回 null
}
Object obj = elements[size-1];
elements[--size] = null;
return obj;
}
/**
* 获取栈的大小
* @return
*/
public int size(){
return size;
}
}
class ArrayStackTest {
public static void main(String[] args) {
ArrayStack stack = new ArrayStack();
for (int i=0; i<13; i++) {
boolean push = stack.push(i);
System.out.println("第"+(i+1)+"次存储数据为:"+i+",存储结果是: "+push);
}
// stack.push(1);
for (int i=0; i<11; i++) {
Object pop = stack.pop();
System.out.println(pop);
}
}
}
刚才那个基于数组实现的栈,是一个固定大小的栈,也就是说,在初始化栈时需要事先指定栈的大小。当栈满之后,就无法再往栈里添加数据了。那我们如何基于数组实现一个可以支持动态扩容的栈呢?
你还记得,我们在数组那一节,是如何来实现一个支持动态扩容的数组的吗?当数组空间不够时,我们就重新申请一块更大的内存,将原来数组中数据统统拷贝过去。这样就实现了一个支持动态扩容的数组。
所以,如果要实现一个支持动态扩容的栈,我们只需要底层依赖一个支持动态扩容的数组就可以了。当栈满了之后,我们就申请一个更大的数组,将原来的数据搬移到新数组中。
下面是在前面顺序栈的基础之上添加了支持动态扩容后的代码:
class ArrayStack {
// 栈大小
private int size;
// 默认栈容量
private int DEFAULT_CAPACITY=10;
// 栈数据
private Object[] elements;
private int MAX_ARRAY_SIZE = Integer.MAX_VALUE-8;
/**
* 默认构造创建大小为 10 的栈
*/
public ArrayStack(){
elements = new Object[DEFAULT_CAPACITY];
}
/**
* 通过指定大小创建栈
* @param capacity
*/
public ArrayStack(int capacity){
elements = new Object[capacity];
}
/**
* 入栈
* @param element
* @return
*/
public boolean push(Object element){
try {
checkCapacity(size+1);
elements[size++]=element;
return true;
}catch (RuntimeException e){
return false;
}
}
/**
* 检查栈容量是否还够
*/
private void checkCapacity(int minCapacity) {
if(elements.length - minCapacity < 0 ){
//throw new RuntimeException("栈容量不够!");
grow(elements.length);
}
}
/**
* 扩容
* @param oldCapacity 原始容量
*/
private void grow(int oldCapacity) {
int newCapacity = oldCapacity+(oldCapacity>>1);
if(newCapacity-oldCapacity<0){
newCapacity = DEFAULT_CAPACITY;
}
if(newCapacity-MAX_ARRAY_SIZE>0){
newCapacity = hugeCapacity(newCapacity);
}
elements = Arrays.copyOf(elements,newCapacity);
}
private int hugeCapacity(int newCapacity) {
return (newCapacity>MAX_ARRAY_SIZE)? Integer.MAX_VALUE : newCapacity;
}
/**
* 出栈
* @return
*/
public Object pop(){
if(size<=0){
return null;//栈为空则直接返回 null
}
Object obj = elements[size-1];
elements[--size] = null;
return obj;
}
/**
* 获取栈的大小
* @return
*/
public int size(){
return size;
}
}
添加测试代码如下:
class ArrayStackTest {
public static void main(String[] args) {
ArrayStack stack = new ArrayStack(5);
for (int i=0; i<40; i++) {
boolean push = stack.push(i);
System.out.println("第"+(i+1)+"次存储数据为:"+i+",存储结果是:"+push);
}
// stack.push(1);
for (int i=0; i<11; i++) {
Object pop = stack.pop();
System.out.println(pop);
}
}
}
上一小节我们手动实现了基于数组的顺序栈,这一小节中我们来实现一下基于链表的链式栈,基于链表的栈跟基于数组的顺序栈有一个很大的区别就是链式栈天生就具备动态扩容的特点。
我们知道链表中的每一个元素都可以称之为节点,因此我们先创建一个节点对象如下:
class Node {
//前驱节点
public Node prev;
//节点数据
private Object data;
//后继节点
public Node next;
public Node(Node prev,Object data,Node next){
this.prev = prev;
this.data =data;
this.next = next;
}
public Object getData() {
return data;
}
}
/**
* 基于双向链表的链式栈实现
*/
class LinkedListStack {
// 栈大小
private int size;
//存储链表尾节点
private Node tail;
public LinkedListStack(){
this.tail = null;
}
/**
* 入栈
* @param data
* @return
*/
public boolean push(Object data){
Node newNode = new Node(tail,data,null);
if(size>0){
tail.next = newNode;
}
tail = newNode;
size++;
return true;
}
/**
* 出栈
* @return
*/
public Object pop(){
if((size-1) < 0){
//栈为空
return null;
}
Object data = tail.getData();
tail = tail.prev;
if(tail!=null){
tail.next = null;
}
size--;
return data;
}
}
从以上的实现我们可以看出,我们采用的是双向链表来实现的链式栈,入栈和出栈操作虽然都很快速,但是由于双向链表需要额外的存储空间来存储前驱指针,因此我们这段程序在空间资源的节省上显得力度不够,所以我们想能不能不用双向链表只用单向链表来解决这个问题呢?答案是肯定的,接下来我们就针对刚刚的代码做出改造。
首先改造我们的节点对象,每个节点对象中只存储数据和 next 指针,并且上面的实现我们是单独创建的节点对象,那现在我们将节点对象声明为栈的内部类。
/**
* 基于单链表实现的栈
*/
class StackBasedOnLinkedList {
// 存储链表头节点
private Node head;
public StackBasedOnLinkedList(){
this.head = null;
}
/**
* 入栈
* @param data
* @return
*/
public boolean push(Object data){
Node newNode = new Node(data,head);
head = newNode;
return true;
}
/**
* 出栈
* @return
*/
public Object pop(){
if(head==null){
return null;
}
Node topNode = head;
head = topNode.next;
topNode.next=null;
return topNode.data;
}
/**
* 节点对象
*/
private static class Node {
//节点数据
private Object data;
// next 指针
private Node next;
public Node(Object data,Node next){
this.data = data;
this.next = next;
}
public Object getData() {
return data;
}
}
}
然后我们编写测试类如下:
class TestStackBasedOnLinkedList {
public static void main(String[] args) {
StackBasedOnLinkedList stack = new StackBasedOnLinkedList();
for(int i=0;i<6;i++){
stack.push(i+"");
System.out.println("第"+(i+1)+"次入栈,入栈的值为:"+i);
}
for(int i=0;i<8;i++){
Object pop = stack.pop();
System.out.println("取出的结果:"+pop);
}
}
}
通过测试我们发现我们基于单链表实现的链式栈跟我们用双向链表实现的链式栈在功能上都是一样的,但是基于单链表实现的链式栈很明显更节约内存空间。
以上内容就是我们自己手动编程来实现的栈,这块内容希望大家自己能够手动编码实现,这对大家思维能力的训练和代码能力的提升都会有很大的帮助。
至此,栈这部分的知识我们就学习完了,接下来我们进入队列的学习。
队列的概念非常容易理解,我们拿日常生活中的一个场景来举例说明,我们去车站的窗口买票,那就要排队,那先来的人就先买,后到的人就后买,先来的人排到队头,后来的人排在队尾,不允许插队, 先进先出,这就是典型的队列。 队列先进先出的特点英文表示为: First In First Out 即 FIFO,为了更好的理解队列这种数据结构,我们以一幅图的形式来表示,并且我们将队列的特点和栈进行比较,如下:
从图中我们可以发现, 队列和栈一样都属于一种操作受限的线性表,栈只允许在一端进行操作,分别是入栈 push()和出栈 pop(),而队列跟栈很相似,支持的操作也有限,最基本的两个操作一个叫入队列 enqueue(),将数据插入到队列尾部,另一个叫出队列 dequeue(),从队列头部取出一个数据。
队列的概念很好理解,基本操作也很容易掌握。作为一种非常基础的数据结构,队列的应用也非常广泛,特别是一些具有某些额外特性的队列,比如循环队列、阻塞队列、并发队列。它们在很多偏底层系统、框架、中间件的开发中,起着关键性的作用。比如高性能队列 Disruptor【dɪsˈrʌptər】、 Linux 环形缓存都用到了循环并发队列,在 java 中, concurrent 并发包利用 ArrayBlockingQueue 来实现公平锁等。
在这节中我们要介绍几种常见的队列:顺序队列,链式队列,循环队列,阻塞队列,并发队列等,并且会针对其中某些队列作手动实现。
跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。同样, 用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。
我们先来看基于数组实现的顺序队列:
class ArrayQueue {
// 存储数据的数组
private Object[] elements;
//队列大小
private int size;
// 默认队列容量
private int DEFAULT_CAPACITY = 10;
// 队列头指针
private int head;
// 队列尾指针
private int tail;
private int MAX_ARRAY_SIZE = Integer.MAX_VALUE-8;
/**
* 默认构造函数 初始化大小为 10 的队列
*/
public ArrayQueue(){
elements = new Object[DEFAULT_CAPACITY];
initPointer(0,0);
}
/**
* 通过传入的容量大小创建队列
* @param capacity
*/
public ArrayQueue(int capacity){
elements = new Object[capacity];
initPointer(0,0);
}
/*** 初始化队列头尾指针
* @param head
* @param tail
*/
private void initPointer(int head,int tail){
this.head = head;
this.tail = tail;
}
/**
* 元素入队列
* @param element
* @return
*/
public boolean enqueue(Object element){
ensureCapacityHelper();
elements[tail++] = element;//在尾指针处存入元素且尾指针后移
size++;//队列元素个数加 1
return true;
}
private void ensureCapacityHelper() {
if (tail == elements.length) {//尾指针已越过数组尾端
//判断队列是否已满 即判断数组中是否还有可用存储空间
if (size < elements.length) {
if (head == 0) {
//扩容
grow(elements.length);
} else {
//进行数据搬移操作 将数组中的数据依次向前挪动直至顶部
for (int i = head; i < tail; i++) {
elements[i - head] = elements[i];
}
//数据搬移完后重新初始化头尾指针
initPointer(0, tail - head);
}
}
}
}
/**
* 扩容
* @param oldCapacity 原始容量
*/
private void grow(int oldCapacity) {
int newCapacity = oldCapacity+(oldCapacity>>1);
if(newCapacity-oldCapacity<0){
newCapacity = DEFAULT_CAPACITY;
}
if(newCapacity-MAX_ARRAY_SIZE>0){
newCapacity = hugeCapacity(newCapacity);
}
elements = Arrays.copyOf(elements,newCapacity);
}
private int hugeCapacity(int newCapacity) {
return (newCapacity>MAX_ARRAY_SIZE)? Integer.MAX_VALUE:newCapacity;
}
/**
* 出队列
* @return
*/
public Object dequeue(){
if(head==tail){
return null;//队列中没有数据
}
Object obj=elements[head++];//取出队列头的元素且头指针后移
size--;//队列中元素个数减 1
return obj;
}
/**
* 获取队列元素个数
* @return
*/
public int getSize() {
return size;
}
}
我们先来编写一个简单的测试类测试我们写好的代码:
class TestArrayQueue {
public static void main(String[] args) {
ArrayQueue queue = new ArrayQueue(4);
//入队列
queue.enqueue("itcast1");
queue.enqueue("itcast2");
queue.enqueue("itcast3");
queue.enqueue("itcast4");
//此时入队列应该走扩容的逻辑
queue.enqueue("itcast5");
queue.enqueue("itcast6");
//出队列
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
//此时入队列应该走数据搬移逻辑
queue.enqueue("itcast7");
//出队列
System.out.println(queue.dequeue());
//入队列
queue.enqueue("itcast8");
//出队列
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
//入队列
queue.enqueue("itcat9");
queue.enqueue("itcat10");
queue.enqueue("itcat11");
queue.enqueue("itcat12");
//出队列
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
}
}
先来查看控制台输出的运行结果:
itcast1
itcast2
itcast3
itcast4
itcast5
itcast6
itcast7
itcast8
null
itcat9
itcat10
通过控制台的输出结果我们可以发现基于数组的顺序队列确实满足先进先出的特点,接下来我们就一起来分析一下实现原理:
对于栈来说,我们只需要一个栈顶指针就可以了。但是队列需要两个指针(数组下标):一个是 head 指针,指向队头;一个是 tail 指针,指向队尾。每一次元素入队列操作,我们的 tail 指针就需要向后移动一位;每一次出队列操作,我们的 head指针就要向后移动一位。
但是当 tail 指针移动到数组末尾之后,由于入队列操作都是从数组尾部存入数据,那此时数组尾部已不能在插入数据了,我们就要根据情况采取不同的措施,第一种情况是数组中已经没有位置可以存储数据了那就要对数组进行扩容,第二种情况是数组中还有位置可以存储数据,只不过是数组尾端已不能再插入数据了,那此时就要将数组中的数据依次向前挪动,将数组尾部的位置空出来供入队列操作,详细的操作流程见下图:
那对于元素出队列来说,只要将 head 指针处的元素从数组中取出来,然后 head 指针后移即可,当然了如果 head 指针和 tail 指针如果重合了则表明队列中已经没有元素数据了此时直接返回 null 即可。
在上一小节中我们使用数组实现了一个队列,那在这一小节中我们使用链表来实现一个队列,通过之前的学习我们已经知道用链表实现的队列叫链式队列。
基于链表的实现,我们同样需要两个指针: head 指针和 tail 指针。它们分别指向链表的第一个结点和最后一个结点。 如图所示,入队时, tail->next= new_node, tail = tail->next;出队时, head= head->nex,如下图所示:
下面进行代码实现:
class LinkedListQueue {
//队列元素个数
private int size;
//头节点
private Node head;
//尾节点
private Node tail;
public LinkedListQueue(){
this.head = null;
this.tail = null;
}
/**
* 入队列
* @param data
* @return
*/
public boolean enqueue(Object data){
Node newNode = new Node(data,null);
if(tail == null){
tail = newNode;
head = newNode;
}else {
tail.next = newNode;
tail = newNode;
}
size++;
return true;
}
/**
* 出队列
* @return
*/
public Object dequeue(){
if(head==null){
return null;
}
Object data = head.data;
head = head.next;
if(head==null){
tail = null;
}
size--;
return data;
}
private static class Node{
//节点数据
private Object data;
//后继节点
private Node next;
public Node(Object data,Node next){
this.data = data;
this.next = next;
}
}
public int getSize() {
return size;
}
}
编写一个测试类进行测试:
class TestLinkedListQueue {
public static void main(String[] args) {
LinkedListQueue queue = new LinkedListQueue();
System.out.println(queue.dequeue());
queue.enqueue("itcast1");
queue.enqueue("itcast2");
queue.enqueue("itcast3");
queue.enqueue("itcast4");
queue.enqueue("itcast5");
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
queue.enqueue("itcast6");
queue.enqueue("itcast7");
queue.enqueue("itcast8");
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
}
}
运行结果如下:
null
itcast1
itcast2
itcast3
itcast4
itcast5
null
itcast6
itcast7
itcast8
通过控制台的输出结果我们可以发现基于链表的链式队列也满足先进先出的特点。
在这个实现中我们发现,循环队列满的时候数组中其实还是有一个位置可以进行数据的存储的,也就是说当下的循环队列的实现方式会浪费一个存储空间,那有没有什么更好的解决方案呢?这个问题留做课后思考题大家请先自行分析并实现一下。
上一章节中我们手动实现了顺序队列,链式队列及循环队列,那在 java 中是否有已经实现好的队列呢?在回答这个问题之前,我们先来聊一聊在实际的软件研发中哪些地方能够用到队列这种数据结构?
首先我们得回顾本章开始所讲到的队列的特点: 先进先出(FIFO),一般情况下,如果是对一些及时消息的处理,并且处理时间很短的情况下是不需要队列的,直接阻塞式的方法调用就可以了。但是如果在消息处理的时候特别费时间,这个时候如果有新消息来了,就只能处于阻塞状态,造成用户等待。这个时候便需要引入队列了。当接收到消息后,先把消息放入队列中,然后再用新的线程进行处理,这个时候就不会有消息阻塞了。所以队列用来存放等待处理元素的集合,这种场景一般用于缓冲、并发访问,及时消息通信,分布式消息队列等。
我们知道, CPU 资源是有限的,任务的处理速度与线程个数并不是线性正相关。相反,过多的线程反而会导致 CPU 频繁切换,处理性能下降。所以,线程池的大小一般都是综合考虑要处理任务的特点和硬件环境,来事先设置的。当我们向固定大小的线程池中请求一个线程时,如果线程池中没有空闲资源了,这个时候线程池如何处理这个请求?是拒绝请求还是排队请求?各种处理策略又是怎么实现的呢?
在今天的课程中我们主要学习了如下的几个内容:
(1)数组
数组是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据。数组通过下标来访问元素,数组访问时可以通过寻址公式快速定位元素位置,但是插入和删除操作为了保证数组的连续性要进行很多数据的搬移操作。
(2)链表
链表( Linked list)是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。链表在进行元素的插入和删除操作时的效率高,但是链表在进行元素获
取时的效率低,而链表又分为单链表,双向链表,循环链表,双向循环链表等。
(3)栈
栈满足先进后出,后进先出即 LIFO,属于一种操作受限的线性表,只允许在一端进行元素的插入和删除,分别是入栈和出栈,时间复杂度都是 O(1),栈可以基于数组实现也可以基于链表实现,分别是顺序栈和链式栈。
(4)队列
队列满足先进先出的特点即 FIFO,队列跟栈一样都属于操作受限的线性表,只不过队列是只允许在队列头获取元素,在队列尾插入元素,分别叫入队列和出队列,当然队列也可以基于数组和链表来实现,分别叫顺序队列和链式队列,在队列中还有一种循环队列可以解决顺序队列需要频繁搬移数据的问题。
1:手动分析一遍 ArrayList 的源码,比如 set 方法等等
2:手动分析一遍 LinkedList 的源码
3:自主完成单链表反转的面试题
4:手动分析一遍 Stack 的源码
5:自主实现基于数组且支持动态扩容的顺序栈
6:自主实现基于链表的链式栈
7:自主实现基于数组的顺序队列
8:自主实现基于链表的链式队列
9:自主实现一个基于数组的循环队列
10:完成课程中留下的课后思考题