【数据结构】队列_OJ题

【数据结构】队列_OJ题

概览

  • 过程模拟:最近的请求次数、无法吃午餐的学生数量
  • 容器设计:设计循环双端队列、设计自助结算系统
  • 单调队列:滑动窗口最大值
  • 字符串处理:字符串轮转
  • 循环决策:Dota2 参议院
  • 约瑟夫环:找出游戏的获胜者

核心知识

一、基本套路

栈题的判断依据是「最近的还没处理完」,队列题反过来,判断依据是「先来的先处理」。按队列在题里扮演什么角色,这八道题可以分成五类。

第一类,过程模拟。 题目直接描述一个排队的过程,照着规则用队列走一遍就能得到答案。最近的请求次数把三秒之外的时间戳从队头清掉,剩下的个数就是答案;无法吃午餐的学生数量让学生排成一队,队头对不上三明治就整队轮转。这一类题的重点是把题目文字翻译成队列动作,代码通常不长,坑都在边界上。

第二类,容器设计。 题目要求实现一个有特殊能力的队列,接口和普通队列一样,内部要额外维护一点东西。设计循环双端队列在循环队列上把两端的操作补齐;设计自助结算系统要在队列之外再维护一个能随时拿出最大值的东西,做法是再开一个单调递减的队列,主队列进一个它跟着更新,主队列出一个如果正好是最大值它就跟着出。

第三类,单调队列。 滑动窗口最大值是这一类里最经典的一道。窗口每往右挪一格,左边吐出一个、右边进来一个,需要随时知道窗口里的最大值。做法是队列里存下标,让它们对应的值从队头到队尾递减:新元素进来时把队尾比它小的全部挤掉,队头如果滑出了窗口就弹掉。每个下标最多进出一次,整体是 O(n)。

第四类,循环决策。 Dota2 参议院和找出游戏的获胜者都是围成一圈做决定:一个议员发言禁言对手,然后轮到下一个人;一群人报数,数到 k 的人出局,剩下的人接着数。共同点是队伍会绕回来,处理办法是把两方的下标分别放进队列,轮到谁就把谁重新排到队尾,或者在原下标上加一圈的长度。

第五类,字符串处理。 字符串轮转问的是一个串能不能通过旋转变成另一个串。判据很短:两个串长度相同,并且后者出现在前者的两倍串里。这一类题用队列本身不多,但裁掉头、接上尾的动作,和出队入队是一回事。

二、边界值

队列题的代码普遍不长,出错的位置也比较固定。

第一类,长度。 空串、只有一个元素的数组、只有一个人的约瑟夫环,都是特殊点。空串一次循环都不进,返回值要么是真要么是空;单元素的输入只经历一次入队或者一次比较,取队头和判空正好落在同一个位置上。

第二类,全部相同与全部相反。 全部相同的元素会让最大值队列里只留一个下标,全部相反的元素(比如一边全是 R、一边全是 D)会让两个队列的长度差拉到最大。这两种极端能同时检验去重逻辑和循环边界。

第三类,窗口与容量的边界。 窗口长度为 1 时,每个元素各自成窗,答案是原数组;窗口长度等于数组长度时,答案只有一个元素;容量为 1 的循环双端队列只能放一个元素;容量为 0 的队列直接不可用。这些值都该单独跑一遍。

第四类,时间的边界。 最近的请求次数里,两个请求正好相差 3000 毫秒时算不算同一个窗口,取决于判断写的是小于还是小于等于。题目要求的是三千毫秒以内,所以边界值 3000 要算,3001 不算。

第五类,循环的圈数。 约瑟夫环里 k 可能比人数还大,报数要绕好几圈;Dota2 参议院里一个议员可能在同一轮里被禁言好几次。处理办法是把下标加上一圈的长度重新入队,而不是在数组里来回绕。

第六类,内存。 C 里没有现成的队列容器,手写的队列要么用数组要么用链表,用数组就要给出容量上限。这八道题的输入规模都在一万以内,静态数组够用;如果规模不设上限,就得改成动态扩容或者链表实现。

三、题目总览

题号 题目 套路 时间复杂度 空间复杂度
933 最近的请求次数 过程模拟 每次 O(1) 均摊 O(窗口内的请求数)
641 设计循环双端队列 容器设计 每个操作 O(1) O(容量)
LCR 184 设计自助结算系统 容器设计加单调队列 每个操作均摊 O(1) O(队列长度)
239 滑动窗口最大值 单调队列 O(n) O(k)
796 字符串轮转 字符串处理 O(n) O(n)
1700 无法吃午餐的学生数量 过程模拟 O(n) O(n)
649 Dota2 参议院 循环决策 O(n) O(n)
1823 找出游戏的获胜者 约瑟夫环 队列 O(nk),递推 O(n) 队列 O(n),递推 O(1)

八道题里时间复杂度最高的是约瑟夫环的队列模拟,O(nk)。它也是最值得对比的一道:同一个问题有 O(n) 的数学递推解法,两行循环就能写完,两种写法的实测数据放在一起看很直观。

排列顺序按套路递进。第一题和第二题是过程模拟,热身用;第三题和第四题是容器设计与单调队列,代码量开始上来;第五题到第八题分别落到字符串、循环报数和约瑟夫环上。时间紧的话,第四题、第六题、第八题先做,它们分别对应单调队列、过程模拟和数学优化三个最常考的落点。

