算法分析学习笔记(二) - 栈和队列(上)


一. 写在前面的话

     本篇为“算法分析学习笔记”系列的第二篇,我们来聊一些跟基础数据结构有关的知识。对于网上无数的算法高手来说,这里讨论的东西都太小儿科了,但是作为一个系列的文章,我们还是要把该有的东西都补充完整的。本篇介绍两种最基本的抽象数据类型——栈和队列,以及支撑这两种数据类型的底层机制,计算机中一个很重要的概念——链表,确切的说,是“链式存储结构”。本篇分为两个部分,上半部分从回答“我们为什么需要各种不同数据结构”开始,展开对栈和队列的讨论,最后以对相关面试题的讨论作为上半部分的结束;下半部分首先讨论“迭代器”——迭代器是一种设计模式,它提供对数据结构的遍历功能,同时又隐藏了数据结构内部的具体实现,我们来简单看看Go语言实现的栈和队列是怎样实现迭代器的;然后,我们总结一下实现抽象数据类型常用的两种方法,以及各自的利弊权衡,并给出一个实际应用的例子——双端队列和随机队列,最后再为大家奉上一些干货——链表相关的面试题。总之,本篇介绍的数据结构虽然简单,但是很接地气。要着重强调和声明的是,本篇大部分的内容均来自于Princeton大学Robert Sedgewick教授的“算法”公开课,一些示意图和代码也是从Sedgewick老师的课程中获得的,这些都是我跟着老师学习算法的经验总结,我对Sedgewick老师表示感谢,是他老人家带我领略了一个无比美妙的新世界。
     让我们开始新的历程吧。

二. 为什么是数据结构?

     你也许会问,为什么要学习数据结构?为什么我们需要这么多数据结构?我对这个问题的回答是——因为需要。如果问题很简单,比如编写一个猜数字的游戏,玩家每次猜一个数字,然后计算机来告诉玩家大了还是小了,还是猜中了,这样的问题包含的数据是不需要什么结构的,我只要保存一个随机生成的数字即可。但计算机解决的大多数问题都是复杂问题,复杂问题包含的信息结构也是复杂的,那么我们就需要用数据结构去模拟这些信息结构,把问题空间的信息结构模拟成解空间的数据结构,然后才谈得上解决计算问题,所以数据结构其实是数据的一种组织方式而已。比如一个复杂的机票预定系统,用户要购买机票,搜索航班信息就必须得快,否则这边还在搜索,那边机票就卖完了,这个就是不可接受的。搜索要快,那么就得有一个高效的搜索算法,搜索算法之所以高效,那是因为该系统对复杂信息的组织高效,比如它用了红黑树来组织机票信息,那么搜索的时间就很快。因此这么多数据结构存在的意义就在于我们要解决各种各样不同需求的复杂问题,它们是各种高效算法的基础和有力支撑。

三. 编程语言中的“泛型”

     数据结构往往扮演着“容器”的角色——以某种方式将某类同型数据组织到一起,比如以字符串的形式将用户的姓名组织成一个队列,或者以实数的形式将待求和的数组织成一个队列。同样都是队列,只是具体的数据单元类型不同,况且将来还可能用队列的方式组织其他新的数据单元类型,如果我们不得不为每一种出现的数据单元类型都编写一个大同小异的队列容器的话,那简直是一场灾难——不仅要“复制粘贴”大量的代码,而且每次添加一个新功能 都要全部检查一遍,容易遗漏或者出错——幸好我们还没有傻到这种程度,对于这样的问题,我们只需要将“数据单元类型”抽象成一种“抽象类型”,每次当我们需要创建一个具体类型的容器时,指定该具体类型即可。很多编程语言都支持这样的机制,C++用模版来支持泛型,Java用“类型参数”的方式来支持泛型,而一些动态类型语言比如Python做的更绝——它的容器可以存放任何类型元素,哪怕它们完全不是一个类型。比如你可以编写如图1-1所示的代码来创建一个字符串类型的栈,但是有一个细节要注意,尖括号中指定的具体数据类型必须是引用类型,即类类型,不能是int, float和char之类的基础类型,要用对应的Integer,Float和Character取而代之,对于这些类型的容器,可以直接插入和取出基础类型,Java会在它们之间自动进行转换,这个就是Java的auto-boxing机制。

图3-1 字符串类型的栈

四. 后进先出的数据结构——栈

     如果我们希望最先放进容器里的元素最后被取出来,而最后放进去的元素最先被取出来,那么这个容器就要被实现为栈。栈有点像枪械的弹夹,元素就好比子弹,我们是将一颗颗的子弹压入弹夹中,射击时最后被压入弹夹的子弹最先打出来。栈的应用很广泛,我们每天打交道的操作系统就需要栈来进行函数调用,特别是递归函数,如果递归的层次太深会“栈溢出”,栈在编译原理中应用也很广泛,它可以用来判断一个算术表达式中的括号是否匹配,用来对逆波兰表达式进行求值等等,栈具有如图4-1所示的API。

