1. 线性表
- 线性表:(linear list)是数据结构的一种,一个线性表是n个具有相同特性的数据元素的有限序列。线性表是最基本、最简单、也是最常用的一种数据结构。
- 线性表主要由顺序表示或链式表示。在实际应用中,常以栈、队列、字符串等特殊形式使用。
- 在数据结构逻辑层次上细分,线性表可分为一般线性表和受限线性表。
- 一般线性表也就是我们通常所说的“线性表”,可以自由的删除或添加结点。
- 受限线性表主要包括栈和队列,受限表示对结点的操作受限制。
- 记录:在稍复杂的线性表中,一个数据元素可由多个数据项(item)组成,此种情况下常把数据元素称为记录(record);
- 文件:含有大量记录的线性表又称文件。
1.1 顺序表示
- 线性表的顺序表示指的是用一组地址连续的存储单元依次存储线性表的数据元素。
- 数组是顺序表示的一种实现方式(在高级语言中)。
- 优点:
- 随机访问速度很快;
- 缺点:
- 数组长度固定,如果要增加数组长度,需要重新申请一块内存区域,然后把现有数组复制过去。
- 只能存储相同类型的数据
- 插入和删除操作耗时长。
1.2 链式表示
- 链式表示:用一组任意的存储单元存储线性表的数据元素(这组存储单元可以是连续的,也可以是不连续的)。
- 使用链表结构可以克服数组链表需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。
- 但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。
- 在链式表示中,的基本存储单位被称为结点。结点中包含有两个域:
- 数据域:存储数据元素。
- 指针域:存储直接前驱/后继的存储位置。指针域中存储的信息称作指针或链。
- 单链表:如果链表中的每个结点中只包含一个指针域,那么就称它为单链表或线性链表。
- 双向链表:结点中包含两个指针域,分别指向该结点的直接前驱和直接后继结点。
- 循环链表:表中最后一个结点的后继指针域指向了头结点,整个链表形成了一个环。
1.3 静态链表
- 用数组描述的链表,即称为静态链表。
- 在静态链表的节点中,指针域存储的并不是指针,而是数组下角标,这样就能通过这些数组索引指出下一个元素在数组中的位置。
- 这种存储结构,仍需要预先分配一个较大的空间,但在作为线性表的插入和删除操作时不需移动元素,仅需修改指针,故仍具有链式存储结构的主要优点。
2. 栈
- 栈(stack)又名堆栈,它是一种运算受限的线性表。限定仅在表尾进行插入和删除操作的线性表。这一端被称为栈顶,相对地,把另一端称为栈底。
- 向栈顶加入元素的操作被称为进栈、入栈或压栈。
- 从栈顶取出元素的操作被称为出栈或退栈。
- 栈的代码实现:
- 在使用代码实现栈之前,我们需要先明确定义栈的几种操作以及状态:
- 入栈:栈指针加一;
- 出栈:栈指针减一;
状态 | 表示方案1 | 表示方案2 |
栈空 | 栈指针 = | 栈指针 = |
栈满 | 栈指针 = | 栈指针 = |
- 对于栈空与栈满,可以采取多种表现方式,不同的表现方式有不同的实现细节:
package 数据结构;
public class Stack {
private int top = 0; // 栈顶指针
private final int[] stack;// 栈空间
public final int size; // 栈的容量
/**
* Stack构造函数
* @param size 栈的尺寸大小
*/
Stack(int size) {
this.size = size;
stack = new int[size];
}
/**
* 入栈操作
* @param element 要入栈的元素
* @return 入栈操作是否成功(栈满会导致失败)
*/
public boolean push(int element) {
if (top < size) {
stack[top++] = element;
return true;
} else return false;
}
/**
* 出站操作
* @return 栈顶元素,如果栈空会返回null
*/
public int pop() {
if (top > 0) return stack[--top];
else return null;
}
/**
* @return 栈是否为空
*/
public boolean isEmpty() {return top <= 0;}
/**
* @return 栈是否为满状态
*/
public boolean isFull() {return top >= size;}
/**
* @return 当前栈中的元素数量
*/
public int getElementNumber() { return top; }
}
3. 队列
- 队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。
3.1 顺序队列
- 建立顺序队列结构必须为其静态分配或动态申请一片连续的存储空间,并设置两个指针进行管理。一个是队头指针front,它指向队头元素;另一个是队尾指针rear,它指向下一个入队元素的存储位置。
- 顺序队列会存在存储空间的浪费,因为无论是头指针还是尾指针,他们都只能不断向后移动,这样随着入队出队操作的不断进行,前面的空间就会闲置下来,没法被利用。
- 顺序队列中的溢出现象:
- 下溢现象:当队列为空时,做出队运算产生的溢出现象。“下溢”是正常现象,常用作程序控制转移的条件。
- 真上溢现象:当队列满时,做进栈运算产生空间溢出的现象。“真上溢”是一种出错状态,应设法避免。
- 假上溢现象:由于入队和出队操作中,头尾指针只增加不减小,致使被删元素的空间永远无法重新利用。当队列中实际的元素个数远远小于向量空间的规模时,也可能由于尾指针已超越向量空间的上界而不能做入队操作。该现象称为"假上溢"现象。
- 代码实现:
- 头指针
front
的作用是:指向队首的第一个元素。 - 尾指针
rear
的作用为:指向队尾最后一个元素的下一个位置 - 根据上面的定义,可以得到顺序队列的几种特殊状态:
- 队列空:
front = rear
- 队列满:
rear = array.length
(array.length
指代队列空间的长度)
- 则顺序队列的java实现为:
package 数据结构;
public class Queue1 {
private int front = 0;
private int rear = 0;
private final int[] queue;
// 构造函数
public Queue1(int size) {
queue = new int[size];
}
// 入队列
public boolean push_back(int value) {
if (rear == queue.length) return false;
else queue[rear++] = value;
return true;
}
// 出队列
public int pop_front() {
if (front == rear) System.out.println("队列空,出队列错误");
else return queue[front++];
return -1;
}
}
3.2 循环队列
- 顺序队列存在存储空间浪费的问题,为了解决这个问题,让数组空间循环利用起来。可以采用循环队列。
- 循环队列的基本思路就是收尾相连,循环利用;每当首/尾指针指向数组的最后一个单位空间的下一个空间时(溢出),就让它重新指向数组的第一个元素。
- 代码实现
- 头指针:定义为指向队首的第一个元素。
- 头指针的移动:
head = (head + 1) % n
- 尾指针:定义为指向队尾最后一个元素的下一个位置。
- 尾指针的移动:
tail = (tail + 1) % n
- 则队列的几个特殊状态可以表示为:
- 队列空:
head = tail
; - 队列满:
head = (tail + 1) % n
;即队尾指针的下一个位置就是队首,由于尾指针指向的位置总是没有元素的,所以这里实际会有一个空间的浪费,但这可以帮助我们区分队列空和队列满,是有必要的。
- 则循环队列的
java
实现为:
package 数据结构;
public class Queue {
private int head = 0;
private int tail = 0;
private final int[] queue;
// 构造函数
public Queue(int size) {
queue = new int[size];
}
// 向队尾添加元素
public boolean push_back(int value) {
if (head == (tail + 1) % queue.length) return false; // 队列满
queue[tail] = value;
tail = (tail + 1) % queue.length;
return true;
}
// 取队首元素
public int pop_head() {
if (head == tail) throw new Error("队列空"); // 队列空
int value = queue[head];
head = (head + 1) % queue.length;
return value;
}
// 获取队列中的元素数量
public int getElementNumber() {
if (head > tail) return tail + queue.length - head;
else return tail - head;
// 上面的两个可以合并为:
// (tail + queue.length - head) % queue.length
}
// 判空
public boolean isEmpty() {return head == tail;}
// 判满
public boolean isFull() {return head == (tail + 1) % queue.length;}
}
4. 相关问题
4.1 一元多项式的表示及运算
- 在数学上,一个一元多项式可按升幂写成:
- 它由个系数唯一的确定。因此,在计算机中,我们可以用一个线性表P来表示这样的一元多项式:
- 每一项的
x
的指数i
隐含在其系数的序号中。这样,多项式之间的运算也就可以转换成这种线性表之间的运算。 - 对于这样多项式,如果采用上面这种存储方案,会浪费大量的存储空间在无意义的0上,对于他们可以只存储非0的项,这样的话我们就需要空间来存储
x
的指数i
。这最多只会比上面的线性表P策略增加2倍的存储空间。 - 考虑到在一元多项式的运算中可能会改变项的次数和系数,使用链式存储策略可能会更便捷。方便插入以及删除。
4.2 进制转换问题
- 进制之间的转换需要抓住一个核心要点,那就是:转换前后数值是不变的,它们在数值运算中是完全等价的。
- 以为例,等号的左边与右边是等价的,只不过是进制不同,那么:
- 等号两边对2取余都等于0;对于2进制来说,除以2其实一次有符号右移操作,除以2的余数其实就是该二进制串的最低位。
- 而上面这种关系是可以推广的,就比如10进制下的1234,对10取余的结果是4,这其实就是1234的一次“有符号右移”操作,其余数就是1234的最低位。这个关系放到其它进制下同样成立,只不过要对它们的基数取余。
- 而对于小数部分来说,其进制转换是通过“符号左移,取整数位”,以为例:
- 等号两边同时乘以2,那么该等号任然成立,此时变成了,而此时我们取整数位得到的就是1,这其实就是二进制串中小数点后的最高位;也即通过这种乘2取整的方法,我们能够不断获得二进制下的最高位。
- 上面这种方法同样可以推广到其它进制,只不过这里的2要换成对应进制下的基数。
- 综上所述,进制转换的方法可以总结为:
- 整数部分转化:一个整数x(不需要关心它的进制)要转N进制,则过程如下:
-
x
对N
取余,并将结果放入栈中; -
x
整除N
,并将结果赋值给x
自己; - 判断
x
是否等于0;
- 如果
x = 0
,则转化结束。 - 如果
x != 0
,则回到步骤1,重复进行。
- 转化结束后,出栈顺序即为最终的N进制数(栈顶为最高位)。
- 小数部分转化:一个小数
x
(不需要关心它的进制)要转化成N进制,过程如下:
-
x
乘以N
,并将结果赋值给x
; - 对
x
取整,然后将结果存入队列中。 - 舍弃
x
的整数部分,然后判断x
是否等于0;
- 如果
x = 0
,则转化结束。 - 如果
x != 0
,则回到步骤1,重复进行。
- 转化结束后,出队列顺序即为最终的N进制数(队首顶为最高位)。
- 以为例,有如下转化过程:
- 进制转换的代码实现(
JavaScript
)为:
function toStringN(number, radix) {
let num1 = Math.floor(number); // 整数部分
let num2 = number - num1; // 小数部分
const stack = [];
while (num1 !== 0) {
stack.push(num1 % radix);
num1 = Math.floor(num1 / radix);
}
const queue = [];
while (num2 !== 0) {
num2 *= radix;
num1 = Math.floor(num2);
queue.push(num1);
num2 -= num1; // 去除整数部分
}
if (stack.length === 0) num1 = 0;
else num1 = stack.reverse().join('');
if (queue.length === 0) num2 = '';
else num2 = '.' + queue.join('');
return num1 + num2;
}
4.3 括号匹配问题
- 借助栈这种数据结构可以很容易的完成括号匹配问题。
4.4 表达式求值(中缀表达式)
- 任何一个表达式都是由操作数、运算符和界限符组成的,我们称它们为单词。
- 操作数:既可以是常数也可以是被说明为变量或常量的标识符;
- 运算符:可以是算术运算符、关系运算符和逻辑运算符三类;
- 基本界限符:有左右括号和表达式结束符等。
- 我们把运算符和界限符统称为算符,它们构成的集合命名为
OP
。算符之间存在优先权关系,现在先给出算符的优先关系表格:
+ | - | * | / | ( | ) | # | |
+ | > | > | < | < | < | > | > |
- | > | > | < | < | < | > | > |
* | > | > | > | > | < | > | > |
/ | > | > | > | > | < | > | > |
( | < | < | < | < | < | = | N/A |
) | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
# | < | < | < | < | < | N/A | = |
- 这里的
< > =
表示的含义分别是:;N/A
表示不存在,如果出现了就说明表达式是非法的,是不正确的。
- 部分书籍上对于
)
的处理可能会有区别,不过我们很明显可以知道)
是不可能出现在栈顶的,因为它不可能入栈,它一出现就要和(
相互抵消,所以所有)
作为的情况都是非法的,都是不存在的。
- 这里的优先级关系是“非对称”的,与他们出现的位置有关,如在上表中既有,也有。
- 表达式求值的算法基本思想是:
- 设置两个栈,一个是用于存储操数和运算结果的
OPND
栈,另外一个是用于存储算符的OPTR
栈。 - 然后置
OPND
栈为空栈,将起始符#
入栈作为栈底元素。置算符栈OPTR
为空。 - 依次读取表达式串,若是操作数则进
OPND
栈,若是运算符则和OPTR
栈顶元素进行优先级比较(栈顶元素为):
- 若栈顶元素优先级更高(),则弹出现在的栈顶元素,同时弹出
OPND
栈顶的两个操作数元素,他们用弹出的栈顶元素作运算,然后将运算结果压入OPND
栈中。之后目标运算符继续与OPTR
栈顶元素比较优先级,重复这一步骤。 - 若两者的优先级相等(),则说明它们是一对的,可以相互抵消,弹出栈顶元素,同时丢掉当前运算符。
- 若栈顶元素的优先级更低(),则该操作符直接入栈。
- 重复步骤3,知道表达式读取结束为止(注意最优一个元素应该是结束符
#
);
- 如果读取结束后
OPTR
栈不为空或者OPND
栈中的元素数量大于1,则说明表达式不正确,无法计算。 - 如果读取结束后
OPTR
栈为空或且OPND
栈中只有一个元素,则这个元素就是表达式的计算结果。
- 那么,这种中缀表达式的计算方式的实现原理是什么了?运算符的优先级又是如何确定的了?我们以这个表达式为例:
- 首先我们要明确,根据上述算法,我们容易知道,一个运算符是否进行运算,关键要看下一个要入栈的运算符是什么。
表达式解析 | OPND | OPTR | 注解 |
# | # | ||
1 | 1 | # | |
+ | 1 | #+ | |
( | 1 | #+( | |
2 | 1,2 | #+( | |
+ | 1,2 | #+(+ | |
3 | 1,2,3 | #+(+ | |
* | 1,2,3 | #+(+* | 从运算规则中我们容易知道,3*4应该是最先运算的 |
4 | 1,2,3,4 | #+(+* | 而*号是否进行运算关键要看它的下一个运算符 |
+ | 1,2,12 | #+(+ | 为了让 |
1,14 | #+(+ | 按照运算规则,下一步应该是2+12,所以我们需要设置 | |
5 | 1,14,5 | #+(+ | |
) | 1,19 | #+( | 遇到右括号了,现在应该完成括号内的所有计算,所以有 |
1,19 | #+ | 左右括号相遇,他们应该相互抵消,所以有 | |
/ | 1,19 | #+/ | |
6 | 1,19,6 | #+/ | 按照运算规则,下一步应该计算19/6 |
+ | 1,19/6 | #+ | 所以我们需要设置优先级 |
25/6 | #+ | 接下来应该计算1+19/6,所以我们需要设置 | |
7 | 25/6,7 | #+ | 接下来应该计算25/6 + 7,这需要看下一个操作符 |
# | 67/6 | # | 当我们遇见结束符#时,应该完成所有的计算,所以需要设置 |
67/6 | 当开始符遇见结束符时,他们应该相互抵消,所以有 |
- 借助上面的例子,我们就不难理解那运算符的优先级是干什么的了,以及如何推到出他们。甚至可以进行自行扩展。