小肥柴慢慢学习数据结构笔记(C篇)(4-2 队列应用)
目录
4-3 问题描述
[LeeCode]
函数原型
/**
* Note: The returned array must be malloced, assume caller calls free().
*/
int* maxSlidingWindow(int* nums, int numsSize, int k, int* returnSize) {
}
4-4 问题分析
【注】第一部分题解参考labuladong,假设按照现在的进度咱们还不知晓“堆”这类好用的数据结构,所以只能笨笨的用双向链表或者类似的双向队列去尝试解决问题。
(1)这是一个类似“滑动窗口”问题,在默认遍历一遍就能够完成问题解答的前提下(即线性时间复杂度),需要一种合适的方式让容量有限的窗口可以不断添加新数据(push)和拿掉老数据(pop),如下图:
(2)运行思路,给出一个框架,工作总结为三项:
for(遍历目标数据num[i]){
1. 窗口window->push(num[i]),添加新数据
2. 窗口window->max(),找出当前窗口最大值,并记录到输出结果res中
3. 窗口window->pop(num[i-k+1]),拿掉老数据
}
(3)细化window的设计
<1> 设想,如果window本身就是一个有序(单调)序列,那么 max( ) = 序列第一个元素,即
int max() {
return window->first;
}
<2> 若需要实现以上设想,那么需要在push阶段就可以让小于push的新数据的元素从尾部退出序列,如下图(黄色方块是尝试添加的新数据,蓝色是目前window中保存的数据)
对应编码大致如下:
void push(新元素){
while(window还有元素(即window不为空,因为元素退出可能会导致window中暂存的数据为空)&& window尾元素 < 新元素){
window尾元素退出,即window需要实现尾删功能。
}
此时“新元素”找到合适位置,甚至新元素就是首位(最大),从尾部进入。
}
<3> 最后从window首拿走数据,以便window移动到下一个位置(更新数据)
void pop(){
window首位元素退出
}
但还需要注意到一个问题,即:如果“甚至新元素就是首位(最大)”呢?此时原本需要退出的元素在之前的push操作中会因为尾删操作就已经删除了,所以需要对pop做限制
void pop(尝试删除的目标元素){
if(只有当目标元素就是首位元素时才执行删除)
window首位元素退出
}
4-5 准备工作:实现双链表
前文提到现在需要一个两端进出的数据结构去实现单调队列的pop/push等工作,有人称之为“双端队列”,实际上双链表即可满足此需求(都是两端进出嘛);参考JDK中LinkedList的实现,编码:
(1)DuList.h
#ifndef DU_LIST_H
#define DU_LIST_H
typedef int ElementType;
typedef struct DuListNode{
ElementType data;
struct Node *prev;
struct Node *next;
} Node;
struct DuList {
Node* first;
Node* last;
int size;
};
typedef struct DuList* LinkedList;
LinkedList createList();
int isEmpty(LinkedList list);
ElementType front(LinkedList list);
ElementType back(LinkedList list);
void pushFront(LinkedList list, ElementType data);
void pushBack(LinkedList list, ElementType data);
void popFront(LinkedList list);
void popBack(LinkedList list);
void printList(LinkedList list);
// void distroyList(LinkedList list); //请自行实现
#endif
(2)DuList.c
#include <stdio.h>
#include <stdlib.h>
#include "DuList.h"
LinkedList createList(){
LinkedList list = malloc(sizeof(struct DuList));
if(list == NULL)
exit(-1);
list->first = NULL;
list->last = NULL;
list->size = 0;
return list;
}
int isEmpty(LinkedList list){
if(list == NULL)
exit(-1);
return list->size == 0;
}
ElementType front(LinkedList list){
return isEmpty(list) ? INT_MIN : list->first->data;
}
ElementType back(LinkedList list){
return isEmpty(list) ? INT_MIN : list->last->data;;
}
void pushFront(LinkedList list, ElementType data){
if(list == NULL)
exit(-1);
Node *newNode = (Node *)malloc(sizeof(Node));
if(newNode == NULL)
exit(-1);
Node* oldFirst = list->first;
newNode->data = data;
newNode->prev = NULL;
newNode->next = oldFirst;
list->first = newNode;
if(oldFirst == NULL)
list->last = newNode;
else
oldFirst->prev = newNode;
list->size++;
}
void pushBack(LinkedList list, ElementType data){
if(list == NULL)
exit(-1);
Node *newNode = (Node *)malloc(sizeof(Node));
if(newNode == NULL)
exit(-1);
Node* oldLast = list->last;
newNode->data = data;
newNode->prev = oldLast;
newNode->next = NULL;
list->last = newNode;
if(oldLast == NULL)
list->first = newNode;
else
oldLast->next = newNode;
list->size++;
}
void popFront(LinkedList list){
if(list == NULL)
exit(-1);
if(!isEmpty(list)){
Node *oldFirst = list->first;
Node *newFirst = list->first->next;
oldFirst->next = NULL;
free(oldFirst);
list->first = newFirst;
if(newFirst == NULL)
list->last = NULL;
else
newFirst->prev = NULL;
list->size--;
}
}
void popBack(LinkedList list){
if(list == NULL)
exit(-1);
if(!isEmpty(list)){
Node *oldLast = list->last;
Node *newLast = list->last->prev;
oldLast->prev = NULL;
free(oldLast);
list->last = newLast;
if(newLast == NULL)
list->first = NULL;
else
newLast->next = NULL;
list->size--;
}
}
void printList(LinkedList list){
if(list == NULL)
exit(-1);
if(isEmpty(list)){
printf("printList, list is Empty\n");
} else {
printf("\nlist =[ ");
Node *tmp = list->first;
while(tmp != NULL){
printf("%d ", tmp->data);
tmp = tmp->next;
}
printf("]\n");
}
}
【注意】为了方便理解,我用了一些傻傻的命名;但头插/尾插(pushFront/pushBack)或是头删/尾删(popFront/popBack)的思路还是值得学习的:
(1)设计上更靠近模块化和OOP思维:一个头节点(first),一个尾节点(last),外加一个大小计数(size);每个节点包含前驱(prev)、后驱(next)和实际存储数据(data)。
typedef struct DuListNode{
ElementType data;
struct Node *prev;
struct Node *next;
} Node;
typedef struct DuList* {
Node* first;
Node* last;
int size;
}LinkedList;
(2)初始化list时,就是一个真正的“空数据”链表;我认为对本文研究的LeeCode问题,如此设计是十分合理的:既没有刻意设置“哨兵”,也没有在初始化时将first和last指称环状,更加贴近常规的思维。
LinkedList createList(){
LinkedList list = malloc(sizeof(struct DuList));
if(list == NULL)
exit(-1);
list->first = NULL;
list->last = NULL;
list->size = 0;
return list;
}
(3)编码上JDK的实现思路很清晰,没有刻意强调list为“空数据”链表的特例,当然也做了合适的处理,例如下面 if(oldFirst == NULL)处的注释:操作时总是围绕newNode展开,这是一个不易出错的编写方法。
void pushFront(LinkedList list, ElementType data){
if(list == NULL)
exit(-1);
Node *newNode = (Node *)malloc(sizeof(Node));
if(newNode == NULL)
exit(-1);
Node* oldFirst = list->first;
newNode->data = data;
newNode->prev = NULL;
newNode->next = oldFirst;
list->first = newNode;
if(oldFirst == NULL) //list为“空数据”链表,last只能是当前头插后的新节点,即first
list->last = newNode;
else
oldFirst->prev = newNode;
list->size++;
}
(4)对应的测试代码如下
#include <stdio.h>
#include <stdlib.h>
#include "DuList.h"
int main(int argc, char *argv[]) {
LinkedList list = createList();
pushFront(list, 0);
int i;
for(i=1; i<=5; i++)
pushBack(list, i);
printList(list);
for(i=-1; i>=-5; i--)
pushFront(list, i);
printList(list);
printf("\nGet first element:%d\n", front(list));
printf("\nGet last element:%d\n", back(list));
printf("\nDelete first element:");
popFront(list);
printList(list);
printf("\nDelete first element:");
popFront(list);
printList(list);
printf("\nDelete last element:");
popBack(list);
printList(list);
printf("\nDelete last element:");
popBack(list);
printList(list);
return 0;
}
【测试结果】
4-6 单调队列实现
【注】移动窗口的次数(即窗口数量) = 数据长度 - 窗口长度 + 1,即
l
e
n
=
s
i
z
e
−
k
+
1
len = size - k + 1
len=size−k+1
#include <stdio.h>
#include <stdlib.h>
#include "DuList.h"
/* run this program using the console pauser or add your own getch, system("pause") or input loop */
int max(LinkedList list){
return front(list);
}
void push(LinkedList list, int n){
while(!isEmpty(list) && back(list) < n)
popBack(list);
pushBack(list, n);
}
void pop(LinkedList list, int n){
if(n == front(list))
popFront(list);
}
int main(int argc, char *argv[]) {
int nums[] = {1, 3, -1, -3, 5, 3, 6, 7};
int k = 3;
LinkedList window = createList();
int len = sizeof(nums)/sizeof(nums[0]);
int size = len - k + 1;
int* res = (int*)malloc(size * sizeof(int));
int i, j;
for(i = 0, j=0; i < len; i++){
if(i < k-1){
push(window, nums[i]);
} else {
push(window, nums[i]);
res[j++] = max(window);
pop(window, nums[i-k+1]);
}
}
printf("result is: ");
for(i = 0; i < size; i++){
printf("%d ", res[i]);
}
printf("\n");
return 0;
}
【输出结果】
【但】这种实现方式太繁琐了,上LeeCode也费劲(要不是为了配合Queue这个数据结构的讲解,我才不愿意用C呢),要不考虑放弃使用LinkedList,直接使用“数组+下标”?
4-7 LeeCode中更轻快解法赏析
以下内容参考LeeCode题解,简介如下:
(1)使用优先队列(Heap,堆) ==> 这块留到后面讲堆这个数据结构的时候在回头看,后续给出跳转链接。
(2)手动挡数组实现双端队列(单调队列) ==> 这个重点讨论,毕竟咱们的实现太臭了
(3)分块 + 预处理 ==>很有意思的解决方案,值得大家学习
【题解优点总结】
(1)使用简单的数组+双下标的模式实现双端队列的功能。由前一篇文章可知:
right 表示队列尾部索引
left 表示队列头部索引
(2)数组不再存储具体的数据,改为存储数据对应下标。
queue[]
并使用类似下面二次指向的数据检索操作指向具体的
nums[queue[x]]
(3)两个索引计算时对应的队列操作如下
right++ 尾插
right-- 尾删
left++ 头插
left-- 头删
【详细代码】需要注意的地方都标记了注释(由LeeCode转载,侵删)
int* maxSlidingWindow(int* nums, int numsSize, int k, int* returnSize) {
int q[numsSize]; //下标数组
int left = 0, right = 0; //头、尾两个索引
for (int i = 0; i < k; ++i) { //这个for循环用来填充第一个窗口,长度为k
while (left < right && nums[i] >= nums[q[right - 1]]) { //这不是就是之前实现的push吗?
right--; //尾删 popBack
}
q[right++] = i; //尾插 pushBack
}
*returnSize = 0;
int* ans = malloc(sizeof(int) * (numsSize - k + 1)); //实际执行窗口数量 numsSize - k + 1
ans[(*returnSize)++] = nums[q[left]]; //由于是单调队列,头(left)标记元素最大!注意此处两层[[]]
for (int i = k; i < numsSize; ++i) {
while (left < right && nums[i] >= nums[q[right - 1]]) { //push
right--;
}
q[right++] = i;
while (q[left] <= i - k) { //popFront,“吐出”上一个窗口最左边元素,准备添加靠右新元素
left++;
}
ans[(*returnSize)++] = nums[q[left]]; //保存结果
}
return ans;
}
4-8 后记
(1)重启这个博客写作,翻阅了不少资料想寻找一个点来完成Queue的应用案例;考虑到上一篇已经提及Linux中的kfifo和Workqueue,于是决定找一个适合大一/大二同学上手的问题去实践队列的设计思想,正好翻到了这个问题;为了这一个LeeCode问题,又只能老老实实手写一遍之前偷懒写的双链表(小肥柴慢慢手写数据结构(C篇)(2-6 双链表 DoubleLinkedList),封装和实现都很敷衍),说实话是有些“刻舟求剑”的意思。
(2)但看来看去,觉得2021年我抛出的一些思路还是正确的,并在此丰富以下表达出来:数据结构的学习,核心是设计思想的学习,训练包括设计思想的编码落地和解决实际问题算法变现,绝对不能硬上。譬如:最合适的C版解法其实是一个简陋的数组!高手在身边呐。
(3)昨天和一位同事讨论了《数据结构》这门课程的教学问题,他觉得数据结构和算法就应该用C去教学,这样做确实能让初学者的编码能力和编程思维等硬指标得到快速提升;但我也抛出了个人的观点:其实使用更高级的语言可能更方便初学者去理解和实现细节,抛去繁冗的信息引导他们,能够给他们增加更多的信心和学习动力。
(4)最后希望大家有空一定要去完成两个训练
232. 用栈实现队列和225. 用队列实现栈,能帮助你更好的复习理解这两个数据结构。
[1] LeeCode题解
[2] 《labuladong的算法小抄》以及博客