图4-1 栈的API Specification

4.1 栈的动态数组实现

     我们先来看看一种较为简单的,使用动态数组来实现栈的方法。在这种方法中,我们使用数组来存储元素。这种实现方式的问题在于数组大小是创建时固定好了的,如果栈装满了还往里面塞入元素,就会上溢出(overflow),所以我们要在适当的时候动态扩展数组;如果栈中的元素都弹出的差不多了,还留那么多空间,那就是浪费,所以我们要在适当的时候对数组进行动态扩展和缩小,这就是这个算法比较有意思的地方——动态调整数组大小,因为一些细节值得仔细推敲,一些技巧值得一学,如果稍不注意就踩坑了。先贴出内部结构的实现细节,如下所示。用一个泛型数组a来存储元素,用N来记录目前栈中元素的个数。
public class ResizingArrayStack<Item> implements Iterable<Item> {
    private Item[] a = (Item[]) new Object[1];  // array of items
    private int N = 0;                          // number of elements on stack
    /* omitted */
}

 
   
      
      
     我们来讨论几个关键的栈操作,首先来看看push()操作,如下所示。push()操作会先检查数组的长度,如果发现长度等于栈中元素个数N,说明此时栈已满,我们就将其扩大一倍,然后再将新元素放入栈顶。resize()函数先创建一个容量更大的数组temp,然后遍历原来的数组a,将元素一个一个地塞入temp中,最后将temp赋值给a,让a指向新的数组。

    public void push(Item item) {
 
     
      
      
// double size of array if necessary
if ( N == a . length ) resize ( 2 * a . length );
a [ N ++] = item ;
}
 
     
      
      
private void resize ( int capacity ) {
assert capacity >= N ;
Item [] temp = ( Item []) new Object [ capacity ];
for ( int i = 0 ; i < N ; i ++) {
temp [ i ] = a [ i ];
}
a = temp ;
}
 
     
      
      
public Item pop () {
if ( isEmpty ()) throw new NoSuchElementException ( "Stack underflow" );
Item item = a [-- N ];
a [ N ] = null ; // avoid loitering
// shrink size of array if necessary
if ( N > 0 && N == a . length / 4 ) resize ( a . length / 2 );
return item ;
}
     比较值得关注的是这里的pop()函数,它先检查栈是否为空,如果不为空则将栈顶元素弹出并返回。值得注意的细节有两个地方,在将要弹出的元素保存在item中后,我们对a[N]赋值为null,这个在Java中被称作“loitering”,就是说,无论item也好,a[N]也好,都不是被弹出对象本身,而是被弹出对象的引用,只有当该对象的引用计数为0的时候,GC才会将这部分内存当作垃圾回收,否则只要有一个引用还在,GC就 不敢回收它,这里将a[N]设置为null,就是取消它的引用计数;另一个值得注意的地方是,当栈中元素的个数降到数组长度的1/4时,该数组的长度减半,为什么要等到元素个数只有1/4而不是1/2时就减半呢?联系前面的push()来看,push()操作是数组满了的时候才扩大一倍,如果这里的pop()操作在栈中元素降到一半时就匆匆忙忙将数组大小减半,就会在极端情况下出现颠簸(thrashing)现象:当栈满的时候,构造一个操作序列“push-pop-push-pop-push-pop...”,数组就会不停地扩大,缩小,扩大 ,缩小……然后每次都要把剩余的元素拷贝过来拷贝过去,造成算法效率低下。而这里只有当元素个数降到1/4时才会对数组进行减半,就避免了这个问题,而栈的空间使用率总是能保持在25%到100%之间。
     那么这样的实现方式算法效率如何?我们来分析一下,插入前N个元素时,每次插入要访问一次数组,再加上如果将数组(最开始长度为1)加倍到长度为k需要访问k次,那么总共要访问N + (2 + 4 + 8 + ... + N) = N + 2N - 1 = 3N - 1。摊销到每一次操作上,就约等于常量时间了。也就是说在栈100%满的那一瞬间插入元素时栈的效率会突然变慢,之后又恢复正常,而每一个操作具有常量的摊销时间,这个效率是可以接受的。完整实现请参考http://algs4.cs.princeton.edu/13stacks/ResizingArrayStack.java.html。

4.2 栈的链式结构实现

     栈的另一种实现方式就是使用链式存储结构,即链表。链表是一个很基础的数据结构,是各种复杂抽象数据类型的基石。它是一种递归的数据结构,严格的定义如下:“Linked lists:A linked list is a recursive data structure that is either empty (null) or a reference to a node having a generic item and a reference to a linked list.”这里的“node”就是一个存储数据的实体,它除了包含数据之外还包含指向其他实体的指针,其定义如图4-2所示。