实例代码

八道题放在同一个目录下,每题一个 .c 文件,解法和测试驱动写在一起。编译命令统一是 gcc -std=gnu99 -Wall -Wextra -g,八个程序都是零告警。

text 复制代码
ds09/
├── p01_recent_counter.c       933     最近的请求次数
├── p02_circular_deque.c       641     设计循环双端队列
├── p03_max_queue.c            LCR 184 设计自助结算系统
├── p04_sliding_window_max.c   239     滑动窗口最大值
├── p05_rotate_string.c        796     字符串轮转
├── p06_students_lunch.c       1700    无法吃午餐的学生数量
├── p07_dota2_senate.c         649     Dota2 参议院
└── p08_circular_game.c        1823    找出游戏的获胜者

C 里没有现成的队列,八道题里的队列都是自己用数组写的:一个数组加两个下标,下标只往前走,需要绕圈的时候取模。数组开多大按题目给的上限来,这几道题的输入规模都在一万以内。

第一题 最近的请求次数(933)

题目:写一个 RecentCounter 类,它只有一个方法 ping(int t),返回过去 3000 毫秒内发生的所有请求数,也就是 t - 3000, t 这个闭区间里的请求个数。每次调用 ping 的 t 都严格递增。

原题链接: leetcode 933 最近的请求次数

思路。 笨办法是每次 ping 都把存下来的时间戳从头到尾数一遍,只数落在区间里的。时间戳的个数最多是 ping 的次数,每次都要整体扫一遍,n 次调用就是 O(n²)。慢在那些早就过期的时间戳上:它们还在数组里,每次都要重新判断一次。

优化用队列。既然 t 严格递增,过期的时间戳一定排在队头,而且只会过期一次。每次 ping 先把新时间戳放到队尾,再看队头是不是小于 t - 3000,是就弹掉,一直弹到队头落在区间里为止,此时队列里剩下的就是答案。每个时间戳最多入队一次、出队一次,均摊下来每次 ping 是 O(1)。

用例走一遍。 输入依次调用 ping(1)、ping(100)、ping(3001)、ping(3002)。

步 调用 入队之后队列里的时间戳 这一轮弹掉的 返回
1 ping(1) 1 无 1
2 ping(100) 1 100 无 2
3 ping(3001) 1 100 3001 无,1 不小于 1 3
4 ping(3002) 100 3001 3002 1,它小于 2 3

第三步是这道题的关键:3001 - 3000 正好等于 1,题目要的是闭区间,所以下标 1 这个请求仍然算数,判断条件写小于而不是小于等于。

图 1 三秒窗口与队列里留下的时间戳

代码。

c 复制代码
# 文件:/home/cocatrice/ds09/p01_recent_counter.c
typedef struct
{
	int a[MAXN];
	int front;
	int rear;
} RecentCounter;

int recentCounterPing(RecentCounter* obj, int t)
{
	obj->a[obj->rear++] = t;
	while (obj->a[obj->front] < t - 3000)
	{
		obj->front++;
	}
	return obj->rear - obj->front;
}

数组的长度按调用次数开,够了就不用动态扩容。队头队尾都只往前加,不取模,因为这道题的队列只进不出地滚动,下标一路涨上去也不会回头。

实测回显。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds09]$ ./p01_recent_counter
ping(1) = 1
ping(100) = 2
ping(3001) = 3
ping(3002) = 3

边界:同一毫秒连续五次
ping(1000) = 1
ping(1000) = 2
ping(1000) = 3
ping(1000) = 4
ping(1000) = 5

边界:正好卡在三秒的边界上
ping(1) = 1
ping(3000) = 2
ping(3001) = 3

边界:时间跨度拉满
ping(1) = 1
ping(100000) = 1

边界值。

输入 结果 说明
同一毫秒连调五次 ping(1000) 1、2、3、4、5 都在窗口内,队头一个都不弹
ping(1) 之后 ping(3000) 1、2 相差 2999,仍在窗口内
ping(1) 之后 ping(3001) 1、2 相差 3000,闭区间仍然算
ping(1) 之后 ping(100000) 1、1 相差远超三秒,队头被弹掉,只剩它自己

复杂度。 每次 ping 均摊 O(1),一个时间戳最多进出队列各一次;空间 O(窗口内的请求数),最坏是 O(n)。

第二题 设计循环双端队列(641)

题目:实现 MyCircularDeque 类,支持在队头插入、在队尾插入、从队头删除、从队尾删除、取队头、取队尾、判空、判满八个操作,容量在构造时给定。插入和删除成功返回 true,失败返回 false;取元素在队列为空时返回 -1。

原题链接: leetcode 641 设计循环双端队列

思路。 笨办法是用数组存,队头插入时把已有元素整体后移,队尾删除时把剩下的元素整体前移,两个操作都是 O(n)。这道题的容量固定,元素一多就白搬了。

优化是在循环队列上把两端补齐。数组开容量加一个位置,多出来的那个位置用来区分空和满:front 指向队头元素,rear 指向队尾的下一个位置,两个下标相等是空,rear 再走一步撞上 front 是满。四个修改操作各自只动一个下标:队尾插入写在 arear 再把 rear 加一;队尾删除把 rear 减一;队头插入先把 front 减一,再写在 afront;队头删除把 front 加一。四个方向都要取模,往前的那个方向要先补一个 capacity 再加,否则下标会变成负数。

