数据结构
数据结构指数据的存储、组织方式。有人认为"程序=数据结构+算法"。因此良好的数据结构对于程序的运行至关重要,尤其是在复杂的系统中,设计优秀的数据结构能够提高系统的灵活性和性能。
在程序的设计和开发过程中难免需要使用各种各样的数据结构,比如有时需要根据产品的特点定义自己的数据结构,因此数据结构对于程序设计至关重要。本章将详细介绍常用的数据结构,具体包括栈、队列、链表、二叉树、红黑树、散列表和位图。每种数据结构都有其特点,下表便列举了常用的数据结构及其优缺点。

1、栈及其Java实现
栈(Stack)又名堆栈,是允许在同一端进行插入和删除操作的特殊线性表。其中,允许进行插入和删除操作的一端叫作栈顶(Top),另一端叫作栈底(Bottom),栈底固定,栈顶浮动。栈中的元素个数为零时,该栈叫作空栈。插入一般叫作进栈(Push),删除叫作退栈(Pop)。栈也叫作后进先出(FILO-First In Last Out)的线性表。具体的数据结构如图所示。

要实现一个栈,需要先实现以下核心方法。
- push():向栈中压入一个数据,先入栈的数据在最下边。
- pop():弹出栈顶数据,即移除栈顶数据。
- peek():返回当前的栈顶数据。
栈的具体实现过程如下。
(1)定义栈的数据结构:
java
package hello.java.datastructure;
/**
* 基于数组实现的顺序栈
* @param <E>
*/
public class Stack<E> {
private Object[] data = null;
private int maxSize=0; //栈的容量
private int top =-1; //栈顶的指针
//构造函数:根据指定的size初始化栈
Stack(){
this(10); //默认的栈大小为10
}
Stack(int initialSize){
if(initialSize >=0){
this.maxSize = initialSize;
data = new Object[initialSize];
top = -1;
}else{
throw new RuntimeException("初始化大小不能小于0:" + initialSize);
}
}
}
以上代码定义了一个Stack的类,用来存储栈的数据结构;定义了一个数组data,用来存储栈中的数据;定义了maxSize,表示栈的最大容量;定义了top,表示栈顶数据的指针;定义了两个栈的构造函数,在构造函数没有参数时默认构造一个大小为10的栈。
(2)数据入栈,向栈顶压入一个数据:
java
//进栈,第1个元素top=0;
public boolean push(E e){
if(top == maxSize -1){
throw new RuntimeException("栈已满,无法将元素入栈!")
}else{
data[++top]=e;
return true;
}
}
以上代码定义了方法push()来向栈中压入数据,在数据入栈前首先判断栈是否满了,具体的判断依据为栈顶元素的指针位置等于栈的最大容量。注意,这里使用maxSize -1是因为栈顶元素的指针是从0开始计算的。在栈有可用空间时,使用data++top=e在栈顶(top位置)上方新压入一个元素并为top加1。
(3)数据出栈,从栈顶移除一个数据:
java
//弹出栈顶的元素
public E pop(){
if(top == -1){
throw new RuntimeException("栈为空!");
}else{
return (E)data[top--];
}
}
以上代码定义了方法pop()来从栈顶移除一个数据,移除前先判断栈顶是否有数据,如果有,则通过datatop--将栈顶数据移出并给top减1。
(4)数据查询:
java
//查看栈顶元素但不移除
public E peek(){
if(top == -1){
throw new RuntimeException("栈为空!");
}else{
return (E)data[top];
}
}
以上代码定义了方法peek()来取出栈顶的数据,在取出栈顶的数据前先判断栈顶的元素是否存在,如果存在,则直接返回栈顶元素(注意:这里没有对栈顶的元素进行删除),否则抛出异常。
2、队列及其Java实现
队列是一种只允许在表的前端进行删除操作且在表的后端进行插入操作的线性表。其中,执行插入操作的端叫作队尾,执行删除操作的端叫作队头。没有元素的队列叫作空队列,在队列中插入一个队列元素叫作入队,从队列中删除一个队列元素叫作出队。因为队列只允许在队头插入,在队尾删除,所以最早进入队列的元素将最先从队列中删除,所以队列又叫作先进先出(FIFO-first in first out)线性表。具体的数据结构如图所示。

