文章中有参考整理其他一些有价值的博客以及官方文档的内容,如有侵权请联系删除。
常用数据结构
数据结构是指相互之间存在着一种或多种关系的数据元素的集合和该集合中数据元素之间的关系组成 。
常用的数据结构有:数组、栈、链表、队列、树、图、堆、散列表等,如图所示:
每一种数据结构都有着独特的数据存储方式,下面为大家介绍它们的结构和优缺点。
(一)数组
数组是数据结构中很基本的结构,很多编程语言都内置数组。
数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
线性表(Linear List)就是数据排成像一条线一样的结构,每个线性表上的数据最多只有两个方向。除了数组,链表、队列、栈也是线性表结构。
因为数组在存储数据时是按顺序存储的,存储数据的内存也是连续的,所以他的特点就是寻址读取数据比较容易,插入和删除比较困难。简单解释一下为什么,在读取数据时,只需要告诉数组要从哪个位置(索引)取数据就可以了,数组会直接把你想要的位置的数据取出来给你。插入和删除比较困难是因为这些存储数据的内存是连续的,要插入和删除就需要变更整个数组中的数据的位置。举个例子:一个数组中编号0->1->2->3->4这五个内存地址中都存了数组的数据,但现在你需要往4中插入一个数据,那就代表着从4开始,后面的所有内存中的数据都要往后移一个位置。这可是很耗时的。
优点:
1、按照索引查询元素速度快
2、按照索引遍历数组方便
缺点:
1、数组的大小固定后就无法扩容
2、数组只能存储一种类型的数据
3、添加,删除的操作慢,因为要移动其他的元素。
适用场景:
频繁查询,对存储空间要求不大,很少增加和删除的情况。
为什么大多数编程语言中,数组要从0开始编号,而不是1开始呢?
从数组的存储模型上来看,下标最确切的定义应该是偏移量(offset)。
如果用a代表数组的首地址,a[0]就是偏移量为0的位置,也就是首地址,a[k]就表示偏移量k个 type_size 的位置,所以计算 a[k] 的内存地址只需要用这个公式:
a[k]_address = base_address + k * type_size
但是,如果数组从 1 开始计数,那我们计算数组元素 a[k] 的内存地址就会变为:
a[k]_address = base_address + (k-1)*type_size
如果从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对CPU来说就是多了一次减法指令。
(二)栈
栈(stack)是一种特殊的线性表,仅能在线性表的一端操作,栈顶允许操作,栈底不允许操作。 栈的特点是:先进后出,或者说是后进先出(LIFO, Last In First Out),从栈顶放入元素的操作叫入栈,取出元素叫出栈。
栈的结构就像一个集装箱,越先放进去的东西越晚才能拿出来,所以,栈常应用于实现递归功能方面的场景,例如斐波那契数列、网页前进后退功能。
Java模拟简单的顺序栈
public class MyStack {
private int[] array;
private int maxSize;
private int top;
public MyStack(int size){
this.maxSize = size;
array = new int[size];
top = -1;
}
// 入栈数据
public void push(int value){
if(top < maxSize - 1){
array[++top] = value;
}
}
// 弹出栈顶数据
public int pop(){
return array[top--];
}
// 访问栈顶数据
public int peek(){
return array[top];
}
// 判断栈是否为空
public boolean isEmpty(){
return (top == -1);
}
// 判断栈是否已满
public boolean isFull(){
return (top == maxSize-1);
}
// ============测试代码============
public static void main(String[] args) {
MyStack stack = new MyStack(3);
stack.push(1);
stack.push(2);
stack.push(3);
System.out.println("栈顶数据:" + stack.peek());
while(!stack.isEmpty()){
System.out.println(stack.pop());
}
}
}
这个栈是用数组实现的,内部定义了一个数组,一个表示最大容量的值以及一个指向栈顶元素的top变量。构造方法根据参数规定的容量创建一个新栈,push()方法是向栈中压入元素,指向栈顶的变量top加一,使它指向原顶端数据项上面的一个位置,并在这个位置上存储一个数据。pop()方法返回top变量指向的元素,然后将top变量减一,便移除了数据项。要知道 top 变量指向的始终是栈顶的元素。
产生的问题:
1、上面栈的实现初始化容量之后,后面是不能进行扩容的(虽然栈不是用来存储大量数据的),如果说后期数据量超过初始容量之后怎么办?(自动扩容)
2、我们是用数组实现栈,在定义数组类型的时候,也就规定了存储在栈中的数据类型,那么同一个栈能不能存储不同类型的数据呢?(声明为Object)
3、栈需要初始化容量,而且数组实现的栈元素都是连续存储的,那么能不能不初始化容量呢?(改为由链表实现)
增强功能版栈
public class ArrayStack {
// 存储元素的数组,声明为Object类型能存储任意类型的数据
private Object[] elementData;
// 指向栈顶的指针
private int top;
// 栈的总容量
private int size;
// 默认构造一个容量为10的栈
public ArrayStack(){
this.elementData = new Object[10];
this.top = -1;
this.size = 10;
}
// 根据传入参数构建栈初始容量
public ArrayStack(int initialCapacity){
if(initialCapacity < 0){
throw new IllegalArgumentException("栈初始容量不能小于0: " + initialCapacity);
}
this.elementData = new Object[initialCapacity];
this.top = -1;
this.size = initialCapacity;
}
// 压入元素
public Object push(Object item){
// 判断是否需要扩容
isGrow(top + 1);
elementData[++top] = item;
return item;
}
// 弹出栈顶元素
public Object pop(){
Object obj = peek();
remove(top);
return obj;
}
// 获取栈顶元素
public Object peek(){
if(top == -1){
throw new EmptyStackException();
}
return elementData[top];
}
// 判断栈是否为空
public boolean isEmpty(){
return (top == -1);
}
// 删除栈顶元素
public void remove(int top){
// 栈顶元素置为null
elementData[top] = null;
this.top--;
}
/**
* 是否需要扩容,如果需要,则扩大一倍并返回true,不需要则返回false
* @param minCapacity
* @return
*/
public boolean isGrow(int minCapacity){
int oldCapacity = size;
// 如果当前元素压入栈之后总容量大于前面定义的容量,则需要扩容
if(minCapacity >= oldCapacity){
// 定义扩大之后栈的总容量
int newCapacity = 0;
// 栈容量扩大两倍(左移一位)判断是否超过int类型所表示的最大范围
if((oldCapacity << 1) - Integer.MAX_VALUE > 0){
newCapacity = Integer.MAX_VALUE;
}else{
newCapacity = (oldCapacity << 1);// 左移一位,相当于*2
}
this.size = newCapacity;
int[] newArray = new int[size];
elementData = Arrays.copyOf(elementData, size);
return true;
}else{
return false;
}
}
/*
============测试代码============
测试自定义栈类 ArrayStack
创建容量为3的栈,然后添加4个元素,3个int,1个String.
*/
public static void main(String[] args){
ArrayStack stack = new ArrayStack(3);
stack.push(1);
stack.push(2);
stack.push(3);
stack.push("Java");
System.out.println(stack.peek());
stack.pop();
stack.pop();
stack.pop();
System.out.println(stack.peek());
}
}
增强功能版栈已解决上面出现部分的问题,第一个能自动扩容,第二个能存储各种不同类型的数据。
基于链表实现的栈
public class NodeStack<T> {
private Node<T> top = null;// 栈顶
public NodeStack() {
this.top = null;
}
class Node<T> {
private T data;// 数据
private Node<T> next;// 指向下一个节点的指针
// 初始化链表
public Node(T data) {
this.data = data;
}
// 获取下一个节点
public Node<T> getNext() {
return this.next;
}
// 设置下一个节点
public void setNext(Node<T> n) {
this.next = n;
}
// 获取节点数据
public T getData() {
return this.data;
}
// 设置节点数据
public void setData(T d) {
this.data = d;
}
}
// 判断栈是否为空
public boolean isEmpty() {
if (top != null) {
return false;
}
return true;
}
// 压栈
public boolean push(T value) {
Node<T> node = new Node<T>(value);
node.setNext(top);
top = node;
return true;
}
// 出栈
public T pop() {
if (top == null) {
return null;
}
T tmp = top.data;
top = top.getNext();
return tmp;
}
// 取出栈顶的值
public T peek() {
if (isEmpty()) {
return null;
}
return top.data;
}
//============测试代码============
public static void main(String[] args) {
NodeStack<String> nodeStack = new NodeStack<String>();
//测试是否为空
System.out.println("是否为空:" + nodeStack.isEmpty());
//压栈测试
nodeStack.push("北京");
nodeStack.push("上海");
nodeStack.push("广州");
nodeStack.push("深圳");
System.out.println("是否为空:" + nodeStack.isEmpty());
//出栈
System.out.println(nodeStack.pop());
System.out.println(nodeStack.pop());
System.out.println(nodeStack.pop());
System.out.println(nodeStack.pop());
System.out.println(nodeStack.pop());
System.out.println("是否为空:" + nodeStack.isEmpty());
}
}
链表实现的栈很全面,不用指定初始化容量,可以存放任意个元素,且能够存储不同类型的数据。
利用栈实现字符串逆序
我们知道栈是后进先出,我们可以将一个字符串分隔为单个的字符,然后将字符一个一个push()进栈,在一个一个pop()出栈就是逆序显示了。eg:
将字符串 “how are you” 反转
/*
利用自定义栈进行字符串反转
*/
@Test
public void testStringReversal(){
NodeStack<Object> stack = new NodeStack<>();
String str = "how are you";
char[] arr = str.toCharArray();
for(char c : arr){
stack.push(c);
}
while(!stack.isEmpty()){
System.out.print(stack.pop());
}
}
利用栈判断分隔符是否匹配
写过xml标签或者html标签的,我们都知道<必须和最近的>进行匹配,[ 也必须和最近的 ] 进行匹配。
eg:<abc[123]abc>是符号相匹配的, <abc[123>abc] 是不匹配的。
对于 12<a[b{c}]>,我们分析在栈中的数据:遇到匹配正确的就消除。
/*
利用自定义栈判断分隔符是否匹配
遇到左边分隔符了就push进栈,遇到右边分隔符了就pop出栈,
判断出栈的分隔符是否和这个有分隔符匹配
最后栈中的内容为空则匹配成功,否则匹配失败。
*/
@Test
public void testMatch(){
NodeStack<Object> stack = new NodeStack<>();
String str = "12<a[b{c}]>";
char[] arr = str.toCharArray();
for(char c : arr){
switch (c) {
case '{':
case '[':
case '<':
stack.push(c);
break;
case '}':
case ']':
case '>':
if(!stack.isEmpty()){
char ch = stack.pop().toString().toCharArray()[0];
if(c == '}' && ch != '{' || c ==']' && ch != '[' || c ==')' && ch != '('){
System.out.println("Error:" + ch + "-" + c);
}
}
break;
default:
break;
}
}
}
(三)队列
队列(Queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表,也就是:先进先出(FIFO—first in first out)。允许插入的端是队尾,允许删除的端是队头。从一端放入元素的操作称为入队,取出元素为出队,示例图如下:
使用场景:因为队列先进先出的特点,在多线程阻塞队列管理中非常适用。
队列分为:
1、单向队列(Queue):只能在一端插入数据,另一端删除数据。
2、双向队列(Deque):每一端都可以进行插入数据和删除数据操作。
利用数组实现单向队列
public class MyQueue {
// 存放任意类型的数组队列
private Object[] queue;
// 指向队头的指针
private int front;
// 指向队尾的指针
private int rear;
// 队列总大小
private int maxSize;
// 队列中元素的实际数目
private int nItems;
// 根据传入参数构建队列指定初始容量
public MyQueue(int size){
maxSize = size;
queue = new Object[maxSize];
front = 0;
rear = -1;
nItems = 0;
}
// 向队列中插入数据
public void insert(Object value){
if(isFull()){
System.out.println("队列已满!!!");
}else{
// 如果队列尾部指向顶了,那么循环回来,执行队列的第一个元素
if(rear == maxSize - 1){
rear = -1;
}
// 队尾指针加1,然后在队尾指针处插入新的数据
queue[++rear] = value;
nItems++;
}
}
// 移除数据
public Object remove(){
if(!isEmpty()){
Object removeValue = queue[front];
queue[front] = null;
front++;
if(front == maxSize){
front = 0;
}
nItems--;
return removeValue;
}
return null;
}
// 查看队头数据
public Object peekFront(){
return queue[front];
}
// 判断队列是否满了
public boolean isFull(){
return (nItems == maxSize);
}
// 判断队列是否为空
public boolean isEmpty(){
return (nItems == 0);
}
// 返回队列的大小
public int getSize(){
return nItems;
}
// ============测试代码============
public static void main(String[] args) {
MyQueue queue = new MyQueue(3);
queue.insert(1);
queue.insert(2);
queue.insert(3);// queue队列数据为[1,2,3]
System.out.println("查看对头数据:" + queue.peekFront()); // 1
queue.remove();// queue队列数据为[2,3]
System.out.println("删除数据后查看对头数据:" + queue.peekFront()); // 2
queue.insert(4);// queue队列数据为[2,3,4]
queue.insert(5);// 队列已满,queue队列数据不变动
}
}
利用数组实现双向队列
public class MyDeque{
// 内部封装存放任意类型的数组
private Object[] elements;
// 队列默认的容量大小
private static final int DEFAULT_CAPACITY = 16;
// 扩容翻倍的基数
private static final int EXPAND_BASE = 2;
/*
队列头部下标
头部下标指向的是队列中第一位元素。
头部插入元素时,first下标左移一位;头部删除元素时,first下标右移一位。
*/
private int first;
/*
队列尾部下标
尾部下标指向的是下一个尾部元素插入的位置。
尾部插入元素时,last下标右移一位;尾部删除元素时,last下标左移一位。
*/
private int last;
// 默认构造方法
public MyDeque() {
// 设置数组大小为默认
elements = new Object[DEFAULT_CAPACITY];
// 初始化队列头部,尾部下标
first = 0;
last = 0;
}
// 取模运算
private int getMod(int logicIndex){
int innerArrayLength = elements.length;
// 由于队列下标逻辑上是循环的,当逻辑下标小于零时
if(logicIndex < 0){
// 加上当前数组长度
logicIndex += innerArrayLength;
}
// 当逻辑下标大于数组长度时
if(logicIndex >= innerArrayLength){
// 减去当前数组长度
logicIndex -= innerArrayLength;
}
// 获得真实下标
return logicIndex;
}
// 向头部插入元素
public void addFirst(Object item) {
// 头部插入元素 first下标左移一位
first = getMod(first - 1);
// 存放新插入的元素
elements[first] = item;
// 判断当前队列大小是否到达临界点
if(first == last){
// 内部数组扩容
expand();
}
}
// 向尾部插入元素
public void addLast(Object item) {
// 存放新插入的元素
elements[last] = item;
// 尾部插入元素last下标右移一位
last = getMod(last + 1);
// 判断当前队列大小是否到达临界点
if(first == last){
// 内部数组扩容
expand();
}
}
// 删除头部元素
public Object removeFirst() {
// 暂存需要被删除的数据
Object temp = elements[first];
// 将当前头部元素引用释放
elements[first] = null;
// 头部下标右移一位
first = getMod(first + 1);
return temp;
}
// 删除尾部元素
public Object removeLast() {
// 获得尾部元素下标(左移一位)
int lastIndex = getMod(last - 1);
// 暂存需要被删除的数据
Object temp = elements[lastIndex];
// 设置尾部下标
last = lastIndex;
return temp;
}
// 获取头部元素
public Object peekFirst() {
return elements[first];
}
// 获取尾部元素
public Object peekLast() {
// 获得尾部元素下标(左移一位)
int lastIndex = getMod(last - 1);
return elements[lastIndex];
}
// 内部数组扩容
private void expand(){
// 内部数组扩容两倍
int elementsLength = elements.length;
Object[] newElements = new Object[elementsLength * EXPAND_BASE];
// 将"first -> 数组尾部"的元素 复制在新数组的前面 (tips:使用System.arraycopy效率更高)
for(int i = first, j = 0; i < elementsLength; i++, j++){
newElements[j] = elements[i];
}
// 将"0 -> first"的元素 复制在新数组的后面 (tips:使用System.arraycopy效率更高)
for(int i=0, j=elementsLength - first; i < first; i++, j++){
newElements[j] = elements[i];
}
// 初始化head,tail下标
first = 0;
last = elements.length;
// 内部数组指向新扩容的数组
elements = newElements;
}
// 判断队列是否为空
public boolean isEmpty(){
// 当且仅当头尾下标相等时队列为空
return (first == last);
}
// 返回队列的大小
public int getSize(){
return getMod(last - first);
}
// 清空队列元素
public void clear() {
int head = first;
int tail = last;
while(head != tail){
elements[head] = null;
head = getMod(head + 1);
}
first = 0;
last = 0;
}
// ============测试代码============
public static void main(String[] args) {
MyDeque deque = new MyDeque();
deque.addFirst(1);
deque.addFirst(2);
deque.addFirst(3);// deque队列数据为[3,2,1]
System.out.println("查看队头数据:" + deque.peekFirst());
System.out.println("查看队尾数据:" + deque.peekLast());
}
}
(四)链表
为什么要引入链表的概念?它是解决什么问题的?
数组作为数据存储结构有一定的缺陷,在无序数组中,搜索是低效的;而在有序数组中,插入效率又很低;不管在哪一个数组中删除效率都很低;况且一个数组创建后,它的大小是不可改变的。链表,可以解决这些问题,链表基本是继数组之后第二种使用最广泛的通用存储结构。
链表是物理存储单元上非连续的、非顺序的存储结构,数据元素的逻辑顺序是通过链表的指针地址实现,每个元素包含两个结点,一个是存储元素的数据域 (内存空间),另一个是指向下一个结点地址的指针域。根据指针的指向,链表能形成不同的结构,例如单向链表,双向链表,循环链表等。
优点:
链表是很常用的一种数据结构,不需要初始化容量,可以任意加减元素;
添加或者删除元素时只需要改变前后两个元素结点的指针域指向地址即可,所以添加,删除很快;
缺点:
因为含有大量的指针域,占用空间较大;
查找元素需要遍历链表来查找,非常耗时。
适用场景:
数据量较小,需要频繁增加,删除操作的场景
单向链表
public class SingleLinkedList {
// 头节点
private Node head;
// 链表节点的个数
private int size;
public SingleLinkedList() {
size = 0;
head = null;
}
// 链表内部节点类
private class Node {
// 每个节点的数据
private Object data;
// 每个节点指向下一个节点的连接
private Node next;
public Node(Object data) {
this.data = data;
}
}
// 在链表头添加元素
public Object addHead(Object value) {
Node newHead = new Node(value);
if (size == 0) {
head = newHead;
} else {
newHead.next = head;
head = newHead;
}
size++;
return value;
}
// 在链表头删除元素
public Object deleteHead() {
Object element = head.data;
head = head.next;
size--;
return element;
}
// 查找指定元素,找到了返回节点Node,找不到返回null
public Node find(Object value) {
Node current = head;
int tempSize = size;
while (tempSize > 0) {
if (value.equals(current.data)) {
return current;
} else {
current = current.next;
}
tempSize--;
}
return null;
}
// 删除指定的元素,删除成功返回true
public boolean delete(Object value) {
if (size == 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;
size--;
} else {// 删除的节点不是第一个节点
previous.next = current.next;
size--;
}
return true;
}
// 判断链表是否为空
public boolean isEmpty() {
return (size == 0);
}
// 重写toString,显示节点信息
public String toString() {
StringBuilder sb = new StringBuilder();
if (size > 0) {
Node node = head;
int tempSize = size;
if (tempSize == 1) {// 当前链表只有一个节点
sb.append("[" + node.data + "]");
return sb.toString();
}
while (tempSize > 0) {
if (node.equals(head)) {
sb.append("[" + node.data + "->");
} else if (node.next == null) {
sb.append(node.data + "]");
} else {
sb.append(node.data + "->");
}
node = node.next;
tempSize--;
}
return sb.toString();
} else {// 如果链表一个节点都没有,直接打印[]
sb.append("[]");
return sb.toString();
}
}
// ============测试代码============
public static void main(String[] args) {
SingleLinkedList singleList = new SingleLinkedList();
singleList.addHead("A");
singleList.addHead("B");
singleList.addHead("C");
singleList.addHead("D");
System.out.println("当前链表:" + singleList);
// 删除C
singleList.delete("C");
System.out.println("删除指定节点后打印当前链表:" + singleList);
// 查找B,不为null表示找到
System.out.println(singleList.find("B"));
}
}
双向链表
public class DoublePointLinkList {
// 头节点
private Node head;
// 尾节点
private Node tail;
// 节点的个数
private int size;
public DoublePointLinkList(){
size = 0;
head = null;
tail = null;
}
// 链表内部节点类
private class Node{
// 每个节点的数据
private Object data;
// 每个节点指向下一个节点的连接
private Node next;
public Node(Object data){
this.data = data;
}
}
// 在链表头新增节点
public void addHead(Object data){
Node node = new Node(data);
if(size == 0){// 如果链表为空,那么头节点和尾节点都是该新增节点
head = node;
tail = node;
size++;
}else{
node.next = head;
head = node;
size++;
}
}
// 在链表尾新增节点
public void addTail(Object data){
Node node = new Node(data);
if(size == 0){// 如果链表为空,那么头节点和尾节点都是该新增节点
head = node;
tail = node;
size++;
}else{
tail.next = node;
tail = node;
size++;
}
}
// 删除头部节点,成功返回true,失败返回false
public boolean deleteHead(){
if(size == 0){// 当前链表节点数为0
return false;
}
if(head.next == null){// 当前链表节点数为1
head = null;
tail = null;
}else{
head = head.next;
}
size--;
return true;
}
// 判断链表是否为空
public boolean isEmpty(){
return (size ==0);
}
// 获得链表的节点个数
public int getSize(){
return size;
}
// 重写toString,显示节点信息
public String toString(){
StringBuilder sb = new StringBuilder();
if(size > 0){
Node node = head;
int tempSize = size;
if(tempSize == 1){// 当前链表只有一个节点
sb.append("[" + node.data + "]");
return sb.toString();
}
while(tempSize > 0){
if(node.equals(head)){
sb.append("[" + node.data + "->");
}else if(node.next == null){
sb.append(node.data + "]");
}else{
sb.append(node.data + "->");
}
node = node.next;
tempSize--;
}
return sb.toString();
}else{// 如果链表一个节点都没有,直接打印[]
sb.append("[]");
return sb.toString();
}
}
// ============测试代码============
public static void main(String[] args) {
DoublePointLinkList doublePointLinkList = new DoublePointLinkList();
doublePointLinkList.addHead("A");
doublePointLinkList.addHead("B");
doublePointLinkList.addTail("C");
doublePointLinkList.addTail("D");
System.out.println("当前链表:" + doublePointLinkList);
System.out.println("链表的节点个数:" + doublePointLinkList.getSize());
}
}
(五)树
前面我们介绍数组的数据结构,我们知道对于有序数组,查找很快,并可以通过二分法查找,但是想要在有序数组中插入一个数据项,就必须先找到插入数据项的位置,然后将所有插入位置后面的数据项全部向后移动一位,来给新数据腾出空间,平均来讲要移动N/2次,这是很费时的。同理,删除数据也是。
然后我们介绍了另外一种数据结构——链表,链表的插入和删除很快,我们只需要改变一些引用值就行了,但是查找数据却很慢了,因为不管我们查找什么数据,都需要从链表的第一个数据项开始,遍历到找到所需数据项为止,这个查找也是平均需要比较N/2次。
那么我们就希望一种数据结构能同时具备数组查找快的优点以及链表插入和删除快的优点,于是 树 诞生了。
树(tree)是一种抽象数据类型(ADT),用来模拟具有树状结构性质的数据集合。它是由n(n>0)个有限节点通过连接它们的边组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:
1、每个节点有零个或多个子节点
2、没有父节点的节点称为根节点
3、每一个非根节点有且只有一个父节点
4、除了根节点外,每个子节点可以分为多个不相交的子树
节点:上图的圆圈,比如A、B、C等都是表示节点。节点一般代表一些实体,在java面向对象编程中,节点一般代表对象。
边:连接节点的线称为边,边表示节点的关联关系。一般从一个节点到另一个节点的唯一方法就是沿着一条顺着有边的道路前进。在Java当中通常表示引用。
树有很多种,向上面的一个节点有多余两个的子节点的树,称为多路树。而每个节点最多只能有两个子节点的一种形式称为二叉树,这也是本篇博客讲解的重点。
树的常用术语
路径:顺着节点的边从一个节点走到另一个节点,所经过的节点的顺序排列就称为“路径”。
根:树顶端的节点称为根。一棵树只有一个根,如果要把一个节点和边的集合称为树,那么从根到其他任何一个节点都必须有且只有一条路径。A是根节点。
父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点;B是D的父节点。
子节点:一个节点含有的子树的根节点称为该节点的子节点;D是B的子节点。
兄弟节点:具有相同父节点的节点互称为兄弟节点;比如上图的D和E就互称为兄弟节点。
叶节点:没有子节点的节点称为叶节点,也叫叶子节点,比如上图的H、E、F、G都是叶子节点。
子树:每个节点都可以作为子树的根,它和它所有的子节点、子节点的子节点等都包含在子树中。
节点的层次:从根开始定义,根为第一层,根的子节点为第二层,以此类推。
深度:对于任意节点n,n的深度为从根到n的唯一路径长,根的深度为0;
高度:对于任意节点n,n的高度为从n到一片树叶的最长路径长,所有树叶的高度为0;
二叉树
在日常的应用中,我们讨论和用得更多的是树的其中一种结构,就是二叉树。
如果我们给二叉树加一个额外的条件,就可以得到一种被称作二叉搜索树(binary search tree)的特殊二叉树。
二叉搜索树要求:
1、若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值
2、若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值
3、它的左、右子树也分别为二叉排序树
4、没有键值相等的节点
遍历树是根据一种特定的顺序访问树的每一个节点。比较常用的有前序遍历,中序遍历和后序遍历。而二叉搜索树最常用的是中序遍历。
中序遍历:左子树——>根节点——>右子树
前序遍历:根节点——>左子树——>右子树
后序遍历:左子树——>右子树——>根节点
二叉搜索树的实现
public class BinarySearchTree<E extends Comparable<E>> {
// 根节点
private Node root;
// 节点个数
private int size;
// 节点对象
private class Node {
// 节点数据
E data;
// 左子节点
Node left;
// 右子节点
Node right;
Node(E e) {
data = e;
}
public boolean addChild(E e) {
// 先比较大小
// 等于
// 小于
// 大于
int val = data.compareTo(e);
if (val == 0) { // 相等不添加元素
return false;
} else if (val > 0) { // 向左添加
if (left == null) {
left = new Node(e);
size++;
return true;
} else {
// 向左侧内部递归添加元素
return left.addChild(e);
}
} else { // 向右添加
if (right == null) {
right = new Node(e);
size++;
return true;
} else {
// 向右侧内部递归添加元素
return right.addChild(e);
}
}
}
// 中序遍历: 左,根,右
public void midDisplay(StringBuilder buf) {
if (left != null) {
left.midDisplay(buf);
}
buf.append(data).append(", ");
if (right != null) {
right.midDisplay(buf);
}
}
// 前序遍历:根,左,右
public void beforeDisplay(StringBuilder buf){
buf.append(data).append(", ");
if(left != null){
left.beforeDisplay(buf);
}
if(right != null){
right.beforeDisplay(buf);
}
}
// 后序遍历:左,右,根
public void afterDisplay(StringBuilder buf){
if(left != null){
left.afterDisplay(buf);
}
if(right != null){
right.afterDisplay(buf);
}
buf.append(data).append(", ");
}
}
// 添加节点数据
public boolean add(E e) {
if (root == null) {
root = new Node(e);
size++;
return true;
} else {
return root.addChild(e);
}
}
// 删除指定节点数据
public boolean delete(E e){
// 如果树中没有数据则返回false
if(root == null){
return false;
}
// 首先搜索需要删除的节点,如果不存在直接返回false
E findNode = find(e);
if(findNode == null){
return false;
}
// 将根节点赋值给当前节点
Node current = root;
// 将根节点赋值给当前节点的父节点
Node parentNode = root;
// 当前节点是否为左节点
boolean isLeftNode = false;
// 定位需要删除节点e的位置
while (!e.equals(current.data)) {
parentNode = current;
int val = e.compareTo(current.data);
if (val < 0) {
isLeftNode = true;
current = current.left;
} else {
isLeftNode = false;
current = current.right;
}
if(e.equals(current.data)){// 已定位到需要删除的节点
break;
}
}
System.out.println("要删除节点的父节点:" + parentNode.data);
System.out.println("要删除节点是否为左节点:" + isLeftNode);
// 情况1:要删除的节点为叶节点(没有子节点的节点)
if (current.left == null && current.right == null) {
if (current == root) {
root = null;
} else if (isLeftNode) {
// 如果要删除的节点为父节点的左节点,把父节点的左节点引用置为空
parentNode.left = null;
} else {
parentNode.right = null;
}
}
// 情况2:要删除的节点有一个子节点
if (current.left == null && current.right != null) {// 有右子节点,没有左子节点
if (root == current) {
root = current.right;
} else if (isLeftNode) {
parentNode.left = current.right;
} else {
parentNode.right = current.right;
}
} else if (current.left != null && current.right == null) {// 有左子节点,没有右子节点
if (root == current) {
root = current.left;
} else if (isLeftNode) {
parentNode.left = current.left;
} else {
parentNode.right = current.left;
}
}
// 情况3:要删除的节点有两个子节点,用该节点的后继节点代替此节点
if(current.left != null && current.right != null){
/*
180
160 200
80 220
60 100
*/
//获取删除节点的后继结点
Node successor = getSuccessor(current);
System.out.println("要删除节点的后继节点为:" + successor.data);
if (root == current) {
Node leftTemp = root.left;
root = successor;
root.left = leftTemp;
} else if (isLeftNode) {
Node leftTemp = current.left;
parentNode.left = successor;
parentNode.left.left = leftTemp;
} else {
Node leftTemp = current.left;
parentNode.right = successor;
parentNode.right.left = leftTemp;
}
}
size--;
return true;
}
// 获取指定节点的后继节点(后继节点:比该节点值大的节点集合中最小的一个节点)
private Node getSuccessor(Node node) {
Node successorParent = node;
Node successor = node;
Node current = node.right;
while (current != null) {
successorParent = successor;
successor = current;
current = current.left;
}
if (successor != node.right) {
successorParent.left = successor.right;
successor.right = node.right;
}
return successor;
}
// 获取树的高度(通过递归实现,简单些)
public int getHeight(){
return getHeight(root);
}
private int getHeight(Node node){
if(node == null){
return 0;
}
int leftHeight = getHeight(node.left);
int rightHeight = getHeight(node.right);
return Math.max(leftHeight, rightHeight) + 1;
}
// 查找节点
public E find(E e){
// 查找某个节点,必须从根节点开始遍历,变量current来保存当前查找的节点
Node current = root;
while (current != null) {
int val = e.compareTo(current.data);
if (val > 0) {// 当前值比查找值大,搜索左子树
current = current.right;
} else if(val < 0){// 当前值比查找值小,搜索右子树
current = current.left;
}else{// 当前值等于查找值, 表明找到了, 返回该数据
return current.data;
}
}
return null;// 遍历完整个树没找到,返回null
}
// 查找树中最大值
public E findMax(){
/*
要找最大值,先找根的右节点,然后一直找这个右节点的右节点,
直到找到没有右节点的节点,那么这个节点就是最大值。
*/
if(root == null){// 如果树中没有数据, 返回null
return null;
}
Node current = root;
Node maxNode = null;
while(current != null){
maxNode = current;
current = current.right;
}
return maxNode.data;
}
// 查找树中最小值
public E findMin(){
/*
要找最小值,先找根的左节点,然后一直找这个左节点的左节点,
直到找到没有左节点的节点,那么这个节点就是最小值。
*/
if(root == null){// 如果树中没有数据, 返回null
return null;
}
Node current = root;
Node minNode = null;
while(current != null){
minNode = current;
current = current.left;
}
return minNode.data;
}
// 获取树的节点个数
public int getSize(){
return size;
}
// 打印节点内容(默认中序遍历打印)
public String toString() {
if (root == null) {
return "[]";
}
StringBuilder buf = new StringBuilder("[");
root.midDisplay(buf);
buf.delete(buf.lastIndexOf(","), buf.length());
return buf.append("]").toString();
}
// ============测试代码============
public static void main(String[] args) {
BinarySearchTree<Integer> tree = new BinarySearchTree<>();
/*
180
160 200
80 220
60 100
*/
tree.add(180);
tree.add(200);
tree.add(160);
tree.add(80);
tree.add(100);
tree.add(220);
tree.add(60);
System.out.println(tree);
System.out.println("树的高度:" + tree.getHeight());
System.out.println("树中节点的个数:" + tree.getSize());
System.out.println("树中最大节点:" + tree.findMax());
System.out.println("树中最小节点:" + tree.findMin());
System.out.println("查找树中是否存在200的节点:" + tree.find(200));
System.out.println("查找树中是否存在300的节点:" + tree.find(300));
tree.delete(220);
System.out.println("删除220叶节点后:" + tree);
tree.delete(160);
System.out.println("删除只有左子节点的160后:" + tree);
tree.delete(200);
System.out.println("删除只有右子节点的200后:" + tree);
tree.delete(80);
System.out.println("删除有两个子节点的80后:" + tree);
tree.delete(180);
System.out.println("删除根节点180后:" + tree);
System.out.println("删除后树中节点的个数:" + tree.getSize());
}
}
二叉树是一种比较有用的折中方案,它添加,删除元素都很快,并且在查找方面也有很多的算法优化,所以,二叉树既有链表的好处,也有数组的好处,是两者的优化方案,在处理大批量的动态数据方面非常有用。
扩展
二叉树有很多扩展的数据结构,包括平衡二叉树、红黑树、B+树等,这些数据结构二叉树的基础上衍生了很多的功能,在实际应用中广泛用到,例如mysql的数据库索引结构用的就是B+树,还有HashMap的底层源码中用到了红黑树。这些二叉树的功能强大,但算法上比较复杂,想学习的话还是需要花时间去深入的。
(六)散列表
散列表是一种使用非常广泛的数据结构,具有优秀的查找性能,根据key找到value,是性能最好的算法!(没有之一)。
散列表存储的是key-value键值对结构的数据,其基础是一个数组。用一个与集合规模差不多大的数组来存储这个集合,将数据元素的关键字映射到数组的下标,这个映射称为“散列函数”,数组称为“散列表”。查找时,根据被查找的关键字找到存储数据元素的地址,从而获取数据元素。
散列表在应用中也是比较常见的,就如Java中有些集合类就是借鉴了哈希原理构造的,例如HashMap,HashTable等,利用hash表的优势,对于集合的查找元素时非常方便的,然而,因为散列表是基于数组衍生的数据结构,在添加删除元素方面是比较慢的,所以很多时候需要用到一种数组链表来做,也就是拉链法。拉链法是数组结合链表的一种结构,较早前的hashMap底层的存储就是采用这种结构,直到jdk1.8之后才换成了数组加红黑树的结构,其示例图如下:
从图中可以看出,左边很明显是个数组,数组的每个成员包括一个指针,指向一个链表的头,当然这个链表可能为空,也可能元素很多。我们根据元素的一些特征把元素分配到不同的链表中去,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。
由于采用hash算法会出现hash冲突,一个数组下标对应了多个元素。常见的解决hash冲突的方法有:开放地址法、重新哈希法、拉链法等等,本文采用的是拉链法解决hash冲突。
散列表的实现
public class MyHashMap<K, V> {
// 内部数组,存储头节点的数组
private Node<K, V>[] nodes;
// 元素个数
private int size;
// 默认哈希表的容量
private static int defaultCapacity = 16;
// 扩展因子
private static float defaultLoadFactor = 0.75f;
// 扩容翻倍的基数
private final static int REHASH_BASE = 2;
// 节点对象(解决哈希冲突采用单向链表)
public class Node<K, V>{
K key;// 键
V value;// 值
Node<K, V> next;// 下一个节点对象
public Node(K key, V value, Node<K,V> next){
this.key = key;
this.value = value;
this.next = next;
}
}
public MyHashMap(){
}
// 根据传入指定容量和扩展因子的构造方法
public MyHashMap(int capacity, int loadFactor){
defaultCapacity = capacity;
defaultLoadFactor = loadFactor;
}
// 添加元素
public V put(K key, V value){
// 初始化数组
if(nodes == null){
nodes = new Node[defaultCapacity];
}
// 计算存储角标
int index = hash(key);
// 获取到数组角标元素,可视为头结点
Node<K, V> node = nodes[index];
// 遍历链表中节点对象
while (node != null){
// 存在重复key将value替换
if(node.key.equals(key)){
node.value = value;
return value;
}else {
node = node.next;
}
}
// 判断是否需要扩展defaultCapacity为数组长度,
// defaultLoadFactor为扩展因子,默认是0.75
if(size >= defaultCapacity * defaultLoadFactor){
// 扩容
resize();
}
// 将新添加的数据作为头结点添加到数组中
nodes[index] = new Node<>(key, value, nodes[index]);
size++;
return value;
}
// 根据key获取元素value
public V get(K key){
// 获取角标位置
int index = hash(key);
// 获取头结点
Node<K, V> node = nodes[index];
if(node != null){
// 遍历链表
while (node != null && !node.key.equals(key)){
node = node.next;
}
if(node == null){
return null;
}else {
return node.value;
}
}
return null;
}
// 根据key删除数据
public boolean remove(K key){
// 如果不存在key返回false
if(!containsKey(key)){
return false;
}
// 根据key获取hash值
int hash = hash(key);
// 将对应的数据置为null
nodes[hash] = null;
size--;
return true;
}
// 判断当前map中是否存在key
public boolean containsKey(K key) {
V value = get(key);
return (value != null);
}
// 判断当前map是否为空
public boolean isEmpty() {
return (size == 0);
}
// 获得当前map存储的键值对数量
public int getSize(){
return size;
}
// 清空整个map
public void clear() {
// 遍历内部数组,将所有桶链表全部清空
for(Node node : nodes){
node = null;
}
// size设置为0
size = 0;
}
// 重写toString
public String toString(){
// 如果map为空,直接返回"{}"
if(isEmpty()){
return "{}";
}
StringBuilder sb = new StringBuilder();
sb.append("{");
for(Node node : nodes){
// 如果数组元素不没空
if(node != null){
// 拼接上键值对,最后会多拼接上一个", "
sb.append(node.key + "=" + node.value + ", ");
}
}
// 删除字符串中最后一个", "
sb.deleteCharAt(sb.length() - 1).deleteCharAt(sb.length() - 1);
return sb.append("}").toString();
}
// 数组扩容
public void resize(){
// 扩容后要对元素重新put(重新散列),所以要将size置为0
size = 0;
// 记录先之前的数组
Node<K, V>[] oldNodes = nodes;
defaultCapacity = defaultCapacity * REHASH_BASE;
nodes = new Node[defaultCapacity];
//遍历散列表中每个元素
for (int i = 0;i < oldNodes.length;i++){
// 扩容后hash值会改变,所以要重新散列
Node<K,V> node = oldNodes[i];
while (node != null){
Node<K,V> oldNode = node;
put(node.key, node.value);// 重新散列
node = node.next;// 指针往后移
oldNode.next = null;// 将当前散列的节点next置为null
}
}
}
// hash算法(这里采用简单的取余算法)
public int hash(K key){
// 通过key的hashCode获得对应的内部数组下标
int code = key.hashCode();
// defaultCapacity为当前数组长度,默认为16;
// hash算法的值被限制在了0-(defaultCapacity-1)
return code % (defaultCapacity - 1);
}
// ============测试代码============
public static void main(String[] args) {
MyHashMap<String, Integer> map = new MyHashMap<>();
map.put("张三", 18);
map.put("李四", 20);
map.put("李四", 21);
map.put("王五", 23);
System.out.println("当前map存储的键值对数量:" + map.getSize());
System.out.println(map);
System.out.println("是否成功删除key为张三的数据:" + map.remove("张三"));
System.out.println("当前map存储的键值对数量:" + map.getSize());
System.out.println(map);
}
}
(七)堆
堆是一种比较特殊的数据结构,可以被看做一棵树的数组对象,具有以下的性质:
1、堆中某个节点的值总是不大于或不小于其父节点的值
2、堆总是一棵完全二叉树
将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。常见的堆有二叉堆、斐波那契堆等。
堆的定义如下:n个元素的序列{k1,k2,ki,…,kn}当且仅当满足下关系时,称之为堆。
(ki <= k2i,ki <= k2i+1)或者(ki >= k2i,ki >= k2i+1), (i = 1,2,3,4…n/2),满足前者的表达式的成为小顶堆,满足后者表达式的为大顶堆,这两者的结构图可以用完全二叉树排列出来,示例图如下:
因为堆有序的特点,一般用来做数组中的排序,称为堆排序。
堆排序代码实现
public class HeapSort {
// 堆排序
public static void sort(int[] arr){
// 1.构建大顶堆
for(int i = arr.length / 2 - 1;i >= 0;i--){
// 从第一个非叶子结点从下至上,从右至左调整结构
adjustHeap(arr, i, arr.length);
}
// 2.调整堆结构+交换堆顶元素与末尾元素
for(int j = arr.length - 1; j > 0;j--){
swap(arr, 0, j);// 将堆顶元素与末尾元素进行交换
adjustHeap(arr, 0, j);// 重新对堆进行调整
}
}
// 调整大顶堆(仅是调整过程,建立在大顶堆已构建的基础上)
public static void adjustHeap(int[] arr, int i, int length){
int temp = arr[i];// 先取出当前元素i
for(int k = i * 2 + 1;k < length;k = k * 2 + 1){// 从i结点的左子结点开始,也就是2i+1处开始
if(k + 1 < length && arr[k] < arr[k+1]){// 如果左子结点小于右子结点,k指向右子结点
k++;
}
if(arr[k] > temp){// 如果子节点大于父节点,将子节点值赋给父节点(不用进行交换)
arr[i] = arr[k];
i = k;
}else{
break;
}
}
arr[i] = temp;// 将temp值放到最终的位置
}
// 交换元素
public static void swap(int[] arr, int a, int b){
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
// ============测试代码============
public static void main(String []args){
int[] arr = {5,7,2,3,4,1,6,9,8};
System.out.println("堆排序前:" + Arrays.toString(arr));
sort(arr);
System.out.println("堆排序后:" + Arrays.toString(arr));
}
}
(八)图
图是由结点的有穷集合V和边的集合E组成。其中,为了与树形结构加以区别,在图结构中常常将结点称为顶点,边是顶点的有序偶对,若两个顶点之间存在一条边,就表示这两个顶点具有相邻关系。
按照顶点指向的方向可分为无向图和有向图:
图是一种比较复杂的数据结构,在存储数据上有着比较复杂和高效的算法,分别有邻接矩阵 、邻接表、十字链表、邻接多重表、边集数组等存储结构,这里不做展开,读者有兴趣可以自己学习深入。