用例走一遍。 容量 3,依次执行插入队头 1、插入队尾 2、插入队头 3、插入队尾 4、插入队尾 5,然后删队尾、删队头、插入队头 6。

步 操作 返回值 队头 队尾 队列内容
1 插入队头 1 true 1 1 1
2 插入队尾 2 true 1 2 1 2
3 插入队头 3 true 3 2 3 1 2
4 插入队尾 4 false 3 2 3 1 2,已经满了
5 插入队尾 5 false 3 2 3 1 2,还是满的
6 删队尾 true 3 1 3 1
7 删队头 true 1 1 1
8 插入队头 6 true 6 1 6 1

容量写的是 3,实际开的是 4 个位置,所以队列里最多放 3 个元素,第四步和第五步都被挡回来了。

图 2 循环双端队列的四个方向

代码。

c 复制代码
# 文件:/home/cocatrice/ds09/p02_circular_deque.c
bool myCircularDequeInsertFront(MyCircularDeque* obj, int value)
{
	if (myCircularDequeIsFull(obj))
	{
		return false;
	}
	obj->front = (obj->front - 1 + obj->capacity) % obj->capacity;
	obj->a[obj->front] = value;
	return true;
}

bool myCircularDequeDeleteLast(MyCircularDeque* obj)
{
	if (myCircularDequeIsEmpty(obj))
	{
		return false;
	}
	obj->rear = (obj->rear - 1 + obj->capacity) % obj->capacity;
	return true;
}

int myCircularDequeGetRear(MyCircularDeque* obj)
{
	if (myCircularDequeIsEmpty(obj))
	{
		return -1;
	}
	return obj->a[(obj->rear - 1 + obj->capacity) % obj->capacity];
}

三个往前的动作里都有同一个写法:先加 capacity 再取模。front 是 0 的时候减一会变成 -1,取模之后是 -1,直接拿去当下标就跑到数组外面了;加上一圈的长度之后变成 capacity - 1,正是绕回来的那个位置。

实测回显。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds09]$ ./p02_circular_deque
插入队头 1:1
插入队尾 2:1
插入队头 3:1
插入队尾 4:0
插入队尾 5:0
三次插入之后       队头=3 队尾=2 空=0 满=1
队尾:2
满了吗:1
删队尾:1
删队头:1
两次删除之后       队头=1 队尾=1 空=0 满=0
插入队头 6:1
再插一个之后       队头=6 队尾=1 空=0 满=0

边界:容量 1 的双端队列
插入队头 1:1
再插入队尾 2:0
队头 1,队尾 1,满了吗 1
删队头:1
空了吗:1,取队头:-1

边界:容量 0 的双端队列
空了吗:1,满了吗:1,插入队尾 1:0

边界值。

输入 结果 说明
容量 1,插入一个再插一个 true、false 留一个空位的写法,容量 n 只能放 n - 1 个
容量 1,删空之后取队头 -1 空队列取元素按题目要求返回 -1
容量 0 空和满同时成立,插入直接失败 数组实际开 1 个位置,放不下任何元素
满了之后再插 false,原数据不动 插入失败不能覆盖已有元素
反复绕圈 队头队尾各自取模后仍在范围内 每绕一圈就把容量补回去一次

复杂度。 八个操作都是 O(1),只是几次加减和取模;空间 O(容量),一块固定数组。

第三题 设计自助结算系统(LCR 184)

题目:实现一个队列,除了入队和出队,还要能随时返回队列里的最大值,三个操作分别是 push_back、pop_front、max_value。出队和取最大值在队列为空时返回 -1。

原题链接: leetcode LCR 184 设计自助结算系统

这一题的前身是面试题 59 - II 队列的最大值,题号改过,链接还是老 slug,搜题号可能搜不到,按题目名搜更稳。

思路。 笨办法是每次问最大值都把整个队列扫一遍,O(n);要频繁问的话,整体就退化成了 O(n²)。

优化是在主队列之外再开一个单调队列专门记最大值。规则有两条:入队时,把最大值队列尾部所有比新元素小的都弹掉,再把新元素放进去,这样最大值队列从队头到队尾是递减的,队头永远最大;出队时,如果弹出来的正好等于最大值队列的队头,最大值队列也跟着弹一个。第一条规则保证了被弹掉的那些元素永远不可能成为最大值,因为它们后面进来了一个更大、而且走得更晚的元素。

用例走一遍。 依次压入 3、4、5,然后弹出两次。

步 操作 主队列 最大值队列 返回值
1 push_back(3) 3 3 无
2 push_back(4) 3 4 4,3 被挤掉 无
3 push_back(5) 3 4 5 5,4 被挤掉 无
4 max_value() 3 4 5 5 5
5 pop_front() 4 5 5 3
6 pop_front() 5 5 4
7 max_value() 5 5 5
8 pop_front() 空 空 5

第三步里 4 被挤出最大值队列,是因为 5 比它大而且排得更靠后:只要 4 还在队列里,5 就一定还在,4 永远当不了最大值。

图 3 主队列与最大值队列

代码。

c 复制代码
# 文件:/home/cocatrice/ds09/p03_max_queue.c
static void maxQueuePush(MaxQueue* obj, int value)
{
	dequePushBack(&obj->data, value);
	while (!dequeEmpty(&obj->maxv) && dequeBack(&obj->maxv) < value)
	{
		dequePopBack(&obj->maxv);
	}
	dequePushBack(&obj->maxv, value);
}

