栈和队列 - Algorithms, Part I, week 2 STACKS AND QUEUES

前言

上一篇:算法分析
下一篇:基本排序html

本篇内容主要是栈,队列 (和包)的基本数据类型和数据结构
文章里头全部的对数函数都是以 2 为底
关于性能分析,可能仍是须要一些数学知识,有时间能够回一下
在不少应用中,咱们须要维护多个对象的集合,而对这个集合的操做也很简单java

基本数据类型

  • 对象的集合
  • 操做:git

    • insert -- 向集合中添加新的对象
    • remove -- 去掉集合中的某个元素
    • iterate -- 遍历集合中的元素并对他们执行某种操做
    • test if empty -- 检查集合是否为空
  • 作插入和删除操做时咱们要明确以什么样的形式去添加元素,或咱们要删除集合中的哪一个元素。

处理这类问题有两个经典的基础数据结构:栈(stack) 和队列(queue)程序员

图片描述

二者的区别在于去除元素的方式:github

  • 栈:去除最近加入的元素,遵循后进先出原则(LIFO: last in first out)。算法

    • 插入元素对应的术语是入栈 -- push;去掉最近加入的元素叫出栈 -- pop
  • 队列:去除最开始加入的元素,遵循先进先出原则(FIFO: first in first out)。编程

    • 关注最开始加入队列的元素,为了和栈的操做区分,队列加入元素的操做叫作入队 -- enqueue;去除元素的操做叫出队 -- dequeue

此篇隐含的主题是模块式编程,也是平时开发须要遵照的原则segmentfault

模块化编程

这一原则的思想是将接口与实现彻底分离。好比咱们精肯定义了一些数据类型和数据结构(如栈,队列等),咱们想要的是把实现这些数据结构的细节彻底与客户端分离。客户端能够选择数据结构不一样的实现方式,可是客户端代码只能执行基本操做。数组

实现的部分没法知道客户端需求的细节,它所要作的只是实现这些操做,这样,不少不一样的客户端均可以使用同一个实现,这使得咱们可以用模块式可复用的算法与数据结构库来构建更复杂的算法和数据结构,并在必要的时候更关注算法的效率。浏览器

Separate client and implementation via API.

图片描述

API:描述数据类型特征的操做
Client:使用API​​操做的客户端程序。
Implementation:实现API操做的代码。

下面具体看下这两种数据结构的实现

栈 Stack

栈 API

假设咱们有一个字符串集合,咱们想要实现字符串集合的储存,按期取出而且返回最后加入的字符串,并检查集合是否为空。咱们须要先写一个客户端而后再看它的实现。

字符串数据类型的栈

图片描述

性能要求:全部操做都花费常数时间
客户端:从标准输入读取逆序的字符串序列

测试客户端

import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;

public static void main(String[] args)
{
    StackOfStrings stack = new StackOfStrings();
    while (!StdIn.isEmpty())
    {
    //从标准输入获取一些字符串
    String s = StdIn.readString();
    //若是字符串为"-",则客户端将栈顶的字符串出栈,并打印出栈的字符串
    if (s.equals("-")) StdOut.print(stack.pop());
    //不然将字符串入栈到栈顶
    else stack.push(s);
    }
}

客户端输入输出:

图片描述

栈的实现:链表

链表(linked-list)链接待添加...

咱们想保存一个有节点组成的,用来储存字符串的链表。节点包含指向链表中下一个元素的引用(first).

维持指针 first 指向链表中的第一个节点

  • Push:入栈,在链表头插入一个新的节点
  • Pop:出栈,去掉链表头处第一个节点

图片描述

Java 实现

public class LinkedStackOfStrings
{
     //栈中惟一的实例变量是链表中的第一个节点的引用
     private Node first = null;
     
     //内部类,节点对象,构成链表中的元素,由一个字符串和指向另外一个节点的引用组成
     private class Node
     {
         private String item;
         private Node next;
     }

     public boolean isEmpty()
     { return first == null; }
     
     //
     public void push(String item)
     {
         //将指向链表头的指针先保存
         Node oldfirst = first;
         //建立新节点:咱们将要插入表头的节点
         first = new Node();
         first.item = item;
         //实例变量的next指针指向链表oldfirst元素,如今变成链表的第二个元素
         first.next = oldfirst;
     }
     
     //出栈
     public String pop()
     {
         //将链表中的第一个元素储存在标量 item 中
         String item = first.item;
         //去掉第一个节点:将原先指向第一个元素的指针指向下一个元素,而后第一个节点就等着被垃圾回收处理
         first = first.next;
         //返回链表中原先保存的元素
         return item;
     }
}

图示

出栈

Pop

入栈

Push

性能分析

经过分析提供给客户算法和数据结构的性能信息,评估这个实现对以不一样客户端程序的资源使用量

Proposition 在最坏的状况下,每一个操做只须要消耗常数时间(没有循环)。
Proposition 具备n个元素的栈使用 ~40n 个字节内存
(没有考虑字符串自己的内存,由于这些空间的开销在客户端上)

