四大基础数据结构:数组、链表、队列和栈

四大基础数据结构:数组、链表、队列和栈

1、时间复杂度(大O表示法)

衡量时间复杂度与空间复杂度的计数表示,即 衡量算法时间长与不长、占用内存多与多。(本文中主要用来表示时间复杂度,暂不涉及空间复杂度)

鉴于不同级别的硬件对数据的处理能力的差异(例如:老式奔腾处理器和最新的I9-9700K处理器),业内常采用大O符号表述。在大O表示法中,我们通过步数来衡量一个算法的快与不快

    public void test(int[] items) { 
        
    }

假设这是一个遍历算法,它需要访问items中的每一个元素,所以如果items的长度为N,那么遍历算法就需要执行N步,它的时间复杂度就是O(N)。

大O表示法的时间复杂度大致可以分为以下几个级别:

  • O(1) :常数级别,无论输入多大,它执行的步数都是恒定的,不会因为输入变大而变大,哈希表的查找就是这个级别。
  • O(N) :线性级别,随着输入变大耗费的步数也正向相关,遍历算法就是这个级别。
  • O(logN) :对数级别,输入每变大一倍,耗费步骤则增加1,二分查找算法属于这个级别。
  • O(N²) :平方级别,随着输入的变大所耗费的步数会成倍增加,一般当你的算法使用双层for循环就是这个级别,比如冒泡排序。

此外还有立方级别,就不过多叙述,和平方级别一样,只不过更大。

关于大O 表示法,它并不像数学一样准确 。

假设某一个算法每次需要进行2N次的遍历才能执行完,那么它的时间复杂度也是O(N),常数在大O 表示法中会被忽略,因为常数是一定的,不会变化。

再比如O(1),即使没学过哈希表应该也能明白,不可能有任何算法只执行一个步骤就能查找到某个数字,应该是执行好几步,但是无论是执行5步也好,6步也罢,因为它的步数是恒定的且和输入规模无关,所以它是O(1)。

最后一点,同一段算法在不同输入下可能可能耗费的步数完全不同,所以大O表示法一般取一个一般值来表示,比如某些排序算法在输入逆序和顺序的情况下会呈现完全不同的时间复杂度,一般取耗费更多的那个。

2、数组

数组 是一种可以快速访问的数据结构,它也是数据结构的基石之一,所有语言都内置支持数组,它在内存中是一块连续的内存地址。
《四大基础数据结构:数组、链表、队列和栈》
在数组中,每一个数组都有一个地址,同时可以通过数组的下标方便的算出每个元素的内存地址,从而实现快速访问和赋值,所以它通过下标查找的效率是O(1) 级别。

在数组的插入和删除中,如果是在数组尾部插入或删除一个元素则可以直接进行,但如果在其他位置插入或者删除则需要调整其他元素的位置,比如你删除数组的第一个元素之后,需要把后面的所有元素都往前移动一位。

数组还有一个巨大弱点就是当这个数组已经装满的时候,就无法再继续装入元素了。

在高级编程语言中,往往都使用动态数组来解决这个问题,所谓动态数组就是自动会进行数组扩容,当数组容量达到某个临界点的时候,动态数组会开辟一个更大的数组,然后将原来的元素复制过去。

Java中的ArrayList类就是这样做的,接下来我会简单实现一个动态数组:

public class DiyList<T> { 

    private Object[] items;

    private int size = 0;

    public DiyList() { 
        items = new Object[16];
    }

    public T get(int index) { 
        if (index > size) { 
            throw new NoSuchElementException();
        }
        return (T) items[index];
    }

    public boolean add(T item) { 
        if (Objects.isNull(item)) { 
            throw new NullPointerException();
        }

        if (size >= items.length / 2) { 
            grow();
        }

        items[size++] = item;
        return true;
    }

    private void grow() { 
        Object[] newItems = new Object[items.length * 2];
        System.arraycopy(items, 0 , newItems, 0 , items.length);
        items = newItems;
    }

    public int size() { 
        return size;
    }

}

由于Java中的范型会有泛型擦除,所以我无法定义一个范型数组,导致只能实现Object数组,在进行get的时候将此元素强转为泛型元素。

在这个例子中,我只实现了基本的get和add方法,动态扩容的最主要方法就是grow方法,它负责了数组的扩容,扩容方式也能简单,建立一个更大的数组,然后把原来的复制过来即可。

动态数组在帮你自动扩容的同时,也带来了复制元素的代价:

  • 当数组需要扩容时,需要进行元素复制。
  • 当删除数组元素时也需要进行元素复制,因为当一删除某一个元素后,数组中间就会有一个空位置,需要把后面的元素都往前挪一格。

《四大基础数据结构:数组、链表、队列和栈》

动态数组比较适合使用下标查询,插入和删除都需要额外的时间消耗和空间消耗。

3. 链表

上一节数组是访问元素比较方便,这一节的链表 则是插入与删除更方便。

数组的元素地址是强制连续的,但是链表的元素内存地址可以是不连续的 ,它通过地址引用的方式指向下一个元素的位置,所以链表的数据结构看起来比较像一个串。

《四大基础数据结构:数组、链表、队列和栈》
链表的代码结构一般是这样的:

public class DiyLinked<T> { 
    private int size = 0;
    private Node<T> item;

    private static class Node<T> { 
        private T item;
        private Node<T> next;
        public Node(T t) { 
            item = t;
        }
    }
}

