数据结构梳理(3) - 栈

前言

上次梳理了数组和链表,这次我们再来看看栈,栈也是常用的数据结构之一,我们这次除了了解它的特性之外,主要是手动来实现它,平常我们可能都是直接使用的java api里的stack类,我们很少会去关注它的实现原理,如果这时候来了个任务,让自己实现一个轻量级的栈呢,对吧,所以自己动手实现才是最可靠的,也是非常有必要的,我主要是分为两种,一种是基于数组实现,一种是基于链表实现,好了,我们开始吧!

目录

1、栈的特性与适用场景
2、基于数组实现栈
3、基于链表实现栈
4、Java api中的栈(stack)实现

正文

1、栈的特性与适用场景

栈是一个非常好用的数据结构,关于栈的特性这里,其实就是四个字,后进先出,我们正常的思维是谁先来就谁先走,类似排队的效果,但是往往一些场景需要的效果正好是反的,需要谁后来就谁先走这样的效果,例如回溯递归效果,迷宫求解路径效果等,在遇到相对应的实际问题时,要能使用栈合理的解决问题。

由于比较简单,我就不赘述了,放一张图加深下对栈的理解。
在这里插入图片描述

2、基于数组实现栈

下面我们来手动实现一个轻量级的栈,一般有两种方式,基于数组或者基于链表,我们首先基于数组来实现,既然是基于数组,所以第一步就是先声明一个数组类型的成员变量,然后为了方便,一般还需声明当前元素个数,栈顶指针,以及默认数组长度等,如下

private  int[] arr;
private int topIndex;
private static final int length_default=10;//栈大小的默认值
private int size;//元素个数

然后就是初始化操作,这里也就是初始化数组,由于int类型默认为0,所以符合要求,初始化代码如下

public ArrayStack() {
	this(length_default);
}

public ArrayStack(int length) {
	arr=new int[length];
	topIndex=-1;
}

有了这些成员变量和初始化操作之后,接下来就是最核心的两个方法,入栈和出栈,我们要结合栈的特性来思考这两个方法该怎么实现,其核心就是这个栈顶指针的调整,每当我们添加一个元素的时候,就将栈顶指针+1,删除一个元素的时候,就将栈顶指针减一,当我们要弹出一个元素的时候,就是直接返回栈顶指针代表的下标元素,然后栈顶指针减一即可,好了,我们现在已经有了思路。

然后为了程序的健壮性,入栈的时候一定要记得判断栈是否满,出栈的时候也一样要判断栈是否为空,由于生命了栈顶指针,在这两个操作之后,一定要记得更新栈顶指针的位置,入栈出栈的代码如下

public void push(int data){
	if(isFull()){
		throw new IndexOutOfBoundsException("栈满啦");
	}
	arr[++topIndex]=data;
	size++;
}

public int pop(){
	if(isEmpty()){
		throw new NullPointerException("栈为空");
	}
	size--;
	return arr[topIndex--];
}

然后为了我们这个数据结构使用的方便,我们可以为其添加获取栈顶元素、判空、判满、打印等方法,如下

public int peek(){
	if(isEmpty()){
		throw new NullPointerException("空指针啦");
	}
	return arr[topIndex];
}

public boolean isEmpty(){
	return topIndex==-1;
}

public boolean isFull(){
	return size==arr.length;
}

//获取栈中元素的个数
public int getSize(){
	return size;
}

public void clear(){
	topIndex=-1;
}

@Override
public String toString() {
	if(isEmpty()){
		return "[]";
	}
	String str = "[ ";  
       for (int i = 0; i <= topIndex; i++) {  
           str += arr[i] + ", ";  
       }  
       str = str.substring(0, str.length()-2) + " ]";  
       return str;  
}

然后还有一个比较常用的功能没有实现,就是扩容,这个根据需求,自己设定扩容方法,一般每次扩大之前的两倍即可。

到这里就基本差不多了,一个基于数组的轻量级栈实现完毕,怎么样,是不是觉得很简单,我们现在可以为它写一写测试代码,验证一下它,如果实际业务还需要其它功能的话,就可以再在这个基础上添砖加瓦,完成更复杂的功能。

3、基于链表实现栈

刚才实现了基于数组的栈,现在我们再换种实现方式,实现一个基于链表的栈。

首先,既然是基于链表,那么我们先啥也不说,定义好我们的节点类,如下

public class Node{
	private int data;
	private Node next;
	
	public Node() {
		
	}
	
	public Node(int data,Node next){
		this.data=data;
		this.next=next;
	}
	
	public int getData(){
		return data;
	}
}

然后我们思考它应该有哪些成员变量呢,首先想到的就是栈顶指针,由于是链表,所以栈顶指针是一个Node对象,然后就是栈中元素的数量size,然后我们在初始化的时候,要分别给这两个对象赋值,如下

private Node topNode;
private int size;

public LinkedStack(){
	topNode=null;
	size=0;
}

接下来就是核心操作,压栈和弹出操作的实现,我们有了上面基于数组的实现方式的启发之后,会发现栈顶指针就是用来指向最新压栈的元素的,那么只要抓住这个核心即可,对链表来说,怎么让栈顶指针指向栈中最新的压栈元素呢,答案就是链表的头插法,然后更新插入节点为栈顶指针即可,然后弹出的时候,只需要删除头结点,也就是栈顶指针指向的节点,然后更新栈顶指针即可。ok,有了思路之后,我们就来实现这两个方法吧,如下

public void push(int data){
	Node node=new Node(data, topNode);
	topNode=node;
	size++;
}

public int pop(){
	if(topNode==null){
		throw new NullPointerException("栈为空");
	}
	int data=topNode.getData();
	topNode=topNode.next;
	size--;
	return data;
}

