题目 | 简述、方案、死人坑 |
---|---|
无重复字符的最长子串 | 这里我们用双指针加哈希表或集合,遇到就循环移除 |
反转链表 | 迭代法: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)
。