图片描述

栈的实现:数组

栈用链表是实现花费常数的时间,可是栈还有更快的实现

另外一种实现栈的 natural way 是使用数组储存栈上的元素
将栈中的N个元素保存在数组中,索引为 n,n 对应的数组位置即为栈顶的位置,即下一个元素加入的地方

  • 使用数组 s[] 在栈上存储n个元素。
  • push():在 s[n] 处添加新元素。
  • pop():从 s[n-1] 中删除元素。

在改进前使用数组的一个缺点是必须声明数组的大小,因此栈有肯定的容量。若是栈上的元素个数比栈的容量多,咱们就必须处理这个问题(调整数组)

图片描述

Java 实现

public class FixedCapacityStackOfStrings
{
     private String[] s;
     //n 为栈的大小,栈中下一个开放位置,也为下一个元素的索引
     private int n = 0;
     
    //int capacity:看如下说明
     public FixedCapacityStackOfStrings(int capacity)
     { s = new String[capacity]; }
    
     public boolean isEmpty()
     { return n == 0; }
    
     public void push(String item)
     { 
         //将元素放在 n 索引的位置,而后 n+1
         s[n++] = item; 
     }
    
     public String pop()
     { 
         //而后返回数组n-1的元素
         return s[--n]; 
     }
}

int capacity: 在构造函数中加入了容量的参数,破坏了API,须要客户端提供栈的容量。不过实际上咱们不会这么作,由于大多数状况下,客户端也没法肯定须要多大栈,并且客户端也可能须要同时维护不少栈,这些栈又不一样时间到达最大容量,同时还有其余因素的影响。这里只是为了简化。在调整数组中会处理可变容量的问题,避免溢出

对于两种实现的思考

上述的实现中咱们暂时没有处理的问题:

Overflow and underflow

  • Underflow :客户端从空栈中出栈咱们没有抛出异常
  • Overflow :使用数组实现,当客户端入栈超过容量发生栈溢出的问题

Null item:客户端是否能向数据结构中插入空元素,上边咱们是容许的

Duplicate items: 客户端是否能向数据结构中重复入栈同一个元素,上边咱们是容许的

Loitering 对象游离:即在栈的数组中,咱们有一个对象的引用,但是咱们已经再也不使用这个引用了

图片描述

数组中当咱们减少 n 时,在数组中仍然有咱们已经出栈的对象的指针,尽管咱们再也不使用它,可是Java系统并不知道。因此为了不这个问题,有效地利用内存,最好将去除元素对应的项设为 null,这样就不会剩下旧元素的引用指针,接下来就等着垃圾回收机制去回收这些内存。这个问题比较细节化,可是却很重要。

public String pop()
{
 String item = s[--n];
 s[n] = null;
 return item;
}

调整数组

以前栈的基本数组实现须要客户端提供栈的最大容量,如今咱们来看解决这个问题的技术。

待解决的问题:创建一个可以增加或者缩短到任意大小的栈。
调整大小是一个挑战,并且要经过某种方式确保它不会频繁地发生。

怎样加长数组

反复增倍法 (repeated doubling):当数组被填满时,创建一个大小翻倍的新数组,而后将全部的元素复制过去。这样咱们就不会那么频繁地建立新数组。

Java 实现

public class ResizingArrayStackOfStrings {

    private String[] s;

    //n 为栈的大小,栈中下一个开放位置,也为下一个元素的索引
    private int n = 0;

    public ResizingArrayStackOfStrings(){
        s = new String[2];
    }

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

    /**
     * 从大小为1的数组开始,若是发现数组被填满,那么就在插入元素以前,将数组长度调整为原来的2倍
     * @param item
     */
    public void push(String item) {
        if (n == s.length) resize(2 * s.length);
        s[n++] = item;
    }

    /**
     * 调整数组方法
     * 建立具备目标容量的新数组,而后把当前栈复制到新栈的前一半
     * 而后从新设置和返回实例标量
     * @param capacity
     */
    private void resize(int capacity) {
        System.out.println("resize when insert item "+ (n+1));
        String[] copy = new String[capacity];
        for (int i = 0; i < n; i++)
            copy[i] = s[i];
        s = copy;
    }

    public String pop() {
        return s[--n];
    }
}

性能分析

往栈中插入 n 个元素的时间复杂度是线性相近的,即与 n 成正比 ~n

Q. 假设咱们从一个空的栈开始,咱们执行 n 次入栈, 那么咱们的 **resize()** 方法被调用了几回?
A. 是以 2 为底的对数次。由于咱们只有在栈的大小等于 2 的幂函数的时候,即 2^1,2^2,2^3 ... 2^i 的时候才会调用 resize().
   在 1 到 n 之间,符合 2 的幂的数字(如 2,4,8,16...) 一共有 logn 个,其中 log 觉得 2 为底.

