前言
本篇博文想要介绍的是三种功能单一且最为基础的数据结构:包,栈,队列。相信阅读此篇博客的大部分读者都能够手撕或者直接口述出这三种数据结构的实现方法。本篇博文将会从一般实现方式入手,通过分析优劣点来进一步优化三种数据结构的实现方法。
包,栈,队列的简介
正如前言中所说。包,栈,队列是功能单一的数据结构。
包:一种只能塞值不能取值的结构。即这个类对外暴露的api永远只有add(),没有get()。他的特性在于遍历内部数据时,并不一定需要按照顺序。侧重点在于统计(如求数字的总和,平均值,方差等),而不在于搜索。
举例:你往一个袋子里面放不同重量的黄金。你的最终目的只想要知道最后你拥有多少金子,但是并不总是想着要把黄金按照一定的规律拿出来用。
栈:典型的LIFO(last in first out 后入先出)结构。最后一个add()进去的,在调用pop()方法时,能够第一个获取到。
举例:像是你自己的邮箱。你最后收到的信件,往往是最重要且是被你第一个打开的。
队列:典型的FIFO(first in first out 先入先出)结构。哪个数据先进去,哪个数据先出来。这种现象或者说是流程是我们生活中最普遍的,因为它能够体现公平性
举例:像是在超市排队付款的队伍,谁先排的队伍,谁就能先结账。
包,栈,队列的详细分析
包
SimpleBag
简单的实现思路如下:
1、使用数组作为内部存储容器。每次调用构造方法的时候传入一个size,用于初始化内部数组;
2、根据包的性质设计公用的api:add(),size(),list();
3、内部维护一个数组的下标,每次add的时候就+1。
public class Bag {
/**
* 内部的数据容器
*/
private int[] data;
/**
* data数组的下标 可以初始化为0 也可以初始化为-1 这里初始化为-1举例
*/
private int size = -1;
/**
* 初始化bag
*
* @param maxSize 最大容量
*/
Bag(int maxSize) {
this.data = new int[maxSize];
}
/**
* 往bag里面添加对象
*/
public void add(int item) {
//每次添加对象都应该判断一下是否满了 避免造成数组越界异常
if (!isFull()) {
this.size++;
this.data[this.size] = item;
} else {
throw new RuntimeException("当前bag已满");
}
}
/**
* 当前的bag是否已经满了
*/
private boolean isFull() {
return this.size == this.data.length - 1;
}
/**
* 查看目前bag里面有多少对象了
*/
public int size() {
return this.size;
}
/**
* 展示当前bag里面拥有的数据
*/
public void list() {
for (int dataItem : this.data) {
System.out.print(dataItem + " ");
}
}
public static void main(String[] args) {
//测试用例
Bag bag = new Bag(3);
bag.add(1);
bag.add(2);
bag.add(3);
System.out.println(bag.size());
bag.list();
bag.add(4);
}
上述实现的不足
- 没有考虑数据结构的通用性,如果需要的不是int类型的bag,则需要重新写一份新的bag类。比如StringBag,FruitBag。。。。。。
- 接着第一点讲,如果bag里面存入的是引用类型的数据,那么list()方法作为bag本身提供的api就显得毫无用处。数据结构使用方往往需要在外部获取到每一个对象并对它们做一些逻辑处理;
- 对于一个数据结构来说,我们大多数情况并不希望它默认是定容的,我们希望它在我们无限制add()后能够自我增长maxSize,而不是告诉我它满了。
BetterBag
- 我们需要引入泛型;
- 我们需要支持迭代,简单理解来说,像java提供的Collection一样,需要我们自己实现的数据结构支持foreach语法糖;
- 内部容器需要自动伸缩
public class BetterBag<T> implements Iterable<T> {
/**
* 内部的数据容器
*/
private T[] data;
/**
* data数组的下标 可以初始化为0 也可以初始化为-1 这里初始化为0举例
*/
private int size = -1;
BetterBag(int initialSize) {
//因为java不支持创建泛型数组 所以这么操作
this.data = (T[]) new Object[initialSize];
}
/**
* 往bag里面添加对象
*/
public void add(T item) {
if (isFull()) {
//如果袋子装满了则把容量调整成两倍
adjustBagSize();
}
this.size++;
this.data[this.size] = item;
}
/**
* 查看目前bag里面有多少对象了
*/
public int size() {
return this.size;
}
/**
* 当前的bag是否已经满了
*/
private boolean isFull() {
return this.size == this.data.length - 1;
}
/**
* 调整当前bag的容量 调整为两倍大小
*/
private void adjustBagSize() {
T[] newData = (T[]) new Object[this.data.length * 2];
System.arraycopy(this.data, 0, newData, 0, this.data.length);
this.data = newData;
}
@Override
public Iterator<T> iterator() {
return new BagIterator();
}
private class BagIterator implements Iterator<T> {
/**
* 初始化迭代时 数组的下标 因为是迭代数组 所以肯定是从0迭代到size
*/
private int first = 0;
@Override
public boolean hasNext() {
return this.first <= size;
}
@Override
public T next() {
T tempData = data[this.first];
this.first++;
return tempData;
}
}
/**
* 用于测试泛型的自定义类
*/
private static class Fruit {
public Fruit(String name, Double price) {
this.name = name;
this.price = price;
}
String name;
Double price;
}
public static void main(String[] args) {
BetterBag<Fruit> fruitBag = new BetterBag<>(3);
Fruit apple = new Fruit("苹果", 5.1);
Fruit grape = new Fruit("葡萄", 6.7);
Fruit orange = new Fruit("橘子", 3.2);
fruitBag.add(apple);
fruitBag.add(grape);
fruitBag.add(grape);
fruitBag.add(orange);
fruitBag.add(orange);
fruitBag.add(orange);
System.out.println(fruitBag.size());
for (Fruit fruit : fruitBag) {
System.out.println(fruit.name + " " + fruit.price);
}
}
}
进一步的思考
上面的bag结构考虑了泛型,迭代,自动伸缩的功能。仔细想想,我们还有哪些地方可以优化的呢?这里我们提出一个理论,读者们可以看一下是否认可。
一个好的数据结构对外提供的api,复杂度应该与内部存储数据量的大小无关。(或者说是影响尽量少)
我们带着这个论点来回顾一下我们上面的实现。add()方法耗时与数据量大小有明显的关系。当内部数组数据量足够大后,一旦触发adjustBagSize()方法,会导致一个大数组的拷贝。在拷贝过程中内存占用还特别的大。所以我们接下去就是对add()方法进行优化。
另一种存储结构——链表
对于链表我相信我不需要过多的解释。
优势 | 劣势 | |
---|---|---|
数组 | 查询快 | 添加删除慢(当数组大小发生变化时) |
链表 | 查询慢 | 在首位添加快 |
对于bag,一个不需要查询,不需要删除,只需要存放的数据结构而言。显然链表的存储方式要优于数组。于是有了我们的最终版代码
BestBag
public class BestBag<T> implements Iterable<T> {
/**
* 每次添加都在头部 这样我们只需要维护一个头结点就行
* 但是这样的话 遍历顺序就跟入库顺序相反
*/
private Node first;
private int size;
BestBag() {
}
/**
* 往bag里面添加对象
*/
public void add(T item) {
Node tempNode = new Node(item);
tempNode.next = first;
first = tempNode;
this.size++;
}
/**
* 查看目前bag里面有多少对象了
*/
public int size() {
return this.size;
}
@Override
public Iterator<T> iterator() {
return new BestBagIterator();
}
class BestBagIterator implements Iterator<T> {
Node current = first;
@Override
public boolean hasNext() {
return current != null;
}
@Override
public T next() {
T tempItem = current.item;
current = current.next;
return tempItem;
}
}
/**
* 链表节点
*/
class Node {
Node(T item) {
this.item = item;
}
private Node next;
private T item;
}
/**
* 用于测试泛型的自定义类
*/
private static class Fruit {
public Fruit(String name, Double price) {
this.name = name;
this.price = price;
}
String name;
Double price;
}
public static void main(String[] args) {
BestBag<BestBag.Fruit> fruitBag = new BestBag<>();
Fruit apple = new Fruit("苹果", 5.1);
Fruit grape = new Fruit("葡萄", 6.7);
Fruit orange = new Fruit("橘子", 3.2);
fruitBag.add(apple);
fruitBag.add(grape);
fruitBag.add(grape);
fruitBag.add(orange);
fruitBag.add(orange);
fruitBag.add(orange);
System.out.println(fruitBag.size());
for (Fruit fruit : fruitBag) {
System.out.println(fruit.name + " " + fruit.price);
}
}
}
栈
栈的实现也跟包大同小异。差异点在于
- 需要对外提供一个pop方法用于弹出栈的数据,而且要做到LIFO;
Stack
public class Stack<T> implements Iterable<T> {
private Node first;
private int size;
public void push(T value) {
Node tempNode = new Node(value);
tempNode.next = first;
first = tempNode;
size++;
}
private boolean isEmpty() {
return first == null;
}
/**
* 删除并返回栈顶的第一个元素
*/
public T pop() {
//如果栈空了则返回null
if (isEmpty()) {
System.out.println("栈空了");
return null;
}
T topValue = first.item;
first = first.next;
size--;
return topValue;
}
public int size() {
return this.size;
}
@Override
public Iterator<T> iterator() {
return new StackIterator();
}
class StackIterator implements Iterator<T> {
Node current = first;
@Override
public boolean hasNext() {
return current != null;
}
@Override
public T next() {
T tempItem = current.item;
current = current.next;
return tempItem;
}
}
class Node {
Node(T item) {
this.item = item;
}
Node next;
T item;
}
/**
* 用于测试泛型的自定义类
*/
private static class Fruit {
public Fruit(String name, Double price) {
this.name = name;
this.price = price;
}
String name;
Double price;
}
public static void main(String[] args) {
Stack<Fruit> fruitStack = new Stack<>();
Fruit apple = new Fruit("苹果", 5.1);
Fruit grape = new Fruit("葡萄", 6.7);
Fruit orange = new Fruit("橘子", 3.2);
fruitStack.push(apple);
fruitStack.push(grape);
fruitStack.push(grape);
fruitStack.push(orange);
fruitStack.push(orange);
fruitStack.push(orange);
int size = fruitStack.size();
System.out.println(size );
for (Fruit fruit : fruitStack) {
System.out.println(fruit.name + " " + fruit.price);
}
System.out.println("开始pop数据");
for (int i = 0; i < size; i++) {
System.out.println(fruitStack.pop().name);
}
//最后一次检查有没有空
System.out.println(fruitStack.pop());
}
}
由于我们是不断的改变头指针,所以天然就是LIFO。
队列
队列与栈的唯一差异就是要FIFO
Queue
package structure.queue;
import java.util.Iterator;
public class Queue<T> implements Iterable<T> {
private Node first;
/**
* 由于需要FIFO 所以我们需要维护last指针,每次入库都加在链表尾部
*/
private Node last;
private int size;
public void enqueue(T value) {
Node tempNode = new Node(value);
if (isEmpty()) {
first = tempNode;
last = tempNode;
} else {
last.next = tempNode;
last = tempNode;
}
size++;
}
private boolean isEmpty() {
return first == null;
}
/**
* 删除并返回队列头部第一个元素
*/
public T dequeue() {
//如果队列空了则返回null
if (isEmpty()) {
System.out.println("队列空了");
return null;
}
T topValue = first.item;
first = first.next;
size--;
return topValue;
}
public int size() {
return this.size;
}
@Override
public Iterator<T> iterator() {
return new QueueIterator();
}
class QueueIterator implements Iterator<T> {
Node current = first;
@Override
public boolean hasNext() {
return current != null;
}
@Override
public T next() {
T tempItem = current.item;
current = current.next;
return tempItem;
}
}
class Node {
Node(T item) {
this.item = item;
}
Node next;
T item;
}
/**
* 用于测试泛型的自定义类
*/
private static class Fruit {
public Fruit(String name, Double price) {
this.name = name;
this.price = price;
}
String name;
Double price;
}
public static void main(String[] args) {
Queue<Fruit> fruitQueue = new Queue<>();
Fruit apple = new Fruit("苹果", 5.1);
Fruit grape = new Fruit("葡萄", 6.7);
Fruit orange = new Fruit("橘子", 3.2);
fruitQueue.enqueue(apple);
fruitQueue.enqueue(grape);
fruitQueue.enqueue(grape);
fruitQueue.enqueue(orange);
fruitQueue.enqueue(orange);
fruitQueue.enqueue(orange);
int size = fruitQueue.size();
System.out.println(size);
for (Fruit fruit : fruitQueue) {
System.out.println(fruit.name + " " + fruit.price);
}
System.out.println("开始dequeue数据");
for (int i = 0; i < size; i++) {
System.out.println(fruitQueue.dequeue().name);
}
//最后一次检查有没有空
System.out.println(fruitQueue.dequeue());
}
}
其他要点
- 问:万一包,栈,队列需要从某一个指定的位置获取数据怎么办?用链表岂不是会很慢?
答:数据结构需要明确职责。我们在设计每一个数据结构的时候要避免出现宽接口的现象。如java自身提供的java.util.Stack,它对外甚至提供了从栈底添加数据的方法。一个好的数据结构,当我们见到它时就知道这一块我们需要用到它的特性。如在代码中使用了Stack而不是Queue,我们一下子就能非常直观的看出这里LIFO很重要。 - 如果使用数组来实现栈和队列,有一个要点非常容易被忽略——记得收缩数组。当我们不断的往栈添加数据时,我们会把数组的大小一直放大。但当我取出大部分数据后,数组后面的那些对象并不会被jvm回收。但是由于我们的指针已经缩小了,我们再也访问不到那些数据了。这种情况也被称为对象的游离。所以记得在size远小于data.length的时候,把数组总长度按一定比例收缩。
总结
- 好的数据结构需要支持迭代,泛型,自动伸缩;
- 数据结构要避免宽接口;