数据结构与算法之——基础数据结构

346 阅读10分钟

基础数据结构

数组

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

  • 什么是线性表: 就是线性结构,一长串. 线性表 非线性表

  • 连续空间和相同的数据类型 因为以上特性,所以某个节点的地址可以直接计算出来 a[i]_address = base_address + i * data_type_size.所以数组具有一个杀手锏的特性随机访问 随机访问

查找

基于下标进行查找,数组的时间复杂度是O(1).因为可以之际额使用公示计算出来数组元素的地址

因为CPU的缓存机制,所以连续的空间会进行缓存,所以数组的访问效率很高,这一点在链表中就无法体现

所以查询效率高

插入和删除

每次进行插入和删除操作,都需要维护修改当前下标后的所有元素下标,所以时间复杂度很高O(n)

实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢? 也就是每次删除的时候,我们只是将其标记为删除,而不是真的进行删除操作.等到内存不足的时候再统一进行删除

这种思想类似与JVM的垃圾回收中的标记-清除算法,第一遍进行检查标记,第二遍才清除垃圾对象. 但是对于JVM来说,这种标记清楚相当于标记整理,会产生内存碎片导致明明内存足够但是无法存储大对象

数组的容器类---ArrayList 底层是可动态增长的数组

  • 有序
  • 可重复
  • 线性表
  • 可动态扩容大小

每次扩容,会将内存扩充至原有的1.5倍大小.扩容需要将原有的集合元素复制到一块新的内存空间中去,所以非常耗时

与数组的区别

  1. 封装了一些基本操作的方法,比如插入删除等操作需要搬迁其他数据等
  2. 支持动态扩容

相对于数组的缺点:

  1. 无法存储基本数据类型,如int、long,需要使用其封装类Integer、Long.但是封装类的Autoboxing、Unboxing有一定的性能消耗

链表

学习自数据结构与算法之美

概念: 链表是通过指针将一些零散的内存块串联起来,内存空间是不连续的.由于没有大小限制,所以天然的支持“动态扩容”(即使是ArrayList的动态扩容,也是将原有的数据复制到另一份内存空间中去,很耗时)

  • 单链表:头指针记录链表的基地址,尾指针指向一个空地址NULL
  • 双向链表
  • 循环链表:相对于单链表,其尾指针指向头节点.循环链表的优点就在于尾节点指向头节点所以在处理一些具有环状的数据结构的时候,相对于而言代码简洁一些

查找

每个节点的查找,无论是使用下标查找还是使用值查找,都需要一个节点一个节点的获取,所以链表的查找操作是复杂的,时间复杂度是O(n)

删除和插入

在已确定相关节点的地址信息的情况下,进行删除和插入操作的时间复杂度是O(1),因为只需要修改前一个指针和后一个指针的指向地址空间即可.但是类似针对单链表的插入操作,首先得找到当前插入的数值所需要在的下标位置,还需要按照这个下标位置获取上一个以及下一个节点的地址信息.才能完成

双向链表(空间换时间)

  • 地址空间更多,具有前指针和后指针,相对于单链表存储相同的数据,地址空间就要更多.
  • 但是可以在O(1)时间复杂度获取前节点的数据.所以在插入和删除操作的时候,就不需要再多进行一步的寻址操作,相对于单链表的插入和删除操作快一些
  • 特定情况下查询更快,在查找上也可以根据上次查找的数值进行判断来决定是向前查找还是向后查找,从而提高查询效率

Java中的LinkedList、LinkedHashMap都是使用的双向链表

特点:

  1. 后进先出

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

栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈。

// 基于数组实现的顺序栈
public class ArrayStack {
  private String[] items;  // 数组
  private int count;       // 栈中元素个数
  private int n;           //栈的大小

  // 初始化数组,申请一个大小为n的数组空间
  public ArrayStack(int n) {
    this.items = new String[n];
    this.n = n;
    this.count = 0;
  }

  // 入栈操作
  public boolean push(String item) {
    // 数组空间不够了,直接返回false,入栈失败。
    if (count == n) return false;
    // 将item放到下标为count的位置,并且count加一
    items[count] = item;
    ++count;
    return true;
  }
  
  // 出栈操作
  public String pop() {
    // 栈为空,则直接返回null
    if (count == 0) return null;
    // 返回下标为count-1的数组元素,并且栈中元素个数count减一
    String tmp = items[count-1];
    --count;
    return tmp;
  }
}

时间复杂度,因为只是栈顶数据的操作,所以是O(1).空间复杂度(计算空间复杂度,是指去除原本存储的消耗的空间,算法本身消耗的,比如说临时变量等)也是O(1)

支持动态扩容的顺序栈 这里支持动态扩容,那么只需要将原本的数组变成支持动态扩容的数组就可以了(ArrayList).因为其实动态扩容就是在达到一定要求的时候,新申请一块区域将数据拷贝过去

队列

先进先出,在队尾添加元素,在队首删除元素

使用数组实现的叫做顺序队列,使用链表实现的叫做链式队列


// 用数组实现的队列
public class ArrayQueue {
  // 数组:items,数组大小:n
  private String[] items;
  private int n = 0;
  // head表示队头下标,tail表示队尾下标
  private int head = 0;
  private int tail = 0;

  // 申请一个大小为capacity的数组
  public ArrayQueue(int capacity) {
    items = new String[capacity];
    n = capacity;
  }

  // 入队
  public boolean enqueue(String item) {
    // 如果tail == n 表示队列已经满了
    if (tail == n) return false;
    items[tail] = item;
    ++tail;
    return true;
  }

  // 出队
  public String dequeue() {
    // 如果head == tail 表示队列为空
    if (head == tail) return null;
    // 为了让其他语言的同学看的更加明确,把--操作放到单独一行来写了
    String ret = items[head];
    ++head;
    return ret;
  }
}

