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

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

1.队列与排队

队列和栈一样,都是有约束条件的数据结构。

队列和排队非常类似,大家排成一排就像队列存储的元素。 以电影院举例,永远是排在最前面的人最先离队进入影院,后来进入队列人排在最后。

在计算机中,我们用先进先出来总结这个特征,或者缩写为FIFO(first in, first out)。 我们再来详细描述队列的约束条件:

  1. 只能在末尾插入元素;
  2. 只能读取开头的元素;
  3. 只能移除开头的元素。

2.队列的实现

数组实现队列

因为队列可以从头部删除尾部插入元素,我们需要两个指针分别标记队列的头部和尾部,分别定义为frontrear

front指向的即将删除的头部元素;

rear指向即将插入元素的位置。

开始时frontrear都指向第一个空元素,如下所示:

在这里插入图片描述

当我们每添加一个元素时,让rear指针向右移动一位。 当我们每删除一个元素时,让front指针向右移动一位

在添加a, b, c, d, e后,数组的存储如下图所示:

在这里插入图片描述

我们继续进行添加和删除操作,在某一个时刻,数组可能出现如下情况:

在这里插入图片描述

大家仔细看看这个数组存储分布,发现什么问题了么?

随着front的右移,导致数组的左侧空间全部浪费了。

那该如何解决这个空间浪费问题呢?

第一个方案:数组调整

当然最简单的办法是每一次删除以后,将右侧的元素依次左移。

大家注意,这是一个不靠谱的方案,这让我们每次删除操作的时间复杂度为O(n)。我们只听说过时间换空间,可没听说过空间换时间!

第二个方案:循环数组

在上面场景下,如果能把数组变成循环数组便可以解决了。 那什么是循环数组呢?循环数组指的是数组末尾元素下一个元素并不是越界,而是第一个元素。

用图表示如下:

在这里插入图片描述

如果使用循环数据,可以完美的解决队列导致空间浪费问题。

代码实现

Java中,队列被称为Queue,数组实现的队列被称为ArrayQueue。本节课我们将自己实现一个YKDArrayQueue

第1步:对象

首先我们加入基本对象和变量。

public class YKDArrayQueue<T> {

    // 前后两个指针
    private int front;
    private int rear;

    // 底层存储数组
    private T[] queue;

    public YKDArrayQueue(int size) {
        this.front = 0;
        this.rear = 0;

        // #1. 特别注意此处的数组泛型的写法
        this.queue = (T[]) new Object[size];
    }
}

如上代码,YKDArrayQueue提供一个创建队列指定大小的构造函数。 需要注意上面#1,创建泛型数组的方式。

第2步:队尾添加元素

// 队列尾部添加元素
public void add(T o){
    this.queue[this.rear] = o;
    // #2. 因此是循环队列,所以处理数组长度取余
    int newRear = (rear + 1) % this.queue.length;
    // #3. 指针相遇表示队列已满,暂不考虑扩容情况
    if(newRear == this.front){
        // #4. 如果加入元素以后指针碰撞,则抛出越界提示
        throw new IndexOutOfBoundsException("队列已满");
    }
    this.rear = newRear;
}

特别注意一下亮点:

  1. #2部分,因为我们是循环列表,所以用取余的方式获取指针位置
  2. #3部分,我们使用头尾相碰作为队列已满的条件。
  3. #4部分,此处我们利用 Java 异常机制,抛出队列已满异常

为了方便大家查看队列数据,我们加入一个toString方法将队列数据连接成字符串返回。

public String toString() {
    StringBuffer sb = new StringBuffer();
    int i = this.front;
    while (i != this.rear) {
        sb.append(this.queue[i]);
        sb.append(" ");
        i++;
    }
    return sb.toString();
}

第 3 步:如何获取队列的长度

在上面我们已经往队列中加入元素了,那加入元素后,应该怎么获取队列的长度呢? 同学们可能会回答,我们知道尾部索引,也知道头部索引,那么长度的计算公式如下:

int size = this.rear - this.front;

大家再仔细想想,在循环数组的情况下,这种计算公式对么? 肯定是不对的,因为循环数组,有可能会出现rear < front的。那应该怎么解决呢?如果小于的情况,我们可以加上数组的长度再进行相减。 完善一下代码,如下:

// 获取队列的长度
public int size() {
  if (this.rear < this.front) {
      return this.rear + this.queue.length - this.front;
  }
  return this.rear - this.front;
}

在此基础上,我们继续完成队列删除和获取索引元素的方法。

第4步:队列头部删除元素

