数据结构与算法——线性表详解之概念
目录
前言
数据结构可以分为逻辑结构和存储结构。
● 逻辑结构是对数据之间逻辑关系的描述,在Java中,一般体现为接口/抽象类;
● 存储结构是数据的逻辑结构在计算机内存中的映像(即数据在计算机实际存储位置,分为顺序存储结构和链式存储结构),在Java中,一般体现为实现类。
线性表是一种逻辑结构,本章将会通过定义、不同分类来一一介绍线性表不同存储结构的实现方式,及线性表的一些衍生。
一、线性表(Linear List)是什么?
1.定义:
由n(n≥0)个数据特性相同的元素构成的有限序列称为线性表。
注1:n是线性表的长度,当n=0时该线性表称为“空表”。
2.特点:
● 有且仅有一个线性起点,又称为起始结点、头结点;
● 有且仅有一个线性终点,又称为终端结点、尾结点;
● 除头结点之外,其它元素都有且仅有一个直接前驱;
● 除尾结点之外,其它元素都有且仅有一个直接后继。
注:每一个结点可以理解为一个对象。
3.术语:
线性关系:
数学: 线性关系指两个变量之间存在一次方函数关系。
数据结构:线性关系指元素与元素之间呈一对一关系。
异同:两者在图像上都呈现一条直线,但是含义不同。
个人理解:只要在图像上呈一条直线的关系,我们都可以称之为线性关系。
二、线性表的抽象——逻辑结构:
// 线性表是一种逻辑结构,主要表现数据与数据一对一的关系。
// 因此,我们可以先创建一个抽象类,用来表示数据元素的操作
public interface LinearList<T> {
// 重置线性表/清空元素
void clear();
// 返回元素个数
int size();
// 根据下标获得元素
T get(int index);
// 根据元素获得下标,元素不存在则返回-1
int index(T t);
// 根据元素获得其前驱结点,若元素下标为0/不存在,则返回null
T prior(T t);
// 根据元素获得其后继结点,若元素下标为size-1/不存在,则返回null
T next(T t);
// 在终端结点之后插入新的元素,长度+1
void add(T t);
// 根据下标增加某个元素,该下标原来的元素及之后元素后移,长度+1
void add(int index, T t);
// 根据下标删除某元素位置,下标为[0, size),长度-1
void del(int index);
// 遍历元素
void list();
}
三、线性表的实现——存储结构:
1.顺序存储结构——顺序表:
1.1 定义:用过一组地址连续的存储单元一次存储线性表的数据元素的存储结构。
1.2 组成:顺序表由数组实现,每个结点只存储数据元素即可。
1.3 特点:
逻辑关系上相邻的数据元素,其物理关系也是相邻的;
可以快速计算出任意一个元素的存储地址,并在该地址存取元素,时间复杂度O(1)
插入、删除操作时,需移动大量元素,存储空间不灵活,时间复杂度O(n)
1.4 顺序表代码实现:
public class LinearListSequence<T> implements LinearList<T> {
private Object[] array;
private int size;
// 初始化
public LinearListSequence(int size) {
array = new Object[size];
}
// 重置线性表/清空元素
@Override
public void clear() {
size = 0;
}
// 返回元素个数
@Override
public int size() {
return size;
}
// 根据下标获得元素
@Override
public T get(int index) {
rangeCheck(index);
return (T) array[index];
}
// 根据元素获得下标,元素不存在则返回-1
@Override
public int index(T t) {
for (int i = 0; i < size; i++) {
if (array[i].equals(t)) {
return i;
}
}
return -1;
}
// 根据元素获得其前驱结点,若元素下标为0/不存在,则返回null
@Override
public T prior(T t) {
for (int i = 1; i < size; i++) {
if (array[i].equals(t)) {
return (T) array[i - 1];
}
}
return null;
}
// 根据元素获得其后继结点,若元素下标为size-1/不存在,则返回null
@Override
public T next(T t) {
for (int i = 0; i < size - 1; i++) {
if (array[i].equals(t)) {
return (T) array[i + 1];
}
}
return null;
}
// 在终端结点之后插入新的元素,长度+1
@Override
public void add(T t) {
add(size, t);
}
// 根据下标增加某个元素,该下标原来的元素及之后元素后移,长度+1
@Override
public void add(int index, T t) {
isFull();
rangeCheckForAdd(index);
for (int i = size; i > index; i--) {
array[i] = array[i - 1];
}
array[index] = t;
size++;
}
// 根据下标删除某元素位置,下标为[0, size),长度-1
@Override
public void del(int index) {
rangeCheck(index);
for (int i = index; i < size; i++) {
array[i] = array[i + 1];
}
size--;
}
// 遍历元素
@Override
public void list() {
Arrays.stream(array).limit(size).forEach(System.out::println);
}
// 当线性表长度和数组长度相同时,数组已满
private void isFull() {
if (size == array.length) throw new RuntimeException("线性表已满");
}
// 查找/删除的位置必须在[0, size)之间
private void rangeCheck(int index) {
if (index < 0 || index >= size) throw new RuntimeException("索引越界异常");
}
// 新增的位置必须在[0, size]之间
private void rangeCheckForAdd(int index) {
if (index < 0 || index > size) throw new RuntimeException("索引越界异常");
}
}
2.链式存储结构——链表:
2.1 定义:用一组地址任意的存储单元存储线性表的数据元素的存储结构(这组存储单元可连续,可不连续)。
2.2 组成:链表由一系列结点组成,结点可以在运行时动态生成。结点包含两个部分:
数据域:存储数据元素,如下图a0
指针域:存储该元素的后继/前驱结点,如下图箭尾所在
2.3 特点:
逻辑关系上相邻的数据元素,其物理关系不一定相邻的。
更改、查询操作复杂,只能通过每个节点的指针域依次向后顺序扫描其余结点,时间复杂度O(n)
插入、删除操作灵活,只需更改前驱结点的指针域和插入/删除节点的指针域,时间复杂度O(1)
2.4 分类:
单链表、循环链表、双向链表
2.5 单链表代码实现:
public class LinearListLinked<T> implements LinearList<T> {
private Node<T> first;
private int size;
// 定义节点类,data存取数据域,next存取下一个指针域
private static class Node<T> {
T data;
Node<T> next;
// 结点初始化
public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
}
/* 重置线性表/清空元素:
1 依次清空每个元素的数据域和指针域
2 清空头指针
3 长度归0
*/
@Override
public void clear() {
Node cur = first;
for (int i = 0; i < size; i++) {
Node<T> next = cur.next;
cur.data = null;
cur.next = null;
cur = next;
}
first = null;
size = 0;
}
// 返回元素个数
@Override
public int size() {
return size;
}
// 根据下标获得元素
@Override
public T get(int index) {
rangeCheck(index);
return node(index).data;
}
// 根据元素获得下标,元素不存在则返回-1
@Override
public int index(T t) {
Node<T> node = first;
for (int i = 0; i < size; i++, node = node.next) {
if (node.data.equals(t)) return i;
}
return -1;
}
// 根据元素获得其前驱结点,若元素下标为0/不存在,则返回null
@Override
public T prior(T t) {
Node<T> node = first;
for (int i = 0; i < size - 1; i++, node = node.next) {
if (node.next.data.equals(t)) return node.data;
}
return null;
}
// 根据元素获得其后继结点,若元素下标为size-1/不存在,则返回null
@Override
public T next(T t) {
Node<T> node = first;
for (int i = 0; i < size - 1; i++, node = node.next) {
if (node.data.equals(t)) return node.next.data;
}
return null;
}
// 在终端结点之后插入新的元素,长度+1
@Override
public void add(T t) {
add(size, t);
}
/* 根据下标增加某个元素,该下标原来的元素及之后元素后移,长度+1
1、 当下标 == 0时,无前驱结点,因此,首先,将新结点的指针域 指向 头结点;
最后,头指针 指向 新结点
2、 当下标 != 0时,有前驱结点,因此,首先,获得插入结点的前驱结点;
然后,将新结点的指针域 指向 前驱结点的下一结点;
最后,将前驱结点的指针域 指向 新结点
*/
@Override
public void add(int index, T t) {
rangeCheckForAdd(index);
if (index == 0) {
first = new Node<>(t, first);
} else {
Node<T> priorNode = node(index - 1);
priorNode.next = new Node<>(t, priorNode.next);
}
size++;
}
/* 根据下标index删除某元素位置,index为[0, size),长度-1
1、 当index == 0时,无前驱结点,因此,只需将头指针 指向 头结点的下一结点
2、 当index != 0时,有前驱结点,因此,首先,获得删除结点的前驱结点;
最后,将前驱结点的指针域 指向 前驱结点的下个结点的下个结点
*/
@Override
public void del(int index) {
rangeCheck(index);
if (index == 0) {
first = first.next;
return;
}
Node<T> prior = node(index - 1);
prior.next = prior.next.next;
}
// 遍历元素
@Override
public void list() {
for (Node<T> node = first; node != null; node = node.next) {
System.out.println(node.data);
}
// 或者如下:
// Node<T> node = first;
// for (int i = 0; i < size; i++, node = node.next) {
// System.out.println(node.data);
// }
}
// 取出第N个结点
private Node<T> node(int index) {
Node<T> x = first;
for (int i = 0; i < index; i++) x = x.next;
return x;
}
// 查找/删除的位置必须在[0, size)之间
private void rangeCheck(int index) {
if (index < 0 || index >= size) throw new RuntimeException("索引越界异常");
}
// 新增的位置必须在[0, size]之间
private void rangeCheckForAdd(int index) {
if (index < 0 || index > size) throw new RuntimeException("索引越界异常");
}
}
3. 顺序存储结构和链式存储结构的区别
顺序表 | 链表 | ||
空间 | 存储空间 | 预先分配,会导致空间闲置或溢出现象 | 动态分配,不出现空间闲置或溢出现象 |
存储密度 | 不用为结点间的逻辑关系增加额外开销,存储密度=1 | 需要借助指针来体现结点间的逻辑关系,存储密度<1 | |
时间 | 存取 | 随机存取,时间复杂度为O(1) | 顺序存取,时间复杂度为O(n) |
插入、删除 | 最多移动所有元素,时间复杂度为O(n) | 不需移动元素,确定插入/删除位置后,时间复杂度为O(1) | |
适用情况 | 1.表长变化不大且能事先确定变化范围 2.少插入、删除,多按下标存取 | 1.表长变化较大 2.多插入、删除,少按下标存取 |
注:存储密度定义:指结点数据本身所占空间和整个结点所占空间之比,即
存储密度 = 结点数据本身所占空间 / 结点所占空间
存储密度越大,存储空间利用率越高。
四、限制的限定表
根据不同的需求,我们衍生了两种操作受限的限定表,分别为栈和队列。
1.栈
1.1 定义:一种运算受限的线性表。限定仅在表尾进行插入和删除操作的线性表。
1.2 存储结构:顺序结构/链式结构,顺序结构较为常见。
1.3 存取运算:后进先出(LIFO,Last In First Out)
1.4 术语:
栈顶:表尾
栈底:表头
空栈:一个元素都没有。此时,栈顶和栈底指向第一个元素空间
入栈/压栈:插入元素到栈顶(表尾),栈底不变,栈顶上移
出栈/弹栈:从栈顶(表尾)删除元素,栈底不变,栈顶下移
1.5 顺序栈思路及实现:
思路(如上图):
创建一个数组array表示一个顺序栈;
创建一个栈顶指针size用来表示以下情况:
栈空:当size == 0时, 表示栈顶指针指向数组第一个元素空间时,栈空,无法出栈;
栈满:当size == array.length时,表示栈顶指针指向数组最后一个元素空间时,栈满,无法入栈;
栈顶指针上移:size++;
栈顶指针下移:size--;
入栈:先判断是否栈满,栈未满时才可以存入元素。栈未满,存入元素,size++;
出栈:先判断是否栈空,栈未空时才可以取出元素。栈未空,取出元素,size--;
public class Stack<T> {
private Object[] array;
private int size; // size相当于栈顶rear
// 初始化
public StackSequence(int size) {
array = new Object[size];
}
// 重置线性表/清空元素
public void clear() {
size = 0;
}
// 返回元素个数
public int size() {
return size;
}
// 入栈
public void push(T t) {
isFull();
array[size++] = t;
}
// 获得栈尾元素
public T peek() {
isEmpty();
return (T) array[size - 1];
}
// 出栈
public T pop() {
isEmpty();
return (T) array[--size];
}
// 获得距离栈顶的位置,起始值1
public int search(T o) {
for (int i = size - 1; i > -1; i--) {
if (array[i].equals(o)) return size - i;
}
return -1;
}
// 遍历元素
public void list() {
for (int i = size - 1; i > -1; i--) {
System.out.println(array[i]);
}
}
// 当栈长度和数组长度相同时,栈已满
private void isFull() {
if (size == array.length) throw new RuntimeException("栈已满");
}
// 当栈长度 == 0时,栈已空
private void isEmpty() {
if (size == 0) throw new RuntimeException("栈空了");
}
}
1.6 递归与栈:
递归定义:程序调用自身的编程称为递归。如,斐波纳契数列:F0=0,F1=1,Fn=F(n-1)+F(n-2)(n>=2,n∈N*)
方法1:通过递归来实现斐波那契数列
优点:代码简洁,易于理解
缺点:
1、时间和空间的消耗比较大
递归由于是函数调用自身,而函数的调用是消耗时间和空间的,每一次函数调用,都需要在内存栈中分配空间以保存参数,返回值和临时变量,而往栈中压入和弹出
数据也都需要时间,所以降低了效率。
2、重复计算
递归中有很多计算都是重复的,递归的本质是把一个问题分解成两个或多个小 问题,多个小问题存在重叠的部分,即存在重复计算,如斐波那契数列的递归实现。
3、调用栈溢出
递归可能时调用栈溢出,每次调用时都会在内存栈中分配空间,而栈空间的容量是有限的,当调用的次数太多,就可能会超出栈的容量,进而造成调用栈溢出。
public static Integer fn(Integer num) {
if (num == 2 || num == 1) return 1;
return fn(--num) + fn(--num);
}
方法2:通过栈代替递归实现斐波那契数列
public static Integer fn(Integer num) {
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(1);
for (int i = 3; i < num; i++) {
Integer last = stack.pop();
Integer penult = stack.pop();
stack.push(last);
stack.push(last + penult);
}
return stack.pop() + stack.pop();
}
2.队列
2.1 定义:一种运算受限的线性表。限定仅在表尾进行插入,仅在表头进行删除的线性表。
2.2 存储结构:顺序结构/链式结构,链式结构较为常见
2.3 存取运算:先进先出(FIFO,First In First Out)
2.4 术语:
队头:表头
队尾:表尾
空队:一个元素都没有
入队:插入元素到队尾,队头不变,队尾右移
出队:从队头删除元素,队头右移,队尾不变
2.5 循环顺序队列思路及实现(重要):
普通顺序队列思路:
创建一个数组array表示一个顺序队列;
创建一个队头指针front和队尾指针rear用来表示以下情况:
队空:当front == rear时, 表示队头指针和队尾指针指向同一个元素空间时,栈空,无法出栈;
队满:当rear == array.length时,表示队尾指针指向数组最后一个元素空间时,栈满,无法入栈;
队头指针上移:front++;
队尾指针上移:rear++;
入队:先判断是否队满,队未满时才可以存入元素。队未满,存入元素,rear++;
出队:先判断是否队空,队未空时才可以取出元素。队未空,取出元素,front++;
问题1:为什么需要队列需要循环?
如图4-3为普通队列。普通队列当队头上移后,队头前的空间无法再次利用,产生假溢出现象。
问题1解决措施:循环队列:
问题2:无法区分队空、队满,如图4-5
解析:一个长度为n的队列有如下情况:0、 1、 2、 ...、 n个元素,共计n+1种情况。而front和rear只能产生n种情况
问题2解决措施:
1 设一个标志以区分队空、队满
2 设一个变量,记录元素个数
3 少用一个元素空间,如图4-6(常用,现实中队列的容量是可扩展的)
循环队列思路:
创建一个数组array表示一个顺序队列;
创建一个队头指针front和队尾指针rear用来表示以下情况:
队空:当front == rear时, 表示队头指针和队尾指针指向同一个元素空间时,栈空,无法出栈;
队满:当front== (rear+1)%array.length时,表示队尾指针指向数组最后一个元素空间时,栈满,无法入栈;
数量:(rear-front+array.length)%array.length
队头指针上移:front=++front%array.length;
队尾指针上移:rear=++rear%array.length;
入队:先判断是否队满,队未满时才可以存入元素。队未满,存入元素,rear++;
出队:先判断是否队空,队未空时才可以取出元素。队未空,取出元素,front++;
/**
* 循环顺序队列最大的缺点就是新增元素的时间复杂度为O(n)
* 思路:数组(长度n),通过队头和队尾只能表示n种情况
* 若队列长度为n,会产生n+1种情况,因此数组长度为n+1
* @author dusiliang
* @date 2021/6/19
*/
public class QueueSequence<T> {
private Object[] array;
private int front; // 队头
private int rear; // 队尾
// 初始化
public QueueSequence(int size) {
array = new Object[size + 1];
}
// 重置线性表/清空元素
public void clear() {
rear = front;
}
// 返回元素个数
public int size() {
return (rear + array.length - front) % array.length;
}
// 入列
public void add(T t) {
isFull();
array[rear] = t;
rear = next(rear);
}
// 获得队尾元素
public T peek() {
isEmpty();
return (T) array[front];
}
// 出列
public T poll() {
isEmpty();
T t = (T) array[front];
front = next(front);
return t;
}
// 遍历元素
public void list() {
for (int i = front; i < rear; i = next(i)) {
System.out.println(array[i]);
}
}
// 获取下一个位置
private int next(int i) {
return (i + 1) % array.length;
}
// 当队列长度和数组长度相同时,队列已满
private void isFull() {
if ((rear + 1) % array.length == front) throw new RuntimeException("队列满了");
}
// 当队列长度 == 0时,队列已空
private void isEmpty() {
if (front == rear) throw new RuntimeException("队列空了");
}
}
2.6 循环链式队列思路及实现:
循环链式队列出队后,出队元素直接释放即可,无需关心空间重复利用。因此,思路如下:
创建一个结点last,存储最后一个元素。队空时该结点为null;只有一个元素时,该结点既是队头又是队尾,该结点的指针域指向自己;
创建一个变量size,既表示队尾指针,又表示长度;
队空:size=0
入队:区分队空情况和非队空情况
出队:区分队空情况和非队空情况
/**
* 思路:采用尾插法、尾指针循环链表
* @author dusiliang
* @date 2021/6/17
*/
public class Queue<T> {
private Node<T> last; // last相当于队尾元素,last.next相当于队头元素
private int size; // size相当于栈顶rear
private static class Node<T> {
T data;
Node<T> next;
// 结点初始化
public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
}
/* 重置线性表/清空元素:
1 依次清空每个元素的数据域和指针域
2 清空头指针
3 长度归0
*/
public void clear() {
Node cur = last;
for (int i = 0; i < size; i++) {
Node<T> next = cur.next;
cur.data = null;
cur.next = null;
cur = next;
}
last = null;
size = 0;
}
// 返回元素个数
public int size() {
return size;
}
// 入列
public void add(T t) {
if (last == null) {
last = new Node<>(t, null);
last.next = last;
} else {
last.next = new Node<>(t, last.next);
last = last.next;
}
size++;
}
// 获得栈尾元素
public T peek() {
isEmpty();
return last.next.data;
}
// 出列
public T poll() {
isEmpty();
T data = last.next.data;
if (size == 1) {
last = null;
} else {
last.next = last.next.next;
}
size--;
return data;
}
// 遍历元素
public void list() {
Node<T> node = last.next;
for (int i = 0; i < size; i++, node = node.next) {
System.out.println(node.data);
}
}
// 当栈长度 == 0时,栈已空
private void isEmpty() {
if (size == 0) throw new RuntimeException("栈空了");
}
}
五、线性表的推广
1.串(String,字符串):
定义:一种内容受限的线性表。是由0个或多个任意字符组成的有限序列。
存储结构:顺序(常用)
掌握算法:
相等
获取子串的下标
BF算法:时间复杂度O(m*n)
KMP算法:时间复杂度O(m+n)
2.数组:
定义:按类型相同的数据元素构成的有序集合。
3.广义表:
定义:是一种非连续性的数据结构,是线性表的一种推广
总结
无