数据结构(一)线性数据结构

36 篇文章 0 订阅

1. 数组

数组(Array) 是一种连续存储的线性数据结构。它由相同类型的元素(element)组成,并且是使用一块连续的内存空间来存储数据。我们可以使用元素的整型索引值(index)来计算出该元素对应的存储地址,但是数组尺寸不能改变。

数组的特点:

  • 容量有限,因为数组需要一块连续的内存空间;
  • 插入 / 删除,分两种情况,如果发生在数组头部,则由于需要将整个数组中的元素向后 / 向前移动,所以效率低下;如果发生在数组尾部,则不需要移动数组中的元素,效率较高;
  • 改 / 查时,由于是按照数组下标索引执行的,效率很高;

操作数组时的复杂度:

假如长度为 n。

访问:O(1)//访问特定位置的元素

插入:O(n )//最坏的情况是发生在数组的头部,需要移动所有元素时

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

数组的优缺点:

数组的下标寻址十分迅速,但内存是有限的,故数组的长度也是有限的,实际应用当中的数据往往十分庞大。

无序数组的查找最坏情况需要遍历整个数组,后来人们提出了二分查找,但是二分查找要求数组的构造一定是有序的,二分法查找解决了普通数组查找复杂度过高的问题。

任何一种数组无法解决的问题就是插入、删除操作比较复杂,因此,在一个增删查改比较频繁的数据结构中,数组不会被优先考虑。

2. 链表

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

链表操作的复杂度:

插入 / 删除 :复杂度为 O(1) ,只需要知道目标位置元素的上一个元素即可。

查询特定位置的元素 :复杂度为 O(n) 

链表与数组的区别:

  • 链表数据结构,弥补了数组需要预先知道数据大小的缺点,它可以充分利用计算机内存空间,实现灵活的内存动态管理。
  • 链表数据结构,相比于数组会占用更多的空间,因为链表中每个节点存放的还有指向其他节点的指针。
  • 链表不具有数组随机读取的优点。

链表分类:

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

2.1 单链表

单链表 单向链表只有一个后继指针 next ,指向后面的节点。链表数据结构通常在物理内存上是不连续的。链表通常都会有一个 head 头结点,它是第一个节点,不保存任何的值,通过头结点我们可以遍历整个链表;尾结点通常指向 null。

2.2 循环链表

循环链表 是一种特殊的单链表,和单链表不同的是,循环链表的尾结点不是指向 null,而是指向链表的头结点。

2.3 双向链表

双向链表 包含两个指针:prev 指向前一个节点,next 指向后一个节点。 

2.4 双向循环链表

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

 2.5. 数组 VS 链表

  • 数组支持随机访问,而链表不支持。
  • 数组使用的是连续内存空间,链表则相反。
  • 数组的大小固定,需要动态扩容的处理逻辑,这个操作是比较耗时的;而链表则天然支持动态扩容。

2.6 链表的应用场景

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

3. 栈

3.1 栈简介

(stack)只允许在有序的线性数据集合的一端(称为栈顶 top)进行加入数据(push)和 移除数据 (pop)。因而按照 后进先出 的原理运作。在栈中,push 和 pop 的操作都发生在栈顶。

常用一维数组或链表来实现,用数组实现的栈叫作 顺序栈 ,用链表实现的栈叫作 链式栈 。 

栈操作的复杂度分析

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

3.2 栈的应用场景

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

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

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

3.2.2 检查符号是否成对出现

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

有效字符串需满足:

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

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

解题思想:首先根据给出的字符,可以判断,给出的组合字符串有多种规则,而满足有效字符串的情况又大致可分为两种:1. 排列成对出现,也就是不存在穿插;2. 层级嵌套,即最里面的是一对,一层一层的往外,也都是一对一对的。好那么我们要怎么解呢?

首先我们需要提供一个对应规则出来,当然用 key-value 最合适了,像下面这样:

// 括号之间的对应规则
    HashMap<Character, Character> mappings = new HashMap<Character, Character>();
    mappings.put(')', '(');
    mappings.put('}', '{');
    mappings.put(']', '[');

然后我们要通过这个对应规则,并结合两种有效字符串的情况,分析如何编写这个题。首先我们需要一个容器,用于存储 给定字符串中的左括号。然后在从中取出来与下一个字符对比,那么这个存取的操作就需要一个后存先取的一个规则。所以我们可以通过 栈的操作来实现:

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])) {
            // 如果当前字符是个 "右括号",就去 stack 栈中寻找,
            // 刚刚加入进去的那个字符是否与该字符匹配成对。
            char topElement = stack.empty() ? '#' : stack.pop();
            if (topElement != mappings.get(chars[i])) {
                return false;
            }
        } else {
            // 如果当前字符是个 "左括号",就压入 stack 栈,等待下一次循环是 "右括号的时候",
            // 来取出值,进行匹配成对校验
            stack.push(chars[i]);
        }
    }
    return stack.isEmpty();
}

