2. 数据结构与算法 —— 数组、栈和队列

1,174 阅读13分钟

线性表

数组

数组是一种「线性表」数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据。

线性表就是数据排成一条线一样的结构。每个线性表上的数据最多只有前后两个方向。

数组特性

数组最大的特点就是「根据下标随机访问数组元素」。

由于数组需要保持内存数据的连续性,就会导致插入、删除操作比较低效。

对于插入操作改进的方法:当数组中的数据不需要保持有序的时候,可以先将插入位置 K 的元素复制到数组的最后,再把新的数据插入到 k 位置。

demo

对于删除操作的改进方法:一般情况,为了内存的连续性,删除第 k 个数据是需要搬移数据,不然中间就会出现空洞。所以,在某些特定的场景下,我们并不一定非得追求数组中数据的连续性。可以将多次的删除操作集中在一起执行,删除的效率就会提高很多。

每次执行删除操作的时候并不是将数据真的删除,而是记录已经被删除。当没有更多的空间存储数据时,再触发一次真正的删除操作,这样就大大的减少了删除操作导致的数据搬移。其实这就是「JVM 标记清除垃圾回收算法」的核心思想。

实现动态数组

public class DynamicArray<T> {
    private T[] mData;
    private int mCapacity;
    private int mCount = 0;

    public DynamicArray() {
        this(10);
    }

    public DynamicArray(int capacity) {
        mCapacity = capacity;
        mData = (T[]) new Object[mCapacity];
    }

    /**
     * 添加数据
     *
     * @param e
     * @param index
     */
    public void add(T e, int index) {
        // 插入的位置一定要是连续的
        checkIndex(index);
        // 数组满了,需要扩容
        if (mCount == mCapacity) {
            resize(2 * mCapacity);
        }
        for (int i = mCount - 1; i > index; i--) {
            mData[i + 1] = mData[i];
        }
        mData[index] = e;
        mCount++;
    }

    public void addLast(T e) {
        add(e, mCount);
    }

    public void addFirst(T e) {
        add(e, 0);
    }

    /**
     * 删除数据
     *
     * @param index
     * @return
     */
    public T remove(int index) {
        checkIndexForRemove(index);

        for (int i = index + 1; i < mCount; i++) {
            mData[i - 1] = mData[i];
        }
        mCount--;
        // 将数组的元素重设为 null,使之前的值孤立,导致其被 GC 回收
        mData[mCount] = null;

        // 如果存放的元素只有 1/4 就进行缩容
        if (mCount <= mCapacity / 4 && mCapacity / 2 != 0) {
            resize(mCapacity / 2);
        }
        return mData[index];
    }

    public void removeElement(T e) {
        int index = find(e);
        remove(index);
    }

    /**
     * 查找数据
     *
     * @param e
     * @return
     */
    public int find(T e) {
        if (mCount == 0) return -1;

        for (int i = 0; i < mCapacity; i++) {
            if (mData[i].equals(e)) return i;
        }
        return -1;
    }

    /**
     * 修改数据
     *
     * @param e
     * @param index
     */
    public void set(T e, int index) {
        checkIndex(index);
        mData[index] = e;
    }

    /**
     * 改变数组的容量
     *
     * @param capacity
     */
    private void resize(int capacity) {
        T[] newData = (T[]) new Object[capacity];
        if (mCapacity >= 0) System.arraycopy(mData, 0, newData, 0, mCapacity);
        mCapacity = capacity;
        mData = newData;
    }

    private void checkIndex(int index) {
        if (index < 0 || index > mCount) {
            throw new IllegalArgumentException("Add failed! Require index >=0 and index <= size.");
        }
    }

    private void checkIndexForRemove(int index) {
        if (index < 0 || index >= mCount) {
            throw new IllegalArgumentException("remove failed! Require index >=0 and index < size.");
        }
    }
}

警惕数组的访问越界问题

下面这个例子中,会无限打印 hello world。

int main(int argc, char* argv[]){
    int i = 0;
    int arr[3] = {0};
    for(; i<=3; i++){
        arr[i] = 0;
        printf("hello world\n");
    }
    return 0;
}