static int maxQueuePop(MaxQueue* obj)
{
	if (dequeEmpty(&obj->data))
	{
		return -1;
	}
	int v = dequeFront(&obj->data);
	dequePopFront(&obj->data);
	if (v == dequeFront(&obj->maxv))
	{
		dequePopFront(&obj->maxv);
	}
	return v;
}

出队时那句判断只能写相等,不能写小于等于:最大值队列里可能存着好几个相同的值,弹出的这一个对应的是队头那一个,其余相同的还留在主队列里,仍然该算最大值。

实测回显。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds09]$ ./p03_max_queue
压入 1、2 之后,最大值 = 2
弹出 1
弹出之后最大值 = 2

边界:一直压入递减的数列
压入 5、4、3 之后,最大值 = 5
弹出 5,最大值 = 5
弹出 4,最大值 = 4

边界:一直压入递增的数列
压入 3、4、5 之后,最大值 = 5
弹出 3,最大值 = 5
弹出 4,最大值 = 5

边界:空队列上取值和弹出
空队列最大值 = -1,弹出 = -1

边界:全部相同的元素
三个 7,最大值 = 7
弹出 7,弹出 7,最大值 = 7

边界值。

输入 结果 说明
连续压入递减的 5、4、3 最大值 5,弹出 5 之后仍然是 5 最大值队列里三个都在
连续压入递增的 3、4、5 最大值 5,弹出 3 和 4 之后还是 5 小的元素入队时就被挤掉了
空队列上取最大值、出队 -1、-1 按题目要求返回 -1,不要断言中止
三个相同的 7 最大值 7,弹两个之后仍是 7 出队判断用相等,不能把剩下的 7 一起弹掉

复杂度。 三个操作均摊 O(1):每个元素最多进出最大值队列各一次;空间 O(队列长度),多开了一个同样大小的单调队列。

第四题 滑动窗口最大值(239)

题目:给一个整数数组 nums 和一个大小为 k 的滑动窗口,窗口从最左边移到最右边,每次向右移动一位,返回每个窗口里的最大值。

原题链接: leetcode 239 滑动窗口最大值

思路。 笨办法是每个窗口都扫一遍取最大值,窗口有 n - k + 1 个,每个窗口扫 k 个元素,总的是 O(nk)。k 接近 n 的时候就是 O(n²)。

优化用单调队列存下标。队列里存的是下标,它们对应的值从队头到队尾递减,队头那个下标对应的就是当前窗口的最大值。窗口右移一格的处理分三步:新元素进来之前,把队尾对应值小于等于它的下标全部弹掉;把新下标放进队尾;看队头的下标是否已经滑出窗口(小于等于 i - k),是就弹掉。三步都是均摊 O(1),因为每个下标最多进队一次、出队一次。

第三步为什么要判断两次:一个下标可能因为值太小被队尾挤掉,也可能因为滑出窗口被队头弹掉,两个出口都得有。

用例走一遍。 数组是 1、3、-1、-3、5、3、6、7,窗口大小 3。

步 i 当前元素 单调队列里的下标 队列对应的值 弹掉的下标 窗口最大值
1 0 1 0 1 无 窗口没满
2 1 3 1 3 0,值比 3 小 窗口没满
3 2 -1 1 2 3 -1 无 3
4 3 -3 1 2 3 3 -1 -3 无 3
5 4 5 4 5 3、2、1,值都比 5 小 5
6 5 3 4 5 5 3 无 5
7 6 6 6 6 5、4,值都比 6 小 6
8 7 7 7 7 6,值比 7 小 7

第五步最能说明问题:5 进来的时候把队里三个下标全挤掉了,因为它们对应的值都更小,而且滑出窗口的时间更早,永远等不到翻身的机会。

图 4 滑动窗口与单调队列里的下标

代码。

c 复制代码
# 文件:/home/cocatrice/ds09/p04_sliding_window_max.c
static int monotonic(int* nums, int n, int k, int* out)
{
	int* q = (int*)malloc(sizeof(int) * (size_t)n);
	int front = 0;
	int rear = 0;
	int i;
	int cnt = 0;

	for (i = 0; i < n; i++)
	{
		while (rear > front && nums[q[rear - 1]] <= nums[i])
		{
			rear--;
		}
		q[rear++] = i;
		if (q[front] <= i - k)
		{
			front++;
		}
		if (i >= k - 1)
		{
			out[cnt++] = nums[q[front]];
		}
	}
	free(q);
	return cnt;
}

队尾那个比较写的是小于等于,不是小于。写小于的话,相等的元素会全部留在队列里,队列长度会退化成窗口长度,虽然答案还是对的,但空间和比较次数都白涨了。

实测回显。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds09]$ ./p04_sliding_window_max
单调队列 3 3 5 5 6 7
暴力解  3 3 5 5 6 7
两个解法一致:是

边界:窗口长度为 1
结果     1 3 -1 -3 5 3 6 7

边界:窗口等于整个数组
结果     7

边界:整个数组递减
结果     9 7 5

边界:整个数组递增
结果     5 7 9

边界:全部相同
结果     4 4 4

随机对拍:
300 组随机数据,不一致 0 组

边界值。

