经典算法思想总结(含LeetCode题目推荐)
贪心算法
算法思想
贪心的本质是选择每一阶段的局部最优,从而达到全局最优。
一般解题步骤
- 将问题分解为若干个子问题
- 找出适合的贪心策略
- 求解每一个子问题的最优解
- 将局部最优解堆叠成全局最优解
LeetCode
455.分发饼干:https://leetcode.cn/problems/assign-cookies/
121.买卖股票的最佳时机:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/
122.买卖股票的最佳时机 II:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii/
55.跳跃游戏:https://leetcode.cn/problems/jump-game/
45.跳跃游戏 II:https://leetcode.cn/problems/jump-game-ii/
动态规划
算法思想
动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。
经典题目:01 背包、完全背包
一般解题步骤
- 确定 dp 数组(dp table)以及下标的含义
- 确定递推公式
- dp 数组如何初始化
- 确定遍历顺序
- 举例推导 dp 数组
LeetCode
509.斐波那契数:https://leetcode.cn/problems/fibonacci-number/
746.使用最小花费爬楼梯:https://leetcode.cn/problems/min-cost-climbing-stairs/
416.分割等和子集:https://leetcode.cn/problems/partition-equal-subset-sum/
518.零钱兑换:https://leetcode.cn/problems/coin-change-ii/
647.回文子串:https://leetcode.cn/problems/palindromic-substrings/
516.最长回文子序列:https://leetcode.cn/problems/longest-palindromic-subsequence/
回溯算法
算法思想
回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条
件时,就“回溯”返回,尝试别的路径。其本质就是穷举。
经典题目:8 皇后
一般解题步骤
- 针对所给问题,定义问题的解空间,它至少包含问题的一个(最优)解。
- 确定易于搜索的解空间结构,使得能用回溯法方便地搜索整个解空间 。
- 以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。
leetcode
77.组合:https://leetcode.cn/problems/combinations/
39.组合总和:https://leetcode.cn/problems/combination-sum/
40.组合总和 II:https://leetcode.cn/problems/combination-sum-ii/
78.子集:https://leetcode.cn/problems/subsets/
90.子集 II:https://leetcode.cn/problems/subsets-ii/
51.N 皇后:https://leetcode.cn/problems/n-queens/
分治算法
算法思想
将一个规模为 N 的问题分解为 K 个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解。
经典题目:二分查找、汉诺塔问题
一般解题步骤
- 将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
- 若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
- 将各个子问题的解合并为原问题的解。
LeetCode
108.将有序数组转换成二叉搜索数:https://leetcode.cn/problems/convert-sorted-array-to-binary-search-tree/
148.排序列表:https://leetcode.cn/problems/sort-list/
23.合并 k 个升序链表:https://leetcode.cn/problems/merge-k-sorted-lists/
未命名
堆
什么是堆
堆是一种满足以下条件的树:
堆中的每一个节点值都大于等于(或小于等于)子树中所有节点的值。或者说,任意一个节点的值都大于等于(或小于等于)所有子节点的值。
大家可以把堆(最大堆)理解为一个公司,这个公司很公平,谁能力强谁就当老大,不存在弱的人当老大,老大手底下的人一定不会比他强。这样有助于理解后续堆的操作。
!!!特别提示:
- 很多博客说堆是完全二叉树,其实并非如此,堆不一定是完全二叉树,只是为了方便存储和索引,我们通常用完全二叉树的形式来表示堆,事实上,广为人知的斐波那契堆和二项堆就不是完全二叉树,它们甚至都不是二叉树。
- (二叉)堆是一个数组,它可以被看成是一个 近似的完全二叉树。——《算法导论》第三版
大家可以尝试判断下面给出的图是否是堆?
第 1 个和第 2 个是堆。第 1 个是最大堆,每个节点都比子树中所有节点大。第 2 个是最小堆,每个节点都比子树中所有节点小。
第 3 个不是,第三个中,根结点 1 比 2 和 15 小,而 15 却比 3 大,19 比 5 大,不满足堆的性质。
堆的用途
当我们只关心所有数据中的最大值或者最小值,存在多次获取最大值或者最小值,多次插入或删除数据时,就可以使用堆。
有小伙伴可能会想到用有序数组,初始化一个有序数组时间复杂度是 O(nlog(n))
,查找最大值或者最小值时间复杂度都是 O(1)
,但是,涉及到更新(插入或删除)数据时,时间复杂度为 O(n)
,即使是使用复杂度为 O(log(n))
的二分法找到要插入或者删除的数据,在移动数据时也需要 O(n)
的时间复杂度。
相对于有序数组而言,堆的主要优势在于插入和删除数据效率较高。 因为堆是基于完全二叉树实现的,所以在插入和删除数据时,只需要在二叉树中上下移动节点,时间复杂度为 O(log(n))
,相比有序数组的 O(n)
,效率更高。
不过,需要注意的是:Heap 初始化的时间复杂度为 O(n)
,而非O(nlogn)
。
堆的分类
堆分为 最大堆 和 最小堆。二者的区别在于节点的排序方式。
- 最大堆:堆中的每一个节点的值都大于等于子树中所有节点的值
- 最小堆:堆中的每一个节点的值都小于等于子树中所有节点的值
如下图所示,图 1 是最大堆,图 2 是最小堆
堆的存储
之前介绍树的时候说过,由于完全二叉树的优秀性质,利用数组存储二叉树即节省空间,又方便索引(若根结点的序号为 1,那么对于树中任意节点 i,其左子节点序号为 2*i
,右子节点序号为 2*i+1
)。
为了方便存储和索引,(二叉)堆可以用完全二叉树的形式进行存储。存储的方式如下图所示:
堆的操作
堆的更新操作主要包括两种 : 插入元素 和 删除堆顶元素。操作过程需要着重掌握和理解。
在进入正题之前,再重申一遍,堆是一个公平的公司,有能力的人自然会走到与他能力所匹配的位置
插入元素
插入元素,作为一个新入职的员工,初来乍到,这个员工需要从基层做起
1.将要插入的元素放到最后
有能力的人会逐渐升职加薪,是金子总会发光的!!!
2.从底向上,如果父结点比该元素小,则该节点和父结点交换,直到无法交换
删除堆顶元素
根据堆的性质可知,最大堆的堆顶元素为所有元素中最大的,最小堆的堆顶元素是所有元素中最小的。当我们需要多次查找最大元素或者最小元素的时候,可以利用堆来实现。
删除堆顶元素后,为了保持堆的性质,需要对堆的结构进行调整,我们将这个过程称之为”堆化“,堆化的方法分为两种:
- 一种是自底向上的堆化,上述的插入元素所使用的就是自底向上的堆化,元素从最底部向上移动。
- 另一种是自顶向下堆化,元素由最顶部向下移动。在讲解删除堆顶元素的方法时,我将阐述这两种操作的过程,大家可以体会一下二者的不同。
自底向上堆化
在堆这个公司中,会出现老大离职的现象,老大离职之后,他的位置就空出来了
首先删除堆顶元素,使得数组中下标为 1 的位置空出。
那么他的位置由谁来接替呢,当然是他的直接下属了,谁能力强就让谁上呗
比较根结点的左子节点和右子节点,也就是下标为 2,3 的数组元素,将较大的元素填充到根结点(下标为 1)的位置。
这个时候又空出一个位置了,老规矩,谁有能力谁上
一直循环比较空出位置的左右子节点,并将较大者移至空位,直到堆的最底部
这个时候已经完成了自底向上的堆化,没有元素可以填补空缺了,但是,我们可以看到数组中出现了“气泡”,这会导致存储空间的浪费。接下来我们试试自顶向下堆化。
自顶向下堆化
自顶向下的堆化用一个词形容就是“石沉大海”,那么第一件事情,就是把石头抬起来,从海面扔下去。这个石头就是堆的最后一个元素,我们将最后一个元素移动到堆顶。
然后开始将这个石头沉入海底,不停与左右子节点的值进行比较,和较大的子节点交换位置,直到无法交换位置。
堆的操作总结
- 插入元素:先将元素放至数组末尾,再自底向上堆化,将末尾元素上浮
- 删除堆顶元素:删除堆顶元素,将末尾元素放至堆顶,再自顶向下堆化,将堆顶元素下沉。也可以自底向上堆化,只是会产生“气泡”,浪费存储空间。最好采用自顶向下堆化的方式。
堆排序
堆排序的过程分为两步:
- 第一步是建堆,将一个无序的数组建立为一个堆
- 第二步是排序,将堆顶元素取出,然后对剩下的元素进行堆化,反复迭代,直到所有元素被取出为止。
建堆
如果你已经足够了解堆化的过程,那么建堆的过程掌握起来就比较容易了。建堆的过程就是一个对所有非叶节点的自顶向下堆化过程。
首先要了解哪些是非叶节点,最后一个节点的父结点及它之前的元素,都是非叶节点。也就是说,如果节点个数为 n,那么我们需要对 n/2 到 1 的节点进行自顶向下(沉底)堆化。
具体过程如下图:
将初始的无序数组抽象为一棵树,图中的节点个数为 6,所以 4,5,6 节点为叶节点,1,2,3 节点为非叶节点,所以要对 1-3 号节点进行自顶向下(沉底)堆化,注意,顺序是从后往前堆化,从 3 号节点开始,一直到 1 号节点。
3 号节点堆化结果:
2 号节点堆化结果:
1 号节点堆化结果:
至此,数组所对应的树已经成为了一个最大堆,建堆完成!
排序
由于堆顶元素是所有元素中最大的,所以我们重复取出堆顶元素,将这个最大的堆顶元素放至数组末尾,并对剩下的元素进行堆化即可。
现在思考两个问题:
- 删除堆顶元素后需要执行自顶向下(沉底)堆化还是自底向上(上浮)堆化?
- 取出的堆顶元素存在哪,新建一个数组存?
先回答第一个问题,我们需要执行自顶向下(沉底)堆化,这个堆化一开始要将末尾元素移动至堆顶,这个时候末尾的位置就空出来了,由于堆中元素已经减小,这个位置不会再被使用,所以我们可以将取出的元素放在末尾。
机智的小伙伴已经发现了,这其实是做了一次交换操作,将堆顶和末尾元素调换位置,从而将取出堆顶元素和堆化的第一步(将末尾元素放至根结点位置)进行合并。
详细过程如下图所示:
取出第一个元素并堆化:
取出第二个元素并堆化:
取出第三个元素并堆化:
取出第四个元素并堆化:
取出第五个元素并堆化:
取出第六个元素并堆化:
堆排序完成!
常见数据结构经典LeetCode题目推荐
数组
704.二分查找:https://leetcode.cn/problems/binary-search/
80.删除有序数组中的重复项 II:https://leetcode.cn/problems/remove-duplicates-from-sorted-array-ii
977.有序数组的平方:https://leetcode.cn/problems/squares-of-a-sorted-array/
链表
707.设计链表:https://leetcode.cn/problems/design-linked-list/
206.反转链表:https://leetcode.cn/problems/reverse-linked-list/
92.反转链表 II:https://leetcode.cn/problems/reverse-linked-list-ii/
61.旋转链表:https://leetcode.cn/problems/rotate-list/
栈与队列
232.用栈实现队列:https://leetcode.cn/problems/implement-queue-using-stacks/
225.用队列实现栈:https://leetcode.cn/problems/implement-stack-using-queues/
347.前 K 个高频元素:https://leetcode.cn/problems/top-k-frequent-elements/
239.滑动窗口最大值:https://leetcode.cn/problems/sliding-window-maximum/
二叉树
105.从前序与中序遍历构造二叉树:https://leetcode.cn/problems/construct-binary-tree-from-preorder-and-inorder-traversal/
117.填充每个节点的下一个右侧节点指针 II:https://leetcode.cn/problems/populating-next-right-pointers-in-each-node-ii
236.二叉树的最近公共祖先:https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-tree/
129.求根节点到叶节点数字之和:https://leetcode.cn/problems/sum-root-to-leaf-numbers/
102.二叉树的层序遍历:https://leetcode.cn/problems/binary-tree-level-order-traversal/
530.二叉搜索树的最小绝对差:https://leetcode.cn/problems/minimum-absolute-difference-in-bst/
图
200.岛屿数量:https://leetcode.cn/problems/number-of-islands/
207.课程表:https://leetcode.cn/problems/course-schedule/
210.课程表 II:https://leetcode.cn/problems/course-schedule-ii/
堆
215.数组中的第 K 个最大元素:https://leetcode.cn/problems/kth-largest-element-in-an-array/
216.数据流的中位数:https://leetcode.cn/problems/find-median-from-data-stream/
217.前 K 个高频元素:https://leetcode.cn/problems/top-k-frequent-elements/
SQL常见面试题总结(2)
题目来源于:牛客题霸 - SQL 进阶挑战
增删改操作
SQL 插入记录的方式汇总:
- 普通插入(全字段) :
INSERT INTO table_name VALUES (value1, value2, ...)
- 普通插入(限定字段) :
INSERT INTO table_name (column1, column2, ...) VALUES (value1, value2, ...)
- 多条一次性插入 :
INSERT INTO table_name (column1, column2, ...) VALUES (value1_1, value1_2, ...), (value2_1, value2_2, ...), ...
- 从另一个表导入 :
INSERT INTO table_name SELECT * FROM table_name2 [WHERE key=value]
- 带更新的插入 :
REPLACE INTO table_name VALUES (value1, value2, ...)
(注意这种原理是检测到主键或唯一性索引键重复就删除原记录后重新插入)
插入记录(一)
描述:牛客后台会记录每个用户的试卷作答记录到 exam_record
表,现在有两个用户的作答记录详情如下:
- 用户 1001 在 2021 年 9 月 1 日晚上 10 点 11 分 12 秒开始作答试卷 9001,并在 50 分钟后提交,得了 90 分;
- 用户 1002 在 2021 年 9 月 4 日上午 7 点 1 分 2 秒开始作答试卷 9002,并在 10 分钟后退出了平台。
试卷作答记录表exam_record
中,表已建好,其结构如下,请用一条语句将这两条记录插入表中。
Filed | Type | Null | Key | Extra | Default | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | auto_increment | (NULL) | 自增 ID |
uid | int(11) | NO | (NULL) | 用户 ID | ||
exam_id | int(11) | NO | (NULL) | 试卷 ID | ||
start_time | datetime | NO | (NULL) | 开始时间 | ||
submit_time | datetime | YES | (NULL) | 提交时间 | ||
score | tinyint(4) | YES | (NULL) | 得分 |
答案:
1 | // 存在自增主键,无需手动赋值 |
插入记录(二)
描述:现有一张试卷作答记录表exam_record
,结构如下表,其中包含多年来的用户作答试卷记录,由于数据越来越多,维护难度越来越大,需要对数据表内容做精简,历史数据做备份。
表exam_record
:
Filed | Type | Null | Key | Extra | Default | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | auto_increment | (NULL) | 自增 ID |
uid | int(11) | NO | (NULL) | 用户 ID | ||
exam_id | int(11) | NO | (NULL) | 试卷 ID | ||
start_time | datetime | NO | (NULL) | 开始时间 | ||
submit_time | datetime | YES | (NULL) | 提交时间 | ||
score | tinyint(4) | YES | (NULL) | 得分 |
我们已经创建了一张新表exam_record_before_2021
用来备份 2021 年之前的试题作答记录,结构和exam_record
表一致,请将 2021 年之前的已完成了的试题作答纪录导入到该表。
答案:
1 | INSERT INTO exam_record_before_2021 (uid, exam_id, start_time, submit_time, score) |
插入记录(三)
描述:现在有一套 ID 为 9003 的高难度 SQL 试卷,时长为一个半小时,请你将 2021-01-01 00:00:00 作为发布时间插入到试题信息表examination_info
,不管该 ID 试卷是否存在,都要插入成功,请尝试插入它。
试题信息表examination_info
:
Filed | Type | Null | Key | Extra | Default | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | auto_increment | (NULL) | 自增 ID |
exam_id | int(11) | NO | UNI | (NULL) | 试卷 ID | |
tag | varchar(32) | YES | (NULL) | 类别标签 | ||
difficulty | varchar(8) | YES | (NULL) | 难度 | ||
duration | int(11) | NO | (NULL) | 时长(分钟数) | ||
release_time | datetime | YES | (NULL) | 发布时间 |
答案:
1 | REPLACE INTO examination_info VALUES |
更新记录(一)
描述:现在有一张试卷信息表 examination_info
, 表结构如下图所示:
Filed | Type | Null | Key | Extra | Default | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | auto_increment | (NULL) | 自增 ID |
exam_id | int(11) | NO | UNI | (NULL) | 试卷 ID | |
tag | char(32) | YES | (NULL) | 类别标签 | ||
difficulty | char(8) | YES | (NULL) | 难度 | ||
duration | int(11) | NO | (NULL) | 时长 | ||
release_time | datetime | YES | (NULL) | 发布时间 |
请把examination_info表中tag
为PYTHON
的tag
字段全部修改为Python
。
思路:这题有两种解题思路,最容易想到的是直接update + where
来指定条件更新,第二种就是根据要修改的字段进行查找替换
答案一:
1 | UPDATE examination_info SET tag = 'Python' WHERE tag='PYTHON' |
答案二:
1 | UPDATE examination_info |
更新记录(二)
描述:现有一张试卷作答记录表 exam_record,其中包含多年来的用户作答试卷记录,结构如下表:作答记录表 exam_record
: submit_time
为 完成时间 (注意这句话)
Filed | Type | Null | Key | Extra | Default | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | auto_increment | (NULL) | 自增 ID |
uid | int(11) | NO | (NULL) | 用户 ID | ||
exam_id | int(11) | NO | (NULL) | 试卷 ID | ||
start_time | datetime | NO | (NULL) | 开始时间 | ||
submit_time | datetime | YES | (NULL) | 提交时间 | ||
score | tinyint(4) | YES | (NULL) | 得分 |
题目要求:请把 exam_record
表中 2021 年 9 月 1 日==之前==开始作答的==未完成==记录全部改为被动完成,即:将完成时间改为’2099-01-01 00:00:00’,分数改为 0。
思路:注意题干中的关键字(已经高亮) " xxx 时间 "
之前这个条件, 那么这里马上就要想到要进行时间的比较 可以直接 xxx_time < "2021-09-01 00:00:00",
也可以采用date()
函数来进行比较;第二个条件就是 "未完成"
, 即完成时间为 NULL,也就是题目中的提交时间 —– submit_time 为 NULL
。
答案:
1 | UPDATE exam_record SET submit_time = '2099-01-01 00:00:00', score = 0 WHERE DATE(start_time) < "2021-09-01" AND submit_time IS null |
删除记录(一)
描述:现有一张试卷作答记录表 exam_record
,其中包含多年来的用户作答试卷记录,结构如下表:
作答记录表exam_record:
start_time
是试卷开始时间submit_time
是交卷,即结束时间。
Filed | Type | Null | Key | Extra | Default | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | auto_increment | (NULL) | 自增 ID |
uid | int(11) | NO | (NULL) | 用户 ID | ||
exam_id | int(11) | NO | (NULL) | 试卷 ID | ||
start_time | datetime | NO | (NULL) | 开始时间 | ||
submit_time | datetime | YES | (NULL) | 提交时间 | ||
score | tinyint(4) | YES | (NULL) | 得分 |
要求:请删除exam_record
表中作答时间小于 5 分钟整且分数不及格(及格线为 60 分)的记录;
思路:这一题虽然是练习删除,仔细看确是考察对时间函数的用法,这里提及的分钟数比较,常用的函数有 TIMEDIFF
和TIMESTAMPDIFF
,两者用法稍有区别,后者更为灵活,这都是看个人习惯。
1. TIMEDIFF
:两个时间之间的差值
1 | TIMEDIFF(time1, time2) |
两者参数都是必须的,都是一个时间或者日期时间表达式。如果指定的参数不合法或者是 NULL,那么函数将返回 NULL。
对于这题而言,可以用在 minute 函数里面,因为 TIMEDIFF 计算出来的是时间的差值,在外面套一个 MINUTE 函数,计算出来的就是分钟数。
TIMESTAMPDIFF
:用于计算两个日期的时间差
1 | TIMESTAMPDIFF(unit,datetime_expr1,datetime_expr2) |
这题需要进行分钟的比较,那么就是 TIMESTAMPDIFF(MINUTE, 开始时间, 结束时间) < 5
答案:
1 | DELETE FROM exam_record WHERE MINUTE (TIMEDIFF(submit_time , start_time)) < 5 AND score < 60 |
1 | DELETE FROM exam_record WHERE TIMESTAMPDIFF(MINUTE, start_time, submit_time) < 5 AND score < 60 |
删除记录(二)
描述:现有一张试卷作答记录表exam_record
,其中包含多年来的用户作答试卷记录,结构如下表:
作答记录表exam_record
:start_time
是试卷开始时间,submit_time
是交卷时间,即结束时间,如果未完成的话,则为空。
Filed | Type | Null | Key | Extra | Default | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | auto_increment | (NULL) | 自增 ID |
uid | int(11) | NO | (NULL) | 用户 ID | ||
exam_id | int(11) | NO | (NULL) | 试卷 ID | ||
start_time | datetime | NO | (NULL) | 开始时间 | ||
submit_time | datetime | YES | (NULL) | 提交时间 | ||
score | tinyint(4) | YES | (NULL) | 分数 |
要求:请删除exam_record
表中未完成作答==或==作答时间小于 5 分钟整的记录中,开始作答时间最早的 3 条记录。
思路:这题比较简单,但是要注意题干中给出的信息,结束时间,如果未完成的话,则为空,这个其实就是一个条件
还有一个条件就是小于 5 分钟,跟上题类似,但是这里是或,即两个条件满足一个就行;另外就是稍微考察到了排序和 limit 的用法。
答案:
1 | DELETE FROM exam_record WHERE submit_time IS null OR TIMESTAMPDIFF(MINUTE, start_time, submit_time) < 5 |
删除记录(三)
描述:现有一张试卷作答记录表 exam_record,其中包含多年来的用户作答试卷记录,结构如下表:
Filed | Type | Null | Key | Extra | Default | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | auto_increment | (NULL) | 自增 ID |
uid | int(11) | NO | (NULL) | 用户 ID | ||
exam_id | int(11) | NO | (NULL) | 试卷 ID | ||
start_time | datetime | NO | (NULL) | 开始时间 | ||
submit_time | datetime | YES | (NULL) | 提交时间 | ||
score | tinyint(4) | YES | (NULL) | 分数 |
要求:请删除exam_record
表中所有记录,==并重置自增主键==
思路:这题考察对三种删除语句的区别,注意高亮部分,要求重置主键;
DROP
: 清空表,删除表结构,不可逆TRUNCATE
: 格式化表,不删除表结构,不可逆DELETE
:删除数据,可逆
这里选用TRUNCATE
的原因是:TRUNCATE 只能作用于表;TRUNCATE
会清空表中的所有行,但表结构及其约束、索引等保持不变;TRUNCATE
会重置表的自增值;使用TRUNCATE
后会使表和索引所占用的空间会恢复到初始大小。
这题也可以采用DELETE
来做,但是在删除后,还需要手动ALTER
表结构来设置主键初始值;
同理也可以采用DROP
来做,直接删除整张表,包括表结构,然后再新建表即可。
答案:
1 | TRUNCATE exam_record; |
表与索引操作
创建一张新表
描述:现有一张用户信息表,其中包含多年来在平台注册过的用户信息,随着牛客平台的不断壮大,用户量飞速增长,为了高效地为高活跃用户提供服务,现需要将部分用户拆分出一张新表。
原来的用户信息表:
Filed | Type | Null | Key | Default | Extra | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | (NULL) | auto_increment | 自增 ID |
uid | int(11) | NO | UNI | (NULL) | 用户 ID | |
nick_name | varchar(64) | YES | (NULL) | 昵称 | ||
achievement | int(11) | YES | 0 | 成就值 | ||
level | int(11) | YES | (NULL) | 用户等级 | ||
job | varchar(32) | YES | (NULL) | 职业方向 | ||
register_time | datetime | YES | CURRENT_TIMESTAMP | 注册时间 |
作为数据分析师,请创建一张优质用户信息表 user_info_vip,表结构和用户信息表一致。
你应该返回的输出如下表格所示,请写出建表语句将表格中所有限制和说明记录到表里。
Filed | Type | Null | Key | Default | Extra | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | (NULL) | auto_increment | 自增 ID |
uid | int(11) | NO | UNI | (NULL) | 用户 ID | |
nick_name | varchar(64) | YES | (NULL) | 昵称 | ||
achievement | int(11) | YES | 0 | 成就值 | ||
level | int(11) | YES | (NULL) | 用户等级 | ||
job | varchar(32) | YES | (NULL) | 职业方向 | ||
register_time | datetime | YES | CURRENT_TIMESTAMP | 注册时间 |
思路:如果这题给出了旧表的名称,可直接create table 新表 as select * from 旧表;
但是这题并没有给出旧表名称,所以需要自己创建,注意默认值和键的创建即可,比较简单。(注意:如果是在牛客网上面执行,请注意 comment 中要和题目中的 comment 保持一致,包括大小写,否则不通过,还有字符也要设置)
答案:
1 | CREATE TABLE IF NOT EXISTS user_info_vip( |
修改表
描述: 现有一张用户信息表user_info
,其中包含多年来在平台注册过的用户信息。
用户信息表 user_info
:
Filed | Type | Null | Key | Default | Extra | Comment |
---|---|---|---|---|---|---|
id | int(11) | NO | PRI | (NULL) | auto_increment | 自增 ID |
uid | int(11) | NO | UNI | (NULL) | 用户 ID | |
nick_name | varchar(64) | YES | (NULL) | 昵称 | ||
achievement | int(11) | YES | 0 | 成就值 | ||
level | int(11) | YES | (NULL) | 用户等级 | ||
job | varchar(32) | YES | (NULL) | 职业方向 | ||
register_time | datetime | YES | CURRENT_TIMESTAMP | 注册时间 |
要求:请在用户信息表,字段 level
的后面增加一列最多可保存 15 个汉字的字段 school
;并将表中 job
列名改为 profession
,同时 varchar
字段长度变为 10;achievement
的默认值设置为 0。
思路:首先做这题之前,需要了解 ALTER 语句的基本用法:
- 添加一列:
ALTER TABLE 表名 ADD COLUMN 列名 类型 【first | after 字段名】;
(first : 在某列之前添加,after 反之) - 修改列的类型或约束:
ALTER TABLE 表名 MODIFY COLUMN 列名 新类型 【新约束】;
- 修改列名:
ALTER TABLE 表名 change COLUMN 旧列名 新列名 类型;
- 删除列:
ALTER TABLE 表名 drop COLUMN 列名;
- 修改表名:
ALTER TABLE 表名 rename 【to】 新表名;
- 将某一列放到第一列:
ALTER TABLE 表名 MODIFY COLUMN 列名 类型 first;
COLUMN
关键字其实可以省略不写,这里基于规范还是罗列出来了。
在修改时,如果有多个修改项,可以写到一起,但要注意格式
答案:
1 | ALTER TABLE user_info |
删除表
描述:现有一张试卷作答记录表 exam_record
,其中包含多年来的用户作答试卷记录。一般每年都会为 exam_record
表建立一张备份表 exam_record_{YEAR},{YEAR}
为对应年份。
现在随着数据越来越多,存储告急,请你把很久前的(2011 到 2014 年)备份表都删掉(如果存在的话)。
思路:这题很简单,直接删就行,如果嫌麻烦,可以将要删除的表用逗号隔开,写到一行;这里肯定会有小伙伴问:如果要删除很多张表呢?放心,如果要删除很多张表,可以写脚本来进行删除。
答案:
1 | DROP TABLE IF EXISTS exam_record_2011; |
创建索引
描述:现有一张试卷信息表 examination_info
,其中包含各种类型试卷的信息。为了对表更方便快捷地查询,需要在 examination_info
表创建以下索引,
规则如下:在 duration
列创建普通索引 idx_duration
、在 exam_id
列创建唯一性索引 uniq_idx_exam_id
、在 tag
列创建全文索引 full_idx_tag
。
根据题意,将返回如下结果:
examination_info | 0 | PRIMARY | 1 | id | A | 0 | BTREE | |||
---|---|---|---|---|---|---|---|---|---|---|
examination_info | 0 | uniq_idx_exam_id | 1 | exam_id | A | 0 | YES | BTREE | ||
examination_info | 1 | idx_duration | 1 | duration | A | 0 | BTREE | |||
examination_info | 1 | full_idx_tag | 1 | tag | 0 | YES | FULLTEXT |
备注:后台会通过 SHOW INDEX FROM examination_info
语句来对比输出结果
思路:做这题首先需要了解常见的索引类型:
- B-Tree 索引:B-Tree(或称为平衡树)索引是最常见和默认的索引类型。它适用于各种查询条件,可以快速定位到符合条件的数据。B-Tree 索引适用于普通的查找操作,支持等值查询、范围查询和排序。
- 唯一索引:唯一索引与普通的 B-Tree 索引类似,不同之处在于它要求被索引的列的值是唯一的。这意味着在插入或更新数据时,MySQL 会验证索引列的唯一性。
- 主键索引:主键索引是一种特殊的唯一索引,它用于唯一标识表中的每一行数据。每个表只能有一个主键索引,它可以帮助提高数据的访问速度和数据完整性。
- 全文索引:全文索引用于在文本数据中进行全文搜索。它支持在文本字段中进行关键字搜索,而不仅仅是简单的等值或范围查找。全文索引适用于需要进行全文搜索的应用场景。
1 | -- 示例: |
有了以上的基础知识之后,该题答案也就浮出水面了。
答案:
1 | ALTER TABLE examination_info |
删除索引
描述:请删除examination_info
表上的唯一索引 uniq_idx_exam_id 和全文索引 full_idx_tag。
思路:该题考察删除索引的基本语法:
1 | -- 使用 DROP INDEX 删除索引 |
这里需要注意的是:在 MySQL 中,一次删除多个索引的操作是不支持的。每次删除索引时,只能指定一个索引名称进行删除。
而且 DROP 命令需要慎用!!!
答案:
1 | DROP INDEX uniq_idx_exam_id ON examination_info; |
RocketMQ常见问题总结
消息队列扫盲
消息队列顾名思义就是存放消息的队列,队列我就不解释了,别告诉我你连队列都不知道是啥吧?
所以问题并不是消息队列是什么,而是 消息队列为什么会出现?消息队列能用来干什么?用它来干这些事会带来什么好处?消息队列会带来副作用吗?
消息队列为什么会出现?
消息队``列算是作为后端程序员的一个必备技能吧,因为分布式应用必定涉及到各个系统之间的通信问题,这个时候消息队列也应运而生了。可以说分布式的产生是消息队列的基础,而分布式怕是一个很古老的概念了吧,所以消息队列也是一个很古老的中间件了。
消息队列能用来干什么?
异步
你可能会反驳我,应用之间的通信又不是只能由消息队列解决,好好的通信为什么中间非要插一个消息队列呢?我不能直接进行通信吗?
很好 👍,你又提出了一个概念,同步通信。就比如现在业界使用比较多的 Dubbo
就是一个适用于各个系统之间同步通信的 RPC
框架。
我来举个 🌰 吧,比如我们有一个购票系统,需求是用户在购买完之后能接收到购买完成的短信。
我们省略中间的网络通信时间消耗,假如购票系统处理需要 150ms ,短信系统处理需要 200ms ,那么整个处理流程的时间消耗就是 150ms + 200ms = 350ms。
当然,乍看没什么问题。可是仔细一想你就感觉有点问题,我用户购票在购票系统的时候其实就已经完成了购买,而我现在通过同步调用非要让整个请求拉长时间,而短信系统这玩意又不是很有必要,它仅仅是一个辅助功能增强用户体验感而已。我现在整个调用流程就有点 头重脚轻 的感觉了,购票是一个不太耗时的流程,而我现在因为同步调用,非要等待发送短信这个比较耗时的操作才返回结果。那我如果再加一个发送邮件呢?
这样整个系统的调用链又变长了,整个时间就变成了 550ms。
当我们在学生时代需要在食堂排队的时候,我们和食堂大妈就是一个同步的模型。
我们需要告诉食堂大妈:“姐姐,给我加个鸡腿,再加个酸辣土豆丝,帮我浇点汁上去,多打点饭哦 😋😋😋” 咦~~~ 为了多吃点,真恶心。
然后大妈帮我们打饭配菜,我们看着大妈那颤抖的手和掉落的土豆丝不禁咽了咽口水。
最终我们从大妈手中接过饭菜然后去寻找座位了…
回想一下,我们在给大妈发送需要的信息之后我们是 同步等待大妈给我配好饭菜 的,上面我们只是加了鸡腿和土豆丝,万一我再加一个番茄牛腩,韭菜鸡蛋,这样是不是大妈打饭配菜的流程就会变长,我们等待的时间也会相应的变长。
那后来,我们工作赚钱了有钱去饭店吃饭了,我们告诉服务员来一碗牛肉面加个荷包蛋 (传达一个消息) ,然后我们就可以在饭桌上安心的玩手机了 (干自己其他事情) ,等到我们的牛肉面上了我们就可以吃了。这其中我们也就传达了一个消息,然后我们又转过头干其他事情了。这其中虽然做面的时间没有变短,但是我们只需要传达一个消息就可以干其他事情了,这是一个 异步 的概念。
所以,为了解决这一个问题,聪明的程序员在中间也加了个类似于服务员的中间件——消息队列。这个时候我们就可以把模型给改造了。
这样,我们在将消息存入消息队列之后我们就可以直接返回了(我们告诉服务员我们要吃什么然后玩手机),所以整个耗时只是 150ms + 10ms = 160ms。
但是你需要注意的是,整个流程的时长是没变的,就像你仅仅告诉服务员要吃什么是不会影响到做面的速度的。
解耦
回到最初同步调用的过程,我们写个伪代码简单概括一下。
那么第二步,我们又添加了一个发送邮件,我们就得重新去修改代码,如果我们又加一个需求:用户购买完还需要给他加积分,这个时候我们是不是又得改代码?
如果你觉得还行,那么我这个时候不要发邮件这个服务了呢,我是不是又得改代码,又得重启应用?
这样改来改去是不是很麻烦,那么 此时我们就用一个消息队列在中间进行解耦 。你需要注意的是,我们后面的发送短信、发送邮件、添加积分等一些操作都依赖于上面的 result
,这东西抽象出来就是购票的处理结果呀,比如订单号,用户账号等等,也就是说我们后面的一系列服务都是需要同样的消息来进行处理。既然这样,我们是不是可以通过 “广播消息” 来实现。
我上面所讲的“广播”并不是真正的广播,而是接下来的系统作为消费者去 订阅 特定的主题。比如我们这里的主题就可以叫做 订票
,我们购买系统作为一个生产者去生产这条消息放入消息队列,然后消费者订阅了这个主题,会从消息队列中拉取消息并消费。就比如我们刚刚画的那张图,你会发现,在生产者这边我们只需要关注 生产消息到指定主题中 ,而 消费者只需要关注从指定主题中拉取消息 就行了。
如果没有消息队列,每当一个新的业务接入,我们都要在主系统调用新接口、或者当我们取消某些业务,我们也得在主系统删除某些接口调用。有了消息队列,我们只需要关心消息是否送达了队列,至于谁希望订阅,接下来收到消息如何处理,是下游的事情,无疑极大地减少了开发和联调的工作量。
削峰
我们再次回到一开始我们使用同步调用系统的情况,并且思考一下,如果此时有大量用户请求购票整个系统会变成什么样?
如果,此时有一万的请求进入购票系统,我们知道运行我们主业务的服务器配置一般会比较好,所以这里我们假设购票系统能承受这一万的用户请求,那么也就意味着我们同时也会出现一万调用发短信服务的请求。而对于短信系统来说并不是我们的主要业务,所以我们配备的硬件资源并不会太高,那么你觉得现在这个短信系统能承受这一万的峰值么,且不说能不能承受,系统会不会 直接崩溃 了?
短信业务又不是我们的主业务,我们能不能 折中处理 呢?如果我们把购买完成的信息发送到消息队列中,而短信系统 尽自己所能地去消息队列中取消息和消费消息 ,即使处理速度慢一点也无所谓,只要我们的系统没有崩溃就行了。
留得江山在,还怕没柴烧?你敢说每次发送验证码的时候是一发你就收到了的么?
消息队列能带来什么好处?
其实上面我已经说了。异步、解耦、削峰。 哪怕你上面的都没看懂也千万要记住这六个字,因为他不仅是消息队列的精华,更是编程和架构的精华。
消息队列会带来副作用吗?
没有哪一门技术是“银弹”,消息队列也有它的副作用。
比如,本来好好的两个系统之间的调用,我中间加了个消息队列,如果消息队列挂了怎么办呢?是不是 降低了系统的可用性 ?
那这样是不是要保证 HA(高可用)?是不是要搞集群?那么我 整个系统的复杂度是不是上升了 ?
抛开上面的问题不讲,万一我发送方发送失败了,然后执行重试,这样就可能产生重复的消息。
或者我消费端处理失败了,请求重发,这样也会产生重复的消息。
对于一些微服务来说,消费重复消息会带来更大的麻烦,比如增加积分,这个时候我加了多次是不是对其他用户不公平?
那么,又 如何解决重复消费消息的问题 呢?
如果我们此时的消息需要保证严格的顺序性怎么办呢?比如生产者生产了一系列的有序消息(对一个 id 为 1 的记录进行删除增加修改),但是我们知道在发布订阅模型中,对于主题是无顺序的,那么这个时候就会导致对于消费者消费消息的时候没有按照生产者的发送顺序消费,比如这个时候我们消费的顺序为修改删除增加,如果该记录涉及到金额的话是不是会出大事情?
那么,又 如何解决消息的顺序消费问题 呢?
就拿我们上面所讲的分布式系统来说,用户购票完成之后是不是需要增加账户积分?在同一个系统中我们一般会使用事务来进行解决,如果用 Spring
的话我们在上面伪代码中加入 @Transactional
注解就好了。但是在不同系统中如何保证事务呢?总不能这个系统我扣钱成功了你那积分系统积分没加吧?或者说我这扣钱明明失败了,你那积分系统给我加了积分。
那么,又如何 解决分布式事务问题 呢?
我们刚刚说了,消息队列可以进行削峰操作,那如果我的消费者如果消费很慢或者生产者生产消息很快,这样是不是会将消息堆积在消息队列中?
那么,又如何 解决消息堆积的问题 呢?
可用性降低,复杂度上升,又带来一系列的重复消费,顺序消费,分布式事务,消息堆积的问题,这消息队列还怎么用啊 😵?
别急,办法总是有的。
RocketMQ 是什么?
哇,你个混蛋!上面给我抛出那么多问题,你现在又讲 RocketMQ
,还让不让人活了?!🤬
别急别急,话说你现在清楚 MQ
的构造吗,我还没讲呢,我们先搞明白 MQ
的内部构造,再来看看如何解决上面的一系列问题吧,不过你最好带着问题去阅读和了解喔。
RocketMQ
是一个 队列模型 的消息中间件,具有高性能、高可靠、高实时、分布式 的特点。它是一个采用 Java
语言开发的分布式的消息系统,由阿里巴巴团队开发,在 2016 年底贡献给 Apache
,成为了 Apache
的一个顶级项目。 在阿里内部,RocketMQ
很好地服务了集团大大小小上千个应用,在每年的双十一当天,更有不可思议的万亿级消息通过 RocketMQ
流转。
废话不多说,想要了解 RocketMQ
历史的同学可以自己去搜寻资料。听完上面的介绍,你只要知道 RocketMQ
很快、很牛、而且经历过双十一的实践就行了!
队列模型和主题模型是什么?
在谈 RocketMQ
的技术架构之前,我们先来了解一下两个名词概念——队列模型 和 主题模型 。
首先我问一个问题,消息队列为什么要叫消息队列?
你可能觉得很弱智,这玩意不就是存放消息的队列嘛?不叫消息队列叫什么?
的确,早期的消息中间件是通过 队列 这一模型来实现的,可能是历史原因,我们都习惯把消息中间件成为消息队列。
但是,如今例如 RocketMQ
、Kafka
这些优秀的消息中间件不仅仅是通过一个 队列 来实现消息存储的。
队列模型
就像我们理解队列一样,消息中间件的队列模型就真的只是一个队列。。。我画一张图给大家理解。
在一开始我跟你提到了一个 “广播” 的概念,也就是说如果我们此时我们需要将一个消息发送给多个消费者(比如此时我需要将信息发送给短信系统和邮件系统),这个时候单个队列即不能满足需求了。
当然你可以让 Producer
生产消息放入多个队列中,然后每个队列去对应每一个消费者。问题是可以解决,创建多个队列并且复制多份消息是会很影响资源和性能的。而且,这样子就会导致生产者需要知道具体消费者个数然后去复制对应数量的消息队列,这就违背我们消息中间件的 解耦 这一原则。
主题模型
那么有没有好的方法去解决这一个问题呢?有,那就是 主题模型 或者可以称为 发布订阅模型 。
感兴趣的同学可以去了解一下设计模式里面的观察者模式并且手动实现一下,我相信你会有所收获的。
在主题模型中,消息的生产者称为 发布者(Publisher) ,消息的消费者称为 订阅者(Subscriber) ,存放消息的容器称为 主题(Topic) 。
其中,发布者将消息发送到指定主题中,订阅者需要 提前订阅主题 才能接受特定主题的消息。
RocketMQ 中的消息模型
RocketMQ
中的消息模型就是按照 主题模型 所实现的。你可能会好奇这个 主题 到底是怎么实现的呢?你上面也没有讲到呀!
其实对于主题模型的实现来说每个消息中间件的底层设计都是不一样的,就比如 Kafka
中的 分区 ,RocketMQ
中的 队列 ,RabbitMQ
中的 Exchange
。我们可以理解为 主题模型/发布订阅模型 就是一个标准,那些中间件只不过照着这个标准去实现而已。
所以,RocketMQ
中的 主题模型 到底是如何实现的呢?首先我画一张图,大家尝试着去理解一下。
我们可以看到在整个图中有 Producer Group
、Topic
、Consumer Group
三个角色,我来分别介绍一下他们。
Producer Group
生产者组:代表某一类的生产者,比如我们有多个秒杀系统作为生产者,这多个合在一起就是一个Producer Group
生产者组,它们一般生产相同的消息。Consumer Group
消费者组:代表某一类的消费者,比如我们有多个短信系统作为消费者,这多个合在一起就是一个Consumer Group
消费者组,它们一般消费相同的消息。Topic
主题:代表一类消息,比如订单消息,物流消息等等。
你可以看到图中生产者组中的生产者会向主题发送消息,而 主题中存在多个队列,生产者每次生产消息之后是指定主题中的某个队列发送消息的。
每个主题中都有多个队列(分布在不同的 Broker
中,如果是集群的话,Broker
又分布在不同的服务器中),集群消费模式下,一个消费者集群多台机器共同消费一个 topic
的多个队列,一个队列只会被一个消费者消费。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。就像上图中 Consumer1
和 Consumer2
分别对应着两个队列,而 Consumer3
是没有队列对应的,所以一般来讲要控制 消费者组中的消费者个数和主题中队列个数相同 。
当然也可以消费者个数小于队列个数,只不过不太建议。如下图。
每个消费组在每个队列上维护一个消费位置 ,为什么呢?
因为我们刚刚画的仅仅是一个消费者组,我们知道在发布订阅模式中一般会涉及到多个消费者组,而每个消费者组在每个队列中的消费位置都是不同的。如果此时有多个消费者组,那么消息被一个消费者组消费完之后是不会删除的(因为其它消费者组也需要呀),它仅仅是为每个消费者组维护一个 消费位移(offset) ,每次消费者组消费完会返回一个成功的响应,然后队列再把维护的消费位移加一,这样就不会出现刚刚消费过的消息再一次被消费了。
可能你还有一个问题,为什么一个主题中需要维护多个队列 ?
答案是 提高并发能力 。的确,每个主题中只存在一个队列也是可行的。你想一下,如果每个主题中只存在一个队列,这个队列中也维护着每个消费者组的消费位置,这样也可以做到 发布订阅模式 。如下图。
但是,这样我生产者是不是只能向一个队列发送消息?又因为需要维护消费位置所以一个队列只能对应一个消费者组中的消费者,这样是不是其他的 Consumer
就没有用武之地了?从这两个角度来讲,并发度一下子就小了很多。
所以总结来说,RocketMQ
通过使用在一个 Topic
中配置多个队列并且每个队列维护每个消费者组的消费位置 实现了 主题模式/发布订阅模式 。
RocketMQ 的架构图
讲完了消息模型,我们理解起 RocketMQ
的技术架构起来就容易多了。
RocketMQ
技术架构中有四大角色 NameServer
、Broker
、Producer
、Consumer
。我来向大家分别解释一下这四个角色是干啥的。
Broker
:主要负责消息的存储、投递和查询以及服务高可用保证。说白了就是消息队列服务器嘛,生产者生产消息到Broker
,消费者从Broker
拉取消息并消费。这里,我还得普及一下关于
Broker
、Topic
和 队列的关系。上面我讲解了Topic
和队列的关系——一个Topic
中存在多个队列,那么这个Topic
和队列存放在哪呢?一个
Topic
分布在多个Broker
上,一个Broker
可以配置多个Topic
,它们是多对多的关系。如果某个
Topic
消息量很大,应该给它多配置几个队列(上文中提到了提高并发能力),并且 尽量多分布在不同Broker
上,以减轻某个Broker
的压力 。Topic
消息量都比较均匀的情况下,如果某个broker
上的队列越多,则该broker
压力越大。所以说我们需要配置多个 Broker。
NameServer
:不知道你们有没有接触过ZooKeeper
和Spring Cloud
中的Eureka
,它其实也是一个 注册中心 ,主要提供两个功能:Broker 管理 和 路由信息管理 。说白了就是Broker
会将自己的信息注册到NameServer
中,此时NameServer
就存放了很多Broker
的信息(Broker 的路由表),消费者和生产者就从NameServer
中获取路由表然后照着路由表的信息和对应的Broker
进行通信(生产者和消费者定期会向NameServer
去查询相关的Broker
的信息)。Producer
:消息发布的角色,支持分布式集群方式部署。说白了就是生产者。Consumer
:消息消费的角色,支持分布式集群方式部署。支持以 push 推,pull 拉两种模式对消息进行消费。同时也支持集群方式和广播方式的消费,它提供实时消息订阅机制。说白了就是消费者。
听完了上面的解释你可能会觉得,这玩意好简单。不就是这样的么?
嗯?你可能会发现一个问题,这老家伙 NameServer
干啥用的,这不多余吗?直接 Producer
、Consumer
和 Broker
直接进行生产消息,消费消息不就好了么?
但是,我们上文提到过 Broker
是需要保证高可用的,如果整个系统仅仅靠着一个 Broker
来维持的话,那么这个 Broker
的压力会不会很大?所以我们需要使用多个 Broker
来保证 负载均衡 。
如果说,我们的消费者和生产者直接和多个 Broker
相连,那么当 Broker
修改的时候必定会牵连着每个生产者和消费者,这样就会产生耦合问题,而 NameServer
注册中心就是用来解决这个问题的。
如果还不是很理解的话,可以去看我介绍
Spring Cloud
的那篇文章,其中介绍了Eureka
注册中心。
当然,RocketMQ
中的技术架构肯定不止前面那么简单,因为上面图中的四个角色都是需要做集群的。我给出一张官网的架构图,大家尝试理解一下。
其实和我们最开始画的那张乞丐版的架构图也没什么区别,主要是一些细节上的差别。听我细细道来 🤨。
第一、我们的 Broker
做了集群并且还进行了主从部署 ,由于消息分布在各个 Broker
上,一旦某个 Broker
宕机,则该Broker
上的消息读写都会受到影响。所以 Rocketmq
提供了 master/slave
的结构,salve
定时从 master
同步数据(同步刷盘或者异步刷盘),如果 master
宕机,则 slave
提供消费服务,但是不能写入消息 (后面我还会提到哦)。
第二、为了保证 HA
,我们的 NameServer
也做了集群部署,但是请注意它是 去中心化 的。也就意味着它没有主节点,你可以很明显地看出 NameServer
的所有节点是没有进行 Info Replicate
的,在 RocketMQ
中是通过 单个 Broker 和所有 NameServer 保持长连接 ,并且在每隔 30 秒 Broker
会向所有 Nameserver
发送心跳,心跳包含了自身的 Topic
配置信息,这个步骤就对应这上面的 Routing Info
。
第三、在生产者需要向 Broker
发送消息的时候,需要先从 NameServer
获取关于 Broker
的路由信息,然后通过 轮询 的方法去向每个队列中生产数据以达到 负载均衡 的效果。
第四、消费者通过 NameServer
获取所有 Broker
的路由信息后,向 Broker
发送 Pull
请求来获取消息数据。Consumer
可以以两种模式启动—— 广播(Broadcast)和集群(Cluster)。广播模式下,一条消息会发送给 同一个消费组中的所有消费者 ,集群模式下消息只会发送给一个消费者。
RocketMQ 功能特性
消息
普通消息
普通消息一般应用于微服务解耦、事件驱动、数据集成等场景,这些场景大多数要求数据传输通道具有可靠传输的能力,且对消息的处理时机、处理顺序没有特别要求。以在线的电商交易场景为例,上游订单系统将用户下单支付这一业务事件封装成独立的普通消息并发送至 RocketMQ 服务端,下游按需从服务端订阅消息并按照本地消费逻辑处理下游任务。每个消息之间都是相互独立的,且不需要产生关联。另外还有日志系统,以离线的日志收集场景为例,通过埋点组件收集前端应用的相关操作日志,并转发到 RocketMQ 。
普通消息生命周期
- 初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。
- 待消费:消息被发送到服务端,对消费者可见,等待消费者消费的状态。
- 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。
- 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
- 消息删除:RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。
定时消息
在分布式定时调度触发、任务超时处理等场景,需要实现精准、可靠的定时事件触发。使用 RocketMQ 的定时消息可以简化定时调度任务的开发逻辑,实现高性能、可扩展、高可靠的定时触发能力。定时消息仅支持在 MessageType 为 Delay 的主题内使用,即定时消息只能发送至类型为定时消息的主题中,发送的消息的类型必须和主题的类型一致。在 4.x 版本中,只支持延时消息,默认分为 18 个等级分别为:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,也可以在配置文件中增加自定义的延时等级和时长。在 5.x 版本中,开始支持定时消息,在构造消息时提供了 3 个 API 来指定延迟时间或定时时间。
基于定时消息的超时任务处理具备如下优势:
- 精度高、开发门槛低:基于消息通知方式不存在定时阶梯间隔。可以轻松实现任意精度事件触发,无需业务去重。
- 高性能可扩展:传统的数据库扫描方式较为复杂,需要频繁调用接口扫描,容易产生性能瓶颈。RocketMQ 的定时消息具有高并发和水平扩展的能力。
定时消息生命周期
- 初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。
- 定时中:消息被发送到服务端,和普通消息不同的是,服务端不会直接构建消息索引,而是会将定时消息单独存储在定时存储系统中,等待定时时刻到达。
- 待消费:定时时刻到达后,服务端将消息重新写入普通存储引擎,对下游消费者可见,等待消费者消费的状态。
- 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。
- 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
- 消息删除:Apache RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。
定时消息的实现逻辑需要先经过定时存储等待触发,定时时间到达后才会被投递给消费者。因此,如果将大量定时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度。
顺序消息
顺序消息仅支持使用 MessageType 为 FIFO 的主题,即顺序消息只能发送至类型为顺序消息的主题中,发送的消息的类型必须和主题的类型一致。和普通消息发送相比,顺序消息发送必须要设置消息组。(推荐实现 MessageQueueSelector 的方式,见下文)。要保证消息的顺序性需要单一生产者串行发送。
单线程使用 MessageListenerConcurrently 可以顺序消费,多线程环境下使用 MessageListenerOrderly 才能顺序消费。
事务消息
事务消息是 Apache RocketMQ 提供的一种高级消息类型,支持在分布式场景下保障消息生产和本地事务的最终一致性。简单来讲,就是将本地事务(数据库的 DML 操作)与发送消息合并在同一个事务中。例如,新增一个订单。在事务未提交之前,不发送订阅的消息。发送消息的动作随着事务的成功提交而发送,随着事务的回滚而取消。当然真正地处理过程不止这么简单,包含了半消息、事务监听和事务回查等概念,下面有更详细的说明。
关于发送消息
不建议单一进程创建大量生产者
Apache RocketMQ 的生产者和主题是多对多的关系,支持同一个生产者向多个主题发送消息。对于生产者的创建和初始化,建议遵循够用即可、最大化复用原则,如果有需要发送消息到多个主题的场景,无需为每个主题都创建一个生产者。
不建议频繁创建和销毁生产者
Apache RocketMQ 的生产者是可以重复利用的底层资源,类似数据库的连接池。因此不需要在每次发送消息时动态创建生产者,且在发送结束后销毁生产者。这样频繁的创建销毁会在服务端产生大量短连接请求,严重影响系统性能。
正确示例:
1 | Producer p = ProducerBuilder.build(); |
消费者分类
PushConsumer
高度封装的消费者类型,消费消息仅仅通过消费监听器监听并返回结果。消息的获取、消费状态提交以及消费重试都通过 RocketMQ 的客户端 SDK 完成。
PushConsumer 的消费监听器执行结果分为以下三种情况:
- 返回消费成功:以 Java SDK 为例,返回
ConsumeResult.SUCCESS
,表示该消息处理成功,服务端按照消费结果更新消费进度。 - 返回消费失败:以 Java SDK 为例,返回
ConsumeResult.FAILURE
,表示该消息处理失败,需要根据消费重试逻辑判断是否进行重试消费。 - 出现非预期失败:例如抛异常等行为,该结果按照消费失败处理,需要根据消费重试逻辑判断是否进行重试消费。
具体实现可以参见这篇文章RocketMQ 对 pull 和 push 的实现。
使用 PushConsumer 消费者消费时,不允许使用以下方式处理消息,否则 RocketMQ 无法保证消息的可靠性。
- 错误方式一:消息还未处理完成,就提前返回消费成功结果。此时如果消息消费失败,RocketMQ 服务端是无法感知的,因此不会进行消费重试。
- 错误方式二:在消费监听器内将消息再次分发到自定义的其他线程,消费监听器提前返回消费结果。此时如果消息消费失败,RocketMQ 服务端同样无法感知,因此也不会进行消费重试。
- PushConsumer 严格限制了消息同步处理及每条消息的处理超时时间,适用于以下场景:
- 消息处理时间可预估:如果不确定消息处理耗时,经常有预期之外的长时间耗时的消息,PushConsumer 的可靠性保证会频繁触发消息重试机制造成大量重复消息。
- 无异步化、高级定制场景:PushConsumer 限制了消费逻辑的线程模型,由客户端 SDK 内部按最大吞吐量触发消息处理。该模型开发逻辑简单,但是不允许使用异步化和自定义处理流程。
SimpleConsumer
SimpleConsumer 是一种接口原子型的消费者类型,消息的获取、消费状态提交以及消费重试都是通过消费者业务逻辑主动发起调用完成。
一个来自官网的例子:
1 | // 消费示例:使用 SimpleConsumer 消费普通消息,主动获取消息处理并提交。 |
SimpleConsumer 适用于以下场景:
- 消息处理时长不可控:如果消息处理时长无法预估,经常有长时间耗时的消息处理情况。建议使用 SimpleConsumer 消费类型,可以在消费时自定义消息的预估处理时长,若实际业务中预估的消息处理时长不符合预期,也可以通过接口提前修改。
- 需要异步化、批量消费等高级定制场景:SimpleConsumer 在 SDK 内部没有复杂的线程封装,完全由业务逻辑自由定制,可以实现异步分发、批量消费等高级定制场景。
- 需要自定义消费速率:SimpleConsumer 是由业务逻辑主动调用接口获取消息,因此可以自由调整获取消息的频率,自定义控制消费速率。
PullConsumer
施工中。。。
消费者分组和生产者分组
生产者分组
RocketMQ 服务端 5.x 版本开始,生产者是匿名的,无需管理生产者分组(ProducerGroup);对于历史版本服务端 3.x 和 4.x 版本,已经使用的生产者分组可以废弃无需再设置,且不会对当前业务产生影响。
消费者分组
消费者分组是多个消费行为一致的消费者的负载均衡分组。消费者分组不是具体实体而是一个逻辑资源。通过消费者分组实现消费性能的水平扩展以及高可用容灾。
消费者分组中的订阅关系、投递顺序性、消费重试策略是一致的。
- 订阅关系:Apache RocketMQ 以消费者分组的粒度管理订阅关系,实现订阅关系的管理和追溯。
- 投递顺序性:Apache RocketMQ 的服务端将消息投递给消费者消费时,支持顺序投递和并发投递,投递方式在消费者分组中统一配置。
- 消费重试策略: 消费者消费消息失败时的重试策略,包括重试次数、死信队列设置等。
RocketMQ 服务端 5.x 版本:上述消费者的消费行为从关联的消费者分组中统一获取,因此,同一分组内所有消费者的消费行为必然是一致的,客户端无需关注。
RocketMQ 服务端 3.x/4.x 历史版本:上述消费逻辑由消费者客户端接口定义,因此,您需要自己在消费者客户端设置时保证同一分组下的消费者的消费行为一致。(来自官方网站)
如何解决顺序消费和重复消费?
其实,这些东西都是我在介绍消息队列带来的一些副作用的时候提到的,也就是说,这些问题不仅仅挂钩于 RocketMQ
,而是应该每个消息中间件都需要去解决的。
在上面我介绍 RocketMQ
的技术架构的时候我已经向你展示了 它是如何保证高可用的 ,这里不涉及运维方面的搭建,如果你感兴趣可以自己去官网上照着例子搭建属于你自己的 RocketMQ
集群。
其实
Kafka
的架构基本和RocketMQ
类似,只是它注册中心使用了Zookeeper
、它的 分区 就相当于RocketMQ
中的 队列 。还有一些小细节不同会在后面提到。
顺序消费
在上面的技术架构介绍中,我们已经知道了 RocketMQ
在主题上是无序的、它只有在队列层面才是保证有序 的。
这又扯到两个概念——普通顺序 和 严格顺序 。
所谓普通顺序是指 消费者通过 同一个消费队列收到的消息是有顺序的 ,不同消息队列收到的消息则可能是无顺序的。普通顺序消息在 Broker
重启情况下不会保证消息顺序性 (短暂时间) 。
所谓严格顺序是指 消费者收到的 所有消息 均是有顺序的。严格顺序消息 即使在异常情况下也会保证消息的顺序性 。
但是,严格顺序看起来虽好,实现它可会付出巨大的代价。如果你使用严格顺序模式,Broker
集群中只要有一台机器不可用,则整个集群都不可用。你还用啥?现在主要场景也就在 binlog
同步。
一般而言,我们的 MQ
都是能容忍短暂的乱序,所以推荐使用普通顺序模式。
那么,我们现在使用了 普通顺序模式 ,我们从上面学习知道了在 Producer
生产消息的时候会进行轮询(取决你的负载均衡策略)来向同一主题的不同消息队列发送消息。那么如果此时我有几个消息分别是同一个订单的创建、支付、发货,在轮询的策略下这 三个消息会被发送到不同队列 ,因为在不同的队列此时就无法使用 RocketMQ
带来的队列有序特性来保证消息有序性了。
那么,怎么解决呢?
其实很简单,我们需要处理的仅仅是将同一语义下的消息放入同一个队列(比如这里是同一个订单),那我们就可以使用 Hash 取模法 来保证同一个订单在同一个队列中就行了。
RocketMQ 实现了两种队列选择算法,也可以自己实现
轮询算法
- 轮询算法就是向消息指定的 topic 所在队列中依次发送消息,保证消息均匀分布
- 是 RocketMQ 默认队列选择算法
最小投递延迟算法
每次消息投递的时候统计消息投递的延迟,选择队列时优先选择消息延时小的队列,导致消息分布不均匀,按照如下设置即可。
producer.setSendLatencyFaultEnable(true);
1
2
3
4
5
6
7
8
9
10
11
- 继承 MessageQueueSelector 实现
- ```java
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
//从mqs中选择一个队列,可以根据msg特点选择
return null;
}
}, new Object());
特殊情况处理
发送异常
选择队列后会与 Broker 建立连接,通过网络请求将消息发送到 Broker 上,如果 Broker 挂了或者网络波动发送消息超时此时 RocketMQ 会进行重试。
重新选择其他 Broker 中的消息队列进行发送,默认重试两次,可以手动设置。
1 | producer.setRetryTimesWhenSendFailed(5); |
消息过大
消息超过 4k 时 RocketMQ 会将消息压缩后在发送到 Broker 上,减少网络资源的占用。
重复消费
emmm,就两个字—— 幂等 。在编程中一个幂等 操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。比如说,这个时候我们有一个订单的处理积分的系统,每当来一个消息的时候它就负责为创建这个订单的用户的积分加上相应的数值。可是有一次,消息队列发送给订单系统 FrancisQ 的订单信息,其要求是给 FrancisQ 的积分加上 500。但是积分系统在收到 FrancisQ 的订单信息处理完成之后返回给消息队列处理成功的信息的时候出现了网络波动(当然还有很多种情况,比如 Broker 意外重启等等),这条回应没有发送成功。
那么,消息队列没收到积分系统的回应会不会尝试重发这个消息?问题就来了,我再发这个消息,万一它又给 FrancisQ 的账户加上 500 积分怎么办呢?
所以我们需要给我们的消费者实现 幂等 ,也就是对同一个消息的处理结果,执行多少次都不变。
那么如何给业务实现幂等呢?这个还是需要结合具体的业务的。你可以使用 写入 Redis
来保证,因为 Redis
的 key
和 value
就是天然支持幂等的。当然还有使用 数据库插入法 ,基于数据库的唯一键来保证重复数据不会被插入多条。
不过最主要的还是需要 根据特定场景使用特定的解决方案 ,你要知道你的消息消费是否是完全不可重复消费还是可以忍受重复消费的,然后再选择强校验和弱校验的方式。毕竟在 CS 领域还是很少有技术银弹的说法。
而在整个互联网领域,幂等不仅仅适用于消息队列的重复消费问题,这些实现幂等的方法,也同样适用于,在其他场景中来解决重复请求或者重复调用的问题 。比如将 HTTP 服务设计成幂等的,解决前端或者 APP 重复提交表单数据的问题 ,也可以将一个微服务设计成幂等的,解决 RPC
框架自动重试导致的 重复调用问题 。
RocketMQ 如何实现分布式事务?
如何解释分布式事务呢?事务大家都知道吧?要么都执行要么都不执行 。在同一个系统中我们可以轻松地实现事务,但是在分布式架构中,我们有很多服务是部署在不同系统之间的,而不同服务之间又需要进行调用。比如此时我下订单然后增加积分,如果保证不了分布式事务的话,就会出现 A 系统下了订单,但是 B 系统增加积分失败或者 A 系统没有下订单,B 系统却增加了积分。前者对用户不友好,后者对运营商不利,这是我们都不愿意见到的。
那么,如何去解决这个问题呢?
如今比较常见的分布式事务实现有 2PC、TCC 和事务消息(half 半消息机制)。每一种实现都有其特定的使用场景,但是也有各自的问题,都不是完美的解决方案。
在 RocketMQ
中使用的是 事务消息加上事务反查机制 来解决分布式事务问题的。我画了张图,大家可以对照着图进行理解。
在第一步发送的 half 消息 ,它的意思是 在事务提交之前,对于消费者来说,这个消息是不可见的 。
那么,如何做到写入消息但是对用户不可见呢?RocketMQ 事务消息的做法是:如果消息是 half 消息,将备份原消息的主题与消息消费队列,然后 改变主题 为 RMQ_SYS_TRANS_HALF_TOPIC。由于消费组未订阅该主题,故消费端无法消费 half 类型的消息,然后 RocketMQ 会开启一个定时任务,从 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC 中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。
你可以试想一下,如果没有从第 5 步开始的 事务反查机制 ,如果出现网路波动第 4 步没有发送成功,这样就会产生 MQ 不知道是不是需要给消费者消费的问题,他就像一个无头苍蝇一样。在 RocketMQ
中就是使用的上述的事务反查来解决的,而在 Kafka
中通常是直接抛出一个异常让用户来自行解决。
你还需要注意的是,在 MQ Server
指向系统 B 的操作已经和系统 A 不相关了,也就是说在消息队列中的分布式事务是——本地事务和存储消息到消息队列才是同一个事务。这样也就产生了事务的最终一致性,因为整个过程是异步的,每个系统只要保证它自己那一部分的事务就行了。
实践中会遇到的问题:事务消息需要一个事务监听器来监听本地事务是否成功,并且事务监听器接口只允许被实现一次。那就意味着需要把各种事务消息的本地事务都写在一个接口方法里面,必将会产生大量的耦合和类型判断。采用函数 Function 接口来包装整个业务过程,作为一个参数传递到监听器的接口方法中。再调用 Function 的 apply() 方法来执行业务,事务也会在 apply() 方法中执行。让监听器与业务之间实现解耦,使之具备了真实生产环境中的可行性。
1.模拟一个添加用户浏览记录的需求
1 | @PostMapping("/add") |
2.发送事务消息的方法
1 | /** |
3.生产者消息监听器,只允许一个类去实现该监听器
1 | @Slf4j |
4.模拟的业务场景,这里的方法必须提取出来,放在别的类里面.如果调用方与被调用方在同一个类中,会发生事务失效的问题.
1 | @Component |
5.消费消息,以及幂等处理
1 | @Service |
如何解决消息堆积问题?
在上面我们提到了消息队列一个很重要的功能——削峰 。那么如果这个峰值太大了导致消息堆积在队列中怎么办呢?
其实这个问题可以将它广义化,因为产生消息堆积的根源其实就只有两个——生产者生产太快或者消费者消费太慢。
我们可以从多个角度去思考解决这个问题,当流量到峰值的时候是因为生产者生产太快,我们可以使用一些 限流降级 的方法,当然你也可以增加多个消费者实例去水平扩展增加消费能力来匹配生产的激增。如果消费者消费过慢的话,我们可以先检查 是否是消费者出现了大量的消费错误 ,或者打印一下日志查看是否是哪一个线程卡死,出现了锁资源不释放等等的问题。
当然,最快速解决消息堆积问题的方法还是增加消费者实例,不过 同时你还需要增加每个主题的队列数量 。
别忘了在
RocketMQ
中,一个队列只会被一个消费者消费 ,如果你仅仅是增加消费者实例就会出现我一开始给你画架构图的那种情况。
什么是回溯消费?
回溯消费是指 Consumer
已经消费成功的消息,由于业务上需求需要重新消费,在RocketMQ
中, Broker
在向Consumer
投递成功消息后,消息仍然需要保留 。并且重新消费一般是按照时间维度,例如由于 Consumer
系统故障,恢复后需要重新消费 1 小时前的数据,那么 Broker
要提供一种机制,可以按照时间维度来回退消费进度。RocketMQ
支持按照时间回溯消费,时间维度精确到毫秒。
这是官方文档的解释,我直接照搬过来就当科普了 😁😁😁。
RocketMQ 如何保证高性能读写
传统 IO 方式
传统的 IO 读写其实就是 read + write 的操作,整个过程会分为如下几步
- 用户调用 read()方法,开始读取数据,此时发生一次上下文从用户态到内核态的切换,也就是图示的切换 1
- 将磁盘数据通过 DMA 拷贝到内核缓存区
- 将内核缓存区的数据拷贝到用户缓冲区,这样用户,也就是我们写的代码就能拿到文件的数据
- read()方法返回,此时就会从内核态切换到用户态,也就是图示的切换 2
- 当我们拿到数据之后,就可以调用 write()方法,此时上下文会从用户态切换到内核态,即图示切换 3
- CPU 将用户缓冲区的数据拷贝到 Socket 缓冲区
- 将 Socket 缓冲区数据拷贝至网卡
- write()方法返回,上下文重新从内核态切换到用户态,即图示切换 4
整个过程发生了 4 次上下文切换和 4 次数据的拷贝,这在高并发场景下肯定会严重影响读写性能故引入了零拷贝技术
零拷贝技术
mmap
mmap(memory map)是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。
简单地说就是内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次 CPU 拷贝。基于此上述架构图可变为:
基于 mmap IO 读写其实就变成 mmap + write 的操作,也就是用 mmap 替代传统 IO 中的 read 操作。
当用户发起 mmap 调用的时候会发生上下文切换 1,进行内存映射,然后数据被拷贝到内核缓冲区,mmap 返回,发生上下文切换 2;随后用户调用 write,发生上下文切换 3,将内核缓冲区的数据拷贝到 Socket 缓冲区,write 返回,发生上下文切换 4。
发生 4 次上下文切换和 3 次 IO 拷贝操作,在 Java 中的实现:
1 | FileChannel fileChannel = new RandomAccessFile("test.txt", "rw").getChannel(); |
sendfile
sendfile()跟 mmap()一样,也会减少一次 CPU 拷贝,但是它同时也会减少两次上下文切换。
如图,用户在发起 sendfile()调用时会发生切换 1,之后数据通过 DMA 拷贝到内核缓冲区,之后再将内核缓冲区的数据 CPU 拷贝到 Socket 缓冲区,最后拷贝到网卡,sendfile()返回,发生切换 2。发生了 3 次拷贝和两次切换。Java 也提供了相应 api:
1 | FileChannel channel = FileChannel.open(Paths.get("./test.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE); |
在如上代码中,并没有文件的读写操作,而是直接将文件的数据传输到 target 目标缓冲区,也就是说,sendfile 是无法知道文件的具体的数据的;但是 mmap 不一样,他是可以修改内核缓冲区的数据的。假设如果需要对文件的内容进行修改之后再传输,只有 mmap 可以满足。
通过上面的一些介绍,结论是基于零拷贝技术,可以减少 CPU 的拷贝次数和上下文切换次数,从而可以实现文件高效的读写操作。
RocketMQ 内部主要是使用基于 mmap 实现的零拷贝(其实就是调用上述提到的 api),用来读写文件,这也是 RocketMQ 为什么快的一个很重要原因。
RocketMQ 的刷盘机制
上面我讲了那么多的 RocketMQ
的架构和设计原理,你有没有好奇
在 Topic
中的 队列是以什么样的形式存在的?
队列中的消息又是如何进行存储持久化的呢?
我在上文中提到的 同步刷盘 和 异步刷盘 又是什么呢?它们会给持久化带来什么样的影响呢?
下面我将给你们一一解释。
同步刷盘和异步刷盘
如上图所示,在同步刷盘中需要等待一个刷盘成功的 ACK
,同步刷盘对 MQ
消息可靠性来说是一种不错的保障,但是 性能上会有较大影响 ,一般地适用于金融等特定业务场景。
而异步刷盘往往是开启一个线程去异步地执行刷盘操作。消息刷盘采用后台异步线程提交的方式进行, 降低了读写延迟 ,提高了 MQ
的性能和吞吐量,一般适用于如发验证码等对于消息保证要求不太高的业务场景。
一般地,异步刷盘只有在 Broker
意外宕机的时候会丢失部分数据,你可以设置 Broker
的参数 FlushDiskType
来调整你的刷盘策略(ASYNC_FLUSH 或者 SYNC_FLUSH)。
同步复制和异步复制
上面的同步刷盘和异步刷盘是在单个结点层面的,而同步复制和异步复制主要是指的 Borker
主从模式下,主节点返回消息给客户端的时候是否需要同步从节点。
- 同步复制:也叫 “同步双写”,也就是说,只有消息同步双写到主从节点上时才返回写入成功 。
- 异步复制:消息写入主节点之后就直接返回写入成功 。
然而,很多事情是没有完美的方案的,就比如我们进行消息写入的节点越多就更能保证消息的可靠性,但是随之的性能也会下降,所以需要程序员根据特定业务场景去选择适应的主从复制方案。
那么,异步复制会不会也像异步刷盘那样影响消息的可靠性呢?
答案是不会的,因为两者就是不同的概念,对于消息可靠性是通过不同的刷盘策略保证的,而像异步同步复制策略仅仅是影响到了 可用性 。为什么呢?其主要原因是 RocketMQ
是不支持自动主从切换的,当主节点挂掉之后,生产者就不能再给这个主节点生产消息了。
比如这个时候采用异步复制的方式,在主节点还未发送完需要同步的消息的时候主节点挂掉了,这个时候从节点就少了一部分消息。但是此时生产者无法再给主节点生产消息了,消费者可以自动切换到从节点进行消费(仅仅是消费),所以在主节点挂掉的时间只会产生主从结点短暂的消息不一致的情况,降低了可用性,而当主节点重启之后,从节点那部分未来得及复制的消息还会继续复制。
在单主从架构中,如果一个主节点挂掉了,那么也就意味着整个系统不能再生产了。那么这个可用性的问题能否解决呢?一个主从不行那就多个主从的呗,别忘了在我们最初的架构图中,每个 Topic
是分布在不同 Broker
中的。
但是这种复制方式同样也会带来一个问题,那就是无法保证 严格顺序 。在上文中我们提到了如何保证的消息顺序性是通过将一个语义的消息发送在同一个队列中,使用 Topic
下的队列来保证顺序性的。如果此时我们主节点 A 负责的是订单 A 的一系列语义消息,然后它挂了,这样其他节点是无法代替主节点 A 的,如果我们任意节点都可以存入任何消息,那就没有顺序性可言了。
而在 RocketMQ
中采用了 Dledger
解决这个问题。他要求在写入消息的时候,要求至少消息复制到半数以上的节点之后,才给客⼾端返回写⼊成功,并且它是⽀持通过选举来动态切换主节点的。这里我就不展开说明了,读者可以自己去了解。
也不是说
Dledger
是个完美的方案,至少在Dledger
选举过程中是无法提供服务的,而且他必须要使用三个节点或以上,如果多数节点同时挂掉他也是无法保证可用性的,而且要求消息复制半数以上节点的效率和直接异步复制还是有一定的差距的。
存储机制
还记得上面我们一开始的三个问题吗?到这里第三个问题已经解决了。
但是,在 Topic
中的 队列是以什么样的形式存在的?队列中的消息又是如何进行存储持久化的呢? 还未解决,其实这里涉及到了 RocketMQ
是如何设计它的存储结构了。我首先想大家介绍 RocketMQ
消息存储架构中的三大角色——CommitLog
、ConsumeQueue
和 IndexFile
。
CommitLog
:消息主体以及元数据的存储主体,存储Producer
端写入的消息主体内容,消息内容不是定长的。单个文件大小默认 1G ,文件名长度为 20 位,左边补零,剩余为起始偏移量,比如 00000000000000000000 代表了第一个文件,起始偏移量为 0,文件大小为 1G=1073741824;当第一个文件写满了,第二个文件为 00000000001073741824,起始偏移量为 1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件。ConsumeQueue
:消息消费队列,引入的目的主要是提高消息消费的性能(我们再前面也讲了),由于RocketMQ
是基于主题Topic
的订阅模式,消息消费是针对主题进行的,如果要遍历commitlog
文件中根据Topic
检索消息是非常低效的。Consumer
即可根据ConsumeQueue
来查找待消费的消息。其中,ConsumeQueue
(逻辑消费队列)作为消费消息的索引,保存了指定Topic
下的队列消息在CommitLog
中的起始物理偏移量offset
,消息大小size
和消息Tag
的HashCode
值。consumequeue
文件可以看成是基于topic
的commitlog
索引文件,故consumequeue
文件夹的组织方式如下:topic/queue/file 三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同样consumequeue
文件采取定长设计,每一个条目共 20 个字节,分别为 8 字节的commitlog
物理偏移量、4 字节的消息长度、8 字节 taghashcode
,单个文件由 30W 个条目组成,可以像数组一样随机访问每一个条目,每个ConsumeQueue
文件大小约 5.72M;IndexFile
:IndexFile
(索引文件)提供了一种可以通过 key 或时间区间来查询消息的方法。这里只做科普不做详细介绍。
总结来说,整个消息存储的结构,最主要的就是 CommitLoq
和 ConsumeQueue
。而 ConsumeQueue
你可以大概理解为 Topic
中的队列。
RocketMQ
采用的是 混合型的存储结构 ,即为 Broker
单个实例下所有的队列共用一个日志数据文件来存储消息。有意思的是在同样高并发的 Kafka
中会为每个 Topic
分配一个存储文件。这就有点类似于我们有一大堆书需要装上书架,RocketMQ
是不分书的种类直接成批的塞上去的,而 Kafka
是将书本放入指定的分类区域的。
而 RocketMQ
为什么要这么做呢?原因是 提高数据的写入效率 ,不分 Topic
意味着我们有更大的几率获取 成批 的消息进行数据写入,但也会带来一个麻烦就是读取消息的时候需要遍历整个大文件,这是非常耗时的。
所以,在 RocketMQ
中又使用了 ConsumeQueue
作为每个队列的索引文件来 提升读取消息的效率。我们可以直接根据队列的消息序号,计算出索引的全局位置(索引序号*索引固定⻓度 20),然后直接读取这条索引,再根据索引中记录的消息的全局位置,找到消息。
讲到这里,你可能对 RocketMQ
的存储架构还有些模糊,没事,我们结合着图来理解一下。
emmm,是不是有一点复杂 🤣,看英文图片和英文文档的时候就不要怂,硬着头皮往下看就行。
如果上面没看懂的读者一定要认真看下面的流程分析!
首先,在最上面的那一块就是我刚刚讲的你现在可以直接 **把 ConsumerQueue
理解为 Queue
**。
在图中最左边说明了红色方块代表被写入的消息,虚线方块代表等待被写入的。左边的生产者发送消息会指定 Topic
、QueueId
和具体消息内容,而在 Broker
中管你是哪门子消息,他直接 全部顺序存储到了 CommitLog。而根据生产者指定的 Topic
和 QueueId
将这条消息本身在 CommitLog
的偏移(offset),消息本身大小,和 tag 的 hash 值存入对应的 ConsumeQueue
索引文件中。而在每个队列中都保存了 ConsumeOffset
即每个消费者组的消费位置(我在架构那里提到了,忘了的同学可以回去看一下),而消费者拉取消息进行消费的时候只需要根据 ConsumeOffset
获取下一个未被消费的消息就行了。
上述就是我对于整个消息存储架构的大概理解(这里不涉及到一些细节讨论,比如稀疏索引等等问题),希望对你有帮助。
因为有一个知识点因为写嗨了忘讲了,想想在哪里加也不好,所以我留给大家去思考 🤔🤔 一下吧。
为什么 CommitLog
文件要设计成固定大小的长度呢?提醒:内存映射机制。
总结
总算把这篇博客写完了。我讲的你们还记得吗 😅?
这篇文章中我主要想大家介绍了
- 消息队列出现的原因
- 消息队列的作用(异步,解耦,削峰)
- 消息队列带来的一系列问题(消息堆积、重复消费、顺序消费、分布式事务等等)
- 消息队列的两种消息模型——队列和主题模式
- 分析了
RocketMQ
的技术架构(NameServer
、Broker
、Producer
、Consumer
) - 结合
RocketMQ
回答了消息队列副作用的解决方案 - 介绍了
RocketMQ
的存储机制和刷盘策略。
等等。。。
SSO 单点登录详解
本文授权转载自:https://ken.io/note/sso-design-implement 作者:ken.io
SSO 介绍
什么是 SSO?
SSO 英文全称 Single Sign On,单点登录。SSO 是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
例如你登录网易账号中心(https://reg.163.com/ )之后访问以下站点都是登录状态。
- 网易直播 https://v.163.com
- 网易博客 https://blog.163.com
- 网易花田 https://love.163.com
- 网易考拉 https://www.kaola.com
- 网易 Lofter http://www.lofter.com
SSO 有什么好处?
- 用户角度 :用户能够做到一次登录多次使用,无需记录多套用户名和密码,省心。
- 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。
- 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时。
SSO 设计与实现
本篇文章也主要是为了探讨如何设计&实现一个 SSO 系统
以下为需要实现的核心功能:
- 单点登录
- 单点登出
- 支持跨域单点登录
- 支持跨域单点登出
核心应用与依赖
应用/模块/对象 | 说明 |
---|---|
前台站点 | 需要登录的站点 |
SSO 站点-登录 | 提供登录的页面 |
SSO 站点-登出 | 提供注销登录的入口 |
SSO 服务-登录 | 提供登录服务 |
SSO 服务-登录状态 | 提供登录状态校验/登录信息查询的服务 |
SSO 服务-登出 | 提供用户注销登录的服务 |
数据库 | 存储用户账户信息 |
缓存 | 存储用户的登录信息,通常使用 Redis |
用户登录状态的存储与校验
常见的 Web 框架对于 Session 的实现都是生成一个 SessionId 存储在浏览器 Cookie 中。然后将 Session 内容存储在服务器端内存中,这个 ken.io 在之前Session 工作原理中也提到过。整体也是借鉴这个思路。
用户登录成功之后,生成 AuthToken 交给客户端保存。如果是浏览器,就保存在 Cookie 中。如果是手机 App 就保存在 App 本地缓存中。本篇主要探讨基于 Web 站点的 SSO。
用户在浏览需要登录的页面时,客户端将 AuthToken 提交给 SSO 服务校验登录状态/获取用户登录信息
对于登录信息的存储,建议采用 Redis,使用 Redis 集群来存储登录信息,既可以保证高可用,又可以线性扩充。同时也可以让 SSO 服务满足负载均衡/可伸缩的需求。
对象 | 说明 |
---|---|
AuthToken | 直接使用 UUID/GUID 即可,如果有验证 AuthToken 合法性需求,可以将 UserName+时间戳加密生成,服务端解密之后验证合法性 |
登录信息 | 通常是将 UserId,UserName 缓存起来 |
用户登录/登录校验
登录时序图
按照上图,用户登录后 AuthToken 保存在 Cookie 中。 domain=test.com
浏览器会将 domain 设置成 .test.com,
这样访问所有 *.test.com 的 web 站点,都会将 AuthToken 携带到服务器端。
然后通过 SSO 服务,完成对用户状态的校验/用户登录信息的获取
登录信息获取/登录状态校验
用户登出
用户登出时要做的事情很简单:
- 服务端清除缓存(Redis)中的登录状态
- 客户端清除存储的 AuthToken
登出时序图
跨域登录、登出
前面提到过,核心思路是客户端存储 AuthToken,服务器端通过 Redis 存储登录信息。由于客户端是将 AuthToken 存储在 Cookie 中的。所以跨域要解决的问题,就是如何解决 Cookie 的跨域读写问题。
解决跨域的核心思路就是:
- 登录完成之后通过回调的方式,将 AuthToken 传递给主域名之外的站点,该站点自行将 AuthToken 保存在当前域下的 Cookie 中。
- 登出完成之后通过回调的方式,调用非主域名站点的登出页面,完成设置 Cookie 中的 AuthToken 过期的操作。
跨域登录(主域名已登录)
跨域登录(主域名未登录)
跨域登出
说明
- 关于方案:这次设计方案更多是提供实现思路。如果涉及到 APP 用户登录等情况,在访问 SSO 服务时,增加对 APP 的签名验证就好了。当然,如果有无线网关,验证签名不是问题。
- 关于时序图:时序图中并没有包含所有场景,只列举了核心/主要场景,另外对于一些不影响理解思路的消息能省就省了。
SQL常见面试题总结(4)
题目来源于:牛客题霸 - SQL 进阶挑战
较难或者困难的题目可以根据自身实际情况和面试需要来决定是否要跳过。
专用窗口函数
MySQL 8.0 版本引入了窗口函数的支持,下面是 MySQL 中常见的窗口函数及其用法:
ROW_NUMBER()
: 为查询结果集中的每一行分配一个唯一的整数值。
1 | SELECT col1, col2, ROW_NUMBER() OVER (ORDER BY col1) AS row_num |
RANK()
: 计算每一行在排序结果中的排名。
1 | SELECT col1, col2, RANK() OVER (ORDER BY col1 DESC) AS ranking |
DENSE_RANK()
: 计算每一行在排序结果中的排名,保留相同的排名。
1 | SELECT col1, col2, DENSE_RANK() OVER (ORDER BY col1 DESC) AS ranking |
NTILE(n)
: 将结果分成 n 个基本均匀的桶,并为每个桶分配一个标识号。
1 | SELECT col1, col2, NTILE(4) OVER (ORDER BY col1) AS bucket |
SUM()
,AVG()
,COUNT()
,MIN()
,MAX()
: 这些聚合函数也可以与窗口函数结合使用,计算窗口内指定列的汇总、平均值、计数、最小值和最大值。
1 | SELECT col1, col2, SUM(col1) OVER () AS sum_col |
LEAD()
和LAG()
: LEAD 函数用于获取当前行之后的某个偏移量的行的值,而 LAG 函数用于获取当前行之前的某个偏移量的行的值。
1 | SELECT col1, col2, LEAD(col1, 1) OVER (ORDER BY col1) AS next_col1, |
FIRST_VALUE()
和LAST_VALUE()
: FIRST_VALUE 函数用于获取窗口内指定列的第一个值,LAST_VALUE 函数用于获取窗口内指定列的最后一个值。
1 | SELECT col1, col2, FIRST_VALUE(col2) OVER (PARTITION BY col1 ORDER BY col2) AS first_val, |
窗口函数通常需要配合 OVER 子句一起使用,用于定义窗口的大小、排序规则和分组方式。
每类试卷得分前三名
描述:
现有试卷信息表 examination_info
(exam_id
试卷 ID, tag
试卷类别, difficulty
试卷难度, duration
考试时长, release_time
发布时间):
id | exam_id | tag | difficulty | duration | release_time |
---|---|---|---|---|---|
1 | 9001 | SQL | hard | 60 | 2021-09-01 06:00:00 |
2 | 9002 | SQL | hard | 60 | 2021-09-01 06:00:00 |
3 | 9003 | 算法 | medium | 80 | 2021-09-01 10:00:00 |
试卷作答记录表 exam_record
(uid
用户 ID, exam_id
试卷 ID, start_time
开始作答时间, submit_time
交卷时间, score 得分):
id | uid | exam_id | start_time | submit_time | score |
---|---|---|---|---|---|
1 | 1001 | 9001 | 2021-09-01 09:01:01 | 2021-09-01 09:31:00 | 78 |
2 | 1002 | 9001 | 2021-09-01 09:01:01 | 2021-09-01 09:31:00 | 81 |
3 | 1002 | 9002 | 2021-09-01 12:01:01 | 2021-09-01 12:31:01 | 81 |
4 | 1003 | 9001 | 2021-09-01 19:01:01 | 2021-09-01 19:40:01 | 86 |
5 | 1003 | 9002 | 2021-09-01 12:01:01 | 2021-09-01 12:31:51 | 89 |
6 | 1004 | 9001 | 2021-09-01 19:01:01 | 2021-09-01 19:30:01 | 85 |
7 | 1005 | 9003 | 2021-09-01 12:01:01 | 2021-09-01 12:31:02 | 85 |
8 | 1006 | 9003 | 2021-09-07 10:01:01 | 2021-09-07 10:21:01 | 84 |
9 | 1003 | 9003 | 2021-09-08 12:01:01 | 2021-09-08 12:11:01 | 40 |
10 | 1003 | 9002 | 2021-09-01 14:01:01 | (NULL) | (NULL) |
找到每类试卷得分的前 3 名,如果两人最大分数相同,选择最小分数大者,如果还相同,选择 uid 大者。由示例数据结果输出如下:
tid | uid | ranking |
---|---|---|
SQL | 1003 | 1 |
SQL | 1004 | 2 |
SQL | 1002 | 3 |
算法 | 1005 | 1 |
算法 | 1006 | 2 |
算法 | 1003 | 3 |
解释:有作答得分记录的试卷 tag 有 SQL 和算法,SQL 试卷用户 1001、1002、1003、1004 有作答得分,最高得分分别为 81、81、89、85,最低得分分别为 78、81、86、40,因此先按最高得分排名再按最低得分排名取前三为 1003、1004、1002。
答案:
1 | SELECT tag, |
第二快/慢用时之差大于试卷时长一半的试卷(较难)
描述:
现有试卷信息表 examination_info
(exam_id
试卷 ID, tag
试卷类别, difficulty
试卷难度, duration
考试时长, release_time
发布时间):
id | exam_id | tag | difficulty | duration | release_time |
---|---|---|---|---|---|
1 | 9001 | SQL | hard | 60 | 2021-09-01 06:00:00 |
2 | 9002 | C++ | hard | 60 | 2021-09-01 06:00:00 |
3 | 9003 | 算法 | medium | 80 | 2021-09-01 10:00:00 |
试卷作答记录表 exam_record
(uid
用户 ID, exam_id
试卷 ID, start_time
开始作答时间, submit_time
交卷时间, score
得分):
id | uid | exam_id | start_time | submit_time | score |
---|---|---|---|---|---|
1 | 1001 | 9001 | 2021-09-01 09:01:01 | 2021-09-01 09:51:01 | 78 |
2 | 1001 | 9002 | 2021-09-01 09:01:01 | 2021-09-01 09:31:00 | 81 |
3 | 1002 | 9002 | 2021-09-01 12:01:01 | 2021-09-01 12:31:01 | 81 |
4 | 1003 | 9001 | 2021-09-01 19:01:01 | 2021-09-01 19:59:01 | 86 |
5 | 1003 | 9002 | 2021-09-01 12:01:01 | 2021-09-01 12:31:51 | 89 |
6 | 1004 | 9002 | 2021-09-01 19:01:01 | 2021-09-01 19:30:01 | 85 |
7 | 1005 | 9001 | 2021-09-01 12:01:01 | 2021-09-01 12:31:02 | 85 |
8 | 1006 | 9001 | 2021-09-07 10:02:01 | 2021-09-07 10:21:01 | 84 |
9 | 1003 | 9001 | 2021-09-08 12:01:01 | 2021-09-08 12:11:01 | 40 |
10 | 1003 | 9002 | 2021-09-01 14:01:01 | (NULL) | (NULL) |
11 | 1005 | 9001 | 2021-09-01 14:01:01 | (NULL) | (NULL) |
12 | 1003 | 9003 | 2021-09-08 15:01:01 | (NULL) | (NULL) |
找到第二快和第二慢用时之差大于试卷时长的一半的试卷信息,按试卷 ID 降序排序。由示例数据结果输出如下:
exam_id | duration | release_time |
---|---|---|
9001 | 60 | 2021-09-01 06:00:00 |
解释:试卷 9001 被作答用时有 50 分钟、58 分钟、30 分 1 秒、19 分钟、10 分钟,第二快和第二慢用时之差为 50 分钟-19 分钟=31 分钟,试卷时长为 60 分钟,因此满足大于试卷时长一半的条件,输出试卷 ID、时长、发布时间。
思路:
第一步,找到每张试卷完成时间的顺序排名和倒序排名 也就是表 a;
第二步,与通过试卷信息表 b 建立内连接,并根据试卷 id 分组,利用having
筛选排名为第二个数据,将秒转化为分钟并进行比较,最后再根据试卷 id 倒序排序就行
答案:
1 | SELECT a.exam_id, |
连续两次作答试卷的最大时间窗(较难)
描述
现有试卷作答记录表 exam_record
(uid
用户 ID, exam_id
试卷 ID, start_time
开始作答时间, submit_time
交卷时间, score
得分):
id | uid | exam_id | start_time | submit_time | score |
---|---|---|---|---|---|
1 | 1006 | 9003 | 2021-09-07 10:01:01 | 2021-09-07 10:21:02 | 84 |
2 | 1006 | 9001 | 2021-09-01 12:11:01 | 2021-09-01 12:31:01 | 89 |
3 | 1006 | 9002 | 2021-09-06 10:01:01 | 2021-09-06 10:21:01 | 81 |
4 | 1005 | 9002 | 2021-09-05 10:01:01 | 2021-09-05 10:21:01 | 81 |
5 | 1005 | 9001 | 2021-09-05 10:31:01 | 2021-09-05 10:51:01 | 81 |
请计算在 2021 年至少有两天作答过试卷的人中,计算该年连续两次作答试卷的最大时间窗 days_window
,那么根据该年的历史规律他在 days_window
天里平均会做多少套试卷,按最大时间窗和平均做答试卷套数倒序排序。由示例数据结果输出如下:
uid | days_window | avg_exam_cnt |
---|---|---|
1006 | 6 | 2.57 |
解释:用户 1006 分别在 20210901、20210906、20210907 作答过 3 次试卷,连续两次作答最大时间窗为 6 天(1 号到 6 号),他 1 号到 7 号这 7 天里共做了 3 张试卷,平均每天 3/7=0.428571 张,那么 6 天里平均会做 0.428571*6=2.57 张试卷(保留两位小数);用户 1005 在 20210905 做了两张试卷,但是只有一天的作答记录,过滤掉。
思路:
上面这个解释中提示要对作答记录去重,千万别被骗了,不要去重!去重就通不过测试用例。注意限制时间是 2021 年;
而且要注意时间差要+1 天;还要注意==没交卷也算在内==!!!! (反正感觉这题描述不清,出的不是很好)
答案:
1 | SELECT UID, |
近三个月未完成为 0 的用户完成情况
描述:
现有试卷作答记录表 exam_record
(uid
:用户 ID, exam_id
:试卷 ID, start_time
:开始作答时间, submit_time
:交卷时间,为空的话则代表未完成, score
:得分):
id | uid | exam_id | start_time | submit_time | score |
---|---|---|---|---|---|
1 | 1006 | 9003 | 2021-09-06 10:01:01 | 2021-09-06 10:21:02 | 84 |
2 | 1006 | 9001 | 2021-08-02 12:11:01 | 2021-08-02 12:31:01 | 89 |
3 | 1006 | 9002 | 2021-06-06 10:01:01 | 2021-06-06 10:21:01 | 81 |
4 | 1006 | 9002 | 2021-05-06 10:01:01 | 2021-05-06 10:21:01 | 81 |
5 | 1006 | 9001 | 2021-05-01 12:01:01 | (NULL) | (NULL) |
6 | 1001 | 9001 | 2021-09-05 10:31:01 | 2021-09-05 10:51:01 | 81 |
7 | 1001 | 9003 | 2021-08-01 09:01:01 | 2021-08-01 09:51:11 | 78 |
8 | 1001 | 9002 | 2021-07-01 09:01:01 | 2021-07-01 09:31:00 | 81 |
9 | 1001 | 9002 | 2021-07-01 12:01:01 | 2021-07-01 12:31:01 | 81 |
10 | 1001 | 9002 | 2021-07-01 12:01:01 | (NULL) | (NULL) |
找到每个人近三个有试卷作答记录的月份中没有试卷是未完成状态的用户的试卷作答完成数,按试卷完成数和用户 ID 降序排名。由示例数据结果输出如下:
uid | exam_complete_cnt |
---|---|
1006 | 3 |
解释:用户 1006 近三个有作答试卷的月份为 202109、202108、202106,作答试卷数为 3,全部完成;用户 1001 近三个有作答试卷的月份为 202109、202108、202107,作答试卷数为 5,完成试卷数为 4,因为有未完成试卷,故过滤掉。
思路:
找到每个人近三个有试卷作答记录的月份中没有试卷是未完成状态的用户的试卷作答完成数
首先看这句话,肯定要先根据人进行分组- 最近三个月,可以采用连续重复排名,倒序排列,排名<=3
- 统计作答数
- 拼装剩余条件
- 排序
答案:
1 | SELECT UID, |
未完成率较高的 50%用户近三个月答卷情况(困难)
描述:
现有用户信息表 user_info
(uid
用户 ID,nick_name
昵称, achievement
成就值, level
等级, job
职业方向, register_time
注册时间):
id | uid | nick_name | achievement | level | job | register_time |
---|---|---|---|---|---|---|
1 | 1001 | 牛客 1 号 | 3200 | 7 | 算法 | 2020-01-01 10:00:00 |
2 | 1002 | 牛客 2 号 | 2500 | 6 | 算法 | 2020-01-01 10:00:00 |
3 | 1003 | 牛客 3 号 ♂ | 2200 | 5 | 算法 | 2020-01-01 10:00:00 |
试卷信息表 examination_info
(exam_id
试卷 ID, tag
试卷类别, difficulty
试卷难度, duration
考试时长, release_time
发布时间):
id | exam_id | tag | difficulty | duration | release_time |
---|---|---|---|---|---|
1 | 9001 | SQL | hard | 60 | 2020-01-01 10:00:00 |
2 | 9002 | SQL | hard | 80 | 2020-01-01 10:00:00 |
3 | 9003 | 算法 | hard | 80 | 2020-01-01 10:00:00 |
4 | 9004 | PYTHON | medium | 70 | 2020-01-01 10:00:00 |
试卷作答记录表 exam_record
(uid
用户 ID, exam_id
试卷 ID, start_time
开始作答时间, submit_time
交卷时间, score
得分):
id | uid | exam_id | start_time | submit_time | score |
---|---|---|---|---|---|
1 | 1001 | 9001 | 2020-01-01 09:01:01 | 2020-01-01 09:21:59 | 90 |
15 | 1002 | 9001 | 2020-01-01 18:01:01 | 2020-01-01 18:59:02 | 90 |
13 | 1001 | 9001 | 2020-01-02 10:01:01 | 2020-01-02 10:31:01 | 89 |
2 | 1002 | 9001 | 2020-01-20 10:01:01 | ||
3 | 1002 | 9001 | 2020-02-01 12:11:01 | ||
5 | 1001 | 9001 | 2020-03-01 12:01:01 | ||
6 | 1002 | 9001 | 2020-03-01 12:01:01 | 2020-03-01 12:41:01 | 90 |
4 | 1003 | 9001 | 2020-03-01 19:01:01 | ||
7 | 1002 | 9001 | 2020-05-02 19:01:01 | 2020-05-02 19:32:00 | 90 |
14 | 1001 | 9002 | 2020-01-01 12:11:01 | ||
8 | 1001 | 9002 | 2020-01-02 19:01:01 | 2020-01-02 19:59:01 | 69 |
9 | 1001 | 9002 | 2020-02-02 12:01:01 | 2020-02-02 12:20:01 | 99 |
10 | 1002 | 9002 | 2020-02-02 12:01:01 | ||
11 | 1002 | 9002 | 2020-02-02 12:01:01 | 2020-02-02 12:43:01 | 81 |
12 | 1002 | 9002 | 2020-03-02 12:11:01 | ||
17 | 1001 | 9002 | 2020-05-05 18:01:01 | ||
16 | 1002 | 9003 | 2020-05-06 12:01:01 |
请统计 SQL 试卷上未完成率较高的 50%用户中,6 级和 7 级用户在有试卷作答记录的近三个月中,每个月的答卷数目和完成数目。按用户 ID、月份升序排序。
由示例数据结果输出如下:
uid | start_month | total_cnt | complete_cnt |
---|---|---|---|
1002 | 202002 | 3 | 1 |
1002 | 202003 | 2 | 1 |
1002 | 202005 | 2 | 1 |
解释:各个用户对 SQL 试卷的未完成数、作答总数、未完成率如下:
uid | incomplete_cnt | total_cnt | incomplete_rate |
---|---|---|---|
1001 | 3 | 7 | 0.4286 |
1002 | 4 | 8 | 0.5000 |
1003 | 1 | 1 | 1.0000 |
1001、1002、1003 分别排在 1.0、0.5、0.0 的位置,因此较高的 50%用户(排位<=0.5)为 1002、1003;
1003 不是 6 级或 7 级;
有试卷作答记录的近三个月为 202005、202003、202002;
这三个月里 1002 的作答题数分别为 3、2、2,完成数目分别为 1、1、1。
思路:
注意点:这题注意求的是所有的答题次数和完成次数,而 sql 类别的试卷是限制未完成率排名,6, 7 级用户限制的是做题记录。
先求出未完成率的排名
1 | SELECT UID, |
再求出最近三个月的练习记录
1 | SELECT UID, |
答案:
1 | SELECT t1.uid, |
试卷完成数同比 2020 年的增长率及排名变化(困难)
描述:
现有试卷信息表 examination_info
(exam_id
试卷 ID, tag
试卷类别, difficulty
试卷难度, duration
考试时长, release_time
发布时间):
id | exam_id | tag | difficulty | duration | release_time |
---|---|---|---|---|---|
1 | 9001 | SQL | hard | 60 | 2021-01-01 10:00:00 |
2 | 9002 | C++ | hard | 80 | 2021-01-01 10:00:00 |
3 | 9003 | 算法 | hard | 80 | 2021-01-01 10:00:00 |
4 | 9004 | PYTHON | medium | 70 | 2021-01-01 10:00:00 |
试卷作答记录表 exam_record
(uid
用户 ID, exam_id
试卷 ID, start_time
开始作答时间, submit_time
交卷时间, score
得分):
id | uid | exam_id | start_time | submit_time | score |
---|---|---|---|---|---|
1 | 1001 | 9001 | 2020-08-02 10:01:01 | 2020-08-02 10:31:01 | 89 |
2 | 1002 | 9001 | 2020-04-01 18:01:01 | 2020-04-01 18:59:02 | 90 |
3 | 1001 | 9001 | 2020-04-01 09:01:01 | 2020-04-01 09:21:59 | 80 |
5 | 1002 | 9001 | 2021-03-02 19:01:01 | 2021-03-02 19:32:00 | 20 |
8 | 1003 | 9001 | 2021-05-02 12:01:01 | 2021-05-02 12:31:01 | 98 |
13 | 1003 | 9001 | 2020-01-02 10:01:01 | 2020-01-02 10:31:01 | 89 |
9 | 1001 | 9002 | 2020-02-02 12:01:01 | 2020-02-02 12:20:01 | 99 |
10 | 1002 | 9002 | 2021-02-02 12:01:01 | 2020-02-02 12:43:01 | 81 |
11 | 1001 | 9002 | 2020-01-02 19:01:01 | 2020-01-02 19:59:01 | 69 |
16 | 1002 | 9002 | 2020-02-02 12:01:01 | ||
17 | 1002 | 9002 | 2020-03-02 12:11:01 | ||
18 | 1001 | 9002 | 2021-05-05 18:01:01 | ||
4 | 1002 | 9003 | 2021-01-20 10:01:01 | 2021-01-20 10:10:01 | 81 |
6 | 1001 | 9003 | 2021-04-02 19:01:01 | 2021-04-02 19:40:01 | 89 |
15 | 1002 | 9003 | 2021-01-01 18:01:01 | 2021-01-01 18:59:02 | 90 |
7 | 1004 | 9004 | 2020-05-02 12:01:01 | 2020-05-02 12:20:01 | 99 |
12 | 1001 | 9004 | 2021-09-02 12:11:01 | ||
14 | 1002 | 9004 | 2020-01-01 12:11:01 | 2020-01-01 12:31:01 | 83 |
请计算 2021 年上半年各类试卷的做完次数相比 2020 年上半年同期的增长率(百分比格式,保留 1 位小数),以及做完次数排名变化,按增长率和 21 年排名降序输出。
由示例数据结果输出如下:
tag | exam_cnt_20 | exam_cnt_21 | growth_rate | exam_cnt_rank_20 | exam_cnt_rank_21 | rank_delta |
---|---|---|---|---|---|---|
SQL | 3 | 2 | -33.3% | 1 | 2 | 1 |
解释:2020 年上半年有 3 个 tag 有作答完成的记录,分别是 C++、SQL、PYTHON,它们被做完的次数分别是 3、3、2,做完次数排名为 1、1(并列)、3;
2021 年上半年有 2 个 tag 有作答完成的记录,分别是算法、SQL,它们被做完的次数分别是 3、2,做完次数排名为 1、2;具体如下:
tag | start_year | exam_cnt | exam_cnt_rank |
---|---|---|---|
C++ | 2020 | 3 | 1 |
SQL | 2020 | 3 | 1 |
PYTHON | 2020 | 2 | 3 |
算法 | 2021 | 3 | 1 |
SQL | 2021 | 2 | 2 |
因此能输出同比结果的 tag 只有 SQL,从 2020 到 2021 年,做完次数 3=>2,减少 33.3%(保留 1 位小数);排名 1=>2,后退 1 名。
思路:
本题难点在于长整型的数据类型要求不能有负号产生,用 cast 函数转换数据类型为 signed。
以及用到的增长率计算公式:(exam_cnt_21-exam_cnt_20)/exam_cnt_20
做完次数排名变化(2021 年和 2020 年比排名升了或者降了多少)
计算公式:exam_cnt_rank_21 - exam_cnt_rank_20
在 MySQL 中,CAST()
函数用于将一个表达式的数据类型转换为另一个数据类型。它的基本语法如下:
1 | CAST(expression AS data_type) |
示例就不一一举例了,这个函数很简单
答案:
1 | SELECT |
聚合窗口函数
对试卷得分做 min-max 归一化
描述:
现有试卷信息表 examination_info
(exam_id
试卷 ID, tag
试卷类别, difficulty
试卷难度, duration
考试时长, release_time
发布时间):
id | exam_id | tag | difficulty | duration | release_time |
---|---|---|---|---|---|
1 | 9001 | SQL | hard | 60 | 2020-01-01 10:00:00 |
2 | 9002 | C++ | hard | 80 | 2020-01-01 10:00:00 |
3 | 9003 | 算法 | hard | 80 | 2020-01-01 10:00:00 |
4 | 9004 | PYTHON | medium | 70 | 2020-01-01 10:00:00 |
试卷作答记录表 exam_record
(uid
用户 ID, exam_id
试卷 ID, start_time
开始作答时间, submit_time
交卷时间, score
得分):
id | uid | exam_id | start_time | submit_time | score |
---|---|---|---|---|---|
6 | 1003 | 9001 | 2020-01-02 12:01:01 | 2020-01-02 12:31:01 | 68 |
9 | 1001 | 9001 | 2020-01-02 10:01:01 | 2020-01-02 10:31:01 | 89 |
1 | 1001 | 9001 | 2020-01-01 09:01:01 | 2020-01-01 09:21:59 | 90 |
12 | 1002 | 9002 | 2021-05-05 18:01:01 | (NULL) | (NULL) |
3 | 1004 | 9002 | 2020-01-01 12:01:01 | 2020-01-01 12:11:01 | 60 |
2 | 1003 | 9002 | 2020-01-01 19:01:01 | 2020-01-01 19:30:01 | 75 |
7 | 1001 | 9002 | 2020-01-02 12:01:01 | 2020-01-02 12:43:01 | 81 |
10 | 1002 | 9002 | 2020-01-01 12:11:01 | 2020-01-01 12:31:01 | 83 |
4 | 1003 | 9002 | 2020-01-01 12:01:01 | 2020-01-01 12:41:01 | 90 |
5 | 1002 | 9002 | 2020-01-02 19:01:01 | 2020-01-02 19:32:00 | 90 |
11 | 1002 | 9004 | 2021-09-06 12:01:01 | (NULL) | (NULL) |
8 | 1001 | 9005 | 2020-01-02 12:11:01 | (NULL) | (NULL) |
在物理学及统计学数据计算时,有个概念叫 min-max 标准化,也被称为离差标准化,是对原始数据的线性变换,使结果值映射到[0 - 1]之间。
转换函数为:
请你将用户作答高难度试卷的得分在每份试卷作答记录内执行 min-max 归一化后缩放到[0,100]区间,并输出用户 ID、试卷 ID、归一化后分数平均值;最后按照试卷 ID 升序、归一化分数降序输出。(注:得分区间默认为[0,100],如果某个试卷作答记录中只有一个得分,那么无需使用公式,归一化并缩放后分数仍为原分数)。
由示例数据结果输出如下:
uid | exam_id | avg_new_score |
---|---|---|
1001 | 9001 | 98 |
1003 | 9001 | 0 |
1002 | 9002 | 88 |
1003 | 9002 | 75 |
1001 | 9002 | 70 |
1004 | 9002 | 0 |
解释:高难度试卷有 9001、9002、9003;
作答了 9001 的记录有 3 条,分数分别为 68、89、90,按给定公式归一化后分数为:0、95、100,而后两个得分都是用户 1001 作答的,因此用户 1001 对试卷 9001 的新得分为(95+100)/2≈98(只保留整数部分),用户 1003 对于试卷 9001 的新得分为 0。最后结果按照试卷 ID 升序、归一化分数降序输出。
思路:
注意点:
- 将高难度的试卷,按每类试卷的得分,利用 max/min (col) over()窗口函数求得各组内最大最小值,然后进行归一化公式计算,缩放区间为[0,100],即 min_max*100
- 若某类试卷只有一个得分,则无需使用归一化公式,因只有一个分 max_score=min_score,score,公式后结果可能会变成 0。
- 最后结果按 uid、exam_id 分组求归一化后均值,score 为 NULL 的要过滤掉。
最后就是仔细看上面公式 (说实话,这题看起来就很绕)
答案:
1 | SELECT |
每份试卷每月作答数和截止当月的作答总数
描述:
现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):
id | uid | exam_id | start_time | submit_time | score |
---|---|---|---|---|---|
1 | 1001 | 9001 | 2020-01-01 09:01:01 | 2020-01-01 09:21:59 | 90 |
2 | 1002 | 9001 | 2020-01-20 10:01:01 | 2020-01-20 10:10:01 | 89 |
3 | 1002 | 9001 | 2020-02-01 12:11:01 | 2020-02-01 12:31:01 | 83 |
4 | 1003 | 9001 | 2020-03-01 19:01:01 | 2020-03-01 19:30:01 | 75 |
5 | 1004 | 9001 | 2020-03-01 12:01:01 | 2020-03-01 12:11:01 | 60 |
6 | 1003 | 9001 | 2020-03-01 12:01:01 | 2020-03-01 12:41:01 | 90 |
7 | 1002 | 9001 | 2020-05-02 19:01:01 | 2020-05-02 19:32:00 | 90 |
8 | 1001 | 9002 | 2020-01-02 19:01:01 | 2020-01-02 19:59:01 | 69 |
9 | 1004 | 9002 | 2020-02-02 12:01:01 | 2020-02-02 12:20:01 | 99 |
10 | 1003 | 9002 | 2020-02-02 12:01:01 | 2020-02-02 12:31:01 | 68 |
11 | 1001 | 9002 | 2020-02-02 12:01:01 | 2020-02-02 12:43:01 | 81 |
12 | 1001 | 9002 | 2020-03-02 12:11:01 | (NULL) | (NULL) |
请输出每份试卷每月作答数和截止当月的作答总数。
由示例数据结果输出如下:
exam_id | start_month | month_cnt | cum_exam_cnt |
---|---|---|---|
9001 | 202001 | 2 | 2 |
9001 | 202002 | 1 | 3 |
9001 | 202003 | 3 | 6 |
9001 | 202005 | 1 | 7 |
9002 | 202001 | 1 | 1 |
9002 | 202002 | 3 | 4 |
9002 | 202003 | 1 | 5 |
解释:试卷 9001 在 202001、202002、202003、202005 共 4 个月有被作答记录,每个月被作答数分别为 2、1、3、1,截止当月累积作答总数为 2、3、6、7。
思路:
这题就两个关键点:统计截止当月的作答总数、输出每份试卷每月作答数和截止当月的作答总数
这个是关键**sum(count(*)) over(partition by exam_id order by date_format(start_time,'%Y%m'))**
答案:
1 | SELECT exam_id, |
每月及截止当月的答题情况(较难)
描述:现有试卷作答记录表 exam_record
(uid
用户 ID, exam_id
试卷 ID, start_time
开始作答时间, submit_time
交卷时间, score
得分):
id | uid | exam_id | start_time | submit_time | score |
---|---|---|---|---|---|
1 | 1001 | 9001 | 2020-01-01 09:01:01 | 2020-01-01 09:21:59 | 90 |
2 | 1002 | 9001 | 2020-01-20 10:01:01 | 2020-01-20 10:10:01 | 89 |
3 | 1002 | 9001 | 2020-02-01 12:11:01 | 2020-02-01 12:31:01 | 83 |
4 | 1003 | 9001 | 2020-03-01 19:01:01 | 2020-03-01 19:30:01 | 75 |
5 | 1004 | 9001 | 2020-03-01 12:01:01 | 2020-03-01 12:11:01 | 60 |
6 | 1003 | 9001 | 2020-03-01 12:01:01 | 2020-03-01 12:41:01 | 90 |
7 | 1002 | 9001 | 2020-05-02 19:01:01 | 2020-05-02 19:32:00 | 90 |
8 | 1001 | 9002 | 2020-01-02 19:01:01 | 2020-01-02 19:59:01 | 69 |
9 | 1004 | 9002 | 2020-02-02 12:01:01 | 2020-02-02 12:20:01 | 99 |
10 | 1003 | 9002 | 2020-02-02 12:01:01 | 2020-02-02 12:31:01 | 68 |
11 | 1001 | 9002 | 2020-01-02 19:01:01 | 2020-02-02 12:43:01 | 81 |
12 | 1001 | 9002 | 2020-03-02 12:11:01 | (NULL) | (NULL) |
请输出自从有用户作答记录以来,每月的试卷作答记录中月活用户数、新增用户数、截止当月的单月最大新增用户数、截止当月的累积用户数。结果按月份升序输出。
由示例数据结果输出如下:
start_month | mau | month_add_uv | max_month_add_uv | cum_sum_uv |
---|---|---|---|---|
202001 | 2 | 2 | 2 | 2 |
202002 | 4 | 2 | 2 | 4 |
202003 | 3 | 0 | 2 | 4 |
202005 | 1 | 0 | 2 | 4 |
month | 1001 | 1002 | 1003 | 1004 |
---|---|---|---|---|
202001 | 1 | 1 | ||
202002 | 1 | 1 | 1 | 1 |
202003 | 1 | 1 | 1 | |
202005 | 1 |
由上述矩阵可以看出,2020 年 1 月有 2 个用户活跃(mau=2),当月新增用户数为 2;
2020 年 2 月有 4 个用户活跃,当月新增用户数为 2,最大单月新增用户数为 2,当前累积用户数为 4。
思路:
难点:
1.如何求每月新增用户
2.截至当月的答题情况
大致流程:
(1)统计每个人的首次登陆月份 min()
(2)统计每月的月活和新增用户数:先得到每个人的首次登陆月份,再对首次登陆月份分组求和是该月份的新增人数
(3)统计截止当月的单月最大新增用户数、截止当月的累积用户数 ,最终按照按月份升序输出
答案:
1 | -- 截止当月的单月最大新增用户数、截止当月的累积用户数,按月份升序输出 |
几道常见的字符串算法题
作者:wwwxmu
1. KMP 算法
谈到字符串问题,不得不提的就是 KMP 算法,它是用来解决字符串查找的问题,可以在一个字符串(S)中查找一个子串(W)出现的位置。KMP 算法把字符匹配的时间复杂度缩小到 O(m+n) ,而空间复杂度也只有 O(m)。因为“暴力搜索”的方法会反复回溯主串,导致效率低下,而 KMP 算法可以利用已经部分匹配这个有效信息,保持主串上的指针不回溯,通过修改子串的指针,让模式串尽量地移动到有效的位置。
具体算法细节请参考:
除此之外,再来了解一下 BM 算法!
BM 算法也是一种精确字符串匹配算法,它采用从右向左比较的方法,同时应用到了两种启发式规则,即坏字符规则 和好后缀规则 ,来决定向右跳跃的距离。基本思路就是从右往左进行字符匹配,遇到不匹配的字符后从坏字符表和好后缀表找一个最大的右移值,将模式串右移继续匹配。
《字符串匹配的 KMP 算法》:http://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html
2. 替换空格
剑指 offer:请实现一个函数,将一个字符串中的每个空格替换成“%20”。例如,当字符串为 We Are Happy.则经过替换之后的字符串为 We%20Are%20Happy。
这里我提供了两种方法:① 常规方法;② 利用 API 解决。
1 | //https://www.weiweiblog.cn/replacespace/ |
对于替换固定字符(比如空格)的情况,第二种方法其实可以使用 replace
方法替换,性能更好!
1 | str.toString().replace(" ","%20"); |
3. 最长公共前缀
Leetcode: 编写一个函数来查找字符串数组中的最长公共前缀。如果不存在公共前缀,返回空字符串 “”。
示例 1:
1 | 输入: ["flower","flow","flight"] |
示例 2:
1 | 输入: ["dog","racecar","car"] |
思路很简单!先利用 Arrays.sort(strs)为数组排序,再将数组第一个元素和最后一个元素的字符从前往后对比即可!
1 | public class Main { |
4. 回文串
4.1. 最长回文串
LeetCode: 给定一个包含大写字母和小写字母的字符串,找到通过这些字母构造成的最长的回文串。在构造过程中,请注意区分大小写。比如
"Aa"
不能当做一个回文字符串。注
意:假设字符串的长度不会超过 1010。回文串:“回文串”是一个正读和反读都一样的字符串,比如“level”或者“noon”等等就是回文串。——百度百科 地址:https://baike.baidu.com/item/%E5%9B%9E%E6%96%87%E4%B8%B2/1274921?fr=aladdin
示例 1:
1 | 输入: |
我们上面已经知道了什么是回文串?现在我们考虑一下可以构成回文串的两种情况:
- 字符出现次数为双数的组合
- 字符出现次数为偶数的组合+单个字符中出现次数最多且为奇数次的字符 (参见 issue665 )
统计字符出现的次数即可,双数才能构成回文。因为允许中间一个数单独出现,比如“abcba”,所以如果最后有字母落单,总长度可以加 1。首先将字符串转变为字符数组。然后遍历该数组,判断对应字符是否在 hashset 中,如果不在就加进去,如果在就让 count++,然后移除该字符!这样就能找到出现次数为双数的字符个数。
1 | //https://leetcode-cn.com/problems/longest-palindrome/description/ |
4.2. 验证回文串
LeetCode: 给定一个字符串,验证它是否是回文串,只考虑字母和数字字符,可以忽略字母的大小写。 说明:本题中,我们将空字符串定义为有效的回文串。
示例 1:
1 | 输入: "A man, a plan, a canal: Panama" |
示例 2:
1 | 输入: "race a car" |
1 | //https://leetcode-cn.com/problems/valid-palindrome/description/ |
4.3. 最长回文子串
Leetcode: LeetCode: 最长回文子串 给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
1 | 输入: "babad" |
示例 2:
1 | 输入: "cbbd" |
以某个元素为中心,分别计算偶数长度的回文最大长度和奇数长度的回文最大长度。
1 | //https://leetcode-cn.com/problems/longest-palindromic-substring/description/ |
4.4. 最长回文子序列
LeetCode: 最长回文子序列
给定一个字符串 s,找到其中最长的回文子序列。可以假设 s 的最大长度为 1000。
最长回文子序列和上一题最长回文子串的区别是,子串是字符串中连续的一个序列,而子序列是字符串中保持相对位置的字符序列,例如,”bbbb”可以是字符串”bbbab”的子序列但不是子串。
给定一个字符串 s,找到其中最长的回文子序列。可以假设 s 的最大长度为 1000。
示例 1:
1 | 输入: |
一个可能的最长回文子序列为 “bbbb”。
示例 2:
1 | 输入: |
一个可能的最长回文子序列为 “bb”。
动态规划: dp[i][j] = dp[i+1][j-1] + 2 if s.charAt(i) == s.charAt(j) otherwise, dp[i][j] = Math.max(dp[i+1][j], dp[i][j-1])
1 | class Solution { |
5. 括号匹配深度
爱奇艺 2018 秋招 Java:
一个合法的括号匹配序列有以下定义:
- 空串””是一个合法的括号匹配序列
- 如果”X”和”Y”都是合法的括号匹配序列,”XY”也是一个合法的括号匹配序列
- 如果”X”是一个合法的括号匹配序列,那么”(X)”也是一个合法的括号匹配序列
- 每个合法的括号序列都可以由以上规则生成。
例如: “”,”()”,”()()”,”((()))”都是合法的括号序列
对于一个合法的括号序列我们又有以下定义它的深度:
- 空串””的深度是 0
- 如果字符串”X”的深度是 x,字符串”Y”的深度是 y,那么字符串”XY”的深度为 max(x,y)
- 如果”X”的深度是 x,那么字符串”(X)”的深度是 x+1
例如: “()()()”的深度是 1,”((()))”的深度是 3。牛牛现在给你一个合法的括号序列,需要你计算出其深度。
1 | 输入描述: |
示例:
1 | 输入: |
代码如下:
1 | import java.util.Scanner; |
6. 把字符串转换成整数
剑指 offer: 将一个字符串转换成一个整数(实现 Integer.valueOf(string)的功能,但是 string 不符合数字要求时返回 0),要求不能使用字符串转换整数的库函数。 数值为 0 或者字符串不是一个合法的数值则返回 0。
1 | //https://www.weiweiblog.cn/strtoint/ |
区间合并
合并两个重叠区间
leetcode 56 合并区间
排序然后从左到右比较讨论即可
合并两个排好序的数组
即归并排序中的归并
进一步扩展为多路归并:【多路归并】从朴素优先队列到多路归并、 373. 查找和最小的 K 对数字、786. 第 K 个最小的素数分数
往往有很多变体,需要对原来有序的数组进行进一步的处理,比如373. 查找和最小的 K 对数字, 786. 第 K 个最小的素数分数 .
求交集
leetcode 452. 用最少数量的箭引爆气球
同样,排好序,从左到右。