由于是链表,没有长度的限制,所以我没有做栈判满的处理,当然实际使用的话,最好还是手动设置一个长度限制,以免发生数据无限压栈的情况发生。

同样的,我们也可为其添加获取栈顶元素、判空、清空、打印等方法,如下

public int peek(){
	if(topNode==null){
		throw new NullPointerException("栈为空");
	}
	int data=topNode.getData();
	return data;
}

public boolean isEmpty(){
	return size==0;
}

public void clear(){
	topNode=null;
	size=0;
}

public int getSize(){
	return size;
}

@Override
public String toString() {
	if (isEmpty()) {
		return "null";
	} else {
		StringBuilder sb = new StringBuilder("");
		for (Node current = topNode; current != null; current = current.next)// 从head开始遍历
		{
			sb.append(current.data + "-");
		}
		int len = sb.length();
		return sb.delete(len - 1, len).append("").toString();// 删除最后一个 -
	}
}

写完之后,我们为了正确性,一定要写一些测试用例来检验这段代码,主要是一些易出错的边界情况的测试。

4、Java api中的栈(stack)实现

好了,上面已经手动实现了一个轻量级的栈,可能功能还比较简单,但是实现了基础之后,再有其它需求,我们只需要在这个打好的“地基”上接着建造即可,所以这个就赘述了。

这时候,如果你有心的话,可能会去翻一番jdk中提供的栈的实现,我们现在来看一下人家实现的一个功能完善的栈是什么样子,和我们的有什么区别,有哪些地方是值得我们学习的。

这时候,我们点开源码,发现,咦,怎么代码这么少,一看,原来是偷懒了,hhhh,,继承了Vector这个类。jdk源代码去掉注释后,代码如下

public class Stack<E> extends Vector<E> {

    public Stack() {
    }
    
    public E push(E item) {
        addElement(item);
        return item;
    }

    public synchronized E pop() {
        E       obj;
        int     len = size();

        obj = peek();
        removeElementAt(len - 1);

        return obj;
    }

    public synchronized E peek() {
        int     len = size();

        if (len == 0)
            throw new EmptyStackException();
        return elementAt(len - 1);
    }

    public boolean empty() {
        return size() == 0;
    }

    public synchronized int search(Object o) {
        int i = lastIndexOf(o);

        if (i >= 0) {
            return size() - i;
        }
        return -1;
    }

    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = 1224463164541339165L;
}

我们可以明显发现很多工作,都是这个Vertor类做的,这个类是干嘛的呢,如果jdk用的不熟的话,可能没有用过这个类,这个其实非常简单,就是一个线程安全版本的ArrayList,然后我们发现Stack类中的部分方法加了synchronized关键字,加了这个关键字之后的效果就是同一时间只能单线程访问,然后我们可以看到push方法虽然没加synchronized关键字,但是它内部调用的是Vector的addElement方法,而Vertor类是线程安全的,所以这个方法也是线程安全的,最终push方法也就是线程安全的了。那说了这么多,相信你也明白了,jdk源码提供的这个Stack类是线程安全的。这是我们应该学习的,我们上面在自己实现的时候,没有考虑这个问题,所以我们可以改进我们上面的实现,再提供一个线程安全的版本。

然后我们再看看具体的方法实现,发现都很简单,和我们的思路差不多,如下我们打开push方法中调用的addElement方法,如下

public synchronized void addElement(E obj) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);//扩容判断
    elementData[elementCount++] = obj;
}

和我们实现的相比,只是多了一个中间的扩容操作,然后我们再看pop方法,pop方法中是调用的Vector中的removeElementAt方法,我们打开这个方法,如下

public synchronized void removeElementAt(int index) {
    modCount++;
    if (index >= elementCount) {
        throw new ArrayIndexOutOfBoundsException(index + " >= " +
                                                 elementCount);
    }
    else if (index < 0) {
        throw new ArrayIndexOutOfBoundsException(index);
    }
    int j = elementCount - index - 1;
    if (j > 0) {
        System.arraycopy(elementData, index + 1, elementData, index, j);
    }
    elementCount--;
    elementData[elementCount] = null; /* to let gc do its work */
}

首先是越界的处理,我们发现作者非常的细心,它将越界又分为了两种情况,一种是上越界,一种是下越界(<0越界),这个是我们可以优化的,其次就是它是做了元素的移动,调用的系统函数,其它的就没啥了。

然后就是我们在使用的时候,有一个最大的区别就是jdk提供的Stack使用了泛型,这样可以兼容所有的类型,而回过头来发现我们实现的,都是只能容纳一种指定的数据类型, 这样肯定是不优雅的,因为栈这个数据结构毕竟是一个容器,我们不可能实现一个int版本的栈,再实现一个String版本的栈,换句话说,也就是我们要实现通用效果,这时候,就可以借助系统实现的方式,使用泛型来完成这一个功能。有关泛型的使用不是本篇的重点,所以不作赘述,其实也不难,就是一些固定的语法,完全可以参展系统的实现代码将我们自己的实现替换为泛型实现方式,这里就不贴代码啦。

结语

好了,本篇到此结束,虽然只是实现一个栈,但是我们会发现在这个过程中还是会遇到很多很多的问题,然后我们再查看jdk源码中栈的实现时,又可以发现很多我们自己设计的容器存在的问题,以及别人优秀的设计思想,以后我们自己再遇到这样的问题的话,就要考虑到这些问题,然后吸收别人优秀的思想,将它运用到实际中去。

按照计划,下一篇是队列的梳理,这两天精力充沛,嘻嘻嘻,进度很快!!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章