算法之队列
队列
介绍
队列与优先队列的区别
- 队列是一种FIFO(First-In-First-Out)先进先出的数据结构,对应于生活中的排队的场景,排在前面的人总是先通过,依次进行。
- 优先队列是特殊的队列,从“优先”一词,可看出有“插队现象”。比如在火车站排队进站时,就会有些比较急的人来插队,他们就在前面先通过验票。优先队列至少含有两种操作的数据结构:insert(插入),即将元素插入到优先队列中(入队);以及deleteMin(删除最小者),它的作用是找出、删除优先队列中的最小的元素(出队)。
优先队列(堆)的特性
- 优先队列的实现常选用二叉堆,在数据结构中,优先队列一般也是指堆。
- 堆的两个性质:
- 结构性:堆是一颗除底层外被完全填满的二叉树,底层的节点从左到右填入,这样的树叫做完全二叉树。
- 堆序性:由于我们想很快找出最小元,则最小元应该在根上,任意节点都小于它的后裔,这就是小顶堆(Min-Heap);如果是查找最大元,则最大元应该在根上,任意节点都要大于它的后裔,这就是大顶堆(Max-heap)。
结构性
通过观察发现,完全二叉树可以直接使用一个数组表示而不需要使用其他数据结构。所以我们只需要传入一个size就可以构建优先队列的结构(元素之间使用compareTo方法进行比较)。
public class PriorityQueue<T extends Comparable<? super T>> {
public PriorityQueue(int capacity) {
currentSize = 0;
array = (T[]) new Comparable[capacity + 1];
}
}
对于数组中的任意位置 i 的元素,其左儿子在位置 2i 上,则右儿子在 2i+1 上,父节点在 在 i/2(向下取整)上。通常从数组下标1开始存储,这样的好处在于很方便找到左右、及父节点。如果从0开始,左儿子在2i+1,右儿子在2i+2,父节点在(i-1)/2(向下取整)。
【注意】这块的数字特指下标从1开始,实际很多算法中数组是从下标0开始的,这种情况下位置i的元素左儿子是2i+1,右儿子是2i+2
堆序性
我们这建立最小堆,即对于每一个元素X,X的父亲中的关键字小于(或等于)X中的关键字,根节点除外(它没有父节点)。
如图所示,只有左边是堆,右边红色节点违反堆序性。根据堆序性,只需要常O(1)找到最小元。
基本堆操作
- insert(插入)
- 上滤:为了插入元素X,我们在下一个可用的位置建立空穴(否则会破坏结构性,不是完全二叉树)。如果此元素放入空穴不破坏堆序性,则插入完成;否则,将父节点下移到空穴,即空穴向根的方向上冒一步。继续该过程,直到X插入空穴为止。这样的过程称为上滤。
图中演示了18插入的过程,在下一个可用的位置建立空穴(满足结构性),发现不能直接插入,将父节点移下来,空穴上冒。继续这个过程,直到满足堆序性。这样就实现了元素插入到优先队列(堆)中。
java实现上滤
public void insert(T x) {
if (null == x) {
return;
}
//判断当前堆(数组)是否已满,满则扩容,防止数组越界
//下标从1开始,0不存放元素
if (currentSize == array.length - 1) {
enlargeArray(array.length * 2 + 1);
}
//将要插入的元素放在最后一个叶子节点,即数组最后一个元素的位置
int hole = ++currentSize;
//父节点 :hole / 2
//左子节点:2*hole
//右子节点:2*hole+1
//x和父节点比较,比父节点大则上滤,原父节点下滤,此处是小顶堆
//x循环到位置1的左或者右子节点会结束
for (array[0] = x; x.compareTo(array[hole / 2]) < 0; hole /= 2) {
array[hole] = array[hole / 2];
}
//最后再将要插入的值赋值过去符合的节点处
array[hole] = x;
}
/**
* 扩容方法
*
* @param newSize :扩容后的容量,为原来的2倍+1
*/
private void enlargeArray(int newSize) {
T[] old = array;
array = (T[]) new Comparable[newSize];
System.arraycopy(old, 0, array, 0, old.length);
}
可以反复使用交换操作来进行上滤过程,但如果插入X上滤d层,则需要3d次赋值;我们这种方式只需要d+1次赋值。
如果插入的元素是新的最小元从而一直上滤到根处,那么这种插入的时间长达O(logN)。但平均来看,上滤终止得要早。业已证明,执行依次插入平均需要2.607次比较,因此平均insert操作上移元素1.607层。上滤次数只比插入次数少一次。
- deleteMin(删除最小元)
- 下滤:类似于上滤操作。因为我们建立的是最小堆,所以删除最小元,就是将根节点删掉,这样就破坏了结构性。所以我们在根节点处建立空穴,为了满足结构性,堆中最后一个元素X必须移动到合适的位置,如果可以直接放到空穴,则删除完成(一般不可能);否则,将空穴的左右儿子中较小者移到空穴,即空穴下移了一层。继续这样的操作,直到X可以放入到空穴中。这样就可以满足结构性与堆序性。这个过程称为下滤。
如图所示:在根处建立空穴,将最后一个元素放到空穴,已满足结构性;为满足堆序性,需要将空穴下移到合适的位置。
注意:堆的实现中,经常发生的错误是只有偶数个元素,即有一个节点只有一个儿子。所以需要测试右儿子的存在性。
public T deleteMin() {
if (isEmpty()) {
throw new UnderflowException();
}
T minItem = findMin();
array[1] = array[currentSize--];
percolateDown(1);
return minItem;
}
/**
* 下滤方法
*
* @param hole :从数组下标hole1开始下滤
*/
private void percolateDown(int hole) {
int child;
T tmp = array[hole];
for (; hole * 2 <= currentSize; hole = child) {
//左儿子
child = hole * 2;
//判断右儿子是否存在
if (child != currentSize &&array[child + 1].compareTo(array[child]) < 0) {
child++;
}
if (array[child].compareTo(tmp) < 0) {
array[hole] = array[child];
} else {
break;
}
}
array[hole] = tmp;
}
这种操作最坏时间复杂度是O(logN)。平均而言,被放到根处的元素几乎下滤到底层(即来自的那层),所以平均时间复杂度是O(logN)。
总结
优先队列常使用二叉堆实现,本篇图解了二叉堆最基本的两个操作:插入及删除最小元。insert以O(1)常数时间执行,deleteMin以O(logN)执行。
面试题
数组中的第K个最大元素
在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
示例 1:
输入: [3,2,1,5,6,4] 和 k = 2 输出: 5
示例 2:
输入: [3,2,3,1,2,4,5,5,6] 和 k = 4 输出: 4
说明:
你可以假设 k 总是有效的,且 1 ≤ k ≤ 数组的长度。
解题思路
排序直接取出
第一种思路代码:
public int findKthLargest(int[] nums, int k) {
Arrays.sort(nums);
return nums[nums.length - k];
}
第一种思路代码2:采用希尔排序。
public static int findKthLargest(int[] nums, int k) {
int gap = 1, i, j, len = nums.length;
int temp;
while (gap < len / 3) {
gap = gap * 3 + 1;
}
for (; gap > 0; gap /= 3) {
for (i = gap; i < len; i++) {
temp = nums[i];
for (j = i - gap; j >= 0 && nums[j] > temp; j -= gap) {
nums[j + gap] = nums[j];
}
nums[j + gap] = temp;
}
}
return nums[nums.length - k];
}
复杂度分析:
时间复杂度:最坏:O(nlog^2n)、最优:O(n)。
空间复杂度:O(1)。
堆的特性
说到堆,它是优先队列(priority queue),它又叫”堆”(heap), 但是可能优先队列这个名字更容易理解一些。Java中PriorityQueue通过二叉小顶堆实现,每次移除元素重新构建二叉小顶堆。
第二种思路代码:
public static int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> priorityQueue = new PriorityQueue<>();
for (int num : nums) {
priorityQueue.add(num);
if (priorityQueue.size() > k) {
priorityQueue.poll();
}
}
return priorityQueue.peek();
}
返回滑动窗口的最大值
给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口 k 内的数字。滑动窗口每次只向右移动一位。
返回滑动窗口最大值。
示例:
输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3 输出: [3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7注意:
你可以假设 k 总是有效的,1 ≤ k ≤ 输入数组的大小,且输入数组不为空。
解题思路
优先队列
时间复杂度为O(n*logK)
返回窗口中的的最大值,最大最小值我们可以优先考虑到“优先队列”,优先队列用于流式数据的最大最小值。算法题中,有出现slide window的都是高频考点。使用优先队列维持一个大顶堆。
- 维持一个max heap(删除滑动窗口最左边的元素,加入新的元素)
- 让最大值位于大顶堆
- 步骤的维持max head的时间复杂度是O(logK)
- 步骤的时间复杂度是O(N)
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0 || k <= 0 || k == 1) {
return nums;
}
PriorityQueue<Integer> queue = new PriorityQueue<>(k, (o1, o2) -> o2 - o1);
int[] max = new int[nums.length - k + 1];
for (int i = 0; i < nums.length; i++) {
//如果是第K个数之前和第K个数,就说明优先队列没有满,继续添加
if (i < k - 1) {
queue.add(nums[i]);
} else if (i == k - 1) {
queue.add(nums[i]);
max[0] = queue.peek();
} else {
//优先队列已满,删除滑动窗口最左边的数[i - k],添加新的数
queue.remove(nums[i - k]);
queue.add(nums[i]);
max[i - k + 1] = queue.peek();
}
}
return max;
}
双端队列
时间复杂度为O(N))
使用双端队列。java中的双端队列deque(支持在两端插入和移除元素)。deque是一个接口,实现它的类有ArrayDeque,LinkedBlockingDeque,LinkedList.
这里参考了一位大神的解法(https://segmentfault.com/a/1190000003903509)
我们用双向队列,在遇到新的数的时候,将新的数和双向队列的末尾进行比较,如果末尾的数比新数小,则把末尾的数扔掉,直到该队列的末尾数比新数大或者队列为空的时候才停止。这样,我们可以保证队列里的元素是从头到位的降序。由于队列中只有窗口里的数,就是窗口里的第一大数,第二大数,第三数…。
如何保持队列呢。每当滑动窗口的k已满,想要新进来一个数,就需要把滑动窗口最左边的数移出队列,添加新的数。
我们在添加新的数的时候,就已经移出了一些数,这样队列头部的数不一定是窗口最左边的数。技巧:我们队列中只存储那个数在原数组的下标。这样可以判断该数是否为最滑动窗口的最左边的数。
为什么这个解法的时间复杂度是O(N)呢。因为每个元素在双端队列里有且仅存在过一次。即最多被操作两次,一次是加入该队列的时候,一次是因为后面有更大的数而被移除队列的时候。
我们来具体看一下,以“输入数组{2,3,4,2,6,2,5,1}及滑动窗口的大小3”为例来看一下:
从头开始遍历数组,我们以下标 i 表示遍历到第几个数字
在开始阶段,队列为空,我们把2入队列,此时i = 0;
i = 1时,num[1] = 3, 由于3大于队列尾2,所以把2移出队列,把3加入队列;
i = 2时,num[2] = 4, 由于4大于3, 所以把3移出队列,把4加入队列,此时滑动窗口刚好经过三个元素,滑动窗口内的最大值就是队列的头元素,也就是4;
i = 3时,num[3] = 2, 因为2小于4,所以把2直接加入队列,此时滑动窗口内的最大值仍然为4;
i = 4时,num[4] = 6, 6大于2和4,所以把2和4移出队列,把6加入到队列中,此时滑动窗口的最大值为6;
i = 5时,num[5] = 2, 因为2小于6,所以把2直接加入队列,此时滑动窗口内的最大值仍然为6;
i = 6时,num[6] = 5, 由于5大于2,所以把2移出队列,把5加入队列中,此时滑动窗口内的最大值仍然为6;
i = 7时,num[7] = 1, 由于6已经不在滑动窗口中了,所以将6从队列头上删除,此时滑动窗口的最大值为5;
遍历结束,那么,如何判断6是否还在滑动窗口中呢,可以通过数组下标进行判断,我们在队列中存储数组的下标而非数值,这样通过判断下标之间的差值是否大于窗口的大小,就可以判断元素是否还在滑动窗口中。
public int[] maxSlidingWindow(int[] nums, int k) {
//判断传进来的是否为int数组,int数组是否为空,int数组是否没有数据
if (nums == null || nums.length == 0) {
return new int[0];
}
ArrayDeque<Integer> adq = new ArrayDeque<Integer>(k);
//获得该nums数组滑动窗口的个数
int[] max = new int[nums.length + 1 - k];
for (int i = 0; i < nums.length; i++) {
//每当新数进来,如果发现队列的头部的数的下标是窗口最左边的下标,则移出队列
if (!adq.isEmpty() && adq.peekFirst() == i - k) {
adq.removeFirst();
}
//把队列尾部的数和新数一一比较,比新数小的都移出队列,直到该队列的末尾数比新数大或者队列为空的时候才停止,保证队列是降序的
while (!adq.isEmpty() && nums[adq.peekLast()] < nums[i]) {
adq.removeLast();
}
//从尾部加入新的数
adq.offerLast(i);
//i < k - 1时,滑动窗口才有最大值(队列头部)
if (i >= k - 1) {
max[i + 1 - k] = nums[adq.peek()];
}
}
return max;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!