图4-2 Node的定义
     Node有各种变种,如果我们要实现一个双链表,就可以在Node结构中再加入一个prev引用。如果我们要实现二叉树,则可以带上left和right引用。如果说复杂数据结构是一座大厦,那么这里的Node就好比一砖一瓦,看似简单的小小链表其实暗藏玄机,处理这种链式数据结构稍不小心就会访问到空指针或者非法的内存地址,因此要想实现健壮的复杂数据类型,链表必须要深入理解并掌握透彻。
     那么我们怎样实现一个链式存储结构的栈呢?方法也很简单,这一次我们不再使用数组,而是将Node声明为一个私有的内部类,并用first变量记录该链式结构的头部,作为栈顶。每次压栈和弹栈操作就直接操作这个first变量即可,如下所示。
public class Stack<Item> implements Iterable<Item> {
    private int N;                // size of the stack
    private Node<Item> first;     // top of stack

    private static class Node<Item> {
        private Item item;
        private Node<Item> next;
    }
    /* omitted */
 
       
}
         
        
        
          
          
     这里的push()操作和pop()操作提供的功能一样,但是内部实现细节就不一样了。对于push()操作,我们先用oldfirst保存原来的栈顶,然后用first指向新栈顶,再把原来的oldfirst链接到新栈顶的next指针上,就实现了压栈操作,因为单链表这种数据结构实现头部插入是很高效的,常量时间就能完成。pop()操作则正好反过来,我们将栈顶元素从链表中删除然后返回即可,它同样也是O(1)的效率。从这里我们可以看出,使用链表结构实现栈,其push和pop操作在最坏情况下常量时间内就能完成,因此它的执行效率比上面那种实现方式更有保障,在任何情况下都能O(1),完整实现请参考http://algs4.cs.princeton.edu/13stacks/Stack.java.html。
 
       
    public void push(Item item) {
        Node<Item> oldfirst = first;
        first = new Node<Item>();
        first.item = item;
        first.next = oldfirst;
        N++;
    }

    public Item pop() {
        if (isEmpty()) throw new NoSuchElementException("Stack underflow");
        Item item = first.item;        // save item to return
        first = first.next;            // delete first node
        N--;
        return item;                   // return the saved item
    }

五. 先进先出的数据结构——队列

     我们再来看看队列,我们从队列中取出的元素一定是我们最早放进去的元素,就好比电影院或者甜品店门口排成的长龙,先到先得。队列的应用同样也很广泛,分布式系统中异步处理机制通常就是将待处理的请求放入消息队列,然后每次从队列中取出消息进行处理。很多事件驱动的异步IO框架也是基于队列机制实现的,比如GUI。队列具有图5-1所示的API。


图5-1 队列API Specification
     队列的具体实现也有两种,一种基于动态数组,另一种基于链表。但这里推荐使用链表的方式实现队列,因为动态数组的实现方式需要通过在数组下表上进行取模运算,将数组变为“环”,实现起来要考虑的因素较多,容易产生bug,而链表实现方式要简洁一些。用链表来实现队列,需要维护两个指针:first和last。first指向队列头部,负责出队列操作;而last指向队列尾部,负责入队列操作。这种实现方式要注意的就是考虑特殊情况,即当链表为空时的入队列和链表只有一个元素时的出队列,照例,先贴出内部数据结构的实现细节如下,两个指针一个整型变量,简单明了。
public class Queue<Item> implements Iterable<Item> {
    private int N;               // number of elements on queue
    private Node<Item> first;    // beginning of queue
    private Node<Item> last;     // end of queue

    private static class Node<Item> {
        private Item item;
        private Node<Item> next;
    }
    /* omitted... */
}
     队列的主要操作就是入队列和出队列两个,都很简单,无非就是个保留旧指针,链接新元素的过程。但要注意对特殊情况的讨论,比如做入队列操作时,若原来队列为空,则需要将first指针也指向last;出队列操作时若队列为空,同样需要将last指针设置为null。这也启发我们,在设计复杂数据结构的时候,每增加一个指针都要小心,因为这些指针都要小心维护,有一个细节没考虑周全就会产生隐藏很深不容易调试的bug。队列的基本操作效率很高,常量时间内就能完成,是一种实用的数据结构,完整实现请参考http://algs4.cs.princeton.edu/13stacks/Queue.java.html。
    public void enqueue(Item item) {
        Node<Item> oldlast = last;
        last = new Node<Item>();
        last.item = item;
        last.next = null;
        if (isEmpty()) first = last;
        else           oldlast.next = last;
        N++;
    }

    public Item dequeue() {
        if (isEmpty()) throw new NoSuchElementException("Queue underflow");
        Item item = first.item;
        first = first.next;
        N--;
        if (isEmpty()) last = null;   // to avoid loitering
        return item; 
}


  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值