输入 结果 说明
k 等于 1 原数组 每个元素各自成窗,队头下标每次都被换掉
k 等于数组长度 只有一个值,7 队列里只留最大的那个下标
数组严格递减 9 7 5 3 1,k 取 3 9 7 5 队列里同时留着三个下标,谁都不挤谁
数组严格递增 1 3 5 7 9,k 取 3 5 7 9 每个新元素都把队列清空,队列长度恒为 1
全部相同 4 4 4 4,k 取 2 4 4 4 用小于等于的比较,队列里始终只留一个

复杂度。 时间 O(n),每个下标进出队列各一次;空间 O(k),队列长度不会超过窗口大小。

第五题 字符串轮转(796)

题目:给两个字符串 s 和 goal,判断 goal 能不能由 s 经过若干次轮转得到。轮转的意思是:把最左边的字符移到最右边,每移一次算一次轮转。

原题链接: leetcode 796 字符串轮转

思路。 笨办法是枚举每一个起点,从那个位置开始逐位和 goal 比一遍,全对上就说明是轮转。起点有 n 个,每个起点最多比 n 位,最坏是 O(n²)。

优化利用一个性质:s 的所有轮转串,都是 s + s 的子串,而且长度和 s 一样。判据就变成三步:先比长度,长度不同直接返回假;再把 s 拼成 s + s;最后在这个两倍串里找 goal。用库函数 strstr 完成查找,整体 O(n)。

长度相等这一条不能省。s 是 abc、goal 是 abcd 时,goal 也可能出现在某个别的位置上,长度先挡住最省事。

用例走一遍。 s 是 abcde,goal 是 cdeab,用逐位比较的写法。

起点 从这个位置开始数五个字符 和 goal 比较
0 abcde 第一位就对不上
1 bcdea 第一位就对不上
2 cdeab 五位全对上,返回真
3 deabc 用不到了
4 eabcd 用不到了

拼接法在同一个例子上是这样:两倍串是 abcdeabcde,goal 从下标 2 开始出现,找到即返回真。

图 5 两倍串里找目标串

代码。

c 复制代码
# 文件:/home/cocatrice/ds09/p05_rotate_string.c
static bool rotateStringConcat(const char* s, const char* goal)
{
	size_t n = strlen(s);
	char doubled[256];
	if (n != strlen(goal))
	{
		return false;
	}
	strcpy(doubled, s);
	strcat(doubled, s);
	return strstr(doubled, goal) != NULL;
}

拼接用的是一块局部数组,长度按两倍留。真要用在长字符串上,这块空间得动态申请,或者在原串上做两趟匹配,那样就不用额外的空间。

实测回显。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds09]$ ./p05_rotate_string
题目给的例子:
abcde    cdeab    拼接法=1 逐位比较=1
abcde    abced    拼接法=0 逐位比较=0

边界:长度不同
abc      abcd     拼接法=0 逐位比较=0
a                 拼接法=0 逐位比较=0

边界:空串
                  拼接法=1 逐位比较=1

边界:单个字符
a        a        拼接法=1 逐位比较=1
a        b        拼接法=0 逐位比较=0

边界:全部相同
aaaa     aaaa     拼接法=1 逐位比较=1

边界:轮转一位
abc      bca      拼接法=1 逐位比较=1
abc      cab      拼接法=1 逐位比较=1

边界:看起来像但不是轮转
abcdef   abcdfe   拼接法=0 逐位比较=0

对拍:两种写法在三千组随机串上的一致性
3000 组,不一致 0 组

边界值。

输入 结果 说明
s 是 abc,goal 是 abcd 假 长度不同,第一步就被挡下
s 和 goal 都是空串 真 空串的空轮转就是它自己
s 是 a,goal 是 a 真 单字符只有一个轮转
s 是 aaaa,goal 是 aaaa 真 全部相同的串,任何起点都成立
s 是 abcdef,goal 是 abcdfe 假 字符全都对得上,顺序不对

复杂度。 拼接法时间 O(n),空间 O(n) 用来放两倍串;逐位比较最坏 O(n²),空间 O(1)。另有一种 O(n) 的写法是把这个匹配交给 KMP,不用额外的两倍串,代价是代码长很多。

第六题 无法吃午餐的学生数量(1700)

题目:学生排成一队,每人喜欢圆形或者方形三明治,用 0 和 1 表示。三明治摞成一摞,每次只看最上面那一个。队头的学生如果喜欢最上面这个三明治就拿走并离开,不喜欢就排到队尾。当队伍里所有学生都不喜欢最上面那个三明治时,剩下的学生就吃不上午餐了,返回这个人数。

原题链接: leetcode 1700 无法吃午餐的学生数量

思路。 直接按题目描述模拟:学生放进队列,三明治用一个下标当栈顶。每一轮看队头学生和栈顶三明治,喜欢就队头出队、栈顶下移,不喜欢就把队头挪到队尾。什么时候停?当连续拒绝的次数等于当前队伍人数时,说明转了一整圈没人能拿走它,剩下的就是答案。

还有一个 O(n) 的计数写法:学生只分两类,先数出喜欢 0 和喜欢 1 各有多少人,然后按栈顶的顺序消耗。每遇到一个三明治,对应喜欢它的人数减一,减到 0 就说明这一类学生已经用光了,后面的三明治没人要,此时剩下的人数就是答案。

用例走一遍。 学生是 1、1、0、0,三明治从上到下是 0、1、0、1。

