| 题目 | 简述、方案、死人坑 |
|---|---|
| 无重复字符的最长子串 | 这里我们用双指针加哈希表或集合,遇到就循环移除 |
| 反转链表 | 迭代法:nxt->cur.next->nxt.next->pre.next->nxt 递归法:双终止->递归下一个->头下下->头下 |
| LRU 缓存 | 哈希表加上双向链表,理论不难但是特别容易出错。我的想法是把只实现两个辅助函数,一个add一个remove,其中每一个函数要封装到一起,不要把功能拆出去。哈希表用的是del hash[key]。 |
| 数组中的第K个最大元素 | 快选法:分治,分成大于和小于pivot点的两部分 分割 pivot用的是l和r指针,终止后j指针指向左侧边界(包含)最大堆:建立一个堆,需要实现以下几个函数, down+pop+bigger+swap+son+parent+序列建堆,另外up和push可以不用 |
| K 个一组翻转链表 | 迭代法:nxt->cur.next->nxt.next->pre.next->nxt,先遍历一遍找到group组数 |
| 三数之和 | 先排序,再双指针,中间记得去重 对 i去重,我们只要第一个不同的i,其他的全部略过对 j和k去重,我们如果命中,则j和k都要更新到不同的下一个 |
| 最大子数组和 | Kadane算法,用short和long来遍历short = max(num, num + short)long = max(long, short) |
| 手撕快排 | 循环完要分治递归啊!!! 血泪教训,l->j和 j+1->r |
| 合并两个有序链表 | 罕见的简单题 |
| 最长回文子串 | 扩展法:最容易错的是指针方向,这两个是往外走,l, r = l - 1, r + 1动态规划:也可以,但是和扩展法的复杂度是一样的 |
无重复字符的最长子串
题目描述
给定一个字符串 s,请你找出其中不含有重复字符的 最长子串 的长度。
方案一:双指针+哈希集合
这道题可以使用 滑动窗口 的思想来解决。滑动窗口是一种常见的用于解决数组/字符串子串问题的技巧。我们维护一个窗口,窗口的左右边界分别是 left 和 right,表示当前子串的起始和结束位置。我们使用一个哈希集合(set)来记录当前窗口内的字符。
- 初始化:
left和right都从 0 开始,表示窗口的初始位置。max_len用于记录最大子串的长度。 - 移动右边界:将
s[right]加入哈希集合,并向右移动right。 - 处理重复字符:如果
s[right]已经在哈希集合中,说明遇到了重复字符,此时需要移动left指针,直到将重复字符移出窗口。 - 更新最大长度:在每一步移动
right后,更新max_len。
|
|
方案二:双指针 + 哈希映射
思路:使用哈希映射(字典)来记录字符最后一次出现的位置。当遇到重复字符时,left 指针可以直接跳转到重复字符的下一个位置,而不需要逐步移动。
|
|
时空复杂度分析
- 时间复杂度:
O(n),其中n是字符串的长度。left和right指针最多各移动n次。 - 空间复杂度:
O(min(m, n)),其中m是字符集的大小。在最坏情况下,哈希集合或哈希表的大小为min(m, n)。
LRU缓存
题目描述
请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 LRUCache 类:
LRUCache(int capacity)以 正整数 作为容量capacity初始化 LRU 缓存int get(int key)如果关键字key存在于缓存中,则返回关键字的值,否则返回-1。void put(int key, int value)如果关键字key已经存在,则变更其数据值value;如果不存在,则向缓存中插入该组key-value。如果插入操作导致关键字数量超过capacity,则应该 逐出 最久未使用的关键字。
函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。
方案:哈希表+双向链表
为了实现 O(1) 时间复杂度的 get 和 put 操作,我们需要结合 哈希表(快速查找)和 双向链表(维护数据顺序)来实现 LRU 缓存。具体思路如下:
-
双向链表:
- 链表中的节点按访问时间排序,最近访问的节点放在链表头部,最近最少使用的节点放在链表尾部。
- 链表的插入和删除操作时间复杂度为
O(1)。
-
哈希表:
- 哈希表存储键和对应的链表节点的指针,便于快速查找和更新。
-
操作逻辑:
get(key):如果键存在,通过哈希表找到节点,将其移动到链表头部(表示最近使用),并返回值;如果键不存在,返回-1。put(key, value):如果键存在,更新节点的值,并将其移动到链表头部;如果键不存在,创建新节点并插入链表头部,同时检查容量是否超过限制,如果超过则删除链表尾部的节点。
|
|
时空复杂度分析
- 时间复杂度:
get和put操作的时间复杂度均为O(1),因为哈希表的查找、插入和删除操作均为O(1),双向链表的插入和删除操作也为O(1)。
- 空间复杂度:
O(capacity),其中capacity是缓存的容量。哈希表和双向链表的空间复杂度均为O(capacity)。
易错点
- 哈希表与链表的同步更新:别他妈忘了这两个要同步更新
- 哈希表的删除和添加:这个贼容易错,我之前就错过
反转链表
题目描述
给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。
方法一:当前不动,后移动到前
|
|
时空复杂度
- 时间复杂度: O(n),其中 n 是链表的长度。我们需要遍历链表中的每个节点一次。
- 空间复杂度: O(1),我们只使用了常数个额外的指针,没有使用额外的空间。
易错点
- 边界条件处理:如果链表为空,直接返回
head。 - 指针操作顺序:在反转节点时,指针的操作顺序非常重要,错误的顺序可能导致链表断裂或死循环。
- 返回值:最终返回的是
dummy.next,而不是prv或cur。
方法二:递归法
|
|
举例说明
假设链表为 1 -> 2 -> 3 -> 4 -> 5,递归过程如下:
- 递归到
5,满足终止条件,返回5。 - 回溯到
4:4.next.next = 4,即5.next = 4。4.next = None。 链表变为:5 -> 4。
- 回溯到
3:3.next.next = 3,即4.next = 3。3.next = None。 链表变为:5 -> 4 -> 3。
- 依此类推,最终链表变为:
5 -> 4 -> 3 -> 2 -> 1。
时空复杂度
- 时间复杂度: O(n),其中 n 是链表的长度。递归会遍历链表中的每个节点一次。
- 空间复杂度: O(n),递归调用栈的深度与链表长度成正比。
易错点
- 递归终止条件:必须判断
if not head or not head.next,否则会导致空指针异常或无限递归。 - 链表成环:在反转指针后,一定要将当前节点的
next置为None,否则链表会成环。 - 返回值的理解:返回值是反转后的新链表的头节点(即原链表的最后一个节点),而不是当前节点。
数组中的第K个最大元素
题目描述
给定整数数组 nums 和整数 k,请返回数组中第 **k** 个最大的元素。
请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。
方法1:快速选择
- 快速选择算法:通过选择一个基准值(pivot),将数组分为两部分,左边部分的元素都大于等于基准值,右边部分的元素都小于等于基准值。然后根据
k的值决定在哪个分区继续查找。 - 分区操作:使用双指针法进行分区操作,确保左边分区的元素都大于等于基准值,右边分区的元素都小于等于基准值。
- 递归调用:根据
k的值决定在左边分区还是右边分区继续查找,直到找到第k大的元素。
|
|
时间复杂度分析
- 平均情况:
O(n),其中n是数组的长度。快速选择算法在平均情况下每次能将问题规模减半,因此时间复杂度为线性。 - 最坏情况:
O(n^2),如果每次选择的基准值都是最小或最大值,导致每次分区只能减少一个元素。
空间复杂度分析
- 空间复杂度:
O(1),除了递归调用栈外,算法没有使用额外的空间。
易错点
- 是严格小于,而不是小于等于:会影响分区结果。如果全是
pivot,这会让指针越界 - 分区(Partition):将数组分为两部分:
- 左侧部分:所有元素 小于 基准值。
- 右侧部分:所有元素 大于 基准值。
- 基准值本身位于正确的位置。
- cnt和k:
k是从1开始计数的,不要弄错k大于cnt意味着前面没罩住,往后找k小于等于cnt意味着结果在前面,缩小尾巴
方法2:最大堆
最大堆是一种树形数据结构,其中每个节点的值都大于或等于其子节点的值。我们可以通过构建一个最大堆来找到第k个最大元素。具体步骤如下:
- 构建最大堆:将整个数组构建成一个最大堆。
- 移除堆顶元素:从堆中移除堆顶元素(最大值),重复k-1次。
- 返回堆顶元素:最后堆顶的元素就是第k个最大的元素。
|
|
时间复杂度分析
返回堆顶元素的时间复杂度为 O(1)
每次移除堆顶元素的时间复杂度为 O(log n)
- 从空堆逐个插入建堆:
- 每次插入的时间复杂度是
O(log n)。 - 总时间复杂度是
O(n log n)。
- 每次插入的时间复杂度是
- 从数组自下而上建堆:
- 使用
heapify_down操作,总时间复杂度是O(n)。
- 使用
为啥从下而上建堆是O(n)
- 常见误解:
- 通常我们会认为建堆的时间复杂度是
O(n log n),因为每个元素插入堆的时间是O(log n),总共有n个元素,所以总的复杂度是O(n log n)。 - 但实际上,建堆的时间复杂度是
O(n),而不是O(n log n)。
- 通常我们会认为建堆的时间复杂度是
- 为什么是
O(n):- 在常规的从空堆插入元素的情况下,每次插入需要
O(log n),所以总复杂度是O(n log n)。 - 但是,当我们从一个已有的数组(
arr)构建堆时,采用的是自下而上的heapify操作,而不是从空堆逐个插入。 - 具体来说,建堆的过程是从数组的中间位置开始(即从
n//2 - 1到0),对每个节点进行heapify_down操作。 heapify_down的时间复杂度取决于当前节点的高度,而不是树的高度。对于第i层的节点,heapify_down的时间复杂度是O(h-i),其中h是树的高度。- 通过数学计算,可以发现建堆的总时间复杂度是
O(n)。
- 在常规的从空堆插入元素的情况下,每次插入需要
- 数学证明
- 假设树的高度为
h,第i层有2^i个节点,每个节点的heapify_down操作的时间复杂度为O(h-i)。 - 总时间复杂度的计算如下: $$ T(n) = \sum_{i=0}^{h} 2^i \cdot (h - i) $$
- 这个求和结果可以证明是
O(n)
- 假设树的高度为
空间复杂度分析
- 构建堆需要额外的空间来存储堆结构,空间复杂度为
O(n)。
易错点
- 堆的构建:在初始化堆时,需要从数组的中间位置开始进行
heapify_down操作,而不是从头开始,这会影响建堆的复杂度,从O(nlogn)降低到O(n) - 堆的维护:在
pop操作后,需要正确地维护堆的性质,即调用heapify_down。 - 边界条件:当
k=1时,直接返回堆顶元素即可,不需要进行移除操作。
K 个一组翻转链表
题目描述
给你链表的头节点 head ,每 k 个节点一组进行翻转,请你返回修改后的链表。
k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。
你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
方法:双指针翻转
- 统计链表长度:首先统计链表的长度,确定需要翻转的组数。
- 分组翻转:对每一组的
k个节点进行翻转,翻转过程中需要维护前驱节点和当前节点的关系。 - 更新指针:翻转完一组后,更新前驱节点和当前节点,继续处理下一组节点。
- 返回结果:最终返回修改后的链表。
|
|
复杂度分析
- 时间复杂度:
- 统计链表长度:
O(n),其中n是链表长度。 - 翻转链表组:外层循环遍历
n//k组,每组需要翻转k个节点,时间复杂度为O(n)。 - 总时间复杂度为
O(n)。
- 统计链表长度:
- 空间复杂度:
- 只使用了常数级别的额外空间(几个指针),空间复杂度为
O(1)。
- 只使用了常数级别的额外空间(几个指针),空间复杂度为
三数之和
题目描述
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c,使得 a + b + c = 0。请你找出所有和为 0 且不重复的三元组。
方法1:排序 + 双指针
- 排序:首先将数组排序,这样可以利用双指针的技巧来减少时间复杂度。
- 固定一个数:遍历数组,固定一个数
nums[i],然后使用双指针在剩下的数组中寻找其他两个数,使得这三个数的和为0。 - 双指针:使用两个指针
j和k,分别指向i+1和len(nums)-1。根据三数之和与0的关系,移动指针来逼近目标。 - 去重:为了避免重复的三元组,需要跳过相同的元素
|
|
时间复杂度分析
- 排序:
O(n log n),其中n是数组的长度。排序是算法中最耗时的部分。 - 双指针遍历:
O(n^2),外层循环遍历每个元素,内层循环使用双指针遍历剩余的元素。 - 总时间复杂度:
O(n log n + n^2),在大多数情况下,O(n^2)是主要部分。
空间复杂度分析
- 排序:
O(log n),排序算法的空间复杂度。 - 结果存储:
O(n),最坏情况下需要存储O(n)个三元组。 - 总空间复杂度:
O(n),主要用于存储结果。
易错点
- 去重:在固定一个数和移动指针时,必须跳过相同的元素,否则会导致重复的三元组。
- 边界条件:在处理数组边界时,确保索引不会越界。
- 排序:在开始双指针之前,必须对数组进行排序,否则无法保证双指针的有效性。
最大子数组和
题目描述
给定一个整数数组 nums,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
方法:动态规划(Kadane’s Algorithm)
-
核心思路:
- 状态定义:
cur_sum表示以当前元素结尾的子数组的最大和。 - 状态转移:对于每个元素,我们有两个选择:要么将其加入到当前子数组中,要么以当前元素为起点重新开始一个新的子数组。我们选择两者中较大的一个,即
cur_sum = max(cur_sum + num, num)。 - 边界条件:初始时,
cur_sum和max_sum都设置为负无穷大,以确保数组中的任何元素都能成为新的起点。 - 最终结果:在整个遍历过程中,我们始终维护一个
max_sum,它记录了到目前为止的最大子数组和。
- 状态定义:
-
算法步骤:
- 初始化
cur_sum和max_sum为-float('inf')。 - 遍历数组中的每个元素:
- 更新
cur_sum为max(cur_sum + num, num)。 - 更新
max_sum为max(max_sum, cur_sum)。
- 更新
- 返回
max_sum。
- 初始化
|
|
时间复杂度分析
- 时间复杂度:
O(n),其中n是数组的长度。我们只需遍历一次数组,每个元素的操作都是常数时间。 - 空间复杂度:
O(1),只使用了常数个额外空间来存储cur_sum和max_sum。
易错点
- 初始化值:
cur_sum和max_sum应初始化为-float('inf'),而不是0,因为数组可能包含负数,我们需要确保任何单个元素都能成为起点。 - 状态转移:在更新
cur_sum时,一定要选择max(cur_sum + num, num),而不是直接加上当前元素,否则可能会错过更优的子数组。
快速排序
题目描述
这是一道快速排序的经典实现题目。给定一个整数数组 nums,要求将数组升序排序。
方法:快速排序
-
核心思路:
- 分治法:快速排序是一种基于分治法的排序算法。它的基本思想是通过选择一个基准值(
pivot),将数组分为两部分:一部分小于等于基准值,另一部分大于等于基准值。 - 递归排序:对每一部分递归地进行快速排序,最终实现整个数组的排序。
- 基准值选择:通常选择数组中间位置的元素作为基准值,这样可以避免最坏情况(如已排序数组)的发生。
- 分区操作:使用双指针法进行分区操作,确保左边分区的元素都小于等于基准值,右边分区的元素都大于等于基准值。
- 分治法:快速排序是一种基于分治法的排序算法。它的基本思想是通过选择一个基准值(
-
算法步骤:
- 终止条件:如果
left >= right,当前数组已经有序,直接返回。 - 基准值选择:选择数组中间位置的元素作为基准值(
pivot)。 - 分区操作:使用双指针
i和j进行分区操作:- 从左向右找到第一个大于等于
pivot的元素。 - 从右向左找到第一个小于等于
pivot的元素。 - 如果
i >= j,分区结束。 - 否则,交换
nums[i]和nums[j],并移动指针。
- 从左向右找到第一个大于等于
- 递归排序:对左右两部分分别进行递归排序。
- 终止条件:如果
|
|
时间复杂度分析
- 平均情况:
O(n log n),其中n是数组的长度。快速排序每次将数组分为两部分,理想情况下每次分区都能将问题规模减半。 - 最坏情况:
O(n^2),如果每次选择的基准值都是最小或最大值,导致每次分区只能减少一个元素。
空间复杂度分析
- 空间复杂度:
O(log n),主要用于递归调用栈,最坏情况下可能需要O(n)的空间。
易错点
-
基准值选择:
- 如果选择的基准值不平衡(如最小或最大值),可能导致算法效率下降。
- 选择数组中间位置的元素作为基准值可以尽量避免这种情况。
-
分区操作:
- 在分区操作中,确保指针的正确移动和元素交换,否则可能导致分区失败。
- 注意指针移动时要检查边界条件,防止越界。
-
递归终止条件:
- 确保递归终止条件正确,否则可能导致无限递归。
-
指针移动:
- 在交换元素后,记得更新指针的位置,否则可能导致死循环。
变体
-
三路快速排序:
- 对于含有大量重复元素的数组,可以将数组分为三部分:小于
pivot的部分、等于pivot的部分和大于pivot的部分。 - 这种方法可以避免重复元素的重复比较。
- 对于含有大量重复元素的数组,可以将数组分为三部分:小于
-
随机化选择基准值:
- 在每次分区时随机选择一个元素作为基准值,从而避免最坏情况的发生。
-
小数组使用插入排序:
- 当数组长度较小时(如
n <= 16),使用插入排序代替快速排序,因为插入排序在小数组上表现更好。
- 当数组长度较小时(如
合并两个有序链表
题目描述
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
方法:双指针
这道题的核心思路是通过双指针遍历两个链表,比较两个链表当前节点的值,将较小的节点连接到新链表中,直到其中一个链表遍历结束。然后将未遍历完的链表直接连接到新链表的末尾。
|
|
时空复杂度分析
- 时间复杂度:
O(m + n),其中m和n分别是两个链表的长度。我们需要遍历两个链表的所有节点。 - 空间复杂度:
O(1),除了分配一个虚拟节点外,没有使用额外的空间。
易错点
- 虚拟节点的使用:使用虚拟节点可以简化链表的操作,避免处理空链表的特殊情况。
- 指针的移动:在每次连接节点后,别忘了移动
cur指针到新链表的最后一个节点。 - 剩余节点的处理:在遍历结束后,一定要将未遍历完的链表直接连接到新链表的末尾,不要遗漏。
变体
- 合并 K 个有序链表:可以使用分治法或最小堆来解决。分治法的时间复杂度为
O(kn log k),其中k是链表的数量,n是每个链表的平均长度。 - 合并两个有序链表并去重:在合并时,如果当前节点的值与下一个节点的值相同,则跳过重复的节点。
最长回文子串
题目描述
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
方法1:中心扩展法
核心思路
回文子串的特点是从中心向两边扩展时,左右字符对称。因此,我们可以遍历字符串,以每个字符为中心,分别考虑奇数长度和偶数长度的回文子串,不断向两边扩展,找到最长的回文子串。
代码实现
|
|
时间复杂度分析
- 时间复杂度:
O(n^2),其中n是字符串的长度。对于每个字符,我们都需要向两边扩展,最坏情况下需要扩展整个字符串。 - 空间复杂度:
O(1),除了存储结果外,算法没有使用额外的空间。
易错点
- 边界条件:在扩展时,要确保左右指针不超出字符串的边界。
- 奇数和偶数长度的回文子串:需要同时考虑以当前字符为中心的回文子串(奇数长度)和以当前字符及下一个字符为中心的回文子串(偶数长度)。
方法2:动态规划
核心思路
使用动态规划来解决这个问题。定义一个二维数组 dp,其中 dp[i][j] 表示字符串从索引 i 到 j 的子串是否是回文子串。根据回文子串的性质,如果 s[i] == s[j] 且 dp[i+1][j-1] 是回文子串,那么 dp[i][j] 也是回文子串。
代码实现
|
|
时间复杂度分析
- 时间复杂度:
O(n^2),其中n是字符串的长度。我们需要填充一个n x n的二维数组。 - 空间复杂度:
O(n^2),用于存储动态规划表。
易错点
- 初始化:所有长度为 1 的子串都是回文子串,长度为 2 的子串需要单独检查。
- 状态转移方程:只有在
s[i] == s[j]且dp[i+1][j-1]是回文子串时,dp[i][j]才是回文子串。
变体
- 最长回文子序列:与最长回文子串不同,子序列可以不连续。可以使用动态规划来解决,时间复杂度为
O(n^2)。 - 计算所有回文子串的数量:可以使用中心扩展法或动态规划来解决,时间复杂度为
O(n^2)。