快速学完 LeetCode top 1~50

文章目录

LeetCode top 1~50

「哈希」

1. 两数之和

题目描述

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。

你可以假设每种输入只会对应一个答案,并且你不能使用两次相同的元素。

你可以按任意顺序返回答案。

核心思路

使用 HashMap 存储 val 到 Index 的映射。

题解代码

java 复制代码
class Solution {
    public int[] twoSum(int[] nums, int target) {
        // 1.由于返回的是索引,所以需要用一个 Map 存储 val 到 Index 的映射
        HashMap<Integer, Integer> valToIndex = new HashMap<>();

        // 2.遍历迭代
        for(int i = 0; i < nums.length; i++){
            int need = target - nums[i];
            if(valToIndex.containsKey(need)){
                return new int[]{valToIndex.get(need), i};
            }
            valToIndex.put(nums[i], i);
        }
        return null;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度 :代码包含一个 for 循环,该循环遍历数组 nums 一次。在每次循环中,执行的操作如计算差值、在 HashMap 中查找和插入元素,平均情况下,这些操作的时间复杂度均为 (O(1))。所以总的时间复杂度为 (O(n)\times O(1)= O(n))。
    • 空间复杂度 :代码中使用了一个 HashMap 来存储数组中的元素及其索引。在最坏情况下,数组中的所有元素都需要存储到 HashMap 中,因此空间复杂度与数组的长度 n n n 成正比,即 (O(n))。

49. 字母异位词分组

题目描述

给你一个字符串数组,请你将字母异位词组合在一起。可以按任意顺序返回结果列表。

核心思路

数据编码和 HashMap -> 找到一种编码方法,使得字母异位词的编码都相同。找到这种编码方式之后,就可以用一个哈希表存储编码相同的所有异位词,得到最终的答案。

编码方法:利用每个字符出现的次数进行编码。 "10001...000"

题解代码

java 复制代码
class Solution {
    public List<List<String>> groupAnagrams(String[] strs) {
        // 编码到分组的映射
        HashMap<String, List<String>> codeToGroup = new HashMap<>();
        for (String s : strs) {
            // 对字符串进行编码
            String code = encode(s);
            // 把编码相同的字符串放在一起
            codeToGroup.putIfAbsent(code, new LinkedList<>());
            codeToGroup.get(code).add(s);
        }

        // 获取结果
        List<List<String>> res = new LinkedList<>();
        for (List<String> group : codeToGroup.values()) {
            res.add(group);
        }

        return res;
    }

    // 利用每个字符的出现次数进行编码
    String encode(String s) {
        char[] count = new char[26];
        for (char c : s.toCharArray()) {
            int delta = c - 'a';
            count[delta]++;
        }
        return new String(count);
    }
}

时空分析

  • 时间复杂度:(O(n \cdot k))
  • 空间复杂度:(O(n \cdot k))
  • 原因分析
    • 时间复杂度
      • 外层有一个 for 循环遍历 strs 数组,循环次数为 n,n 是 strs 数组的长度。
      • 对于 strs 中的每一个字符串,都会调用 encode 方法。在 encode 方法中,又有一个 for 循环遍历字符串 s,假设字符串平均长度为 k,这个循环时间复杂度为 (O(k))。
      • 此外,在 groupAnagrams 方法的 for 循环中,每次循环还有平均时间复杂度为 (O(1)) 的 putIfAbsentadd 操作。所以整体时间复杂度为 (O(n)\times O(k + 1)= O(n \cdot k))。
    • 空间复杂度
      • 代码中使用了一个 HashMap 来存储编码后的字符串及其对应的字符串列表。在最坏情况下,所有不同的编码字符串都不同,且每个编码字符串对应一个长度为 k 的字符串列表。每个编码字符串长度为 26(常数),假设平均每个编码字符串对应一个长度为 k 的字符串列表,所以 HashMap 占用空间为 (O(n \cdot k))。
      • 此外,res 列表存储所有分组后的结果,在最坏情况下,它也占用 (O(n \cdot k)) 的空间。因此,总的空间复杂度为 (O(n \cdot k))。

128. 最长连续序列

题目描述

给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。

请你设计并实现时间复杂度为 O(n) 的算法解决此问题。

核心思路

HashSet -> 想找连续序列,首先要找到这个连续序列的开头元素,然后递增,看看之后有多少个元素还在 nums 中,即可得到最长连续序列的长度了。

题解代码

java 复制代码
class Solution {
    public int longestConsecutive(int[] nums) {
        // 转化成哈希集合,方便快速查找是否存在某个元素
        HashSet<Integer> set = new HashSet<Integer>();
        for (int num : nums) {
            set.add(num);
        }

        int res = 0;

        for (int num : set) {
            if (set.contains(num - 1)) {
                // num 不是连续子序列的第一个,跳过
                continue;
            }
            // num 是连续子序列的第一个,开始向上计算连续子序列的长度
            int curNum = num;
            int curLen = 1;

            while (set.contains(curNum + 1)) {
                curNum += 1;
                curLen += 1;
            }
            // 更新最长连续序列的长度
            res = Math.max(res, curLen);
        }

        return res;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度
      • 首先,有一个 for 循环遍历数组 nums 并将元素添加到 HashSet 中,这个过程的时间复杂度为 (O(n)),因为向 HashSet 中添加元素平均时间复杂度是 (O(1)),总共添加 n 个元素。
      • 然后,又有一个 for 循环遍历 HashSet 中的元素。在这个循环中,对于每个元素,首先检查其前驱元素是否存在,如果不存在,则从该元素开始查找连续序列的长度。虽然这里有一个 while 循环,但对于每个连续序列,只会从序列的起始元素进入 while 循环一次。例如,对于序列 [1, 2, 3],只有 1 会进入 while 循环来计算这个序列的长度。因此,这个 for 循环的总时间复杂度也是 (O(n))。所以整体时间复杂度为 (O(n + n)= O(n))。
    • 空间复杂度
      • 代码中使用了一个 HashSet 来存储数组中的元素。在最坏情况下,数组中的所有元素都不相同,HashSet 需要存储 n 个元素,所以空间复杂度为 (O(n))。

「双指针」

283. 移动零

题目描述

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

请注意 ,必须在不复制数组的情况下原地对数组进行操作。

核心思路

快慢指针 -> 先移除所有 0,然后把最后的元素都置为 0,就相当于移动 0 的效果。

题解代码

java 复制代码
class Solution {
    public void moveZeroes(int[] nums) {
        // 去除 nums 中的所有 0
        // 返回去除 0 之后的数组长度
        int p = removeElement(nums, 0);
        // 将 p 之后的所有元素赋值为 0
        for (; p < nums.length; p++) {
            nums[p] = 0;
        }
    }

    // 双指针技巧,复用 [27. 移除元素] 的解法。
    int removeElement(int[] nums, int val) {
        int fast = 0, slow = 0;
        while (fast < nums.length) {
            if (nums[fast] != val) {
                nums[slow] = nums[fast];
                slow++;
            }
            fast++;
        }
        return slow;
    }
}

时空分析

  • 时间复杂度:(O(n))

  • 空间复杂度:(O(1))

  • 原因分析

    • 时间复杂度:代码的核心操作集中在两个方法的循环中:

      • removeElement 方法中,fast 指针从 0 遍历到数组末尾(共 n 次,n 为数组长度),循环内操作均为常数时间 (O(1)),因此该方法时间复杂度为 (O(n));

      • moveZeroes 方法中,除调用 removeElement 外,还包含一个从 (i = r) 到 (i = n-1) 的循环,该循环执行次数为 (n - r)(r 为 removeElement 的返回值),最多为 n 次,时间复杂度为 (O(n))。

        总操作次数为 (O(n) + O(n) = O(n)),忽略常数系数后,整体时间复杂度为 (O(n))。

    • 空间复杂度 :两个方法均仅使用固定数量的临时变量(如 fastslowri),未申请额外数组或动态内存,空间占用不随输入规模 n 变化,因此空间复杂度为常数级 (O(1))。

11. 盛最多水的容器

题目描述

给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0)(i, height[i])

找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

返回容器可以储存的最大水量。

说明: 你不能倾斜容器。

核心思路

左右指针 -> 用 leftright 两个指针从两端向中心收缩,一边收缩一边计算 [left, right] 之间的矩形面积,取最大的面积值即是答案。

题解代码

java 复制代码
class Solution {
    public int maxArea(int[] height) {
        int left = 0, right = height.length - 1;
        int res = 0;
        while (left < right) {
            // [left, right] 之间的矩形面积
            int cur_area = Math.min(height[left], height[right]) * (right - left);
            res = Math.max(res, cur_area);
            // 双指针技巧,移动较低的一边
            if (height[left] < height[right]) {
                left++;
            } else {
                right--;
            }
        }
        return res;
    }
}

时空分析

  • 时间复杂度:(O(n))

  • 空间复杂度:(O(1))

  • 原因分析

    • 时间复杂度 :代码使用双指针法,核心是一个 while 循环:

      • 初始时左指针 left = 0,右指针 right = n - 1(n 为数组长度);

      • 每次循环中,指针只会向中间移动一次(left++right--);

      • 循环终止条件为 left >= right,最多执行(n-1)次(指针从两端移动到相邻位置);

      • 循环内部操作(计算面积、更新结果、移动指针)均为常数时间 (O(1))。

        总操作次数为 (O(n)),因此时间复杂度为 (O(n))。

    • 空间复杂度 :仅使用固定数量的临时变量(leftrightresarea),未申请额外的数组或动态内存空间,空间占用不随输入规模 n 变化,因此空间复杂度为常数级 (O(1))。

15. 三数之和

题目描述

给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != ji != kj != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。

注意: 答案中不可以包含重复的三元组。

核心思路

nSumTarget 模版。

排序 + 双指针 -> 先给数组从小到大排序,然后双指针 lohi 分别在数组开头和结尾,这样就可以控制 nums[lo]nums[hi] 这两数之和的大小。如果你想让它俩的和大一些,就让 lo++,如果你想让它俩的和小一些,就让 hi--

题解代码

java 复制代码
class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        Arrays.sort(nums);
        // n 为 3,从 nums[0] 开始计算和为 0 的三元组
        return nSumTarget(nums, 3, 0, 0);
    }

    // 注意:调用这个函数之前一定要先给 nums 排序
    // n 填写想求的是几数之和,start 从哪个索引开始计算(一般填 0),target 填想凑出的目标和
    List<List<Integer>> nSumTarget(int[] nums, int n, int start, long target) {
        int sz = nums.length;
        List<List<Integer>> res = new ArrayList<>();
        // 至少是 2Sum,且数组大小不应该小于 n
        if (n < 2 || sz < n) return res;
        // 2Sum 是 base case
        if (n == 2) {
            // 双指针那一套操作
            int lo = start, hi = sz - 1;
            while (lo < hi) {
                int sum = nums[lo] + nums[hi];
                int left = nums[lo], right = nums[hi];
                if (sum < target) {
                    while (lo < hi && nums[lo] == left) lo++;
                } else if (sum > target) {
                    while (lo < hi && nums[hi] == right) hi--;
                } else {
                    res.add(new ArrayList<>(Arrays.asList(left, right)));
                    while (lo < hi && nums[lo] == left) lo++;
                    while (lo < hi && nums[hi] == right) hi--;
                }
            }
        } else {
            // n > 2 时,递归计算 (n-1)Sum 的结果
            for (int i = start; i < sz; i++) {
                List<List<Integer>> sub = nSumTarget(nums, n - 1, i + 1, target - nums[i]);
                for (List<Integer> arr : sub) {
                    // (n-1)Sum 加上 nums[i] 就是 nSum
                    arr.add(nums[i]);
                    res.add(arr);
                }
                while (i < sz - 1 && nums[i] == nums[i + 1]) i++;
            }
        }
        return res;
    }
}

时空分析

  • 时间复杂度:(O(n^2))

  • 空间复杂度:(O(\log n))

  • 原因分析:

    • 时间复杂度

      • 首先对数组进行排序,排序操作的时间复杂度为 (O(n \log n))(其中 n 为数组长度)。
      • 核心逻辑通过递归实现 nSum 计算,对于本题的 threeSum(即 (n = 3)):
        • 外层有一个遍历数组的循环,时间复杂度为 (O(n));
        • 每次循环内部调用 2Sum 逻辑,2Sum 通过双指针实现,时间复杂度为 (O(n));
        • 因此 3Sum 的整体计算复杂度为 (O(n \times n) = O(n^2))。
      • 由于 (O(n^2)) 的增长速度快于排序的 (O(n \log n)),最终时间复杂度由 (O(n^2)) 主导。
    • 空间复杂度

      • 主要来源于两部分:

        • 排序过程中(Java 的 Arrays.sort 对基本类型使用双轴快排)的递归栈空间,复杂度为 (O(\log n));
        • 递归调用 nSumTarget 的栈深度,对于 threeSum 仅从 3Sum 递归到 2Sum,深度为常数级((O(1)))。
      • 因此整体空间复杂度由排序的递归栈空间主导,为 (O(\log n))。

42. 接雨水

题目描述

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

核心思路

对于任意一个位置 i,能够装的水为:

java 复制代码
water[i] = min(
           # 左边最高的柱子
           max(height[0..i]),
           # 右边最高的柱子
           max(height[i..end])
        ) - height[i]

然后将这些位置求和即可。

题解代码

java 复制代码
class Solution {
    public int trap(int[] height) {
        if (height.length == 0) {
            return 0;
        }
        int n = height.length;
        int res = 0;
        // 数组充当备忘录
        int[] l_max = new int[n];
        int[] r_max = new int[n];
        // 初始化 base case
        l_max[0] = height[0];
        r_max[n - 1] = height[n - 1];
        // 从左向右计算 l_max
        for (int i = 1; i < n; i++)
            l_max[i] = Math.max(height[i], l_max[i - 1]);
        // 从右向左计算 r_max
        for (int i = n - 2; i >= 0; i--)
            r_max[i] = Math.max(height[i], r_max[i + 1]);
        // 计算答案
        for (int i = 1; i < n - 1; i++)
            res += Math.min(l_max[i], r_max[i]) - height[i];
        return res;
    }
}

时空分析