步 队头学生 栈顶三明治 动作 队列变成 连续拒绝
1 1 0 不喜欢,排到队尾 1 0 0 1 1
2 1 0 不喜欢,排到队尾 0 0 1 1 2
3 0 0 拿走,栈顶下移 0 1 1 0
4 0 1 不喜欢,排到队尾 1 1 0 1
5 1 1 拿走,栈顶下移 1 0 0
6 1 0 不喜欢,排到队尾 0 1 1
7 0 0 拿走,栈顶下移 1 0
8 1 1 拿走,队伍空了 空 0

八步之后队伍空了,答案是 0。

图 6 学生队列与三明治栈

代码。

c 复制代码
# 文件:/home/cocatrice/ds09/p06_students_lunch.c
static int countStudentsCount(int* students, int n, int* sandwiches, int m)
{
	int want[2] = {0, 0};
	int i;

	for (i = 0; i < n; i++)
	{
		want[students[i]]++;
	}
	for (i = 0; i < m; i++)
	{
		if (want[sandwiches[i]] == 0)
		{
			break;
		}
		want[sandwiches[i]]--;
	}
	return want[0] + want[1];
}

计数写法里那个 break 是关键:一旦某类学生的人数是 0,后面再遇到他们喜欢的三明治也没人拿,循环可以直接停,剩下的人数就是答案。

实测回显。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds09]$ ./p06_students_lunch
题目给的例子:
喜欢 [1 1 0 0]       模拟法=0 计数法=0
喜欢 [1 1 1 0 0 1]   模拟法=3 计数法=3

边界:一个学生,正好吃得上
学生 0 三明治 0   模拟法=0 计数法=0

边界:一个学生,吃不上
学生 0 三明治 1   模拟法=1 计数法=1

边界:全部喜欢圆形
全 0 全 0            模拟法=0 计数法=0

边界:全部吃不上
全 1 全 0            模拟法=3 计数法=3

边界:三明治不够分
三人两个三明治  模拟法=1 计数法=1

对拍:
300 组,不一致 0 组

边界值。

输入 结果 说明
一个学生,喜欢的和三明治一致 0 直接拿走
一个学生,喜欢 0 但只有三明治 1 1 转一圈没人要,剩他自己
学生全喜欢 0,三明治全是 0 0 全部吃得上
学生全喜欢 1,三明治全是 0 3 一个都拿不走,模拟在第一轮就停下
三个人两个三明治 1 三明治不够分,剩下的人数按实际算

复杂度。 模拟法最坏 O(n²):每轮最多转一圈,一圈最多 n 次比较;计数法 O(n),只扫一遍学生和一遍三明治,空间都是 O(1)(模拟法要一个队列,O(n))。

第七题 Dota2 参议院(649)

题目:参议院里每个议员属于两个阵营之一,R 是 Radiant,D 是 Dire。议员按顺序发言,轮到的议员可以让一个对方阵营的议员失去本轮及以后所有的发言权。被禁言的议员不再发言。所有人都发过言算一轮结束,然后开始下一轮。某一方全部被禁言时,另一方获胜。给一个表示议员阵营的字符串,返回获胜方。

原题链接: leetcode 649 Dota2 参议院

思路。 笨办法是按轮模拟:每一轮从头到尾扫一遍,让每个还活着的议员去找下一个还活着的对手,把他标记成禁言,扫完一轮再来一轮,直到某一方没人。每一轮是 O(n),轮数最多 O(n),整体 O(n²)。

优化用一个很省事的观察:每个议员只需要在「自己发言」和「被禁言」之间做选择,而发言顺序是固定的。把两个阵营的下标分别放进队列,每次从两个队头各取一个出来比大小:下标小的那个先发言,于是把对方禁言,自己下标加上总人数之后重新排到队尾。加一圈长度是为了让他在下一轮里排到后面;被禁言的那个直接丢掉,不再入队。一方队列空了,另一方获胜。每个议员最多入队两次,整体 O(n)。

用例走一遍。 输入 RDD。

轮 R 队头 D 队头 谁先发言 队列变化
1 0 1 R,下标 0 更小 D 的 1 被禁言丢弃,R 的 0 加 3 变成 3 排到队尾
2 3 2 D,下标 2 更小 R 的 3 被禁言丢弃,D 的 2 加 3 变成 5 排到队尾
3 空 5 无人可发 R 方没人了,Dire 获胜

第二轮是关键:R 那个议员第一轮发过言,按规则他下一轮还会排在后面的位置上,所以他的下标要加上一圈的长度,才能在下一轮里排在 D 的后面。

图 7 两个阵营的下标队列

代码。

c 复制代码
# 文件:/home/cocatrice/ds09/p07_dota2_senate.c
static char* predictPartyVictory(const char* senate)
{
	int n = (int)strlen(senate);
	static int rq[10005];
	static int dq[10005];
	int rf = 0, rr = 0;
	int df = 0, dr = 0;
	int i;

	for (i = 0; i < n; i++)
	{
		if (senate[i] == 'R')
		{
			rq[rr++] = i;
		}
		else
		{
			dq[dr++] = i;
		}
	}

	while (rf < rr && df < dr)
	{
		int r = rq[rf++];
		int d = dq[df++];
		if (r < d)
		{
			rq[rr++] = r + n;
		}
		else
		{
			dq[dr++] = d + n;
		}
	}
	return rf < rr ? "Radiant" : "Dire";
}

