小编会一直更新数据结构相关方面的知识,使用的语言是Java,但是其中的逻辑和思路并不影响,如果感兴趣可以关注合集。
希望大家看完之后可以自己去手敲实现一遍,同时在最后我也列出一些基本和经典的题目,可以尝试做一下。大家也可以自己去力扣或者洛谷牛客这些网站自己去练习,数据结构光看不敲是学不好的,加油,祝你早日学会数据结构这门课程。
你要吃得了苦,经得起讽。
目录
本节重点掌握优先级队列,平时写题用的挺多的。
双端队列
概述
通过之前的学习,我们知道了队列是一种只能在其中一端删除元素,另一端添加元素的数据结构。那么现在我们来学习一下双端队列。
双端队列顾名思义就是在两端都可以删除和添加元素的一种数据结构。
同样我们也分别用链表和数组来模拟实现一下双端队列,为了接下来实现方便,我们先写一个双端队列接口,定义一些基本的方法,添加移除获得队头队尾元素,判空判满这些的。
public interface Deque<E> {
// 在队头添加元素
boolean pushInHead(E e);
// 在队尾添加元素
boolean pushInTail(E e);
// 移除队头元素
E pollHead();
// 移除队尾元素
E pollTail();
// 获得队头元素
E peekHead();
// 获得队尾元素
E peekTail();
// 判断队列是否为空
boolean isEmpty();
// 判断队列是否满
boolean isFull();
}
链表实现
因为是双端队列,为了提高效率我们使用带哨兵节点的双向环形链表来模拟实现。
结构
除了节点类以外,我们还要设置一个capacity属性表示容量,一个size属性表示大小,以及还有一个哨兵节点sentinel,又因为是双向环形链表,所以初始化的时候我们要把sentinel的prev指针和next指针都指向自己。
public class LinkedListDeque<E> implements Deque<E> {
static class Node<E> {
Node<E> prev;
E value;
Node<E> next;
public Node(Node<E> prev, E value, Node<E> next) {
this.prev = prev;
this.value = value;
this.next = next;
}
}
int capacity; // 容量
int size; // 大小
Node<E> sentinel; //哨兵节点
public LinkedListDeque(int capacity) {
this.capacity = capacity;
sentinel = new Node<>(null, null, null);
sentinel.next = sentinel;
sentinel.prev = sentinel;
}
}
遍历
为了方便后面写好一个方法我们就测试一次,我们就先把遍历给实现了。遍历双向环形链表也是非常简单啊,和之前遍历单向环形链表是一样的,从sentinel的next节点开始,当再一次来到sentinel节点的时候就表示走完一圈了。
// 遍历
public void forEach() {
Node<E> cur = sentinel.next;
while (cur != sentinel) {
System.out.println(cur.value);
cur = cur.next;
}
}
队列是否为空
因为我们提供了size和capacity属性,所以判空和判满都非常简单。当size == 0时就表示队列是空的。
@Override
public boolean isEmpty() {
return size == 0;
}
队列是否为满
当size == capacity 时队列就是满的。
@Override
public boolean isFull() {
return size == capacity;
}
添加元素
在队头添加元素
在队头添加元素,就相当于在链表头部添加元素,双向环形链表在头部添加元素如果不清楚可以看我前两篇文章,有详细讲解。我们只需要把新节点cur加在哨兵节点sentinel的next位置上就行了,然后注意改变四个指针的指向,其中两个就是cur的next和prev,还有就是哨兵节点的next和原来链表头节点的prev。最后不要忘了最后把队列的大小+1。
// 在队头添加元素
@Override
public boolean pushInHead(E value) {
// 链表满,特殊处理
if (isFull()) {
return false;
}
// 拿到哨兵节点
Node<E> pre = sentinel;
// 拿到链表原来的头节点
Node<E> last = sentinel.next;
// 设置cur的两个指针指向
Node<E> cur = new Node<>(pre, value, last);
// 设置pre的next指向cur
pre.next = cur;
// 设置last的prev指向cur
last.prev = cur;
// 队列大小+1;
size++;
return true;
}
在队尾添加元素
在队头添加元素,就相当于在链表尾部添加元素,而我们使用的是双向环形链表,所以链表的尾部就是我们哨兵节点的prev指针指向的节点pre。添加的逻辑都是一样的,以至于在代码上只需要改两个地方,就是pre和last所指代的节点不同。
// 在链表尾部添加节点
@Override
public boolean pushInTail(E value) {
// 链表满,特殊处理
if (isFull()) {
return false;
}
// 拿到尾节点
Node<E> pre = sentinel.prev;
// 拿到待插入位置的下一个节点
Node<E> last = sentinel;
// 设置cur的两个指针指向
Node<E> cur = new Node<>(pre, value, last);
// 设置pre的next指向cur
pre.next = cur;
// 设置last的prev指向cur
last.prev = cur;
// 队列大小+1;
size++;
return true;
}
实现完添加元素之后,我们就可以测试一下了。
@Test
public void test05(){
LinkedListDeque<Integer> queue = new LinkedListDeque<>(10);
queue.pushInHead(1);
queue.pushInHead(2);
queue.pushInHead(3);
queue.pushInTail(2);
queue.pushInTail(1);
queue.forEach();
}
OK,显示测试通过同时打印也与预期一致。
移除元素
移除队头元素
移除队头元素我们就是删除链表头节点cur就行了,也就是让sentinel的next指向cur的next就行了,然后改变一下前后节点的指针指向,最后不要忘了size-1。
// 移除队头元素
@Override
public E pollHead() {
// 链表为空,返回null
if (isEmpty()) {
return null;
}
// 拿到待删除节点的上一个节点
Node<E> pre = sentinel;
// 待删除节点cur
Node<E> cur = sentinel.next;
// 待删除节点的下一个节点last
Node<E> last = cur.next;
// 改变前后节点的next和prev指向
pre.next = last;
last.prev = pre;
// 链表大小-1
size--;
return cur.value;
}
移除队尾元素
因为是双向环形链表,所以链表的尾部就是我们哨兵节点的prev指针指向的节点pre。逻辑和上面是一样的,代码也就只要改一下pre,cur,last代表的节点就行了。
// 移除队尾节点
@Override
public E pollTail() {
// 链表为空,返回null
if (isEmpty()) {
return null;
}
// 待删除节点cur
Node<E> cur = sentinel.prev;
// 待删除节点的前一个节点
Node<E> pre = cur.prev;
// 待删除节点的下一个节点last
Node<E> last = sentinel;
// 改变前后节点的next和prev指向
pre.next = last;
last.prev = pre;
// 链表大小-1
size--;
return cur.value;
}
敲完之后都测试一下,这里测试用例和结果就不贴了。
获得元素
获得队头元素
获得队头元素,队头就是我们sentinel节点的next指向的节点,我们返回他的value就行了。
// 获得队头元素
@Override
public E peekHead() {
// 队列为空,返回null
if (isEmpty()) {
return null;
}
return sentinel.next.value;
}
获得队尾元素
因为我们是环形链表,队尾就是我们sentinel节点的prev指针指向的节点,我们返回他的value就行了。
// 获得队尾元素
@Override
public E peekTail() {
// 队列为空,返回null
if (isEmpty()) {
return null;
}
return sentinel.prev.value;
}
可以去测试了。
数组实现
结构
和之前一样,我们也是采用循环数组来实现双端队列,这里我们要设置两个变量,一个指向队头,一个指向队尾。其实结构和我们上一篇中的队列是一样的。
public class ArrayDeque<E> implements Deque<E> {
E[] array;
int head;
int tail;
public ArrayDeque(int capacity) {
array = (E[]) new Object[capacity + 1];
}
}
另外在之前我们循环数组的索引是通过求模元素来实现索引不越界的,这一次我们仿照java底层源码的方式来实现,在java提供的ArrayDeque中它是通过两个工具方法来实现的,我先把这两个工具方法写好再来解释一下。
// +1操作更改索引
static int inc(int i, int length) {
if (i + 1 >= length) {
return 0;
}
return i + 1;
}
// -1操作更改索引
static int dec(int i, int length) {
if (i - 1 < 0) {
return length - 1;
}
return i - 1;
}
我们仔细回想一下在之前我们是在索引+1之后给模上一个数组的长度来保证它不越界,其实我们仔细思考它只有在+1后等于数组长度时才会有影响,影响是变成了0,那我们提供了inc和dec这两个工具方法的效果和模运算是一样的,如果i+1等于数组的长度我们就把索引i变成0,否则就返回i+1。当i-1小于0时我们就把索引变成数组长度-1,否则返回i-1。
遍历
遍历我们只需要从head节点开始,一直让cur往后走,当来到tail节点时就让它停。和之前队列的遍历是一样的。
public void forEach() {
int cur = head;
while (cur != tail) {
System.out.println(array[cur]);
cur = inc(cur, array.length);
}
}
队列是否为空
这个其实有了之前环形数组实现队列的基础,我们很容易就想到当tail与head相同时就代表队列为空。
@Override
public boolean isEmpty() {
return tail == head;
}
队列是否为满
这个和我们之前是一样的,考虑的还是tail和head的距离是不是1,因为这次是双端队列,head也可能往后移动的特性,所以tail可能大于head,也可能head大于tail,所以这里我们要分情况讨论一下。
@Override
public boolean isFull() {
if (tail > head) {
return tail - head == array.length - 1;
} else if (tail < head) {
return head - tail == 1;
} else {
return false;
}
}
二者相等时就是队列为空的情况,我们返回false。
添加元素
队头添加元素
之前模拟队列时,在队尾添加元素,我们是让tail指针往后走,所以在队头添加元素,让head指针往前走就行了,也就是head-1,而head-1我们有工具方法dec()既能-1还能更新索引。
@Override
public boolean pushInHead(E value) {
if (isFull()) {
return false;
}
// 让head-1并更新索引
head = dec(head, array.length);
// 在head索引处添加元素
array[head] = value;
return true;
}
测试一下也是没什么问题的
队尾添加元素
这个其实和我们之前实现的差不多,让tail指针往后走就行了。
@Override
public boolean pushInTail(E value) {
// 队满,返回添加失败
if (isFull()) {
return false;
}
// 在tail位置添加新元素
array[tail] = value;
// 让tail指针往后移
tail = inc(tail, array.length);
return true;
}
移除元素
移除队头元素
队头元素就是head指针指向的节点,所以只需要先拿到head节点的元素,接着让head+1并且更新索引就好了。
@Override
public E pollHead() {
// 队空,返回null
if (isFull()) {
return null;
}
// 拿到队头元素
E value = array[head];
// 让head指针+1并且更新索引
head = inc(head, array.length);
return value;
}
移除队尾元素
队尾元素就是tail指针指向的前一个位置,所以只需要先让tail-1并且更新索引,然后拿到前一个位置的元素返回就好了。
@Override
public E pollTail() {
// 队空,返回null
if (isFull()) {
return null;
}
// 让tail指针-1并且更新索引
tail = dec(tail, array.length);
// 返回队尾元素
return array[tail];
}
测试一下也是没什么问题的
获得元素
获得队头元素
这个也是很简单,返回head指针指向的元素就行了。
@Override
public E peekHead() {
// 队空,返回null
if (isFull()) {
return null;
}
return array[head];
}
获得队尾元素
一样,返回tail指针指向的上一个位置的元素就行了。
@Override
public E peekTail() {
// 队空,返回null
if (isFull()) {
return null;
}
return array[dec(tail, array.length)];
}
测试一下也是没什么问题的。
优先级队列
概述
优先级队列的特点和队列是一样的,只允许一端入,一端出,但是入队和出队的,优先级队列会根据优先级来出队,优先级高的先出队,优先级低的后出队,优先级的规则我们是可以人为规定的,这里优先级如果理解不了,我们就可以理解成大的数先出队,小的数后出队,当然通过我们的实现我们也可以让小的数先出队,大的数后出队。优先级队列让我们每次操作都可以拿到最大的数或者是最小的数。
另外优先级队列的实现基本上都是堆实现,当然也有数组链表的实现方法,但是都没有堆实现快,所以有些地方也把优先级队列叫做堆。但是实际上这是两种不同的数据结构,下面我来介绍一下堆。
堆
概述
计算机科学中,堆是一种基于树的数据结构,通常用完全二叉树实现。听到这里你肯定会问完全二叉树又是什么东西呢?不要怕,我们这里只简单介绍一下它,后面学习到树的时候我们再深入学习它。
我们先来认识一下二叉树,二叉树就是由许多节点组成,自上而下分布,树中每个节点最多只有两个分支,一个指向左节点,一个指向右节点,又叫做左孩子和右孩子,同时这个节点叫做父节点,注意树中任何地方都不可以存在环,这里我们只要记住树中没有环就行了。二叉树最上面的节点叫做根节点,图中节点1就是根节点,2,3就是它的左右孩子节点。
介绍完了二叉树,我们来看一下什么是完全二叉树。树是存在层结构的,图中的树第一层是节点1,第二层是节点23,一个节点在第几层其实就是看它是根节点的第几代,我们将根节点看作第一代。完全二叉树就是除了最后一层其它每一层都是填满的,同时最后一层的节点也必须是从左到右的,中间不能存在节点缺失。我们要添加节点也是从左往右添加,图中我们就是添加在节点5的右孩子上,不能说添加在节点6的左孩子上,这就导致从左到右缺失了5节点的右孩子。
认识了完全二叉树后,我们也就可以来了解一下堆了。
堆分为两种,一种是大根堆,一种是小根堆。任意节点C与它的父节点P符合P.value>=C.value,我们称之为大根堆,任意节点C与它的父节点P符合P.value<=C.value,我们称之为小根堆。因此我们可以知道,最大值或者最小值一定出现在完全二叉树中的根节点,也就是堆顶。大根堆的实现符合大的元素先出队列的优先级队列,小根堆的实现就符合小的元素先出队列的优先级队列。
虽然堆是基于树的结构实现的,是非线性的,但是我们可以通过数组这种线性的结构来存储堆。这里分两种情况
1.如果我们从索引0开始存储节点数据(也就是索引0的位置存放根节点),那么对于节点i,它的父节点就是floor((i-1)/2),(floor函数就是对一个小数向下取整,1.5向下取整就是1),它的左子节点就是2*i+1,右子节点就是2*i+2,当然计算后得值都要<size。
2.如果我们从索引1开始存储节点数据(也就是索引1的位置存放根节点),节点i的父节点为floor(i/2),它的左子节点为2*i,右子节点为2*i+1,当然计算后得值都要<size。
这张图是第一种情况,大家对照左边这个数组,和右边这颗完全二叉树,计算索引一下就能有所体会了。
堆实现
有了对堆的认识,以及数组表示堆的索引换算之后我们就开始用大根堆来实现优先级队列。
结构
既然是数组实现堆结构,那我们肯定是需要一个数组,另外还需要一个size变量来维护我们队列中元素的个数。
public class PriorityQueue <E> implements Queue<E>{
E[] array;
int size;
public PriorityQueue(int capacity) {
this.array = (E[]) new Object[capacity];
}
}
队列是否为空
这个因为我们有一个size变量去维护队列的个数,所以当size == 0时我们的队列就为空。
@Override
public boolean isEmpty() {
return size == 0;
}
队列是否为满
这个也简单,当size == 数组长度时就代表队列已经满了。
@Override
public boolean isFull() {
return size == array.length;
}
添加元素
添加元素,我们先把它添加到最后,但是这样还不够,因为新元素的加入可能会破坏我们堆的结构,所以我们还需要对它进行调整,调整的过程就是与它的父亲进行比较,如果小于它父亲那就不用调整,如果加入的节点比它父节点大,那么就和它父节点交换位置,接着再和当前来到的位置的父节点继续比较,直到比父节点小或者来到根节点就停止。这个不断往上窜的过程也叫做heapify。
@Override
public boolean push(E value) {
// 队满,返回添加失败
if (isFull()) {
return false;
}
// 入堆新元素,我们先把它加入到最后面,索引就是index
int index = size++;
// 拿到父节点的索引
int parent = (index - 1) / 2; //因为小数会自动向下取整,所以可以不用调用floor方法
// 与父节点比较
// 直到当前位置小于父节点或者来到了根节点的位置
while (index > 0 && (Integer) value > (Integer) array[parent]) {
// 如果父节点小,就和父节点交换位置
array[index] = array[parent];
// 更新当前位置
index = parent;
// 更新当前节点的父节点索引
parent = (index - 1) / 2;
}
array[index] = value;
return true;
}
注意我这里在比较的时候是直接把value给强转成了整型,主要是为了方便,因为我们没有给泛型设置优先级,所以这里比较不强转就实现不了。
移除元素
那现在就是出队列的情况,我们现在是大顶堆,那出队列肯定是根节点先出去,但是我们当我们把堆顶移除后,我们还需要去保持堆的结构,只要堆不是只有一个元素了,那么移除后,就不能让堆顶空着。首先我们可以把堆顶和最后一个元素交换,任何让最后一个元素出队,这样做的目的是因为如果不交换,就需要删除数组的0索引,这样的效率是非常低的,交换之后就变成移除数组最后一个元素,这很简单,只需要让我们的size--就可以实现了,所以这一步就是提升效率的。第二步,因为最后一个元素来到了堆顶,那现在堆的结构可能是不对的,所以我们就需要把结构调整过来,调整的策略就是,从堆顶开始,不断与两个孩子节点中的较大者交换,直到父元素大于两个孩子或者没有孩子为止。
@Override
public E poll() {
// 队空,返回null
if (isFull()) {
return null;
}
// 交换堆顶和最后一个位置
swap(0, size - 1);
// 大小-1
size--;
// 不断与孩子节点比较
down(0);
return array[size];
}
// 不断与孩子节点比较
private void down(int parent) {
// 左孩子索引
int left = 2 * parent + 1;
// 右孩子索引
int right = left + 1; //2*parent+2
int max = parent; // 假设父元素最大
// 与左孩子比较
if (left < size && (Integer) array[left] > (Integer) array[max]) {
max = left;
}
// 与右孩子比较
if (right < size && (Integer) array[right] > (Integer) array[max]) {
max = right;
}
if (max != parent) { // 有孩子比父亲大
swap(max, parent);
// 不断下潜
down(max);
}
}
// 交换元素i和元素j
private void swap(int i, int j) {
E value = array[i];
array[i] = array[j];
array[j] = value;
}
获得元素
这个我们只需要返回索引0位置的元素就行了,因为我们的的操作都很好的维护了堆结构。
@Override
public E peek() {
// 队列空 返回null
return array[0];
}
至于遍历的话,其实优先级队列没有遍历这一说,因为你入队的顺序和出队的顺序都不一样了,你遍历出来的信息是没有意义的,我们只需要判断出队元素是不是最大值就行了。
@Test
public void test06(){
PriorityQueue<Integer> queue = new PriorityQueue<>(10);
queue.push(1);
queue.push(888);
queue.push(2);
queue.push(999);
queue.push(3);
queue.push(666);
while(!queue.isEmpty()){
System.out.println(queue.poll());
}
}
测试显示通过,同时出队顺序也是从大到小。我这里是数组模拟实现的大根堆,你可以尝试模拟实现一下小根堆。
至于还有一个阻塞队列,因为涉及到了操作系统中的线程安全问题,考虑到四大件的学习顺序,还有阻塞队列在平时写题目也不会用到,所以这里就不讲了。重点掌握优先级队列,平时用的挺多的。
相关题目
1046. 最后一块石头的重量 - 力扣(LeetCode)
1337. 矩阵中战斗力最弱的 K 行 - 力扣(LeetCode)
1464. 数组中两元素的最大乘积 - 力扣(LeetCode)
有梦别怕苦,想赢别喊累。