数组代码实现队列的原理

  1. 相对于栈的只有栈顶操作,队列需要两个操作,所以需要两个指针head和tail
  2. 入队的时候,head不动,即队首不动,只动tail的位置即可.插入几个数据tail变动几次
  3. 出队的时候,head动,删除几个数据就动几次

实际上,我们在出队时可以不用搬移数据。如果没有空闲空间了,我们只需要在入队时,再集中触发一次数据的搬移操作。


   // 入队操作,将item放入队尾
  public boolean enqueue(String item) {
    // tail == n表示队列末尾没有空间了
    if (tail == n) {
      // tail ==n && head==0,表示整个队列都占满了
      if (head == 0) return false;
      // 数据搬移
      for (int i = head; i < tail; ++i) {
        items[i-head] = items[i];
      }
      // 搬移完之后重新更新head和tail
      tail -= head;
      head = 0;
    }
    
    items[tail] = item;
    ++tail;
    return true;
  }

可以看出,每次删除只是将头部元素置为空,但是数组还是有元素的.只有进行插入操作的时候,才会将原有的信息进行重新排序,将整体复制平移

链表代码实现队列的原理

  1. 同样需要两个指针,head和tail
  2. 入队时,tail->next= new_node, tail = tail->next;出队时,head = head->next。

循环队列

//TODO 这个还没理解

阻塞队列和并发队列

//TODO

链表代码

1.1 技巧一: 理解指针和引用的概念

指针(C、C++、Go)和引用(python、java)的含义都是存储所指对象的内存地址

将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。

在编写链表代码的时候,我们经常会有这样的代码:p->next=q。这行代码是说,p 结点中的 next 指针存储了 q 结点的内存地址。

还有一个更复杂的,也是我们写链表代码经常会用到的:p->next=p->next->next。这行代码表示,p 结点的 next 指针存储了 p 结点的下下一个结点的内存地址。

1.2 技巧二:警惕指针丢失和内存泄露

我们插入结点时,一定要注意操作的顺序,要先将结点 x 的 next 指针指向结点 b,再把结点 a 的 next 指针指向结点 x,这样才不会丢失指针,导致内存泄漏。

比如这个,如果将a的next指针中存储了x的内存地址,那么这个时候a的next指针就无法找到b了,所以就不能在使用这条x线了

1.3 技巧三:使用哨兵简化实现难度

链表的插入操作

new_node->next=p->next

p-next->new_node

1. 标识一个新的节点的next指针中存储p的next内存地址(这个时候p->next和now_node->nex都是存储了同一个内存地址,指向同一个值)
2. 将new_node的内存地址用p-next存储在,这个时候就相当于p-next=new_node、new_node->下一个节点
这样就实现了节点的插入操作

链表首插入一个节点

if (head == null) {
  head = new_node;
}

链表删除一个节点

p->next = p->next->next;

删除最后一个节点

if (head->next == null) {
   head = null;
}

所以哨兵其实就是为了解决这种边界问题

原理就是,在每个链表头部都增加一个节点哨兵节点,这个哨兵节点并不存储任何数据,只是放在那.这样所有的插入和删除操作就可以统一使用一套代码实现了

1.4 技巧四:重点处理边界条件

我经常用来检查链表代码是否正确的边界条件有这样几个:

  1. 如果链表为空时,代码是否能正常工作?
  2. 如果链表只包含一个结点时,代码是否能正常工作?
  3. 如果链表只包含两个结点时,代码是否能正常工作?
  4. 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?

1.5 技巧五:举例画图,辅助思考

1. 二维数组

二维数组的下标是按照维度来获取的.比如

int[][] a=new int[][]{
    {1,2,3,4},{null,2,3,1},{1}
}
a[1][2]获取的是第二个外部数组的第三个数值

2. 为什么数组下标从0开始

原因一: 寻址公式:a[i]_address = base_address + i * data_type_size 如果下标从1开始,那么寻址公式就需要变成:a[i]_address = base_address + (i-1) * data_type_size. 所以:系统就需要多一步减法操作. 其次,也可能设计者是从这个公式开始设计数组的,所以这个公式没有做i-1那么下标就需要进行一个从0开始的操作来满足公式需要

原因二: 后人开发其他语言的时候,都沿用了C语言下标从0开始.

3. 二维数组的寻址操作

对于 m * n 的数组,a [ i ][ j ] (i < m,j < n)的地址为: address = base_address + ( i * n + j) * type_size

4. 基于链表实现LRU缓存淘汰算法

  1. 创建一个有序的单链表结构,每次获取缓存数据都从头开始遍历
  2. 如果数据以及在链表中,就将该位置数据删除并从头部新增一条一样的数据
  3. 如果数据不在链表中
    1. 链表未满,则直接在头部新增一条数据
    2. 链表已满,则删除尾部的最后一条数据并从头部新增查询的数据

5. 如何实现浏览器的前进后退

创建两个栈X和Y,首次浏览的页面压入栈X,当进行后退操作的时候,就将栈X的栈顶页面取出,并压入栈Y.当进入前进操作的时候就将栈Y的栈顶取出压入栈X.

后退 前进 无法前进(栈Y没有数据)

6. 基于队列实现线程池的阻塞请求

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

第一种是非阻塞的处理方式,直接拒绝任务请求;
另一种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。那如何存储排队的请求呢?

  • 基于链表实现,会实现一个无限制阻塞的线程池,即无限制累加进行等待.新来一个就一直在后面排队等待
  • 基于数组实现,就会实现一个可以配置最大等待链接数量的一个概念.

在大部分资源有限的场景的时候需要等待都可以使用队列来实现“先进先出”