要实现一个队列,需要先实现以下核心方法。
- add():向队列的尾部加入一个元素(入队),先入队列的元素在最前边。
- poll():删除队列头部的元素(出队)。
- peek():取出队列头部的元素。
队列的简单实现如下。
(1)定义队列的数据结构:
java
package hello.java.datastructure;
public class Queue<E> {
private Object[] data=null;
private int maxSize; //队列的容量
private int front; //队列头,允许删除
private int rear; //队列尾,允许插入
//构造函数,默认的队列大小为10
public Queue(){
this(10);
}
public Queue(int initialSize){
if(initialSize >=0){
this.maxSize = initialSize;
data = new Object[initialSize];
front = rear =0;
}else{
throw new RuntimeException("初始化大小不能小于0:" + initialSize);
}
}
}
以上代码定义了一个名为Queue的队列数据结构,并定义了用于存储队列数据的data数组、队列头位置标记front、队列尾位置标记rear、队列的容量maxSize。队列的默认长度为10,在初始化时,front的位置等于rear的位置,都为0;在有新的数据加入队列时,front的值加1。
(2)向队列插入数据:
java
//在队列的尾部插入数据
public boolean add(E e){
if(rear== maxSize){
throw new RuntimeException("队列已满,无法插入新的元素!");
}else{
data[rear++]=e;
return true;
}
}
以上代码定义了方法add()来向队列中插入数据,在插入前先判断队列是否满了,如果队列有空间,则通过datarear++=e向队列的尾部加入数据并将队尾的指针位置加1。
(3)取走队列中的数据:
java
//删除队列头部的元素:出队
public E poll(){
if(empty()){
throw new RuntimeException("空队列异常!");
}else{
E value = (E) data[front]; //临时保存队列front端的元素的值
data[front++] = null; //释放队列front端的元素
return value;
}
}
以上代码定义了方法poll()来取出队列头部的数据,并将队列头部的数据设置为null以释放队列头部的位置,最后返回队列头部的数据。
(4)队列数据查询:
java
//取出队列头部的元素,但不删除
public E peek(){
if(empty()){
throw new RuntimeException("空队列异常!");
}else{
return (E) data[front];
}
}
以上代码定义了方法peek()来访问并返回队列头部的数据。
3、链表
链表是由一系列节点(链表中的每一个元素都叫作一个节点)组成的数据结构,节点可以在运行过程中动态生成。每个节点都包括两部分内容:存储数据的数据域;存储下一个节点地址的指针域。
由于链表是随机存储数据的,因此在链表中插入数据的时间复杂度为O(1),比在线性表和顺序表中插入的效率要高;但在链表中查找一个节点时需要遍历链表中所有元素,因此时间复杂度为O(n),而在线性表和顺序表中查找一个节点的时间复杂度分别为O(logn)和O(1)。
链表有3种不同的类型:单向链表、双向链表及循环链表。下面将以Java语言为基础分别介绍这3种不同的链表结构。
3.1、链表的特点
链表通过一组存储单元存储线性表中的数据元素,这组存储单元可以是连续的,也可以是不连续的。因此,为了表示每个数据元素与其直接后继数据元素之间的逻辑关系,对数据元素来说,除了存储其本身的信息,还需要存储直接后继数据元素的信息(即直接后继数据元素的存储位置)。由这两部分信息组成一个"节点"。链表数据结构的优点是插入快,缺点是数据查询需要遍历整个链表,效率慢。链表的具体数据结构如图所示。

链表根据具体的实现又分为单向链表、双向链表和循环链表。
3.2、单向链表的操作及其Java实现
单向链表(又称单链表)是链表的一种,其特点是链表的链接方向是单向的,访问链表时要从头部开始顺序读取。单向链表是链表中结构最简单的。一个单向链表的节点(Node)可分为两部分:第1部分为数据区(data),用于保存节点的数据信息;第2部分为指针区,用于存储下一个节点的地址,最后一个节点的指针指向null。具体的数据结构如图所示。

3.2.1、单向链表的操作
- (1)查找:单向链表只可向一个方向遍历,一般在查找一个节点时需要从单向链表的第1个节点开始依次访问下一个节点,一直访问到需要的位置。
- (2)插入:对于单向链表的插入,只需将当前插入的节点设置为头节点,将Next指针指向原来的头节点即可。插入后的结果如图所示。

- (3)删除:对于单向链表的删除,我们只需将该节点的上一个节点的Next指针指向该节点的下一个节点,然后删除该节点即可。具体过程如图所示。

