栈的定义和抽象数据类型
1.栈的定义
栈(stack)是一种特殊的线性表,其插入和删除操作只允许在线性表的一端进行。类似于一摞盘子,你只能从最上面拿,不能从中间抽取。允许操作的一端称为栈顶(Top),不允许才做的一端称之为栈底(bottom),栈中插入元素称之为入栈(push),删除元素称之为出栈(pop)。
2.栈的抽象数据类型
栈的基本操作有创建栈,判断栈是否为空,入栈,出栈和取栈顶元素等。栈不支持对指定位置的插入和删除等操作。
public interface Stack<T>{
public abstract boolean isEmpty(); //判断栈是否为空
public abstract void Push(T x); //元素x被入栈
public abstract T peek(); //返回栈顶元素,未出栈
public abstract T pop(); //出栈,返回栈顶元素
}
实例
//1.创建一个字符型的栈
Stack<Character> stack=new Stack<>();
System.out.println(stack);
//2.测试栈是否为空
System.out.println(stack.empty());
//3.入栈
stack.push('a');
stack.push('b');
stack.push('c');
System.out.println(stack);
//4.查看栈顶元素
System.out.println(stack.peek());
System.out.println(stack);
//5.出栈
stack.pop();
System.out.println(stack);
//6.返回对象在栈中的位置
System.out.println(stack.search('b'));
测试下面这段代码
public void test(int x) {
Stack<Integer> s1 = new Stack<Integer>();
Stack<Integer> s2 = new Stack<Integer>();
s1.push(x);
s2.push(x);
int p1 = s1.peek();
int p2 = s2.peek();
System.out.println(p1==p2);
System.out.println(s1.peek() == s2.peek());
}
如果x在[-128, 127],那么两次都会输出true;如果不是在这个范围,会输出true和false。
感兴趣的小伙伴可以去查找别的文章来了解一下原理
3.栈的存储结构和实现
在入栈和出栈过程中,只需要一两个临时变量存储空间,所以空间复杂度是
O(1)。
入栈、出栈只涉及栈顶个别数据的操作,所以时间复杂度是O(1)
栈的存储结构有顺序存储结构和存储结构,分别称为顺序栈和链式栈。顺序栈类和链式栈类实现Stack<T>栈接口 ,继承关系如下图。
1.顺序栈
声明SepStack<T>顺序栈类,实现栈接口,使用顺序表存储栈元素,入栈和出栈操作实现为顺序表尾插入和尾删除,时间复杂度为O(1);当顺序表扩充容量时,入栈的时间复杂度是O(n).。这个详细的会在下面的队列里面讲为什么入栈扩容时的时间复杂度会有O(n)。
2.链式表
声明LinkedStack<t>链式栈类,实现栈接口,使用单链表存储栈元素,入栈和出栈操作实现单链表头插入和头删除,时间复杂度是O(1)。
栈的应用
1.栈是嵌套调用机制实现的基础
在一个函数题中调用另外一个函数,称为函数的嵌套调用。例如,在main()函数中调用LinkedStack<T>(),其中再调用SinglyList<T>(),此时3个函数均在执行中,仍然占用系统资源。根据嵌套调用的规则,每个函数在执行完后返回调用语句。那么,操作系统怎么样做到返回调用函数?它如何知道该返回哪个函数?
由于函数返回次序与调用次序正好相反,如果借助一个栈“记住”函数从何而来,就能获得函数返回的路径。当函数被调用时,操作系统将该函数的有关信息入栈,一个函数在执行完返回时间出栈,获得调用函数信息。
2.使用栈以非递归方式实现递归算法
题目要求:
程序中出现括号( ),[ ],{ }等,都应该是左右匹配的,比如说“()”是匹配的,但是“)(”是不匹配的,那么编译系统会给出错误信息。
括号是可以嵌套的,比如说((())),匹配规则是,一个右括号与其前面最近的一个左括号进行匹配,然后编译器来判断是否匹配。
那么这里需要用一个栈来保存多个嵌套的左括号。
算法描述:
以判断表达式中的圆括号是否匹配为例,使用栈判断包括匹配问题算法问题如下,设target是一个表达式字符串,target=“((1+2)*3+4)”,如下图所示
1.从左到右依次对target中的每个字符ch进行语法检测。若是左括号“(”,则入栈,如果是右括“)”则出栈,如果出栈的字符为“(”,则表示这一对括号匹配;若栈为空或出栈字符不是“(”,则缺少与ch匹配的"("。
2.重复执行1操作,当target检查结束时,若栈为空,则表示括号匹配;否则,栈中仍有“(”,表示缺少")"。
下面是算法实现
public class Match {
//检查target字符串中的left,right表示的成分是否左右匹配,返回匹配结果字符串
public static String check(String target,String left,String right){
//声明接口对象stack,引用实现Stack接口的顺序栈类的实例,创建空栈
Stack<String> stack =new Stack<>();
int i =0;
char ch =' ';
while(i<target.length()){
//从target中的第i个开始,查找与left和right首字符相等的字符序号
while(i<target.length()&&(ch=target.charAt(i))!=left.charAt(0)&&ch!=left.charAt(0)&&ch!=right.charAt(0)){
i++;
if (target.indexOf(left,i)==i){
//若与left匹配,则入栈
stack.push(left);
i+=left.length();
}
else if(target.startsWith(right,i)) {
//若与right匹配,则出栈
if (stack.isEmpty() || !stack.pop().equals(left))
return "语法错误,i=" + i + ",多余" + right;
i += right.length();
}
}
}
return (stack.isEmpty())?"匹配":"语法错误,i="+i+",缺少"+right;
}
public static void main(String[] args) {
String target ="((1+2)*3+4) (",left="(",right=")";
System.out.println(target+Match.check(target,left,right));
}
}
队列的定义及抽象数据类型
1.队列的定义
队列是一种特殊的线性表,其插入和删除操作分别在线性表的两端进行的。想队列中插入原数的过程称为入队,删除元素的过程称为出队。允许入队的一端称为队尾(rear),允许出队的一端称为队头(front),没有元素的队列称为空队列。队列如下图所示
队列只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作。最先入队的元素总是最先出队,因此队列的特点是先进先出,后进后出。
2.队列的抽象数据类型
队列的基本操作有创建队列,判断队列是否为空,入队和出队。队列不支持对指定位置的插入,删除等操作。声明队列接口Queue<T>如下,描述队列抽象数据类型。
public interface Queue<T> {
public abstract boolean isEmpty();//判断队列是否为空
public abstract boolean add(T X);//元素x入队,若添加成功,则返回true,否则返回false
public abstract T peek();//返回队头元素,没有删除。若队列为空,则返回null
public abstract T poll();//出队,返回队列元素。若队列为空,则返回null
}
Queue队列
Queue是java中实现队列的接口,它总共只有6中方法,如下
压入元素(添加):add(),offer()
- 相同:未超出容量,从队尾压入元素,返回压入的那个元素。
- 区别:在超出的容量时,add()方法会抛出异常,offer()返回false。
弹出元素(删除)remove(),poll()
- 相同:容量大于0的时候,删除并返回队头被删除的那个元素
- 区别:在容量为0的时候,remove()会抛出异常,poll()返回false。
获取队头元素(不删除):element(),peek()
- 相同:容量大于0时,都返回队头的元素,但是不删除。
- 区别:容量为0时,element()会抛出异常,peek()返回null
抛出异常 | 返回特殊值 | |
插入 | add(e) | offer(e) |
删除 | remove() | poll() |
检查 | element() | peek() |
Que:
import java.util.LinkedList;
import java.util.Queue;
public class QueueExample {
public static void main(String[] args) {
Queue<String> queue =new LinkedList<>();
//添加元素到队列
queue.add("Apple");
queue.add("Banana");
queue.add("Orange");
//获取队列的头部元素
String head =queue.peek();
System.out.println("头部元素:"+head);
//遍历队列并输出元素
System.out.println("队列元素:");
for(String element:queue){
System.out.println(element);
}
//移除队列的头部元素
String removedElement=queue.remove();
System.out.println("移除的元素:"+removedElement);
//队列大小
int size =queue.size();
System.out.println("队列的大小: "+size);
//判断队列是否为空
boolean isEmpty =queue.isEmpty();
System.out.println("队列是否为空:"+isEmpty);
}
}
3.在JAVA中有哪些常见的队列
在java中,队列也是一种线性的数据结构
ArrayList:ArrayList可以被用作为队列,通过在列表末尾添加元素,并使用remove(0)方法从列表的开头删除元素。但是,由于在列表的开头删除原数会导致后续的元素的移动,因此对于大量的插入和删除操作来说,ArrayList的性能可能不是最佳的选择。
LinkedList:LinkedList也可以用作队列。Linkedlist实现了Queue接口,可以使用offer()方法在队列的末尾添加元素,使用poll()从队列的开头删除并返回元素,LinkedList对于删除和插入具有良好的性能,因为它是用了指针来链接元素,而不需要移动其他元素。
对入队,出队的操作的时间复杂度要求是O(1),如何才能做到呢?队列的存储结构经历演变的过程。
队列的存储结构和实现
1.顺序循环队列
(1)使用顺序表,出队效率低
如果使用一个顺序作为队列的成员变量,那么入队操作执行顺序表尾操作,时间复杂度0(1);出队的操作执行顺序表头删除,时间复杂度都为O(n),效率较低。希望出队的操作效率也为O(1),因此不使用一个顺序表作为队列的成员变量。
(2)使用数组,存在假溢出
顺序队列使用数组存储数据元素,用front,rear,记住队列头,尾元素下标,入队和出队改变front,rear的值,不需要移动元素。
1.当队列为空时,设置队头为空,队尾下标front=rear=-1。
2.第一个元素入队,front=rear=0,同时改变了两个下标。
3.入队操作,rear++,元素存入rear的位置。
4.出队操作,返回front队头元素,front++;
5.当入队的元素个数(包括已经出队的元素)超过数组的容量时,rea下标越界,数据溢出,此时由于之前已有若干个元素出队,数组前面空出来许多的存储单元,因此这种溢出并不是因存储空间不够而产生的,被称为假溢出。
(2)上述的顺序队列存在以下的缺点
1.第一个元素入队时,同时改变front,rear下标。
2.无法约定列空的条件,如图的(a)(b)都是队列空,但是front,rear两者的关系不同。
3.假溢出。
顺序队列之所以出现假溢出的现象,是因为顺序队列的存储单元没有重复使用机制。解决办法是将顺序队列设计成循环结构。
(3)顺序循环队列
顺序循环队列是指,将顺序队列设计成在逻辑上首尾相接的循环结构,则可循环使用顺序队列的存储结构单元。设front是队头的下标,rear是下一个入队元素下标。
1.设置初始空队列为front=rear=0.约定队列空条件是front==rear。
2.入队操作改变rear,出队操作改变front,变化规律如下,其中length表示数组的容量。
front=(front+1)%length;
rear=(rear+1)%length;
front,rear下标的取值范围是0~length-1,不会出现假溢出的现象。
3.约定队列满的条件是front==(rear+1)%length,此时队列中仍有一个空位置。如果不留一个空位置,则队列满的条件也是front==rear,与队列空条件相同。
4.当队列满时再入队,将数组的容量扩充一倍,按照队列元素次序复制数组元素。
2.链式队列
(1)使用单链表,入队的效率低
使用一个单链表做循环双链表作为队列的成员变量,入队,出队操作的效率分析如图。
1.使用一个单链表作为队列的成员变量,入队操作执行单链表尾插入,时间复杂度为O(n),效率较低;出队操作执行单链表头删除,时间复杂度为O(1)。所以不能使用一个单链表作为队列的成员变量,因为入队操作达不到O(1)。
2.使用一个循环双链表作为队列的成员变量,入队操作执行循环双链表尾插入,出队操作执行循环双链表头删除,时间复杂度都是O(1),但是占用较多的空间。
(2)单链表设计,增加尾指针
链式队列的最好结构是,采用单链表(不带头结点),增加一个尾指针可使入队时间为O(1),如图所示:设front和rear分别指向对头和队尾
(1)设置初始为空队列,front=rear=null,队列为空的条件是front==null&&rear==null。
(2)入队操作,将值为x的结点链放在rear后,使rear指向值为x的结点成为新的队尾。
(3)出队操作,当队列不为空时,取得队头结点元素,删除队头的节点,使front指向后继结点。
(4)当第一个元素入队或最后一个元素出队时,同时改变front和rear。
队列的应用
例题:
例题解释:将n个自然数(1~n)排列称环形,使得每相邻两数之和为素数,构成一个素数环。
算法思路:现将1放入素数环中,last=1,记得素数环最后一个元素:对2~n的自然数key,测试last+key是否为素数,若是,则将key添加到素数环,否则说明key暂时无法处理,必须等待下一次测试。因此,需要设置一个队列用于存放等待测试的自然数。
算法描述:设n=10,values数组存储1-n的初始序列,那么素数环和队列的变化过程如图所示:
- 将values数组全部入队。last记录素数环最后一个元素。
- 出队一个元素key,测试key+last是否为素数,如果是,则将key添加到素数环中,last为key,否则,key再次入队等待。
- 重复2,直到队列为空
代码实现
public class PrimeRing {
public PrimeRing(int n){
List<Integer> ring=new ArrayList<Integer>();//用于存储素数环
ring.add(1); //将1添加到素数环中
Deque<Integer> que=new ArrayDeque<Integer>(n); //创建一个队列,FIFO
//2-n全部入队
for(int i=2;i<=n;i++){
que.addLast(i); //依次入队
}
int i=0;
while(!que.isEmpty()){
int k=que.removeFirst(); //队首元素出队
if(isPrime(ring.get(i)+k)){
i++;
ring.add(k);
}else{
que.addLast(k);
}
}
System.out.println("素数环:"+ring.toString());
}
//判断一个数是否为素数
public boolean isPrime(int n){
if(n==2)
return true;
if(n<2 || n>2 && n%2==0)
return false;
int j=(int)Math.sqrt(n); //返回n的平方根
if(j%2==0)
j--;
while(j>2 && n%j!=0)
j-=2;
return j<2;
}
public static void main(String[] args) {
PrimeRing pr=new PrimeRing(10);
}
运行结果
素数环:[1, 2, 3, 4, 7, 10, 9, 8, 5, 6]
递归
1.定义
递归的定义是指,用一个概念本身直接或者间接的定义它自己。数学中的许多概念是递归定义的。总结一句话就是自己不断调用自己的过程。
递归函数有两个主要前提:
- 终止条件——当一定条件满足时,函数返回特定值,不再递归调用
- 递归调用——函数调用自身,其输入值更接近终止条件
递归三要素:
1.一定有一种可以退出程序的情况;
所谓递归,就是会在函数内部代码中,调用这个函数本身,所以,我们必须要找出递归的结束条件,不然的话,会一直调用自己,进入无底洞。也就是说,我们需要找出当参数为啥时,递归结束,之后直接把结果返回,请注意,这个时候我们必须能根据这个参数的值,能够直接知道函数的结果是什么。
2.总是在尝试将一个问题化简到更小的规模
3.父问题与子问题不能有重叠的部分
下面是用递归实现5!的阶乘递归过程。
public class Factorial {
public static int factorial(int n) {
if (n <= 1) {
return 1; // 基本情况:0的阶乘为1
} else {
return n * factorial(n - 1); // 递归调用
}
}
public static void main(String[] args) {
int result = factorial(5);
System.out.println("5的阶乘是:" + result); // 输出 5的阶乘是:120
}
}
这是一个常见的递归求和的案例,如果num=5,对于人来讲是一个sum函数不停的调用自己,那么对于电脑来讲是一个5个函数依次调用,只不过这5个函数长的一样都叫sum而已。
public static int sum5(int num){
return num + sum4(num - 1);
};
public static int sum4(int num){
return num + sum3(num - 1);
};
public static int sum3(int num){
return num + sum2(num - 1);
};
public static int sum2(int num){
return num + sum1(num - 1);
};
public static int sum1(int num){
return 1;
};
递归求值过程
每次递归调用都会创建一个新的栈帧,递归是一种可以将复杂的问题简化的方法,但是最好要慎用!因为比较消耗内存,很影响性能。
递归有两个特性,一个是必须有边界条件,还有一个是收敛性。
边界条件就是我们为了避免方法间的无限调用而专门设定的退出时机。不然无限压栈容易栈溢出,而且哪怕设定了退出条件但依然压栈特别多,也容易栈溢出,所以说在java语言里,鉴于递归的低效性,使用的场景不是特别多,所以java程序员即便不熟悉递归,也不影响工作。