线性数据结构

转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!

数组 #

  • 数组(Array)是一种常见数据结构,由相同类型的元素(element)组成,并且是使用一块连续的内存来存储
  • 直接可以利用元素的**索引(index)**可以计算出该元素对应的存储地址
  • 数组的特点是:提供随机访问并且容量有限

假设数组长度为n:
访问:O(1) //访问特定位置的元素

插入:O(n) //最坏的情况插入在数组的首部并需要移动所有元素

删除:O(n) //最坏的情况发生在删除数组的开头并需要移动第一元素后面所有的元素时

ly-20241212141849981

链表 #

链表简介 #

  • 链表(LinkedList)虽然是一种线性表,但是并不会按线性的顺序存储数据,使用的不是连续的内存空间来存储数据

  • 链表的插入删除操作的复杂度为O(1),只需要直到目标位置元素的上一个元素即可。但是,在查找一个节点或者访问特定位置的节点的时候复杂度为O(n)

  • 使用链表结构可以克服数组需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理

    但链表不会节省空间,相比于数组会占用更多空间,因为链表中每个节点存放的还有指向其他节点的指针。除此之外,链表不具有数组随机读取的优点

链表分类 #

单链表双向链表循环链表双向循环链表

假设链表中有n个元素
访问:O(n) //访问特地给位置的元素

插入删除:O(1) //必须要知道插入元素的位置

单链表 #

  • 单链表只有一个方向,结点只有一个后继指针next指向后面的节点。因此,链表这种数据结构通常在物理内存上是不连续
  • 我们习惯性地把第一个结点叫做头结点,链表通常有一个不保存任何值的head节点(头结点),通过头结点我们可以遍历整个链表,尾结点通常指向null
  • 如下图 ly-20241212141850281

循环链表 #

  • 循环链表是一种特殊的单链表,和单链表不同的是循环链表的尾结点不是指向null,而是指向链表的头结点
  • 如图 ly-20241212141850545

双向链表 #

  • 双向链表包含两个指针,一个prev指向前一个节点,另一个next指向
  • 如图 ly-20241212141850726

双向循环链表 #

双向循环链表的最后一个节点的next指向head,而head的prev指向最后一个节点,构成一个环

ly-20241212141850919

应用场景 #

  • 如果需要支持随机访问的话,链表无法做到
  • 如果需要存储的数据元素个数不确定,并且需要经常添加删除数据的话,使用链表比较合适
  • 如果需要存储的数据元素的个数确定,并且不需要经常添加删除数据的话,使用数组比较合适

数组 vs 链表 #

  • 数组支持随机访问,链表不支持
  • 数组使用的是连续内存空间 对CPU缓存机制友好,链表则相反
  • 数组的大小固定,而链表则天然支持动态扩容。如果生命的数组过小,需要另外申请一个更大的内存空间存放数组元素,然后将原数组拷贝进去,这个操作比较耗时

#

栈简介 #

  • 栈(stack)只允许在有序的线性数据集合一端(称为栈顶top)进行加入数据(push)移除数据(pop)。因而按照**后进先出(LIFO,Last In First Out)**的原理运作。
  • 栈中,pushpop的操作都发生在栈顶
  • 栈常用一维数组链表来实现,用数组实现的叫顺序栈,用链表实现的叫做链式栈

假设堆栈中有n个元素。 访问:O(n)//最坏情况 插入删除:O(1)//顶端插入和删除元素

如图:
ly-20241212141851089

栈的常见应用场景 #

当我们要处理的数据,只涉及在一端插入删除数据,并且满足后进先出(LIFO,LastInFirstOut)的特性时,我们就可以使用这个数据结构。

实现浏览器的回退和前进功能 #

我们只需要使用两个栈(Stack1 和 Stack2)和就能实现这个功能。比如你按顺序查看了 1,2,3,4 这四个页面,我们依次把 1,2,3,4 这四个页面压入 Stack1 中。当你想回头看 2 这个页面的时候,你点击回退按钮,我们依次把 4,3 这两个页面从 Stack1 弹出,然后压入 Stack2 中。假如你又想回到页面 3,你点击前进按钮,我们将 3 页面从 Stack2 弹出,然后压入到 Stack1 中。示例图如下
ly-20241212141851260