3.2.2、单向链表的Java实现
单向链表的Java实现如下。
(1)定义单向链表的数据结构:
java
public class SingleLinkedList {
private int length; //链表节点的个数
private Node head; //头节点
public SingleLinkedList(){
size = 0;
head = null;
}
//链表的每个节点的数据结构描述类
private class Node{
private Object data; //每个节点的数据
private Node next; //每个节点指向下一个节点的连接
public Node(Object data){
this.data = data;
}
}
}
以上代码定义了名为SingleLinkedList的单向链表,并定义了length表示链表的大小;head,表示链表的头部;名为Node的内部类,表示链表的节点数据结构,在Node中有data和next两个属性,分别表示该链表节点的数据和下一个节点的连接。这样就完成了对链表数据结构的定义。
(2)插入单向链表数据:
java
//在链表头添加元素
public Object addHead(Object obj){
Node newHead = new Node(obj); //step 1 :定义新节点
if(length== 0){ //step 2 :如果链表为空,则将该节点设置为头部节点
head = newHead;
}else{//step 3: 设置当前节点为头部节点,并将当前节点的下一个节点指向原来的头部节点
head = newHead;
newHead.next = head;
}
length ++; //step 4:链表长度+1
return obj;
}
以上代码定义了方法addHead()来向链表的头部加入节点。具体操作为:首先定义一个节点;接着判断链表的长度是否为0,如果为0,则表示链表为空链表,直接将该节点设置为链表的头部节点;如果节点的长度不为0,则将当前插入的节点设置为头节点,将当前插入节点的Next指针指向原头节点即可;最后给链表的长度加1。
(3)删除单向链表数据:
java
//删除指定的元素,删除成功则返回true
public boolean delete(Object value){
if(length == 0){
return false;
}
Node current = head;
Node previous = head;
while(current.data ! = value){
if(current.next == null){
return false;
}else{
previous = current;
current = current.next;
}
}
//如果删除的节点是头节点
if(current == head){
head = current.next;
length--;
}else{//删除的节点不是头节点
previous.next = current.next;
length--;
}
return true;
}
以上代码定义了方法delete()来删除单向链表中的数据,具体的删除操作为:首先判断链表的长度,如果链表长度为0,则说明链表为空,即不包含任何元素,直接返回false;如果链表不为空,则通过while循环找到要删除的元素;如果要删除的节点是头节点,则需要把要删除的节点的下一个节点指定为头节点,删除该节点,把节点长度减1;如果删除的节点不是头节点,则将该节点的上一个节点的Next指针指向该节点的下一个节点,删除该节点,并把节点长度减1。
(4)单向链表数据查询:
java
//查找指定的元素,若找到了则返回节点Node,找不到则返回null
public Node find(Object obj){
Node current = head;
int tempSize = length;
while(tempSize > 0){
if(obj.equals(current.data)){
return current;
}else{
current = current.next;
}
tempSize--;
}
return null;
}
以上代码定义了名为find()的单向链表节点查询方法。该方法很简单,定义了一个while循环来查找数据,如果当前数据和要查找的数据相同,则返回该数据;如果不同,则将当前节点的下一个节点设置为当前节点,沿着当前节点向前继续寻找。这里将tempSize减1的目的是控制while循环的条件,在tempSize为0时表示遍历完了整个链表还没找到该数据,这时返回null。
3.3、双向链表及其Java实现
在双向链表的每个数据节点中都有两个指针,分别指向其直接后继和直接前驱节点。所以,从双向链表中的任意一个节点开始,都可以很方便地访问它的直接前驱节点和直接后继节点。具体的数据结构如图所示。