3.2.3 反转字符串

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

3.2.4 维护函数调用

最后一个被调用的函数必须先完成执行,符合栈的 后进先出 的特性。

3.3 栈的实现

既可以通过数组实现,也可以通过链表来实现。不管基于数组还是链表,入栈、出栈的时间复杂度都为 O(1)。

使用数组实现一个栈,其中包括 push() 、pop()、peek()、isEmpty()、size()。pop :返回栈顶元素并出栈,peek : 返回栈顶元素不出栈。

提示:每次入栈之前先判断栈的容量是否够用,如果不够用就用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;
    }

}

4. 队列

4.1 队列简介

队列 是 先进先出 的线性表。在具体应用中通常用链表或者数组来实现,用数组实现的队列叫作 顺序队列 ,用链表实现的队列叫作 链式队列 。队列只允许在后端 (rear) 进行插入操作也就是 入队 enqueue,在前端(front)进行删除操作也就是出队 dequeue。(front 、rear代表数组的下标)

队列的操作方式和堆栈 的唯一的区别在于队列只允许新数据在后端进行添加。

队列的操作复杂对解析:

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

4.2 队列的分类

  • 单队列
  • 循环队列

4.2.1 单队列

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

顺序队列 "假溢出" 的问题

之所以称为 "假溢出",是因为并不是真的溢出,而是队头有空位置,对尾却无位置可用,从而导致无法继续从队尾添加元素,最终导致所谓的 "假溢出" 现象。

"假溢出"  现象详解

当一个队列有n个元素时,则顺序存储的队列就需要建立一个长度大于n的数组,我们把队列中的所有元素存储在数组的前n个单元中,此时数组下标为0的一端即是队头。

对于添加元素是从队尾进行的,不涉及数组元素的移动,时间复杂度为 0(1)。

但是对于从队头移除元素,就需要向队头方向移动整个数组中的所有元素。但是这样的效率很低,时间复杂队为 0(n),有时候我们就想,为什么出队列的时候就一定要移动所有元素呢?如果不限制队列中的元素,必须存储在数组的前n个单元。那么出队的性能就会大大增加。也就是说队头不需要一定在下标为0的位置。

那么问题来了,这样就导致了前面有位置,但末尾无法添加的情况,这就是所谓的假溢出了。我们接下来进一步用图描述一下该现象:

 如上图所示如果出队的数量大于入队的数量就会造成队列的假溢出,比如c4,c5,c6入队,c3,c4出队的情况下依据判断堆满的条件,队列已经满了,但实际上根本没有满就是 "假溢出"。

总结一句话就是 "假溢出现象",是由我们对出队性能的需求造成的,那么有没有什么办法可以规避掉这个问题吗?答案是必须的,那就是循环队列

4.2.2 循环队列

循环队列可以解决顺序队列的假溢出和越界问题。解决办法就是:队列假溢出时在添加元素,就从头开始,这样在逻辑上也就会形成头尾相接的循环,这也就是循环队列名字的由来。

我们将 rear 指针指向数组下标为 0 的位置就不会有越界问题了。当我们再向队列中添加元素的时候, rear 向后移动。

在顺序队列中,我们知道 front == rear 的时候,表示队列为空;但在循环队列中则不一样, front == rear 也可能表示队列已满。解决办法有两种: 

  • 可以设置一个标志变量 flag,front == rear 并且 flag = 0 的时候队列为空,当front == rear 并且 flag=1 的时候队列为满。
  • 队列为空的时候就是 front == rear ,队列满的时候,我们保证数组还有一个空闲的位置,rear 就指向这个空闲位置,如下图所示,那么现在判断队列是否为满的条件就是: (rear+1) % QueueSize = front 。

 

4.3 队列的应用场景

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

  • 阻塞队列: 阻塞队列可以看成在队列基础上加了阻塞操作的队列。当队列为空的时候,出队操作阻塞,当队列满的时候,入队操作阻塞。使用阻塞队列我们可以很容易实现“生产者 - 消费者“模型。
  • 线程池中的请求/任务队列: 线程池中没有空闲线程时,新的任务请求线程资源时,线程池该如何处理呢?答案是将这些请求放在队列中,当有空闲线程的时候,会循环从队列中获取任务来执行。队列分为无界队列(基于链表)和有界队列(基于数组)。无界队列的特点就是可以一直入列,除非系统资源耗尽,比如 :FixedThreadPool 使用无界队列 LinkedBlockingQueue。但是有界队列就不一样了,当队列满的话后面再有任务/请求就会拒绝,在 Java 中的体现就是会抛出java.util.concurrent.RejectedExecutionException 异常。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值