咱们在插入 2^i 个元素时,须要复制数组 logn 次,须要花费访问数组 n + (2 + 4 + 8 + ... + m) ~3n 的时间,其中 m = 2^logn = n

  • n : 不管数组翻不翻倍,对于每一个新元素,入栈须要 Θ(1) 时间。所以,对于 n 个元素,它须要 Θ(n) 时间。即忽略常数项,插入 n 个 就有 n 次入栈的操做,就访问 n 次数组
  • (2 + 4 + 8 + ... + n):

    • 若是 n = 2^i 个元素入栈,须要数组翻倍 lgn 次。
      从技术上讲,总和(2 + 4 + 8 + .. + m)是具备 logN 个元素的几何级数
      而后:(2 + 4 + 8 + .. + m)= 2 *(2 ^ log N - 1) = 2(N - 1) = 2N - 2 ~2N
  • => N +(2 + 4 + 8 + .. + N)= N + 2N - 2 = 3N - 2 ~3N

举个栗子~,若是咱们往栈中插入 8 (2^3) 个元素,那么咱们必须将数组翻倍 lg8 次,即3次。所以,8个元素入栈的开销为 8 +(2 + 4 + 8)= 22 次 ≈ 24 次

再举个栗子~,若是插入 16 (2^4) 个元素,那么咱们必须将数组翻倍 lg16 次,即4次。所以,16个元素入栈的开销为 16 +(2 + 4 + 8 + 16)= 46 次 ≈ 48 次

图片描述

或者粗略想象一下,若是咱们计算一下开销,插入前 n (n = 2^i) 个元素,是对 2 的幂从1到N求和。 这样,总的开销大约是3N。先要访问数组一次,对于复制要访问两次。因此,要插入元素,大约须要访问数组三次。
下边的图是观察时间开销的另外一种方式,表示了入栈操做须要访问数组的次数。

图片描述

每次遇到2的幂,须要进行斜线上的数组访问时间,可是从宏观上来看,是将那些元素入栈上花去了红色直线那些时间 这叫作平摊分析。考虑开销时将总的开销平均给全部的操做。关于平摊分析就再也不解释了,有兴趣能够自行了解...

怎样缩小数组

若是数组翻倍屡次后,又有屡次出栈,那么若是不调整数组大小,数组可能会变得太空。那数组在什么状况下去缩小,缩小多少才合适?
咱们也许这么考虑,当数组满了的时候将容量翻倍,那么当它只有一半满的时候,将容量缩减一半。可是这样并不合理,由于有一种现象叫作thrashing:即客户端恰好反复交替入栈出栈入栈出栈...

若是数组满了就会反复翻倍减半翻倍减半,而且每一个操做都会新建数组,都要花掉正比与N的时间,这样就会致使thrashing频繁发生要花费平方时间,这不是咱们想要的。

图片描述

有效的解决方案是直到数组变为 1/4 满的时候才将容量减半
咱们只要测试数组是否为 1/4 满,若是是,则调整大小使其为 1/2 满。

不变式:数组老是介于25% 满与全满之间

public String pop()
 {
     String item = s[--n];
     //解决以前说的对象引用游离问题
     s[n] = null;
     if (n > 0 && n == s.length/4) resize(s.length/2);
     return item;
 }

这样的好处:

  • 由于是半满的,既能够插入向栈插入元素,又能够从栈删除元素,而不须要再次进行调整数组大小的操做直到数组全满,或者再次1/4满。
  • 每次调整大小时, 开销已经在平摊给了每次入栈和出栈

下图展现了上边测试写的客户端例子中数组上的操做

图片描述

能够看到在开始时,数组大小从1倍增到2又到4,但一旦到8,数组的大小则维持一段时间不变,直到数组中只有2个元素时才缩小到4,等等。

算法分析

运行时间

数组调整大小并不常常发生,但这是实现栈API的一种颇有效的方式,客户端不须要提供栈的最大容量,但依然保证了咱们使用的内存大小老是栈中实际元素个数的常数倍,因此分析说明对于任意的操做序列,每一个操做的平均运行时间与常数成正比。

这里,存在最坏状况(worst case)。当栈容量翻倍时,须要正比于N的时间,因此性能不如咱们想要的那么好,可是优点在于进行入栈出栈操做时很是快,入栈只须要访问数组并移动栈顶索引。对于大多数操做都很高效的。对于众多的客户端这是个颇有效的权衡。

图片描述

内存使用

栈的内存用量实际上比链表使用更少的内存。

给出的命题. 一个 ResizingArrayStackOfStrings 内存用量在 ~8n bytes~32n bytes 之间,取决于数组有多满。

只看 “private String[] s;”

图片描述

・~ 8n 当数组满时. -- 栈的实际长度 = n,因此内存占用是 8 乘以栈不为空的元素个数
・~ 32n 当数组 1/4 满时. -- 栈的实际长度 = 4n,因此内存占用是 8 乘以(4 乘以栈的有效元素个数)

