27.长度最小的数组
解题方式
- 这段代码利用滑动窗口 ,通过右指针不断扩展 窗口累加元素,并在和满足条件时移动左指针收缩 窗口,从而在 O(n)O (n ) 时间内找到满足和 ≥≥ 目标值的最小长度子数组。
java
public int minSubArrayLen(int target, int[] nums) {
int l = nums.length;
int sum =0,left=0;
int res = Integer.MAX_VALUE;
for(int right =0 ;right<l ;right++){
sum+=nums[right];
while(sum>=target){
res = Math.min(res,right-left+1);
sum-=nums[left];
left++;
}
}
return res == Integer.MAX_VALUE?0:res;
}
Kotlin
//数组长度 val len = nums.size
//最大值 var res = Int.MAX_VALUE
//最大值最小值 maxOf()/ minOf()
fun minSubArrayLen(target: Int, nums: IntArray): Int {
val len = nums.size
var sum = 0
var left = 0
var res = Int.MAX_VALUE
for (right in 0 until len) {
// 将右边界元素加入窗口
sum += nums[right]
// 当窗口和满足条件时,尝试收缩左边界
while (sum >= target) {
// 更新最小长度
res = minOf(res, right - left + 1)
// 移除左边界元素并移动左指针
sum -= nums[left]
left++
}
}
// 如果 res 未被更新过,说明没有找到满足条件的子数组,返回 0
return if (res == Int.MAX_VALUE) 0 else res
}
}
1.代码逻辑详解
这段代码利用滑动窗口的思想,在一次遍历中寻找满足条件的最小子数组长度。
- 初始化阶段 :
sum = 0:用于记录当前滑动窗口内元素的总和。left = 0:滑动窗口的左边界起始位置。res = Integer.MAX_VALUE:用于记录满足条件的最小长度。初始化为最大值,是为了方便后续进行比较取最小值(如果最后这个值没变,说明没找到符合条件的子数组)。
- 扩展窗口(移动右指针 right**)** :
- 通过
for循环遍历数组,right指针充当窗口的右边界。 sum += nums[right]:将当前右边界指向的元素加入窗口,更新当前窗口的总和。
- 通过
- 收缩窗口(移动左指针 left**)** :
while (sum >= target):这是一个核心判断。只要当前窗口内的元素和sum大于或等于目标值target,说明我们找到了一个合法的子数组。- 更新结果 :在
while循环内部,首先计算当前窗口的长度right - left + 1,并与历史最小值res比较,保留较小的那个。 - 尝试更优解 :为了寻找可能更短的子数组,我们需要缩小窗口。
sum -= nums[left]:将左边界元素从总和中减去。left++:左边界向右移动一位。
- 这个过程会一直持续,直到窗口内的和小于
target为止。
- 返回结果 :
- 最后判断
res是否仍为Integer.MAX_VALUE。如果是,说明从未进入过while循环(即没有任何子数组和满足条件),返回0;否则返回记录的最小长度res。
- 最后判断
2. 关键点
- 窗口收缩的条件 :必须是
while (sum >= target)。这里不能写成if,因为当找到一个满足条件的窗口后,可能需要连续移动多次left才能让sum再次小于target。例如,如果数组元素很小而target很大,可能移动一次不够;或者当前窗口非常大,移动一次后可能仍然满足条件,我们需要一直移动到刚好不满足为止,以确保找到以当前right结尾的最短子数组。 - 长度的计算 :数组下标是从 0 开始的,所以从
left到right的长度是right - left + 1。 - 哨兵值的使用 :使用
Integer.MAX_VALUE作为初始值是一个常用的技巧,用来标记"尚未找到解"的状态。
3. 复杂度分析
- 时间复杂度:O(N)
- 其中 NN 是数组的长度。
- 虽然代码中有两层循环(
for和while),但这并不是 O(N2)O (N 2) 。因为每个元素最多被访问两次:一次是被右指针right加入窗口,另一次是被左指针left移出窗口。因此,操作总次数是线性的,即 2N2N ,简化为 O(N)O (N) 。
- 空间复杂度:O(1)
- 只需要常数级别的额外空间来存储变量
sum、left、right和res,不需要依赖输入数组大小的额外存储空间。
- 只需要常数级别的额外空间来存储变量
4. 优缺点
- 优点 :
- 高效 :相比于暴力解法(双重循环枚举所有子数组,时间复杂度 O(N2)O (N 2) ),滑动窗口将时间复杂度降低到了 O(N)O (N) ,在处理大规模数据时性能优势非常明显。
- 逻辑清晰:代码结构简洁,通过维护两个指针即可动态调整窗口大小,易于理解和实现。
- 缺点 :
- 适用场景受限 :这种"快慢指针"式的滑动窗口主要适用于数组元素均为正整数的情况。
- 局限性说明 :如果数组中包含负数,
sum随着right的增加不一定增加,随着left的增加不一定减少,此时while (sum >= target)的逻辑就会失效(因为缩小窗口可能会导致和变大,或者扩大窗口导致和变小),这种算法就不再适用了。
28.删除倒数第N个链表
- 代码通过引入哑结点 简化边界处理,先遍历链表计算总长度 ,再根据长度定位到待删除节点的前驱,最后修改指针指向完成删除。
java
public ListNode removeNthFromEnd(ListNode head, int n) {
//建立含头节点的节点,防止删除头节点无法操作
ListNode dummy = new ListNode(0, head);
int length = getLength(head);
ListNode cur = dummy;
for (int i = 1; i < length - n + 1; ++i) {
cur = cur.next;
}
cur.next = cur.next.next;
ListNode ans = dummy.next;
return ans;
}
public int getLength(ListNode head) {
int length = 0;
while (head != null) {
++length;
head = head.next;
}
return length;
}
}
1.代码逻辑详解
这段代码的核心思路非常直观:要删除倒数第 NN 个节点,首先需要知道链表的总长度,从而算出该节点在正数第几个位置。
- 引入哑结点 :
- 创建一个哑结点
dummy,其next指向head。 - 目的 :处理边界情况。如果链表只有一个节点且需要删除它,或者需要删除头结点,直接操作
head会非常麻烦。使用dummy可以让我们统一处理所有节点的删除逻辑,最后返回dummy.next即可。
- 创建一个哑结点
- 第一次遍历(计算长度) :
- 调用
getLength(head)方法。 - 该方法通过
while循环遍历整个链表,统计节点总数length。
- 调用
- 定位待删除节点的前驱 :
- 我们需要删除的是倒数第 NN 个节点。
- 假设链表长度为 LL ,那么倒数第 NN 个节点就是正数第 L−N+1L −N+1 个节点。
- 为了执行删除操作(
cur.next = cur.next.next),我们需要找到待删除节点的前一个节点 (即第 L−NL −N 个节点)。 - 代码中
cur初始化为dummy(相当于第 0 个节点)。 for循环从i = 1执行到i < length - n + 1。这意味着循环体执行了 L−NL −N 次,cur指针最终停留在第 L−NL −N 个节点上。
- 执行删除 :
cur.next = cur.next.next:跳过待删除的节点,将其前驱直接连接到其后继。
- 返回结果 :
- 返回
dummy.next,即处理后的新链表头结点。
- 返回
2. 关键点
- 哑结点(Dummy Node) :这是链表题目中的经典技巧。它避免了在删除头结点时需要单独判断
head == null或返回head.next的复杂逻辑,使代码更加健壮。 - 索引计算 :
- 链表长度: LL
- 目标节点位置(从1开始): L−N+1L −N+1
- 目标前驱节点位置(从0开始,dummy为0): L−NL −N
- 循环条件
i < length - n + 1实际上就是让cur走 L−NL −N 步,正好停在待删除节点的前一位。
- 边界情况 :例如链表长度为 5,删除倒数第 1 个(即正数第 5 个)。
cur需要走到第 4 个节点。公式 5−1=45−1=4 ,逻辑成立。
3. 复杂度分析
- 时间复杂度:O(L)
- 其中 LL 是链表的长度。
getLength方法遍历了一次链表,耗时 O(L)O (L) 。- 主函数中的
for循环遍历了部分链表(从头到待删除节点前),耗时 O(L)O (L) 。 - 总时间复杂度为 O(L)+O(L)=O(L)O (L )+O (L )=O (L) 。
- 空间复杂度:O(1)
- 只需要常数级别的额外空间来存储
dummy、cur、length等变量,不依赖链表长度。
- 只需要常数级别的额外空间来存储
4. 优缺点
- 优点 :
- 逻辑简单:符合直觉,先算总数再找位置,容易理解和编写,不易出错。
- 代码清晰:将"计算长度"封装成独立函数,主逻辑非常干净。
- 缺点 :
- 遍历次数:需要遍历链表两次(一次算长度,一次找位置)。虽然时间复杂度量级相同,但在实际运行中比"一次遍历"的方法(如双指针/快慢指针法)稍慢。
- 改进空间 :可以使用快慢指针 技巧。让快指针先走 NN 步,然后快慢指针同时移动,当快指针到达末尾时,慢指针正好指向待删除节点的前驱。这样只需要一次遍历。
29.层序遍历
解题方式
- 利用队列 实现广度优先搜索 ,通过记录每一层的节点数量来分层处理,依次将节点出队取值并将其子节点入队,从而完成二叉树的层序遍历。
java
//offeer() 加到末尾
//poll() 删除并返回第一个元素
public List<List<Integer>> levelOrder(TreeNode root) {
// 结果列表,每个元素是一个 List<Integer>,代表一层的节点值
List<List<Integer>> res = new ArrayList<>();
// 如果根节点为空,直接返回空结果
if (root == null) return res;
// 使用 LinkedList 作为队列,实现广度优先搜索(BFS)
// offer() 方法在尾部添加元素,poll() 方法从头部取出并删除元素
LinkedList<TreeNode> queue = new LinkedList<>();
queue.offer(root); // 先将根节点入队
// 当队列不为空时,说明还有节点未处理
while (!queue.isEmpty()) {
// 临时列表,用于存储当前层的所有节点值
List<Integer> levelValues = new ArrayList<>();
// 当前队列的大小就是当前层的节点个数(因为上一层节点已经全部出队)
int size = queue.size();
// 循环处理当前层的每一个节点
while (size != 0) {
// 弹出队首节点(当前层的节点)
TreeNode currentNode = queue.poll();
// 将当前节点的值加入当前层的列表
levelValues.add(currentNode.val);
// 如果左子节点不为空,则将其加入队列(下一层)
if (currentNode.left != null) queue.offer(currentNode.left);
// 如果右子节点不为空,则将其加入队列(下一层)
if (currentNode.right != null) queue.offer(currentNode.right);
// 当前层剩余待处理节点数减1
size--;
}
// 当前层所有节点处理完毕,将这一层的结果加入最终结果列表
res.add(levelValues);
}
// 返回完整的层序遍历结果
return res;
}
Kotlin
fun levelOrder(root: TreeNode?): List<List<Int>> {
val res = ArrayList<List<Int>>()
// 如果根节点为空,直接返回空结果
if (root == null) return res
// Kotlin 中通常使用 ArrayDeque 来实现队列,性能优于 LinkedList
val queue: Queue<TreeNode> = ArrayDeque()
queue.offer(root)
// 当队列不为空时,说明还有节点未处理
while (queue.isNotEmpty()) {
val levelValues = ArrayList<Int>()
// 记录当前层的节点数量
val size = queue.size
// 循环处理当前层的每一个节点
for (i in 0 until size) {
// 弹出队首节点
val currentNode = queue.poll()
// 将当前节点的值加入当前层的列表
levelValues.add(currentNode.`val`)
// 如果左子节点不为空,加入队列
if (currentNode.left != null) queue.offer(currentNode.left)
// 如果右子节点不为空,加入队列
if (currentNode.right != null) queue.offer(currentNode.right)
}
// 当前层处理完毕,加入结果列表
res.add(levelValues)
}
return res
}
1. 代码逻辑详解
这段代码使用了广度优先搜索 的策略,借助一个队列来实现二叉树的层序遍历。
- 初始化与边界处理 :
- 创建一个结果列表
res,用于存储最终的层序遍历结果。 - 首先判断根节点
root是否为空。如果为空,直接返回空列表,避免空指针异常。
- 创建一个结果列表
- 队列初始化 :
- 使用
LinkedList创建一个队列queue,并将根节点root加入队列(offer)。 - 队列的作用是暂存当前层以及下一层需要访问的节点,遵循"先进先出"的原则。
- 使用
- 外层循环(控制层数) :
while (!queue.isEmpty()):只要队列不为空,说明树中还有节点未被访问。- 每次进入外层循环,意味着开始处理新的一层。
- 内层循环(处理当前层) :
- 创建一个临时列表
levelValues,用于存储当前层所有节点的值。 - 关键步骤 :
int size = queue.size()。在开始处理当前层之前,先记录队列的大小。这个size正好是当前层节点的总数(因为上一层的节点已经全部出队,而当前层的节点已经全部入队)。 while (size != 0):循环size次,确保只处理当前层的节点,而不混入下一层新加入的节点。queue.poll():取出队首节点(即当前层的一个节点)。levelValues.add(...):将该节点的值存入临时列表。- 子节点入队 :如果当前节点有左子节点或右子节点,将它们依次加入队列。注意,这些新加入的节点属于下一层,它们会在下一次外层循环中被处理。
size--:当前层待处理节点数减 1。
- 创建一个临时列表
- 收集结果 :
- 当内层循环结束时,说明当前层的所有节点都已处理完毕。
- 将
levelValues(当前层的节点值列表)加入到最终结果res中。
2. 关键点
- 队列的作用:队列是 BFS 的核心数据结构。它保证了节点是按照"从上到下、从左到右"的顺序被访问的。
- "快照"思想( size****变量) :这是层序遍历最关键的技巧。因为在内层循环中,我们会不断向队列中添加下一层的节点,导致队列长度动态变化。如果不先记录
size,就无法区分哪些节点属于当前层,哪些属于下一层。通过固定size,我们将每一层的处理过程隔离开来。 - 空树处理 :代码开头对
root == null的判断是必不可少的鲁棒性检查。
3. 复杂度分析
- 时间复杂度:O(N)
- 其中 NN 是二叉树的节点总数。
- 每个节点都会被入队一次、出队一次,且仅被访问一次。因此总的时间复杂度是线性的。
- 空间复杂度:O(W)
- 其中 WW 是二叉树的最大宽度(即某一层拥有的最大节点数)。
- 队列中最多同时存储一层的所有节点。在最坏情况下(例如完全二叉树的最后一层),空间复杂度为 O(N)O (N ) ;在最好情况下(链状树),空间复杂度为 O(1)O(1) 。平均来看,空间复杂度取决于树的最大宽度。
4. 优缺点
- 优点 :
- 逻辑清晰:利用队列模拟层级扩散的过程,非常符合人类对"层"的直观理解。
- 通用性强:这是解决二叉树层级相关问题(如求平均层值、锯齿形遍历、寻找最底层最左边节点等)的标准模板。
- 缺点 :
- 空间开销:相比于深度优先搜索,BFS 需要维护一个队列来存储节点。对于宽度非常大的树(例如完全二叉树),队列可能会占用较多内存。
- 递归替代:虽然也可以用深度优先搜索配合记录"当前深度"来通过递归实现层序遍历,但那种方法需要额外的逻辑来管理层级索引,不如 BFS 直观