检查符号是否承兑出现 #

给定一个包括 '('')''{''}''['']' 的字符串,判断该字符串是否有效

有效字符串需满足:

  1. 左括号必须用相同类型的右括号闭合。
  2. 左括号必须以正确的顺序闭合

比如 “()"、”()[]{}"、"{[]}" 都是有效字符串,而 “(]” 、"([)]" 则不是。

这个问题实际是 Leetcode 的一道题目,我们可以利用栈 Stack 来解决这个问题。

  1. 首先我们将括号间的对应规则存放在 Map 中,这一点应该毋容置疑;
  2. 创建一个栈。遍历字符串,如果字符是左括号就直接加入stack中,否则stack 的栈顶元素这个括号做比较,如果不相等就直接返回 false。遍历结束,如果stack为空,返回 true
public boolean isValid(String s){
    // 括号之间的对应规则
    HashMap<Character, Character> mappings = new HashMap<Character, Character>();
    mappings.put(')', '(');
    mappings.put('}', '{');
    mappings.put(']', '[');
    Stack<Character> stack = new Stack<Character>();
    char[] chars = s.toCharArray();
    for (int i = 0; i < chars.length; i++) {
        if (mappings.containsKey(chars[i])) {
            char topElement = stack.empty() ? '#' : stack.pop();
            if (topElement != mappings.get(chars[i])) {
                return false;
            }
        } else {
            stack.push(chars[i]);
        }
    }
    return stack.isEmpty();
}

反转字符串 #

将字符串中的每个字符先入栈再出栈就可以了。

维护函数调用 #

最后一个被调用的函数必须先完成执行,符合栈的 后进先出(LIFO, Last In First Out) 特性。

栈的实现 #

  • 栈既可以通过数组实现,也可以通过链表实现。两种情况下,入栈出栈的时间复杂度均为O(1)

  • 下面使用数组下实现栈,具有push()pop() (返回栈顶元素并出栈)、peek() (返回栈顶元素不出栈)、isEmpty()size() 这些基本的方法

    每次入栈前先判断栈容量是否够用,如果不够用就用Arrays.copyOf() 进行扩容

    public class MyStack {
        private int[] storage;//存放栈中元素的数组
        private int capacity;//栈的容量
        private int count;//栈中元素数量
        private static final int GROW_FACTOR = 2;
    
        //不带初始容量的构造方法。默认容量为8
        public MyStack() {
            this.capacity = 8;
            this.storage=new int[8];
            this.count = 0;
        }
    
        //带初始容量的构造方法
        public MyStack(int initialCapacity) {
            if (initialCapacity < 1)
                throw new IllegalArgumentException("Capacity too small.");
    
            this.capacity = initialCapacity;
            this.storage = new int[initialCapacity];
            this.count = 0;
        }
    
        //入栈
        public void push(int value) {
            if (count == capacity) {
                ensureCapacity();
            }
            storage[count++] = value;
        }
    
        //确保容量大小
        private void ensureCapacity() {
            int newCapacity = capacity * GROW_FACTOR;
            storage = Arrays.copyOf(storage, newCapacity);
            capacity = newCapacity;
        }
    
        //返回栈顶元素并出栈
        private int pop() {
            if (count == 0)
                throw new IllegalArgumentException("Stack is empty.");
            count--;
            return storage[count];
        }
    
        //返回栈顶元素不出栈
        private int peek() {
            if (count == 0){
                throw new IllegalArgumentException("Stack is empty.");
            }else {
                return storage[count-1];
            }
        }
    
        //判断栈是否为空
        private boolean isEmpty() {
            return count == 0;
        }
    
        //返回栈中元素的个数
        private int size() {
            return count;
        }
    
    }
    /*----
    MyStack myStack = new MyStack(3);
    myStack.push(1);
    myStack.push(2);
    myStack.push(3);
    myStack.push(4);
    myStack.push(5);
    myStack.push(6);
    myStack.push(7);
    myStack.push(8);
    System.out.println(myStack.peek());//8
    System.out.println(myStack.size());//8
    for (int i = 0; i < 8; i++) {
        System.out.println(myStack.pop());
    }
    System.out.println(myStack.isEmpty());//true
    myStack.pop();//报错:java.lang.IllegalArgumentException: Stack is empty. 
    */
    