这里只是计算了 Java中数组占用的空间。一样地,这个分析只针对栈自己 而不包括客户端上的字符串。

调整数组实现VS链表实现

图片描述

那么使用可调整大小的数组与链表之间如何取舍呢?

  • 这是两种 API相同的不一样的实现,客户端能够互换使用。

哪一个更好呢?

  • 不少情形中,咱们会有同一API的多种实现。你须要根据客户端的性质选择合适的实现。

Linked-list implementation.

  • 对于链表,每一个操做最坏状况下须要常数时间,这是有保障的
  • 可是为了处理连接,咱们须要一些额外的时间和空间。因此链表实现会慢一些

Resizing-array implementation.

  • 可调大小的数组实现有很好的分摊时间,因此整个过程总的平均效率不错
  • 浪费更少的空间,对于每一个操做也许有更快的实现

因此对于一些客户端,也许会有区别。如下这样的情形你不会想用可调大小数组实现:你有一架飞机进场等待降落,你不想系统忽然间不能高效运转;或者互联网上的一个路由器,数据包以很高的速度涌进来,你不想由于某个操做忽然变得很慢而丢失一些数据。

客户端就能够权衡,若是想要得到保证每一个操做可以很快完成,就使用链表实现
若是不须要保证每一个操做,只是关心总的时间,可能就是用可调大小数组实现。由于总的时间会小得多,若是不是最坏状况下单个操做很是快。
尽管只有这些简单的数据结构,咱们都须要作很重要的权衡,在不少实际情形中真的会产生影响。

队列 Queue

接下来咱们简要考虑一下使用相同基本底层数据结构的队列的实现。

队列的API

这是字符串队列对应的API,实际上和栈的API是相同的,只是名字不同而已...
入栈换成了入队(enqueue),出栈换成了出队(dequeue)。语义是不一样的。
入队操做向队尾添加元素,而出队操做从队首移除元素。
就像你排队买票同样,入队时你排在队列的最后,在队列里待的最久的人是下一个离开队列的人。

数据结构的性能要求:全部操做都花费常数时间。

图片描述

链表实现

队列的链表表示中,咱们须要维护两个指针引用。一个是链表中的第一个元素,另外一个是链表最后一个元素。

图片描述

插入的时候咱们在链表末端添加元素;移除元素的时候不变,依然从链表头取出元素。

  • 出队 dequeue

那么这就是出队操做的实现,和栈的出栈操做是同样的。保存元素,前进指针指向下一个节点,这样就删除了第一个节点,而后返回该元素。

图片描述

  • 入队 enqueue

入队操做时,向链表添加新节点。咱们要把它放在链表末端,这样它就是最后一个出队的元素。
首先要作的是保存指向最后一个节点的指针,由于咱们须要将它指向下一个节点的指针从null变为新的节点。
而后给链表末端建立新的节点并对其属性赋值,将旧的指针从null变为指向新节点。

图片描述

实际上如今指针操做仅限于如栈和队列这样的少数几个实现以及一些其余的基本数据结构了。如今不少操做链表的通用程序都封装在了这样的基本数据类型里。

Java 实现

图片描述

这里处理了当队列为空时的特殊状况。为了保证去除最后一个元素后队列是空的,咱们将last设为null,还保证first和last始终都是符合咱们预想。
完整代码: Queue.java 这里用到了泛型和迭代器的实现

数组实现

用可调大小的数组实现并不难,但绝对是一个棘手的编程练习。

  • 咱们维护两个指针,分别指向队列中的第一个元素和队尾,即下一个元素要加入的地方。
  • 对于入队操做在 tail 指向的地方加入新元素
  • 出队操做移除 head 指向的元素

棘手的地方是一旦指针的位置超过了数组的容量,必须重置指针回到0,这里须要多写一些代码,并且和栈同样实现数据结构的时候你须要加上调整容量的方法。

图片描述

完整代码:ResizingArrayQueue.java

额外补充

两个栈实现队列的数据结构
实现具备两个栈的队列,以便每一个队列操做都是栈操做的常量次数(平摊次数)。
提示:
1.若是将元素推入栈而后所有出栈,它们将以相反的顺序出现。若是你重复这个过程,它们如今又恢复了正常的队列顺序。
2.为了不不停的出栈入栈,能够加某个断定条件,好比当 dequeue 栈为空时,将 enqueue 栈的元素出栈到 dequeue 栈,而后最后从dequeue 栈出栈,也就实现了出队的操做。直到 dequeue 栈的元素都出栈了,再次触发出队操做时,再从 enqueue 栈导入数据重复上边的过程

实现参考:QueueWithTwoStacks.java

泛型 -- Generic

接下来咱们要处理的是前面实现里另外一个根本性的缺陷。前面的实现只适用于字符串,若是想要实现其余类型数据的队列和栈怎么 StackOfURLs, StackOfInts... 嗯。。。
这个问题就涉及泛型的话题了。