注意处理队列为空的情况。

// 删除队列头部元素
  public T remove() {
    if (this.front == this.rear) {
      throw new IndexOutOfBoundsException("队列为空,不允许remove");
    }
    T item = this.queue[this.front];
    this.front = (this.front + 1) % this.queue.length;
    return item;
  }

第5步:获取队列索引元素

同样要注意队列为空的情况。

 // 获取队列中索引位置元素
  public T get(int i) {
    if (i < 0 || i >= this.size()) {
      throw new IndexOutOfBoundsException("获取队列元素,越界");
    }
    int index = (i + this.front) % this.queue.length;
    return this.queue[index];
  }

完整代码如下:

public class YKDArrayQueue<T> {

  // 前后两个指针
  private int front;
  private int rear;

  // 底层存储数组
  private T[] queue;

  public YKDArrayQueue(int size) {
    this.front = 0;
    this.rear = 0;

    // #1. 特别注意此处的数组泛型的写法
    this.queue = (T[]) new Object[size];
  }

  // 队列尾部添加元素
  public void add(T o) {
    this.queue[this.rear] = o;
    // 因此是循环队列,所以处理数组长度取余
    int newRear = (rear + 1) % this.queue.length;
    // 暂不考虑扩容情况
    if (newRear == this.front) {
      // 如果加入元素以后指针碰撞,则抛出越界提示
      throw new IndexOutOfBoundsException("队列已满");
    }
    this.rear = newRear;
  }

  // 删除队列头部元素
  public T remove() {
    if (this.front == this.rear) {
      throw new IndexOutOfBoundsException("队列为空,不允许remove");
    }
    T item = this.queue[this.front];
    this.front = (this.front + 1) % this.queue.length;
    return item;
  }

    // 获取队列中索引位置元素
    public T get(int i) {
      if (i < 0 || i >= this.size()) {
        throw new IndexOutOfBoundsException("获取队列元素,越界");
      }
      int index = (i + this.front) % this.queue.length;
      return this.queue[index];
    }

  // 获取队列的长度
  public int size() {
    if (this.rear < this.front) {
      return this.rear + this.queue.length - this.front;
    }
    return this.rear - this.front;
  }

  public String toString() {
    StringBuffer sb = new StringBuffer();
    int i = this.front;
    while (i != this.rear) {
      sb.append(this.queue[i]);
      sb.append(" ");
      i++;
    }
    return sb.toString();
  }

链表实现队列

通过学习了栈、队列数组版本的实现方案,我们会发现对于这种类似的数据结构,用数组实现是一件非常繁琐的事情,主要因为两个原因:

  • 数组天生对频繁的操作很不友好,每次插入删除操作都需要调整数组。
  • 数组连续空间存储特性,导致用数组实现的数据结构都存在越界或者扩容问题。

而链表是栈、队列底层存储最好的选择,本节我们来学习下如何用链表实现队列。

链表实现队列,需要哪些变量呢?

大致思路跟数组相同,但因为节点个数计算较为复杂,我们稍作修改:

  • 为了方便在头部删除节点,我们需要一个front指针指向链表的第一个节点;
  • 为了方便在尾部插入节点,我们需要一个rear指针指向链表的最后一个节点;
  • 链表只能遍历统计节点个数,会有额外的时间开销,所以我们增加size存储节点个数;

知道了差别后,链表队列的实现就会容易许多。

add: 类似于在列表尾部如果加入第一个节点 。

remove: 类似于链表如何删除第一个节点 。

get: 遍历链表返回对应索引的值。

size: 直接返回变量。

不熟悉链表的插入删除方法的同学可以看我这篇文章:仿写LinkList:基于节点的数据结构——链表

代码实现

完整代码如下:

public class YKDLinkedQueue<T> {

  // 前后两个指针
  private Node<T> front;
  private Node<T> rear;
  private int size = 0;

  // 队列尾部添加元素
  public void add(T o) {
    Node<T> node = new Node<>(o);
    if (this.front == null) {
      this.front = node;
    } else {
      this.rear.setNext(node);
    }
    this.size++;
    this.rear = node;
  }

  // 删除队列头部元素
  public T remove() {
    if(this.front == null){
      throw new IndexOutOfBoundsException("队列为空,不能删除");
    }
    Node<T> temp = this.front;
    this.front = temp.getNext();
    temp.setNext(null);
    this.size--;
    return temp.getContent();
  }