  • 时间复杂度:(O(n))

  • 空间复杂度:(O(n))

  • 原因分析

    • 时间复杂度:代码包含三个独立的线性循环,均基于数组长度 n 执行:

      • 第一个循环(计算左侧最大值数组 (l_max))从 (i = 1) 到 (i = n-1),共执行 (n-1) 次;

      • 第二个循环(计算右侧最大值数组 (r_max))从 (j = n-2) 到 (j = 0),共执行 (n-1) 次;

      • 第三个循环(累加总接水量)从 (i = 0) 到 (i = n-1),共执行 n 次。

        总操作次数为 ((n-1) + (n-1) + n = 3n - 2),根据大 O 表示法的定义,忽略常数项和系数后,时间复杂度为(O(n))。

    • 空间复杂度:额外使用了两个长度为 n 的数组 (l_max) 和 (r_max),用于存储每个位置的左侧最大值和右侧最大值,总额外空间为 2n;其他变量(如 n、i、j、res)仅占用常数空间。忽略系数后,空间复杂度为 (O(n))。


「滑动窗口」

3. 无重复字符的最长子串

题目描述

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

核心思路

用一个 HashMap(CharToCount) 统计窗口中出现的字符的数量。当 window[c] 值大于 1 时,说明窗口中存在重复字符,不符合条件,就该移动 left 缩小窗口了。

题解代码

java 复制代码
class Solution {
    public int lengthOfLongestSubstring(String s) {
        Map<Character, Integer> window = new HashMap<>();

        int left = 0, right = 0;
        // 记录结果
        int res = 0;
        while (right < s.length()) {
            char c = s.charAt(right);
            right++;
            // 进行窗口内数据的一系列更新
            window.put(c, window.getOrDefault(c, 0) + 1);
            // 判断左侧窗口是否要收缩
            while (window.get(c) > 1) {
                char d = s.charAt(left);
                left++;
                // 进行窗口内数据的一系列更新
                window.put(d, window.get(d) - 1);
            }
            // 在这里更新答案
            res = Math.max(res, right - left);
        }
        return res;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度 :(O(min(n, m))),其中 n 是字符串 s 的长度,m 是字符集的大小
  • 原因分析
    • 时间复杂度
      • 代码使用滑动窗口算法,通过 while 循环遍历字符串 sright 指针从字符串开头开始,逐步向右移动,最多移动 n 次,其中 n 是字符串 s 的长度。
      • 对于每个 right 指针移动,虽然存在一个内层 while 循环用于收缩左侧窗口,但从整体来看,left 指针也最多向右移动 n 次。也就是说,两个指针移动的总次数最多为 2n 次。因此,整体时间复杂度为 (O(n))。
    • 空间复杂度
      • 代码中使用了一个 HashMap 来存储窗口内的字符及其出现的次数。在最坏情况下,窗口内可能包含所有不同的字符。如果字符集大小为 m,那么 HashMap 最多会存储 m 个不同的字符。当 (m < n) 时,空间复杂度为 (O(m));当 (m \geq n) 时,窗口内最多只能容纳 n 个不同字符,此时空间复杂度为 (O(n))。所以空间复杂度为 (O(min(n, m)))。

438. 找到字符串中所有字母异位词

题目描述

给定两个字符串 sp,找到 s 中所有 p异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。

核心思路

先用一个 HashMap 统计 p 中各个字符出现的次数,need。然后在用一个 HashMap 统计 window 中各字符出现的次数。如果 window 满足了 need(用一个 valid 实现),就将索引添加到结果列表中。

什么时候扩大窗口? right 一直往右遍历。

什么时候缩小窗口? right - left >= p.length() 时缩小窗口。

什么时候更新答案? 当缩小窗口时,如果满足 valid 则更新答案。

note:Integer 包装器类型,判断值是否相等要使用 .equals()

题解代码

java 复制代码
class Solution {
    public List<Integer> findAnagrams(String s, String t) {
        Map<Character, Integer> need = new HashMap<>();
        Map<Character, Integer> window = new HashMap<>();
        for (char c : t.toCharArray()) {
            need.put(c, need.getOrDefault(c, 0) + 1);
        }

        int left = 0, right = 0;
        int valid = 0;
        // 记录结果
        List<Integer> res = new ArrayList<>();
        while (right < s.length()) {
            char c = s.charAt(right);
            right++;
            // 进行窗口内数据的一系列更新
            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (window.get(c).equals(need.get(c))) {
                    valid++;
                }
            }
            // 判断左侧窗口是否要收缩
            while (right - left >= t.length()) {
                // 当窗口符合条件时,把起始索引加入 res
                if (valid == need.size()) {
                    res.add(left);
                }
                char d = s.charAt(left);
                left++;
                // 进行窗口内数据的一系列更新
                if (need.containsKey(d)) {
                    if (window.get(d).equals(need.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d) - 1);
                }
            }
        }
        return res;
    }
}

时空分析

  • 时间复杂度 :(O(n + m)),其中 n 是字符串 s 的长度,m 是字符串 p 的长度
  • 空间复杂度:(O(m))
  • 原因分析
    • 时间复杂度
      • 首先有一个 for 循环遍历字符串 p,将其字符及出现次数存入 need 哈希表,这个操作的时间复杂度为 (O(m)),因为 p 的长度为 m,每次插入操作平均时间复杂度为 (O(1))。
      • 然后通过一个 while 循环遍历字符串 sright 指针最多移动 n 次。每次移动 right 指针时,对窗口内数据的更新(如判断字符是否在 need 中,更新 window 哈希表等)操作时间复杂度为常数 (O(1))。
      • 当窗口大小达到 p 的长度时,会进行窗口收缩操作,left 指针最多移动 n 次,每次收缩时对窗口内数据的更新操作时间复杂度也为常数 (O(1))。
      • 因此,总的时间复杂度为 (O(n + m))。
    • 空间复杂度
      • 代码中使用了两个哈希表 windowneedneed 哈希表存储字符串 p 中不同字符及其出现次数,其大小取决于 p 中不同字符的种类,最多为 m 个不同字符,所以 need 占用空间为 (O(m))。
      • window 哈希表存储窗口内的字符及出现次数,其大小不会超过 need 哈希表的大小,所以也为 (O(m))。
      • 此外,存储结果的 res 列表占用空间取决于找到的异位词的数量,在最坏情况下,所有长度为 m 的子串都是异位词,数量为 (n - m + 1),但这部分空间通常不计算在空间复杂度内(因为它是输出结果所需要的空间)。所以整体空间复杂度为 (O(m))。

「子串」

560. 和为 K 的子数组

题目描述

给你一个整数数组 nums 和一个整数 k ,请你统计并返回该数组中和为 k 的子数组的个数。

子数组是数组中元素的连续非空序列。

核心思路

前缀和 和 HashMap -> 需要在维护 preSum 前缀和数组的同时动态维护 count 映射,而不能等到 preSum 计算完成后再处理 count,因为 count[need] 应该维护 preSum[0..i] 中值为 need 的元素个数。

题解代码

java 复制代码
class Solution {
    public int subarraySum(int[] nums, int k) {
        int n = nums.length;
        // 前缀和数组
        int[] preSum = new int[n + 1];
        preSum[0] = 0;
        // 前缀和到该前缀和出现次数的映射,方便快速查找所需的前缀和
        HashMap<Integer, Integer> count = new HashMap<>();
        count.put(0, 1);
        // 记录和为 k 的子数组个数
        int res = 0;

        // 计算 nums 的前缀和
        for (int i = 1; i <= n; i++) {
            preSum[i] = preSum[i - 1] + nums[i - 1];
            // 如果之前存在值为 need 的前缀和
            // 说明存在以 nums[i-1] 结尾的子数组的和为 k
            int need = preSum[i] - k;
            if (count.containsKey(need)) {
                res += count.get(need);
            }
            // 将当前前缀和存入哈希表
            if (!count.containsKey(preSum[i])) {
                count.put(preSum[i], 1);
            } else {
                count.put(preSum[i], count.get(preSum[i]) + 1);
            }
        }
        return res;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度
      • 整个代码由一个 for 循环构成,该循环会迭代 n 次,n 为数组 nums 的长度。
      • 在每次循环体中,主要操作包括计算前缀和 preSum[i]、计算差值 need、在 HashMapvalToCount)中进行查找和插入操作。这些操作在平均情况下时间复杂度均为常数 (O(1))。
      • 根据时间复杂度计算规则,总的时间复杂度为循环次数乘以每次循环的时间复杂度,即 (O(n)\times O(1)= O(n))。
    • 空间复杂度
      • 代码使用了一个 HashMapvalToCount),在最坏情况下,所有的前缀和都不同,HashMap 需要存储 (n + 1) 个键值对(因为前缀和数组 preSum 的长度为 (n + 1)),因此其空间复杂度为 (O(n))。
      • 同时,代码还使用了长度为 (n + 1) 的前缀和数组 preSum,其空间复杂度也是 (O(n))。通常在分析空间复杂度时,取这些额外空间中的最大值,所以整体空间复杂度为 (O(n))。

239. 滑动窗口最大值

题目描述

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

核心思路

单调队列作为滑动窗口。

使用一个队列充当不断滑动的窗口,每次滑动记录其中的最大值:

如何在 O(1) 时间计算最大值,只需要一个特殊的数据结构「单调队列」,push 方法依然在队尾添加元素,但是要把前面比自己小的元素都删掉,直到遇到更大的元素才停止删除。

题解代码

java 复制代码
class Solution {
    // 单调队列的实现
    class MonotonicQueue {
        LinkedList<Integer> q = new LinkedList<>();
        public void push(int n) {
            // 将小于 n 的元素全部删除
            while (!q.isEmpty() && q.getLast() < n) {
                q.pollLast();
            }
            // 然后将 n 加入尾部
            q.addLast(n);
        }

        public int max() {
            return q.getFirst();
        }

        public void pop(int n) {
            if (n == q.getFirst()) {
                q.pollFirst();
            }
        }
    }

    // 解题函数的实现
    public int[] maxSlidingWindow(int[] nums, int k) {
        MonotonicQueue window = new MonotonicQueue();
        List<Integer> res = new ArrayList<>();

        for (int i = 0; i < nums.length; i++) {
            if (i < k - 1) {
                // 先填满窗口的前 k - 1
                window.push(nums[i]);
            } else {
                // 窗口向前滑动,加入新数字
                window.push(nums[i]);
                // 记录当前窗口的最大值
                res.add(window.max());
                // 移出旧数字
                window.pop(nums[i - k + 1]);
            }
        }
        // 需要转成 int[] 数组再返回
        int[] arr = res.stream().mapToInt(Integer::intValue).toArray();
        return arr;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(k))
  • 原因分析
    • 时间复杂度
      • 代码中有一个 for 循环遍历数组 nums,循环次数为 n,n 是数组 nums 的长度。
      • 对于 for 循环中的每次迭代,window.push(nums[i]) 操作虽然包含一个 while 循环,但从整体来看,每个元素最多被添加和删除一次,因此均摊下来每次 push 操作的时间复杂度为 (O(1))。window.max() 操作的时间复杂度为 (O(1)),因为只是返回队列头部元素。window.pop(nums[i - k + 1]) 操作的时间复杂度也为 (O(1))。
      • 所以总的时间复杂度为 (O(n)\times O(1)= O(n))。
    • 空间复杂度
      • 代码中使用了一个 MonotonicQueue,其中用 LinkedList 来存储数据。在最坏情况下,队列中会存储窗口大小 k 个元素(例如数组元素单调递增的情况)。
      • 除了 MonotonicQueue,其他变量如 res 是用于存储结果,不计入额外空间复杂度。所以空间复杂度为 (O(k))。

76. 最小覆盖子串

题目描述

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 ""

注意:

  • 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
  • 如果 s 中存在这样的子串,我们保证它是唯一的答案。

核心思路

滑动窗口 -> 标准滑动窗口框架,用两个 HashMap 分别充当 window 和 need,统计每个字符的数量。

什么时候扩大窗口?right < n 时扩大窗口。

什么时候缩小窗口?当 valid >= need.size() 时缩小窗口,也就是窗口中覆盖了 t 的所有字符。

什么时候更新答案?当缩小窗口的时候更新答案。

题解代码

java 复制代码
class Solution {
    public String minWindow(String s, String t) {
        Map<Character, Integer> need = new HashMap<>();
        Map<Character, Integer> window = new HashMap<>();
        for (char c : t.toCharArray()) {
            need.put(c, need.getOrDefault(c, 0) + 1);
        }

        int left = 0, right = 0;
        int valid = 0;
        // 记录最小覆盖子串的起始索引及长度
        int start = 0, len = Integer.MAX_VALUE;
        while (right < s.length()) {
            // c 是将移入窗口的字符
            char c = s.charAt(right);
            // 扩大窗口
            right++;
            // 进行窗口内数据的一系列更新
            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (window.get(c).equals(need.get(c)))
                    valid++;
            }

            // 判断左侧窗口是否要收缩
            while (valid == need.size()) {
                // 在这里更新最小覆盖子串
                if (right - left < len) {
                    start = left;
                    len = right - left;
                }
                // d 是将移出窗口的字符
                char d = s.charAt(left);
                // 缩小窗口
                left++;
                // 进行窗口内数据的一系列更新
                if (need.containsKey(d)) {
                    if (window.get(d).equals(need.get(d)))
                        valid--;
                    window.put(d, window.get(d) - 1);
                }                    
            }
        }
        // 返回最小覆盖子串
        return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
    }
}

时空分析