泛型的引出

实际上不少编程环境中这一点都是不得不考虑的。

  • 第一种方法:咱们对每个数据类型都实现一个单独的栈

    这太鸡肋了,咱们要把代码复制到须要实现栈的地方,而后把数据类型改为这个型那个型,那么若是咱们要处理上百个不一样的数据类型,咱们就得有上百个不一样的实现,想一想就很心累。

    不幸的是Java 推出 1.5 版本前就是陷在这种模式里,而且很是多的编程语言都没法摆脱这样的模式。因此咱们须要采用一种现代模式,不用给每一个类型的数据都分别搞实现。

  • 第二种方法:咱们对 Object 类实现数据结构
    有一个普遍采用的捷径是使用强制类型转换对不一样的数据类型重用代码。Java中全部的类都是 Object 的子类,当客户端使用时,就将结果转换为对应的类型。可是这种解决方案并不使人满意。
    这个例子中咱们有两个栈:苹果的栈和桔子的栈。接下来,当从苹果栈出栈的时候须要客户端将出栈元素强制转换为苹果类型,这样类型检查系统才不会报错。

图片描述

这样作的问题在于:

  • 必须客户端完成强制类型转换,经过编译检查。
  • 存在一个隐患,若是类型不匹配,会发生运行时错误。

第三种方法:使用泛型

这种方法中客户端程序不须要强制类型转换。在编译时就能发现类型不匹配的错误,而不是在运行时。

这个使用泛型的例子中栈的类型有一个类型参数(Apple),在代码里这个尖括号中。

图片描述

若是咱们有一个苹果栈,而且试图入栈一个桔子,咱们在编译时就会提示错误,由于声明中那个栈只包含苹果,桔子禁止入内。

优秀的模块化编程的指导原则就是咱们应当欢迎编译时错误,避免运行时错误。

由于若是咱们能在编译时检测到错误,咱们给客户交付产品或者部署对一个API的实现时,就有把握对于任何客户端都是没问题的。
有些运行时才会出现的错误可能在某些客户端的开发中几年以后才出现,若是这样,就必须部署咱们的软件,这对每一个人都是很困难的。

实际上优秀的泛型实现并不难。只须要把每处使用的字符串替换为泛型类型名称。

链表栈的泛型实现

如这里的代码所示,左边是咱们使用链表实现的字符串栈,右边是泛型实现。

图片描述

左边每处用到字符串类型的地方咱们换成了item。在最上面类声明的地方咱们用尖括号声明 item 是咱们要用的泛型类型,这样的实现很是直截了当,而且出色地解决了不一样的数据类型单独实现的问题。

数组栈的泛型实现

基于数组的实现,这种方法无论用。目前不少编程语言这方面都有问题,而对Java尤为是个难题。咱们想作的是用泛型名称 item 直接声明一个新的数组。好比这样:

public class FixedCapacityStack<Item>
{
 private Item[] s;
 private int n = 0;

     public FixedCapacityStack(int capacity)
     //看这里看这里像这样,可是实际咱们在java当中我却不能这样方便的实现
     { s = new Item[capacity]; }

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

     public void push(Item item)
     { s[n++] = item; }

     public Item pop()
     { return s[--n]; }
}

若有备注的这行所示。其余部分都和以前的方法没区别。不幸的是,Java不容许建立泛型数组。对于这个问题有各类技术方面的缘由,在网上关于这个问题你能看到大量的争论,这个不在咱们讨论的范围以内。关于协变的内容,能够自行了解,嗯。。。我一会也去了解了解...

这里,要行得通咱们须要加入强制类型转换
咱们建立 Object 数组,而后将类型转换为 item 数组。教授的观点是优秀的代码应该不用强制类型转换, 要尽可能避免强制类型转换,由于它确实在咱们的实现中留下了隐患。但这个状况中咱们必须加入这个强制类型转换。

图片描述

当咱们编译这个程序的时候,Java会发出警告信息说咱们在使用未经检查或者不安全的操做,详细信息须要使用-Xlint=unchecked 参数从新编译。

图片描述

咱们加上这个参数从新编译以后显示你在代码中加入了一个未经检查的强制类型转换,而后 java 就警告你不该该加入未经检查的强制类型转换。可是这么作并非咱们的错,由于你不容许咱们声明泛型数组,咱们才不得不这么作。收到这个警告信息请不要认为是你的代码中有什么问题。

自动装箱 (Autoboxing) 与拆箱 (Unboxing)

接下来,是个跟Java有关的细节问题,
Q. 对基本数据类型,咱们怎样使用泛型?
咱们用的泛型类型是针对 Object 及其子类的。前面讲过,是从 Object 数组强制类型转换来的。为了处理基本类型,咱们须要使用Java的包装对象类型。

如大写的 Integer 是整型的包装类型等等。另外,有个过程叫自动打包,自动转换基本类型与包装类型,因此处理基本类型这个问题,基本上都是在后台完成的.

