数据结构指的是“一组数据的存储结构”,算法指的是“操作数据的一组方法”。
数据结构是为算法服务的,算法是要作用再特定的数据结构上的。
最常用的数据结构预算法:
- 数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Tire树
- 算法: 递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法
1、线性表
线性表(List): 零个或多个数据元素的有限序列。每个线性表上的数据最多只有前和后两个方向,直接前驱元素,直接后驱元素。
实现线性表的方式一般有两种,一种是使用数组存储线性表的元素,即用一组连续的存储单元依次存储线性表的数据元素。另一种是使用链表存储线性表的元素,即用一组任意的存储单元存储线性表的数据元素(存储单元可以是连续的,也可以是不连续的)。
常见的线性表结构:数组,链表、队列、栈等。
1.1、数组
线性表顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。
什么是数组:
- 数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
- 连续的内存空间和相同类型的数据(随机访问的前提)
- 优点:两限制使得具有随机访问的特性;缺点:删除,插入数据效率低。
- 数组怎么根据下标随机访问的?
通过寻址公式:a[i]_address = base_address + i * data_type_size
其中 data_type_size 表示数组中每个元素的大小,base_address 是首元素地址,i为数组下标。
为何数组插入和删除低效:
- 插入与删除:
插入:若有一元素想往int[n]的第k个位置插入数据,需要从k位置元素开始依次往后移。
删除:若想在int[n]中删除第k个位置数据,后面元素向前顺移。 - 低效的插入和删除:
1) 插入:数组若无序,插入新的元素时,可以将第K个位置元素移动到数组末尾,把新的元素,插入到第k个位置,此处复杂度为O(1)。
2) 删除:多次删除集中在一起,提高删除效率
记录下已经被删除的数据,每次的删除操作并不是搬移数据,只是记录数据已经被删除,当数组没有更多的存储空间时,再触发一次真正的删除操作。即JVM标记清除垃圾回收算法。
数组实现线性表
数组是一种大小固定的数据结构,对线性表的所有操作都可以通过数组来实现。虽然数组一旦创建之后,它的大小就无法改变了,但是当数组不能再存储线性表中的新元素时,我们可以创建一个新的大的数组来替换当前数组。这样就可以使用数组实现动态的数据结构。
- 创建一个更大的数组来替换当前数组
int[] oldArray = new int[10];
int[] newArray = new int[20];
for (int i = 0; i < oldArray.length; i++) {
newArray[i] = oldArray[i];
}
// 也可以使用System.arraycopy方法来实现数组间的复制
// System.arraycopy(oldArray, 0, newArray, 0, oldArray.length);
oldArray = newArray;
- 在数组位置index上添加元素e
//oldArray 表示当前存储元素的数组
//size 表示当前元素个数
public void add(int index, int e) {
if (index > size || index < 0) {
System.out.println("位置不合法...");
}
//如果数组已经满了 就扩容
if (size >= oldArray.length) {
// 扩容函数可参考代码1
}
for (int i = size - 1; i >= index; i--) {
oldArray[i + 1] = oldArray[i];
}
// 将数组elementData从位置index的所有元素往后移一位
// System.arraycopy(oldArray, index, oldArray, index + 1,size - index);
oldArray[index] = e;
size++;
}
上面简单写出了数组实现线性表的两个典型函数,具体我们可以参考Java里面的ArrayList集合类的源码。数组实现的线性表优点在于可以通过下标来访问或者修改元素,比较高效,主要缺点在于插入和删除的花费开销较大,比如当在第一个位置前插入一个元素,那么首先要把所有的元素往后移动一个位置。为了提高在任意位置添加或者删除元素的效率,可以采用链式结构来实现线性表。
1.2、链表
线性表链式存储结构
什么是链表
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
链表由一系列节点组成,这些节点不必在内存中相连。每个节点由数据部分Data和链部分Next,Next指向下一个节点,这样当添加或者删除时,只需要改变相关节点的Next的指向,效率很高。
链表的特点
- 插入、删除数据效率高O(1)级别(只需更改指针指向即可),随机访问效率低O(n)级别(需要从链头至链尾进行遍历)。
- 和数组相比,内存空间消耗更大,因为每个存储数据的节点都需要额外的空间存储后继指针。
常用链表
- 单链表
- 循环链表
- 双向链表
- 双向循环链表
1.2.1、单链表
1)每个节点只包含一个指针,即后继指针。
2)单链表有两个特殊的节点,即首节点和尾节点。为什么特殊?用首节点地址表示整条链表,尾节点的后继指针指向空地址null。
3)性能特点:插入和删除节点的时间复杂度为O(1),查找的时间复杂度为O(n)。
下面主要用代码来展示链表的一些基本操作,需要注意的是,这里主要是以单链表为例,暂时不考虑双链表和循环链表。
链表实现线性表
- 链表的节点
class Node<E> {
E item;
Node<E> next;
//构造函数
Node(E element) {
this.item = element;
this.next = null;
}
}
- 定义好节点后,使用前一般是对头节点和尾节点进行初始化
//头节点和尾节点都为空 链表为空
Node<E> head = null;
Node<E> tail = null;
- 空链表创建一个新节点
//创建一个新的节点 并让head指向此节点
head = new Node("nodedata1");
//让尾节点也指向此节点
tail = head;
- 链表追加一个节点
//创建新节点 同时和最后一个节点连接起来
tail.next = new Node("node1data2");
//尾节点指向新的节点
tail = tail.next;
- 顺序遍历链表
Node<String> current = head;
while (current != null) {
System.out.println(current.item);
current = current.next;
}
- 倒序遍历链表
static void printListRev(Node<String> head) {
//倒序遍历链表主要用了递归的思想
if (head != null) {
printListRev(head.next);
System.out.println(head.item);
}
}
- 单链表反转
//单链表反转 主要是逐一改变两个节点间的链接关系来完成
static Node<String> revList(Node<String> head) {
if (head == null) {
return null;
}
Node<String> nodeResult = null;
Node<String> nodePre = null;
Node<String> current = head;
while (current != null) {
Node<String> nodeNext = current.next;
if (nodeNext == null) {
nodeResult = current;
}
current.next = nodePre;
nodePre = current;
current = nodeNext;
}
return nodeResult;
}
上面的几段代码主要展示了链表的几个基本操作,还有很多像获取指定元素,移除元素等操作大家可以自己完成,写这些代码的时候一定要理清节点之间关系,这样才不容易出错。
链表的实现还有其它的方式,常见的有循环单链表,双向链表,循环双向链表。 循环单链表 主要是链表的最后一个节点指向第一个节点,整体构成一个链环。 双向链表 主要是节点中包含两个指针部分,一个指向前驱元,一个指向后继元,JDK中LinkedList集合类的实现就是双向链表。** 循环双向链表** 是最后一个节点指向第一个节点。
1.2.2、循环链表
1)除了尾节点的后继指针指向首节点的地址外均与单链表一致。
2)适用于存储有循环特点的数据,比如约瑟夫问题。
1.2.3、双向链表
1)节点除了存储数据外,还有两个指针分别指向前一个节点地址(前驱指针prev)和下一个节点地址(后继指针next)。
2)首节点的前驱指针prev和尾节点的后继指针均指向空地址。
3)性能特点:
和单链表相比,存储相同的数据,需要消耗更多的存储空间。
插入、删除操作比单链表效率更高O(1)级别。以删除操作为例,删除操作分为2种情况:给定数据值删除对应节点和给定节点地址删除节点。对于前一种情况,单链表和双向链表都需要从头到尾进行遍历从而找到对应节点进行删除,时间复杂度为O(n)。对于第二种情况,要进行删除操作必须找到前驱节点,单链表需要从头到尾进行遍历直到p->next = q,时间复杂度为O(n),而双向链表可以直接找到前驱节点,时间复杂度为O(1)。
对于一个有序链表,双向链表的按值查询效率要比单链表高一些。因为我们可以记录上次查找的位置p,每一次查询时,根据要查找的值与p的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。
1.2.4、双向循环链表
首节点的前驱指针指向尾节点,尾节点的后继指针指向首节点。
选择数组还是链表?
- 插入、删除和随机访问的时间复杂度
数组:插入、删除的时间复杂度是O(n),随机访问的时间复杂度是O(1)。
链表:插入、删除的时间复杂度是O(1),随机访问的时间复杂端是O(n)。
- 数组缺点
1)若申请内存空间很大,比如100M,但若内存空间没有100M的连续空间时,则会申请失败,尽管内存可用空间超过100M。
2)大小固定,若存储空间不足,需进行扩容,一旦扩容就要进行数据复制,而这时非常费时的。 - 链表缺点
1)内存空间消耗更大,因为需要额外的空间存储指针信息。
2)对链表进行频繁的插入和删除操作,会导致频繁的内存申请和释放,容易造成内存碎片,如果是Java语言,还可能会造成频繁的GC(自动垃圾回收器)操作。 - 如何选择?
数组简单易用,在实现上使用连续的内存空间,可以借助CPU的缓冲机制预读数组中的数据,所以访问效率更高,而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法预读。
如果代码对内存的使用非常苛刻,那数组就更适合。
应用
-
如何分别用链表和数组实现LRU缓冲淘汰策略?
1)什么是缓存?
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。
2)为什么使用缓存?即缓存的特点
缓存的大小是有限的,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?就需要用到缓存淘汰策略。
3)什么是缓存淘汰策略?
指的是当缓存被用满时清理数据的优先顺序。
4)有哪些缓存淘汰策略?
常见的3种包括先进先出策略FIFO(First In,First Out)、最少使用策略LFU(Least Frenquently Used)、最近最少使用策略LRU(Least Recently Used)。
5)链表实现LRU缓存淘汰策略
当访问的数据没有存储在缓存的链表中时,直接将数据插入链表表头,时间复杂度为O(1);当访问的数据存在于存储的链表中时,将该数据对应的节点,插入到链表表头,时间复杂度为O(n)。如果缓存被占满,则从链表尾部的数据开始清理,时间复杂度为O(1)。
6)数组实现LRU缓存淘汰策略:
方式一:首位置保存最新访问数据,末尾位置优先清理
当访问的数据未存在于缓存的数组中时,直接将数据插入数组第一个元素位置,此时数组所有元素需要向后移动1个位置,时间复杂度为O(n);当访问的数据存在于缓存的数组中时,查找到数据并将其插入数组的第一个位置,此时亦需移动数组元素,时间复杂度为O(n)。缓存用满时,则清理掉末尾的数据,时间复杂度为O(1)。
方式二:首位置优先清理,末尾位置保存最新访问数据
当访问的数据未存在于缓存的数组中时,直接将数据添加进数组作为当前最有一个元素时间复杂度为O(1);当访问的数据存在于缓存的数组中时,查找到数据并将其插入当前数组最后一个元素的位置,此时亦需移动数组元素,时间复杂度为O(n)。缓存用满时,则清理掉数组首位置的元素,且剩余数组元素需整体前移一位,时间复杂度为O(n)。(优化:清理的时候可以考虑一次性清理一定数量,从而降低清理次数,提高性能。) -
如何通过单链表实现“判断某个字符串是否为水仙花字符串”?(比如 上海自来水来自海上)
1)前提:字符串以单个字符的形式存储在单链表中。
2)遍历链表,判断字符个数是否为奇数,若为偶数,则不是。
3)将链表中的字符倒序存储一份在另一个链表中。
4)同步遍历2个链表,比较对应的字符是否相等,若相等,则是水仙花字串,否则,不是。
设计思想
时空替换思想:“用空间换时间” 与 “用时间换空间”
当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高,时间复杂度小相对较低的算法和数据结构,缓存就是空间换时间的例子。如果内存比较紧缺,比如代码跑在手机或者单片机上,这时,就要反过来用时间换空间的思路。
1.3、栈
什么是栈
栈(stack)是限定仅在表尾进行插入和删除操作的线性表。
栈是限制插入和删除只能在一个位置上进行的表,该位置是表的末端,叫作栈顶,对栈的基本操作有push(进栈)和pop(出栈),前者相当于插入,后者相当于删除最后一个元素。栈有时又叫作LIFO(Last In First Out)表,即后进先出。
栈的实现
栈主要是包含入栈和出栈这两个操作,都是在栈顶进行插入和删除数据,可以基于数组或者链表来实现。
- 设计方法结构
//创建栈
Class createStack() {
// 定义变量
//判断栈是否为空
int isEmpty(Stack S);
//出栈
void pop(Stack S);
//入栈
void push(Stack S,Element e);
//清空栈
void makeEmpty(Stack S);
//返回栈顶元素
Element top(Stack S);
//销毁栈
void destoryStack(Stack &S);
}
- 栈的一个基本实现
//基于数组实现的顺序栈
public class ArrayStack{
private String[] items;//数组,元素类型为字符串
private int count;//栈中元素的个数;
private int n;//栈的大小
//初始化数组,申请一个大小为n的空间
public ArrayStach(int n){
this.items = new String[n];
this.n = n;
this.count = 0;
}
//入栈操作
public boolean push(String item){
//数组空间不够,直接返回失败
if(count == n) return false;
items[count] = item;
++count;
return true;
}
//出栈操作
public String pop(){
//栈为空,直接返回null
if(count==0) return null;
String tmp = items[count-1];
--count;
return tmp;
}
}
不管是顺序栈还是链式栈,存储数据需要n,但是在入栈和出栈的过程中只需要一两个临时变量存储空间,所以空间复杂度是O(1)
栈的应用
- 函数调用栈
操作系统为每个线程都分配了一块独立的内存空间,用来存储函数调用时的临时变量,每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成返回之后,将这个函数对应的栈帧出栈:
//以此为例
int main(){
int a = 1;
int ret = 0;
int res = 0;
ret = add(3,5);
System.out.println(res);
retrun 0;
}
int add(int x,int y){
int sum = 0;
sum = x+y;
return sum;
}
main函数调用了add()函数,获取了计算结果,最终打印出了res的值,过程如下图所示
- 栈在表达式中的应用
例如包含加减乘除的四则运算,编译器通过两个栈来实现,其中一个是保存操作数的栈,另一个是保存运算符的栈。从左到右,当遇到数字的时候,直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较;如果比运算符栈顶的元素的优先级高,就将当前运算符入栈;如果比当前的运算符优先级低或者相等,就从运算符栈中取出栈顶运算符,从操作数栈的栈顶取出2个操作数进行计算,将最后的结果压入操作数栈,继续比较。
- 栈在括号匹配中的应用
圆括号()、方括号[]和花括号{},并且它们可以任意嵌套。比如,{[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式;用栈来保存未匹配的左括号,从左到右依次扫描字符串;当扫描到左括号时,将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号,如果能够匹配,就弹出,继续扫描剩余的字符串。如果扫描过程中遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
- 如何利用栈实现浏览器的前进和后退功能?
采用两个栈X和Y,将首次浏览的页面依次压入栈X,当点击后退按钮时,在依次从栈X中取出数据放入栈Y中,当点击前进按钮时,再将栈Y中的元素依次取出放入栈X中,当栈X中没有数据时,就说明没有页面可以继续后退浏览了。当栈Y中没有元素,说明没有页面可以点击前进了。
1.4、队列
什么是队列
队列(queue)是只允许在一端进行插入操作、而在另一端进行删除操作的线性表。
队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作—出队dequeue(),而在表的后端(rear)进行插入操作—入队enqueue(),和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。
特点
-
队列跟栈一样,也是一种抽象的数据结构。
-
具有先进先出的特性,支持在队尾插入元素,在队头删除元素。
队列的实现
- 数组实现队列
队列可以用数组来实现,也可以用链表来实现。用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。
实现思路:
实现队列需要两个指针:一个是head指针,指向队头;一个是tail指针,指向队尾。你可以结合下面这幅图来理解。当a,b,c,d依次入队之后,队列中的head指针指向下标为0的位置, tail指针指向下标为4的位置。
当我们调用两次出队操作之后,队列中head指针指向下标为2的位置, tail指针仍然指向下标为4的位置.
随着不停地进行入队、出队操作, head和tail都会持续往后移动。当tail移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。这个问题该如何解决呢?
在出队时可以不用搬移数据。如果没有空闲空间了,我们只需要在入队时,再集中触发一次数据的搬移操作。
当队列的tail指针移动到数组的最右边后,如果有新的数据入队,我们可以将 head到tail之间的数据,整体搬移到数组中0到tail-head的位置。
- 基于链表来实现队列
/**
* 利用链表来实现队列
* */
class MyQueue<E> {
public class Node {
//链表中的一个节点
Node next = null;
int data;
//构造函数,用于添加链表时候使用
public Node(int d) {
this.data = d;
}
}
Node head = null; //队列头
Node tail = null; //队列尾
/**
* 入队操作:
* 若该队列尾空,则入队节点既是头结点也是尾节点
* 若队列不为空,则先用tail节点的next指针指向该节点
* 然后将tail节点指向该节点
* */
public void put(E data) {
Node newNode = new Node(data); //构造一个新节点
if(head == null && tail == null) {
//队列为空
head = newNode;
tail = newNode;
}else {
tail.next = newNode;
tail = newNode;
}
}
/**
* 判断队列是否为空:当头结点等于尾节点的时候该队列就为空
* */
public boolean isEmpty() {
return head == tail;
}
/**
* 出队操作:
* 若队列为空,则返回null
* 否则,返回队列的头结点,并将head节点指向下一个
* */
public Integer pop() {
if(this.isEmpty()) {
return null;
}
int data = head.data;
head = head.next;
return data;
}
public int size() {
int count = 0;
Node tmp = head;
while(tmp != null) {
count++;
tmp = tmp.next;
}
return count;
}
}
- 使用linkedList来实现队列
public class MyQueue<E> {
private LinkedList<E> list = new LinkedList<>();
// 入队
public void enqueue(E e) {
list.addLast(e);
}
// 出队
public E dequeue() {
return list.removeFirst();
}
}
public class MyQueue2<E> {
private LinkedList<E> list = new LinkedList<>();
private int size = 0; //用于统计队列的长度
public synchronized void put(E data) {
//保证线程安全,实现同步操作
list.add(data);
size++;
}
public synchronized E pop() {
size--;
return list.removeFirst(); //从头取出
}
public synchronized int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
}
普通的队列是一种先进先出的数据结构,而优先队列中,元素都被赋予优先级。当访问元素的时候,具有最高优先级的元素最先被删除。优先队列在生活中的应用还是比较多的,比如医院的急症室为病人赋予优先级,具有最高优先级的病人最先得到治疗。在Java集合框架中,类PriorityQueue就是优先队列的实现类,具体大家可以去阅读源码。
阻塞队列和并发队列(应用比较广泛)
阻塞队列其实就是在队列基础上增加了阻塞操作。
简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。
你应该已经发现了,上述的定义就是一个"生产者-消费者模型" !是的,我们可以使用阻塞队列,轻松实现一个"生产者-消费者模型" !这种基干阴寒队列实现的"生产者-消费者模型" ,可以有效地协调生产和消费的速度。当"生产 , 者"生产数据的速度过快, “消费者"来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到"消费者"消费了数据, “生产者"才会被唤醒继续"生产而且不仅如此,基于阻塞队列,我们还可以通过协调"生产者"和"消费者"的个数,来提高数据,的处理效率。比如前面的例子,我们可以多配置几个"消费者” ,来应对一个"生产者”
小结:
队列最大的特点就是先进先出,主要的两个操作是入队和出队。
它既可以用数组来实现,也可以用链表来实现。用数组实现的叫顺序队列,用链表实现的叫链式队列。
长在数组实现队列的时候,会有数据搬移操作,要想解决数据搬移的问题,我们就需要像环一样的循环队列。要想写出没有bug的循环队列实现代码,关键要确定好队空和队满的,判定条件。
阻塞队列、并发队列,底层都还是队列这种数据结构,只不过在之上附加了很多其他功能。阻塞队列就是入队、出队操作可以阴寒,并发队列就是队列的操作多线程安全。
用栈实现队列
class CQueue {
LinkedList<Integer> A, B;
public CQueue() {
A = new LinkedList<Integer>();
B = new LinkedList<Integer>();
}
public void appendTail(int value) {
A.addLast(value);
}
public int deleteHead() {
if(!B.isEmpty()) return B.removeLast();
if(A.isEmpty()) return -1;
while(!A.isEmpty())
B.addLast(A.removeLast());
return B.removeLast();
}
}
class CQueue {
//两个栈,一个出栈,一个入栈
private Stack<Integer> stack1;
private Stack<Integer> stack2;
public CQueue() {
stack1 = new Stack<>();
stack2 = new Stack<>();
}
public void appendTail(int value) {
stack1.push(value);
}
public int deleteHead() {
if(!stack2.isEmpty()){
return stack2.pop();
}else{
while(!stack1.isEmpty()){
stack2.push(stack1.pop());
}
return stack2.isEmpty() ? -1 : stack2.pop();
}
}
}
class CQueue {
Deque<Integer> stack1;
Deque<Integer> stack2;
public CQueue() {
stack1 = new LinkedList<Integer>();
stack2 = new LinkedList<Integer>();
}
public void appendTail(int value) {
stack1.push(value);
}
public int deleteHead() {
// 如果第二个栈为空
if (stack2.isEmpty()) {
while (!stack1.isEmpty()) {