在Node元素中,除了本身的数据item之外,往往还会持有一个next对象,它用来指向下一个节点的引用。

链表的数据结构也注定了它无法快速的访问某个元素,只能依靠遍历的方式慢慢查找,所以说链表的查询数据是比较慢的,是O(N) 这个级别,但是它在头节点(第一个节点)进行插入和删除比较快,只需要O(1) 就可以了,因为你只需要改变一下第一个元素的引用。

我为什么要特意提到头节点呢, 因为如果你想要在尾节点进行插入和删除,势必需要遍历这个链表,找到最后这个链表节点再进行插入,遍历链表的花费是O(N)。

上面提到的这种只带一有一个next引用的链表叫做单向链表,为了解决尾插的问题,又出现了双向链表。

双向链表,也就是链表中有前后两个指针引用,分别指向前面的元素和后面的元素。
《四大基础数据结构:数组、链表、队列和栈》

双向链表的代码一般是这样的:

public class DiyLinked<T> { 
    private int size = 0;
    private Node<T> first;
    private Node<T> last;

    private static class Node<T> { 
        private T item;
        private Node<T> next;
        private Node<T> prev;
        public Node(T t) { 
            item = t;
        }
    }
 }   

双向链表会同时维护first 和 last 两个节点,所以当你需要尾插的时候,直接通过last节点进行插入或者删除。
Java中的LinkedList就是一个双向链表,实现也很简单,链表是数据结构的另一个基石,很多数据结构都可以在链表的基础上进行变形得来。

4、 队列

队列 是一个先入先出的数据结构,它在数据结构上可以使用数组或者链表去实现。

队列的特性就像我们日常排队买东西一样,先到先得。
《四大基础数据结构:数组、链表、队列和栈》

通过图示大家可以看出队列只有两个动作:入队(enqueue)出队(dequeue),一般呢是在尾部插入,在头部取出。
队列可以分为两种:

  • 有界队列 :能装入的元素有限。
  • 无界队列 :能装入的元素无限,只要内存还有,就能一直装下去。

我将利用数组和链表的特性,分别构造这两种队列。

4.1 数组构造队列

数组在前文中也提到过了,它有两个缺点:

  • 数组是恒定的,想要变大只能使用动态数组。
  • 删除数组元素会迫使其他元素移位。

如果我们想要使用数组构造队列,第一个问题只能通过动态数组来解决,不过实际中很多队列都是恒定的,是不能变大变小的,因为计算机的内存它不是无限的,所以用数组构造有界队列是比较合适的。

第二个问题,在构造队列时要通过复用数组位置来解决,也就是给数组安上两个指针,一个指向尾节点,一个指向头节点,也就是所谓的环形数组。
《四大基础数据结构:数组、链表、队列和栈》

环形数组队列的两种操作:

  • 出队:出队直接拿first节点的数据即可,然后将first下标元素置为null,并将first指向的下标+1即可。
  • 入队:入队之前需要先判断一下end+1下标上是否有元素,没有才能正常入队,如果有元素则队列已满。

4.2 链表构造队列

使用链表构造队列只需要使用我们上文提到过的双向链表即可,它简直是天生可以作为队列使用,因为它同时维护了头节点和尾节点,而且由于它可以一直链接下去,所以做无界队列正合适。

Java中常用的LinkedList就同时实现了Queue 这个接口,也就是说LinkedList也支持队列操作。

5、栈

栈 ,是一种先入后出/后入先出的数据结构,如果要举一个生活中的例子,我觉得最形象应该就是弹夹了,最先放进去的子弹往往最后被打出。

栈,同样也可以使用数组和链表两种方式来构造数据结构,因为其实它会上文中提到过的队列是极其类似的。

在计算机世界中,栈常常被用于程序的调用帧栈,在程序中有时候出现的StackOverFlow异常就是由于程序调用栈用尽了,所以栈一般都是有相对固定的深度,从这点上看用数组构造栈是一个比较好的主意。(栈深往往也被设定的内存影响)
《四大基础数据结构:数组、链表、队列和栈》
栈使用push插入元素,使用pop读取元素,最后被push的那个元素往往就是第一个被pop的元素。

同样的,栈这个数据结构在Java里面也有,它的名字就叫做Stack,但是它不光具有栈的两个操作,还具有数组的一些可用操作,因为它继承了一个List类,像这样的宽接口设计是类库设计失败的典范,希望大家不要模仿~

6、结语

最后的结语部分,带大家复习一下这四种数据结构:

  • 数组:下标查找快,插入和删除慢。
  • 链表:头尾插入和删除快,查找慢。
  • 队列:尾插和头出,时间复杂度是O(1),非常快。
  • 栈:尾插尾出,时间复杂度是O(1),非常快。

在如今的编程中,更多的是只用到前两种,选用什么容器取决于你的业务属性。

  • 在选用数组时可以预估一下集合规模然后初始化一个比较合适的动态数组,避免多次扩容。
  • 选用链表时可以先问问自己是查询的场景多还是插入的场景多。
  • 使用队列和栈时也可以先了解一下自己使用的类库的底层实现是数组还是链表,当你面对的东西对你来说不再是黑盒时,就可以轻松的作出判断了~
    原文作者:Jiyao Xiong
    原文地址: https://blog.csdn.net/weixin_46585151/article/details/123649231
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