Autoboxing:基本数据类型到包装类型的自动转换。
unboxing:包装器类型到基本数据类型的自动转换。

综上所述,咱们能定义适用于任何数据类型的泛型栈的API,并且咱们有基于链表和数组两种实现。咱们讲过的使用可变大小数组或者链表,对于任何数据类型都有很是好的性能。

额外补充

在 Java 6, 必须在变量声明(左侧)和构造函数调用(右侧)中指定具体类型。从Java 7 开始,可使用菱形运算符:
Stack<Integer> stack = new Stack<>();

Q. 为何Java须要强制转换(或反射)?
简短的回答: 向后兼容性。
详细地回答:须要了解类型擦除协变数组

Q. 当我尝试建立泛型数组时,为何会出现“没法建立泛型数组”错误?
public class ResizingArrayStack<Item> {Item[] a = new Item[1];}

A. 根本缘由是Java中的数组是协变的,但泛型不是。换句话说,String [] 是 Object [] 的子类型,但 Stack <String> 不是 Stack <Object> 的子类型。

Q. 那么,为何数组是协变的呢?

A. 许多程序员(和编程语言理论家)认为协变数组是 Java 类型系统中的一个严重缺陷:它们会产生没必要要的运行时性能开销(例如,参见ArrayStoreException)而且可能致使细微的 BUG。在Java中引入了协变数组,以免Java在其设计中最初没有包含泛型的问题,例如,实现Arrays.sort(Comparable [])并使用 String [] 类型的输入数组进行调用。

Q. 我能够建立并返回参数化类型的新数组,例如,为泛型队列实现 toArray() 方法吗?

A. 不容易。若是客户端将所需具体类型的对象传递给 toArray(),则可使用反射来执行此操做。这是 Java 的 Collection Framework 采用的(笨拙)方法。

迭代器 Iterators

Java还提供了另外一种可以使客户端代码保持优雅紧凑,绝对值得添加到咱们的基本数据类型的特性 -- 迭代器。

遍历

对于遍历功能,大多数客户端想作的只是遍历集合中的元素,咱们考虑的任何内部表示,这对于客户端是不相关的,他们并不关心集合的内部实现。也就是说咱们容许客户端遍历集合中的元素,但没必要让客户端知道咱们是用数组仍是链表。

Java提供了一个解决方式,就是实现遍历机制,而后使用 foreach.

Foreach loop

咱们自找麻烦地要让咱们的数据类型添加迭代器是由于,若是咱们的数据结构是可遍历的,在Java中咱们就可使用很是紧凑优雅的客户端代码,即所谓的for-each语句来进行集合的遍历。
使用了迭代器后,如下两种写法均可以不考虑底层的内部实现而遍历某个集合,两种方法是等价的:

Stack<String> stack;
...

// "foreach" loop
for (String s : stack)
    StdOut.println(s);
    
...
// 与上边的方法等价
Iterator<String> i = stack.iterator();
while (i.hasNext())
{
    String s = i.next();
    StdOut.println(s);
}

因此若是咱们有一个栈 stack 能够写(for String s: stack) 表示对栈中每一个字符串,执行打印输出。
咱们也能够写成下边这种完整形式的代码,但不会有人这么作,由于它和上边的简写形式是等价的。
不使用迭代器的话要实现遍历客户端代码中就要执行很是多没必要要的入栈出栈操做。因此这是可以让遍历数据结构中的元素的客户端代码变得这么紧凑的关键所在。

要使用户定义的集合支持 foreach 循环:

  • 数据类型必须具备名为 iterator() 的方法
  • iterator() 方法返回一个对象,这个对象具备两个核心方法:

    • hasNext() 方法, 当再也不遍历到任何元素时,返回false
    • next() 方法, 返回集合中的下一个元素

迭代器

为了支持 foreach 循环,Java 提供了两个接口。

  • Iterator 接口:有 next() 和 hasNext() 方法。
  • Iterable 接口:iterator() 方法返回 一个迭代器 Iterator
  • 二者都应该与泛型一块儿使用

Q. 什么是 Iterable ?
A. 在 Java 语言中 Iterable 是具备返回迭代器的方法的一种类

来源:jdk 8 java.lang.Iterable 接口

//此处T能够随便写为任意标识,常见的如T、E、K、V等形式的参数经常使用于表示泛型
//在实例化泛型类时,必须指定T的具体类型
public interface Iterable<T> {
    /**
     * Returns an iterator over elements of type {@code T}.
     *
     * @return an Iterator.
     */
    Iterator<T> iterator();
    ...
}

Q. 那么什么是迭代器 iterator ?
A. 迭代器是具备 hasNext() 和 next() 方法的类。

来源:jdk 8 java.util.Iterator 接口

public interface Iterator<E> {
    boolean hasNext();
    E next();
    default void remove()
    default void forEachRemaining(Consumer<? super E> action)
}