两个队列的下标都只增不减,数组开两倍长度就够:每个议员最多被重新入队一次。这道题还有一种写法是把两个队列合并成一个,用计数器记下待执行的禁言数,代码更短,代价是可读性差一些。

实测回显。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds09]$ ./p07_dota2_senate
题目给的例子:
RD           -> Radiant
RDD          -> Dire
RRDDD        -> Radiant

边界:只有一方
RRR          -> Radiant
DDD          -> Dire

边界:单个字符
R            -> Radiant
D            -> Dire

边界:交替出现
RDRD         -> Radiant
DRDR         -> Dire
RDRDRD       -> Radiant

边界:一方多一个
RDDR         -> Radiant
DDRR         -> Dire

边界:人数悬殊
RRRRRRRRRD   -> Radiant

长输入:一万个议员,前一半是 R
10000 个议员(5000 R 在前,5000 D 在后)-> Radiant

边界值。

输入 结果 说明
只有 R Radiant 对面队列一开始就是空的,循环不进
单个 R 或单个 D 各自获胜 只有一个议员,没人能禁言他
交替出现 RDRD Radiant 下标小的先动手,先手方赢
一方多一个 RDDR Radiant 多出来的那个在下一轮才发挥作用
一万个议员,前五千是 R Radiant 先手方五千个连续的禁言名额,对面没机会还手

复杂度。 时间 O(n),每个议员最多入队两次、出队两次;空间 O(n),两个下标队列。

第八题 找出游戏的获胜者(1823)

题目:n 个小伙伴围成一圈,编号从 1 到 n。从 1 号开始报数,每报到第 k 个人就淘汰他,然后从下一个人继续报数,直到只剩一个人。返回获胜者的编号。

原题链接: leetcode 1823 找出游戏的获胜者

思路。 直接按规则模拟:所有人入队,每轮把队头 k - 1 个人依次挪到队尾,第 k 个人出队淘汰。这样转 n - 1 轮,队列里剩下的人就是答案。每一轮要挪 k - 1 次,整体 O(nk)。k 很大的时候这个代价很明显。

优化是约瑟夫环的数学递推。设 f(i) 表示 i 个人时最后活下来的人的下标(从 0 开始数),有 f(1) = 0,f(i) = (f(i-1) + k) % i。意思是:i 个人淘汰掉第 k 个人之后,剩下 i - 1 个人,新的一轮从被淘汰者的下一个人开始,这相当于把整个圈子往前平移了 k 个位置,所以把子问题的答案加 k 再对 i 取模就换算回来了。最后 f(n) 是从 0 开始的下标,加一换成从 1 开始的编号。整个过程一层循环,O(n),不需要任何队列。

用例走一遍。 n 取 5,k 取 2,两种写法一起看。

队列模拟:

轮 这一轮的动作 淘汰 队列剩下
1 1 报 1 挪到队尾,2 报 2 2 3 4 5 1
2 3 报 1 挪到队尾,4 报 2 4 5 1 3
3 5 报 1 挪到队尾,1 报 2 1 3 5
4 3 报 1 挪到队尾,5 报 2 5 3

数学递推:

i f(i) = (f(i-1) + k) % i 换成从 1 开始的编号
1 0 1
2 (0 + 2) % 2 = 0 1
3 (0 + 2) % 3 = 2 3
4 (2 + 2) % 4 = 0 1
5 (0 + 2) % 5 = 2 3

两种写法都给 3。

图 8 约瑟夫环的出局顺序

代码。

c 复制代码
# 文件:/home/cocatrice/ds09/p08_circular_game.c
static int findWinnerMath(int n, int k)
{
	int ans = 0;
	int i;
	for (i = 2; i <= n; i++)
	{
		ans = (ans + k) % i;
	}
	return ans + 1;
}

递推式里的取模是对 i 取的,不是对 n。第 i 轮圈子还剩 i 个人,下标范围就是 0 到 i - 1,用 n 去取模会在中途算错。

实测回显。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds09]$ ./p08_circular_game
题目给的例子:
n=5 k=2  队列=3  递推=3
n=6 k=5  队列=1  递推=1

边界:只有一个人
n=1 k=1  队列=1  递推=1
n=1 k=99 队列=1  递推=1

边界:k 等于 1,每次都是队头淘汰
n=6 k=1  队列=6  递推=6

边界:k 很大
n=5 k=100 队列=2  递推=2

边界:人数刚好整除
n=8 k=4  队列=6  递推=6

对拍:
60 乘 20 共 1200 组,不一致 0 组

边界值。

输入 结果 说明
n 等于 1 1 只有一个人,不管 k 是多少都直接返回
k 等于 1 n 每次都淘汰队头,最后一轮剩下的正是 n 号
k 远大于 n,比如 100 2 报数绕好几圈,队列写法靠取模自然处理
n 等于 8,k 等于 4 6 人数刚好被 k 整除,淘汰均匀分布
n 从 1 到 60、k 从 1 到 20 1200 组全部一致 两种解法互相验证

复杂度。 队列模拟时间 O(nk),空间 O(n);数学递推时间 O(n),空间 O(1)。人数上万、k 也上万的时候,两者的差距会拉到几个数量级,能写出递推就用递推。

