目录
实现栈
实现思路
什么是栈
我们在写实现栈的代码之前要先了解一下什么是栈。栈首先是一种数据结构,数据先进后出。就是说第一个被添加到空栈里面的元素,只能最后一个被遍历到或被删除。这种数据结构其实我们在生活中很常见到。你一般会不会用柜子装一些书?没错,这就是典型的栈结构——最先放进去的书要扒开所有后面放的书才能拿出来,这种模式叫做LIFO(Last In First Out),即后进先出。
栈的用处
那为什么要用栈呢?栈有很广的应用场景。我们常用的“撤销”功能,浏览器的历史记录,以及底层的递归,判断有效括号,表达式求值和数制转换等问题 均能见到他的身影。建议阅读CSDN博主 KLeonard《栈的应用》这篇文章来加深对栈的应用的印象。
我们这次栈的功能的实现是基于动态列表的,如果大家看不懂可以直接滑到下面,这里有Java的Stack官方库代码供大家直接使用。事不宜迟,我们现在就开始正式介绍实现栈所需的一些功能。
实现栈所需的基本功能一览
首先,我们需要一个方法来添加元素,这个过程叫做压栈,我们姑且把这个方法起名为push()把。
其次我们需要去删除元素,即出栈——那我们给这个方法起名为pop()。但是由于这个pop()是boolean类型的,因此虽然最后实现了删除的效果,但返回的是一个布尔值。由于我们在后续需要用到双栈,即将一个栈中元素删除后又将其添加到另一个栈里,我们就需要再写一个有返回值的方法,我们给该方法起名为popR()吧,popReturn的缩写。
另外,在应用栈时,我们难免会需要取元素,即取栈最后添加的元素,我们给他起名为topE()方法,topElement的缩写。
不仅如此,我们还急需一个判断栈是否为空的方法isEmpty(),这样pop()方法在删除元素前先判断栈是否为空,不为空再进行删除操作,避免了一些不必要的麻烦。
还有一个print()打印方法,这个功能可以直接打印栈中的所有元素,和topE()方法应用上各有殊途。print()的一大优势就是,允许不用Debug的情况下都可以查看到栈的状态。
最后,为了快速生成一个栈,我们通常会想——先初始化一个数组,添加一些值,然后通过循环遍历数组并把元素压入该空栈中,这样就避免了一长串的push()方法的使用。那我们为何不把这个循环操作封装成一个方法,然后调用该方法,把数组作为参数传入,然后立马就得到了一个新鲜的栈……这想想都美滋滋!所以我们再写一个cvtArr()的方法(convertArray的缩写),将数组内所有元素快速压入栈中。
注:remove(),add()等5个方法皆是动态列表里面的内置方法
代码展示
package advance.dataStructure;
import java.util.ArrayList;
import java.util.List;
class MyStack {
private List<Integer> data=new ArrayList<>();// 动态列表
//添加元素(压栈)
public void push(int e) {
data.add(e);
}
//判断栈是否为空
public boolean isEmpty() {
return data.isEmpty();
}
// 返回栈顶元素
public int topE() {
return data.get(data.size() - 1);
}
//删除元素(出栈)
public boolean pop() {
if (isEmpty())
return false;
data.remove(data.size() - 1);
return true;
}//删除元素,并返回该元素
public int popR() {
if (isEmpty())
return 0;
data.remove(data.size() - 1);
return topE();
}
//打印栈内所有元素
public void print() {
for (int e : data)
System.out.println(e);
}
//将数组转换成栈
public void cvtArr(int[] arr) {
for (int e : arr)
push(e);
}
};
public class MyStack_Main {
//测试
public static void main(String[] args) {
MyStack obj = new MyStack();
int[] arr = { 3, 2, 5, 1 };
obj.cvtArr(arr);
obj.pop();
obj.print();
}
}
官方标准库(栈)Java
package advance.dataStructure;
import java.util.Stack;
public class Stack_library {
public static void main(String[] args) {
//1.创建栈对象
Stack<Integer> stack = new Stack<>();
int[] arr = { 1, 4, 2, 5 };
//2.添加元素(压栈)
for (int e : arr)
stack.push(e);
// 3.判断栈是否为空
stack.empty()
// 4. 删除元素(出栈)
stack.pop();
// 5. 返回栈顶元素(stack.peek())
System.out.println("The top element is: " + stack.peek());
// 6.返回栈的长度.(stack.size())
System.out.println("The size is: " + stack.size());
}
}
代码讲解
相信在看完《实现栈所需的基本功能一览》这个模块后,对栈的Java代码已经不难理解了。这里我写了两块代码,一块是自己实现的,一个是官方写的。个人建议如果真要写项目了,还是选择官方写的比较好,毕竟自己写的还是担心有点靠不住。不过平常写代码自娱自乐的时候,调用自己实现的栈也未尝不可。
另外,在第一段代码中,动态列表data的remove()和get()方法里面的参数时列表索引。其他……没了
实现队列
实现思路
什么是队列(queue)
队列基本型
我们直接上定义:队列是限制仅在一端进行插入操作,在另一端进行删除操作的线性表。我看到队列这种结构总有种莫名的熟悉感。事实上,当年在学习电流的时候就用到了这个模型,电子们从电路的一端向另一端“滚动”,且进去的个数总等于出来的个数。。。好吧,言归正传。
队列 (queue) 相对栈 (stack) 来说实现的要复杂一些,毕竟栈有栈底,只有一个出入口,是单向的;而队列有一个入口,一个出口,是双向的。这就给了它几条性质:
1.队列遵循先进先出的规则(FIFO->first in first out),也就是说先入队的元素先出队,即先被添加的元素先被删除
2.队列的长度是有限的,并且其数据在数据库中的分布是连续的
3.队列需要用到两个指针,头指针p0 (pointerToTheFront的简写) 和尾指针prear (pointerToTheRear的简写),分别指向队列的第一个元素(队头)和最后一个元素(队尾)
那为什么偏要用指针呢?因为有了指针才能确保先进先出——毕竟队列由数组实现,数组可没有先进先出的习惯。于是此时需要两个指针,来指明添加和删除元素的方向。在基于数组实现的队列中,指针就相当于数组索引一样的存在,事实上,在我们接下来的实现程序中指针值就被当做索引使用
普通队列
普通队列说起来就有点鸡肋了,但这也是队列的雏形。假设一个空队列长度为3,那么此时p0和prear重合并指向第0个元素。我们每添加一个元素,prear的值就会自增1,指针挪到下一个元素并指向该元素;同理,我们每删除一个元素,p0就会自增1,指针挪到下一个元素并指向该元素。
普通队列:空队列
此时我们在空队列中添加一个元素2,prear指向第一个元素2;接下来添加一个4,prear指向第二个元素4;最后添加一个6,prear指向第三个元素6。此时队列满了,无法再添加元素。看来只能删除元素释放内存了:我们删除队列第一个元素2,此时p0指向第二个元素4,如下图所示。
普通队列:满队列
现在的问题是,即便我们删除了一个的元素,也无法再在队列中添加元素了,因为prear指针指向了队列的最后一个元素,无法再向后挪,相当于是添加新元素的入口被堵死了。这样还是很浪费存储空间的,因为p0前面的队列空间都用不了了。
循环队列
这个时候我们就需要循环队列的加持。我们不是希望能够利用p0之前的队列空间么?在循环队列中,每次添加,删除操作后,指针都会取余队列长度。这样,prear在满的时候就会指向第一个元素的位置,如下图所示。此时新元素就可以被添加,多聪明!循环队列和普通队列的不同之处在于,循环队列的两个指针可以通过取余循环在队列中行进自如,而普通队列,prear指向最后一个元素时就被卡死了。
循环队列
现在内存的问题解决了,我们现在还有一个未解之谜,即如何判断队列是否为空和队列是否为满——这两个判断不论是在入队,出队,还是打印值等功能中都起到了尤为重要的作用。那如何判断他们呢?
对于满,就是p0指向队列索引0的位置,prear指向队列索引最大值位置。注:重复一下,我们为了方便,以下程序会在给队列添加/删除元素前让指针指向该元素位(指针就相当于该元素的索引),所以,在每次在执行添加后,我们要把指针自增1指向下一个元素位。言归正传,在添加最后一个元素的时候,prear+1等于队列长度,取余后得到prear此时在元素位0,也就是说在列表为满的时候,有p0==prear。
对于空,我们要分两种情况。1.当队列还未添加元素时,p0==prear==0。2.当队列满后清空时:(清空过程) p0==prear (满) ->p0==prear+1 ->p0==prear+2->p0==prear+3,队列被清空。但是此时prear==3,队列长度为3,prear%3==0,prear还是等于p0。当然,对于任意{p0∈N|p0∈[0,queue.length-1]},都有p0==prear,剩下的情况大家可以自行推算。
循环列表从满到空的全过程
我们现在惊讶的发现,判断空和满的条件竟然都是p0==prear!于是为了区别这两个条件,我们不得已在队列末尾留出一空元素位,里边什么也不存储,这样判断队列是否为满的条件就变成了:(prear+1)%length==p0,与判断是否为空的条件p0==prear区分开来。
大家可以自行推算一下,留空元素位这个方法不管遇到了什么情况——不论是p0在prear前面,还是prear经过一轮添加操作跑到了p0的前面,队列都运转的稳如泰山,不出一点岔子。
实现队列所需要写的基本功能
首先我们需要一个构造器,给类里面的属性赋值。
其次是入队,我们给这个功能起名为enQueue(),如果队列不为满就执行添加元素的指令,且指针索引自增一,为下一次添加指令作好基础。
然后是出队,我们给这个功能起名为deQueue(),如果队列不为空就执行删除元素的指令,同时指针索引自增一,移到下一个元素的位置
较为重要的判断队列是否为空和是否为满的两个功能,分别起名为isEmpty() 和 isFull()。第一个判断队列是否为空的功能主要语句是p0==prear。
最后就是不难实现但很实用的三个小功能:一个是返回队列第一个元素;一个是返回列表最后一个元素;另外一个是打印队列中所有元素。我们把这三个小功能分别起名为:front(),rear(),和print()。
代码展示
package advance.dataStructure;
class MyQueue {
private int p0;//头指针的位置
private int prear;//尾指针的位置
private int capacity;//队列的实际长度
private int[] arr;//队列的载体
// private ArrayList<Integer> data;
public MyQueue(int len) {//len是队列的名义长度
capacity = len + 1;//capacity是加上了空元素位后的队列长度
arr = new int[capacity];
p0 = prear = 0;
}
//打印队列
public void print() {
int last=this.arr.length-1;
for (int i=0;i<last;i++) {
System.out.print(arr[i]+", ");
}
System.out.println(arr[last]);
}//打印效果:a, b, c, d
//入队
public boolean enQueue(int value) {
if (isFull()) {
return false;
}
if(prear==arr.length-1){//如果指针在空元素位就回到索引0
prear=(prear+1)%capacity;
}
arr[prear] = value;
prear = (prear + 1) % capacity;// 指针指向新添元素的下一个位置
return true;
}
//出队
public boolean deQueue() {
if (isEmpty()) {
return false;
}
arr[p0]=0;
p0 = (p0 + 1) % capacity;// 指针移到被删除元素的下一个位置
return true;
}
public int front() {//返回p0指向的元素
if (isEmpty()) {
return -1;
}
return arr[p0];
}
public int rear() {// 返回最prear指向的元素
if (isEmpty()) {
return -1;
}
return arr[(prear - 1) % capacity];
}// 由于指针 prear 之前已经指向了该元素的下一个元素位, 所以指向当前元素是prear-1
public boolean isEmpty() {
return p0 == prear;
}
// 为了将isFull()方法的条件判断 和 isEmpty()的分开来, 我们需要占用一下队列的最后一个位置
public boolean isFull() {
return (prear + 1) % capacity == p0;
}
};
public class Main{
//测试
public static void main(String[] args) {
int len = 5;//队列名义长度
MyQueue q = new MyQueue(len);
int[] arr = { 5, 1, 2, 3, 4 };
for (int e : arr) {//循环添加元素至队列
q.enQueue(e);
}
q.deQueue();
q.deQueue();
System.out.println(q.front() + " and " + q.rear() + "\n");
q.print();
}
}
官方标准库(队列)Java
package advance.dataStructure;
import java.util.LinkedList;
import java.util.Queue;
public class Queue_library {
public static void main(String[] args) {
Queue<Integer> q = new LinkedList();
//打印第一个元素
System.out.println("the first element: "+q.peek());
//添加新元素
q.offer(5);
q.offer(4);
//删除元素
q.poll();
System.out.println("the first element: "+q.peek()+" and size:"+q.size());
}
}
代码讲解
我们在代码中使用的是顺序存储,还有一种链式存储的方法这里不予介绍。在构造方法MyQueue中,我们看到了len参数,该参数允许我们直接在实例化的时候把len(名义长度) 赋给类,使其能够在类MyQueue中被使用。
enQueue()方法的细节
我们讲一下enQueue() 方法模块中的第二个if条件判断语句的由来。当第一轮添加操作结束后,此时列队为满。在删除第一个元素后,头指针p0指向了索引1的位置。尾指针prear指向空元素位(不记得空元素位的作用的读者可以回到《循环队列》模块再看一下)。
如果此时进行添加操作,按照最初的设想,应该先添加元素,然后(prear+1)%capacity 使 prear指向索引0的位置,这样元素就添加到了空元素位。但问题是,这个空元素位是需要被置空的,不是用来放元素的,因为在Main类里初始化队列的时候,长度填的是5,如果样的话,实际上这个队列里岂不是有6个有效元素了?
我们演示一遍原代码(空元素位用0表示):queue=5,1,2,3,4,0。deQueue() -> 0,1,2,3,4,0。enQueue(10) -> 0,1,2,3,4,10?!想必大家现在看出问题来了,原本10应该添加到第一个元素的位置,但是现在却错误的出现在了空元素位。
为了避免这种情况,我在enQueue()方法中添加了一个if条件判断语句来判断此时尾指针prear所在的索引是否为capacity-1(空元素位),是的话就先 (prear+1)%capacity,使prear指向索引0的位置,再添加元素到此处,然后prear+1指向下一个元素位。使用改良后的enqueue()代码效果为:10,1,2,3,4。完美的实现!
用栈实现队列
实现思路
栈也可以实现队列。这个时候我们需要用到双栈,一个栈控制入,一个栈控制出。我们姑且把控制入的栈叫做stackIn,控制出的栈叫做stackOut。比如我们在stackIn压入1,2,3,5。如果要删除操作或者是取第一个元素,要先把stackIn清空,放到另一个栈stackOut中。
双栈实现队列
为什么要搞得这么复杂呢?我们这么做是有根据的。我们注意到,由于栈是后进后出,也就是说stackIn中循环删除的顺序是:5,3,2,1。在stackIn删除一个元素,在stackOut就添加一个元素,因此添加顺序和删除顺序一致。操作完成后,我们惊奇的发现,在stackOut中,5刚好在栈底,而1作为最后添加进的元素处在栈顶。这么一来删的就是1,刚好就实现了队列的效果。
我们把清空stackIn然后又添加至stackOut的这一过程封装成dump()方法。其他方法都比较简单,老熟人了,这里我们不展开介绍,大家可以看一下代码。
代码展示
package advance.dataStructure;
public class Queue_StackBase {
public MyStack stackIn;
public MyStack stackOut;
//初始化
public Queue_StackBase() {
stackIn = new MyStack();
stackOut = new MyStack();
}
//入队
public void push(int e) {
stackIn.push(e);
}
//出队
public int pop() {
dump();
return stackOut.popR();
}
//返回第一个元素
public int peek() {
dump();
return stackOut.topE();
}
//判读双栈是否为空
public boolean empty() {
return stackIn.isEmpty() && stackOut.isEmpty();
}
//清空stackIn的元素并转移至stackOut栈中
private void dump(){
if (!stackOut.isEmpty()) return;
while (!stackIn.isEmpty()){
stackOut.push(stackIn.popR());
}
}
}
代码讲解
这段代码的逻辑在读者们看了前面两章之后应该就不难了,我们主要讨论一下dump()中为什么有if条件语句if (!stackOut.isEmpty()) return。
dump()
在dump() 添加1,2,3,5到stackOut之后,执行删除方法 pop() ,然后调用push()方法添加元素4。那么此时队列理应的出队顺序是:2,3,5,4。因此再次执行删除方法pop()的时候。删除的是2。如果我们不加上if语句,那么4会压到2之前,这样下一次删除的元素就是4?!这是错误的队列逻辑,因此只要stackOut不为空,stackIn就不会清栈。
另外补充一下,stackOut.push(stackIn.popR()) 中的 popR() 方法是之前在实现栈时写的方法,它会返回被删除的栈顶元素。这样stackOut.push()就可以把该元素压入栈中,很合乎其理!
栈的应用:求最小栈
实现思路
求最小栈的意思就是使用栈去找最小值。我们都对使用数组寻找最小值了如指掌,但是栈要如何寻找最小值呢?我们可以使用双栈,一个栈用来收取数据,另外一个则收取数据中最小的那个。这样不就转化成我们使用数组求最小值的情况了么?
代码展示
package advance.dataStructure;
import java.util.Stack;//调用官方库
public class MinStack {
Stack<Integer> stack = new Stack<>();//初始化对象
Stack<Integer> stackMin = new Stack<>();// 储存最小值
public void pushE(int e) {
stack.push(e);
if (stackMin.isEmpty() || e < getMin())
if (!(stackMin.isEmpty()))
stackMin.pop();
stackMin.push(e);
}
public int getMin() {//返回最小值
return stackMin.peek();
}
}
package advance.dataStructure;
//测试
public class MinStack_Main {
public static void main(String[] args) {
MinStack obj=new MinStack();
int[] arr= {3,2,5,1};
for(int e:arr)//快速压栈
obj.pushE(e);
System.out.println(""+obj.getMin());
}
}
代码讲解
实操起来也很简单。我们先创建两个栈,一个叫stack,存储着所有数据;一个叫stackMin,存储着最小数据。如果stackMin中什么都没有,那就直接把该数据压入栈stackMin中。如果stackMin中有数据,而输入数据e又比该数据小,那就先把stackMin中的数据先删掉,然后再将当前最小数据e压入stackMin栈中,如果e比该数据大,则不变——不管怎么样,一定要保证stackMin里面存储的是最小值。
输入结束后,就调用getMin()方法找到最小值。事实上,我们可以看到,用栈求最小值其实和使用数组求最小值的原理如出一辙。
总结
栈与队列是无处不在的,十分常见的两种数据结构,他们各有优势,又可以互相转换。虽然有官方库可以直接调用,但是了解一下他的实现原理,以后学起更加抽象的栈和队列的知识时更能够举一反三。