  // 获取队列中索引位置元素
  public T get(int i) {
    if (i < 0 || i >= this.size()) {
      throw new IndexOutOfBoundsException("获取队列元素,越界");
    }
    Node<T> node = this.front;
    while(i > 0){
      node = node.getNext();
      i--;
    }
    return node.getContent();
  }

  // 获取队列的长度
  public int size() {
    return this.size;
  }

  public String toString() {
    StringBuffer sb = new StringBuffer();
    Node<T> node = this.front;
    while (node.getNext() != null) {
      sb.append(node.getContent());
      sb.append(" ");
      node = node.getNext();
    }
    return sb.toString();
  }

3.队列的常见算法——滑块窗口

滑块是我们在网站中经常使用到的一种交互,用于滑动解锁、验证等等。

那滑块和队列有什么关系呢?如果我们将滑块当做队列,那么滑块的滑动区间也就表示队列所处理的所有数据。

当我们将滑块往右侧滑动的时候,可以理解为队列一边在尾部添加元素,一边在头部删除元素

1.获取最小的连续子数组

LeetCode中,有一道非常经典的题目:

给定一个含有 n 个正整数的数组nums和一个正整数 s ,找出该数组中满足其和 >= s 的长度最小的连续子数组。如果不存在符合条件的连续子数组,返回 0

输入: s = 7, nums = {2, 3, 1, 1, 4, 3} 输出: 2 解释: 子数组 [4,3] 是该条件下的长度最小的连续子数组。

暴力法

这个题目应该怎么求解呢?首先我们来看看暴力求解法

我们可以遍历所有的连续组合情况,然后依次判断其是否满足>=7,并获取最小的数组个数。

思路比较简单,我们直接来看代码

  // 查找最短子数组的长度
  public static int findMin(int[] nums, int s) {
    // 先定义最短长度为 nums.length + 1,因为肯定不会超过这个值
    int minLength = nums.length + 1;
    for (int i = 0; i < nums.length; i++) {
      int sum = 0;
      for (int j = i; j < nums.length ; j++) {
        sum += nums[j];
        // 获取调整 minLength
        if (sum >= s) {
          minLength = Math.min(minLength, j - i + 1);
          break;
        }
      }
    }
    return minLength;
  }

代码比较简单,核心思路是遍历所有连续子数组的情况

结果为2,和上面答案一样。这种解法的时间复杂度很容易计算出来,为O(N^2)

在面试算法的时候,就算我们不知道最优解是什么,但是至少我们要给出暴力解决问题的办法,这点很重要。一个解决方案都不提供是面试官最讨厌的。

滑块窗口法

从前 7 章经验可以看出O(N^2)肯定不是最优解!那有什么时间复杂度更低的解决方案呢?这就要用到今天的重点滑块窗口法

在使用滑块窗口法之前,我们先来看看上面的双重循环有哪些需要优化的地方?

暴力法缺点

我们以i = 2开始举例,遍历i = 2开始的最小连续数组,如下图所示:

在这里插入图片描述

从图中可以看出直到遍历到1, 1, 4, 3才找到满足>=7的子数组,之后我们便默认这个数组是当前的最优解,开始执行i = 3的遍历。 但在这个时候1, 1, 4, 3是最优解么?肯定不是?那应该如何优化呢?我们可以如下图所示进一步寻找:

在这里插入图片描述

我们推动滑块左边指针,推动到某一处,刚好滑块区域数组和>=7即可,这才是当前情况下长度最短的子数组。

滑块窗口思想

这就是滑块窗口的核心思想:

利用左右两个指针形成滑块效果,先固定左侧指针,推动右侧指针寻求到当前的,然后再推动左侧指针寻找解中的最优解。 滑块窗口的思想在连续子数组方面应用非常广泛。

我们试想下,时间复杂度是多少呢?无论左指针,还是右指针都只会将数组遍历一遍,所以时间复杂度为O(N),很赞!!

滑块窗口算法实现

明白了滑块解法的原理,我们来看看算法如何实现。

第 1 步:先思考下程序需要哪些变量?

// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
    // 左右两个指针
    int front = 0;
    int rear = 0;

    // 当前最小长度,为数组长度 + 1
    int minLength = nums.length + 1;

    // 滑块内元素的和,用于判断解和最优解
    int sum = 0;
}

特别注意sum这个变量,用于存储当前滑块的内部数据的和。

第 2 步:思考下程序运行的终止条件是什么?

frontrear都运行到数组结束为止。

我们继续添加代码如下

// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
    // 左右两个指针
    int front = 0;
    int rear = 0;

    // 当前最小长度,为数组长度 + 1
    int minLength = nums.length + 1;

