本篇文章是为了记录自己在学习数据结构时的笔记,会对常见的数据结构做基本的介绍以及使用Java语言进行实现。包括
- 动态数组
- 栈
- 队列
- 链表
- 二分搜索树
- 优先队列和堆
- 线段树
- Trie树
- 并查集
- AVL树
- 红黑树
- 哈希表
动态数组
API介绍
数组是一种根据下标操作的数据结构,它的查询速度很快,但是它有缺点,那就是数组的容量一旦在创建时确定,就不能进行更改,所以为了克服这一缺点,我们实现一个自己的数组,并除此以外,还会实现一些方法,包括以下
- add(int index, E e)
- 向指定index添加元素e
- get(int index)
- 获得指定index的元素
- remove(int index)
- 删除指定index的元素并返回该元素
- set(int index, E e)
- 更改index处的元素为e
- getSize()
- 返回数组中元素的个数
- contains(E e)
- 查询数组是否包含元素e
- isEmpty()
- 查看数组是否为空(是否有元素)
- find(E e)
- 返回数组中元素e第一次出现的index,若没有元素e,则返回-1
新建一个Array类,它含有两个私有成员变量
- E[] data
- 用以保存数据
- int size
- 用以记录数组中元素的个数
除此以外还有两个构造方法
- Array(int capacity)
- 设定数组的容量
- Array()
- 容量默认为10
public class Array<E> {
private E[] data;
private int size;
public Array(int capacity) {
data = (E[]) new Object[capacity];
size = 0;
}
public Array() {
this(10);
}
}
现在我们来实现上面提到的方法。
方法实现
首先来实现getSize()方法,这个是返回数组元素的个数的,我们直接返回size即可
public int getSize() {
return size;
}
isEmpty()是为了查看数组中是否还有元素,如果size为0的话说明数组为空,所以我们返回size == 0即可
public boolean isEmpty() {
return size == 0;
}
现在来实现add(int index, E e)方法,该方法的实现是将index后面的元素都向后移动一位,然后在index处插入元素e
public void add(int index, E e) {
//对inex进行验证 如果不符合规范则抛出异常
if (index < 0 || index > size) {
throw new IllegalArgumentException("参数错误");
}
//将元素向后移动
for (int i = size; i > index; i--) {
data[i] = data[i - 1];
}
//在index处插入元素e
data[index] = e;
//数组中元素个数+1
size++;
}
根据这个方法,我们可以很快的实现addFirst(E e)和addLast(E e)方法,这两个方法一个是在数组头添加元素,一个是在数组的末尾添加一个元素
public void addLast(E e) {
//在index = size处添加元素 即在数组末尾添加一个元素
add(size,e);
}
public void addFirst(E e) {
//在index = 0处添加一个元素 即在数组头添加一个元素
add(0,e);
}
下面来实现remove(int index)方法,该方法是删除index处的元素,并将该元素返回,以添加的操作相反,删除是将后面的元素向前移动,覆盖掉index处的元素即可删除
public E remove(int index) {
//参数检查
if (index < 0 || index >= size) {
throw new IllegalArgumentException("参数错误");
}
//获得index处的元素用以返回
E e = data[index];
//将元素从后向前移一个
for (int i = index; i < size - 1; i++) {
data[i] = data[i+1];
}
//数组中元素个数-1
size --;
//返回删除的元素
return e;
}
同理,根据这个方法我们可以快速的实现removeLast()和removeFirst()方法
public E removeLast() {
return remove(size -1);
}
public E removeFirst() {
return remove(0);
}
我们可以添加一个删除指定元素的方法removeElement(E e),我们会遍历数组,如果发现有元素等于该元素,那么删除该元素并退出方法,所以这个方法只删除第一个元素e,并不是数组所有的元素e
public void removeElement(E e) {
//遍历数组
for (int i = 0; i < size; i++) {
//如果找到等于该元素的元素
if (e.equals(data[i])) {
//删除该元素
remove(i);
//退出方法
return;
}
}
}
下面实现contains(E e)方法,这个方法的思路同删除指定元素相似,遍历数组,如果找到元素与指定元素相同,那么返回true,如果遍历完数组还没有找到与之相等的元素,那么返回false
public boolean contains(E e) {
//遍历数组
for (int i = 0; i < size; i++) {
//如果找到元素,那么返回true
if (data[i].equals(e)) {
return true;
}
}
//如果遍历完所有数组没有找到,那么返回false
return false;
}
find(E e)方法的实现也是遍历数组,如果找到了元素,那么返回下标,如果遍历完数组都没有找到,那么返回-1
public int find(E e) {
//遍历数组
for (int i = 0; i < size; i++) {
//找到元素则返回下标
if (data[i].equals(e)) {
return i;
}
}
//如果遍历完数组都没有找到,返回-1
return -1;
}
下面实现get(int index)和set(int index, E e),这两个方法的实现及其简单,直接上代码
public E get(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("参数错误");
}
return data[index];
}
public void set(int index, E e) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("参数错误");
}
data[index] = e;
}
我们可以根据get方法实现getLast()和getFirst()方法
public E getFirst() {
return get(0);
}
public E getLast() {
return get(size - 1);
}
现在我们已经实现了API中提到的所有的方法,但是我们还是没有解决数组容量固定的问题,为了解决这个问题,我们需要实现一个resize(int newCapacity),它的作用是该表数组的容量大小,这样当数组的容量不足时,我们调用该方法就可以将数组进行扩容,或者当数组中有大量空间空闲时,我们可以缩小数组的容量,代码如下
private void resize(int newCapacity) {
//创建一个新容量的数组
E[] temp = (E[]) new Object[newCapacity];
//将数组中的数据全部放入新数组中
for (int i =0; i < size; i++) {
temp[i] = data[i];
}
//改变数组指针指向
data = temp;
}
现在我们改变add(int index, E e)和remove(int index)方法,我们会在添加元素和删除元素时检查数组的容量,以便对数组进行扩容或者缩容
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("参数错误");
}
//如果数组容量满了 那么将数组的容量扩为原来的两倍
if (size == data.length) {
resize(data.length * 2);
}
for (int i = size; i > index; i--) {
data[i] = data[i - 1];
}
data[index] = e;
size++;
}
public E remove(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("参数错误");
}
E e = data[index];
for (int i = index; i < size - 1; i++) {
data[i] = data[i+1];
}
size --;
//如果数组中的元素个数为数组容量的1/4,那么容量变为原来的1/2
//思考一下为什么是1/4 提示:复杂度震荡
if (size == data.length/4) {
resize(data.length/2);
}
return e;
}
为了方便的打印Array类,我们重写toString()方法如下
public String toString() {
StringBuilder str = new StringBuilder();
str.append("size " + size);
str.append(" capacity " + data.length);
str.append("\n[");
for (int i = 0; i < size; i++) {
if (i == size - 1) {
str.append(data[i].toString());
} else {
str.append(data[i].toString() + ", ");
}
}
str.append("]");
return str.toString();
}
至此,我们已经完全实现了Array,它的容量没有限制,并且提供了很多的方法供用户调用,我们将使用该类来实现其它的基本的数据结构。下面贴出完整的代码
public class Array<E> {
private E[] data;
private int size;
public Array(int capacity) {
data = (E[]) new Object[capacity];
size = 0;
}
public Array() {
this(10);
}
public int getSize() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
public void addLast(E e) {
add(size,e);
}
public void addFirst(E e) {
add(0,e);
}
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("参数错误");
}
if (size == data.length) {
resize(data.length * 2);
}
for (int i = size; i > index; i--) {
data[i] = data[i - 1];
}
data[index] = e;
size++;
}
public E removeLast() {
return remove(size -1);
}
public E removeFirst() {
return remove(0);
}
public E remove(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("参数错误");
}
E e = data[index];
for (int i = index; i < size - 1; i++) {
data[i] = data[i+1];
}
size --;
if (size == data.length/4) {
resize(data.length/2);
}
return e;
}
public void removeElement(E e) {
for (int i = 0; i < size; i++) {
if (e.equals(data[i])) {
remove(i);
return;
}
}
}
public boolean contains(E e) {
for (int i = 0; i < size; i++) {
if (data[i].equals(e)) {
return true;
}
}
return false;
}
public int find(E e) {
for (int i = 0; i < size; i++) {
if (data[i].equals(e)) {
return i;
}
}
return -1;
}
private void resize(int newCapacity) {
E[] temp = (E[]) new Object[newCapacity];
for (int i =0; i < size; i++) {
temp[i] = data[i];
}
data = temp;
}
public E get(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("参数错误");
}
return data[index];
}
public E getFirst() {
return get(0);
}
public E getLast() {
return get(size - 1);
}
public void set(int index, E e) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("参数错误");
}
data[index] = e;
}
public String toString() {
StringBuilder str = new StringBuilder();
str.append("size " + size);
str.append(" capacity " + data.length);
str.append("\n[");
for (int i = 0; i < size; i++) {
if (i == size - 1) {
str.append(data[i].toString());
} else {
str.append(data[i].toString() + ", ");
}
}
str.append("]");
return str.toString();
}
}
栈
栈是一种先进后出的结构,比如你放书会把书放在最上面,最先放的书在最下面,而你拿书却是从最上面拿,最后放的最先拿到,栈正是怎么一种结构,我们规定最上面的位置叫做栈顶,我们向栈中添加元素是添加到栈顶,向栈中取出元素是从栈顶取出的,我们先来定义一个Stack接口,里面规定了一个栈包含的操作
public interface Stack<E> {
//向栈中压入一个元素
void push(E e);
//将栈顶元素弹出
E pop();
//栈是否为空
boolean isEmpty();
//获得栈中元素的个数
int getSize();
//获得栈顶元素
E peek();
}
下面我们将使用上面实现的Array来实现一个ArrayStack,我们把数组的最后位置定义为栈顶
public class ArrayStack<E> implements Stack<E> {
private Array<E> data;
public ArrayStack(int capacity) {
data = new Array<>(capacity);
}
public ArrayStack() {
data = new Array<>();
}
@Override
public void push(E e) {
data.addLast(e);
}
@Override
public E pop() {
return data.removeLast();
}
@Override
public boolean isEmpty() {
return data.isEmpty();
}
@Override
public int getSize() {
return data.getSize();
}
@Override
public E peek() {
return data.getLast();
}
public String toString() {
StringBuilder res = new StringBuilder();
res.append("Stack: ");
res.append("[");
for (int i = 0; i < data.getSize(); i++) {
res.append(data.get(i));
if (i != data.getSize()-1) {
res.append(", ");
}
}
res.append("] top");
return res.toString();
}
}
上面的代码极其的简单,只要仔细的阅读就可以完全的理解,这里不多做解释。
下面介绍一个有关于栈的题目,此题来自于LeetCode第20题
给定一个只包括’(’,’)’,’{’,’}’,’[’,’]’ 的字符串,判断字符串是否有效。有效字符串需满足:
1. 左括号必须用相同类型的右括号闭合。
2. 左括号必须以正确的顺序闭合。注意空字符串可被认为是有效字符串。
这道题的解题思路是,如果遇到左括号’(’, ‘[’, ‘{’,那么将左括号压入栈中,如果遇到右括号,那么将栈顶的左括号弹出,判断两个括号是否匹配,如果不匹配返回fasle,如果匹配进行下一轮,最后如果字符串遍历完毕,如果栈为空说明匹配成功,如果栈不为空,所以左边的括号多匹配失败,代码如下
import java.util.Stack;
class Solution {
public boolean isValid(String s) {
//创建一个空栈
Stack<Character> stack = new Stack<>();
//遍历字符串
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
//如果是左括号,则压入栈中
if (c == '(' || c == '[' || c == '{') {
stack.push(c);
} else {
//如果是右括号 先判断栈是否为空
if (stack.isEmpty()) {
return false;
}
//获得栈顶的左括号
char charTop = stack.pop();
//下面三种皆为不匹配的情况
if (c == ')' && charTop != '(') {
return false;
}
if (c == ']' && charTop != '[') {
return false;
}
if (c == '}' && charTop != '{') {
return false;
}
}
}
//这里不能直接返回true 要根据栈是否为空决定返回值
return stack.isEmpty();
}
}
队列
队列是一种先进先出的结构,假设你在排队,那么最先排队的人最先得到服务。我们只能从队尾添加元素,从队首取出元素。老规矩,我们首先规定一下队列Queue的API
public interface Queue<E> {
//向队列中添加一个元素
void enqueue(E e);
//从队列中取出一个元素
E dequeue();
//获得队首的元素
E getFront();
//获取队列中元素的个数
int getSize();
//判断队列是否为空
boolean isEmpty();
}
数组队列
现在我们将使用动态数组Array类来实现队列,实现的逻辑也十分的简单,如下
public class ArrayQueue<E> implements Queue<E> {
private Array<E> array;
public ArrayQueue() {
array = new Array<>();
}
public ArrayQueue(int capacity) {
array = new Array<>(capacity);
}
@Override
public void enqueue(E e) {
array.addLast(e);
}
@Override
public E dequeue() {
return array.removeFirst();
}
@Override
public E getFront() {
return array.getFirst();
}
@Override
public int getSize() {
return array.getSize();
}
@Override
public boolean isEmpty() {
return array.isEmpty();
}
@Override
public String toString() {
StringBuilder res = new StringBuilder();
res.append("Queue: ");
res.append("front [");
for (int i = 0; i < array.getSize(); i++) {
res.append(array.get(i));
if (i != array.getSize()-1) {
res.append(", ");
}
}
res.append("] tail");
return res.toString();
}
}
注意上面我们的dequeue操作是调用了动态数组的removeFirst操作,这个操作需要遍历整个数组将元素向前移动,所以该操作是O(n)的。
循环队列
上面队列的dequeue操作是O(n)级别的,这是因为上面会将数组整体向前移一位,但是如果我们不这么做,而是增加一个变量front来记录队首的位置,这样我们只要将front向前移一位即可,这样的操作就是O(1)级别的
这样做的同时,我们发现,如果当tail来到数组的末尾,按道理应该将数组进行扩容,但是front前面还有空间
这个时候我们应当将tail移动到数组头去
这时tail的计算公式不再是简单的tail = tail + 1,而是tail = (tail + 1) % data.length,如果不理解这个式子,就想象一下时钟,11点向前一步就是12点,也可以称为是0点,这个时候时钟的计算公式为(11 + 1) % 12。因为这种循环的特性,我们把这种实现方式称为循环队列。这次我们实现队列不在使用上面的动态数组,有了上面实现栈和队列的经验,想必可以容易理解下面的代码(在关键的步骤给予注释)
public class LoopQueue<E> implements Queue<E> {
private int front;
private int tail;
//队列中元素的个数
private int size;
//底层实现的数组
private E[] data;
//构造方法初始化
public LoopQueue(int capacity) {
data = (E[]) new Object[capacity];
size = 0;
front = 0;
tail = 0;
}
//默认容量为10
public LoopQueue() {
this(10);
}
@Override
public void enqueue(E e) {
//首先判断数组是不是满了,如果是那么就进行扩容
if (size == data.length) {
resize(2 * data.length);
}
//向队尾添加元素
data[tail] = e;
//tail向后移动 不是简单的+1 上面已有解释
tail = (tail +1) % data.length;
size++;
}
//数组伸缩操作,已接触过
private void resize(int newCapacity) {
E[] temp = (E[]) new Object[newCapacity];
for (int i =0; i < size; i++) {
//这里我们将队列的头对应到新数组的开头
temp[i] = data[(front + i)%data.length];
}
//重新记录front和tail的位置
front = 0;
tail = size;
data = temp;
}
@Override
public E dequeue() {
//如果队列为空,抛出异常
if (size == 0) {
throw new IllegalArgumentException("队列为空");
}
//获得出队的元素
E e = data[front];
data[front] = null;
//front向前移动(带循环)
front = (front + 1) % data.length;
size--;
//缩容操作,不做解释
if (size == data.length / 4) {
resize(data.length / 2);
}
return e;
}
@Override
public E getFront() {
if (size == 0) {
throw new IllegalArgumentException("队列为空");
}
return data[front];
}
@Override
public int getSize() {
return size;
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public String toString() {
StringBuilder str = new StringBuilder();
str.append("Queue: size " + size);
str.append(" capacity " + data.length);
str.append("\nfront [");
for (int i = 0; i < size; i++) {
if (i == size - 1) {
str.append(data[(front + i) % data.length].toString());
} else {
str.append(data[(front + i) % data.length].toString() + ", ");
}
}
str.append("] tail");
return str.toString();
}
}
这次我们得到的dequeue操作就是O(1)的了(严格的讲均摊复杂度为O(1),因为里面resize()复杂度是O(n)的)。
链表
链表是一种非常重要的线性数据结构,我们在实现栈和队列时使用的是动态数组实现的,这个动态数组是针对用户而言是动态的,实际上底层是静态的,是通过resize()操作去解决容量问题的。而链表则是一种真正的动态数据结构,它是这么一种数据结构,我们把数据存储在一个节点(Node)中,一个节点一般包含两部分的内容,一个是存储的数据,一个是它要指向的下一个节点
class Node {
private E e;
private Node next;
}
一个节点指向一个节点,所以最后看起来就像是一个链,我们把这种数据结构称为链表
最后一个节点的下一个节点为NULL,表示后面没有节点了。它是一个真正的动态的数据结构,不需要处理容量的问题。但是它也有缺点,它没有数组那样快的查询能力,它要查询某个节点的数据,只能通过头结点一直寻找下来(后面我们将看到),所以它的查询速度比数组慢。
链表实现
现在我们将实现这么一个结构,首先设计好节点类
public class LinkedList<E> {
//我们将Node设置为LinkedList的私有内部类
private class Node<E> {
public E e;
public Node next;
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
public Node(E e) {
this(e, null);
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return e.toString();
}
}
}
我们要想向链表中添加(或其他操作)元素,不可避免的要遍历链表(因为链表不能通过索引访问,只能通过前面的节点找到后面的节点),而要遍历链表,我们就要将链表的头存储起来,这样才能遍历链表,我们将链表的头称为head
同时我们使用变量size来记录链表中元素的个数
public class LinkedList<E> {
//为了节省篇幅,Node类不再展示,下同
//头结点
private Node head;
//链表中元素的个数
private int size;
public LinkedList() {
head = null;
size = 0;
}
}
现在我们实现两个简单的方法getSize()和isEmpty()
public int getSize() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
添加元素
向链表头添加元素
首先将要插入的新节点指向head,然后将head设置为新节点,实现如下
public void addFirst(E e) {
//体会一下这条语句的意思
head = new Node(e,head);
size++;
}
在链表的中间添加一个元素
比如现在往节点1后面插入一个元素,首先将新节点指向节点2,然后节点1指向新节点,实现如下
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("参数错误");
}
//如果是头结点需要单独处理
if (index == 0) {
addFirst(e);
}
//prev代表要插入位置的前一个节点
Node prev = head;
for (int i = 0; i < index - 1; i++) {
prev = prev.next;
}
prev.next = new Node(e, prev.next);
size++;
}
向链表的尾部添加一个元素
直接复用上面的代码
public void addLast(E e) {
add(size, e);
}
虚拟头结点
我们在向链表中添加元素时,因为head前面没有节点,所以我们在添加元素时会对head进行单独的处理,为了不使head具有特殊性,我们在链表的最头部添加一个虚拟头结点,里面不存储元素,它的存在是为了使得操作链表方便
现在我们修改上面的head位dummyHead
public class LinkedList<E> {
//虚拟结点
private Node dummyHead;
private int size;
public LinkedList() {
//这里修改了
dummyHead = new Node(null, null);
size = 0;
}
public int getSize() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
//直接调用add方法
public void addFirst(E e) {
add(0,e);
}
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("参数错误");
}
//不需要对head进行单独的处理了
//index - 1修改为了index
Node prev = dummyHead;
for (int i = 0; i < index; i++) {
prev = prev.next;
}
prev.next = new Node(e, prev.next);
size++;
}
public void addLast(E e) {
add(size, e);
}
}
获得某个索引的值
实现的思路同add很像,不过这里我们找的不是前一个节点,而是当前的节点
public E get(int index) {
if (index <