队列 #

队列简介 #

  • 队列是**先进先出(FIFO,First In,First Out)**的线性表

  • 通常用链表数组来实现,用数组实现的队列叫做顺序队列,用链表实现的队列叫做链式队列

  • 队列只允许在后端(rear)进行插入操作也就是入队enqueue,在前端(front)进行删除操作也就是出队 dequeue

  • 队列的操作方式和堆栈类似,唯一的区别在于队列只允许新数据在后端进行添加(不允许在后端删除)

    假设队列中有n个元素。 访问:O(n)//最坏情况 插入删除:O(1)//后端插入前端删除元素

    队列

队列分类 #

单队列 #

这是常见的队列,每次添加元素时,都是添加到队尾。单队列又分为顺序队列(数组实现)链式队列(链表实现)

顺序队列存在假溢出:即明明有位置却不能添加

假设下图是一个顺序队列,我们将前两个元素 1,2 出队,并入队两个元素 7,8。当进行入队、出队操作的时候,front 和 rear 都会持续往后移动,当 rear 移动到最后的时候,我们无法再往队列中添加数据,即使数组中还有空余空间,这种现象就是 ”假溢出“ 。除了假溢出问题之外,如下图所示,当添加元素 8 的时候,rear 指针移动到数组之外(越界)
ly-20241212141851614

为了避免当只有一个元素的时候,队头和队尾重合使处理变得麻烦,所以引入两个指针,front 指针指向对头元素(不是头结点),rear 指针指向队列最后一个元素的下一个位置,这样当 front 等于 rear 时,此队列不是还剩一个元素,而是空队列。——From 《大话数据结构》
(当只有一个元素时,front 指向0,rear指向1)

循环队列 #

循环队列可以解决顺序队列的假溢出和越界问题。解决办法就是:从头开始,这样也就会形成头尾相接的循环,这也就是循环队列名字的由来。 ly-20241212141851784 (超出的时候,将rear指向0下标)。之后再添加时,向后移动即可

  • 顺序队列中,我们说 front==rear 的时候队列为空,循环队列中则不一样,也可能为满,如上图所示。解决办法有两种:
    1. 可以设置一个标志变量 flag,当 front==rear 并且 flag=0 的时候队列为空,当front==rear 并且 flag=1 的时候队列为满。
    2. 队列为空的时候就是 front==rear队列满的时候,我们保证数组还有一个空闲的位置,rear 就指向这个空闲位置,如下图所示,那么现在判断队列是否为满的条件就是: (rear+1) % QueueSize= frontly-20241212141851957 其实也就是换一个定义罢了

常见应用场景 #

当我们需要按照一定顺序来处理数据的时候可以考虑使用队列这个数据结构

  • 阻塞队列: 阻塞队列可以看成在队列基础上加了阻塞操作的队列。当队列为空的时候,出队操作阻塞,当队列满的时候,入队操作阻塞。使用阻塞队列我们可以很容易**实现“生产者 - 消费者“**模型
  • 线程池中的请求/任务队列: 线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理呢?答案是将这些请求放在队列中,当有空闲线程的时候,会循环中反复从队列中获取任务来执行。队列分为无界队列(基于链表)和有界队列(基于数组)。无界队列的特点就是可以一直入列,除非系统资源耗尽,比如 :FixedThreadPool 使用无界队列 LinkedBlockingQueue。但是有界队列就不一样了,当队列满的话后面再有任务/请求就会拒绝,在 Java 中的体现就是会抛出**java.util.concurrent.RejectedExecutionException** 异常。
  • Linux 内核进程队列(按优先级排队)
  • 现实生活中的派对,播放器上的播放列表;
  • 消息队列
  • 等等……