在 C 语言中,只要访问的不是受限的内存,所有的内存空间都是可以自由访问的。根据寻址公式,a[3] 会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的地址,那么 a[3] = 0 就相当于 i = 0,所以就会导致代码无限循环。

数组越界在 C 语言中是一种未决行为,并没有规定数组越界时编译器应该如何处理。只要数组通过偏移计算得到的内存地址是可用的,那么程序就不会报任何错误。

对于不同编译器,在内存分配时,会按照内存地址递增或者递减的方式进行分配,如果是按照递增的方式,就会造成无限循环。

slvher: 对文中示例的无限循环有疑问的同学,建议去查函数调用的栈桢结构细节(操作系统或计算机体系结构的教材应该会讲到)。 函数体内的局部变量存在栈上,且是连续压栈。在Linux进程的内存布局中,栈区在高地址空间,从高向低增长。变量i和arr在相邻地址,且i比arr的地址大,所以arr越界正好访问到i。当然,前提是i和arr元素同类型,否则那段代码仍是未决行为。 不诉离殇: 例子中死循环的问题跟编译器分配内存和字节对齐有关 数组3个元素 加上一个变量a 。4个整数刚好能满足8字节对齐 所以i的地址恰好跟着a2后面 导致死循环。。如果数组本身有4个元素 则这里不会出现死循环。。因为编译器64位操作系统下 默认会进行8字节对齐 变量i的地址就不紧跟着数组后面了

数组的下标为什么是从 0 开始?

  1. 数组的下标其实准确的来说应该叫「偏移量」,计算数据在内存中的地址用到的公式是 a[i]_address = base_address + i * data_type_size ,所以如果下标从 1 开始,计算地址时需要先将其 -1 再计算,这样会让 CPU 多进行一次运算。

  2. 由于历史原因,C 语言的数组下标就是从 0 开始的,后来的语言都沿用了这一习惯,一定程度上减少了 C 程序员学习的成本。

课后思考题

JVM 的标记垃圾回收算法是什么?

大多数主流虚拟机采用可达性分析算法来判断对象是否存活,在标记阶段,会遍历所有 GC ROOTS,将所有 GC ROOTS 可达的对象标记为存活。只有当标记工作完成后,清理工作才会开始。

  1. 不足:标记和清除的效率都不高,但是知道只有少量垃圾产生时会很高效。
  2. 空间问题:会产生不连续的内存碎片。

二维数组的内存寻址公式是怎样的呢?

对于一个 m*n 的数组,数据 a[i][j](i<m, j<n) 的地址为:

a[i][j]_address = base_adress + data_type_size * (i * n + j)

参考:数组:为什么很多编程语言中数组都从0开始编号?

leetcode 题目 20,155,232,844,224,682,496.

栈是一种「操作受限」的线性表,只允许在一端插入和删除数据。用数组实现的栈,叫作「顺序栈」。用链表实现的栈,叫作「链式栈」。

当某个数据集合只涉及在一端插入和删除数据,并且满足「先进后出」的特性时,我们就应该首选「栈」这个数据结构。

顺序栈

public class ArrayStack<T> {
    private T[] mData;
    private int mCount;

    public ArrayStack(int capacity) {
        mData = (T[]) new Object[capacity];
    }

    /**
     * 入栈
     *
     * @param item
     */
    public boolean push(T item) {
        if (mCount == mData.length) return false;

        mData[mCount] = item;
        mCount++;
        return true;
    }

    /**
     * 出栈
     *
     * @return
     */
    public T pop() {
        if (mCount == 0) return null;

        mCount--;
        return mData[mCount];
    }
}

要支持动态扩容的顺序栈,将数组改成支持动态扩容的数组即可。

栈的应用非常广泛,如:用在函数调用中、用在表达式求值中、用在括号匹配中

如何实现浏览器的前进和后退功能?

