leetcode 100
链表
反转链表
11
反转区间链表
22
合并链表
33
合并 k 个链表
44
k个一组翻转链表
给你链表的头节点 head
,每 k
个节点一组进行翻转,请你返回修改后的链表。
k
是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k
的整数倍,那么请将最后剩余的节点保持原有顺序。
你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
解题思想
- 分组反转:
- 通过遍历链表,将链表分成若干长度为
k
的小段。 - 对每一小段进行反转操作。
- 反转后的小段重新链接到整体链表上。
- 通过遍历链表,将链表分成若干长度为
- 辅助哑节点:
- 引入一个哑节点(dummy node),它的
next
指向链表的头节点。这使得在处理头节点的反转时可以避免特殊处理,简化了代码的逻辑。
- 引入一个哑节点(dummy node),它的
- 双指针技巧:
- 使用两个指针
pre
和end
来标记需要反转的小段的起始和结束位置。 pre
用于标记当前小段的前一个节点。end
用于遍历到当前小段的结束节点。
- 使用两个指针
- 反转操作:
- 通过辅助函数
reverse
实现对从节点a
到节点b
之间的部分链表的反转。 - 反转后,将这部分链表重新连接到原链表上。
- 通过辅助函数
- 链表遍历和反转:
- 遍历链表,每次找到
k
个节点,进行反转操作。 - 如果剩余节点不足
k
个,则保持其原有顺序。
- 遍历链表,每次找到
下面是这个思路的实现:
1 | /** |
这个算法的时间复杂度是 O(n),其中 n 是链表的长度。因为需要遍历每个节点一次。
核心思路
- 分组:
- 使用
for
循环将end
指针向前移动k
次,找到每个需要反转的小段。如果end
到达链表末尾且不足k
个节点,则跳出循环,不再进行反转。
- 使用
- 反转:
- 使用辅助函数
reverse
反转找到的小段链表。 - 断开当前小段与链表的连接,反转后重新连接到主链表上。
- 使用辅助函数
- 调整指针:
- 将
pre
和end
指针调整到新的位置,准备处理下一个小段。
- 将
这种方法通过局部反转来实现整体链表的反转,确保了每个小段的反转操作独立且高效,并且代码简洁易读。
删除链表的倒数第N个节点
给你一个链表,删除链表的倒数第 n
个结点,并且返回链表的头结点。
1 | /** |
双指针法:
- 时间复杂度:
O(L)
,其中L
是链表的长度。 - 空间复杂度:
O(1)
。 - 只需遍历一次链表。
两两交换链表的节点
给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。
解法一–迭代
使用迭代实现,通过迭代地遍历链表,并在每次迭代中交换当前节点和下一个节点的位置来达到相邻节点交换的目的。具体步骤如下:
- 首先,检查链表是否为空或者只有一个节点,如果是,则直接返回原链表。
- 创建一个哨兵节点
dummy
,将它的next
指向头节点head
。这样做是为了简化处理头节点的情况。 - 使用一个指针
current
指向哨兵节点,初始化时也指向头节点。 - 在循环中,每次处理两个相邻节点:
- 将
first
指向当前节点的下一个节点。 - 将
second
指向当前节点的下两个节点(即first
的下一个节点)。 - 交换
first
和second
节点的位置。 - 更新指针
current
,使其指向交换后的第一个节点,即first
。
- 将
- 当链表中剩余的节点不足两个时,停止循环。
- 返回哨兵节点的
next
,即新的头节点。
这个方法的关键点在于使用迭代遍历链表,并在每次迭代中交换相邻的两个节点的位置。
1 | /** |
解法二–递归
一种更简单的递归解法可以用于交换链表中的每两个相邻节点。递归方法直接对每对节点进行处理,而不需要显式地使用哨兵节点。下面是递归实现的方法:
1 | /** |
随机链表的复制
给你一个长度为 n
的链表,每个节点包含一个额外增加的随机指针 random
,该指针可以指向链表中的任何节点或空节点。
构造这个链表的 深拷贝。 深拷贝应该正好由 n
个 全新 节点组成,其中每个新节点的值都设为其对应的原节点的值。新节点的 next
指针和 random
指针也都应指向复制链表中的新节点,并使原链表和复制链表中的这些指针能够表示相同的链表状态。复制链表中的指针都不应指向原链表中的节点 。
例如,如果原链表中有 X
和 Y
两个节点,其中 X.random --> Y
。那么在复制链表中对应的两个节点 x
和 y
,同样有 x.random --> y
。
返回复制链表的头节点。
用一个由 n
个节点组成的链表来表示输入/输出中的链表。每个节点用一个 [val, random_index]
表示:
val
:一个表示Node.val
的整数。random_index
:随机指针指向的节点索引(范围从0
到n-1
);如果不指向任何节点,则为null
。
你的代码 只 接受原链表的头节点 head
作为传入参数。
这道题的解题思想是使用两次遍历原链表来创建新链表。在第一次遍历中,我们创建了新节点,并使用 Map 数据结构将原节点和新节点进行了映射。在第二次遍历中,我们设置了新节点的 next
和 random
指针,根据原节点的指针找到对应的新节点,并将其指针设置到新节点上。最后,我们返回新链表的头节点。
1 | /** |
时间复杂度:O(N),因为我们需要遍历原链表两次,并且在每次遍历中,对每个节点进行常数时间的操作。
双指针
字符串
二叉树
二叉树的层次遍历
给你二叉树的根节点 root
,返回其节点值的 层序遍历 。 (即逐层地,从左到右访问所有节点)。
解题思想
层次遍历(广度优先遍历)的核心思想是逐层处理节点,即先处理根节点,再处理第一层的所有节点,接着处理第二层的所有节点,以此类推。为了实现这种遍历方式,通常使用队列来帮助记录当前层和下一层的节点。
具体的步骤:
- 初始化:
- 如果根节点为空,直接返回空数组。
- 使用队列(FIFO,先进先出)数据结构来辅助层次遍历。将根节点加入队列。
- 初始化一个结果数组
res
,用于存储每一层的节点值。
- 循环处理每一层:
- 当队列不为空时,继续循环。
- 记录当前队列的长度
size
,即当前层的节点数量。 - 初始化一个数组
cur
,用于存储当前层的节点值。
- 处理当前层的每个节点:
- 使用
for
循环遍历当前层的每个节点(循环次数为size
)。 - 从队列中取出一个节点,将其值加入
cur
数组。 - 如果该节点有左子节点,则将左子节点加入队列。
- 如果该节点有右子节点,则将右子节点加入队列。
- 使用
- 保存当前层的结果:
- 将当前层的节点值数组
cur
加入结果数组res
。
- 将当前层的节点值数组
- 返回结果:
- 当所有节点都处理完毕后,
while
循环结束,返回结果数组res
。
- 当所有节点都处理完毕后,
1 | /** |
时间复杂度分析
对于层次遍历(广度优先遍历)二叉树,时间复杂度的分析如下:
- 每个节点在
queue
中最多进队列和出队列各一次,因此每个节点被处理的次数是常数级的。 - 假设二叉树有
n
个节点,则总的处理时间为O(n)
。
算法的时间复杂度是 O(n)
,其中 n
是二叉树中的节点总数。
将有序的数组转换成二叉搜索树
给定一个整数数组 nums
,其中元素已经按 升序 排列,请你将其转换为一棵平衡二叉搜索树。
将一个已排序的整数数组转换为平衡二叉搜索树(BST)的问题可以通过递归的方式解决。核心思想是找到数组的中间元素作为根节点,然后将数组的左半部分作为左子树,右半部分作为右子树,递归地进行下去。
1 | /** |
时间复杂度
- 时间复杂度:
O(n)
,其中n
是数组的长度。每个元素在递归过程中只访问一次。 - 空间复杂度:
O(log n)
,递归调用的最大深度是树的高度,对于平衡二叉树高度为log n
。
这种方法通过递归和分治的思想,将一个排序数组转换为一棵平衡的二叉搜索树,从而实现了高效的构建过程。
验证二叉搜索树
给你一个二叉树的根节点 root
,判断其是否是一个有效的二叉搜索树。
有效 二叉搜索树定义如下:
节点的左
子树
只包含
小于
当前节点的数。
节点的右子树只包含 大于 当前节点的数。
所有左子树和右子树自身必须也是二叉搜索树。
1 | /** |
复杂度分析
- 时间复杂度:
O(n)
,其中n
是树中节点的数量。每个节点最多访问一次。 - 空间复杂度:
O(h)
,其中h
是树的高度。递归调用的最大深度是树的高度。在最坏情况下(树是完全不平衡的),空间复杂度为O(n)
。在最佳情况下(树是完全平衡的),空间复杂度为O(log n)
。
这种实现通过递归和分治法,有效地检查了每个节点是否满足二叉搜索树的条件。
判断完全二叉树
前序和中序遍历构造二叉树
给定两个整数数组 preorder
和 inorder
,其中 preorder
是二叉树的先序遍历, inorder
是同一棵树的中序遍历,请构造二叉树并返回其根节点。
从前序遍历和中序遍历的序列构造一棵二叉树,可以通过递归的方式实现。前序遍历的第一个元素总是根节点,而中序遍历中根节点的位置可以将树分为左子树和右子树。我们可以利用这一点,递归地构造二叉树。下面是实现代码,并带有详细的逐行解释。
解题思想
要从前序遍历和中序遍历的序列构造一棵二叉树,我们可以利用前序遍历的特点,即前序遍历的第一个元素总是当前子树的根节点。而中序遍历可以将树分为左子树和右子树。通过递归地构建子树,我们可以重建整个二叉树。具体步骤如下:
- 找到根节点:前序遍历的第一个元素是当前子树的根节点。
- 划分左右子树:在中序遍历中找到根节点的位置,根节点左边的部分是左子树,右边的部分是右子树。
- 递归构建子树:根据划分结果,递归地构建左子树和右子树。
代码实现
下面是完整的代码以及逐行解释:
1 | /** |
示例过程
preorder = [3, 9, 20, 15, 7]
inorder = [9, 3, 15, 20, 7]
调用 buildTree(preorder, inorder) 将构建如下的二叉树:
二叉树如下:
1 | 3 |
给定前序遍历 preorder = [3, 9, 20, 15, 7]
和中序遍历 inorder = [9, 3, 15, 20, 7]
,调用 buildTree(preorder, inorder)
将构建如下的二叉树:
根节点:
- 前序遍历的第一个元素
3
是根节点。 - 在中序遍历中找到
3
的位置,左边是[9]
(左子树),右边是[15, 20, 7]
(右子树)。
- 前序遍历的第一个元素
左子树:
- 前序遍历的左子树部分
[9]
,中序遍历的左子树部分[9]
。 - 根节点
9
,没有左右子树。
- 前序遍历的左子树部分
右子树:
- 前序遍历的右子树部分
[20, 15, 7]
,中序遍历的右子树部分[15, 20, 7]
。 - 根节点
20
,在中序遍历中找到20
的位置,左边是[15]
,右边是[7]
。 - 递归构建左子树
[15]
,右子树[7]
。
- 前序遍历的右子树部分
通过上述步骤,最终构建出完整的二叉树。
时间复杂度
- 时间复杂度:
O(n^2)
,其中n
是树中节点的数量。在最坏情况下,每次查找中序遍历的根节点位置都需要O(n)
的时间。 - 空间复杂度:
O(n)
,用于存储构建的二叉树以及递归调用栈的空间。递归的深度最大为树的高度,对于平衡树是O(log n)
,最坏情况下(如链状树)是O(n)
。
前序和中序的序列构造一棵二叉树并给出右侧视图
请根据二叉树的前序遍历,中序遍历恢复二叉树,并打印出二叉树的右视图
如输入[1,2,4,5,3],[4,2,5,1,3]时,通过前序遍历的结果[1,2,4,5,3]和中序遍历的结果[4,2,5,1,3]可重建出以下二叉树:[1,3,5]
1 | function solve(preOrder, inOrder) { |
解题思路
- 构建二叉树:
- 利用前序遍历数组
preOrder
确定根节点。 - 利用中序遍历数组
inOrder
确定根节点的位置,从而划分左子树和右子树的节点。 - 递归地构建左子树和右子树。
- 利用前序遍历数组
- 获取二叉树右视图:
- 使用广度优先搜索(BFS)遍历二叉树。
- 在每一层中,记录最后一个访问的节点,即为该层的右视图节点。
- 将这些节点值依次加入结果数组中。
时间复杂度分析
- 构建二叉树:
buildTree
函数中,preOrder
和inOrder
数组的切片操作每次都需要O(n)
时间,其中n
是当前数组的长度。- 在最坏情况下,构建树的每个节点都需要
O(n)
次切片操作,因此构建树的总时间复杂度为O(n^2)
。
- 获取右视图:
right
函数中,使用 BFS 遍历树,每个节点访问一次,因此时间复杂度为O(n)
。
综合起来,构建树的时间复杂度为 O(n^2)
,获取右视图的时间复杂度为 O(n)
。因此,总的时间复杂度为 O(n^2)
。
二叉树的最大深度
111
二叉树的最小深度
22
二叉搜索树中第K小的元素
给定一个二叉搜索树的根节点 root
,和一个整数 k
,请你设计一个算法查找其中第 k
个最小元素(从 1 开始计数)。
解题思想
解题思想是利用二叉搜索树(BST)的性质和中序遍历来找到第 k
小的元素。BST 的一个重要性质是中序遍历其节点会得到一个有序的递增序列。通过中序遍历,我们可以一次性访问 BST 中的所有节点,并且能够按照递增的顺序进行计数,因此第 k
小的元素就是中序遍历过程中第 k
个访问的节点。
具体步骤如下:
中序遍历:
- 中序遍历(Inorder Traversal)是指按照左子树 -> 根节点 -> 右子树的顺序遍历树的所有节点。
- 对于 BST,中序遍历可以得到一个从小到大的有序序列。
使用栈来模拟递归:
- 为了避免递归带来的栈溢出问题和更清晰的逻辑,我们使用一个显式的栈来模拟递归。
- 将当前节点和所有左子节点依次入栈,直到到达最左端(即当前节点为
null
)。 - 弹出栈顶节点,访问该节点并将计数器加一。
- 如果计数器等于
k
,则当前节点就是第k
小的元素,返回该节点的值。 - 否则,将当前节点指向其右子树,继续上述过程。
代码实现
1 | /** |
详细解析
初始化和定义:
- 定义一个栈
stack
用于模拟递归。 cur
指针用于遍历节点,初始化为根节点。count
计数器用于记录已访问的节点数。
- 定义一个栈
外层
while
循环:- 条件:当前节点不为空或者栈不为空。
- 目的是遍历所有节点,保证在遍历完所有节点前不会退出循环。
内层
while
循环:- 将当前节点及其所有左子节点压入栈中,直到最左端。
- 栈的作用是保存节点的访问路径,以便后续处理。
弹出和处理栈顶节点:
- 从栈中弹出栈顶节点
cur
。 - 访问该节点,并将
count
加一。 - 如果
count
等于k
,则返回当前节点的值。
- 从栈中弹出栈顶节点
转向右子树:
- 将当前节点指向其右子树,继续上述过程。
- 如果右子树存在,会在下一轮
while
循环中被处理。
返回结果:
- 如果遍历结束后仍未找到第
k
小的元素,返回null
(理论上此情况不会发生,因为题目保证k
有效)。
- 如果遍历结束后仍未找到第
通过以上步骤,代码能够高效地找到二叉搜索树中的第 k
小元素。
时间复杂度
时间复杂度:
O(N)
- 每个节点被访问一次且仅被入栈和出栈各一次,因此总的时间复杂度是
O(N)
,其中N
是树中的节点总数。
- 每个节点被访问一次且仅被入栈和出栈各一次,因此总的时间复杂度是
空间复杂度:
O(H)
- 其中
H
是树的高度。在最坏情况下,栈的深度等于树的高度。在一个平衡树中,H = O(log N)
;在一个完全不平衡的树中(链表),H = O(N)
。
- 其中
二叉树的右侧视图
给定一个二叉树的 根节点 root
,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。
解题思想
该算法的目的是获取二叉树的右视图,即从右侧看二叉树时能看到的所有节点。解题的核心思想是使用广度优先搜索(BFS),逐层遍历二叉树,并记录每一层的最后一个节点。
具体解题步骤如下:
- 广度优先搜索(BFS):
- BFS 是一种逐层遍历树或图的算法。对于树的层次遍历,BFS 非常适用,因为它能够按层次逐层访问节点。
- 使用队列来实现 BFS,因为队列是先进先出(FIFO)的数据结构,适合按顺序处理节点。
- 逐层遍历并记录最后一个节点:
- 在每一层的遍历中,记录该层的最后一个节点。这个节点就是从右侧视角能看到的节点。
- 使用一个变量
lastNode
来保存每层最后一个节点。 - 遍历完每一层后,将
lastNode
的值添加到结果数组res
中。
- 节点入队和出队:
- 从队列中取出当前层的所有节点,并将它们的左子节点和右子节点依次加入队列,确保下一层的节点能够被处理。
步骤详解
- 输入检查:
- 如果根节点为空,直接返回空数组,因为没有节点可以被看到。
- 初始化队列和结果数组:
- 队列初始包含根节点,用于开始层次遍历。
- 结果数组用于保存每层的最右侧节点值。
- 广度优先搜索:
- 使用
while
循环进行 BFS,只要队列不为空,就继续处理节点。
- 使用
- 逐层处理:
for
循环遍历当前层的所有节点,使用levelSize
记录当前层的节点数。- 每次从队列中取出一个节点,并将其左子节点和右子节点加入队列。
lastNode
保存当前层的最后一个节点。
- 保存结果:
- 每层处理完后,将
lastNode
的值添加到结果数组中。
- 每层处理完后,将
- 返回结果:
- 最终返回结果数组,其中包含从右侧视角能看到的所有节点值。
1 | /** |
时间复杂度和空间复杂度
时间复杂度:
O(N)
,其中N
是树中的节点总数。每个节点被访问一次,因此时间复杂度为O(N)
。空间复杂度:
O(D)
,其中D
是树的最大深度。在最坏情况下,队列中可能包含一层的所有节点,因此空间复杂度为O(D)
。
二叉树的直径
给你一棵二叉树的根节点,返回该树的 直径 。
二叉树的 直径 是指树中任意两个节点之间最长路径的 长度 。这条路径可能经过也可能不经过根节点 root
。
两节点之间路径的 长度 由它们之间边数表示。
这道题的解题思想是通过深度优先搜索 (DFS) 来计算二叉树的直径。二叉树的直径定义为任意两个节点路径中最远距离的节点数目。我们通过递归的方式计算每个节点的左右子树的深度,并更新当前的最大直径。
代码实现:
1 | /** |
解题思路
- **深度优先搜索 (DFS)**:使用 DFS 递归计算每个节点的深度。
- 最大直径更新:在递归过程中,计算左右子树的深度之和,并不断更新最大直径。
- 递归返回深度:每个节点的深度为其左右子树深度的最大值加一。
时间复杂度
- 时间复杂度:每个节点都遍历一次,所以时间复杂度是 (O(N)),其中 (N) 是二叉树的节点数。
- 空间复杂度:由于递归调用栈的深度为树的高度,最坏情况下,空间复杂度为 (O(N))(当树退化为链表时)。
通过这种方法,我们能够有效地计算出二叉树的直径,并且代码具有较高的效率和可读性。
二叉树转为链表
给你二叉树的根结点 root
,请你将它展开为一个单链表:
- 展开后的单链表应该同样使用
TreeNode
,其中right
子指针指向链表中下一个结点,而左子指针始终为null
。 - 展开后的单链表应该与二叉树 先序遍历 顺序相同。
解题思路
- 后序遍历二叉树:
- 采用后序遍历(右 -> 左 -> 根)的方式遍历二叉树,这样可以确保在修改当前节点之前,右子树和左子树已经被处理。
- 后序遍历的原因是我们要从下到上,从右到左地处理节点,这样可以确保每个节点的右指针正确指向下一个节点。
- 修改节点指针:
- 将当前节点的右指针指向上一个处理过的节点。
- 将当前节点的左指针设为
null
,因为在链表中不需要左指针。 - 更新
pre
为当前节点,以便下一个节点能够正确地指向它。
1 | var flatten = function (root) { |
时间复杂度
- 时间复杂度:
O(n)
,其中n
是二叉树的节点数。每个节点被访问一次,且每个节点的指针修改操作都是常数时间操作。 - 空间复杂度:
O(h)
,其中h
是二叉树的高度。空间复杂度主要取决于递归调用栈的深度。对于平衡二叉树,空间复杂度为O(log n)
;对于退化为链表的二叉树,空间复杂度为O(n)
。
总结
这段代码通过后序遍历(先右子树,再左子树,最后根节点)处理二叉树中的每个节点,并将二叉树原地转换为链表形式。每次遍历到一个节点时,将其右指针指向上一个访问过的节点,左指针设为 null
,然后更新 pre
为当前节点。最终,二叉树被转换为一个按先序遍历顺序的链表。
二叉树的路径总和
给定一个二叉树的根节点 root
,和一个整数 targetSum
,求该二叉树里节点值之和等于 targetSum
的 路径 的数目。
路径 不需要从根节点开始,也不需要在叶子节点结束,但是路径方向必须是向下的(只能从父节点到子节点)。
解题思想
我们需要找到二叉树中所有路径的和为给定的 targetSum
的路径条数。为了实现这一目标,我们需要在树中每个节点进行深度优先搜索(DFS),并从每个节点开始,检查所有可能的路径是否满足条件。这样做可以确保我们不会遗漏任何可能的路径。
1 | var pathSum = function (root, targetSum) { |
时间复杂度
- 对于每个节点,需要调用
depth
函数来计算从该节点开始的所有路径和。depth
函数在最坏情况下会访问树中的每个节点,因此其时间复杂度为O(N)
,其中N
是节点总数。 dfs
函数需要遍历整棵树中的每个节点,因此其时间复杂度也为O(N)
。
总的时间复杂度为 O(N^2)
,因为对于树中的每个节点,我们都执行了一次 depth
函数的完整遍历。
空间复杂度
空间复杂度主要由递归调用栈的深度决定。在最坏情况下(即树为单链表时),递归深度为 N
,因此空间复杂度为 O(N)
。
总结:该算法通过遍历每个节点并从每个节点开始检查所有可能路径,最终找到所有和为 targetSum
的路径条数,其时间复杂度为 O(N^2)
,空间复杂度为 O(N)
。
解法二–前缀和
解题思想
该算法使用深度优先搜索(DFS)遍历整棵树,并使用一个哈希表 prefixSum
来记录从根节点到当前节点的路径和的出现次数。通过计算当前路径和与目标路径和的差值,可以快速判断是否存在从某个节点到当前节点的路径和等于 targetSum
。这使得算法在一次遍历过程中即可计算出所有符合条件的路径条数。
1 | var pathSum = function (root, targetSum) { |
时间复杂度
- 单次遍历整棵树,时间复杂度为
O(N)
,其中N
是节点总数。
空间复杂度
- 主要由哈希表的大小和递归调用栈的深度决定。最坏情况下,空间复杂度为
O(N)
。
二叉树的最近路径
给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
百度百科中最近公共祖先的定义为:“对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”
1 | /** |
时间复杂度分析
时间复杂度主要取决于对二叉树的遍历,因为需要在树中搜索节点 p
和 q
,并找到它们的最近公共祖先。
- 最坏情况下,我们需要遍历整棵二叉树,因为
p
和q
可能分别位于树的最深层,此时时间复杂度为O(n)
,其中n
是二叉树中节点的个数。
空间复杂度分析
空间复杂度包括递归调用的栈空间以及递归函数本身使用的空间。
- 递归深度:在最坏情况下,递归调用的深度可以达到树的高度。对于平衡二叉树,递归深度为
O(log n)
;对于最坏情况下的不平衡二叉树,递归深度为O(n)
。 - 额外空间:除了递归调用的栈空间外,递归函数本身使用的额外空间很小,是常数级别的空间,因此空间复杂度主要取决于递归调用的栈空间。
综合考虑,改进后的代码在时间复杂度上是 O(n)
,在空间复杂度上是 O(log n)
到 O(n)
,具体取决于树的形状(平衡还是不平衡)。
二叉树最大的路径和
二叉树中的 路径 被定义为一条节点序列,序列中每对相邻节点之间都存在一条边。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。
路径和 是路径中各节点值的总和。
给你一个二叉树的根节点 root
,返回其 最大路径和 。
1 | /** |
在分析这段代码的时间复杂度时,我们可以考虑以下几个方面:
时间复杂度分析
递归函数的调用次数:
- 对于每个节点,递归函数
maxPathSumRecursive
都会被调用一次。因此,时间复杂度与节点的数量成正比,即为O(n)
,其中n
是二叉树中节点的个数。
- 对于每个节点,递归函数
每次递归的时间复杂度:
- 在每次调用
maxPathSumRecursive
时,我们执行了常数时间的计算操作(比如比较、加法、取最大值等)。因此,每个递归调用的时间复杂度可以视为O(1)
。
- 在每次调用
总体时间复杂度:
- 综合以上两点,整个算法的时间复杂度为
O(n)
,其中n
是二叉树中节点的个数。这是因为我们对每个节点都进行了一定数量的常数时间操作,总共进行了n
次这样的操作。
- 综合以上两点,整个算法的时间复杂度为
空间复杂度分析
递归调用的空间复杂度:
- 在递归调用的过程中,会使用系统栈来存储递归调用的信息,因此空间复杂度取决于递归调用的深度。
- 在最坏情况下,二叉树是一个链式结构(类似链表),递归深度可以达到
O(n)
,此时空间复杂度为O(n)
。 - 在平衡二叉树的情况下,递归深度为
O(log n)
,因此空间复杂度为O(log n)
。
额外的空间:
- 除了递归调用的栈空间外,算法本身并没有使用额外的辅助空间,因此额外空间复杂度为
O(1)
。
- 除了递归调用的栈空间外,算法本身并没有使用额外的辅助空间,因此额外空间复杂度为
综合考虑,该算法的时间复杂度为 O(n)
,空间复杂度取决于递归调用的深度,最坏情况下为 O(n)
,平均情况下为 O(log n)
(对于平衡二叉树)。
栈和队列
111
222
回溯
回溯算法是一种基于递归的算法设计技术,用于解决一些组合问题。其核心思想是逐步构建一个解,然后在发现该解不满足条件时,返回到上一步,尝试另一种可能性。这个过程不断重复,直到找到所有可能的解或确定没有解。
回溯算法的基本步骤
- 选择:选择一个可能的选择进行尝试。
- 约束:检查当前选择是否满足问题的约束条件。
- 终止条件:判断当前选择是否为一个完整的解。
- 回溯:如果当前选择不满足条件或不是一个完整的解,回溯到上一步,尝试其他选择。
回溯算法的伪代码
以下是回溯算法的一般伪代码结构:
1 | function backtrack(solution): |
回溯算法的应用
回溯算法在许多经典的计算问题中都有应用,包括但不限于以下几个方面:
- N皇后问题:在N×N的棋盘上放置N个皇后,使得它们不能互相攻击。
- 图的着色问题:为图中的每个节点分配一种颜色,确保相邻节点颜色不同。
- 数独:填充数独的解。
- 旅行商问题:找到一条经过所有城市并且总距离最短的路径。
回溯算法的优缺点
优点:
- 简单易理解,适用于小规模问题。
- 可以用于生成所有可能的解,保证找到所有可能的解。
缺点:
- 对于大规模问题,回溯算法的时间复杂度可能非常高,因为其本质上是穷举所有可能的解。
- 需要大量的递归调用,可能导致较高的空间复杂度。
示例
以经典的N皇后问题为例,来说明回溯算法的应用。N皇后问题要求在N×N的棋盘上放置N个皇后,使得它们互相不能攻击。回溯算法的步骤如下:
- 从第一个皇后开始,尝试将其放置在第一行的一个位置。
- 递归地尝试将下一个皇后放置在下一行的某个位置,同时检查是否与之前放置的皇后冲突。
- 如果发现冲突,回溯到上一步,尝试其他位置。
- 重复上述步骤,直到所有皇后都成功放置或者确定无解。
通过回溯算法,可以系统地探索所有可能的解决方案,并找到满足条件的所有解。
全排列
给定一个不含重复数字的数组 nums
,返回其 所有可能的全排列 。可以 按任意顺序 返回答案。
解题思想
回溯算法基本思路:
- 回溯算法是一种通过尝试所有可能的候选解来解决问题的方法。它适用于求解组合优化问题,其中每一个解都是通过递归构建的。
- 在本问题中,我们要求生成数组
nums
的所有排列,即不同元素的所有可能顺序组合。
具体实现步骤:
- 使用一个递归函数
backtrack
来构建排列。 - 使用一个
path
数组来存储当前的排列路径。 - 使用一个
used
数组来标记每个元素是否已经被使用过。 - 当
path
的长度等于nums
的长度时,将path
加入结果集合res
。 - 对于每个位置
i
,如果nums[i]
没有被使用过,则将其加入path
,标记为已使用,然后递归调用backtrack
。 - 递归完成后,回溯:将
path
的最后一个元素移除,将其对应的used
标记为未使用,以便尝试下一个可能的排列。
- 使用一个递归函数
返回结果:
- 最终,函数返回
res
,即包含所有排列的数组。
- 最终,函数返回
1 | var permute = function (nums) { |
时间复杂度分析
时间复杂度:假设数组
nums
的长度为n
,生成所有排列的数量为n!
(即阶乘)。因此,生成所有排列的时间复杂度为O(n!)
。- 每个排列的构建涉及
n
次选择,每次选择中有n
个选项,因此总共有n!
种排列。 - 在每个排列的构建过程中,需要对
path
的操作(push 和 pop),这些操作的时间复杂度可以视为常数时间,因为path
最多包含n
个元素。
- 每个排列的构建涉及
空间复杂度:主要是递归调用和存储结果集所需的空间。
- 递归调用的最大深度为
n
,因此空间复杂度为O(n)
。 - 结果集
res
最多包含n!
个排列,每个排列的长度为n
,因此结果集的空间复杂度为O(n * n!)
。
- 递归调用的最大深度为
总结
回溯算法是一种递归枚举所有可能性的有效方法,尤其适用于组合优化问题。在本问题中,通过回溯算法可以生成数组的所有排列。虽然时间复杂度是 O(n!)
,这在大规模问题上可能会导致性能问题,但在实践中,回溯算法在处理小规模问题时表现良好,并且能够准确地找到所有解。
全排列||
给定一个可包含重复数字的序列 nums
,按任意顺序 返回所有不重复的全排列。
这个问题的解题思想是利用回溯算法生成包含重复元素的全排列,并通过一些条件判断和技巧来确保生成的排列是唯一的。
解题思想:
排序数组:
- 首先对输入的数组
nums
进行排序,这样相同的元素会相邻排列。排序后的数组有助于在回溯过程中判断重复元素和跳过已经处理过的情况。
- 首先对输入的数组
回溯算法:
- 使用回溯算法来探索所有可能的排列。回溯算法是一种深度优先搜索(DFS)的应用,通过递归实现。
- 主要思路是从左到右依次选择未使用过的元素,加入当前排列
cur
中,然后递归处理剩余的元素。如果当前cur
的长度等于nums
的长度,则说明找到了一个完整的排列,将其加入结果数组res
中。 - 在递归调用之后,需要撤销选择,即将当前加入的元素从
cur
中移除,并将其标记为未使用,以便进行下一次选择。
处理重复情况:
- 使用
used
数组来标记每个位置的元素是否已经被选择过。 - 在循环中,如果当前元素已经被使用 (
used[i] === true
),则跳过该元素。 - 对于排序后的数组,在判断重复元素时,如果当前元素与上一个元素相同,并且上一个元素未被使用过 (
!used[i - 1]
),则跳过当前元素,以确保不会生成重复的排列。
- 使用
递归终止条件:
- 当
cur
的长度等于nums
的长度时,将当前排列加入res
中,并返回,结束当前递归路径。
- 当
1 | var permuteUnique = function(nums) { |
复杂度分析
- 时间复杂度:假设数组
nums
的长度为n
,在最坏情况下,所有排列都是唯一且符合条件的。回溯算法的时间复杂度为O(n * n!)
,其中n!
是所有可能的排列数,每个排列的生成和处理都需要O(n)
的时间复杂度。 - 空间复杂度:主要消耗在递归调用栈和存储结果的空间。使用了
used
数组和cur
数组来记录状态和当前排列,以及最终的res
数组来存储所有符合条件的排列。因此,空间复杂度为O(n)
。
总体来说,这种解法利用回溯算法和一些额外的判断条件,能够高效地生成包含重复元素的所有唯一排列,并且确保每个排列只出现一次。
下一个排列
这段代码实现了寻找给定整数数组 nums
的下一个排列,并且要求在原地修改数组,只允许使用常数额外空间。下面我将详细解释代码的实现和其背后的解题思路。
解题思路:
**从右向左找到第一个降序的位置
i
**:- 从数组的倒数第二个元素开始向前查找,找到第一个位置
i
满足nums[i] < nums[i+1]
。这是因为如果该位置满足这个条件,可以使得我们修改这个位置以获得字典序更大的排列。
- 从数组的倒数第二个元素开始向前查找,找到第一个位置
**找到大于
nums[i]
的最小元素位置j
**:- 在位置
i+1
到数组末尾之间,找到最小的元素nums[j]
,满足nums[j] > nums[i]
。这样做是为了确保新的排列尽可能小地增加。
- 在位置
**交换元素
nums[i]
和nums[j]
**:- 将位置
i
的元素和位置j
的元素进行交换。
- 将位置
翻转从位置
i+1
到末尾的元素:- 最后,翻转从位置
i+1
到数组末尾的所有元素。这一步确保得到的是下一个字典序更大的排列,并且是最小的增加量。
- 最后,翻转从位置
特殊情况处理:
- 如果整个数组已经是降序排列(即没有找到位置
i
满足nums[i] < nums[i+1]
),则直接翻转整个数组,得到最小的字典序排列。
- 如果整个数组已经是降序排列(即没有找到位置
code
1 | var nextPermutation = function (nums) { |
这段代码通过以上的步骤实现了寻找数组 nums
的下一个排列,并且在原地进行修改,符合题目要求的要求。
复杂度分析:
时间复杂度:整个算法的时间复杂度为
O(n)
,其中n
是数组nums
的长度。这是因为我们需要遍历数组两次(一次找位置i
,一次找位置j
),并进行一次翻转操作。空间复杂度:算法使用了常数额外空间
O(1)
,除了存储输入数组外,没有使用额外空间。
示例解释:
假设输入数组 nums = [1, 2, 3]
:
**找到位置
i
**:从右向左遍历,找到nums[1] < nums[2]
,因此i = 1
。**找到位置
j
**:在位置i+1
到数组末尾中,找到nums[2] = 3
大于nums[1] = 2
,因此j = 2
。交换元素:交换
nums[i]
和nums[j]
,得到数组[1, 3, 2]
。翻转操作:翻转从位置
i+1
到末尾的元素,得到最终结果[1, 3, 2]
,这是数组[1, 2, 3]
的下一个排列。
子集
给你一个整数数组 nums
,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
解题思想
这段代码使用了回溯算法来生成给定数组 nums
的所有子集。回溯算法是一种递归的深度优先搜索方法,它通过不断地做选择和撤销选择来探索所有可能的解空间。
**回溯函数
backtrack
**:backtrack
函数接收两个参数:start
表示当前处理的起始位置,subset
表示当前构建的子集。- 在每次递归调用开始时,将当前子集
subset
的拷贝加入结果集res
中,这样做是为了避免后续对subset
的修改影响到已经加入结果集的内容。 - 然后,从
start
开始循环遍历数组nums
,将nums[i]
加入到subset
中,然后递归调用backtrack(i + 1, subset)
来处理下一个位置的元素。 - 在递归返回后,通过
subset.pop()
进行回溯,即移除最后加入的元素,以便尝试下一个可能的选择。
结束条件:
- 当
backtrack
函数开始时,如果start
等于nums.length
,表示当前位置已经超过数组长度,不再进行递归调用,直接返回。
- 当
初始调用:
- 初始时调用
backtrack(0, [])
,从数组的第一个位置开始生成子集,初始子集为空数组[]
。
- 初始时调用
1 | /** |
时间复杂度
- 时间复杂度:O(2^n)
- 回溯算法的时间复杂度主要取决于生成所有子集的数量。对于每个元素,可以选择加入或不加入子集,因此共有 2^n 种可能的子集组合。
- 每个子集的生成过程中,都涉及将当前子集的拷贝加入结果集
res
,这个操作的复杂度是 O(n),其中 n 是subset
的长度。 - 所以总体来说,时间复杂度是 O(2^n * n)。
空间复杂度
- 空间复杂度:O(n * 2^n)
- 空间复杂度主要取决于递归调用栈的深度,最坏情况下可以达到 O(n)。
- 另外,存储结果集的空间复杂度为 O(n * 2^n),因为有 2^n 个子集,每个子集的平均长度为 O(n)。
总结
通过回溯算法,这段代码能够有效地生成一个数组的所有子集。每次递归调用都尝试将当前位置的元素加入子集,并递归处理下一个位置,直到遍历完整个数组。通过合理的回溯和递归操作,确保了每个可能的组合都被考虑到,同时避免了重复计算,从而达到了生成所有子集的目的。
字母大小全排列
给定一个字符串 s
,通过将字符串 s
中的每个字母转变大小写,可以获得一个新的字符串。返回 所有可能得到的字符串集合 。以 任意顺序 返回输出。
要解决这个问题,我们可以使用回溯算法来生成所有可能的字符串集合,其中每个字符可以转换为其大小写形式。
解法一——回溯
解题思路
回溯算法基本思路:
- 我们从字符串的第一个字符开始,依次考虑每个字符的大小写转换。
- 对于每个字符,可以选择保持其原始大小写,或者转换为相应的另一种大小写形式。
- 使用递归来生成所有可能的组合,直到处理完字符串的所有字符。
具体实现步骤:
- 定义一个递归函数
backtrack
,它接收两个参数:当前处理的字符索引index
和当前形成的字符串current
。 - 在每个字符位置上,分两种情况递归处理:
- 将当前字符转换为其大写形式或小写形式,并继续向下递归。
- 如果已经到达字符串的末尾(
index === s.length
),则将形成的字符串加入结果集合。
- 使用一个数组
res
来存储所有可能的字符串。
- 定义一个递归函数
返回结果:
- 最终,将生成的所有字符串存储在数组
res
中返回。
- 最终,将生成的所有字符串存储在数组
实现代码
1 | var letterCasePermutation = function(s) { |
代码说明
**回溯函数
backtrack
**:backtrack
函数根据当前处理的字符索引index
和形成的当前字符串current
进行递归处理。- 当
index
等于s.length
时,表示已经处理完所有字符,将current
加入结果集res
中。 - 对于每个字符,首先将其原样加入
current
中,然后判断是否为字母,如果是则添加其对应的大写形式。
字符处理:
- 使用正则表达式
/[a-zA-Z]/.test(char)
来检测当前字符是否为字母。 - 如果是字母,则添加其大写形式到
current
中(避免添加两次相同的形式)。
- 使用正则表达式
初始调用:
- 初始调用
backtrack(0, '')
从字符串的第一个字符开始递归处理,初始的current
为空字符串。
- 初始调用
时间复杂度
时间复杂度:O(2^n)
- 回溯算法的时间复杂度主要取决于生成的字符串数量,对于每个字符,可以选择两种形式(原始形式和大写形式)。
空间复杂度:
- 空间复杂度主要由递归调用栈的深度决定,最坏情况下可以达到 O(n),其中 n 是字符串
s
的长度。 - 另外,存储结果的空间复杂度为 O(2^n),因为最多会生成 2^n 个字符串。
- 空间复杂度主要由递归调用栈的深度决定,最坏情况下可以达到 O(n),其中 n 是字符串
这种方法通过递归和回溯的方式,有效地生成了所有可能的字符串形式,以达到题目要求的所有可能得到的字符串集合。
解法二——迭代
除了使用回溯算法外,还可以考虑使用迭代的方法来生成所有可能的字符串集合。这种方法可以通过遍历输入字符串,并根据每个字符的类型(字母或数字)动态更新结果集合。让我展示一种基于迭代的解决方案。
迭代解决方案
迭代解决方案的基本思路是使用一个数组来存储每一步生成的结果,并根据输入字符串中的每个字符更新这个数组。
初始定义:
- 创建一个初始的数组
result
,将空字符串""
加入作为初始的结果。
- 创建一个初始的数组
迭代过程:
- 遍历输入字符串
s
中的每个字符。 - 对于每个字符,如果是字母,则遍历当前
result
数组中的每个字符串,生成一个新的字符串,包括原始字符和其大写形式,并将生成的新字符串加入一个临时数组nextResult
。 - 如果是数字,则直接将当前
result
数组中的每个字符串加上这个数字字符,加入到nextResult
中。
- 遍历输入字符串
更新结果集:
- 将
nextResult
数组的内容更新回result
,以便下一次迭代继续使用。
- 将
返回结果:
- 最终
result
中存储的即为所有可能得到的字符串集合。
- 最终
实现代码
1 | /** |
代码说明
初始定义:
- 创建一个初始的
result
数组,其中包含一个空字符串""
,作为初始的结果。
- 创建一个初始的
迭代过程:
- 遍历输入字符串
s
中的每个字符char
。 - 如果
char
是字母(使用正则表达式/[a-zA-Z]/.test(char)
进行判断),则遍历当前result
数组中的每个字符串str
,生成两个新的字符串:str + char.toLowerCase()
:将char
转换为小写加入新的字符串。str + char.toUpperCase()
:将char
转换为大写加入新的字符串。
- 如果
char
是数字,则直接遍历result
数组中的每个字符串str
,将char
加入到str
后面形成新的字符串。
- 遍历输入字符串
更新结果集:
- 将生成的新字符串数组
nextResult
赋值给result
,以便下一次迭代使用。
- 将生成的新字符串数组
返回结果:
- 最终返回
result
,其中包含了所有可能的字符串集合。
- 最终返回
时间复杂度
时间复杂度:O(2^n)
- 对于每个字符,可能生成两个新字符串(大小写形式),因此最终的字符串数量为 2^n。
- 每次循环需要遍历
result
数组中的字符串,因此总体时间复杂度是 O(2^n * n),其中 n 是输入字符串s
的长度。
空间复杂度:O(2^n * n)
- 空间复杂度主要取决于存储结果的数组
result
,以及每个字符串的长度为 n。
- 空间复杂度主要取决于存储结果的数组
这种迭代方法避免了递归调用带来的额外内存消耗,同时通过动态更新结果集来生成所有可能的字符串集合,是另一种有效的解决方案。
在处理字符串大小写转换生成所有可能字符串集合的问题中,回溯算法和迭代算法各有其优缺点,简单性可以从几个角度来评估:
方法比较
理解和实现:
- 回溯算法:
- 回溯算法通常涉及递归和回溯的概念,需要理解递归调用和回溯过程中的状态管理。
- 实现时需要考虑如何维护当前路径、处理选择列表以及正确的回溯操作。
- 迭代算法:
- 迭代算法更加直观,可以通过循环和条件语句来动态更新结果集。
- 每一步都是直接在当前状态下进行计算,相对来说更易于理解和实现。
- 回溯算法:
代码复杂度:
- 回溯算法:
- 需要设计和管理递归函数,考虑递归调用带来的栈空间消耗。
- 可能需要额外的数据结构来存储中间状态,如路径的深拷贝。
- 迭代算法:
- 使用基本的循环和数组操作,代码结构相对直观和简单。
- 不涉及递归调用,可以减少栈空间的使用,适用于大数据集的情况。
- 回溯算法:
性能和空间消耗:
- 回溯算法:
- 可能存在大量的递归调用,如果不适当地进行状态管理和剪枝,会消耗较多的栈空间。
- 需要考虑额外的空间复杂度用于存储结果集和中间状态。
- 迭代算法:
- 在每一步迭代中动态更新结果集,可能会更有效地利用内存。
- 不会遇到栈溢出的问题,适合处理大规模数据集。
- 回溯算法:
结论
从简单性的角度来看,迭代算法通常更容易理解和实现。它直接在当前状态下处理,避免了递归调用可能带来的复杂性和额外的内存消耗。在大多数情况下,迭代算法对于处理字符串大小写转换生成所有可能字符串集合的问题更加直观和高效。
因此,如果您更注重简单性和直观性,并且不需要额外的递归调用管理,推荐使用迭代算法。如果您熟悉回溯算法或者问题需要经典的深度优先搜索(DFS)策略,那么回溯算法也是一个有效的选择,尤其适用于需要深度探索所有可能性的场景。
电话号码的字母组合
给定一个仅包含数字 2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
要解决这个问题,我们可以使用回溯算法。给定一个仅包含数字 2-9 的字符串,每个数字对应多个字母,我们需要生成所有可能的字母组合。
数字到字母的映射
首先,需要一个映射表,将数字映射到对应的字母:
1 | 2 -> "abc" |
解题思路
我们使用回溯算法来生成所有可能的组合:
- 初始化映射表:使用一个数组
mapping
来存储数字到字母的映射关系。 - 定义回溯函数:这个函数将逐步构建可能的字母组合。
- 处理递归终止条件:当组合长度等于输入字符串长度时,将当前组合加入结果集。
- 遍历当前数字对应的所有字母:将每个字母加入当前组合,然后递归处理下一个数字。
- 回溯:在递归返回后,移除当前字母,以便尝试下一个可能的字母。
代码实现
以下是具体的代码实现:
1 | var letterCombinations = function(digits) { |
复杂度分析
- 时间复杂度:O(3^N * 4^M),其中 N 是输入中对应 3 个字母的数字个数,M 是对应 4 个字母的数字个数。因为每个数字对应的字母数不同,组合的总数是 3^N * 4^M。
- 空间复杂度:O(N),这里 N 是输入字符串的长度。递归调用的深度最多为 N,存储当前路径的数组
cur
也是 O(N)。
这种方法简洁且高效,利用回溯法可以生成所有可能的组合。
组合总和
给你一个 无重复元素 的整数数组 candidates
和一个目标整数 target
,找出 candidates
中可以使数字和为目标数 target
的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates
中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target
的不同组合数少于 150
个。
理解解题思路并逐行解释代码,然后进行复杂度分析是非常重要的。我们将继续使用回溯算法来解决允许重复选择元素的组合求和问题。
解题思路
回溯算法基本思路:
- 回溯算法是一种通过深度优先搜索(DFS)寻找所有解的算法。
- 在本题中,我们需要找出数组
candidates
中所有允许重复选择的组合,使得这些组合的元素之和等于给定的target
。
递归函数设计:
- 设计一个递归函数
backtrack
,该函数会根据当前的选择路径进行搜索,并更新当前的组合cur
和剩余目标值remain
。
- 设计一个递归函数
回溯过程:
- 与前面的题目相比,不同之处在于每次递归调用时,可以从当前位置开始选择元素,并允许重复选择当前位置的元素。
- 当当前组合
cur
的和等于target
时,将当前组合加入结果集res
中。 - 如果当前组合的和已经超过
target
,则进行回溯操作,尝试其他可能的组合。
代码实现
1 | var combinationSum = function(candidates, target) { |
代码解释和改进
**回溯函数
backtrack
**:- 参数
start
:表示从candidates
数组的哪个位置开始选择元素。 - 参数
remain
:表示当前剩余的目标值。 - 如果
remain === 0
,将当前组合cur
加入结果集res
中。 - 遍历
candidates
数组,从start
开始选择元素:- 如果当前元素大于
remain
,跳过当前循环(剪枝操作)。 - 将当前元素加入
cur
中,递归调用backtrack
继续向下搜索。 - 回溯(撤销选择),尝试下一个可能的选择。
- 如果当前元素大于
- 参数
调用回溯函数:
backtrack(0, target)
:从candidates
数组的第一个位置开始,初始目标值为target
。
复杂度分析
- 时间复杂度:假设
candidates
数组的长度为n
,目标值为target
。在最坏情况下,每个元素都可能被选取多次,因此时间复杂度为O((target / min(candidates))^n)
,即指数级别的复杂度。 - 空间复杂度:递归调用的深度最多为
target / min(candidates)
,加上存储结果的空间,空间复杂度也为O((target / min(candidates))^n)
。
示例
假设 candidates = [2, 3, 6, 7]
,target = 7
。
调用 combinationSum(candidates, target)
后,返回结果应为 [[2, 2, 3], [7]]
,表示所有可能的组合为 [2, 2, 3]
和 [7]
。
这种方法能够有效地找出所有允许重复选择元素的组合,利用回溯算法遍历所有可能性,是经典的组合求和问题的解决方法之一。
括号生成
数字 n
代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
解题思想
- 回溯法:
- 回溯法是一种通过递归来尝试所有可能的组合的算法。在这个问题中,我们使用回溯法来生成所有可能的括号组合。
- 有效性条件:
- 在构建组合的过程中,我们需要确保每一步构建出来的部分组合都是有效的。
- 有效的括号组合需要满足:
- 左括号
(
的数量不超过右括号)
的数量。 - 在任何时候,添加的右括号
)
数量不能超过左括号(
的数量。
- 左括号
1 | var generateParenthesis = function(n) { |
空间复杂度主要取决于递归调用时使用的栈空间,以及存储结果的空间。下面来详细分析空间复杂度:
时间复杂度分析:
对于生成有效括号组合的问题,时间复杂度分析主要考虑两方面:
生成所有可能的括号组合:
- 每个括号组合长度为
2n
,每个位置上可以是左括号(
或右括号)
,因此所有可能的括号组合总数是2^(2n)
。
- 每个括号组合长度为
有效括号组合的数量:
- 有效的括号组合数量是第
n
个卡塔兰数C(n)
,其近似值为4^n / (n * sqrt(n))
。虽然生成所有可能的括号组合需要O(2^(2n))
的时间,但实际上有效的括号组合数量少得多,大约是O(4^n / sqrt(n))
。
- 有效的括号组合数量是第
生成和验证有效括号组合的时间:
- 在最坏情况下,生成每个有效组合的时间复杂度是
O(n)
,因为需要在递归过程中构建和检查每个组合的有效性。
- 在最坏情况下,生成每个有效组合的时间复杂度是
综合来看,总时间复杂度主要受到有效括号组合数量的影响,因此总时间复杂度约为 O(4^n / sqrt(n))
。
空间复杂度分析:
空间复杂度主要考虑两方面:
递归调用栈的空间:
- 在递归过程中,调用栈的最大深度为
2n
,即左括号和右括号各n
个。因此,递归调用栈的空间复杂度为O(n)
。
- 在递归过程中,调用栈的最大深度为
存储结果的空间:
result
数组用于存储所有有效的括号组合。有效括号组合的数量是O(4^n / sqrt(n))
,每个组合的长度为2n
。- 因此,结果数组的空间复杂度为
O(n * 4^n / sqrt(n))
,简化为O(4^n / sqrt(n))
。
综合来看,总空间复杂度约为 O(4^n / sqrt(n))
。
结论:
- 时间复杂度:
O(4^n / sqrt(n))
- 空间复杂度:
O(4^n / sqrt(n))
这种复杂度表示该算法在生成有效括号组合时是相对高效的,但随着 n
的增大,组合数量呈指数级增长,计算和存储的资源需求也会显著增加。
单词搜索
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
这个问题可以用深度优先搜索(DFS)结合回溯法来解决。我们需要在一个二维网格中搜索是否存在一个单词,其中单词的字母必须按照字母顺序通过相邻的单元格(水平或垂直相邻)构成,并且同一个单元格内的字母不能重复使用。
解题思想:
遍历网格:
- 对网格中的每个单元格进行遍历,尝试从该单元格开始匹配单词。
**深度优先搜索 (DFS)**:
- 定义一个DFS函数,用于从当前单元格出发,尝试匹配单词的每个字母。
- 如果当前单元格的字母与单词中对应位置的字母匹配,则继续搜索相邻的四个方向(上、下、左、右)。
- 使用回溯法,即在搜索完一个方向后,需要撤销当前单元格的选择,以便尝试其他方向。
边界条件和剪枝:
- 边界条件包括检查索引是否越界,当前单元格是否已经被访问,以及当前单元格的字母是否与单词对应位置的字母匹配。
- 剪枝操作可以减少不必要的搜索,例如一旦发现某个方向无法匹配,则立即返回。
code:
1 | var exist = function (board, word) { |
复杂度分析:
时间复杂度:
- 最坏情况下,需要遍历整个网格的每个单元格,进行一次DFS。DFS的深度最大为单词的长度
L
。每次DFS有四个方向,因此时间复杂度为O(N * M * 4^L)
,其中N
和M
分别为网格的行数和列数,L
为单词的长度。
- 最坏情况下,需要遍历整个网格的每个单元格,进行一次DFS。DFS的深度最大为单词的长度
空间复杂度:
- 主要空间消耗在于递归调用栈。最坏情况下递归栈的深度为单词的长度
L
,因此空间复杂度为O(L)
。
- 主要空间消耗在于递归调用栈。最坏情况下递归栈的深度为单词的长度
这个解法通过遍历每个单元格,并使用DFS进行深度优先搜索和回溯,确保可以找到所有可能的路径并验证其有效性。通过剪枝操作,避免了不必要的计算,从而提高了算法的效率。
动态规划
哈希
字母异位词分组
给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。
字母异位词 是由重新排列源单词的所有字母得到的一个新单词。
1 | /** |
最长连续序列
给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。
请你设计并实现时间复杂度为 O(n) 的算法解决此问题。
使用 Set 模拟哈希数据,存储唯一的值,
1 | /** |
移动 0
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
请注意 ,必须在不复制数组的情况下原地对数组进行操作。
快慢指针
1 | /** |
双指针
1 | var moveZeroes = function (nums) { |
计数法
1 | var moveZeroes = function (nums) { |
盛最多水的容器
给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。
找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
返回容器可以储存的最大水量。
说明:你不能倾斜容器。
1 | /** |
接雨水
1 | /** |
无重复字符的最长子串
给定一个字符串 s ,请你找出其中不含有重复字符的 最长
子串
的长度。
1 | /** |
最长无重复子数组
1 | /** |
最大子数组和
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组:是数组中的一个连续部分。
1 | /** |
除自身以外数组的乘积
给你一个整数数组 nums
,返回 数组 answer
,其中 answer[i]
等于 nums
中除 nums[i]
之外其余各元素的乘积 。
题目数据 保证 数组 nums
之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。
请 不要使用除法,且在 O(*n*)
时间复杂度内完成此题。
遍历两次,逻辑清晰
通过两次遍历数组,算法将左侧乘积和右侧乘积分别累积到结果数组中,逻辑简单且直观。以下是两次遍历的具体步骤:
- 第一次遍历:计算左侧乘积
- 初始化
left
为 1,遍历数组时,将left
的值存入res[i]
中,并更新left
为当前元素与其乘积。
- 初始化
- 第二次遍历:计算右侧乘积并计算最终结果
- 初始化
right
为 1,从右向左遍历数组时,将right
与res[i]
相乘并存回res[i]
,然后更新right
为当前元素与其乘积。
- 初始化
1 | /** |
合并区间
以数组 intervals
表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi]
。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。
示例 1:
1 | 输入:intervals = [[1,3],[2,6],[8,10],[15,18]] |
示例 2:
1 | 输入:intervals = [[1,4],[4,5]] |
找到字符串中所有字母异位词
思想:基于滑动窗口和哈希表
滑动窗口:使用两个指针
left
和right
维护一个窗口,初始时窗口大小为目标字符串p
的长度。通过移动指针来扩展或缩小窗口,以在源字符串s
中寻找符合条件的子串。哈希表:创建一个哈希表
pMap
,用于记录目标字符串p
中每个字符的出现次数。然后,通过在遍历源字符串s
的过程中更新这个哈希表,并根据哈希表的信息来判断窗口内的子串是否符合条件。
算法的具体步骤如下:
- 首先,构建目标字符串
p
的哈希表,记录其中每个字符的出现次数。 - 然后,使用两个指针
left
和right
维护一个窗口,在源字符串s
上进行遍历。 - 在遍历过程中,每次移动右指针
right
,将右指针位置的字符加入窗口中,并更新哈希表。 - 同时,检查哈希表中记录的字符出现次数,如果在窗口内出现的字符数与目标字符串中相应字符的数目匹配,则计数器
count
减一。 - 当
count
减为 0 时,表示窗口内的字符与目标字符串中的字符匹配,此时记录窗口的起始位置。 - 继续移动左指针
left
,缩小窗口,直到窗口大小与目标字符串长度相等。 - 在移动左指针的过程中,同样需要更新哈希表,并根据哈希表中记录的字符出现次数,更新计数器
count
。 - 最后,返回所有符合条件的子串的起始位置。
通过滑动窗口和哈希表的方法,可以高效地在源字符串 s
中找到所有与目标字符串 p
是字母异位词的子串的起始索引。
1 | var findAnagrams = function (s, p) { |
滑动窗口最大值
1 | /** |
最小的K个数
解题思路
排序法:
- 先对数组进行排序,然后选择前 k 个数。
堆(优先队列)法:
- 维护一个大小为 k 的最大堆,遍历数组,将每个元素插入堆中。如果堆的大小超过 k,则移除堆顶元素。最后堆中的元素即为最小的 k 个数。
解法一–排序
1 | function GetLeastNumbers_Solution(input, k) { |
改进:直接使用 slice
方法来获取最小的 k 个数,而不需要显式地创建和填充新数组。
1 | function GetLeastNumbers_Solution(input, k) { |
解法二–堆(优先队列)法
当 k 较小且数组较大时,使用堆的性能会更好。下面是一个使用 JavaScript 中的最小堆实现的方法:
基于堆的代码
1 | class MaxHeap { |
时间复杂度
- 排序法:时间复杂度为 O(nlogn)O(n \log n)O(nlogn),其中 nnn 是数组的长度。
- 堆法:时间复杂度为 O(nlogk)O(n \log k)O(nlogk),其中 nnn 是数组的长度,kkk 是要返回的最小元素的数量。对于较大的数组和较小的 k,这种方法更高效。
寻找第K大的数
有一个整数数组,请你根据快速排序的思路,找出数组中第 k 大的数。
给定一个整数数组 a ,同时给定它的大小n和要找的 k ,请返回第 k 大的数(包括重复的元素,不用去重),保证答案存在。
要求:时间复杂度 𝑂(𝑛𝑙𝑜𝑔𝑛),空间复杂度 𝑂(1)
数据范围:0≤𝑛≤10000≤n≤1000, 1≤𝐾≤𝑛1≤K≤n,数组中每个元素满足 0≤𝑣𝑎𝑙≤100000000≤val≤10000000
1 | function findKth(a, n, K) { |
优化解法–快速选择
快速选择是一个更高效的解法,它的平均时间复杂度是 O(n)。
1 | function findKth(a, n, K) { |
选择合适的方法
- 对于小规模数据,可以直接使用排序法。
- 对于大规模数据,快速选择法更高效。
最小覆盖字串
描述:给定一个字符串 s
、一个字符串 t
。返回 s
中涵盖 t
所有字符的最小子串。如果 s
中不存在涵盖 t
所有字符的子串,则返回空字符串 ""
。
注意:
- 对于
t
中重复字符,我们寻找的子字符串中该字符数量必须不少于t
中该字符数量。 - 如果
s
中存在这样的子串,我们保证它是唯一的答案。
示例 1:
1 | 输入:s = "ADOBECODEBANC", t = "ABC" |
示例 2:
1 | 输入:s = "a", t = "a" |
示例 3:
1 | 输入: s = "a", t = "aa" |
代码
1 | /** |
第二种方法
1 | var minWindow = function(s, t) { |
设计LRU缓存结构
描述
设计LRU(最近最少使用)缓存结构,该结构在构造时确定大小,假设大小为 capacity ,操作次数是 n ,并有如下功能:
- Solution(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存
- get(key):如果关键字 key 存在于缓存中,则返回key对应的value值,否则返回 -1 。
- set(key, value):将记录(key, value)插入该结构,如果关键字 key 已经存在,则变更其数据值 value,如果不存在,则向缓存中插入该组 key-value ,如果key-value的数量超过capacity,弹出最久未使用的key-value
1 | // 定义双向链表节点的构造函数 |
二倍数对数组
给定一个长度为偶数的整数数组 arr
,只有对 arr
进行重组后可以满足 “对于每个 0 <= i < len(arr) / 2
,都有 arr[2 * i + 1] = 2 * arr[2 * i]
” 时,返回 true
;否则,返回 false
。
解题思路
这道题的核心在于如何将数组进行重组,使得重组后的数组满足特定条件:对于每个 0 <= i < len(arr) / 2
,都有 arr[2 * i + 1] = 2 * arr[2 * i]
。这个条件实际上要求数组的一半元素是另一半元素的两倍。
为了实现这一点,我们可以采用以下步骤:
- 排序:将数组进行排序,以便我们能轻松地找到符合条件的元素对。
- 计数:使用一个计数器来记录数组中每个元素的出现次数。
- 匹配:从最小的元素开始,尝试匹配每个元素和它的两倍。如果能够找到这样的配对,则继续,否则返回
false
。
具体步骤
- 将数组进行排序。
- 遍历排序后的数组,使用一个哈希表来记录每个元素的出现次数。
- 对于每个元素,如果当前元素已经被匹配过(计数为零),则跳过。否则,检查它的两倍是否存在且计数大于零。如果存在,则将两个元素的计数都减一。
- 如果所有元素都能找到匹配,则返回
true
;否则,返回false
。
代码实现
1 | /** |
复杂度分析
- 时间复杂度:$O(n \log n)$,主要由排序步骤决定。
- 空间复杂度:$O(n)$,哈希表存储每个元素的计数。
总结
通过排序和计数相结合的方法,我们能够有效地判断数组是否能重组成满足条件的形式。这种方法确保了我们能在合理的时间和空间复杂度内解决问题。
动态规划
爬楼梯
假设你正在爬楼梯。需要 n
阶你才能到达楼顶。
每次你可以爬 1
或 2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
1 | /** |
解法2–动态规划
利用动态规划的思想
1 | /** |
杨辉三角
给定一个非负整数 numRows
,生成「杨辉三角」的前 numRows
行。
在「杨辉三角」中,每个数是它左上方和右上方的数的和。
1 | /** |
杨辉三角 ||
给定一个非负索引 rowIndex
,返回「杨辉三角」的第 rowIndex
行。
在「杨辉三角」中,每个数是它左上方和右上方的数的和。
- 我们定义了一个函数
getRow
,它接受一个整数rowIndex
作为参数,并返回杨辉三角的第rowIndex
行。 - 我们初始化了一个数组
dp
,它的长度为rowIndex + 1
,并且所有的元素都被初始化为1
。我们使用这个数组来存储当前行的值。 - 我们开始一个外部循环,从
1
开始一直到rowIndex
结束。这个循环用来生成每一行的值。 - 在内部循环中,我们从当前行的倒数第二个位置开始向前遍历,直到第一个位置。这是因为在杨辉三角中,除了第一个和最后一个位置的元素为
1
,其他位置的元素是由上一行的两个相邻元素相加而得到的。 - 在内部循环中,我们更新当前行的值。对于当前行的第
j
个元素,它的值等于上一行的第j - 1
个元素和第j
个元素之和。这就是杨辉三角的定义。 - 内部循环结束后,当前行的所有元素已经更新完毕。
- 最后,我们返回生成的第
rowIndex
行的数组dp
。
通过这段代码,我们可以生成杨辉三角的第 rowIndex
行。它的时间复杂度为 O(n^2),其中 n 是 rowIndex
的值。
1 | /** |
打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
1 | /** |
矩阵置零
给定一个 *m* x *n*
的矩阵,如果一个元素为 0 ,则将其所在行和列的所有元素都设为 0 。请使用 原地 算法。
1 | var setZeroes = function(matrix) { |
螺旋矩阵
给你一个 m
行 n
列的矩阵 matrix
,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。
1 | /** |
旋转图像
给定一个 n × n 的二维矩阵 matrix
表示一个图像。请你将图像顺时针旋转 90 度。
你必须在** 原地** 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。
1 | /** |
搜索二维矩阵 ||
编写一个高效的算法来搜索 *m* x *n*
矩阵 matrix
中的一个目标值 target
。该矩阵具有以下特性:
- 每行的元素从左到右升序排列。
- 每列的元素从上到下升序排列。
这道题的解题思想是利用矩阵的排序性质进行高效搜索。具体来说,每行的元素从左到右升序排列,每列的元素从上到下升序排列。这些性质允许我们从矩阵的某个角落开始,通过逐步排除不可能的区域来缩小搜索范围。我们可以选择从矩阵的右上角(或者左下角)开始搜索,因为这样我们可以根据当前元素和目标值的比较结果决定移动的方向,从而高效地进行搜索。
具体步骤
选择起始点:从矩阵的右上角开始搜索。右上角的元素是当前行中的最大值,同时也是当前列中的最小值。
比较目标值与当前元素
:
- 如果当前元素等于目标值,返回
true
。 - 如果当前元素小于目标值,说明目标值可能在当前行的下方,因此向下移动一行。
- 如果当前元素大于目标值,说明目标值可能在当前列的左侧,因此向左移动一列。
- 如果当前元素等于目标值,返回
更新搜索范围:根据比较结果,更新行或列的索引,继续搜索,直到找到目标值或搜索范围超出矩阵的边界。
返回结果:如果在搜索过程中找到目标值,返回
true
;如果搜索完成后仍未找到目标值,返回false
。
1 | /** |
复杂度分析
- 时间复杂度:$O(m + n)$,其中 $m$ 是矩阵的行数,$n$ 是矩阵的列数。在最坏的情况下,我们可能会从右上角走到左下角,遍历矩阵中的所有行和列。
- 空间复杂度:$O(1)$,只使用了常数级别的额外空间。
总结
这种方法充分利用了矩阵的排序性质,从右上角开始搜索,通过比较当前元素和目标值来决定移动方向,逐步排除不可能的区域,从而高效地找到目标值。