双向链表和单向链表的不同之处在于,单向链表除数据项外只定义了一个Next指针指向下一个节点,而双向链表定义了Prev和Next两个指针分别指向上一个节点和下一个节点,这样我们便可以从两个方向遍历并处理节点的数据了。双向链表的Java实现代码如下:
(1)定义双向链表的数据结构:
java
public class TwoWayLinkedList {
private Node head; //表示链表头
private Node tail; //表示链表尾
private int length; //表示链表的长度
private class Node{
private Object data;
private Node next;
private Node prev;
public Node(Object data){
this.data = data;
}
}
public TwoWayLinkedList(){
size = 0;
head = null;
tail = null;
}
}
以上代码定义了一个名为TwoWayLinkedList的双向链表的数据结构,其中定义了:head,表示链表头;tail,表示链表尾;length,表示链表长度;Node,表示链表的节点,链表的节点包含data、prev、next,分别表示节点数据、上一个节点和下一个节点。这样双向链表的数据结构就定义好了。
(2)在链表头部增加节点:
java
//在链表头部增加节点
public void addHead(Object value){
Node newNode = new Node(value);
if(length == 0){
head = newNode;
tail = newNode;
length++;
}else{
head.prev = newNode;
newNode.next = head;
head = newNode;
length++;
}
}
以上代码定义了addHead()来向链表的头部加入数据,具体操作为:首先,新建一个节点;然后,判断链表的长度,如果链表的长度为0,则说明链表是空链表,将链表的头部和尾部均设置为当前节点并将链表长度加1即可;如果链表不是空链表,则将原链表头部的上一个节点设置当前节点,将当前节点的下一个节点设置为原链表头的节点,将链表的头部节点设置为当前节点,这样就完成了双向链表的头部节点的插入;最后,需要将链表的长度加1。
(3)在链表尾部增加节点:
java
//在链表尾部增加节点
public void addTail(Object value){
Node newNode = new Node(value);
if(length == 0){
head = newNode;
tail = newNode;
length++;
}else{
newNode.prev = tail;
tail.next = newNode;
tail = newNode;
length++;
}
}
以上代码定义了名为addTail()的方法来给链表尾部加入数据,具体操作为:首先新建一个节点;然后判断链表的长度,如果链表的长度为0,则说明链表是空链表,将链表的头部和尾部均设置为当前节点并将链表长度加1即可;如果链表长度不为空,则将当前节点的上一个节点设置为原尾部节点,将原来的尾部节点的下一个节点设置为当前节点,将尾部节点设置为新的节点,这样就完成了双向链表尾部的插入;最后需要把链表的长度加1。
(4)删除链表的头部节点:
java
//删除链表的头部节点
public Node deleteHead(){
Node temp = head;
if(length ! = 0){
head = head.next;
head.prev = null;
length--;
return temp;
}else{ return null }
}
以上代码定义了一个名为deleteHead()的方法来删除链表的头部节点,具体操作为:首先定义一个临时节点来存储当前头部节点;然后判断节点的长度,如果节点的长度为0,则直接返回null;如果节点的长度不为0,则将当前头部节点设置为原头部节点的下一个节点,将头部节点的上一个节点设置为null,然后删除该节点;最后,将节点的长度减1。
(5)删除链表的尾部节点:
java
//删除链表的尾部节点
public Node deleteTail(){
Node temp = tail;
if(length ! = 0){
tail = tail.prev;
tail.next = null;
length--;
return temp;
}else{ return null }
}
以上代码定义了一个deleteTail()方法来删除链表尾部的节点,具体操作为:首先定义一个临时节点来存储当前尾部节点;然后判断节点的长度,如果节点的长度为0,则直接返回null;如果节点的长度不为0,则将当前尾部节点设置为原尾部节点的上一个节点,将尾部节点的下一个节点设置为null,然后删除该节点;最后,将节点的长度减1。
3.4、循环链表
循环链表的链式存储结构的特点是:表中最后一个节点的指针域指向头节点,整个链表形成一个环。具体的数据结构如图所示。

循环节点的实现和单向链表十分相似,只是在链表中,尾部元素的Next指针不再是null,而是指向头部节点,其他实现和单向链表相同。
4、散列表
散列表(Hash Table,也叫作哈希表)是根据数据的关键码值(Key-Value对)对数据进行存取的数据结构。散列表通过映射函数把关键码值映射到表中的一个位置来加快查找。这个映射函数叫作散列函数,存放记录的数组叫作散列表。
给定表M,存在函数f(key),对任意给定的关键字key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为散列表,称函数f(key)为散列函数。具体的数据结构如图所示。

散列表算法通过在数据元素的存储位置和它的关键字(可用key表示)之间建立一个确定的对应关系,使每个关键字和散列表中唯一的存储位置相对应。在查找时只需根据这个对应关系找到给定关键字在散列表中的位置即可,真正做到一次查找命中。
4.1、常用的构造散列函数
常用的构造散列函数如下。
- 直接定址法:取关键字或关键字的某个线性函数值为散列地址,即h(key) = key或h(key) = a×key +b,其中a和b为常数。
- 平方取值法:取关键字平方后的中间几位为散列地址。
- 折叠法:将关键字分割成位数相同的几部分,然后取这几部分的叠加和作为散列地址。
- 除留余数法:取关键字被某个不大于散列表长度m的数p除后所得的余数为散列地址,即h(key) = key/p (p≤m)。
- 随机数法:选择一个随机函数,取关键字的随机函数值作为其散列地址,即h(key)=random(key)。
- Java HashCode实现:在Java中计算HashCode 的公式为f(key) = s0× 31n-1+s1× 31n-2+...+sn-1。具体实现如下:
java
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
4.2、Hash的应用
Hash主要用于用信息安全加密和快速查询的应用场景。
- 信息安全:Hash主要被用于信息安全领域的加密算法中,它把一些不同长度的信息转化成杂乱的128位编码,这些编码的值叫作Hash值。也可以说,Hash就是找到一种数据内容和数据存放地址之间的映射关系。
- 快速查找:散列表,又叫作散列,是一种更加快捷的查找技术。基于列表集合查找的一般做法是从集合中拿出一个元素,看它是否与当前数据相等,如果不相等,则缩小范围,继续查找。而散列表是完全另外一种思路,在知道key值以后,就可以直接计算这个元素在集合中的位置,不需要一次又一次的遍历查找。