Java还容许 remove() 方法,但咱们认为这不是一个很好的特性,它有可能成为调试隐患,通常不用。
那么,只要有 hasNext() 和 next() 方法就使得数据结构是可遍历的,因此咱们要实现这两个方法。

下面咱们要作的是看看如何使咱们的栈、队列和后面要讲到的其余数据结构实现所谓的 Iterable(可遍历类)接口。

实例

咱们要给咱们全部的基本数据结构提供遍历机制。实现这个功能并不特别难,并且绝对值得投入精力。这是基于栈的代码。

栈实现迭代器: 链表实现

接下来咱们要实现Iterable接口。
实现Iterable接口意味着什么呢?这个类须要有iterator()方法返回迭代器。
什么是迭代器呢?咱们要用一个内部类。这个例子中,命名为 ListIterator 的内部类实现 Iterator 接口,而且是泛化(generic)的。
ListIterator 这个类主要完成的是实现 hasNext() 和 next() 方法。从名字就能清楚知道它们的语义。

  • hasNext() 在完成遍历以后会返回 false;若是尚未完成,应该返回 true
  • next() 方法提供要遍历的下一个元素

图片描述

因此若是是基于链表的数据结构,咱们要从表头 first 元素开始,这是处于表头的元素.
咱们要维护迭代器中的实例变量 current 存储当前正在遍历的元素。咱们取出current元素,而后将 current 引用指向下一个元素,并返回以前储存的item,也就将current 移动到了下一个元素上。
客户端会一直测试 hasNext(),因此当current变成空指针,hasNext 返回 false 终止遍历。
在咱们的遍历中,咱们只须要关注实现 next() 和 hasNext()方法,使用一个局部实例变量 current 就能完成。
若是遍历已经终止,客户端还试图调用 next() 或者试图调用 remove() 时抛出异常,咱们不提供remove()方法。

完整代码:StackImpIterator

栈实现迭代器: 数组实现

对于基于数组的实现,就更简单了。使用迭代器咱们能控制遍历顺序,使其符合语义和数据结构。
遍历栈时你要让元素以出栈的顺序返回,即对于数组是逆序的,那么这种状况下next() 就将索引减 1,返回下一个元素。
而咱们的实例变量是数组的索引。
只要该变量为正,hasNext() 返回 true。要实现这个遍历机制只须要写几行Java代码,之后遇到涉及对象集合的基本数据类型中咱们都会用这种编程范式。

图片描述

完整代码:ResizingArrayStack

实际上不少客户端并不关心咱们返回元素的顺序。咱们常常作的是直接向集合中插入元素,接下来遍历已有的元素。这样的数据结构叫作背包。

咱们来看看它的API。

图片描述

顺序并不重要,因此咱们想要能直接添加元素,也许还想知道集合大小。
咱们想遍历背包中全部的元素,这个API更简单,功能更少,但依然提供了几个重要的操做。
使用这个API,咱们已经看过实现了,只须要将栈的出栈操做或者队列的出队操做去掉,就能得到这个有用的数据结构的良好的实现

完整代码:Bag--ListIterator Bag--ArrayIterator

栈与队列的应用

栈的应用

栈确实很是基础,不少计算基于它运行 由于它能实现递归
·Java虚拟机
·解析编译器 (处理编译一种编程语言或者解释为实际的计算)
·文字处理器中的撤消
·Web浏览器中的后退按钮(当你使用网页浏览器上的后退按钮时,你去过的网页就存储在栈上)
·打印机的PostScript语言
·在编译器中实现函数的方式 (当有函数被调用时,整个局部环境和返回地址入栈,以后函数返回时, 返回地址和环境变量出栈. 有个栈包含所有信息,不管函数调用的是不是它自己。栈就包含了递归。实际上,你总能显式地使用栈将递归程序非递归化。)

队列的应用

应用程序
·数据缓冲区(iPod,TiVo,声卡...)
·异步数据传输(文件IO,sockets...)
·共享资源上分配请求(打印机,处理器...)
...

模拟现实世界
·交通的流量分析
·呼叫中心客户的等待时间
·肯定在超市收银员的数量...

前面的一些基本数据结构和实现看起来至关基础和简单,但立刻咱们就要涉及这些基本概念的一些很是复杂的应用。
首先要提到的是咱们实现的数据类型和数据结构每每能在 Java 库中找到,不少编程环境都是如此。好比在 Java 库中就能找到栈和队列。
Java 对于集合有一个通用 API,就是所谓的List接口。具体请查看对应版本 jdk 的源码。
List接口包含从表尾添加,从表头移除之类的方法,并且它的实现使用的是可变大小数组。

在 Java 库中

  • java.util.ArrayList 使用调整大小数组实现
  • java.util.LinkedList 使用双向链表实现