  • 时间复杂度 :(O(n + m)),其中 n 是字符串 s 的长度,m 是字符串 t 的长度
  • 空间复杂度:(O(m))
  • 原因分析
    • 时间复杂度
      • 首先,通过一个 for 循环遍历字符串 t,将每个字符及其出现次数存入 need 哈希表,此操作时间复杂度为 (O(m)),因为 t 的长度为 m,每次哈希表插入操作平均时间复杂度为 (O(1))。
      • 然后,使用滑动窗口算法遍历字符串 sright 指针从字符串开头开始,最多移动 n 次,其中 n 是字符串 s 的长度。每次移动 right 指针,对窗口内数据的更新(如判断字符是否在 need 中,更新 window 哈希表等)操作时间复杂度为常数 (O(1))。
      • 当窗口内包含 t 中所有字符后,会通过一个 while 循环收缩窗口,left 指针最多移动 n 次,每次收缩窗口时对窗口内数据的更新操作时间复杂度也为常数 (O(1))。
      • 因此,总的时间复杂度为 (O(n + m))。
    • 空间复杂度
      • 代码中使用了两个哈希表 windowneedneed 哈希表存储字符串 t 中不同字符及其出现次数,其大小取决于 t 中不同字符的种类,最多为 m 个不同字符,所以 need 占用空间为 (O(m))。
      • window 哈希表存储窗口内的字符及出现次数,其大小不会超过 need 哈希表的大小,所以也为 (O(m))。此外,用于记录最小覆盖子串起始索引和长度的变量占用常数级空间。所以整体空间复杂度为 (O(m))。

「普通数组」

53. 最大子数组和

题目描述

给你一个整数数组 nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组 是数组中的一个连续部分。

核心思路

动态规划 -> dp 数组的含义:nums[i] 为结尾的「最大子数组和」为 dp[i]dp[i] 有两种「选择」,要么与前面的相邻子数组连接,形成一个和更大的子数组;要么不与前面的子数组连接,自成一派,自己作为一个子数组。在这两种选择中择优,就可以计算出最大子数组。

滑动窗口 -> right < n 时扩大窗口,windowSum < 0 时缩小窗口,扩大窗口的时候同时更新答案。

题解代码

java 复制代码
class Solution {
    public int maxSubArray(int[] nums) {
        int n = nums.length;
        int[] dp = new int[n];
        // base case
        // 第一个元素前面没有子数组
        dp[0] = nums[0];
        // 状态转移方程
        for (int i = 1; i < n; i++) {
            dp[i] = Math.max(nums[i], nums[i] + dp[i - 1]);
        }
        // 得到 nums 的最大子数组
        int res = Integer.MIN_VALUE;
        for (int i = 0; i < n; i++) {
            res = Math.max(res, dp[i]);
        }
        return res;
    }
}
java 复制代码
class Solution {
    public int maxSubArray(int[] nums) {
        int n = nums.length;
        int windowSum = 0;
        int left = 0, right = 0;
        int res = Integer.MIN_VALUE;

        while(right < n){
            windowSum += nums[right];
            right++;

            res = Math.max(res, windowSum);

            while(windowSum < 0){
                windowSum -= nums[left];
                left++;
            }
        }

        return res;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度
      • 代码中有两个 for 循环。第一个 for 循环从索引 1n - 1 遍历数组 nums,循环次数为 (n - 1) 次,每次循环中计算 dp[i] 的操作时间复杂度为 (O(1)),所以这个循环的时间复杂度为 (O(n))。
      • 第二个 for 循环从索引 0n - 1 遍历 dp 数组以找到最大值,循环次数为 n 次,每次循环中比较操作时间复杂度为 (O(1)),这个循环时间复杂度也为 (O(n))。
      • 总的时间复杂度为这两个循环时间复杂度之和,由于 (O(n)+O(n)= O(n)),所以整体时间复杂度为 (O(n))。
    • 空间复杂度
      • 代码中使用了一个长度为 n 的数组 dp 来存储中间结果,因此空间复杂度为 (O(n))。
  • 时间复杂度:(O(n))
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 代码中有一个外层 while 循环,right 指针从 0 移动到 n - 1,最多移动 n 次。
      • 在每次外层循环中,更新 windowSumres 是常数时间操作 (O(1))。
      • 内层还有一个 while 循环用于收缩窗口,left 指针从 0 移动到 n - 1,最多移动 n 次。虽然看起来有嵌套循环,但从整体上看,leftright 指针都最多移动 n 次。所以总的时间复杂度为 (O(n + n)= O(n))。
    • 空间复杂度
      • 代码中仅使用了几个固定的变量,如 windowSumleftrightres。这些变量所占用的空间不会随着输入数组 nums 的大小而改变,始终为常数级别的空间。因此空间复杂度为 (O(1))。

56. 合并区间

题目描述

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi]。请你合并所有重叠的区间,并返回一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。

核心思路

技巧 -> 一个区间可以表示为 [start, end],先按区间的 start 排序。对于几个相交区间合并后的结果区间 xx.start 一定是这些相交区间中 start 最小的,x.end 一定是这些相交区间中 end 最大的。

题解代码

java 复制代码
class Solution {
    public int[][] merge(int[][] intervals) {
        LinkedList<int[]> res = new LinkedList<>();
        // 按区间的 start 升序排列
        Arrays.sort(intervals, (a, b) -> {
            return a[0] - b[0];
        });

        res.add(intervals[0]);
        for (int i = 1; i < intervals.length; i++) {
            int[] curr = intervals[i];
            // res 中最后一个元素的引用
            int[] last = res.getLast();
            if (curr[0] <= last[1]) {
                last[1] = Math.max(last[1], curr[1]);
            } else {
                // 处理下一个待合并区间
                res.add(curr);
            }
        }
        return res.toArray(new int[0][0]);
    }
}

时空分析

  • 时间复杂度:(O(n \log n))
  • 空间复杂度:(O(\log n))(不考虑结果数组空间)或 (O(n))(考虑结果数组空间)
  • 原因分析
    • 时间复杂度
      • 代码首先使用 Arrays.sortintervals 数组进行排序,这一步的时间复杂度为 (O(n \log n)),其中 n 是区间数组的长度。这是因为常见的排序算法(如 Java 中的 Arrays.sort 采用的双轴快速排序或归并排序)在平均和最坏情况下的时间复杂度为 (O(n \log n))。
      • 随后的 for 循环遍历排序后的区间数组。循环次数为 (n - 1),每次循环中执行的操作(比较当前区间与结果列表中最后一个区间,并进行合并或添加操作)时间复杂度均为常数 (O(1))。因此,该循环的时间复杂度为 (O(n))。
      • 由于 (O(n \log n)) 的增长速度快于 (O(n)),整体时间复杂度由排序步骤主导,为 (O(n \log n))。
    • 空间复杂度
      • 如果不考虑存储结果的 res 列表所占用的空间,空间复杂度主要来自排序算法。在平均情况下,排序算法(如快速排序)的空间复杂度为 (O(\log n)),这是因为递归调用栈的深度为 (O(\log n))。
      • 当考虑 res 列表时,在最坏情况下(所有区间都不重叠),res 列表需要存储 n 个区间,此时空间复杂度为 (O(n))。

189. 轮转数组

题目描述

给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。

核心思路

技巧 -> 先把整个数组反转,再分别反转前 k 个和后 n-k 个即可。

例如 [1,2,3,4,5], k = 3, 先反转整个数组得 [5,4,3,2,1], 再反转前 k 个得 [3,4,5,2,1], 再反转后 n-k 个得 [3,4,5,1,2] 即为答案。

note:k 需要小于 n。(取模)

题解代码

java 复制代码
class Solution {
    public void rotate(int[] nums, int k) {
        int n = nums.length;
        k %= n;
        
        reverse(nums, 0, n - 1);
        reverse(nums, 0, k - 1);
        reverse(nums, k, n - 1);
    }

    public void reverse(int[] nums, int start, int end){
        while(start < end){
            int temp = nums[start];
            nums[start] = nums[end];
            nums[end] = temp;
            start++;
            end--;
        }
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 代码中调用了三次 reverse 方法。每次 reverse 方法内部是一个 while 循环,while 循环中每次迭代执行的操作(交换两个元素的值、更新指针)时间复杂度为常数 (O(1))。
      • 第一次 reverse(nums, 0, n - 1) 中,while 循环执行 (\frac{n}{2}) 次;第二次 reverse(nums, 0, k - 1) 中,while 循环执行 (\frac{k}{2}) 次;第三次 reverse(nums, k, n - 1) 中,while 循环执行 (\frac{n - k}{2}) 次。
      • 总的操作次数为 (\frac{n}{2} + \frac{k}{2} + \frac{n - k}{2} = n),忽略常数系数后,时间复杂度为 (O(n))。
    • 空间复杂度
      • reverse 方法中,仅使用了常数级别的额外空间,如临时变量 temp 以及循环变量 startend。这些变量所占用的空间不会随着输入数组 nums 的大小而改变。
      • 除了这些,代码中没有使用其他会随输入规模增长而增加空间占用的数据结构。因此,空间复杂度为 (O(1))。

238. 除自身以外数组的乘积

题目描述

给你一个整数数组 nums,返回 数组 answer ,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积。

题目数据 保证 数组 nums 之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。

不要使用除法, 且在 O(n) 时间复杂度内完成此题。

核心思路

前缀积 -> 前缀和数组中两个元素之差是子数组元素之和,那么如果构造「前缀积」数组,两个元素相除就是子数组元素之积。

构造一个 prefix 数组记录「前缀积」,再用一个 suffix 记录「后缀积」,根据前缀和后缀积就能计算除了当前元素之外其他元素的积。

题解代码

java 复制代码
class Solution {
    public int[] productExceptSelf(int[] nums) {
        int n = nums.length;
        // 从左到右的前缀积,prefix[i] 是 nums[0..i] 的元素积
        int[] prefix = new int[n];
        prefix[0] = nums[0];
        for (int i = 1; i < nums.length; i++) {
            prefix[i] = prefix[i - 1] * nums[i];
        }
        // 从右到左的前缀积,suffix[i] 是 nums[i..n-1] 的元素积
        int[] suffix = new int[n];
        suffix[n - 1] = nums[n - 1];
        for (int i = n - 2; i >= 0; i--) {
            suffix[i] = suffix[i + 1] * nums[i];
        }
        // 结果数组
        int[] res = new int[n];
        res[0] = suffix[1];
        res[n - 1] = prefix[n - 2];
        for (int i = 1; i < n - 1; i++) {
            // 除了 nums[i] 自己的元素积就是 nums[i] 左侧和右侧所有元素之积
            res[i] = prefix[i - 1] * suffix[i + 1];
        }
        return res;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度
      • 代码中有三个 for 循环。第一个 for 循环计算从左到右的前缀积数组 prefix,循环次数为 (n - 1) 次,每次循环的操作时间复杂度为 (O(1)),所以该循环时间复杂度为 (O(n))。
      • 第二个 for 循环计算从右到左的前缀积数组 suffix,循环次数为 (n - 1) 次,每次循环操作时间复杂度为 (O(1)),此循环时间复杂度也为 (O(n))。
      • 第三个 for 循环填充结果数组 res,循环次数为 (n - 2) 次,每次循环操作时间复杂度为 (O(1)),该循环时间复杂度同样为 (O(n))。
      • 总的时间复杂度为 (O(n)+O(n)+O(n)= O(n)),因为在大 O 表示法中,系数可以忽略。
    • 空间复杂度
      • 代码中使用了三个额外的数组 prefixsuffixres,它们的长度均为 n。所以总的额外空间复杂度为 (O(n + n + n)= O(n)),同样在大 O 表示法中,系数可忽略。因此空间复杂度为 (O(n))。

41. 缺失的第一个正数

题目描述

给你一个未排序的整数数组 nums ,请你找出其中没有出现的最小的正整数。

请你实现时间复杂度为 O(n) 并且只使用常数级别额外空间的解决方案。

核心思路

对于一个长度为 N 的数组,其中没有出现的最小正整数只能在 1, N+1 中。这是因为如果 1, N 都出现了,那么答案是 N+1,否则答案是 1, N 中没有出现的最小正整数。

数组设计成哈希表 -> 我们对数组进行遍历,对于遍历到的数 x,如果它在 1, N 的范围内,那么就将数组中的第 x−1 个位置(注意:数组下标从 0 开始)打上「标记」。在遍历结束之后,如果所有的位置都被打上了标记,那么答案是 N+1,否则答案是最小的没有打上标记的位置加 1。

算法的流程如下:

  • 我们将数组中所有小于等于 0 的数修改为 N+1;
  • 我们遍历数组中的每一个数 x,它可能已经被打了标记,因此原本对应的数为 ∣x∣,其中 ∣∣ 为绝对值符号。如果 ∣x∣∈ 1, N,那么我们给数组中的第 ∣x∣−1 个位置的数添加一个负号。注意如果它已经有负号,不需要重复添加;
  • 在遍历完成之后,如果数组中的每一个数都是负数,那么答案是 N+1,否则答案是第一个正数的位置加 1。

题解代码

java 复制代码
class Solution {
    public int firstMissingPositive(int[] nums) {
        int n = nums.length;

        // 1.将负数和 0 变为 n + 1
        for (int i = 0; i < n; i++) {
            if (nums[i] <= 0) {
                nums[i] = n + 1;
            }
        }

        // 2.进行映射 -> 将 <= n 的元素对应位置变为负数
        for (int i = 0; i < n; i++) {
            int num = Math.abs(nums[i]);
            if (1 <= num && num <= n) {
                nums[num - 1] = -Math.abs(nums[num - 1]);
            }
        }

        // 3.查找第一个正数元素的下标,返回 i + 1
        for (int i = 0; i < n; i++) {
            if (nums[i] > 0) {
                return i + 1;
            }
        }

        return n + 1;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 代码包含三个 for 循环,每个循环都遍历数组 nums 一次。
      • 第一个 for 循环用于将负数和 0 变为 n + 1,时间复杂度为 (O(n)),因为它对数组中的每个元素进行一次检查和可能的修改,每次操作时间复杂度为 (O(1))。
      • 第二个 for 循环用于映射,将 1n 之间的元素对应位置变为负数,同样遍历数组一次,时间复杂度为 (O(n)),每次操作时间复杂度为 (O(1))。
      • 第三个 for 循环用于查找第一个正数元素的下标,也是遍历数组一次,时间复杂度为 (O(n)),每次操作时间复杂度为 (O(1))。
      • 总的时间复杂度为 (O(n)+O(n)+O(n)= O(n)),在大 O 表示法中,常数系数可忽略。
    • 空间复杂度
      • 代码仅使用了常数级别的额外空间,如循环变量 i 和临时变量 num。没有使用额外的数据结构,其空间占用不随输入数组 nums 的大小而改变。
      • 所以空间复杂度为 (O(1))。

「矩阵」

73. 矩阵置零

题目描述

给定一个 m x n 的矩阵,如果一个元素为 0 ,则将其所在行和列的所有元素都设为 0 。请使用原地算法。

核心思路

二维数组遍历 -> 用两个标记数组分别记录每一行和每一列是否有零出现 -> 首先遍历数组一次,如果某个元素为 0,那么就将该元素所在的行和列所对应标记数组的位置置为 true。最后再次遍历该数组,用标记数组更新原数组即可。

题解代码

java 复制代码
class Solution {
    public void setZeroes(int[][] matrix) {
        int m = matrix.length, n = matrix[0].length;
        boolean[] row = new boolean[m];
        boolean[] col = new boolean[n];
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == 0) {
                    row[i] = col[j] = true;
                }
            }
        }
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (row[i] || col[j]) {
                    matrix[i][j] = 0;
                }
            }
        }
    }
}

时空分析