    // 滑块内元素的和,用于判断解和最优解
    int sum = 0;

    while (front < nums.length && rear < nums.length) {
    }
}

第 3 步:我们优先推动右指针,找到解为止

// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
    ...

    while (front < nums.length && rear < nums.length) {
        // 推进右指针,寻找解
        while (rear < nums.length) {
            // 一直累加 sum, 直到刚好 >= s时停止
            sum += nums[rear];
            if (sum >= s) {
                break;
            }
            rear++;
        }
    }
}

注意累加 sum,直到刚好 >= s 时跳出循环

第 4 步:推动左指针找寻当前解中最优解

// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
    ...

    while (front < nums.length && rear < nums.length) {
        // 推进右指针,寻找解
        while (rear < nums.length) {
            // 一直累加 sum, 直到刚好 >= s时停止
            sum += nums[rear];
            if (sum >= s) {
                break;
            }
            rear++;
        }
        // 尝试推进左指针,寻找当前最优解
        while (front < nums.length) {
            // 一直减少左侧元素, 直到刚好 < s时停止
            sum -= nums[front];
            if (sum < s) {
                // 此时找到当前最优解 rear - front + 1
                minLength = Math.min(minLength, rear - front + 1);
                // 两个指针同时向右推动,查找下一个解
                rear++;
                front++;
                break;
            }
            front++;
        }
    }
}

左指针的推进逻辑比较复杂,大家需要多读读上面的代码,特别是代码注释如果还是不理解,则跟着代码的思路在本子上画一画每一步执行结果,这是理解复杂算法最有效的手段

第 5 步:异常情况的处理

比如如果nums为空的情况,上面代码将返回1,而不是0。因此我们在循环之前加强一下判断

// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
    ...

    // 处理异常情况
    if(nums.length == 0){
        return 0;
    }

    while (front < nums.length && rear < nums.length) {
      ...
    }
}

运行结果和暴力法一样,但是效率提升是非常大的。

2.无重复字符的最长连续子串

题目描述:

给定一个字符串,请你找出其中不含有重复字符的 最长连续子串 的长度,此处的字符串暂时只考虑a - z

示例 1:
输入: “abcabcbb”
输出: 3
解释: 因为无重复字符的最长子串是 “abc”,所以其长度为 3。

示例 2:
输入: “bbbbb”
输出: 1
解释: 因为无重复字符的最长子串是 “b”,所以其长度为 1。

示例 3:
输入: “pwwkew”
输出: 3
解释: 因为无重复字符的最长子串是 “wke”,所以其长度为 3。

思考一下,这道题跟上一题的区别在哪里?在这里应该选择什么作为转折点呢?

这里的核心转折点是右指针推动的下一个元素,在滑块子串中已经存在了,也就是已经重复了,这时候应该怎么办呢? 应该转换一下方向了,推动左指针移动一步。然后再考虑右指针是否可以推动。

附:ASCⅡ码表

那如何判断字符串中是否有重复字符呢?

我们设置一个数组,元素为0~25,通过字母的ascⅡ的值对应数组的元素。比如:a对应0(‘a’-‘a’),b对应1(‘b’-‘a’),…将已出现的字母对应的元素置1,之后便可以判断对应元素是否为1来确定该字符是否已经出现过。

代码实现
public class Demo {

  // 查找最长无重复字符的连续子串
  public static int findMax(String str) {
    // 创建滑块的左右两个指针
    int front = 0;
    int rear = 0;

    // 最长长度
    int maxLength = 0;
    // 用于标记滑块中的字符
    int[] chars = new int[26];

    // 在这里是寻求最大长度所以不存在左移指针寻求最优解,只需要rear指针到达尾部结束
    while (rear < str.length()) {
      // 移动尾指针
      while (rear < str.length()) {
        int index = str.charAt(rear) - 'a';
        // 当遇到rear指向的字符在滑动中重复
        if (chars[index] == 0) {
          // 如果不重复,则继续推动rear指针右移
          chars[index] = 1;
          rear++;
        } else {
          // 重复则停止移动右指针
          break;
        }
      }

      // 获取当前的子串长度
      maxLength = Math.max(maxLength, count(chars));
      // 循环将左侧指针推动一步,直到子数组中没有重复的元素
      int index = str.charAt(front) - 'a';
      chars[index] = 0;
      front++;
    }
    return maxLength;
  }

  // 获取元素个数
  public static int count(int[] chars) {
    int size = 0;
    for (int aChar : chars) {
      if (aChar != 0) {
        size++;
      }
    }
    return size;
  }
  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值