咱们考虑的不少原则其实 Java 库中 LinkedList 接口同样考虑了。 可是咱们目前仍是要用咱们本身的实现。
问题在于这样的库通常是开发组(committee phenomenon)设计的,里头加入了愈来愈多的操做,API变得过宽和臃肿。
在API中拥有很是多的操做并很差,等成为有经验的程序员之后知道本身在作什么了,就能够高效地使用一些集合库。可是经验不足的程序员使用库常常会遇到问题。用这样包含了那么多操做的,像瑞士军刀同样的实现,很难知道你的客户端须要的那组操做是不是高效实现的。因此这门算法课上坚持的原则是咱们在课上实现以前,能代表你理解性能指标前,先不要使用 Java 库.

Q. 在不运行程序的状况下观察下面一段将会打印什么?

int n = 50;

Stack<Integer> stack = new Stack<Integer>();
while (n > 0) {
    stack.push(n % 2);
    n = n / 2;
}

for (int digit : stack) {
    StdOut.print(digit);
}

StdOut.println();

A. 110010

值得注意的是,若是使用的是上边咱们本身定义的 iterator 去遍历,那么获得的就是符合栈后进先出特色的答案,可是若是直接使用java.util.Stack 中的Stack,在重写遍历方式前,他获得的就是先进先出的答案,这不符合栈的数据类型特色

图片描述

这是由于 JDK (如下以 jdk8 为例) 中的 Stack 继承了 Vector 类

package java.util;

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

而 Vector 这个类中 Stack 实现的迭代器的默认的遍历方式是FIFO的,并非栈特色的LIFO

图片描述

状态(已关闭,短时间不会修复):让 JDK 中的栈去继承 Vector 类并非一个好的设计,可是由于兼容性的问题因此不会去修复。

因此更印证了前面的提议,若是在没有对 JDK 底层数据结构有熟悉的了解前,提交的做业不推荐使用 JDK 封装的数据结构!

编程练习

Programming Assignment 2: Deques and Randomized Queues

原题地址:里头有具体的 API 要求和数据结构实现的性能要求。

使用泛型实现双端队列和随机队列。此做业的目标是使用数组和链表实现基本数据结构,并加深泛型和迭代器的理解。

Dequeue: double-ended queue or deque (发音为“deck”) 是栈和队列的归纳,支持从数据结构的前面或后面添加和删除元素

性能要求:deque 的实现必须在最坏的状况下支持每一个操做(包括构造函数)在最坏状况下花费常量时间。一个包含 n 个元素的双端队列最多使用 48n + 192 个字节的内存,并使用与双端队列当前元素数量成比例的空间。此外,迭代器实现必须支持在最坏的状况下每一个操做(包括构造函数)都是用常数时间。

Randomized queue: 随机队列相似于栈或队列,除了从数据结构中移除的元素是随机均匀选择的。

性能要求:随机队列的实现必须支持每一个操做( 包括生成迭代器 )都花费常量的平摊时间。
也就是说,对于某些常数c,任意 m 个随机队列操做序列(从空队列开始)在最坏状况下最多 c 乘以 m 步。包含n个元素的随机队列使用最多48n + 192 个字节的内存。
此外,迭代器实现必须支持在最坏状况下 next()和 hasNext()操做花费常量时间; 迭代器中的构造函数花费线性时间; 可能(而且将须要)为每一个迭代器使用线性数量的额外内存。

Permutation: 写一个叫 Permutation.java 的客户端,将整数 k 做为命令行参数; 使用StdIn.readString() 读取标准输入的字符串序列; 而且随机均匀地打印它们中的 k 个。每一个元素从序列中最多打印一次。

好比在输入端的序列是:
% more distinct.txt
A B C D E F G H I

那么打印的时候:
% java-algs4 Permutation 3 < distinct.txt
C
G
A

而绝对不出现:
C
G
G
同个元素被屡次打印的状况

命令行输入:能够假设 0≤k≤n,其中 n 是标准输入上的字符串的个数。

性能要求:客户端的运行时间必须与输入的数据大小成线性关系。能够仅使用 恒定数量的内存 加上 一个最大大小为 n 的 Deque 或RandomizedQueue 对象的大小。(对于额外的挑战,最多只使用一个最大大小为 k 的 Deque 或 RandomizedQueue 对象)

每一次做业都会有一个 bonus 的分数,就是相似奖励的分数,本次的做业的额外加分是上分的括号内容,同时还有内存使用之类

Test 3 (bonus): check that maximum size of any or Deque or RandomizedQueue object

created is equal to k
  • filename = tale.txt, n = 138653, k = 5
  • filename = tale.txt, n = 138653, k = 50
  • filename = tale.txt, n = 138653, k = 500
  • filename = tale.txt, n = 138653, k = 5000
  • filename = tale.txt, n = 138653, k = 50000

==> passed

Test 8 (bonus): Uses at most 40n + 40 bytes of memory
==> passed

Total: 3/2 tests passed!

附录

git 地址 100/100:在此

相关文章
相关标签/搜索