使用两个栈 X 和 Y,我们把首次浏览的网站依次压入栈 X,当点击后退键时,再一次从 X 中出栈,并且一次压入栈 Y 中。当点击前进键时,就从 Y 中出栈,并压入栈 X 中。当 X 中没有数据时,那就说明没有可以后退的页面。当 Y 中没有数据时,那就说明没有可以前进的页面。

参考 栈:如何实现浏览器的前进和后退功能?

课后思考

为什么函数要用「栈」来保存临时变量?其他数据结构不行吗?

不一定非要使用栈来存储临时变量,只不过函数调用符合后进先出的的特性,用栈来实现就是顺理成章。

从调用函数进入被调用函数,对于数据来说,变化的是作用域。所以从根本上,只要保证每进入一个新的函数,都是一个新的作用域就可以。而要实现这个,用栈就非常方便。在进入被调用函数的时候,分配一段内存空间给这个函数变量,在函数结束的时候,将栈顶复位,正好回到调用函数的作用域内。

JVM 里的“栈”跟我们说的“栈”是不是一回事?如果不是,它为什么也叫做栈呢?

阿杜S考特: 内存中的堆栈和数据结构的堆栈不是一个概念,可以说内存中的堆栈是真实存在的物理区间,数据结构重的堆栈式抽象的数据存储结构。

内存空间在逻辑上分为三部分:代码区、静态数据区和动态数据区,动态数据区又分为栈区和堆区。

代码区:存储方法体的二进制代码。高级调度(作业调度)、中级调度(内存调度)、低级调度(进程调度)控制代码区执行代码的切换 静态数据区:存储全局变量、静态变量、常量,其中常量包括 final 修饰的和 String 常量。系统自动分配和回收。 栈区:存储运行方法的形参、局部变量、返回值。由系统自动分配和回收。 堆区:new 一个对象的引用或者地址存储在栈区,指向该对象存储在堆区的真实数据。

队列

队列也是一种操作受限的线性表,并且满足「先进先出」的特性,只有两个基本操作「入队」和「出队」。同样,用数组实现的队列叫做「顺序队列」,用链表实现的队列叫做「链式队列」。

队列的应用非常广泛,比如循环队列、阻塞队列、并发队列。它们在很多底层系统、框架、中间件的开发中,起着关键的作用。比如高性能队列 Disruptor、Linux 环形缓存,都用到了循环队列;Java concurrent 并发包利用 ArrayBlockingQueue 来实现公平锁。

顺序队列

为了保持内存空间的连续,需要进行数据搬移。

public class ArrayQueue<T> {
    private T[] mItems;
    private int n;
    // 指向对头的指针
    private int head = 0;
    // 指向队尾的指针
    private int tail = 0;

    public ArrayQueue(int capacity) {
        this.n = capacity;
        mItems = (T[]) new Object[capacity];
    }

    /**
     * 入队
     * 时间复杂度:最好时间复杂度为 O(1),最坏时间复杂度为 O(n),平均时间复杂度为 O(1)
     * 空间复杂度 O(1)
     *
     * @param item 入队的元素
     * @return 入队是否成功
     */
    public boolean enqueue(T item) {
        // 当后一个指针 tail == n 时,就表示队列已经满了
        if (tail == n) {
            // 当 head == 0 并且 tail == n, 就表示整个队列都满了
            if (head == 0) {
                return false;
            }
            // 数据搬移
            for (int i = head; i < tail; i++) {
                mItems[i - head] = mItems[i];
            }
            tail -= head;   // 先设置 tail 的位置
            head = 0;
        }

        mItems[tail++] = item;
        return true;
    }
    
    /**
     * 出队
     * 时间复杂度为 O(1)
     * 空间复杂度为 O(1)
     *
     * @return 出队的元素
     */
    public T dequeue() {
        // 当前一个指针 head 等于 tail 指针时,就表示队列已经空了
        if (head == tail) return null;
        return mItems[head++];
    }
}

链式队列

链式队列的代码其实很简单

public class LinkedListQueue<T> {

    // 队列头
    private Node<T> mHead;
    // 队列尾
    private Node<T> mTail;