  • 时间复杂度:(O(m \times n))
  • 空间复杂度:(O(m + n))
  • 原因分析
    • 时间复杂度
      • 代码中有两个嵌套的 for 循环结构。第一个嵌套循环用于标记哪些行和列需要置零,外层循环遍历 m 次,内层循环对于每次外层循环遍历 n 次,总共执行 (m \times n) 次操作,每次操作时间复杂度为 (O(1)),所以该部分时间复杂度为 (O(m \times n))。
      • 第二个嵌套循环用于根据标记结果将对应行和列的元素置零,同样外层循环遍历 m 次,内层循环遍历 n 次,执行 (m \times n) 次操作,每次操作时间复杂度为 (O(1)),该部分时间复杂度也为 (O(m \times n))。
      • 整体时间复杂度为 (O(m \times n)+O(m \times n)= O(m \times n)),在大 O 表示法中,常数系数可忽略。
    • 空间复杂度
      • 代码中使用了两个布尔数组 rowcolrow 的长度为 mcol 的长度为 n,用于记录哪些行和列需要置零。
      • 这两个数组占用的空间为 (O(m + n)),除此之外没有使用与输入规模相关的其他额外空间。所以空间复杂度为 (O(m + n))。

54. 螺旋矩阵

题目描述

给你一个 mn 列的矩阵 matrix ,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。

核心思路

二维数组遍历 -> 按照 右、下、左、上 的顺序遍历数组,并使用四个变量圈定未遍历元素的边界。

题解代码

java 复制代码
class Solution {
    public List<Integer> spiralOrder(int[][] matrix) {
        int m = matrix.length, n = matrix[0].length;
        int upper_bound = 0, lower_bound = m - 1;
        int left_bound = 0, right_bound = n - 1;
        List<Integer> res = new LinkedList<>();
        // res.size() == m * n 则遍历完整个数组
        while (res.size() < m * n) {
            if (upper_bound <= lower_bound) {
                // 在顶部从左向右遍历
                for (int j = left_bound; j <= right_bound; j++) {
                    res.add(matrix[upper_bound][j]);
                }
                // 上边界下移
                upper_bound++;
            }
            
            if (left_bound <= right_bound) {
                // 在右侧从上向下遍历
                for (int i = upper_bound; i <= lower_bound; i++) {
                    res.add(matrix[i][right_bound]);
                }
                // 右边界左移
                right_bound--;
            }
            
            if (upper_bound <= lower_bound) {
                // 在底部从右向左遍历
                for (int j = right_bound; j >= left_bound; j--) {
                    res.add(matrix[lower_bound][j]);
                }
                // 下边界上移
                lower_bound--;
            }
            
            if (left_bound <= right_bound) {
                // 在左侧从下向上遍历
                for (int i = lower_bound; i >= upper_bound; i--) {
                    res.add(matrix[i][left_bound]);
                }
                // 左边界右移
                left_bound++;
            }
        }
        return res;
    }
}

时空分析

  • 时间复杂度:(O(m \times n))
  • 空间复杂度 :(O(1))(不考虑结果列表 res 占用空间)或 (O(m \times n))(考虑结果列表 res 占用空间)
  • 原因分析
    • 时间复杂度
      • 代码通过循环按螺旋顺序遍历矩阵中的每一个元素。
      • 每次循环中,四个 if 块内的 for 循环分别负责遍历矩阵的上、右、下、左四条边,每个 for 循环最多遍历矩阵的一行或一列元素。
      • 由于矩阵共有 (m \times n) 个元素,且每个元素仅被访问一次,每次访问元素的操作时间复杂度为 (O(1)),所以总的时间复杂度为 (O(m \times n))。
    • 空间复杂度
      • 如果不考虑存储结果的 res 列表占用的空间,代码中仅使用了常数级别的额外空间,如 upper_boundlower_boundleft_boundright_bound 等变量,这些变量所占用的空间不会随着矩阵大小的变化而变化,因此空间复杂度为 (O(1))。
      • 当考虑 res 列表时,由于 res 最终会存储矩阵中的所有 (m \times n) 个元素,所以此时空间复杂度为 (O(m \times n))。

48. 旋转图像

题目描述

给定一个 n × n 的二维矩阵 matrix 表示一个图像。请你将图像顺时针旋转 90 度。

你必须在 原地 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。

核心思路

二维数组遍历 -> 先把二维矩阵沿对角线反转,然后反转矩阵的每一行,结果就是顺时针旋转整个矩阵。

题解代码

java 复制代码
class Solution {
    public void rotate(int[][] matrix) {
        int n = matrix.length;
        // 先沿对角线反转二维矩阵
        for (int i = 0; i < n; i++) {
            for (int j = i; j < n; j++) {
                // swap(matrix[i][j], matrix[j][i]);
                int temp = matrix[i][j];
                matrix[i][j] = matrix[j][i];
                matrix[j][i] = temp;
            }
        }
        // 然后反转二维矩阵的每一行
        for (int[] row : matrix) {
            reverse(row);
        }
    }

    // 反转一维数组
    void reverse(int[] arr) {
        int i = 0, j = arr.length - 1;
        while (j > i) {
            // swap(arr[i], arr[j]);
            int temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
            i++;
            j--;
        }
    }
}

时空分析

  • 时间复杂度:(O(n^2))
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 第一个嵌套 for 循环用于沿对角线反转二维矩阵。外层循环执行 n 次,对于外层循环的每一次迭代,内层循环执行 (n - i) 次(i 为外层循环变量)。总的操作次数为 (\sum_{i = 0}^{n - 1}(n - i)=\frac{n(n + 1)}{2}),在大 O 表示法中,忽略常数系数,其时间复杂度为 (O(n^2))。
      • 第二个 for 循环用于反转二维矩阵的每一行。它遍历 n 行,对于每一行调用 reverse 方法。reverse 方法内部是一个 while 循环,执行 (\frac{n}{2}) 次操作,所以这部分总的操作次数为 (n\times\frac{n}{2}=\frac{n^2}{2}),同样忽略常数系数,时间复杂度为 (O(n^2))。
      • 两个主要操作的时间复杂度都是 (O(n^2)),因此整体时间复杂度为 (O(n^2))。
    • 空间复杂度
      • rotate 方法中,无论是沿对角线交换元素还是反转每一行,都是在原矩阵上进行操作,仅使用了常数级别的额外空间,如临时变量 temp 以及循环变量 ij 等。这些变量所占用的空间不会随着输入矩阵的大小而改变。
      • 所以空间复杂度为 (O(1))。

240. 搜索二维矩阵 II

题目描述

编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性:

  • 每行的元素从左到右升序排列。
  • 每列的元素从上到下升序排列。

核心思路

二维数组遍历 -> 从右上角开始遍历,规定只能向左或向下移动。如果向左移动,元素在减小,如果向下移动,元素在增大,这样的话就可以根据当前位置的元素和 target 的相对大小来判断应该往哪移动,不断接近从而找到 target 的位置。

题解代码

java 复制代码
class Solution {
    public boolean searchMatrix(int[][] matrix, int target) {
        int m = matrix.length, n = matrix[0].length;
        // 初始化在右上角
        int i = 0, j = n - 1;
        while (i < m && j >= 0) {
            if (matrix[i][j] == target) {
                return true;
            }
            if (matrix[i][j] < target) {
                // 需要大一点,往下移动
                i++;
            } else {
                // 需要小一点,往左移动
                j--;
            }
        }
        // while 循环中没有找到,则 target 不存在
        return false;
    }
}

时空分析

  • 时间复杂度:(O(m + n))
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 代码使用了一个 while 循环来遍历矩阵。在循环过程中,指针 i 最多增加 m 次(从矩阵的第一行移动到最后一行),指针 j 最多减少 n 次(从矩阵的最后一列移动到第一列)。
      • 每次循环中执行的操作(如比较元素、移动指针)时间复杂度均为常数 (O(1))。
      • 因此,总的时间复杂度为 (O(m + n))。
    • 空间复杂度
      • 代码中仅使用了常数级别的额外空间,如变量 ij 等。这些变量所占用的空间不会随着矩阵的大小(mn 的值)而改变。
      • 所以空间复杂度为 (O(1))。

「链表」

160. 相交链表

题目描述

给你两个单链表的头节点 headAheadB ,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null

图示两个链表在节点 c1 开始相交:

题目数据 保证 整个链式结构中不存在环。

注意 ,函数返回结果后,链表必须 保持其原始结构

核心思路

两个指针 p1p2 分别在两条链表上前进,我们可以让 p1 遍历完链表 A 之后开始遍历链表 B,让 p2 遍历完链表 B 之后开始遍历链表 A,这样相当于「逻辑上」两条链表接在了一起。

如果这样进行拼接,就可以让 p1p2 同时进入公共部分,也就是同时到达相交节点 c1

题解代码

java 复制代码
public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        // p1 指向 A 链表头结点,p2 指向 B 链表头结点
        ListNode p1 = headA, p2 = headB;

        while (p1 != p2) {
            // p1 走一步,如果走到 A 链表末尾,转到 B 链表
            if (p1 == null) {
                p1 = headB;
            } else {
                p1 = p1.next;
            }

            // p2 走一步,如果走到 B 链表末尾,转到 A 链表
            if (p2 == null) {
                p2 = headA;
            } else {
                p2 = p2.next;
            }
        }

        return p1;
    }
}

时空分析