踩坑点

  • 最近的请求次数里区间是闭区间,判断写成 afront < t - 3000,写成小于等于会把正好三千毫秒前的那次请求漏掉。
  • 循环双端队列里往前走的两个操作要先加容量再取模,少加一步会得到负数下标,直接越界。
  • 留一个空位的循环结构容量是 n - 1,容量给 1 时一个元素都放不下,容量给 0 时任何插入都失败。
  • 设计自助结算系统时,出队判断最大值队列要不要跟着弹,条件只能写相等;写成小于等于会把后面相同的最大值一起弹掉。
  • 单调队列里存的是下标,不是值。存值的话,元素滑出窗口时无法判断被挤掉的到底是哪一个。
  • 滑动窗口最大值里,队尾比较要用小于等于,用小于会让相等的元素全部留在队列里,队列长度退化到窗口大小。
  • 字符串轮转要先比较长度,长度不同的两个串直接返回假,否则短串是长串轮转串的子串这种输入会被误判。
  • 无法吃午餐的模拟要有明确的停止条件:连续拒绝的次数等于当前队伍人数。没有这个条件会一直转圈。
  • Dota2 参议院里被禁言的议员要直接丢弃,重新排到队尾的议员下标要加上一圈的长度,否则下一轮的先后顺序会错。
  • 约瑟夫环的递推取模是对当前人数 i 取的,不是对总人数 n 取,写成 n 会在中途算错。
  • 约瑟夫环的递推是从 0 开始编号的,返回之前要加一换成从 1 开始的编号。
  • C 里手写队列时,下标只增不减的写法要把数组开够;用循环数组的写法,取模之前要断言容量大于 0。

本篇总结(模拟面试问题)

问:怎么判断一道题该用队列?

核心要点:看有没有先来后到的关系。题目里出现排队、轮转、按顺序处理、谁先来谁先服务这类描述,基本都是队列。另外两种信号是滑动窗口求极值(用单调队列)和一圈一圈报数(用队列模拟循环)。反过来,如果需要把最近的先处理,那是栈。

问:单调队列为什么能一次求出滑动窗口的最大值?

核心要点:队列里存下标,对应的值从队头到队尾递减,所以队头永远是当前窗口的最大值。新元素进队时把队尾比它小的全部弹掉,因为那些元素既更小、又更早滑出窗口,永远等不到成为最大值的机会;队头滑出窗口时弹掉。每个下标最多进出各一次,整体 O(n)。

问:设计一个能 O(1) 取最大值的队列,思路是什么?

核心要点:主队列之外再开一个单调递减的队列专门记最大值。入队时把最大值队列尾部所有更小的值弹掉再压入新值;出队时如果弹出的值正好等于最大值队列的队头,它也弹一个。两个队列的队头始终同步,取最大值就是读最大值队列的队头。

问:循环双端队列里往前走的操作,为什么要先加容量再取模?

核心要点:下标减一之后可能是 -1,负数取模在 C 里结果还是负数,拿去访问数组就越界了。先加上一圈的长度把结果变成正数,再取模,落点正好是数组末尾那一格。往后的操作不会出现负数,直接取模即可。

问:字符串轮转为什么可以变成在一个两倍串里找子串?

核心要点:s 的每一种轮转,都是把某个后缀接到某个前缀后面,而这类串恰好是 s + s 里所有长度等于 s 的子串。所以把 s 拼一遍,在结果里找 goal 就能判断。长度要先比,长度不同直接返回假。

问:无法吃午餐这道题怎么从 O(n²) 优化到 O(n)?

核心要点:模拟慢在每个学生可能被反复轮转多次。注意学生只分两类,喜欢的类型和数量才是关键:先统计两类学生各多少人,再按三明治从上到下的顺序消耗,遇到某一类人数为 0 就停下,剩下的人数就是答案。整个过程只扫两遍。

问:约瑟夫环的递推公式是怎么来的?

核心要点:记 f(i) 为 i 个人时获胜者的下标(从 0 开始)。淘汰掉第 k 个人之后,剩下 i - 1 个人,新的起点是被淘汰者的下一位,相当于把整个圈子整体往前平移了 k 位,所以 f(i) = (f(i - 1) + k) % i,边界是 f(1) = 0。最后加一换成从 1 开始的编号。

问:队列和单调队列的区别是什么?

核心要点:普通队列只保证先进先出,元素怎么进就怎么出。单调队列在队列的基础上维护队内元素的单调性,入队时会主动弹掉一些元素,所以它不保存全部历史,只保存「还有机会成为答案」的那些。代价是它不能用来还原完整的出队顺序。

参考

相关推荐
H.莓飛1 小时前
【数据结构】栈_OJ题
linux·数据结构·算法·centos
Ruiery1 小时前
Linux 6.6内核 CPU 深度解析(一):BSP 拉起 — 从实模式到 kernel_init 的完整旅程
linux·运维·服务器
赵民勇1 小时前
dconf命令详解
linux·运维
平行侠2 小时前
065 B树(B-Tree)
数据结构·b树·树查找
qeen873 小时前
【Linux】Linux环境下的进程终止与进程等待问题
linux·运维·服务器
LRL_4 小时前
【实战指南】Node.js 跨平台依赖下载:如何在 Windows/Linux 环境下互跨下载目标系统的 npm/pnpm 包
linux·windows·node.js
高山有多高4 小时前
【Linux笔记】网络命令
linux
一条破秋裤5 小时前
Linux 线程同步:读写锁
java·linux·jvm
lvwangshu5 小时前
势能分析(摊还分析)
数据结构·数学·算法