    /**
     * 入队
     * 时间复杂度为 O(1)
     * 空间复杂度为 O(1)
     *
     * @param value 入队的元素
     */
    public void enqueue(T value) {
        if (mTail == null) {
            Node<T> newNode = new Node<>(value);
            mHead = newNode;
            mTail = newNode;
        } else {
            mTail.next = new Node<>(value);
            mTail = mTail.next;
        }
    }

    /**
     * 出队
     * 时间复杂度为 O(1)
     * 空间复杂度为 O(1)
     *
     * @return  返回出队的元素
     */
    public T dequeue() {
        if (mHead == null) return null;
        T value = mHead.value;
        mHead = mHead.next;
        if (mHead == null) mTail = null;
        return value;
    }

    private class Node<K> {
        private K value;
        private Node<K> next;

        public Node(K value) {
            this.value = value;
        }
    }
}

循环队列

解决顺序队列需要搬移的问题。

循环队列需要注意的是怎么区分队满和队列为空。充分利用 % 运算符的特性可以循环得到 [0, n] 之间的值。所以队满可以用 (mTail + 1) % mItems.length == mHead 进行判断。对空则就是头指针和尾指针相等的情况。

public class CircularQueue<T> {
    // 数组
    private T[] mItems;
    private int mHead;
    private int mTail;

    public CircularQueue(int capacity) {
        mItems = (T[]) new Object[capacity];
        mHead = 0;
        mTail = 0;
    }

    /**
     * 入队
     * 时间、空间复杂度 O(1)
     *
     * @param item 入队元素
     * @return 是否入队成功
     */
    public boolean enqueue(T item) {
        // 队满,为了与对空区分,需要浪费一个存储空间
        if ((mTail + 1) % mItems.length == mHead) return false;

        mItems[mTail++] = item;
        return true;
    }

    /**
     * 出队
     * 时间、空间复杂度都是 O(1)
     *
     * @return 返回出队元素
     */
    public T dequeue() {
        // 队空
        if (mHead == mTail) return null;
        T item = mItems[mHead];
        mHead = (mHead + 1) % mItems.length;
        return item;
    }
}

阻塞队列和并发队列

阻塞队列其实就是在队列的基础上加了阻塞操作。简单的说,就是在队列为空的时候,从队列头取出数据就会被阻塞,因为没有数据可取,直到有了数据才返回。如果队列已经满了,那么插入数据就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。这样我们可以使用阻塞队列轻松实现一个「生产——者消费者模型」。

基于阻塞队列,还可以通过协调「生产者」和「消费者」的个数,来提高数据的处理效率。

阻塞队列

并发队列其实就是线程安全的队列。最简单直接的方式就是在 enqueue()、dequeue() 方法上加锁,但是锁的颗粒度比较大大,并发度灰比较低,同一时刻仅允许一个存或者一个取操作。实际上,基于数组的循环队列,利用 CAS 原子操作,可以实现非常高效的并发队列。这也是循环队列比顺序队列应用更加广泛的原因。

线程池在队列中的应用

线程池没有空闲线程时,新的任务请求线程时,线程池应该如何处理?各种处理的策略又是如何实现的?

一般有两种策略:第一种是非阻塞的处理方式,直接拒绝任务请求。第二种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。

然后是排队请求的存储问题,对于线程我们肯定希望先来的先处理,所以就应该选择队列这种数据结构,但是队列又分为基于数组和基于链表的。后者可以实现一个无限排队的无界队列,但是这种方式会导致过多的排队等待,请求处理的响应时间过长。所以,对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。而基于数组实现的有界队列,队列的大小有限制,所以线程池的排队请求超过队列大小时,接下来的请求会被拒绝。

实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过「队列」这种数据结构来实现请求排队。

课后思考

除了线程池还有哪些类似的池结构或者场景会用到队列的排队请求呢?

分布式应用中的消息队列也是一种队列结构。

如何实现无锁并发队列?

考虑使用 CAS 实现无锁队列,在入队前获取 tail 指针位置,并比较 tail 是否发生变化。如果没有变化,则允许入队;反之,本次入队失败。出队则获取 head 位置,进行 CAS。