  • 时间复杂度:(O(m + n))
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 假设链表 headA 的长度为 m,链表 headB 的长度为 n。
      • 两个指针 p1p2 同步移动。p1headA 开始,若到达链表末尾则转至 headB 继续;p2headB 开始,若到达链表末尾则转至 headA 继续。
      • 在最坏情况下,p1p2 总共会遍历两个链表的所有节点,即移动 (m + n) 次。每次移动操作(判断指针是否为空以及指针移动)的时间复杂度为 (O(1))。所以,整体时间复杂度为 (O(m + n))。
    • 空间复杂度
      • 代码仅使用了两个指针 p1p2 作为额外空间。这两个指针所占用的空间不会随着链表长度的变化而变化,始终为常数级。
      • 没有使用其他随输入规模增长而增加空间占用的数据结构,因此空间复杂度为 (O(1))。

206. 反转链表

题目描述

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

核心思路

迭代 -> 三个指针 cur,pre 与 next。

题解代码

java 复制代码
class Solution {
    public ListNode reverseList(ListNode head) {
        ListNode cur = head, pre = null;
        while(cur != null){
            ListNode next = cur.next;
            cur.next = pre;
            pre = cur;
            cur = next;
        }
        return pre;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 代码通过一个 while 循环来遍历链表。循环条件是 cur != null,即只要当前节点不为空就继续循环,直到遍历完整个链表。
      • 假设链表长度为 n,循环会执行 n 次,每次循环内执行的操作(如保存当前节点的下一个节点、修改指针方向、更新 precur)时间复杂度均为常数 (O(1))。
      • 根据时间复杂度的计算规则,总的时间复杂度为循环次数乘以每次循环的时间复杂度,即 (O(n)\times O(1)= O(n))。
    • 空间复杂度
      • 代码中除了链表本身占用的空间外,仅使用了常数级别的额外空间。定义了 curprenext 三个指针变量,这些变量所占用的空间不会随着链表长度 n 的变化而改变。
      • 所以空间复杂度为 (O(1))。

234. 回文链表

题目描述

给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false

核心思路

  1. 先通过快慢指针找到链表的中点。(注意奇偶长度)
  2. 从 slow 开始反转后面的链表,现在就可以开始比较回文串了。

题解代码

java 复制代码
class Solution {
    public boolean isPalindrome(ListNode head) {
        ListNode slow, fast;
        slow = fast = head;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }

        if (fast != null)
            slow = slow.next;

        ListNode left = head;
        ListNode right = reverse(slow);
        while (right != null) {
            if (left.val != right.val)
                return false;
            left = left.next;
            right = right.next;
        }

        return true;
    }

    ListNode reverse(ListNode head) {
        ListNode pre = null, cur = head;
        while (cur != null) {
            ListNode next = cur.next;
            cur.next = pre;
            pre = cur;
            cur = next;
        }
        return pre;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 首先,通过快慢指针找到链表的中间节点。快指针每次移动两步,慢指针每次移动一步,当快指针到达链表末尾时,慢指针到达中间节点。这个过程中,快指针最多移动 (n/2) 次(n 为链表长度),时间复杂度为 (O(n))。
      • 然后,对链表的后半部分进行反转,调用 reverse 方法。该方法通过遍历链表后半部分来反转链表,链表后半部分最长为 (n/2),时间复杂度为 (O(n))。
      • 最后,比较反转后的后半部分链表和前半部分链表,最多比较 (n/2) 次,时间复杂度为 (O(n))。
      • 由于这三个主要步骤的时间复杂度都是 (O(n)),在大 O 表示法中,取其中最高阶项,所以整体时间复杂度为 (O(n))。
    • 空间复杂度
      • 代码中除了链表本身占用的空间外,仅使用了常数级别的额外空间。如快慢指针 slowfast,以及 reverse 方法中使用的 precurnext 等指针变量,这些变量所占用的空间不会随着链表长度 n 的变化而改变。
      • 所以空间复杂度为 (O(1))。

141. 环形链表

题目描述

给你一个链表的头节点 head ,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。

如果链表中存在环 ,则返回 true 。 否则,返回 false

核心思路

快慢指针 -> 每当慢指针 slow 前进一步,快指针 fast 就前进两步。如果 fast 最终遇到空指针,说明链表中没有环;如果 fast 最终和 slow 相遇,那肯定是 fast 超过了 slow 一圈,说明链表中含有环。

题解代码

java 复制代码
public class Solution {
    public boolean hasCycle(ListNode head) {
        // 快慢指针初始化指向 head
        ListNode slow = head, fast = head;
        // 快指针走到末尾时停止
        while (fast != null && fast.next != null) {
            // 慢指针走一步,快指针走两步
            slow = slow.next;
            fast = fast.next.next;
            // 快慢指针相遇,说明含有环
            if (slow == fast) {
                return true;
            }
        }
        // 不包含环
        return false;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 代码使用快慢指针遍历链表。快指针每次移动两步,慢指针每次移动一步。
      • 假设链表长度为 n,在最坏情况下,即链表有环且快指针需要完整绕环多圈才能与慢指针相遇,快指针移动的距离最大为 2n(因为快指针移动速度是慢指针的两倍),而每次移动操作的时间复杂度为 (O(1))。
      • 所以整体时间复杂度为 (O(n)),因为 2n 在大 O 表示法中忽略常数系数后为 (O(n))。
    • 空间复杂度
      • 代码中仅使用了两个指针 slowfast 作为额外空间。这两个指针所占用的空间不会随着链表长度的变化而变化,始终为常数级。
      • 没有使用其他随输入规模增长而增加空间占用的数据结构,因此空间复杂度为 (O(1))。

142. 环形链表 II

题目描述

给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始 )。如果 pos-1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

不允许修改 链表。

核心思路

基于环形链表的解法,直观地来说就是当快慢指针相遇时,让其中任一个指针指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。

题解代码

java 复制代码
class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode fast, slow;
        fast = slow = head;
        while (fast != null && fast.next != null) {
            fast = fast.next.next;
            slow = slow.next;
            if (fast == slow) break;
        }
        // 上面的代码类似 hasCycle 函数
        if (fast == null || fast.next == null) {
            // fast 遇到空指针说明没有环
            return null;
        }

        // 重新指向头结点
        slow = head;
        // 快慢指针同步前进,相交点就是环起点
        while (slow != fast) {
            fast = fast.next;
            slow = slow.next;
        }
        return slow;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 第一部分,通过快慢指针判断链表是否有环,快指针每次移动两步,慢指针每次移动一步。在最坏情况下,快指针需要遍历链表长度 n 的距离(因为即使在有环的情况下,快指针也最多遍历 n 步就会与慢指针相遇),每次移动操作时间复杂度为 (O(1)),所以这部分时间复杂度为 (O(n))。
      • 第二部分,如果存在环,重新让慢指针指向头结点,然后快慢指针同步移动找到环的起点。从相遇点到环起点的距离加上头结点到环起点的距离,最多为链表长度 n,每次移动操作时间复杂度为 (O(1)),所以这部分时间复杂度也为 (O(n))。
      • 总体时间复杂度为 (O(n) + O(n) = O(n)),在大 O 表示法中,常数系数可忽略。
    • 空间复杂度
      • 代码中仅使用了常数级别的额外空间,如 fastslow 两个指针变量。这些变量所占用的空间不会随着链表长度 n 的变化而改变。
      • 没有使用其他随输入规模增长而增加空间占用的数据结构,因此空间复杂度为 (O(1))。

21. 合并两个有序链表

题目描述

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

核心思路

双指针和虚拟头结点

题解代码

java 复制代码
class Solution {
    public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        // 虚拟头结点
        ListNode dummy = new ListNode(-1), p = dummy;
        ListNode p1 = l1, p2 = l2;

        while (p1 != null && p2 != null) {
            // 比较 p1 和 p2 两个指针
            // 将值较小的的节点接到 p 指针
            if (p1.val > p2.val) {
                p.next = p2;
                p2 = p2.next;
            } else {
                p.next = p1;
                p1 = p1.next;
            }
            // p 指针不断前进
            p = p.next;
        }

        if (p1 != null) {
            p.next = p1;
        }

        if (p2 != null) {
            p.next = p2;
        }

        return dummy.next;
    }
}

时空分析

  • 时间复杂度:(O(m + n)),其中 m 和 n 分别是链表 l1 和 l2 的长度。
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • 代码通过一个 while 循环来合并两个链表,循环条件是 p1 != null && p2 != null,即只要两个链表都未遍历完就继续循环。
      • 在循环内部,每次比较 p1p2 所指向节点的值,并将值较小的节点接到结果链表 p 之后,然后相应指针移动一步。每次循环的操作时间复杂度为 (O(1))。
      • 由于链表 l1 长度为 m,链表 l2 长度为 n,循环最多执行 (m + n) 次(当一个链表遍历完,另一个链表还有剩余节点时,会继续遍历剩余节点)。所以时间复杂度为 (O(m + n))。
    • 空间复杂度
      • 代码中除了输入的两个链表外,仅使用了常数级别的额外空间。定义了虚拟头结点 dummy 和指针 pp1p2,这些变量所占用的空间不会随着链表长度的变化而改变。
      • 所以空间复杂度为 (O(1))。

2. 两数相加

题目描述

给你两个 非空 的链表,表示两个非负的整数。它们每位数字都是按照 逆序 的方式存储的,并且每个节点只能存储 一位 数字。

请你将两个数相加,并以相同形式返回一个表示和的链表。

你可以假设除了数字 0 之外,这两个数都不会以 0 开头。

核心思路

用一个 carry 变量记录进位,然后通过 (valA + valB + carry) % 10 计算当前位的值,通过 (valA + valB + carry) / 10 计算进位。

题解代码

java 复制代码
class Solution {
    public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
        // 在两条链表上的指针
        ListNode p1 = l1, p2 = l2;
        // 虚拟头结点(构建新链表时的常用技巧)
        ListNode dummy = new ListNode(-1);
        // 指针 p 负责构建新链表
        ListNode p = dummy;
        // 记录进位
        int carry = 0;
        // 开始执行加法,两条链表走完且没有进位时才能结束循环
        while (p1 != null || p2 != null || carry > 0) {
            // 先加上上次的进位
            int val = carry;
            if (p1 != null) {
                val += p1.val;
                p1 = p1.next;
            }
            if (p2 != null) {
                val += p2.val;
                p2 = p2.next;
            }
            // 处理进位情况
            carry = val / 10;
            val = val % 10;
            // 构建新节点
            p.next = new ListNode(val);
            p = p.next;
        }
        // 返回结果链表的头结点(去除虚拟头结点)
        return dummy.next;
    }
}

时空分析

  • 时间复杂度:(O(\max(m, n))),其中 m 和 n 分别是链表 l1 和 l2 的长度。
  • 空间复杂度:(O(\max(m, n)))
  • 原因分析
    • 时间复杂度
      • 代码通过一个 while 循环来遍历两个链表并进行加法运算。循环条件为 p1 != null || p2 != null || carry > 0,这意味着只要两个链表中还有未处理的节点,或者存在进位,循环就会继续。
      • 在每次循环中,进行的操作(如计算当前位的和、处理进位、创建新节点)时间复杂度都为 (O(1))。
      • 由于两个链表中较长的那个链表决定了循环的次数,假设链表 l1 的长度为 m,链表 l2 的长度为 n,则循环最多执行 (\max(m, n)) 次。因此,时间复杂度为 (O(\max(m, n)))。
    • 空间复杂度
      • 代码构建了一个新的链表来存储两个链表相加的结果。在最坏情况下,新链表的长度为 (\max(m, n) + 1)(当最后有进位时)。
      • 除了新链表占用的空间外,只使用了常数级别的额外空间,如指针 p1p2p 以及变量 carry 等。这些额外空间不随链表长度变化。
      • 所以空间复杂度取决于新链表的长度,为 (O(\max(m, n)))。

19. 删除链表的倒数第 N 个结点

题目描述

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

核心思路

要删除倒数第 n 个节点,就得获得倒数第 n + 1 个节点的引用。

第一步,我们先让一个指针 p1 指向链表的头节点 head,然后走 k 步。

第二步,用一个指针 p2 指向链表头节点 head

第三步,让 p1p2 同时向前走,p1 走到链表末尾的空指针时走了 n - k 步,p2 也走了 n - k 步,也就是链表的倒数第 k 个节点。

这样,只遍历了一次链表,就获得了倒数第 k 个节点 p2

题解代码

java 复制代码
class Solution {
    // 主函数
    public ListNode removeNthFromEnd(ListNode head, int n) {
        // 虚拟头结点
        ListNode dummy = new ListNode(-1);
        dummy.next = head;
        // 删除倒数第 n 个,要先找倒数第 n + 1 个节点
        ListNode x = findFromEnd(dummy, n + 1);
        // 删掉倒数第 n 个节点
        x.next = x.next.next;
        return dummy.next;
    }

    // 返回链表的倒数第 k 个节点
    ListNode findFromEnd(ListNode head, int k) {
        ListNode p1 = head;
        // p1 先走 k 步
        for (int i = 0; i < k; i++) {
            p1 = p1.next;
        }
        ListNode p2 = head;
        // p1 和 p2 同时走 n - k 步
        while (p1 != null) {
            p2 = p2.next;
            p1 = p1.next;
        }
        // p2 现在指向第 n - k 个节点
        return p2;
    }
}

时空分析

  • 时间复杂度:(O(L)),其中 L 是链表的长度。
  • 空间复杂度:(O(1))
  • 原因分析
    • 时间复杂度
      • findFromEnd 方法中,首先通过一个 for 循环让指针 p1 先走 k 步,时间复杂度为 (O(k))。这里 (k = n + 1)。
      • 然后通过一个 while 循环让 p1p2 同时移动,直到 p1 到达链表末尾。p1 从链表第 (k + 1) 个节点移动到末尾,移动的步数为 (L - k),所以这个 while 循环的时间复杂度为 (O(L - k))。
      • 总体上,findFromEnd 方法的时间复杂度为 (O(k + L - k) = O(L))。
      • removeNthFromEnd 方法中,调用 findFromEnd 方法找到倒数第 (n + 1) 个节点后,删除倒数第 n 个节点的操作时间复杂度为 (O(1))。
      • 因此,整个 removeNthFromEnd 函数的时间复杂度取决于 findFromEnd 方法,为 (O(L))。
    • 空间复杂度
      • 代码中除了链表本身占用的空间外,仅使用了常数级别的额外空间。如 dummy 虚拟头节点、p1p2 指针以及循环变量 i 等,这些变量所占用的空间不会随着链表长度 L 的变化而改变。
      • 所以空间复杂度为 (O(1))。

24. 两两交换链表中的节点

题目描述

给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。

核心思路

递归

题解代码

java 复制代码
class Solution {
    // 定义:输入以 head 开头的单链表,将这个单链表中的每两个元素翻转,
    // 返回翻转后的链表头结点
    public ListNode swapPairs(ListNode head) {
        if (head == null || head.next == null) {
            return head;
        }
        ListNode first = head;
        ListNode second = head.next;
        ListNode others = head.next.next;
        // 先把前两个元素翻转
        second.next = first;
        // 利用递归定义,将剩下的链表节点两两翻转,接到后面
        first.next = swapPairs(others);
        // 现在整个链表都成功翻转了,返回新的头结点
        return second;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度
      • 该算法通过递归的方式对链表进行两两翻转。
      • 每一次递归处理链表中的两个节点,并且递归深度与链表长度 n 相关。对于长度为 n 的链表,递归调用次数为 (\frac{n}{2})(向上取整)。
      • 每次递归内部执行的操作(如指针的调整)时间复杂度为 (O(1))。
      • 因此,总的时间复杂度为 (O(n)),因为递归调用次数与链表长度成正比。
    • 空间复杂度
      • 由于使用了递归,递归调用在栈中会占用空间。
      • 递归的深度最大为链表的长度 n,所以递归调用栈占用的空间为 (O(n))。
      • 除了递归调用栈,没有使用其他与链表长度相关的额外空间。所以空间复杂度为 (O(n))。

25. K 个一组翻转链表

题目描述

给你链表的头节点 head ,每 k 个节点一组进行翻转,请你返回修改后的链表。

k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。

你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。

核心思路

  • 先反转以 head 开头的 k 个元素 。这里可以复用前面实现的 reverseN 函数。
  • 将第 k + 1 个元素作为 head 递归调用 reverseKGroup 函数
  • 将上述两个过程的结果连接起来

题解代码

java 复制代码
class Solution {
    public ListNode reverseKGroup(ListNode head, int k) {
        ListNode p1 = head, p2 = head;

        // 找到第 k + 1 个结点
        for(int i = 0; i < k; i++){
            // 如果不够 K 个,不需要反转
            if(p2 == null) return head;
            p2 = p2.next;
        }

        // 反转前 k 个元素
        ListNode pre = reverseK(p1, k);
        // 递归反转后续链表,并连接起来
        head.next = reverseKGroup(p2, k);

        return pre;
    }

    // 反转前 k 个链表
    public ListNode reverseK(ListNode head, int k){
        ListNode pre = null, cur = head;

        while(k != 0){
            ListNode next = cur.next;
            cur.next = pre;
            pre = cur;
            cur = next;
            k--;
        }

        return pre;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(\left\lfloor\frac{n}{k}\right\rfloor))
  • 原因分析
    • 时间复杂度
      • 设链表长度为 n。在 reverseKGroup 函数中,每次处理 k 个节点。定位每组第 (k + 1) 个节点的 for 循环时间复杂度为 (O(k)),反转 k 个节点的 reverseK 函数时间复杂度也为 (O(k))。
      • 完整的 k 节点组数量为 (\left\lfloor\frac{n}{k}\right\rfloor)(向下取整),处理这些组的时间复杂度为 (\sum_{i = 1}^{\left\lfloor\frac{n}{k}\right\rfloor} 2k = 2k\left\lfloor\frac{n}{k}\right\rfloor)。
      • 最后剩余不足 k 个节点时,遍历时间复杂度最多为 (O(k))。由于 (2k\left\lfloor\frac{n}{k}\right\rfloor + k \leq 2kn + k = O(n))(当 k 为常数时),所以总的时间复杂度为 (O(n))。
    • 空间复杂度
      • 空间主要消耗在递归调用栈上。每处理 k 个节点进行一次递归,递归深度为 (\left\lfloor\frac{n}{k}\right\rfloor)。
      • 每次递归使用常数级额外空间,因此空间复杂度为 (O(\left\lfloor\frac{n}{k}\right\rfloor))。

138. 随机链表的复制

题目描述

给你一个长度为 n 的链表,每个节点包含一个额外增加的随机指针 random ,该指针可以指向链表中的任何节点或空节点。

构造这个链表的 深拷贝 。 深拷贝应该正好由 n全新 节点组成,其中每个新节点的值都设为其对应的原节点的值。新节点的 next 指针和 random 指针也都应指向复制链表中的新节点,并使原链表和复制链表中的这些指针能够表示相同的链表状态。复制链表中的指针都不应指向原链表中的节点

例如,如果原链表中有 XY 两个节点,其中 X.random --> Y 。那么在复制链表中对应的两个节点 xy ,同样有 x.random --> y

返回复制链表的头节点。

用一个由 n 个节点组成的链表来表示输入/输出中的链表。每个节点用一个 [val, random_index] 表示:

  • val:一个表示 Node.val 的整数。
  • random_index:随机指针指向的节点索引(范围从 0n-1);如果不指向任何节点,则为 null

你的代码 接受原链表的头节点 head 作为传入参数。

核心思路

一个哈希表 + 两次遍历。

第一次遍历专门克隆节点,借助哈希表把原始节点和克隆节点的映射存储起来;第二次专门组装节点,照着原数据结构的样子,把克隆节点的指针组装起来。

题解代码

java 复制代码
class Solution {
    public Node copyRandomList(Node head) {
        HashMap<Node, Node> originToClone = new HashMap<>();
        // 第一次遍历,先把所有节点克隆出来
        for (Node p = head; p != null; p = p.next) {
            if (!originToClone.containsKey(p)) {
                originToClone.put(p, new Node(p.val));
            }
        }
        // 第二次遍历,把克隆节点的结构连接好
        for (Node p = head; p != null; p = p.next) {
            if (p.next != null) {
                originToClone.get(p).next = originToClone.get(p.next);
            }
            if (p.random != null) {
                originToClone.get(p).random = originToClone.get(p.random);
            }
        }
        // 返回克隆之后的头结点
        return originToClone.get(head);
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度
      • 代码进行了两次遍历链表的操作。
      • 第一次遍历,通过 for 循环从链表头 head 开始,每次移动到下一个节点,直到 p == null。在每次循环中,进行 containsKeyput 操作,这两个操作在 HashMap 中的平均时间复杂度为 (O(1))。链表长度为 n,所以第一次遍历的时间复杂度为 (O(n))。
      • 第二次遍历同样是从链表头开始,每次移动到下一个节点,在每次循环中,对克隆节点的 nextrandom 指针进行赋值,操作时间复杂度也为 (O(1))。链表长度为 n,第二次遍历时间复杂度也为 (O(n))。
      • 两次遍历的时间复杂度相加,总的时间复杂度为 (O(n) + O(n) = O(n)),在大 O 表示法中,常数系数可忽略。
    • 空间复杂度
      • 代码使用了一个 HashMap 来存储原节点和克隆节点的对应关系。在最坏情况下,即链表中所有节点都不同时,HashMap 需要存储 n 个键值对,每个键值对占用一定的空间。
      • 除了 HashMap 外,没有使用其他与链表长度相关的额外空间。所以空间复杂度为 (O(n))。

148. 排序链表

题目描述

给你链表的头结点 head ,请将其按 升序 排列并返回 排序后的链表

核心思路

归并排序(分治) ->

  • 找到 链表的中间结点 head2 的 前一个节点,并断开 head2 与其前一个节点的连接。这样我们就把原链表均分成了两段更短的链表。
  • 分治,递归调用 sortList,分别排序 head(只有前一半)和 head2。
  • 排序后,我们得到了两个有序链表,那么 合并两个有序链表,得到排序后的链表,返回链表头节点。

题解代码

java 复制代码
class Solution {
    public ListNode sortList(ListNode head) {
        // 如果链表为空或者只有一个节点,无需排序
        if (head == null || head.next == null) {
            return head;
        }
        // 找到中间节点 head2,并断开 head2 与其前一个节点的连接
        // 比如 head=[4,2,1,3],那么 middleNode 调用结束后 head=[4,2] head2=[1,3]
        ListNode head2 = findMiddleNode(head);
        // 分治
        head = sortList(head);
        head2 = sortList(head2);
        // 合并
        return mergeTwoLists(head, head2);
    }

    // 快慢指针寻找链表中点,并将中点与前一个结点断开连接
    public ListNode findMiddleNode(ListNode head){
        ListNode fast = head, slow = head, pre = head;

        while(fast != null && fast.next != null){
            pre = slow;
            slow = slow.next;
            fast = fast.next.next;
        }

        pre.next = null;
        return slow;
    }

    // 合并两个有序列表
    public ListNode mergeTwoLists(ListNode head1, ListNode head2){
        ListNode dummy = new ListNode(-1), p = dummy;
        ListNode p1 = head1, p2 = head2;

        while(p1 != null && p2 != null){
            if(p1.val < p2.val){
                p.next = p1;
                p1 = p1.next;
            }else{
                p.next = p2;
                p2 = p2.next;
            }
            p = p.next;
        }

        if(p1 == null) p.next = p2;
        if(p2 == null) p.next = p1;
        return dummy.next;
    }
}

时空分析

  • 时间复杂度:(O(n \log n))
  • 空间复杂度:(O(\log n))
  • 原因分析
    • 时间复杂度 :本题采用归并排序(分治)。sortList 的递归会把长度为 n 的链表不断二分,形成高度为 (\log n) 的递归树;在每一层中,findMiddleNode 通过快慢指针定位中点、mergeTwoLists 合并两个有序链表,两者处理当前层所有节点的总代价均为 (O(n))。因此总时间复杂度为 (O(n) \times O(\log n) = O(n \log n))。
    • 空间复杂度 :合并时直接在原链表节点上重接指针,未额外开辟链表节点。额外空间主要来自 sortList 递归调用栈,递归深度等于分治树的高度 (\log n),因此空间复杂度为 (O(\log n))。

23. 合并 K 个升序链表

题目描述

给你一个链表数组,每个链表都已经按升序排列。

请你将所有链表合并到一个升序链表中,返回合并后的链表。

核心思路

PriorityQueue -> 合并 k 个有序链表的逻辑类似合并两个有序链表,难点在于,如何快速得到 k 个节点中的最小节点,接到结果链表上?这里我们就要用到优先级队列这种数据结构,把链表节点放入一个最小堆,就可以每次获得 k 个节点中的最小节点。

题解代码

java 复制代码
class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        ListNode dummy = new ListNode(-1), p = dummy;
        PriorityQueue<ListNode> q = new PriorityQueue<>((a,b) -> (a.val - b.val));
        
        // 将 K 个链表的头节点加入优先级队列
        for(ListNode head : lists){
            if(head != null){
                q.add(head);
            }
        }

        while(!q.isEmpty()){
            // 获取最小节点,接到结果链表中
            ListNode cur = q.poll();
            p.next = cur;
            p = p.next;
            if(cur.next != null){
                q.add(cur.next);
            }
        }

        return dummy.next;
    }
}

时空分析

  • 时间复杂度:(O(n \log k))
  • 空间复杂度:(O(k))
  • 原因分析
    • 时间复杂度:设 k 为链表的个数,n 为所有链表节点的总数。首先将 k 个链表的头节点加入优先级队列,这一步的时间复杂度为 (O(k \log k))。随后每次从优先级队列中弹出一个最小节点接到结果链表上,再将其后继节点入队,队列中始终至多包含 k 个节点,因此每次入队和出队操作的时间复杂度均为 (O(\log k))。所有 n 个节点各会被入队和出队一次,这一部分的时间复杂度为 (O(n \log k)),因此整体时间复杂度为 (O(n \log k))。
    • 空间复杂度:优先级队列中最多同时存放 k 个链表节点,因此额外空间复杂度为 (O(k))。结果链表直接复用原有节点,未额外开辟节点空间。

146. 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 ,则应该 逐出 最久未使用的关键字。

函数 getput 必须以 O(1) 的平均时间复杂度运行。

核心思路

要让 putget 方法的时间复杂度为 O(1),可以总结出 cache 这个数据结构必要的条件:

1、显然 cache 中的元素必须有时序,以区分最近使用的和久未使用的数据,当容量满了之后要删除最久未使用的那个元素腾位置。

2、我们要在 cache 中快速找某个 key 是否已存在并得到对应的 val

3、每次访问 cache 中的某个 key,需要将这个元素变为最近使用的,也就是说 cache 要支持在任意位置快速插入和删除元素。

哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢,所以结合二者的长处,可以形成一种新的数据结构:哈希链表 LinkedHashMap

putget 的具体逻辑,可以画出这样一个流程图:

题解代码

java 复制代码
class LRUCache {
    int cap;
    LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<>();
    public LRUCache(int capacity) {
        this.cap = capacity;
    }

    public int get(int key) {
        if (!cache.containsKey(key)) {
            return -1;
        }
        // 将 key 变为最近使用
        makeRecently(key);
        return cache.get(key);
    }

    public void put(int key, int val) {
        if (cache.containsKey(key)) {
            // 修改 key 的值
            cache.put(key, val);
            // 将 key 变为最近使用
            makeRecently(key);
            return;
        }

        if (cache.size() >= this.cap) {
            // 链表头部就是最久未使用的 key
            int oldestKey = cache.keySet().iterator().next();
            cache.remove(oldestKey);
        }
        // 将新的 key 添加链表尾部
        cache.put(key, val);
    }

    private void makeRecently(int key) {
        int val = cache.get(key);
        // 删除 key,重新插入到队尾
        cache.remove(key);
        cache.put(key, val);
    }
}

时空分析

  • 时间复杂度:(O(1))
  • 空间复杂度:(O(capacity))
  • 原因分析
    • 时间复杂度LinkedHashMap 底层由哈希表和双向链表组合实现,getputremovecontainsKey 等操作的平均时间复杂度均为 (O(1))。makeRecently 内部先 removeput,仅由若干次常数级操作组成;put 中通过迭代器获取链表头部(最久未使用的键)也是 (O(1)) 操作。因此 getput 方法的平均时间复杂度都为 (O(1))。
    • 空间复杂度 :缓存中最多同时保存 capacity 个键值对,LinkedHashMap 需要为这些键值对同时维护哈希表桶和双向链表节点,占用空间与 capacity 成正比,因此空间复杂度为 (O(capacity))。

「二叉树」

94. 二叉树的中序遍历

题目描述

给定一个二叉树的根节点 root ,返回它的中序遍历。

核心思路

二叉树的 DFS 遍历模版。

java 复制代码
/// 二叉树的遍历框架
void traverse(TreeNode root) {
    if (root == null) {
        return;
    }
    // 前序位置
    traverse(root.left);
    // 中序位置
    traverse(root.right);
    // 后序位置
}

题解代码

java 复制代码
class Solution {
    List<Integer> res = new LinkedList<>();

    public List<Integer> inorderTraversal(TreeNode root) {
        traverse(root);
        return res;
    }

    public void traverse(TreeNode root){
        if(root == null){
            return;
        }

        traverse(root.left);
        res.add(root.val);
        traverse(root.right);
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(h))
  • 原因分析
    • 时间复杂度
      • 该代码实现了二叉树的中序遍历。在遍历过程中,每个节点都会被访问一次且仅一次。假设二叉树节点数为 n,每次访问节点时执行的操作(如添加节点值到结果列表)时间复杂度为常数 (O(1))。所以总的时间复杂度为 (O(n\times1)= O(n))。
    • 空间复杂度
      • 空间复杂度主要来源于递归调用栈。在最坏情况下,二叉树是一条链,递归深度达到树的高度 (h = n),此时空间复杂度为 (O(n))。而在平衡二叉树中,树的高度 (h =\log n),空间复杂度为 (O(\log n))。一般情况下,空间复杂度取决于树的高度 h,因此空间复杂度为 (O(h))。这里不考虑存储结果 res 所占用的空间,如果考虑 res,其空间复杂度为 (O(n)),因为最终 res 会存储 n 个节点的值。但按照常规分析递归算法空间复杂度的方式,不把存储结果的空间计算在内,所以空间复杂度为 (O(h))。

104. 二叉树的最大深度

题目描述

给定一个二叉树 root ,返回其最大深度。

二叉树的 最大深度 是指从根节点到最远叶子节点的最长路径上的节点数。

核心思路

递归(分解问题) -> 输入一个节点,返回以该节点为根的二叉树的最大深度。

题解代码

java 复制代码
class Solution {
    // 定义:输入一个节点,返回以该节点为根的二叉树的最大深度
    public int maxDepth(TreeNode root) {
        if (root == null) {
            return 0;
        }
        int leftMax = maxDepth(root.left);
        int rightMax = maxDepth(root.right);
        // 根据左右子树的最大深度推出原二叉树的最大深度
        return 1 + Math.max(leftMax, rightMax);
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(h))
  • 原因分析
    • 时间复杂度
      • 此代码通过递归方式计算二叉树的最大深度。每个节点在递归过程中都会被访问一次,且仅一次。设二叉树的节点数为 n,每次访问节点执行的操作(如递归调用、比较等)时间复杂度为常数 (O(1))。所以整体时间复杂度为 (O(n\times1)= O(n))。
    • 空间复杂度
      • 空间复杂度主要由递归调用栈的深度决定。在最坏情况下,二叉树为一条链,此时树的高度 (h = n),递归调用栈深度也为 n,空间复杂度为 (O(n))。对于平衡二叉树,树的高度 (h = \log n),递归调用栈深度为 (\log n),空间复杂度为 (O(\log n))。一般地,空间复杂度取决于树的高度 h,因此空间复杂度为 (O(h))。

226. 翻转二叉树

题目描述

给你一棵二叉树的根节点 root ,翻转这棵二叉树,并返回其根节点。

核心思路

递归(分解问题)-> 把二叉树上的每个节点的左右子节点都交换一下。

题解代码

java 复制代码
class Solution {
    // 定义:将以 root 为根的这棵二叉树翻转,返回翻转后的二叉树的根节点
    TreeNode invertTree(TreeNode root) {
        if (root == null) {
            return null;
        }
        // 利用函数定义,先翻转左右子树
        TreeNode left = invertTree(root.left);
        TreeNode right = invertTree(root.right);

        // 然后交换左右子节点
        root.left = right;
        root.right = left;

        // 和定义逻辑自洽:以 root 为根的这棵二叉树已经被翻转,返回 root
        return root;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(h))
  • 原因分析
    • 时间复杂度invertTree 通过递归遍历整棵二叉树,每个节点都会被访问一次且仅一次。在每个节点处所做的操作(交换左右子节点、返回节点)均为 (O(1))。设二叉树的节点数为 n,因此整体时间复杂度为 (O(n))。
    • 空间复杂度:额外空间主要来自递归调用栈,递归深度等于二叉树的高度 h。最坏情况下二叉树退化为一条链,(h = n),空间复杂度为 (O(n));对于平衡二叉树 (h = \log n),空间复杂度为 (O(\log n))。综合表示为 (O(h))。

101. 对称二叉树

题目描述

给你一个二叉树的根节点 root , 检查它是否轴对称。

核心思路

递归(分解问题) -> 判断两棵树是否镜像对称,只要判断两棵子树都是镜像对称的就行了。

题解代码

java 复制代码
class Solution {
    public boolean isSymmetric(TreeNode root) {
        if (root == null) return true;
        // 检查两棵子树是否对称
        return check(root.left, root.right);
    }

    // 定义:判断输入的两棵树是否是镜像对称的
    boolean check(TreeNode left, TreeNode right) {
        if (left == null || right == null) {
            return left == right;
        }
        // 两个根节点需要相同
        if (left.val != right.val) return false;
        // 左右子树也需要镜像对称
        return check(left.right, right.left) && check(left.left, right.right);
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(h))
  • 原因分析
    • 时间复杂度check 函数同时递归遍历左右两棵子树对应位置的节点对,每对镜像位置的节点至多被访问一次,两棵子树合计节点数不超过 n,因此整体访问的节点数为 (O(n)),每次比较均为 (O(1)),总时间复杂度为 (O(n))。
    • 空间复杂度:递归调用栈的深度等于两棵子树同时下降的层数,最坏为树的高度 h。最坏情况下树退化为链,空间复杂度为 (O(n));平衡二叉树时为 (O(\log n))。整体表示为 (O(h))。

543. 二叉树的直径

题目描述

给你一棵二叉树的根节点,返回该树的 直径

二叉树的 直径 是指树中任意两个节点之间最长路径的 长度 。这条路径可能经过也可能不经过根节点 root

两节点之间路径的 长度 由它们之间边数表示。

核心思路

二叉树的直径,就是左右子树的最大深度之和 -> 在求最大深度时,运用二叉树的后序遍历,在 maxDepth 的后序遍历位置顺便计算最大直径。

题解代码

java 复制代码
class Solution {
    int maxDiameter = 0;

    public int diameterOfBinaryTree(TreeNode root) {
        maxDepth(root);
        return maxDiameter;
    }

    int maxDepth(TreeNode root) {
        if (root == null) {
            return 0;
        }
        int leftMax = maxDepth(root.left);
        int rightMax = maxDepth(root.right);
        // 后序遍历位置顺便计算最大直径
        maxDiameter = Math.max(maxDiameter, leftMax + rightMax);
        return 1 + Math.max(leftMax, rightMax);
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(h))
  • 原因分析
    • 时间复杂度maxDepth 采用后序遍历二叉树,每个节点都恰好被访问一次;在每个节点的后序位置更新最大直径以及返回子树深度,均为常数时间 (O(1))。设节点数为 n,因此整体时间复杂度为 (O(n))。
    • 空间复杂度:额外空间主要来自递归调用栈,栈的最大深度等于二叉树的高度 h。最坏情况下(链状树)为 (O(n)),平衡二叉树为 (O(\log n)),整体表示为 (O(h))。

102. 二叉树的层序遍历

题目描述

给你二叉树的根节点 root ,返回其节点值的 层序遍历 。 (即逐层地,从左到右访问所有节点)。

核心思路

二叉树 BFS 遍历模版。

java 复制代码
void levelOrderTraverse(TreeNode root) {
    if (root == null) {
        return;
    }
    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);
    // 记录当前遍历到的层数(根节点视为第 1 层)
    int depth = 1;

    while (!q.isEmpty()) {
        int sz = q.size();
        for (int i = 0; i < sz; i++) {
            TreeNode cur = q.poll();
            // 访问 cur 节点,同时知道它所在的层数
            System.out.println("depth = " + depth + ", val = " + cur.val);

            // 把 cur 的左右子节点加入队列
            if (cur.left != null) {
                q.offer(cur.left);
            }
            if (cur.right != null) {
                q.offer(cur.right);
            }
        }
        depth++;
    }
}

题解代码

java 复制代码
class Solution {
    public List<List<Integer>> levelOrder(TreeNode root) {
        List<List<Integer>> res = new LinkedList<>();
        if (root == null) {
            return res;
        }

        Queue<TreeNode> q = new LinkedList<>();
        q.offer(root);
        // while 循环控制从上向下一层层遍历
        while (!q.isEmpty()) {
            int sz = q.size();
            // 记录这一层的节点值
            List<Integer> level = new LinkedList<>();
            // for 循环控制每一层从左向右遍历
            for (int i = 0; i < sz; i++) {
                TreeNode cur = q.poll();
                level.add(cur.val);
                if (cur.left != null)
                    q.offer(cur.left);
                if (cur.right != null)
                    q.offer(cur.right);
            }
            res.add(level);
        }
        return res;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度:每个节点恰好会入队和出队一次,入队时将其值加入当前层的列表、并把左右子节点加入队列,这些都是 (O(1)) 操作。设二叉树节点数为 n,因此整体时间复杂度为 (O(n))。
    • 空间复杂度 :队列中最多同时存放一层的节点,完全二叉树最后一层的节点数可达 (n/2),因此队列的最大占用空间为 (O(n))。结果列表 res 也需要存储所有节点的值,占用 (O(n)) 空间。综合空间复杂度为 (O(n))。

108. 将有序数组转换为二叉搜索树

题目描述

给你一个整数数组 nums ,其中元素已经按 升序 排列,请你将其转换为一棵 平衡 二叉搜索树。(平衡二叉树 是指该树所有节点的左右子树的高度相差不超过 1。)

核心思路

递归(分解问题)-> 二叉树的构建问题遵循固定的套路,构造整棵树可以分解成:先构造根节点,然后构建左右子树。一个有序数组对于 BST 来说就是中序遍历结果,根节点在数组中心,数组左侧是左子树元素,右侧是右子树元素。

题解代码

java 复制代码
class Solution {
    public TreeNode sortedArrayToBST(int[] nums) {
        return build(nums, 0, nums.length - 1);
    }

    // 将闭区间 [left, right] 中的元素转化成 BST,返回根节点
    TreeNode build(int[] nums, int left, int right) {
        if (left > right) {
            // 区间为空
            return null;
        }
        // 构造根节点
        // BST 节点左小右大,中间的元素就是根节点
        int mid = (left + right) / 2;
        TreeNode root = new TreeNode(nums[mid]);
        // 递归构建左子树
        root.left = build(nums, left, mid - 1);
        // 递归构造右子树
        root.right = build(nums, mid + 1, right);

        return root;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(\log n))
  • 原因分析
    • 时间复杂度 :递归函数 build 每次为区间中点创建一个 TreeNode,然后分别对左右两个子区间递归。整棵树的每个节点恰好被创建一次,且每次创建及索引计算都是 (O(1)),因此总时间复杂度为 (O(n)),其中 n 为数组的长度。
    • 空间复杂度:由于每次都取区间中点作为根节点,构造出的 BST 是平衡的,树高为 (\log n),递归调用栈的最大深度也为 (\log n),因此额外空间复杂度为 (O(\log n))。结果二叉树本身占用的空间不计入额外空间。

98. 验证二叉搜索树

题目描述

给你一个二叉树的根节点 root ,判断其是否是一个有效的二叉搜索树。

有效 二叉搜索树定义如下:

  • 节点的左子树只包含 严格小于 当前节点的数。
  • 节点的右子树只包含 严格大于 当前节点的数。
  • 所有左子树和右子树自身必须也是二叉搜索树。

核心思路

递归(分解问题)-> BST 左小右大的特性是指 root.val 要比左子树的所有节点都更大,要比右子树的所有节点都小。通过使用辅助函数,增加函数参数列表,在参数中携带额外信息,将这种约束传递给子树的所有节点。

题解代码

java 复制代码
class Solution {
    public boolean isValidBST(TreeNode root) {
        return isValidBST(root, null, null);
    }

    // 限定以 root 为根的子树节点必须满足 max.val > root.val > min.val
    boolean isValidBST(TreeNode root, TreeNode min, TreeNode max) {
        // base case
        if (root == null) return true;
        // 若 root.val 不符合 max 和 min 的限制,说明不是合法 BST
        if (min != null && root.val <= min.val) return false;
        if (max != null && root.val >= max.val) return false;
        // 限定左子树的最大值是 root.val,右子树的最小值是 root.val
        return isValidBST(root.left, min, root)
                && isValidBST(root.right, root, max);
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(h))
  • 原因分析
    • 时间复杂度isValidBST 递归遍历整棵二叉树,每个节点都会被访问一次,并在访问时与传入的上下界比较,比较操作为 (O(1))。设节点数为 n,因此整体时间复杂度为 (O(n))。
    • 空间复杂度:递归调用栈的深度等于二叉树的高度 h。最坏情况下二叉树退化为链,空间复杂度为 (O(n));平衡二叉树时为 (O(\log n))。综合表示为 (O(h))。

230. 二叉搜索树中第 K 小的元素

题目描述

给定一个二叉搜索树的根节点 root ,和一个整数 k ,请你设计一个算法查找其中第 k 小的元素(从 1 开始计数)。

核心思路

递归(遍历)-> BST 的中序遍历结果是有序的(升序),所以用一个外部变量记录中序遍历结果第 k 个元素即是第 k 小的元素。

题解代码

java 复制代码
class Solution {
    public int kthSmallest(TreeNode root, int k) {
        // 利用 BST 的中序遍历特性
        traverse(root, k);
        return res;
    }

    // 记录结果
    int res = 0;
    // 记录当前元素的排名
    int rank = 0;
    void traverse(TreeNode root, int k) {
        if (root == null) {
            return;
        }
        traverse(root.left, k);
        // 中序代码位置
        rank++;
        if (k == rank) {
            // 找到第 k 小的元素
            res = root.val;
            return;
        }

        traverse(root.right, k);
    }
}

时空分析

  • 时间复杂度:(O(h + k))
  • 空间复杂度:(O(h))
  • 原因分析
    • 时间复杂度:中序遍历会从根节点一路向左走到最小节点,这个下降路径的长度为树的高度 h;然后按升序依次访问前 k 个节点即可返回结果。因此时间复杂度为 (O(h + k)),最坏情况下(树退化为链且 k 接近 n)为 (O(n))。
    • 空间复杂度:额外空间主要来自递归调用栈,最大深度等于树的高度 h。最坏情况下为 (O(n)),平衡二叉树为 (O(\log n)),整体表示为 (O(h))。

199. 二叉树的右视图

题目描述

给定一个二叉树的 根节点 root,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。

核心思路

BFS 层序遍历 -> 每一层的最后一个节点就是二叉树的右侧视图。我们可以把 BFS 反过来,从右往左遍历每一行,进一步提升效率。

题解代码

java 复制代码
class Solution {
    public List<Integer> rightSideView(TreeNode root) {
        List<Integer> res = new LinkedList<>();
        if(root == null) return res;

        Queue<TreeNode> q = new LinkedList<>();
        q.offer(root);

        while(!q.isEmpty()){
            int sz = q.size();
            TreeNode last = q.peek();

            for(int i = 0; i < sz; i++){
                TreeNode cur = q.poll();
                if(cur.right != null){
                    q.offer(cur.right);
                }
                if(cur.left != null){
                    q.offer(cur.left);
                }
            }

            res.add(last.val);
        }
        return res;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度:算法采用 BFS 从右向左逐层遍历二叉树,每个节点会入队和出队各一次,且每次操作都是 (O(1))。设节点数为 n,因此总时间复杂度为 (O(n))。
    • 空间复杂度 :队列中最多同时存放某一层的所有节点,完全二叉树最后一层的节点数可达 (n/2),因此队列占用空间为 (O(n));结果列表 res 存放每层最右侧节点,规模不超过树的高度,同样在 (O(n)) 之内。综合空间复杂度为 (O(n))。

114. 二叉树展开为链表

题目描述

给你二叉树的根结点 root ,请你将它展开为一个单链表:

  • 展开后的单链表应该同样使用 TreeNode ,其中 right 子指针指向链表中下一个结点,而左子指针始终为 null
  • 展开后的单链表应该与二叉树 先序遍历 顺序相同。

核心思路

递归(分解问题)-> 递归函数 flatten 的定义:flatten 函数输入一个节点 root,那么以 root 为根的二叉树就会被拉平为一条链表

如何利用这个定义来完成算法?想想怎么把以 root 为根的二叉树拉平为一条链表?很简单,以下流程:

1、将 root 的左子树和右子树拉平。

2、将 root 的右子树接到左子树下方,然后将整个左子树作为右子树。

题解代码

java 复制代码
class Solution {
    // 定义:将以 root 为根的树拉平为链表
    public void flatten(TreeNode root) {
        // base case
        if (root == null) return;
        // 先递归拉平左右子树
        flatten(root.left);
        flatten(root.right);

        // ***后序遍历位置***
        // 1、左右子树已经被拉平成一条链表
        TreeNode left = root.left;
        TreeNode right = root.right;

        // 2、将左子树作为右子树
        root.left = null;
        root.right = left;

        // 3、将原先的右子树接到当前右子树的末端
        TreeNode p = root;
        while (p.right != null) {
            p = p.right;
        }
        p.right = right;
    }
}

时空分析

  • 时间复杂度:(O(n^2))
  • 空间复杂度:(O(h))
  • 原因分析
    • 时间复杂度flatten 会对每个节点递归处理一次,节点访问本身为 (O(n))。但在后序位置,需要通过 while (p.right != null) p = p.right; 在拉平后的左子树末尾寻找连接点,这一步在最坏情况下需要遍历左子树的整条右链。对于退化成链的输入,累计遍历总代价可达 (O(n^2)),因此整体时间复杂度为 (O(n^2))。
    • 空间复杂度:额外空间主要来自递归调用栈,最大深度等于二叉树的高度 h。最坏情况下(链状树)为 (O(n)),平衡二叉树为 (O(\log n)),整体表示为 (O(h))。

105. 从前序与中序遍历序列构造二叉树

题目描述

给定两个整数数组 preorderinorder ,其中 preorder 是二叉树的 先序遍历inorder 是同一棵树的 中序遍历,请构造二叉树并返回其根节点。

核心思路

构造二叉树,第一件事一定是找根节点,然后想办法构造左右子树。

二叉树的前序和中序遍历结果的特点如下:

前序遍历结果第一个就是根节点的值,然后再根据中序遍历结果确定左右子树的节点。

题解代码

java 复制代码
class Solution {
    // 存储 inorder 中值到索引的映射
    HashMap<Integer, Integer> valToIndex = new HashMap<>();

    public TreeNode buildTree(int[] preorder, int[] inorder) {
        for (int i = 0; i < inorder.length; i++) {
            valToIndex.put(inorder[i], i);
        }
        return build(preorder, 0, preorder.length - 1,
                    inorder, 0, inorder.length - 1);
    }

    // 定义:前序遍历数组为 preorder[preStart..preEnd]
    // 中序遍历数组为 inorder[inStart..inEnd]
    // 构造这个二叉树并返回该二叉树的根节点
    TreeNode build(int[] preorder, int preStart, int preEnd,
                   int[] inorder, int inStart, int inEnd) {
        if (preStart > preEnd) {
            return null;
        }

        // root 节点对应的值就是前序遍历数组的第一个元素
        int rootVal = preorder[preStart];
        // rootVal 在中序遍历数组中的索引
        int index = valToIndex.get(rootVal);

        int leftSize = index - inStart;

        // 先构造出当前根节点
        TreeNode root = new TreeNode(rootVal);
        // 递归构造左右子树
        root.left = build(preorder, preStart + 1, preStart + leftSize,
                inorder, inStart, index - 1);

        root.right = build(preorder, preStart + leftSize + 1, preEnd,
                inorder, index + 1, inEnd);
        return root;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度 :首先用一次 for 循环把 inorder 中每个值到索引的映射存入 HashMap,时间复杂度为 (O(n))。之后递归函数 build 每层为一个节点分配空间并通过 HashMap 在 (O(1)) 时间内定位根节点在中序数组中的位置,然后划分左右子树递归下去。整棵树共有 n 个节点,每个节点仅被处理一次,因此整体时间复杂度为 (O(n))。
    • 空间复杂度HashMap 中存放了 n 个键值对,占用 (O(n)) 空间;递归调用栈的最大深度等于二叉树的高度 h,最坏为 (O(n)),平衡时为 (O(\log n))。综合来看,空间复杂度为 (O(n))(不计构造出的二叉树本身)。

437. 路径总和 III

题目描述

给定一个二叉树的根节点 root ,和一个整数 targetSum ,求该二叉树里节点值之和等于 targetSum路径 的数目。

路径 不需要从根节点开始,也不需要在叶子节点结束,但是路径方向必须是向下的(只能从父节点到子节点)。

核心思路

递归(遍历) + 前缀和 + HashMap -> 这道题与 560. 和为 K 的子数组 思想类似答题步骤:

  1. 求前缀和
  2. 查看 HashMap(定义为:preSumToCount) 中是否有 preSum-target 的 key,有则加上对应的 count。
  3. 将该 preSum 放入到 HashMap 中,并更新 count 次数。

note:数据大小,preSum 应该定义为 long 类型。

题解代码

java 复制代码
class Solution {
    // 记录前缀和
    // 定义:从二叉树的根节点开始,路径和为 pathSum 的路径有 preSumCount.get(pathSum) 个
    HashMap<Long, Integer> preSumCount = new HashMap<>();

    long pathSum, targetSum;
    int res = 0;

    public int pathSum(TreeNode root, int targetSum) {
        if (root == null) {
            return 0;
        }
        this.pathSum = 0;
        this.targetSum = targetSum;
        this.preSumCount.put(0L, 1);
        traverse(root);
        return res;
    }

    void traverse(TreeNode root) {
        if (root == null) {
            return;
        }
        // 前序遍历位置
        pathSum += root.val;
        // 从二叉树的根节点开始,路径和为 pathSum - targetSum 的路径条数
        // 就是路径和为 targetSum 的路径条数
        res += preSumCount.getOrDefault(pathSum - targetSum, 0);
        // 记录从二叉树的根节点开始,路径和为 pathSum 的路径条数
        preSumCount.put(pathSum, preSumCount.getOrDefault(pathSum, 0) + 1);

        traverse(root.left);
        traverse(root.right);

        // 后序遍历位置
        preSumCount.put(pathSum, preSumCount.get(pathSum) - 1);
        pathSum -= root.val;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(n))
  • 原因分析
    • 时间复杂度traverse 对二叉树进行一次前序与后序相结合的遍历,每个节点仅被访问一次。在每个节点处执行的操作(更新 pathSum、查询/更新 preSumCount、回溯还原)均为哈希表的平均 (O(1)) 操作。设节点数为 n,因此整体时间复杂度为 (O(n))。
    • 空间复杂度 :哈希表 preSumCount 记录了从根到当前节点路径上出现过的所有前缀和,最坏情况下(如链状树)会同时保存 n 个不同的前缀和,占用 (O(n)) 空间;递归调用栈的最大深度也为树高 h,最坏为 (O(n))。综合空间复杂度为 (O(n))。

236. 二叉树的最近公共祖先

题目描述

给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。

百度百科 中最近公共祖先的定义为:"对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。"

核心思路

递归(分解问题) -> 最近公共祖先模板

递归函数的定义:给该函数输入三个参数 rootpq,它会返回一个节点:

情况 1,如果 pq 都在以 root 为根的树中,函数返回的即使 pq 的最近公共祖先节点。

情况 2,那如果 pq 都不在以 root 为根的树中怎么办呢?函数理所当然地返回 null 呗。

情况 3,那如果 pq 只有一个存在于 root 为根的树中呢?函数就会返回那个节点。

java 复制代码
// 定义:在以 root 为根的二叉树中寻找值为 val1 或 val2 的节点
TreeNode find(TreeNode root, int val1, int val2) {
    // base case
    if (root == null) {
        return null;
    }
    // 前序位置,看看 root 是不是目标值
    if (root.val == val1 || root.val == val2) {
        return root;
    }
    // 去左右子树寻找
    TreeNode left = find(root.left, val1, val2);
    TreeNode right = find(root.right, val1, val2);

    // 后序位置,已经知道左右子树是否存在目标值
    return left != null ? left : right;
}

题解代码

java 复制代码
class Solution {
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        return find(root, p.val, q.val);
    }

    public TreeNode find(TreeNode root, int val1, int val2){
        if(root == null) return null;
        if(root.val == val1 || root.val == val2) return root;

        TreeNode left = find(root.left, val1, val2);
        TreeNode right = find(root.right, val1, val2);

        if(left != null && right != null){
            return root;
        }

        return left != null ? left : right;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(h))
  • 原因分析
    • 时间复杂度find 采用后序遍历的思想访问二叉树中的每个节点,每个节点最多被访问一次,节点处的比较与返回操作均为 (O(1))。设节点数为 n,因此整体时间复杂度为 (O(n))。
    • 空间复杂度:额外空间主要来自递归调用栈,栈的最大深度等于二叉树的高度 h。最坏情况下(链状树)为 (O(n)),平衡二叉树为 (O(\log n)),整体表示为 (O(h))。

124. 二叉树中的最大路径和

题目描述

二叉树中的 路径 被定义为一条节点序列,序列中每对相邻节点之间都存在一条边。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。

路径和 是路径中各节点值的总和。

给你一个二叉树的根节点 root ,返回其 最大路径和

核心思路

递归(分解问题) -> 分解问题的思想:定义一个 oneSideMax 函数来计算从根节点 root 为起点的最大单边路径和。oneSideMax 函数和 maxDepth 函数原理类似,只不过 maxDepth 计算最大深度,oneSideMax 计算「单边」最大路径和。然后在计算单边路径和时顺便计算最大路径和。(后序遍历位置)

题解代码

java 复制代码
class Solution {
    int res = Integer.MIN_VALUE;

    public int maxPathSum(TreeNode root) {
        if (root == null) {
            return 0;
        }
        // 计算单边路径和时顺便计算最大路径和
        oneSideMax(root);
        return res;
    }

    // 定义:计算从根节点 root 为起点的最大单边路径和
    int oneSideMax(TreeNode root) {
        if (root == null) {
            return 0;
        }
        int leftMaxSum = Math.max(0, oneSideMax(root.left));
        int rightMaxSum = Math.max(0, oneSideMax(root.right));
        // 后序遍历位置,顺便更新最大路径和
        int pathMaxSum = root.val + leftMaxSum + rightMaxSum;
        res = Math.max(res, pathMaxSum);
        // 实现函数定义,左右子树的最大单边路径和加上根节点的值
        // 就是从根节点 root 为起点的最大单边路径和
        return Math.max(leftMaxSum, rightMaxSum) + root.val;
    }
}

时空分析

  • 时间复杂度:(O(n))
  • 空间复杂度:(O(h))
  • 原因分析
    • 时间复杂度oneSideMax 采用后序遍历,每个节点都会被访问一次,且在后序位置更新最大路径和以及返回单边最大路径和的操作均为 (O(1))。设二叉树节点数为 n,因此整体时间复杂度为 (O(n))。
    • 空间复杂度:额外空间主要来自递归调用栈,栈的最大深度等于二叉树的高度 h。最坏情况下(链状树)为 (O(n)),平衡二叉树为 (O(\log n)),整体表示为 (O(h))。

🐮🐴

相关推荐
退休倒计时5 小时前
【每日一题】LeetCode 17. 电话号码的字母组合 TypeScript
算法·leetcode·typescript
鱼儿也有烦恼5 小时前
数据结构合集
算法·algorithm
临沂堇6 小时前
刷题日志 | LeetCode Hot 100 双指针
算法·leetcode·职场和发展
XWalnut6 小时前
LeetCode刷题 day29
java·算法·leetcode
小欣加油8 小时前
leetcode1331 数组序号转换
数据结构·c++·算法·leetcode·职场和发展
Hesionberger10 小时前
动态规划与二分法破解最长递增子序列
java·数据结构·python·算法·leetcode
令狐掌门11 小时前
2026华为OD面试题020:日志文件异常检测
算法·leetcode·华为od
To_OC20 小时前
LC 17 电话号码的字母组合:我的回溯算法,就是从这道题开窍的
javascript·算法·leetcode
海石1 天前
【JS击败90%】前缀和+定长滑动窗口
算法·leetcode