Skip to content

常见算法题

两数之和

two-sum-csharp-hashmap-v3

两数之和是什么? 题目一般是:

给你一个整数数组 nums 和一个目标值 target。
请你在数组中找出两个数,使它们相加等于 target。
返回这两个数的下标。

比如:

nums = [2, 7, 11, 15]
target = 9

因为:

2 + 7 = 9

所以返回:

[0, 1]

注意:返回的是下标,不是数字本身。

核心思路

暴力做法是两层循环:

拿 nums[i] 和后面每个 nums[j] 相加

这样时间复杂度是:

c
O(n²)

更好的做法是用 C# 的:

c
Dictionary<int, int>

它相当于哈希表。

字典里存:

key:数字
value:数字对应的下标

遍历当前数字 nums[i] 时,我们不去暴力找另一个数,而是直接算:

c
complement = target - nums[i]

然后问字典:

之前有没有出现过 complement?

如果出现过,就找到答案了。

C# 代码

c
using System.Collections.Generic; // 引入集合命名空间,用来使用 Dictionary
public class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
    public int[] TwoSum(int[] nums, int target) // 定义 TwoSum 函数,输入数组 nums 和目标值 target,返回两个下标
    { // TwoSum 函数开始
        Dictionary<int, int> map = new Dictionary<int, int>(); // 创建字典,key 存数字,value 存数字对应的下标
        for (int i = 0; i < nums.Length; i++) // 从左到右遍历数组中的每一个数字
        { // for 循环开始
            int current = nums[i]; // 取出当前数字
            int complement = target - current; // 计算当前数字需要搭配的另一个数字
            if (map.ContainsKey(complement)) // 如果字典里已经出现过 complement
            { // if 语句开始
                return new int[] { map[complement], i }; // 返回 complement 的下标和当前数字的下标
            } // if 语句结束
            if (!map.ContainsKey(current)) // 如果当前数字还没有被放进字典
            { // if 语句开始
                map.Add(current, i); // 把当前数字和它的下标加入字典
            } // if 语句结束
        } // for 循环结束
        return new int[] { }; // 如果没有找到答案,返回空数组
    } // TwoSum 函数结束
} // Solution 类结束

为什么要先查再存?

这点很关键。

假设:

c
nums = [3, 3]
target = 6

当我们遍历第一个 3 时:

c
complement = 6 - 3 = 3

如果先把自己存进去,再查字典,就可能把同一个下标用了两次。

所以正确顺序是:

先查 complement 是否存在
再把当前数字存入字典

这样可以保证:

两个下标一定不同

为什么 Dictionary 快?

Dictionary 底层是哈希表。

查找一个 key 平均情况下是:

O(1)

所以整个数组只需要遍历一次:

O(n)

比暴力双循环的:

O(n²)

快很多。

复杂度

时间复杂度:

O(n)

因为只遍历一次数组。

空间复杂度:

O(n)

因为最坏情况下,字典里可能存下很多数字。

面试高分回答

CAUTION

两数之和可以用哈希表优化。遍历数组时,对于当前数字 nums[i],我们计算它需要的另一个数字 complement = target - nums[i]。如果 complement 已经在字典中,说明之前出现过一个数字可以和当前数字相加得到 target,直接返回两个下标。如果没有找到,就把当前数字和下标存入字典。这样每个元素只遍历一次,字典查询平均是 O(1),所以总时间复杂度是 O(n),空间复杂度是 O(n)

三数之和

一句话理解

三数之和就是:在数组里找所有不重复的三元组,让它们相加等于 0。经典解法是:先排序,再固定一个数,剩下两个数用双指针找。

three-sum-two-pointers

题目意思

给你一个数组:

c
nums = [-1, 0, 1, 2, -1, -4]

你要找所有三元组:

c
a + b + c = 0

并且三元组不能重复。

这个例子的答案是:

c
[-1, -1, 2]
[-1, 0, 1]

注意:返回的是数字组合,不是下标。

核心思路

两数之和可以用哈希表。

三数之和最经典的是:

先排序。

固定第一个数 nums[i]

剩下部分用 leftright 双指针找另外两个数。

排序后:

c
[-4, -1, -1, 0, 1, 2]

假设固定:

c
nums[i] = -1

那问题就变成:

c
在 i 后面找两个数,让 left + right = 1

也就是:

c
-1 + nums[left] + nums[right] = 0

为什么要排序

排序后,数组从小到大。

如果三数之和太小:

c
sum < 0

说明需要更大的数,所以 left 右移。

如果三数之和太大:

c
sum > 0

说明需要更小的数,所以 right 左移。

如果不排序,你就不知道指针应该往哪边移动。

双指针怎么走

固定 i 后:

c
left = i + 1
right = nums.size() - 1

然后计算:

c
sum = nums[i] + nums[left] + nums[right]

如果:

c
sum == 0

说明找到一个答案。

如果:

c
sum < 0

说明当前和太小,让 left++

如果:

c
sum > 0

说明当前和太大,让 right--

去重非常重要

三数之和最容易错在重复答案。

比如排序后:

c
[-4, -1, -1, 0, 1, 2]

这里有两个 -1

如果不跳过重复的固定数,[-1, 0, 1] 可能会被加入多次。

所以要做三层去重:

固定数 i 去重。

找到答案后,left 去重。

找到答案后,right 去重。

C++ 标准写法

c
#include <algorithm> // 引入 sort 函数,用来给数组排序
#include <vector> // 引入 vector 容器,用来保存数组和答案
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    vector<vector<int>> threeSum(vector<int>& nums) // 定义 threeSum 函数,返回所有不重复三元组
    { // threeSum 函数开始
        vector<vector<int>> result; // 创建结果数组,用来保存所有满足条件的三元组
        sort(nums.begin(), nums.end()); // 先给数组排序,方便双指针移动和去重
        int n = nums.size(); // 保存数组长度,避免后面反复调用 size
        for (int i = 0; i < n - 2; i++) // 枚举第一个数,至少要给 left 和 right 留两个位置
        { // for 循环开始
            if (i > 0 && nums[i] == nums[i - 1]) // 如果当前固定数和上一个固定数相同
            { // if 语句开始
                continue; // 跳过重复固定数,避免重复三元组
            } // if 语句结束
            if (nums[i] > 0) // 如果固定数已经大于 0
            { // if 语句开始
                break; // 后面的数都更大,三数之和不可能等于 0,直接结束
            } // if 语句结束
            int left = i + 1; // 左指针从固定数后面一位开始
            int right = n - 1; // 右指针从数组最后一位开始
            while (left < right) // 只要左右指针还没有相遇,就继续查找
            { // while 循环开始
                int sum = nums[i] + nums[left] + nums[right]; // 计算当前三数之和
                if (sum == 0) // 如果三数之和正好等于 0
                { // if 语句开始
                    result.push_back({nums[i], nums[left], nums[right]}); // 把当前三元组加入答案
                    left++; // 左指针右移,继续找新的组合
                    right--; // 右指针左移,继续找新的组合
                    while (left < right && nums[left] == nums[left - 1]) // 如果左指针遇到重复值
                    { // while 循环开始
                        left++; // 跳过重复的左值,避免重复答案
                    } // while 循环结束
                    while (left < right && nums[right] == nums[right + 1]) // 如果右指针遇到重复值
                    { // while 循环开始
                        right--; // 跳过重复的右值,避免重复答案
                    } // while 循环结束
                } // if 语句结束
                else if (sum < 0) // 如果三数之和小于 0
                { // else if 语句开始
                    left++; // 说明和太小,需要更大的数,所以左指针右移
                } // else if 语句结束
                else // 否则说明三数之和大于 0
                { // else 语句开始
                    right--; // 说明和太大,需要更小的数,所以右指针左移
                } // else 语句结束
            } // while 循环结束
        } // for 循环结束
        return result; // 返回所有不重复三元组
    } // threeSum 函数结束
}; // Solution 类结束

复杂度

时间复杂度是 O(n²)

因为外层固定一个数是 O(n),里面双指针整体移动是 O(n)

空间复杂度通常认为是 O(1)O(log n),看排序实现是否使用额外栈空间;结果数组不算额外空间。

常见坑

第一个坑:没有排序就直接双指针。

第二个坑:没有去重,导致答案重复。

第三个坑:返回下标。三数之和一般返回三元组数字,不是下标。

第四个坑:找到一个答案后,只移动一个指针。通常命中后 left++right-- 都要移动,并且跳过重复值。

面试高分回答

CAUTION

三数之和通常用排序加双指针。先对数组排序,然后枚举第一个数 nums[i],把问题转化为在 i 后面的区间里寻找两个数,使三数之和为 0。由于数组有序,如果当前和小于 0,就让 left 右移增大和;如果当前和大于 0,就让 right 左移减小和;如果等于 0,就记录答案,并跳过重复的 leftright。为了避免重复三元组,还要跳过重复的固定数 i。整体时间复杂度是 O(n²)

反转链表

一句话理解

反转链表就是把每个节点的 next 指针反过来:原来 1 -> 2 -> 3 -> null,反转后变成 3 -> 2 -> 1 -> null

reverse-linked-list

核心思路

反转链表最经典的写法是三个指针:

prev:已经反转好的前半部分的头。

current:当前正在处理的节点。

next:提前保存 current 后面的节点,防止链表断掉。

为什么一定要保存 next

因为一旦你执行:

c
current->next = prev

当前节点就不再指向原来的后面节点了。如果没提前保存,后面的链表就丢了。

循环过程

假设原链表是:

c
1 -> 2 -> 3 -> null

一开始:

c
prev = null
current = 1

第一轮:

c
next = 2
1->next = null
prev = 1
current = 2

此时已经反转好的部分是:

c
1 -> null

还没处理的是:

c
2 -> 3 -> null

最后会变成:

c
3 -> 2 -> 1 -> null

C++ 代码:迭代写法

这是面试最推荐写法,简单、稳定、空间复杂度低。

c
struct ListNode // 定义链表节点结构体
{ // ListNode 结构体开始
    int val; // 保存当前节点的值
    ListNode* next; // 保存下一个节点的地址
    ListNode(int x) : val(x), next(nullptr) // 构造函数,用传入的 x 初始化节点值,并让 next 默认为空
    { // 构造函数开始
    } // 构造函数结束
}; // ListNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    ListNode* reverseList(ListNode* head) // 定义反转链表函数,传入旧头节点,返回新头节点
    { // reverseList 函数开始
        ListNode* prev = nullptr; // prev 表示已经反转好的前半部分,一开始为空
        ListNode* current = head; // current 表示当前正在处理的节点,一开始是旧头节点
        while (current != nullptr) // 只要当前节点不为空,就继续反转
        { // while 循环开始
            ListNode* next = current->next; // 先保存 current 后面的节点,防止反转指针后丢失链表
            current->next = prev; // 把 current 的 next 指向 prev,完成当前节点的指针反转
            prev = current; // prev 向前推进到 current,表示反转好的部分变长了
            current = next; // current 移动到原来的下一个节点,继续处理后面的链表
        } // while 循环结束
        return prev; // current 为空时,prev 就是反转后的新头节点
    } // reverseList 函数结束
}; // Solution 类结束

为什么返回 prev

循环结束时:

c
current == null

说明旧链表已经全部处理完。

这时候 prev 指向最后处理的节点。

而最后处理的节点,正好是反转后链表的新头节点。

比如:

c
1 -> 2 -> 3 -> null

最后处理的是 3,所以新头就是 3

复杂度

时间复杂度是 O(n)

因为每个节点只处理一次。

空间复杂度是 O(1)

因为只用了 prevcurrentnext 三个指针,没有额外开数组或栈。

递归写法

递归写法也可以,但面试时更容易写错。它的思想是:先把后面的链表反转好,再把当前节点接到尾部。

c
struct ListNode // 定义链表节点结构体
{ // ListNode 结构体开始
    int val; // 保存当前节点的值
    ListNode* next; // 保存下一个节点的地址
    ListNode(int x) : val(x), next(nullptr) // 构造函数,用传入的 x 初始化节点值,并让 next 默认为空
    { // 构造函数开始
    } // 构造函数结束
}; // ListNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    ListNode* reverseList(ListNode* head) // 定义递归反转链表函数
    { // reverseList 函数开始
        if (head == nullptr || head->next == nullptr) // 如果链表为空,或者只有一个节点
        { // if 语句开始
            return head; // 空链表或单节点链表反转后还是自己
        } // if 语句结束
        ListNode* newHead = reverseList(head->next); // 递归反转 head 后面的链表,并拿到新头节点
        head->next->next = head; // 让原来的下一个节点反过来指向 head
        head->next = nullptr; // 把 head 的 next 断开,避免形成环
        return newHead; // 返回反转后的新头节点
    } // reverseList 函数结束
}; // Solution 类结束

递归为什么要 head->next = nullptr

原来是:

c
1 -> 2

递归回来后会做:

c
2 -> 1

但原来的:

c
1 -> 2

还在。

如果不把 1->next 置空,就会变成:

c
1 <-> 2

也就是形成环。

所以递归写法一定要断开旧指针。

面试高分回答

NOTE

反转链表的核心是逐个改变节点的 next 指针方向。迭代做法通常使用三个指针:prev 表示已经反转好的部分,current 表示当前处理节点,next 用来提前保存后续节点,避免修改 current->next 后丢失链表。每次循环先保存 next,再令 current->next = prev,然后 prevcurrent 向后移动。循环结束时 prev 就是新的头节点。时间复杂度是 O(n),空间复杂度是 O(1)

合并两个有序链表

一句话理解

合并两个有序链表,就是每次比较两个链表当前头节点,谁小就把谁接到结果链表后面,然后那个链表往后走一步。

merge-two-sorted-lists

题目意思

给你两个已经升序排好的链表:

list1: 1 -> 2 -> 4
list2: 1 -> 3 -> 4

你要把它们合成一个新的升序链表:

1 -> 1 -> 2 -> 3 -> 4 -> 4

注意:链表本来就是有序的,所以我们不用重新排序,只要像拉拉链一样,每次拿更小的节点接到结果后面。

核心思路

准备一个 dummy 虚拟头节点。

再准备一个 tail 指针,表示结果链表当前最后一个节点。

然后循环比较:

c
list1 当前节点
list2 当前节点

如果 list1->val <= list2->val,就接 list1

否则接 list2

接完之后:

被接走的链表往后移动。

tail 也往后移动。

为什么要用 dummy

如果不用 dummy,你要单独处理“结果链表第一个节点是谁”。

用了 dummy 之后,所有节点都统一接在 tail->next 后面。

最后返回:

c
dummy.next

因为 dummy 自己不是答案的一部分,dummy.next 才是真正的新头节点。

C++ 迭代写法

c
struct ListNode // 定义链表节点结构体
{ // ListNode 结构体开始
    int val; // 保存当前节点的值
    ListNode* next; // 保存下一个节点的地址
    ListNode(int x) : val(x), next(nullptr) // 构造函数,用 x 初始化 val,并让 next 默认指向空
    { // 构造函数开始
    } // 构造函数结束
}; // ListNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) // 定义合并两个有序链表的函数
    { // mergeTwoLists 函数开始
        ListNode dummy(0); // 创建虚拟头节点,方便统一处理结果链表的开头
        ListNode* tail = &dummy; // tail 指向结果链表的尾部,一开始指向 dummy
        while (list1 != nullptr && list2 != nullptr) // 只要两个链表都还没有走完,就继续比较
        { // while 循环开始
            if (list1->val <= list2->val) // 如果 list1 当前节点的值更小或相等
            { // if 语句开始
                tail->next = list1; // 把 list1 当前节点接到结果链表尾部
                list1 = list1->next; // list1 指针向后移动一位
            } // if 语句结束
            else // 否则说明 list2 当前节点更小
            { // else 语句开始
                tail->next = list2; // 把 list2 当前节点接到结果链表尾部
                list2 = list2->next; // list2 指针向后移动一位
            } // else 语句结束
            tail = tail->next; // tail 向后移动到结果链表新的尾节点
        } // while 循环结束
        if (list1 != nullptr) // 如果 list1 还有剩余节点
        { // if 语句开始
            tail->next = list1; // 直接把 list1 剩余部分接到结果链表后面
        } // if 语句结束
        else // 否则说明 list1 已经走完
        { // else 语句开始
            tail->next = list2; // 直接把 list2 剩余部分接到结果链表后面
        } // else 语句结束
        return dummy.next; // 返回 dummy 后面的节点,也就是真正的新链表头
    } // mergeTwoLists 函数结束
}; // Solution 类结束

为什么剩余链表可以直接接上

因为两个输入链表本身已经是有序的。

比如比较到后面时:

c
结果链表: 1 -> 1 -> 2
list1: 4
list2: 3 -> 4

接完 3 后,如果 list2 走完了,list1 剩下的 4 本来就是有序的,直接接上即可。

不需要再创建新节点,也不需要再排序。

递归写法

递归写法更短,但零基础时更推荐先掌握迭代法。

c
struct ListNode // 定义链表节点结构体
{ // ListNode 结构体开始
    int val; // 保存当前节点的值
    ListNode* next; // 保存下一个节点的地址
    ListNode(int x) : val(x), next(nullptr) // 构造函数,用 x 初始化 val,并让 next 默认指向空
    { // 构造函数开始
    } // 构造函数结束
}; // ListNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) // 定义递归合并两个有序链表的函数
    { // mergeTwoLists 函数开始
        if (list1 == nullptr) // 如果 list1 已经为空
        { // if 语句开始
            return list2; // 直接返回 list2 剩余部分
        } // if 语句结束
        if (list2 == nullptr) // 如果 list2 已经为空
        { // if 语句开始
            return list1; // 直接返回 list1 剩余部分
        } // if 语句结束
        if (list1->val <= list2->val) // 如果 list1 当前节点更小或相等
        { // if 语句开始
            list1->next = mergeTwoLists(list1->next, list2); // list1 当前节点后面接递归合并后的结果
            return list1; // 返回 list1 作为当前合并结果的头
        } // if 语句结束
        else // 否则说明 list2 当前节点更小
        { // else 语句开始
            list2->next = mergeTwoLists(list1, list2->next); // list2 当前节点后面接递归合并后的结果
            return list2; // 返回 list2 作为当前合并结果的头
        } // else 语句结束
    } // mergeTwoLists 函数结束
}; // Solution 类结束

复杂度

时间复杂度是 O(m + n)

因为两个链表的每个节点最多被处理一次。

空间复杂度:

迭代法是 O(1)

递归法是 O(m + n),因为递归调用会占用函数栈。

面试高分回答

NOTE

合并两个有序链表可以用双指针。分别指向两个链表当前头节点,每次比较两个节点的值,把较小的节点接到结果链表尾部,然后对应链表向后移动。为了简化处理结果链表的头节点,通常使用一个 dummy 虚拟头节点和一个 tail 尾指针。循环结束后,如果某个链表还有剩余节点,因为它本身已经有序,可以直接接到 tail 后面。迭代法时间复杂度是 O(m+n),空间复杂度是 O(1)

判断链表是否有环

一句话理解

判断链表是否有环,最经典的方法是“快慢指针”:slow 每次走 1 步,fast 每次走 2 步。如果有环,fast 一定会在环里追上 slow;如果没有环,fast 会先走到 null

linked-list-cycle-detection

什么叫链表有环

正常链表是这样:

c
1 -> 2 -> 3 -> null

最后会走到 null

有环链表是这样:

1 -> 2 -> 3 -> 4 -> 5
          ↑         |
          └─────────┘

节点 5next 指回了节点 3,所以一直走下去会在 3 -> 4 -> 5 -> 3 里面绕圈,永远走不到 null

快慢指针思路

准备两个指针:

slow:慢指针,每次走一步。

fast:快指针,每次走两步。

每一轮:

c
slow = slow->next
fast = fast->next->next

如果链表没有环,fast 走得更快,所以它会先到链表尾部,也就是遇到 null

如果链表有环,fastslow 都会进入环。进入环之后,fast 每轮比 slow 多走一步,所以它一定会追上 slow

为什么有环一定会相遇

你可以把环想成操场跑道。

slow 每次走 1 格。

fast 每次走 2 格。

它们都进入跑道后,fast 每一轮都会比 slow 多接近 1 格。

只要一直跑,快的人一定会追上慢的人。

所以:

c
slow == fast

就说明链表有环。

C++ 标准写法

c
struct ListNode // 定义链表节点结构体
{ // ListNode 结构体开始
    int val; // 保存当前节点的值
    ListNode* next; // 保存下一个节点的地址
    ListNode(int x) : val(x), next(nullptr) // 构造函数,用 x 初始化 val,并让 next 默认指向空
    { // 构造函数开始
    } // 构造函数结束
}; // ListNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    bool hasCycle(ListNode* head) // 定义判断链表是否有环的函数
    { // hasCycle 函数开始
        ListNode* slow = head; // slow 是慢指针,一开始指向头节点
        ListNode* fast = head; // fast 是快指针,一开始也指向头节点
        while (fast != nullptr && fast->next != nullptr) // 只有 fast 和 fast->next 都不为空,fast 才能安全走两步
        { // while 循环开始
            slow = slow->next; // 慢指针每次走一步
            fast = fast->next->next; // 快指针每次走两步
            if (slow == fast) // 如果快慢指针相遇
            { // if 语句开始
                return true; // 说明链表有环
            } // if 语句结束
        } // while 循环结束
        return false; // fast 走到了 null,说明链表没有环
    } // hasCycle 函数结束
}; // Solution 类结束

为什么 while 条件要写两个判断

这里必须写:

c
fast != nullptr && fast->next != nullptr

因为 fast 每次要走两步:

c
fast = fast->next->next

如果 fast 已经是 null,访问 fast->next 会崩。

如果 fast->nextnull,访问 fast->next->next 也不安全。

所以循环条件是在保护快指针安全移动。

哈希表写法

也可以用哈希表记录访问过的节点。

如果某个节点第二次出现,说明有环。

这个方法更直观,但空间复杂度是 O(n)

c
#include <unordered_set> // 引入 unordered_set,用来记录访问过的节点地址
using namespace std; // 使用标准命名空间,避免每次写 std::

struct ListNode // 定义链表节点结构体
{ // ListNode 结构体开始
    int val; // 保存当前节点的值
    ListNode* next; // 保存下一个节点的地址
    ListNode(int x) : val(x), next(nullptr) // 构造函数,用 x 初始化 val,并让 next 默认指向空
    { // 构造函数开始
    } // 构造函数结束
}; // ListNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    bool hasCycle(ListNode* head) // 定义判断链表是否有环的函数
    { // hasCycle 函数开始
        unordered_set<ListNode*> visited; // 创建哈希集合,用来保存已经访问过的节点地址
        ListNode* current = head; // current 从头节点开始遍历
        while (current != nullptr) // 只要 current 还没有走到空,就继续遍历
        { // while 循环开始
            if (visited.find(current) != visited.end()) // 如果当前节点之前已经访问过
            { // if 语句开始
                return true; // 说明链表绕回来了,也就是有环
            } // if 语句结束
            visited.insert(current); // 把当前节点地址记录到哈希集合里
            current = current->next; // current 移动到下一个节点
        } // while 循环结束
        return false; // 如果正常走到 null,说明没有环
    } // hasCycle 函数结束
}; // Solution 类结束

复杂度对比

方法时间复杂度空间复杂度面试推荐
快慢指针O(n)O(1)推荐
哈希表O(n)O(n)好理解,但不够省空间

常见坑

第一个坑:只写普通遍历。

c
while (head != nullptr)

如果链表有环,这个循环永远不会结束。

第二个坑:快指针移动前没有判断 fast->next

第三个坑:比较节点值,而不是比较节点地址。

判断是否相遇要写:

c
slow == fast

不是:

c
slow->val == fast->val

因为链表里可能有多个节点的值相同,值相同不代表是同一个节点。

面试高分回答

NOTE

判断链表是否有环可以用 Floyd 快慢指针算法。定义 slowfast 两个指针,初始都指向头节点。每轮 slow 走一步,fast 走两步。如果链表无环,fast 会先到达 null;如果链表有环,两个指针进入环后,fast 每轮都会比 slow 多走一步,最终一定会相遇。因此只要出现 slow == fast,就说明有环。这个方法时间复杂度是 O(n),空间复杂度是 O(1)

有效括号

一句话理解

“有效括号”就是判断括号有没有正确配对、正确闭合。最经典做法是用栈:左括号入栈,右括号来了就和栈顶匹配。

valid-parentheses-stack

题目意思

给你一个只包含这些字符的字符串:

c
'(', ')', '[', ']', '{', '}'

判断它是不是有效括号字符串。

有效例子:

c
()
()[]{}
({[]})

无效例子:

c
(]
([)]
((
]

有效括号必须满足:

左括号要被同类型右括号关闭。

括号关闭顺序要正确。

不能多左括号,也不能多右括号。

为什么用栈

括号匹配有一个特点:

后打开的括号,要先关闭。

比如:

c
({[]})

打开顺序是:

c
(  {  [

关闭顺序必须是:

c
]  }  )

这正好符合栈的特点:

后进先出

所以这题天然适合用栈。

核心规则

遇到左括号:

入栈

遇到右括号:

看栈顶是不是对应的左括号

如果匹配:

弹出栈顶

如果不匹配:

c
直接返回 false

最后:

栈为空,说明全部匹配成功
栈不为空,说明还有左括号没被关闭

C++ 标准写法

c
#include <stack> // 引入 stack 容器,用来保存还没有匹配的左括号
#include <string> // 引入 string 类型,用来接收括号字符串
using namespace std; // 使用标准命名空间,避免每次写 std::

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    bool isValid(string s) // 定义判断括号是否有效的函数
    { // isValid 函数开始
        stack<char> st; // 创建字符栈,用来保存左括号
        for (char ch : s) // 从左到右遍历字符串中的每一个字符
        { // for 循环开始
            if (ch == '(' || ch == '[' || ch == '{') // 如果当前字符是左括号
            { // if 语句开始
                st.push(ch); // 把左括号压入栈中,等待后续右括号匹配
            } // if 语句结束
            else // 否则当前字符就是右括号
            { // else 语句开始
                if (st.empty()) // 如果右括号来了,但是栈里没有左括号
                { // if 语句开始
                    return false; // 说明没有可匹配的左括号,括号无效
                } // if 语句结束
                char top = st.top(); // 取出栈顶左括号,也就是最近出现的左括号
                if (ch == ')' && top != '(') // 如果当前是右小括号,但栈顶不是左小括号
                { // if 语句开始
                    return false; // 类型不匹配,括号无效
                } // if 语句结束
                if (ch == ']' && top != '[') // 如果当前是右中括号,但栈顶不是左中括号
                { // if 语句开始
                    return false; // 类型不匹配,括号无效
                } // if 语句结束
                if (ch == '}' && top != '{') // 如果当前是右大括号,但栈顶不是左大括号
                { // if 语句开始
                    return false; // 类型不匹配,括号无效
                } // if 语句结束
                st.pop(); // 匹配成功后,弹出栈顶左括号
            } // else 语句结束
        } // for 循环结束
        return st.empty(); // 遍历结束后,栈为空才说明所有左括号都被匹配了
    } // isValid 函数结束
}; // Solution 类结束

执行过程例子

字符串:

c
({[]})

读到 (

c
栈:[ ( ]

读到 {

c
栈:[ (, { ]

读到 [

c
栈:[ (, {, [ ]

读到 ]

栈顶是 [,匹配成功,弹出 [

读到 }

栈顶是 {,匹配成功,弹出 {

读到 )

栈顶是 (,匹配成功,弹出 (

最后栈为空:

有效

无效情况

第一种:右括号太早出现。

c
")"

一开始就遇到右括号,但栈是空的,没有左括号可以匹配,所以无效。

第二种:括号类型不匹配。

c
"(]"

遇到 ] 时,栈顶是 (,类型不匹配,所以无效。

第三种:最后还有左括号没关。

c
"(()"

遍历结束后,栈里还有 (,说明没闭合,所以无效。

复杂度

时间复杂度是 O(n)

因为每个字符只遍历一次。

空间复杂度是 O(n)

最坏情况下,字符串全是左括号,栈里要存 n 个字符。

面试高分回答

NOTE

有效括号可以用栈来判断。因为括号闭合满足“后打开的括号先关闭”,这正好符合栈的后进先出。遍历字符串时,遇到左括号就入栈;遇到右括号时,先判断栈是否为空,如果为空说明没有左括号匹配,直接返回 false;如果不为空,就检查栈顶左括号是否和当前右括号类型匹配,不匹配也返回 false,匹配则弹出栈顶。遍历结束后,只有栈为空才说明所有括号都正确闭合。时间复杂度是 O(n),空间复杂度是 O(n)

最小栈

一句话理解

最小栈就是:除了普通栈的 pushpoptop,还要能用 O(1) 时间拿到当前栈里的最小值 getMin()

min-stack-explained

题目要求

普通栈支持:

c
push(x):压入元素
pop():弹出栈顶
top():查看栈顶

最小栈额外要求:

c
getMin():快速拿到当前栈里的最小值

关键是:getMin() 不能每次都遍历整个栈,否则就是 O(n)。题目通常要求它是 O(1)

核心思路

用两个栈:

dataStack:正常保存所有数据。

minStack:保存“当前这一层为止的最小值”。

比如依次压入:

c
5, 2, -3, 0

dataStack 是:

c
0
-3
2
5

minStack 是:

c
-3
-3
2
5

为什么最后一层是 -3

因为压入 0 时,当前最小值已经是 -3,所以这一层的最小值仍然是 -3

这样 getMin() 只需要看:

c
minStack.top()

就能立刻拿到最小值。

为什么 pop 要两个栈一起弹

因为 minStack 的每一层和 dataStack 的每一层是对应的。

dataStack 弹掉一个元素,当前栈的最小值状态也要回到上一层。

所以:

c
dataStack.pop()
minStack.pop()

必须一起做。

C++ 标准写法

c
#include <stack> // 引入 stack 容器,用来实现普通栈和辅助最小栈
#include <algorithm> // 引入 min 函数,用来计算两个数中的较小值
using namespace std; // 使用标准命名空间,避免每次写 std::

class MinStack // 定义最小栈类
{ // MinStack 类开始
private: // private 表示下面成员只能在类内部访问
    stack<int> dataStack; // dataStack 保存真实压入的所有数据
    stack<int> minStack; // minStack 保存每一层对应的当前最小值

public: // public 表示下面函数可以被外部调用
    MinStack() // 定义构造函数
    { // 构造函数开始
    } // 构造函数结束

    void push(int val) // 定义 push 函数,用来压入一个元素
    { // push 函数开始
        dataStack.push(val); // 真实数据栈直接压入 val
        if (minStack.empty()) // 如果最小栈当前为空
        { // if 语句开始
            minStack.push(val); // 第一个元素就是当前最小值
        } // if 语句结束
        else // 否则说明最小栈里已经有当前最小值
        { // else 语句开始
            int currentMin = minStack.top(); // 取出压入 val 之前的最小值
            minStack.push(min(val, currentMin)); // 把 val 和旧最小值中更小的那个压入最小栈
        } // else 语句结束
    } // push 函数结束

    void pop() // 定义 pop 函数,用来弹出栈顶元素
    { // pop 函数开始
        dataStack.pop(); // 弹出真实数据栈的栈顶
        minStack.pop(); // 同时弹出最小栈的栈顶,让最小值状态回到上一层
    } // pop 函数结束

    int top() // 定义 top 函数,用来查看当前栈顶元素
    { // top 函数开始
        return dataStack.top(); // 返回真实数据栈的栈顶
    } // top 函数结束

    int getMin() // 定义 getMin 函数,用来获取当前最小值
    { // getMin 函数开始
        return minStack.top(); // 最小栈的栈顶永远保存当前最小值
    } // getMin 函数结束
}; // MinStack 类结束

执行过程

依次执行:

c
push(5)
push(2)
push(-3)
push(0)

状态如下:

操作dataStack 栈顶minStack 栈顶当前最小值
push(5)555
push(2)222
push(-3)-3-3-3
push(0)0-3-3

执行:

c
getMin()

直接返回:

c
-3

执行:

c
pop()

弹出 0,两个栈都弹一层,当前最小值还是 -3

再执行:

c
pop()

弹出 -3,两个栈都弹一层,当前最小值回到 2

为什么不能只保存一个 min 变量

如果只用一个变量:

c
minValue = 当前最小值

当最小值被弹出时,你不知道“上一个最小值”是谁。

比如:

c
push(5)
push(2)
push(-3)

当前最小值是 -3

如果 pop() 弹出了 -3,新的最小值应该变回 2

但只靠一个 minValue,你已经忘了之前的最小值历史。

所以要用辅助栈保存每一层的最小值状态。

复杂度

pushO(1)

popO(1)

topO(1)

getMinO(1)

空间复杂度是 O(n),因为除了数据栈,还需要一个辅助最小栈。

面试高分回答

NOTE

最小栈通常用两个栈实现。一个普通栈保存真实数据,另一个辅助栈保存每一层对应的当前最小值。每次 push 时,真实栈压入原值,辅助栈压入 val 和之前最小值中的较小者;每次 pop 时两个栈同时弹出。这样辅助栈的栈顶始终是当前栈中的最小值,所以 getMin() 可以做到 O(1)。这种做法的时间复杂度都是 O(1),空间复杂度是 O(n)

二分查找

一句话理解

二分查找就是:在有序数组里,每次看中间元素 mid,如果目标更大就去右半边找,如果目标更小就去左半边找,每次都能排除一半。

binary-search-explained

前提条件

二分查找必须用于“有序”数据。

比如:

c
[1, 3, 5, 7, 9, 11, 13]

如果数组是乱的:

c
[7, 1, 13, 3, 9]

就不能直接二分,因为你无法通过 mid 判断目标应该在左边还是右边。

核心过程

假设要找:

c
target = 9

数组是:

[1, 3, 5, 7, 9, 11, 13]

一开始:

c
left = 0
right = 6
mid = 3
nums[mid] = 7

因为:

c
7 < 9

说明目标如果存在,一定在右边,所以:

c
left = mid + 1

下一轮继续在右半边找。

C++ 标准写法:闭区间

闭区间的意思是:当前查找范围包含 left,也包含 right

所以循环条件写:

c
left <= right
#include <vector> // 引入 vector 容器,用来保存数组
using namespace std; // 使用标准命名空间,避免每次写 std::

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    int search(vector<int>& nums, int target) // 定义二分查找函数,找到返回下标,找不到返回 -1
    { // search 函数开始
        int left = 0; // left 表示当前查找范围的左边界
        int right = nums.size() - 1; // right 表示当前查找范围的右边界
        while (left <= right) // 闭区间 [left, right] 还有元素时继续查找
        { // while 循环开始
            int mid = left + (right - left) / 2; // 计算中间下标,避免 left + right 可能溢出
            if (nums[mid] == target) // 如果中间元素正好等于目标值
            { // if 语句开始
                return mid; // 直接返回目标值所在下标
            } // if 语句结束
            else if (nums[mid] < target) // 如果中间元素小于目标值
            { // else if 语句开始
                left = mid + 1; // 目标只可能在右半边,所以左边界移动到 mid 右边
            } // else if 语句结束
            else // 否则说明中间元素大于目标值
            { // else 语句开始
                right = mid - 1; // 目标只可能在左半边,所以右边界移动到 mid 左边
            } // else 语句结束
        } // while 循环结束
        return -1; // 循环结束还没找到,说明目标不存在
    } // search 函数结束
}; // Solution 类结束

为什么 mid 这样写

推荐写:

c
mid = left + (right - left) / 2

而不是:

c
mid = (left + right) / 2

因为如果 leftright 都很大,left + right 可能整数溢出。

虽然很多面试小数组不会真的溢出,但这么写更专业。

复杂度

时间复杂度是 O(log n)

因为每次都把查找范围砍掉一半。

比如 1024 个数:

c
1024 -> 512 -> 256 -> 128 -> ... -> 1

大约只要查 10 次。

空间复杂度是 O(1)

因为只用了 leftrightmid 几个变量。

常见坑

第一个坑:数组没排序就用二分。

第二个坑:闭区间写法中,循环条件应该是:

c
left <= right

第三个坑:更新边界时要跳过 mid

c
left = mid + 1
right = mid - 1

因为 mid 已经比较过了。

第四个坑:如果要找“第一个等于 target 的位置”或“最后一个等于 target 的位置”,普通二分不够,还要改边界逻辑。

面试高分回答

WARNING

二分查找适用于有序数组。维护左右边界 leftright,每次计算中间位置 mid。如果 nums[mid] == target,直接返回;如果 nums[mid] < target,说明目标只可能在右半边,于是令 left = mid + 1;如果 nums[mid] > target,说明目标只可能在左半边,于是令 right = mid - 1。每次都能排除一半数据,所以时间复杂度是 O(log n),空间复杂度是 O(1)

搜索旋转排序数组

一句话理解

搜索旋转排序数组,就是在“被旋转过的有序数组”里找 target。它还是用二分查找,但每轮要先判断哪一半是有序的。

search-rotated-sorted-array

题目意思

原本有序数组:

v
[0, 1, 2, 4, 5, 6, 7]

旋转后可能变成:

c
[4, 5, 6, 7, 0, 1, 2]

现在给你这个旋转数组和一个目标值:

c
target = 0

你要返回它的下标。

这里 0 的下标是 4,所以返回:

c
4

如果找不到,就返回:

c
-1

为什么不能直接普通二分

普通二分要求数组整体有序。

但旋转数组不是整体有序:

c
[4, 5, 6, 7, 0, 1, 2]

中间有一个断点:

c
7 -> 0

所以不能简单地说:

c
nums[mid] < target 就一定去右边
nums[mid] > target 就一定去左边

但是它有一个关键性质:

每次二分时,左半边和右半边至少有一边是有序的。

这就是解题关键。

核心判断

每轮都有:

c
left
mid
right

先看:

c
nums[left] <= nums[mid]

如果成立,说明左半边有序。

否则,说明右半边有序。

然后判断 target 是否在有序的那一半里。

如果在,就保留那一半。

如果不在,就去另一半。

例子走一遍

数组:

c
[4, 5, 6, 7, 0, 1, 2]

目标:

target = 0

一开始:c#

c
left = 0
right = 6
mid = 3
nums[mid] = 7

判断左半边:

c
nums[left] = 4
nums[mid] = 7
4 <= 7

所以左半边 [4, 5, 6, 7] 是有序的。

target = 0 不在 [4, 7] 之间。

所以目标不可能在左半边,丢掉左半边:

c
left = mid + 1

下一轮就在右半边 [0, 1, 2] 里继续二分。

C++ 标准写法

下面这个版本对应 LeetCode 33:数组里没有重复元素。

c
#include <vector> // 引入 vector 容器,用来接收数组
using namespace std; // 使用标准命名空间,避免每次写 std::

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    int search(vector<int>& nums, int target) // 定义搜索函数,找到 target 返回下标,找不到返回 -1
    { // search 函数开始
        int left = 0; // left 表示当前搜索区间的左边界
        int right = nums.size() - 1; // right 表示当前搜索区间的右边界
        while (left <= right) // 闭区间 [left, right] 还有元素时继续搜索
        { // while 循环开始
            int mid = left + (right - left) / 2; // 计算中间下标,避免 left + right 整数溢出
            if (nums[mid] == target) // 如果中间值正好等于目标值
            { // if 语句开始
                return mid; // 直接返回目标值下标
            } // if 语句结束
            if (nums[left] <= nums[mid]) // 如果左半边从 left 到 mid 是有序的
            { // if 语句开始
                if (nums[left] <= target && target < nums[mid]) // 如果 target 落在左半边有序范围内
                { // if 语句开始
                    right = mid - 1; // 保留左半边,丢掉 mid 和右半边
                } // if 语句结束
                else // 否则 target 不在左半边
                { // else 语句开始
                    left = mid + 1; // 丢掉左半边和 mid,去右半边搜索
                } // else 语句结束
            } // if 语句结束
            else // 否则说明右半边从 mid 到 right 是有序的
            { // else 语句开始
                if (nums[mid] < target && target <= nums[right]) // 如果 target 落在右半边有序范围内
                { // if 语句开始
                    left = mid + 1; // 保留右半边,丢掉左半边和 mid
                } // if 语句结束
                else // 否则 target 不在右半边
                { // else 语句开始
                    right = mid - 1; // 丢掉右半边和 mid,去左半边搜索
                } // else 语句结束
            } // else 语句结束
        } // while 循环结束
        return -1; // 搜索区间为空还没找到,说明 target 不存在
    } // search 函数结束
}; // Solution 类结束

为什么边界条件是这样

在左半边有序时:

c
nums[left] <= target && target < nums[mid]

这里右边写 < nums[mid],不是 <= nums[mid],因为前面已经判断过:

c
nums[mid] == target

如果等于早就返回了。

所以这里不需要再包含 mid

右半边同理:

c
nums[mid] < target && target <= nums[right]

mid 已经排除,只判断 mid 右边到 right

复杂度

时间复杂度是 O(log n)

因为每轮仍然丢掉一半元素。

空间复杂度是 O(1)

因为只用了 leftrightmid 几个变量。

常见坑

第一个坑:忘记先判断哪一半有序。

旋转数组不能直接套普通二分。

第二个坑:边界写乱。

建议记住闭区间写法:

c
while (left <= right)
left = mid + 1
right = mid - 1

第三个坑:重复元素版本会更复杂。

如果数组里有重复元素,比如:

c
[1, 0, 1, 1, 1]

有时 nums[left] == nums[mid],你无法判断哪边有序,这时通常要移动 leftright 去跳过重复,最坏可能退化到 O(n)

面试高分回答

NOTE

搜索旋转排序数组可以用二分查找。虽然数组整体不是有序的,但每次根据 leftmidright 切分后,至少有一半是有序的。先判断 nums[left] <= nums[mid],如果成立说明左半边有序,再判断 target 是否落在 [nums[left], nums[mid]) 中;如果落在里面就搜索左半边,否则搜索右半边。反之说明右半边有序,再判断 target 是否落在 (nums[mid], nums[right]] 中。每轮都能排除一半元素,所以无重复元素时复杂度是 O(log n)

快速排序

一句话理解

快速排序就是:选一个基准值 pivot,把比它小的放左边,比它大的放右边,然后递归排序左右两边。

quicksort-explained

核心思想

快速排序分三步:

选一个基准值 pivot

partition 分区,让小于等于 pivot 的元素去左边,大于等于 pivot 的元素去右边。

递归排序 pivot 左边和右边。

比如:

c
[6, 3, 8, 5, 2, 7, 4, 1]

1pivot

分区后,1 会被放到它最终应该在的位置。

然后继续对 1 左边、右边做同样的事。

partition 是什么

partition 是快排最关键的步骤。

它的目标不是一次性把整个数组排好,而是先把 pivot 放对位置。

分区完成后一定满足:

c
pivot 左边 <= pivot
pivot 右边 >= pivot

所以 pivot 自己已经排好了,后面不需要再动它。

C++ 标准写法

下面用的是比较好理解的 Lomuto 分区法:选最后一个元素作为 pivot

c
#include <vector> // 引入 vector 容器,用来保存数组
#include <algorithm> // 引入 swap 函数,用来交换两个元素
using namespace std; // 使用标准命名空间,避免每次写 std::

class Solution // 定义一个排序类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    void quickSort(vector<int>& nums) // 定义快速排序入口函数
    { // quickSort 入口函数开始
        if (nums.empty()) // 如果数组为空
        { // if 语句开始
            return; // 空数组不需要排序,直接返回
        } // if 语句结束
        quickSortRange(nums, 0, nums.size() - 1); // 对整个数组区间执行快速排序
    } // quickSort 入口函数结束

private: // private 表示下面函数只在类内部使用
    void quickSortRange(vector<int>& nums, int left, int right) // 定义对指定区间排序的递归函数
    { // quickSortRange 函数开始
        if (left >= right) // 如果区间里没有元素,或者只有一个元素
        { // if 语句开始
            return; // 这种区间天然有序,直接返回
        } // if 语句结束
        int pivotIndex = partition(nums, left, right); // 执行分区,并拿到 pivot 最终位置
        quickSortRange(nums, left, pivotIndex - 1); // 递归排序 pivot 左边的区间
        quickSortRange(nums, pivotIndex + 1, right); // 递归排序 pivot 右边的区间
    } // quickSortRange 函数结束

    int partition(vector<int>& nums, int left, int right) // 定义分区函数,把 pivot 放到正确位置
    { // partition 函数开始
        int pivot = nums[right]; // 选择最右边的元素作为 pivot
        int smaller = left - 1; // smaller 表示“小于等于 pivot 区域”的最后一个位置
        for (int current = left; current < right; current++) // 从 left 遍历到 right - 1,不包含 pivot 本身
        { // for 循环开始
            if (nums[current] <= pivot) // 如果当前元素小于等于 pivot
            { // if 语句开始
                smaller++; // 小于等于区域扩大一格
                swap(nums[smaller], nums[current]); // 把当前元素交换到小于等于区域末尾
            } // if 语句结束
        } // for 循环结束
        swap(nums[smaller + 1], nums[right]); // 把 pivot 放到小于等于区域后面,也就是它的最终位置
        return smaller + 1; // 返回 pivot 的最终下标
    } // partition 函数结束
}; // Solution 类结束

为什么递归左右两边

分区之后,pivot 已经在正确位置。

但是左边内部不一定有序,右边内部也不一定有序。

比如:

c
左边都 <= pivot
右边都 >= pivot

这只是“大方向对了”,不是全部排好了。

所以还要对左边和右边继续快排,直到区间长度变成 01

复杂度

平均时间复杂度是:

c
O(n log n)

因为理想情况下,每次分区都能把数组分成差不多两半。

最坏时间复杂度是:

c
O(n²)

比如数组本来就有序,而你每次都选最左或最右作为 pivot,分区会非常不均匀。

空间复杂度平均是:

c
O(log n)

主要来自递归调用栈。

最坏空间复杂度可能是:

c
O(n)

因为递归太深。

快排稳定吗

普通快速排序不稳定。

稳定的意思是:相等元素排序前后的相对顺序不变。

快排在分区时会交换元素,所以相等元素的相对顺序可能改变。

如何避免最坏情况

常见优化:

随机选择 pivot

三数取中:从左、中、右三个数里选中间大小的作为 pivot

小区间改用插入排序。

递归时优先处理较小区间,减少栈深度。

工程里的排序通常会做很多混合优化,不只是最朴素快排。

面试高分回答

WARNING

快速排序是一种分治排序算法。它每次选择一个基准值 pivot,通过 partition 操作把数组分成两部分,使左边元素小于等于 pivot,右边元素大于等于 pivot,此时 pivot 已经处在最终位置。然后递归排序左右两个子区间。快排平均时间复杂度是 O(n log n),最坏情况下会退化到 O(n²),空间复杂度平均是 O(log n)。普通快排不是稳定排序。为了降低退化概率,常见做法是随机选择 pivot 或三数取中。

归并排序

一句话理解

归并排序就是:先把数组不断拆成很小的有序段,再把两个有序段合并成更大的有序段,最后整个数组就有序了。

merge-sort-explained

核心思想

归并排序分两步:

Divide:把数组从中间不断拆开。

Merge:把两个已经有序的小数组合并成一个更大的有序数组。

比如:

c
[6, 3, 8, 5, 2, 7, 4, 1]

先拆到单个元素:

c
[6] [3] [8] [5] [2] [7] [4] [1]

单个元素天然有序。

然后开始合并:

c
[6] + [3] -> [3, 6]
[8] + [5] -> [5, 8]
[2] + [7] -> [2, 7]
[4] + [1] -> [1, 4]

再继续合并,直到变成完整有序数组。

Merge 是关键

假设有两个有序数组:

c
left  = [3, 5, 6, 8]
right = [1, 2, 4, 7]

合并时准备两个指针:

i 指向 left 当前元素。

j 指向 right 当前元素。

每次比较:

c
left[i] 和 right[j]

谁小就放进临时数组,然后对应指针后移。

最后得到:

c
[1, 2, 3, 4, 5, 6, 7, 8]

C++ 标准写法

c
#include <vector> // 引入 vector 容器,用来保存数组和临时数组
using namespace std; // 使用标准命名空间,避免每次写 std::

class Solution // 定义排序类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    void mergeSort(vector<int>& nums) // 定义归并排序入口函数
    { // mergeSort 函数开始
        if (nums.empty()) // 如果数组为空
        { // if 语句开始
            return; // 空数组不需要排序,直接返回
        } // if 语句结束
        vector<int> temp(nums.size()); // 创建临时数组,用来辅助合并
        sortRange(nums, temp, 0, nums.size() - 1); // 对整个数组范围执行归并排序
    } // mergeSort 函数结束

private: // private 表示下面函数只在类内部使用
    void sortRange(vector<int>& nums, vector<int>& temp, int left, int right) // 定义递归排序区间的函数
    { // sortRange 函数开始
        if (left >= right) // 如果区间里只有一个元素或者没有元素
        { // if 语句开始
            return; // 单个元素天然有序,直接返回
        } // if 语句结束
        int mid = left + (right - left) / 2; // 计算中间位置,避免 left + right 溢出
        sortRange(nums, temp, left, mid); // 递归排序左半边
        sortRange(nums, temp, mid + 1, right); // 递归排序右半边
        merge(nums, temp, left, mid, right); // 把两个有序半边合并起来
    } // sortRange 函数结束

    void merge(vector<int>& nums, vector<int>& temp, int left, int mid, int right) // 定义合并两个有序区间的函数
    { // merge 函数开始
        int i = left; // i 指向左半边当前元素
        int j = mid + 1; // j 指向右半边当前元素
        int k = left; // k 指向临时数组当前要写入的位置
        while (i <= mid && j <= right) // 当左右两边都还有元素时继续比较
        { // while 循环开始
            if (nums[i] <= nums[j]) // 如果左边当前元素小于等于右边当前元素
            { // if 语句开始
                temp[k] = nums[i]; // 把左边当前元素放入临时数组
                i++; // 左边指针后移一位
            } // if 语句结束
            else // 否则说明右边当前元素更小
            { // else 语句开始
                temp[k] = nums[j]; // 把右边当前元素放入临时数组
                j++; // 右边指针后移一位
            } // else 语句结束
            k++; // 临时数组写入位置后移一位
        } // while 循环结束
        while (i <= mid) // 如果左半边还有剩余元素
        { // while 循环开始
            temp[k] = nums[i]; // 把左半边剩余元素复制到临时数组
            i++; // 左边指针后移一位
            k++; // 临时数组写入位置后移一位
        } // while 循环结束
        while (j <= right) // 如果右半边还有剩余元素
        { // while 循环开始
            temp[k] = nums[j]; // 把右半边剩余元素复制到临时数组
            j++; // 右边指针后移一位
            k++; // 临时数组写入位置后移一位
        } // while 循环结束
        for (int p = left; p <= right; p++) // 遍历当前合并区间
        { // for 循环开始
            nums[p] = temp[p]; // 把临时数组中的有序结果拷贝回原数组
        } // for 循环结束
    } // merge 函数结束
}; // Solution 类结束

复杂度

时间复杂度稳定是:

c
O(n log n)

因为一共拆成大约 log n 层,每一层合并时都会处理全部 n 个元素。

空间复杂度是:

c
O(n)

因为合并时需要临时数组。

归并排序稳定吗

稳定。

稳定的意思是:如果两个元素相等,排序前后它们的相对顺序不变。

代码里用了:

c
nums[i] <= nums[j]

相等时优先取左边元素,这样可以保持稳定性。

和快速排序区别

快速排序平均很快,通常原地排序,额外空间少,但最坏可能退化到 O(n²),而且普通快排不稳定。

归并排序时间稳定,总是 O(n log n),而且稳定,但需要额外 O(n) 空间。

链表排序里经常用归并排序,因为链表合并很方便,不需要像数组那样频繁随机访问。

面试高分回答

NOTE

归并排序是一种分治排序算法。它先把数组递归拆分成左右两半,直到每个区间只有一个元素,然后再把两个有序区间合并成一个更大的有序区间。合并时使用两个指针分别指向左右区间当前元素,每次取较小者放入临时数组,最后再拷贝回原数组。归并排序时间复杂度稳定为 O(n log n),空间复杂度是 O(n),并且它是稳定排序,适合对稳定性有要求的场景,也常用于链表排序和外部排序。

堆排序

一句话理解

堆排序就是:先把数组变成大根堆,让最大值在堆顶;然后每次把堆顶最大值换到数组末尾,再调整剩下的部分继续保持大根堆。

heap-sort-explained

先理解堆排序用什么堆

如果想把数组升序排序:

从小到大

通常用大根堆。

因为大根堆的堆顶永远是当前最大值。

每一轮把最大值放到数组最后,最后面的有序区就会越来越大。

堆排序流程

第一步:建大根堆。

比如数组:

c
[6, 3, 8, 5, 2, 7, 4, 1]

建成大根堆后,最大值会到 nums[0]

第二步:交换堆顶和末尾。

c
swap(nums[0], nums[end])

这样最大值就被放到了最后。

第三步:缩小堆范围。

最后一个元素已经排好了,不再参与堆调整。

第四步:对堆顶做 heapify

因为刚换上来的堆顶可能破坏大根堆规则,所以要让它往下沉,直到重新满足父节点大于等于孩子。

重复这个过程,数组就排好了。

为什么 heapify 是下沉

假设堆顶被换成了一个很小的数。

它可能比孩子小,违反大根堆规则。

那就让它和较大的孩子交换。

交换后继续往下看,直到它比孩子都大,或者已经到叶子节点。

这个过程叫:

c
下沉

也叫:

c
sift down
heapify

C++ 标准写法

c
#include <vector> // 引入 vector 容器,用来保存数组
#include <algorithm> // 引入 swap 函数,用来交换数组元素
using namespace std; // 使用标准命名空间,避免每次写 std::

class Solution // 定义排序类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    void heapSort(vector<int>& nums) // 定义堆排序入口函数
    { // heapSort 函数开始
        int n = nums.size(); // 保存数组长度
        for (int i = n / 2 - 1; i >= 0; i--) // 从最后一个非叶子节点开始向前建堆
        { // for 循环开始
            heapify(nums, n, i); // 对当前位置执行下沉调整,让子树满足大根堆
        } // for 循环结束
        for (int end = n - 1; end > 0; end--) // 从数组末尾开始,把最大值一个个放到最终位置
        { // for 循环开始
            swap(nums[0], nums[end]); // 把堆顶最大值交换到当前堆的最后位置
            heapify(nums, end, 0); // 缩小堆范围后,从堆顶重新下沉调整
        } // for 循环结束
    } // heapSort 函数结束

private: // private 表示下面函数只在类内部使用
    void heapify(vector<int>& nums, int heapSize, int root) // 定义下沉调整函数,heapSize 表示当前堆大小
    { // heapify 函数开始
        int largest = root; // 假设当前 root 是父子三者中最大的
        int left = root * 2 + 1; // 根据数组下标计算左孩子位置
        int right = root * 2 + 2; // 根据数组下标计算右孩子位置
        if (left < heapSize && nums[left] > nums[largest]) // 如果左孩子存在,并且左孩子比当前最大值更大
        { // if 语句开始
            largest = left; // 把最大值位置更新为左孩子
        } // if 语句结束
        if (right < heapSize && nums[right] > nums[largest]) // 如果右孩子存在,并且右孩子比当前最大值更大
        { // if 语句开始
            largest = right; // 把最大值位置更新为右孩子
        } // if 语句结束
        if (largest != root) // 如果最大值不是 root,说明 root 违反了大根堆规则
        { // if 语句开始
            swap(nums[root], nums[largest]); // 把 root 和更大的孩子交换
            heapify(nums, heapSize, largest); // 交换后继续向下调整被换下去的节点
        } // if 语句结束
    } // heapify 函数结束
}; // Solution 类结束

为什么建堆从 n / 2 - 1 开始

数组表示堆时:

c
左孩子 = 2 * i + 1
右孩子 = 2 * i + 2

叶子节点没有孩子,不需要 heapify

最后一个非叶子节点的位置是:

c
n / 2 - 1

所以建堆从这里开始,往前调整到根节点。

复杂度

建堆复杂度是:

c
O(n)

排序阶段有 n - 1 轮,每轮 heapify 是:

c
O(log n)

所以总时间复杂度是:

c
O(n log n)

空间复杂度是:

c
O(1)

因为堆排序是在原数组上交换,不需要额外开一个大数组。

堆排序稳定吗

普通堆排序不稳定。

因为它会交换堆顶和末尾元素,可能改变相等元素的相对顺序。

比如两个相同分数的玩家,排序前 A 在 B 前面,堆排序后它们的相对顺序可能变。

和快排、归并排序对比

堆排序:时间稳定 O(n log n),空间 O(1),不稳定,缓存局部性一般。

快速排序:平均 O(n log n),最坏 O(n²),通常实际很快,不稳定。

归并排序:稳定 O(n log n),但需要 O(n) 额外空间。

面试高分回答

NOTE

堆排序通常用大根堆实现升序排序。先从最后一个非叶子节点开始向前 heapify,把数组建成大根堆,此时堆顶就是最大值。然后不断把堆顶和当前堆的最后一个元素交换,使最大值进入最终位置,再缩小堆的范围,并从堆顶重新 heapify,恢复大根堆性质。重复这个过程直到堆大小为 1。堆排序时间复杂度是 O(n log n),空间复杂度是 O(1),属于原地排序,但普通堆排序不是稳定排序。

Top K 问题

一句话理解

Top K 问题就是:从一堆数据里找出“最大的 K 个”“最小的 K 个”或“频率最高的 K 个”。它不一定需要把所有数据完整排序。

top-k-problem

常见题型

比如:

c
nums = [7, 1, 9, 3, 10, 5, 8]
k = 3

找最大的 3 个数:

c
[10, 9, 8]

注意,很多题只要求找出 Top K,不一定要求这 K 个数内部有序。

方法一:直接排序

最简单的做法:

先排序,再取前 K 个

如果是找最大 K 个,就降序排序后取前 K 个。

优点:简单,代码不容易错。

缺点:做了多余工作,因为我们其实只关心前 K 个,不一定需要整个数组都排好。

复杂度:

c
O(n log n)

方法二:大小为 K 的小根堆

如果要找最大的 K 个数,最常用的是维护一个大小为 K 的小根堆。

为什么是小根堆?

因为堆里保存“当前最大的 K 个数”,而这 K 个数里最小的那个在堆顶。

每来一个新数 x

如果堆大小还不到 K,直接放进去。

如果堆已经有 K 个元素,并且 x > 堆顶,说明 x 比当前 Top K 里面最小的数更强,应该替换掉堆顶。

如果 x <= 堆顶,说明它进不了 Top K,直接忽略。

复杂度:

c
O(n log k)

kn 小很多时,这个方法非常好。

C++ 小根堆写法:找最大 K 个

c
#include <queue> // 引入 priority_queue,用来实现堆
#include <vector> // 引入 vector,用来保存数组和答案
#include <functional> // 引入 greater,用来创建小根堆
using namespace std; // 使用标准命名空间,避免每次写 std::

class Solution // 定义 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    vector<int> topKLargest(vector<int>& nums, int k) // 定义函数,用来找最大的 k 个数
    { // topKLargest 函数开始
        priority_queue<int, vector<int>, greater<int>> heap; // 创建小根堆,堆顶是当前 Top K 中最小的数
        for (int num : nums) // 遍历数组中的每一个数字
        { // for 循环开始
            if (heap.size() < k) // 如果堆里还不足 k 个数
            { // if 语句开始
                heap.push(num); // 直接把当前数字放入堆
            } // if 语句结束
            else if (num > heap.top()) // 如果当前数字比堆顶更大
            { // else if 语句开始
                heap.pop(); // 弹出当前 Top K 中最小的那个数
                heap.push(num); // 把更大的当前数字放入堆
            } // else if 语句结束
        } // for 循环结束
        vector<int> result; // 创建结果数组,用来保存最大的 k 个数
        while (!heap.empty()) // 只要堆不为空,就继续取出元素
        { // while 循环开始
            result.push_back(heap.top()); // 把堆顶元素加入结果数组
            heap.pop(); // 弹出堆顶元素
        } // while 循环结束
        return result; // 返回结果数组,注意这里结果不一定是降序
    } // topKLargest 函数结束
}; // Solution 类结束

方法三:快速选择 QuickSelect

快速选择和快速排序很像,也用 partition

快排是:

左右两边都递归排序

快速选择是:

c
只去目标所在的一边继续找

比如找第 K 大。

每次 partition 后,某个位置已经确定。

如果这个位置正好是第 K 大的位置,就结束。

如果目标在左边,只处理左边。

如果目标在右边,只处理右边。

平均复杂度:

c
O(n)

最坏复杂度:

c
O(n²)

但随机选 pivot 后,平均表现很好。

三种方法怎么选

方法时间复杂度空间复杂度适合场景
排序O(n log n)看排序实现数据不大,想快速写对
K 大小堆O(n log k)O(k)数据很大,K 比较小,流式数据
快速选择平均 O(n)O(1) 左右只要找到 Top K 分界,不要求内部有序

面试高分回答

TIP

Top K 问题不能一上来只说排序。最简单可以排序后取前 K 个,复杂度是 O(n log n)。如果数据量大且 K 比较小,常用大小为 K 的堆,例如找最大 K 个就维护小根堆,堆里始终保存当前最大的 K 个元素,复杂度是 O(n log k),也适合流式数据。如果只需要找到 Top K 的集合或第 K 大元素,可以用快速选择,利用 partition 每次只递归目标所在的一边,平均复杂度是 O(n)。所以面试里要根据是否要求有序、数据规模、K 的大小、是否流式输入来选择方案。

二叉树前中后序遍历

一句话理解

二叉树的前序、中序、后序,区别就是“根节点什么时候访问”。

binary-tree-traversals

先记口诀

前序遍历:

根 -> 左 -> 右

中序遍历:

左 -> 根 -> 右

后序遍历:

左 -> 右 -> 根

注意:左子树永远在右子树前面。前、中、后说的是“根节点”的位置。

举个例子

假设二叉树是:

        A
       / \
      B   C
     / \ / \
    D  E F  G

前序遍历:

c
A B D E C F G

因为先访问根 A,再访问左子树 B D E,再访问右子树 C F G

中序遍历:

c
D B E A F C G

因为先访问左子树,再访问根 A,最后访问右子树。

后序遍历:

c
D E B F G C A

因为先访问左子树,再访问右子树,最后访问根 A

递归代码

c
#include <iostream> // 引入输入输出库,用来打印遍历结果
using namespace std; // 使用标准命名空间,避免每次写 std::

struct TreeNode // 定义二叉树节点结构体
{ // TreeNode 结构体开始
    char val; // 保存当前节点的值
    TreeNode* left; // 保存左孩子节点的地址
    TreeNode* right; // 保存右孩子节点的地址
    TreeNode(char x) : val(x), left(nullptr), right(nullptr) // 构造函数,用 x 初始化节点值,并让左右孩子默认为空
    { // 构造函数开始
    } // 构造函数结束
}; // TreeNode 结构体结束

void preorder(TreeNode* root) // 定义前序遍历函数,顺序是根、左、右
{ // preorder 函数开始
    if (root == nullptr) // 如果当前节点为空
    { // if 语句开始
        return; // 空节点不需要处理,直接返回
    } // if 语句结束
    cout << root->val << " "; // 先访问根节点
    preorder(root->left); // 再递归遍历左子树
    preorder(root->right); // 最后递归遍历右子树
} // preorder 函数结束

void inorder(TreeNode* root) // 定义中序遍历函数,顺序是左、根、右
{ // inorder 函数开始
    if (root == nullptr) // 如果当前节点为空
    { // if 语句开始
        return; // 空节点不需要处理,直接返回
    } // if 语句结束
    inorder(root->left); // 先递归遍历左子树
    cout << root->val << " "; // 再访问根节点
    inorder(root->right); // 最后递归遍历右子树
} // inorder 函数结束

void postorder(TreeNode* root) // 定义后序遍历函数,顺序是左、右、根
{ // postorder 函数开始
    if (root == nullptr) // 如果当前节点为空
    { // if 语句开始
        return; // 空节点不需要处理,直接返回
    } // if 语句结束
    postorder(root->left); // 先递归遍历左子树
    postorder(root->right); // 再递归遍历右子树
    cout << root->val << " "; // 最后访问根节点
} // postorder 函数结束

它们分别适合什么场景

前序遍历常用于“先处理自己,再处理孩子”的场景。

比如:复制一棵树、序列化树、打印目录结构。

中序遍历常用于二叉搜索树。

因为二叉搜索树满足左小右大,所以中序遍历会得到升序结果。

后序遍历常用于“先处理孩子,再处理自己”的场景。

比如:释放整棵树、删除目录、计算文件夹大小、表达式树求值。

复杂度

时间复杂度都是:

c
O(n)

因为每个节点都会访问一次。

空间复杂度是:

c
O(h)

其中 h 是树的高度,主要来自递归调用栈。

如果树很平衡,h 大约是 log n

如果树退化成链表,h 可能是 n

面试高分回答

NOTE

前序、中序、后序遍历都是深度优先遍历。它们的区别在于访问根节点的时机:前序是根左右,中序是左根右,后序是左右根。对于同一棵树,左子树总是在右子树之前被处理。前序常用于复制或序列化树,中序遍历二叉搜索树可以得到有序序列,后序适合先处理子节点再处理父节点的场景,比如释放树或计算子树信息。三种遍历时间复杂度都是 O(n)

二叉树层序遍历

一句话理解

二叉树层序遍历就是:从上到下、从左到右,一层一层访问节点。它本质是 BFS,通常用队列 queue 实现。

binary-tree-level-order

例子

假设二叉树是:

        A
       / \
      B   C
     / \ / \
    D  E F  G

层序遍历结果是:

c
A B C D E F G

如果题目要求“按层返回”,结果就是:

c
[[A], [B, C], [D, E, F, G]]

为什么用队列

队列是先进先出。

先把根节点放进队列。

每次从队头取出一个节点,访问它。

然后把它的左孩子、右孩子放到队尾。

这样就能保证:上一层的节点一定比下一层的节点先被处理。

C++ 代码:按层返回

c
#include <queue> // 引入 queue 容器,用来做广度优先遍历
#include <vector> // 引入 vector 容器,用来保存每一层的结果
using namespace std; // 使用标准命名空间,避免每次写 std::

struct TreeNode // 定义二叉树节点结构体
{ // TreeNode 结构体开始
    int val; // 保存当前节点的值
    TreeNode* left; // 保存左孩子节点地址
    TreeNode* right; // 保存右孩子节点地址
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) // 构造函数,用 x 初始化节点值,并让左右孩子默认为空
    { // 构造函数开始
    } // 构造函数结束
}; // TreeNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    vector<vector<int>> levelOrder(TreeNode* root) // 定义层序遍历函数,按层返回节点值
    { // levelOrder 函数开始
        vector<vector<int>> result; // 创建结果数组,用来保存每一层的节点值
        if (root == nullptr) // 如果根节点为空
        { // if 语句开始
            return result; // 空树没有节点,直接返回空结果
        } // if 语句结束
        queue<TreeNode*> q; // 创建队列,用来保存等待访问的节点
        q.push(root); // 先把根节点放入队列
        while (!q.empty()) // 只要队列不为空,就继续遍历
        { // while 循环开始
            int levelSize = q.size(); // 记录当前层有多少个节点
            vector<int> currentLevel; // 创建数组,用来保存当前层的节点值
            for (int i = 0; i < levelSize; i++) // 只处理当前层的这些节点
            { // for 循环开始
                TreeNode* node = q.front(); // 取出队头节点
                q.pop(); // 把队头节点从队列中弹出
                currentLevel.push_back(node->val); // 把当前节点的值加入当前层结果
                if (node->left != nullptr) // 如果当前节点有左孩子
                { // if 语句开始
                    q.push(node->left); // 把左孩子放入队列,等待下一层处理
                } // if 语句结束
                if (node->right != nullptr) // 如果当前节点有右孩子
                { // if 语句开始
                    q.push(node->right); // 把右孩子放入队列,等待下一层处理
                } // if 语句结束
            } // for 循环结束
            result.push_back(currentLevel); // 当前层处理完后,把这一层结果加入总结果
        } // while 循环结束
        return result; // 返回所有层的遍历结果
    } // levelOrder 函数结束
}; // Solution 类结束

为什么要记录 levelSize

如果只是普通遍历,可以一直弹队列。

但如果题目要求:

每一层单独放一个数组

那就必须知道“当前层有几个节点”。

所以每一轮 while 开始时记录:

c
levelSize = q.size()

然后只处理这 levelSize 个节点。

处理这些节点时,新加入队列的孩子节点属于下一层,不能混到当前层里。

复杂度

时间复杂度是 O(n)

因为每个节点都会进队一次、出队一次。

空间复杂度是 O(w)

w 是树的最大宽度。最坏情况下,队列里可能放下某一整层的节点,所以也可以说最坏是 O(n)

面试高分回答

NOTE

二叉树层序遍历本质是广度优先搜索 BFS,通常用队列实现。先把根节点入队,然后循环取出队头节点并访问它,再把它的左孩子和右孩子依次入队。由于队列先进先出,所以可以保证节点按从上到下、从左到右的顺序被访问。如果题目要求按层返回,就在每轮循环开始时记录当前队列大小 levelSize,只处理这 levelSize 个节点,它们正好属于当前层。时间复杂度是 O(n),空间复杂度最坏是 O(n)

二叉树最大深度

一句话理解

二叉树最大深度,就是从根节点走到最远叶子节点,最多经过多少层节点。

binary-tree-max-depth

先看定义

如果树是空的:

深度 = 0

如果只有一个根节点:

深度 = 1

如果最长路径是:

A -> B -> D -> H

一共经过 4 个节点,那么最大深度就是:

4

递归思路

对任意一个节点来说,它的最大深度等于:

左子树最大深度 和 右子树最大深度 的较大值 + 1

公式就是:

c
maxDepth(root) = max(maxDepth(root->left), maxDepth(root->right)) + 1

为什么要 +1

因为左子树和右子树算的是孩子下面的深度,而当前 root 自己也占一层。

C++ 递归写法

c
#include <algorithm> // 引入 max 函数,用来比较左右子树深度
using namespace std; // 使用标准命名空间,避免每次写 std::

struct TreeNode // 定义二叉树节点结构体
{ // TreeNode 结构体开始
    int val; // 保存当前节点的值
    TreeNode* left; // 保存左孩子节点地址
    TreeNode* right; // 保存右孩子节点地址
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) // 构造函数,用 x 初始化节点值,并让左右孩子默认为空
    { // 构造函数开始
    } // 构造函数结束
}; // TreeNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    int maxDepth(TreeNode* root) // 定义求二叉树最大深度的函数
    { // maxDepth 函数开始
        if (root == nullptr) // 如果当前节点为空
        { // if 语句开始
            return 0; // 空树深度是 0
        } // if 语句结束
        int leftDepth = maxDepth(root->left); // 递归求左子树最大深度
        int rightDepth = maxDepth(root->right); // 递归求右子树最大深度
        return max(leftDepth, rightDepth) + 1; // 当前树深度等于左右较大深度加上当前节点这一层
    } // maxDepth 函数结束
}; // Solution 类结束

为什么递归这么自然

因为二叉树本身就是递归结构:

一棵树 = 根节点 + 左子树 + 右子树

求整棵树的最大深度,就要先知道左子树和右子树的最大深度。

然后取更深的一边,再加上当前根节点这一层。

层序遍历写法

也可以用队列一层一层遍历。

每遍历完一层,深度 +1

c
#include <queue> // 引入 queue 容器,用来做层序遍历
using namespace std; // 使用标准命名空间,避免每次写 std::

struct TreeNode // 定义二叉树节点结构体
{ // TreeNode 结构体开始
    int val; // 保存当前节点的值
    TreeNode* left; // 保存左孩子节点地址
    TreeNode* right; // 保存右孩子节点地址
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) // 构造函数,用 x 初始化节点值,并让左右孩子默认为空
    { // 构造函数开始
    } // 构造函数结束
}; // TreeNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    int maxDepth(TreeNode* root) // 定义求二叉树最大深度的函数
    { // maxDepth 函数开始
        if (root == nullptr) // 如果根节点为空
        { // if 语句开始
            return 0; // 空树深度是 0
        } // if 语句结束
        queue<TreeNode*> q; // 创建队列,用来保存等待遍历的节点
        q.push(root); // 先把根节点放入队列
        int depth = 0; // depth 用来记录已经遍历了多少层
        while (!q.empty()) // 只要队列不为空,就说明还有层没有遍历
        { // while 循环开始
            int levelSize = q.size(); // 记录当前层有多少个节点
            for (int i = 0; i < levelSize; i++) // 只处理当前层的节点
            { // for 循环开始
                TreeNode* node = q.front(); // 取出队头节点
                q.pop(); // 弹出队头节点
                if (node->left != nullptr) // 如果当前节点有左孩子
                { // if 语句开始
                    q.push(node->left); // 把左孩子放入队列,等待下一层处理
                } // if 语句结束
                if (node->right != nullptr) // 如果当前节点有右孩子
                { // if 语句开始
                    q.push(node->right); // 把右孩子放入队列,等待下一层处理
                } // if 语句结束
            } // for 循环结束
            depth++; // 当前层处理完,深度加 1
        } // while 循环结束
        return depth; // 返回最终层数,也就是最大深度
    } // maxDepth 函数结束
}; // Solution 类结束

复杂度

时间复杂度是:

O(n)

因为每个节点都会被访问一次。

递归写法空间复杂度是:

O(h)

h 是树的高度,来自递归调用栈。

层序遍历空间复杂度最坏是:

O(n)

因为队列可能存下某一层很多节点。

面试高分回答

WARNING

二叉树最大深度可以用递归求解。对于空节点,深度为 0;对于非空节点,它的最大深度等于左子树最大深度和右子树最大深度的较大值,再加上当前节点这一层,也就是 max(leftDepth, rightDepth) + 1。这个方法本质是 DFS,时间复杂度是 O(n),空间复杂度是 O(h)。也可以用层序遍历 BFS,每遍历完一层就让深度加一。

最近公共祖先

一句话理解

最近公共祖先 LCA,就是两个节点共同的祖先里,离它们最近的那个节点。

lowest-common-ancestor

先理解什么是祖先

在二叉树里,一个节点往上走能经过的节点,都叫它的祖先。

比如这棵树:

        3
       / \
      5   1
     / \ / \
    6  2 0  8
      / \
     7   4

节点 6 的祖先路径是:

6 -> 5 -> 3

节点 4 的祖先路径是:

4 -> 2 -> 5 -> 3

它们的公共祖先有:

5 和 3

5 离它们更近,所以:

LCA(6, 4) = 5

注意:一个节点也可以是自己的祖先。所以如果问 LCA(5, 4),答案就是 5

普通二叉树怎么做

普通二叉树没有大小规则,不能像二叉搜索树那样比较大小。

所以常用递归。

递归函数的含义可以这样理解:

在当前 root 这棵树里,寻找 p 或 q。

如果当前节点是空:

返回 nullptr

如果当前节点就是 pq

返回当前节点

否则分别去左子树和右子树找:

left = 在左子树里找
right = 在右子树里找

然后看结果:

如果 leftright 都不为空,说明 pq 分别在当前节点两边,当前节点就是最近公共祖先。

如果只有 left 不为空,说明答案在左边,返回 left

如果只有 right 不为空,说明答案在右边,返回 right

如果都为空,说明这棵子树里没有目标,返回空。

C++ 普通二叉树写法

c
struct TreeNode // 定义二叉树节点结构体
{ // TreeNode 结构体开始
    int val; // 保存当前节点的值
    TreeNode* left; // 保存左孩子节点地址
    TreeNode* right; // 保存右孩子节点地址
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) // 构造函数,用 x 初始化节点值,并让左右孩子默认为空
    { // 构造函数开始
    } // 构造函数结束
}; // TreeNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) // 定义求最近公共祖先的函数
    { // lowestCommonAncestor 函数开始
        if (root == nullptr) // 如果当前节点为空
        { // if 语句开始
            return nullptr; // 空树里找不到 p 或 q,返回空
        } // if 语句结束
        if (root == p || root == q) // 如果当前节点本身就是 p 或 q
        { // if 语句开始
            return root; // 直接返回当前节点,因为一个节点可以是自己的祖先
        } // if 语句结束
        TreeNode* left = lowestCommonAncestor(root->left, p, q); // 去左子树寻找 p 或 q
        TreeNode* right = lowestCommonAncestor(root->right, p, q); // 去右子树寻找 p 或 q
        if (left != nullptr && right != nullptr) // 如果左右子树都找到了目标节点
        { // if 语句开始
            return root; // 说明 p 和 q 分别在两边,当前 root 就是最近公共祖先
        } // if 语句结束
        if (left != nullptr) // 如果只有左子树找到了目标或答案
        { // if 语句开始
            return left; // 把左子树找到的结果继续往上返回
        } // if 语句结束
        return right; // 否则返回右子树结果,可能是某个目标节点,也可能是空
    } // lowestCommonAncestor 函数结束
}; // Solution 类结束

为什么左右都不为空时 root 是答案

因为:

c
left != nullptr

说明 pq 在左子树里。

c
right != nullptr

说明另一个目标在右子树里。

它们一个在左,一个在右,第一次能把它们汇合起来的节点,就是当前 root

所以当前 root 就是最近公共祖先。

二叉搜索树的更快写法

如果题目明确说是二叉搜索树 BST,可以利用性质:

左子树所有值小于根。

右子树所有值大于根。

如果 pq 都比 root 小,答案在左边。

如果 pq 都比 root 大,答案在右边。

否则说明一个在左、一个在右,或者当前 root 就是其中一个节点,那么 root 就是最近公共祖先。

c
struct TreeNode // 定义二叉树节点结构体
{ // TreeNode 结构体开始
    int val; // 保存当前节点的值
    TreeNode* left; // 保存左孩子节点地址
    TreeNode* right; // 保存右孩子节点地址
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) // 构造函数,用 x 初始化节点值,并让左右孩子默认为空
    { // 构造函数开始
    } // 构造函数结束
}; // TreeNode 结构体结束

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) // 定义 BST 最近公共祖先函数
    { // lowestCommonAncestor 函数开始
        while (root != nullptr) // 只要当前节点不为空,就继续查找
        { // while 循环开始
            if (p->val < root->val && q->val < root->val) // 如果 p 和 q 都比 root 小
            { // if 语句开始
                root = root->left; // 说明最近公共祖先一定在左子树
            } // if 语句结束
            else if (p->val > root->val && q->val > root->val) // 如果 p 和 q 都比 root 大
            { // else if 语句开始
                root = root->right; // 说明最近公共祖先一定在右子树
            } // else if 语句结束
            else // 否则说明 p 和 q 分散在 root 两侧,或者 root 就是其中一个节点
            { // else 语句开始
                return root; // 当前 root 就是最近公共祖先
            } // else 语句结束
        } // while 循环结束
        return nullptr; // 如果树为空或没有找到,就返回空
    } // lowestCommonAncestor 函数结束
}; // Solution 类结束

复杂度

普通二叉树递归写法:

时间复杂度是 O(n),最坏要访问所有节点。

空间复杂度是 O(h)h 是树高,来自递归调用栈。

二叉搜索树写法:

时间复杂度是 O(h)

如果树平衡,约等于 O(log n)

如果树退化成链表,最坏是 O(n)

面试高分回答

TIP

最近公共祖先是两个节点共同祖先中距离它们最近的那个节点。普通二叉树没有大小关系,常用递归:如果当前节点为空返回空;如果当前节点等于 pq,返回当前节点;然后分别在左右子树查找。如果左右子树都返回非空,说明两个目标分别在当前节点两侧,当前节点就是最近公共祖先;如果只有一边非空,就把那边结果向上返回。这个方法时间复杂度是 O(n)。如果是二叉搜索树,可以利用左小右大的性质,根据 pq 与当前节点值的大小关系决定往左还是往右,复杂度是 O(h)

岛屿数量

一句话理解

“岛屿数量”就是在二维网格里数有多少块互不连通的陆地。遇到一块新的 1,岛屿数 +1,然后用 DFS/BFS 把和它上下左右连着的所有 1 都标记掉。

number-of-islands

题目意思

网格里:

'1' 表示陆地
'0' 表示水

如果多个陆地通过“上、下、左、右”连在一起,它们算同一座岛。

注意:斜着连不算。

比如:

1 1 0 0
1 0 0 1
0 0 1 1

左上角那几个 1 是一座岛;右边连着的 1 是另一座岛。

核心思路

从左到右、从上到下扫描整个网格。

如果遇到 0,跳过。

如果遇到 1,说明发现了一座新岛:

c
count++

然后从这个 1 出发,用 DFS 把这座岛上所有相连的 1 都标记为已访问。

常见做法是直接把访问过的 1 改成 0,可以理解成“把这座岛淹掉”。

这样后面扫描到同一座岛的其他格子时,就不会重复计数。

C++ DFS 写法

c
#include <vector> // 引入 vector 容器,用来保存二维网格
using namespace std; // 使用标准命名空间,避免每次写 std::

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    int numIslands(vector<vector<char>>& grid) // 定义计算岛屿数量的函数
    { // numIslands 函数开始
        if (grid.empty()) // 如果网格为空
        { // if 语句开始
            return 0; // 空网格没有岛屿,直接返回 0
        } // if 语句结束
        int rows = grid.size(); // 保存网格的行数
        int cols = grid[0].size(); // 保存网格的列数
        int count = 0; // count 用来记录岛屿数量
        for (int r = 0; r < rows; r++) // 从第一行遍历到最后一行
        { // 外层 for 循环开始
            for (int c = 0; c < cols; c++) // 从当前行第一列遍历到最后一列
            { // 内层 for 循环开始
                if (grid[r][c] == '1') // 如果当前格子是还没访问过的陆地
                { // if 语句开始
                    count++; // 发现一座新岛,岛屿数量加 1
                    dfs(grid, r, c, rows, cols); // 从当前陆地出发,把整座岛标记掉
                } // if 语句结束
            } // 内层 for 循环结束
        } // 外层 for 循环结束
        return count; // 返回最终岛屿数量
    } // numIslands 函数结束

private: // private 表示下面函数只在类内部使用
    void dfs(vector<vector<char>>& grid, int r, int c, int rows, int cols) // 定义 DFS 函数,用来淹掉一整座岛
    { // dfs 函数开始
        if (r < 0 || r >= rows || c < 0 || c >= cols) // 如果当前位置越过网格边界
        { // if 语句开始
            return; // 越界位置不能继续搜索,直接返回
        } // if 语句结束
        if (grid[r][c] == '0') // 如果当前位置是水,或者已经被标记访问过
        { // if 语句开始
            return; // 水不能扩展岛屿,直接返回
        } // if 语句结束
        grid[r][c] = '0'; // 把当前陆地改成水,表示已经访问过
        dfs(grid, r - 1, c, rows, cols); // 向上搜索相邻陆地
        dfs(grid, r + 1, c, rows, cols); // 向下搜索相邻陆地
        dfs(grid, r, c - 1, rows, cols); // 向左搜索相邻陆地
        dfs(grid, r, c + 1, rows, cols); // 向右搜索相邻陆地
    } // dfs 函数结束
}; // Solution 类结束

为什么这样不会重复数

假设扫描到第一块陆地:

c
count++

然后 DFS 会把和它连着的所有陆地都改成 0

后面循环再走到这座岛的其他格子时,看到的已经是 0,所以不会再 count++

这就是“发现一座,清理一整座”。

复杂度

时间复杂度是 O(m * n)

因为每个格子最多被访问一次。

空间复杂度最坏是 O(m * n)

因为如果整张图都是陆地,DFS 递归栈最深可能接近格子总数。

面试高分回答

TIP

岛屿数量可以用 DFS 或 BFS。遍历整个网格,遇到一个未访问过的陆地 1,说明发现一座新岛,岛屿数加一。然后从这个格子出发,向上下左右四个方向搜索,把所有连通的陆地都标记为已访问,常见做法是直接把 1 改成 0。这样同一座岛不会被重复统计。整个过程中每个格子最多访问一次,所以时间复杂度是 O(m*n)

BFS 最短路径

一句话理解

BFS 最短路径就是:从起点开始一层一层向外扩散,先到达的点距离一定更短。所以在“无权图”或“每一步代价相同”的地图里,BFS 第一次到达终点时,就是最短路径。

bfs-shortest-path

核心前提

BFS 能求最短路径,有一个非常重要的前提:

每条边的代价相同

比如网格里每走一格都算 1 步,这种就适合 BFS。

如果有的路代价是 1,有的路代价是 5,那 BFS 就不适合了,要用 Dijkstra

如果是游戏寻路,并且想更快朝目标方向搜索,常见会用 A*

为什么第一次到终点就是最短

BFS 的访问顺序是:

距离起点 0 步
距离起点 1 步
距离起点 2 步
距离起点 3 步
...

它不会先处理远的点,再回头处理近的点。

所以当 BFS 第一次遇到终点时,说明更短距离的所有可能都已经检查过了。

这就是 BFS 能求无权最短路径的根本原因。

网格 BFS 代码

下面代码里,0 表示能走,1 表示障碍。返回从起点到终点的最短步数,走不到就返回 -1

c
#include <queue> // 引入 queue,用来实现 BFS 的先进先出队列
#include <vector> // 引入 vector,用来保存二维网格和距离数组
#include <utility> // 引入 pair,用来保存格子的行列坐标
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    int shortestPath(vector<vector<int>>& grid, pair<int, int> start, pair<int, int> target) // 定义 BFS 最短路径函数
    { // shortestPath 函数开始
        int rows = grid.size(); // 获取网格行数
        int cols = grid[0].size(); // 获取网格列数
        vector<vector<int>> dist(rows, vector<int>(cols, -1)); // 创建距离数组,-1 表示还没有访问过
        queue<pair<int, int>> q; // 创建 BFS 队列,队列里保存待处理格子的坐标
        q.push(start); // 把起点放入队列
        dist[start.first][start.second] = 0; // 起点到起点的距离是 0
        int dr[4] = {-1, 1, 0, 0}; // 四个方向的行变化,分别表示上、下、左、右
        int dc[4] = {0, 0, -1, 1}; // 四个方向的列变化,分别表示上、下、左、右
        while (!q.empty()) // 只要队列不为空,就继续 BFS
        { // while 循环开始
            pair<int, int> current = q.front(); // 取出队头格子
            q.pop(); // 弹出队头格子
            int r = current.first; // 取出当前格子的行号
            int c = current.second; // 取出当前格子的列号
            if (current == target) // 如果当前格子就是终点
            { // if 语句开始
                return dist[r][c]; // 返回起点到终点的最短距离
            } // if 语句结束
            for (int i = 0; i < 4; i++) // 遍历上下左右四个方向
            { // for 循环开始
                int nr = r + dr[i]; // 计算邻居格子的行号
                int nc = c + dc[i]; // 计算邻居格子的列号
                if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) // 如果邻居越界
                { // if 语句开始
                    continue; // 越界格子不能走,跳过
                } // if 语句结束
                if (grid[nr][nc] == 1) // 如果邻居是障碍
                { // if 语句开始
                    continue; // 障碍不能走,跳过
                } // if 语句结束
                if (dist[nr][nc] != -1) // 如果邻居已经访问过
                { // if 语句开始
                    continue; // 已访问格子已经有最短距离,不重复入队
                } // if 语句结束
                dist[nr][nc] = dist[r][c] + 1; // 邻居距离等于当前距离加 1
                q.push({nr, nc}); // 把邻居加入队列,等待后续处理
            } // for 循环结束
        } // while 循环结束
        return -1; // 队列空了还没到终点,说明不可达
    } // shortestPath 函数结束
}; // Solution 类结束

为什么要 dist 数组

dist[r][c] 有两个作用。

第一个作用:记录起点到当前格子的最短距离。

第二个作用:判断这个格子有没有访问过。

如果 dist[r][c] == -1,说明没访问过。

如果不是 -1,说明已经访问过,而且第一次访问时的距离就是最短距离。

如果要返回路径本身

如果题目不只要步数,还要具体路径,比如:

c
S -> ... -> E

那就额外开一个 parent 数组。

每次把邻居入队时,记录:

c
parent[neighbor] = current

最后从终点一路通过 parent 倒着走回起点,再反转一下,就是完整路径。

面试高分回答

CAUTION

BFS 求最短路径适用于无权图或所有边权相同的图。它从起点开始按层扩散,先访问距离为 1 的节点,再访问距离为 2 的节点,因此第一次到达终点时,路径长度一定最短。实现上通常使用队列保存待访问节点,并用 visiteddist 数组避免重复访问。对于网格题,每次从当前格子向上下左右四个方向扩展;对于普通图,则遍历当前节点的邻接表。时间复杂度是 O(V + E),网格里就是 O(m*n)

DFS 回溯

一句话理解

DFS 回溯就是:沿着一条路往深处试,试完以后撤销刚才的选择,再换另一条路继续试。

dfs-backtracking

DFS 和回溯的关系

DFS 是“往深处走”的搜索方式。

回溯 是“走完一条路后,恢复现场,再试下一条路”。

所以回溯常常写成:

做选择
递归
撤销选择

比如你在生成全排列:

[1, 2, 3]

你先选 1,再选 2,再选 3,得到:

[1, 2, 3]

记录答案后,要撤销 3,再尝试别的选择。否则后面的分支会被前面的选择污染。

回溯模板

核心模板可以这样记:

如果满足结束条件:
    记录答案
    返回

遍历当前可选项:
    做选择
    递归进入下一层
    撤销选择

最关键的是最后一步:

撤销选择

没有撤销,就不是完整的回溯。

C++ 例子:全排列

下面代码用回溯生成 [1,2,3] 的所有排列。

c
#include <vector> // 引入 vector 容器,用来保存输入数组、路径和答案
using namespace std; // 使用标准命名空间,避免每次写 std::

class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    vector<vector<int>> permute(vector<int>& nums) // 定义全排列函数,输入 nums,返回所有排列
    { // permute 函数开始
        vector<vector<int>> result; // 创建结果数组,用来保存所有完整排列
        vector<int> path; // 创建路径数组,用来保存当前正在构造的排列
        vector<bool> used(nums.size(), false); // 创建 used 数组,用来标记每个数字是否已经被当前路径使用
        dfs(nums, used, path, result); // 从空路径开始执行 DFS 回溯
        return result; // 返回所有排列结果
    } // permute 函数结束

private: // private 表示下面函数只在类内部使用
    void dfs(vector<int>& nums, vector<bool>& used, vector<int>& path, vector<vector<int>>& result) // 定义 DFS 回溯函数
    { // dfs 函数开始
        if (path.size() == nums.size()) // 如果当前路径长度已经等于 nums 长度
        { // if 语句开始
            result.push_back(path); // 说明得到一个完整排列,把它加入答案
            return; // 当前分支结束,返回上一层
        } // if 语句结束
        for (int i = 0; i < nums.size(); i++) // 遍历每一个可能选择的数字
        { // for 循环开始
            if (used[i]) // 如果 nums[i] 已经在当前路径里用过
            { // if 语句开始
                continue; // 当前数字不能重复使用,跳过它
            } // if 语句结束
            used[i] = true; // 做选择:标记 nums[i] 已经被使用
            path.push_back(nums[i]); // 做选择:把 nums[i] 放入当前路径
            dfs(nums, used, path, result); // 递归:进入下一层,继续选择下一个数字
            path.pop_back(); // 撤销选择:把刚才加入路径的 nums[i] 移除
            used[i] = false; // 撤销选择:恢复 nums[i] 为未使用状态
        } // for 循环结束
    } // dfs 函数结束
}; // Solution 类结束

为什么要 used 数组

全排列里,每个数字只能用一次。

比如已经选了:

[1, 2]

下一层就不能再选 12

所以用:

c
used[i] = true

表示这个数字已经在当前路径里。

当递归回来后,要恢复:

c
used[i] = false

这样别的分支还能正常使用它。

为什么要 path.pop_back()

假设当前路径是:

[1, 2, 3]

这条分支结束后,要回到:

[1, 2]

然后尝试其他可能。

如果不 pop_back(),路径会一直残留之前的数字,后续答案就乱了。

这就是“回溯”的意义:走完以后恢复现场。

适合哪些题

DFS 回溯常见于这些题:

全排列。

组合。

子集。

括号生成。

电话号码字母组合。

N 皇后。

数独。

单词搜索。

棋盘路径搜索。

只要题目像“从一堆选择里试出所有合法答案”,大概率可以考虑回溯。

复杂度怎么说

回溯题复杂度通常和“搜索树大小”有关。

全排列有:

n!

种结果,所以时间复杂度大约是:

O(n * n!)

这里多出来的 n,通常来自复制路径到答案。

空间复杂度主要是递归深度和路径:

O(n)

如果把结果数组也算进去,那会更大,因为要存所有答案。

面试高分回答

NOTE

DFS 回溯是一种试探式搜索。它会在当前层枚举所有可选项,先做一个选择,把它加入当前路径,然后递归进入下一层;当这一条路径搜索完成后,再撤销刚才的选择,恢复现场,继续尝试其他分支。它常用于全排列、组合、子集、N 皇后、数独、路径搜索等需要枚举所有可能解的问题。核心模板就是:终止条件、遍历选择、做选择、递归、撤销选择。回溯的关键是撤销操作,否则前一个分支的状态会影响后一个分支。

全排列

permutations-backtracking

全排列是什么? 全排列就是:给你一组数字,把它们所有可能的排列顺序都列出来。

比如:

[1, 2, 3]

它的全排列有:

[1, 2, 3]
[1, 3, 2]
[2, 1, 3]
[2, 3, 1]
[3, 1, 2]
[3, 2, 1]

如果有 n 个不同数字,全排列数量是:

n!

也就是:

3 个数:3 * 2 * 1 = 6 种
4 个数:4 * 3 * 2 * 1 = 24 种

核心思想:回溯

全排列是非常经典的回溯题。

你可以把它想成:

当前位置要填一个数字
从还没用过的数字里选一个
选完继续往下填
填完一个完整排列后,退回来换另一个数字

回溯里有三个关键东西:

path:当前正在拼的排列
used:某个数字是否已经用过
result:保存所有完整排列

C++ 代码

c
#include <vector> // 引入 vector 容器,用来保存输入数组、路径和答案
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    vector<vector<int>> permute(vector<int>& nums) // 定义全排列函数,输入 nums,返回所有排列
    { // permute 函数开始
        vector<vector<int>> result; // 创建结果数组,用来保存所有完整排列
        vector<int> path; // 创建路径数组,用来保存当前正在构造的排列
        vector<bool> used(nums.size(), false); // 创建 used 数组,用来标记每个数字是否已经被当前路径使用
        dfs(nums, used, path, result); // 从空路径开始执行 DFS 回溯
        return result; // 返回所有排列结果
    } // permute 函数结束
private: // private 表示下面函数只在类内部使用
    void dfs(vector<int>& nums, vector<bool>& used, vector<int>& path, vector<vector<int>>& result) // 定义 DFS 回溯函数
    { // dfs 函数开始
        if (path.size() == nums.size()) // 如果当前路径长度已经等于 nums 长度
        { // if 语句开始
            result.push_back(path); // 说明得到一个完整排列,把它加入答案
            return; // 当前分支结束,返回上一层
        } // if 语句结束
        for (int i = 0; i < nums.size(); i++) // 遍历每一个可能选择的数字
        { // for 循环开始
            if (used[i]) // 如果 nums[i] 已经在当前路径里用过
            { // if 语句开始
                continue; // 当前数字不能重复使用,跳过它
            } // if 语句结束
            used[i] = true; // 做选择:标记 nums[i] 已经被使用
            path.push_back(nums[i]); // 做选择:把 nums[i] 放入当前路径
            dfs(nums, used, path, result); // 递归:进入下一层,继续选择下一个数字
            path.pop_back(); // 撤销选择:把刚才加入路径的 nums[i] 移除
            used[i] = false; // 撤销选择:恢复 nums[i] 为未使用状态
        } // for 循环结束
    } // dfs 函数结束
}; // Solution 类结束

为什么要 used

因为全排列里,一个数字在同一个排列中只能出现一次。

比如 [1, 2, 3],如果当前 path 已经是:

[1, 2]

那么下一步就不能再选 12,只能选 3

所以 used[i] 的作用就是告诉我们:

nums[i] 当前这条递归路径里用没用过

为什么要 pop_back

因为递归回来之后,要尝试别的选择。

比如先走:

[1, 2, 3]

走完之后,要退回到:

[1, 2]

再退回到:

[1]

然后尝试:

[1, 3, 2]

这个“退回来”的动作,就是:

c
path.pop_back();
used[i] = false;

复杂度

时间复杂度:

c
O(n * n!)

因为一共有 n! 个排列,每个排列复制进答案时长度是 n

空间复杂度:

c
O(n)

这是递归深度、pathused 的辅助空间。 如果把结果数组也算进去,就是:

c
O(n * n!)

面试高分回答

IMPORTANT

全排列是典型回溯问题。每一层递归表示当前位置要选择一个数字,从所有未使用的数字里选择一个加入 path,然后继续递归下一层。当 path 长度等于输入数组长度时,说明得到一个完整排列,把它加入结果集。递归返回后要撤销选择,也就是 pop_back 并恢复 used 状态,这样才能继续尝试其他分支。关键点是:used 防止同一个排列里重复使用元素,撤销选择防止不同递归分支互相污染。

子集

subsets-backtracking

子集是什么? 子集就是:给你一个数组,每个元素都可以选择“要”或者“不要”,最后把所有可能的选择结果列出来。

比如:

nums = [1, 2, 3]

它的子集有:

c
[]
[1]
[2]
[3]
[1, 2]
[1, 3]
[2, 3]
[1, 2, 3]

如果有 n 个元素,每个元素都有两种选择:选 / 不选,所以子集数量是:

c
2^n

它和全排列的区别

全排列是:

所有数字都要用上,只是顺序不同。

子集是:

数字可以不用,重点是选了哪些数字。

比如 [1, 2, 3]

[1, 2, 3] 是一个排列,也是一个子集
[1, 2] 不是全排列,但它是子集
[] 空集也是子集

核心思路:回溯

子集题可以这样想:

从当前位置开始
每次选择一个数字加入 path
把当前 path 加入答案
然后继续往后选择
回来之后撤销选择

这里最重要的是:

下一层递归从 i + 1 开始

为什么?

因为子集不关心顺序。

如果已经选了 1,后面只能继续选 23。 不能再回头选 1 前面的东西,否则会出现重复组合。

C++ 代码

c
#include <vector> // 引入 vector 容器,用来保存数组、路径和答案
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    vector<vector<int>> subsets(vector<int>& nums) // 定义求子集函数,输入 nums,返回所有子集
    { // subsets 函数开始
        vector<vector<int>> result; // 创建结果数组,用来保存所有子集
        vector<int> path; // 创建路径数组,用来保存当前正在构造的子集
        dfs(nums, 0, path, result); // 从下标 0 开始执行回溯
        return result; // 返回所有子集
    } // subsets 函数结束
private: // private 表示下面的函数只在类内部使用
    void dfs(vector<int>& nums, int startIndex, vector<int>& path, vector<vector<int>>& result) // 定义 DFS 回溯函数
    { // dfs 函数开始
        result.push_back(path); // 把当前 path 加入答案,因为每一个 path 都是一个合法子集
        for (int i = startIndex; i < nums.size(); i++) // 从 startIndex 开始枚举后面的数字
        { // for 循环开始
            path.push_back(nums[i]); // 做选择:把 nums[i] 加入当前子集
            dfs(nums, i + 1, path, result); // 递归:下一层只能从 i + 1 开始继续选
            path.pop_back(); // 撤销选择:把刚才加入的 nums[i] 移除,尝试其他分支
        } // for 循环结束
    } // dfs 函数结束
}; // Solution 类结束

为什么 result.push_back(path) 放在递归一开始?

因为子集和全排列不一样。

全排列必须等到:

c
path.size() == nums.size()

才是一个完整答案。

但子集里:

[]
[1]
[1, 2]
[1, 2, 3]

每一个中间状态都是答案。

所以进入每一层递归时,当前 path 都应该被收集。

为什么不需要 used 数组?

全排列需要 used,因为它每一层都可以从头扫描所有数字,要靠 used 防止重复使用。

子集不需要 used,因为我们用 startIndex 控制方向:

只能往后选,不能回头选

比如选了 2 之后,下一层只能从 3 开始,不会再选 12

复杂度

时间复杂度:

O(n * 2^n)

因为一共有 2^n 个子集,每个子集复制进答案时最多需要 O(n)

空间复杂度:

c
O(n)

这是递归深度和 path 的空间。 如果把最终答案也算进去,就是:

c
O(n * 2^n)

面试高分回答

子集是典型回溯问题。每个元素本质上都有选和不选两种状态,所以一共有 2^n 个结果。实现时用 path 保存当前子集,用 startIndex 控制下一层从哪里开始选,防止重复组合。每进入一层递归,都先把当前 path 加入结果集,因为子集问题中每个中间状态都是合法答案。然后从 startIndex 开始枚举元素,选择一个元素加入 path,递归处理后面的元素,递归返回后再 pop_back 撤销选择,继续尝试其他分支。

组合总和

combination-sum-backtracking

组合总和是什么? 组合总和通常指 LeetCode 39:给你一组候选数字 candidates 和一个目标值 target,找出所有数字和等于 target 的组合。

重点是:

同一个数字可以被重复使用。

比如:

candidates = [2, 3, 6, 7]
target = 7

答案是:

[2, 2, 3]
[7]

因为:

2 + 2 + 3 = 7
7 = 7

它和子集有什么区别?

子集是:

每个数字通常只选一次。

组合总和是:

同一个数字可以选很多次。

比如数字 2 可以这样用:

[2, 2, 3]

所以这题的关键不是“选一次就跳过”,而是:

选了 candidates[i] 之后,下一层还可以继续从 i 开始选。

也就是:

c
dfs(i, remain - candidates[i])

不是:

c
dfs(i + 1, remain - candidates[i])

核心思路:回溯

我们用三个核心变量:

path:当前已经选择的组合
remain:距离 target 还差多少
startIndex:当前这一层从哪个位置开始选

举个例子:

c
target = 7
path = [2, 2]
remain = 3

这表示:

现在已经选了 2 和 2
还差 3 就能凑到 target

如果下一步选 3

c
path = [2, 2, 3]
remain = 0

说明找到一个答案。

C++ 代码

c
#include <vector> // 引入 vector 容器,用来保存候选数组、当前组合和所有答案
#include <algorithm> // 引入 algorithm,用来对 candidates 排序,方便剪枝
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) // 定义组合总和函数,输入候选数组和目标值
    { // combinationSum 函数开始
        vector<vector<int>> result; // 创建结果数组,用来保存所有满足条件的组合
        vector<int> path; // 创建路径数组,用来保存当前正在尝试的组合
        sort(candidates.begin(), candidates.end()); // 先排序,方便后面发现超过 remain 时直接剪枝
        dfs(candidates, target, 0, path, result); // 从下标 0 开始执行回溯,初始剩余目标就是 target
        return result; // 返回所有满足条件的组合
    } // combinationSum 函数结束
private: // private 表示下面的函数只在类内部使用
    void dfs(vector<int>& candidates, int remain, int startIndex, vector<int>& path, vector<vector<int>>& result) // 定义 DFS 回溯函数
    { // dfs 函数开始
        if (remain == 0) // 如果剩余目标为 0,说明当前 path 的数字和刚好等于 target
        { // if 语句开始
            result.push_back(path); // 把当前组合加入答案
            return; // 当前分支已经找到答案,返回上一层
        } // if 语句结束
        for (int i = startIndex; i < candidates.size(); i++) // 从 startIndex 开始枚举候选数字,避免组合顺序重复
        { // for 循环开始
            if (candidates[i] > remain) // 如果当前数字已经大于剩余目标
            { // if 语句开始
                break; // 因为数组已经排序,后面的数字更大,所以直接停止循环
            } // if 语句结束
            path.push_back(candidates[i]); // 做选择:把当前数字加入组合
            dfs(candidates, remain - candidates[i], i, path, result); // 递归:因为数字可以重复使用,所以下一层仍然从 i 开始
            path.pop_back(); // 撤销选择:把刚才加入的数字移除,尝试下一个候选数字
        } // for 循环结束
    } // dfs 函数结束
}; // Solution 类结束

为什么递归传 i,不是 i + 1

因为题目允许:

同一个数字重复使用。

比如:

[2, 2, 3]

第一个 2 选完之后,下一层还要能继续选 2

所以写:

c
dfs(candidates, remain - candidates[i], i, path, result)

如果写成 i + 1,那每个数字最多只能用一次,就变成另一类题了。

为什么需要 startIndex

因为组合不关心顺序。

下面两个组合本质一样:

[2, 2, 3]
[2, 3, 2]

我们只想保留一种。

所以用 startIndex 控制:

后面只能选当前位置及其后面的数字
不要回头选前面的数字

这样就不会产生重复顺序。

为什么可以剪枝?

如果排好序之后:

c
candidates = [2, 3, 6, 7]
remain = 4

当你枚举到 6 时:

6 > 4

说明 6 不能选。

因为后面的 7 更大,也不可能选。

所以可以直接:

break

这就是剪枝,可以减少很多无意义递归。

复杂度

时间复杂度不太好精确写成一个简单公式,因为数字可以重复选择,和 target、候选数字大小都有关系。

面试里可以这样说:

回溯会枚举所有可能组合,最坏情况下搜索树可能很大。

更常见的表达是:

时间复杂度和答案数量、target 大小、候选数字分布有关。

空间复杂度:

O(target / min(candidates))

这是递归最深可能选择多少个数字。 比如最小数字是 2target = 8,最深可能是:

[2, 2, 2, 2]

如果算最终答案空间,就要再加上所有结果占用的空间。

面试高分回答

NOTE

组合总和是典型回溯问题。用 path 保存当前组合,用 remain 表示还差多少凑到目标值,用 startIndex 控制搜索起点,避免出现 [2,3,2] 这种顺序不同但本质相同的重复组合。每次从 startIndex 开始枚举候选数字,选择一个数字加入 path,然后递归搜索剩余目标。由于题目允许同一个数字重复使用,所以递归时传入的是 i,不是 i + 1。当 remain == 0 时说明找到一个合法组合,加入结果。数组排序后,如果当前数字已经大于 remain,后续数字也一定更大,可以直接 break 剪枝。

爬楼梯

climbing-stairs-dp

爬楼梯是什么题? 爬楼梯是经典动态规划题。题目一般是:

你要爬到第 n 阶楼梯。
每次可以爬 1 阶或者 2 阶。
问一共有多少种不同爬法?

比如:

n = 3

有 3 种爬法:

1 + 1 + 1
1 + 2
2 + 1

所以答案是:

3

核心思想

这题不要真的去模拟人爬楼梯。

面试里要从“最后一步”分析:

如果最后一步爬 1 阶,那么之前一定站在第 n - 1 阶。
如果最后一步爬 2 阶,那么之前一定站在第 n - 2 阶。

所以:

到第 n 阶的方法数 = 到第 n - 1 阶的方法数 + 到第 n - 2 阶的方法数

也就是:

c
dp[n] = dp[n - 1] + dp[n - 2]

这就是斐波那契数列的思想。

举个例子

假设要到第 5 阶。

最后一步只有两种可能:

从第 4 阶走 1 阶到第 5 阶
从第 3 阶走 2 阶到第 5 阶

所以:

c
dp[5] = dp[4] + dp[3]

如果:

c
dp[4] = 5
dp[3] = 3

那么:

c
dp[5] = 5 + 3 = 8

为什么不是排列组合硬算?

因为 n 变大之后,所有路径会非常多。

比如:

c
n = 5

所有爬法是:

c
1 + 1 + 1 + 1 + 1
1 + 1 + 1 + 2
1 + 1 + 2 + 1
1 + 2 + 1 + 1
2 + 1 + 1 + 1
1 + 2 + 2
2 + 1 + 2
2 + 2 + 1

一共 8 种。

如果靠枚举,会越来越慢。 动态规划的优势是:

小问题算过之后直接复用。

动态规划数组写法

c
#include <vector> // 引入 vector 容器,用来保存动态规划数组
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    int climbStairs(int n) // 定义爬楼梯函数,输入楼梯阶数 n,返回不同爬法数量
    { // climbStairs 函数开始
        if (n <= 2) // 如果 n 是 1 或 2,可以直接返回答案
        { // if 语句开始
            return n; // n 为 1 时答案是 1,n 为 2 时答案是 2
        } // if 语句结束
        vector<int> dp(n + 1, 0); // 创建 dp 数组,dp[i] 表示爬到第 i 阶的方法数
        dp[1] = 1; // 爬到第 1 阶只有 1 种方法:一次爬 1 阶
        dp[2] = 2; // 爬到第 2 阶有 2 种方法:1+1 或 2
        for (int i = 3; i <= n; i++) // 从第 3 阶开始,依次计算到第 n 阶
        { // for 循环开始
            dp[i] = dp[i - 1] + dp[i - 2]; // 状态转移:最后一步来自 i-1 或 i-2
        } // for 循环结束
        return dp[n]; // 返回爬到第 n 阶的方法数
    } // climbStairs 函数结束
}; // Solution 类结束

空间优化写法

上面用了一个 dp 数组,其实没必要保存所有结果。

因为计算:

c
dp[i]

只需要:

c
dp[i - 1]
dp[i - 2]

所以可以只用两个变量。

c
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面函数可以被外部调用
    int climbStairs(int n) // 定义爬楼梯函数,输入楼梯阶数 n,返回不同爬法数量
    { // climbStairs 函数开始
        if (n <= 2) // 如果 n 是 1 或 2,可以直接返回答案
        { // if 语句开始
            return n; // n 为 1 时答案是 1,n 为 2 时答案是 2
        } // if 语句结束
        int prev2 = 1; // prev2 表示 dp[i - 2],初始对应 dp[1]
        int prev1 = 2; // prev1 表示 dp[i - 1],初始对应 dp[2]
        int current = 0; // current 表示当前要计算的 dp[i]
        for (int i = 3; i <= n; i++) // 从第 3 阶开始计算
        { // for 循环开始
            current = prev1 + prev2; // 当前方法数等于前一阶方法数加前两阶方法数
            prev2 = prev1; // 更新 prev2,让它变成下一轮的 dp[i - 2]
            prev1 = current; // 更新 prev1,让它变成下一轮的 dp[i - 1]
        } // for 循环结束
        return prev1; // 循环结束后 prev1 就是 dp[n]
    } // climbStairs 函数结束
}; // Solution 类结束

复杂度

动态规划数组写法:

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

空间优化写法:

时间复杂度:O(n)
空间复杂度:O(1)

面试高分回答

爬楼梯是典型动态规划问题。定义 dp[i] 表示爬到第 i 阶的方法数。到第 i 阶的最后一步只有两种情况:从第 i - 1 阶爬 1 阶上来,或者从第 i - 2 阶爬 2 阶上来,所以状态转移方程是 dp[i] = dp[i - 1] + dp[i - 2]。初始条件是 dp[1] = 1dp[2] = 2。因为每次只依赖前两个状态,所以可以用两个变量做空间优化,把空间复杂度从 O(n) 降到 O(1)

打家劫舍

house-robber-dp

打家劫舍是什么题? 打家劫舍是经典动态规划题。题目大概是:

有一排房子,每间房都有一定金额。
小偷不能偷相邻的两间房。
问最多能偷多少钱?

比如:

nums = [2, 7, 9, 3, 1]

最佳偷法是:

偷第 0 间、第 2 间、第 4 间

金额是:

2 + 9 + 1 = 12

核心思想

每到一间房,只有两个选择:

偷当前房
不偷当前房

如果偷当前房:

前一间房就不能偷
所以只能加上 dp[i - 2]

如果不偷当前房:

答案就是前一间房的最优结果 dp[i - 1]

所以状态转移方程是:

c
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])

dp[i] 是什么意思?

dp[i] 表示:从第 0 间房到第 i 间房,在不偷相邻房子的前提下,最多能偷多少钱。

注意,这句话很重要:

dp[i] 不是一定偷第 i 间房。

它表示的是:

考虑到第 i 间房为止的最大收益。

举个例子

c
nums = [2, 7, 9, 3, 1]

初始化:

c
dp[0] = 2
dp[1] = max(2, 7) = 7

算第 2 间房:

c
偷第 2 间:dp[0] + nums[2] = 2 + 9 = 11
不偷第 2 间:dp[1] = 7
dp[2] = max(11, 7) = 11

算第 3 间房:

c
偷第 3 间:dp[1] + nums[3] = 7 + 3 = 10
不偷第 3 间:dp[2] = 11
dp[3] = max(10, 11) = 11

算第 4 间房:

c
偷第 4 间:dp[2] + nums[4] = 11 + 1 = 12
不偷第 4 间:dp[3] = 11
dp[4] = max(12, 11) = 12

最终答案:

12

C++ 代码

c
#include <vector> // 引入 vector 容器,用来保存房子金额和动态规划数组
#include <algorithm> // 引入 algorithm,用来使用 max 函数
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    int rob(vector<int>& nums) // 定义打家劫舍函数,输入每间房的钱,返回最多能偷的钱
    { // rob 函数开始
        int n = nums.size(); // 获取房子的数量
        if (n == 0) // 如果没有房子
        { // if 语句开始
            return 0; // 没有房子可以偷,所以返回 0
        } // if 语句结束
        if (n == 1) // 如果只有一间房
        { // if 语句开始
            return nums[0]; // 只能偷这一间房,所以返回 nums[0]
        } // if 语句结束
        vector<int> dp(n, 0); // 创建 dp 数组,dp[i] 表示考虑到第 i 间房时最多能偷多少钱
        dp[0] = nums[0]; // 只有第 0 间房时,最多只能偷 nums[0]
        dp[1] = max(nums[0], nums[1]); // 只有前两间房时,因为不能相邻偷,所以取金额更大的那一间
        for (int i = 2; i < n; i++) // 从第 2 间房开始计算
        { // for 循环开始
            int robCurrent = dp[i - 2] + nums[i]; // 偷当前房:当前金额加上 i-2 位置的最优结果
            int skipCurrent = dp[i - 1]; // 不偷当前房:结果就是 i-1 位置的最优结果
            dp[i] = max(skipCurrent, robCurrent); // 当前最优结果等于偷和不偷两种选择里的最大值
        } // for 循环结束
        return dp[n - 1]; // 返回考虑到最后一间房时的最大金额
    } // rob 函数结束
}; // Solution 类结束

为什么不能贪心?

你可能会想:

每次偷金额最大的房子不就行了吗?

不行。

比如:

nums = [2, 7, 9, 3, 1]

如果先偷最大的 9,还能偷 21,得到 12,这次刚好对。 但换一个例子:

nums = [2, 1, 1, 2]

如果只看局部,可能会错。正确答案是:

2 + 2 = 4

这类题要考虑“当前选择对后面选择的影响”,所以更适合动态规划。

空间优化

因为:

c
dp[i] 只依赖 dp[i - 1] 和 dp[i - 2]

所以不一定需要完整数组,可以只用两个变量。

c
#include <vector> // 引入 vector 容器,用来保存房子金额
#include <algorithm> // 引入 algorithm,用来使用 max 函数
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    int rob(vector<int>& nums) // 定义打家劫舍函数,输入每间房的钱,返回最多能偷的钱
    { // rob 函数开始
        int prev2 = 0; // prev2 表示 dp[i - 2],也就是隔一间之前的最优结果
        int prev1 = 0; // prev1 表示 dp[i - 1],也就是前一间之前的最优结果
        for (int money : nums) // 依次遍历每一间房的钱
        { // for 循环开始
            int robCurrent = prev2 + money; // 偷当前房:当前金额加上 dp[i - 2]
            int skipCurrent = prev1; // 不偷当前房:保持 dp[i - 1]
            int current = max(skipCurrent, robCurrent); // 当前最优结果等于偷和不偷两种选择中的最大值
            prev2 = prev1; // 更新 prev2,让它变成下一轮的 dp[i - 2]
            prev1 = current; // 更新 prev1,让它变成下一轮的 dp[i - 1]
        } // for 循环结束
        return prev1; // 遍历结束后,prev1 就是最终最大金额
    } // rob 函数结束
}; // Solution 类结束

复杂度

动态规划数组写法:

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

空间优化写法:

时间复杂度:O(n)
空间复杂度:O(1)

面试高分回答

IMPORTANT

打家劫舍是典型动态规划问题。定义 dp[i] 表示考虑到第 i 间房为止,在不能偷相邻房子的限制下,最多能偷多少钱。对于第 i 间房,只有两种选择:不偷它,那么结果是 dp[i - 1];偷它,那么第 i - 1 间不能偷,结果是 dp[i - 2] + nums[i]。因此状态转移方程是 dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])。由于每个状态只依赖前两个状态,所以可以把空间从 O(n) 优化到 O(1)

最长递增子序列

longest-increasing-subsequence

最长递增子序列是什么? 最长递增子序列,英文叫 Longest Increasing Subsequence,简称 LIS

它的意思是:

从数组中挑出一些数字,保持原来的相对顺序不变,让这些数字严格递增,并且长度尽量长。

比如:

nums = [10, 9, 2, 5, 3, 7, 101, 18]

一个最长递增子序列可以是:

[2, 3, 7, 101]

长度是:

4

注意,也可以是:

[2, 3, 7, 18]

所以答案通常问的是“长度”,不一定要求输出具体是哪一个序列。

子序列和子数组的区别

这个点面试特别容易考。

子数组要求连续:

[2, 5, 3]

它在原数组里是连续的一段。

子序列不要求连续:

[2, 3, 7, 101]

它可以跳过中间的数字,但原来的相对顺序不能乱。

也就是说:

[2, 3, 7, 101] 可以
[2, 7, 3, 101] 不可以

因为 7 在原数组里本来就在 3 后面,顺序不能倒过来。

动态规划怎么想?

我们定义:

dp[i] 表示:以 nums[i] 结尾的最长递增子序列长度。

这句话非常关键。

不是说:

前 i 个元素里的最长长度

而是:

必须以 nums[i] 这个数字作为结尾

为什么要这样定义?

因为如果要把 nums[i] 接到前面的某个序列后面,必须满足:

nums[j] < nums[i]

只有前面的数字比当前数字小,才能组成递增序列。

所以状态转移是:

c
如果 nums[j] < nums[i]
dp[i] = max(dp[i], dp[j] + 1)

举个例子

c
nums = [10, 9, 2, 5, 3, 7, 101, 18]

一开始每个数字自己都能组成长度为 1 的递增子序列:

dp = [1, 1, 1, 1, 1, 1, 1, 1]

看到 5 的时候:

前面有 2 比 5 小
所以 [2, 5] 长度是 2
dp[3] = 2

看到 7 的时候:

前面有 2、5、3 都比 7 小
可以组成 [2, 5, 7]
也可以组成 [2, 3, 7]
长度是 3
dp[5] = 3

看到 101 的时候:

c
可以接在 [2, 3, 7] 后面
得到 [2, 3, 7, 101]
长度是 4
dp[6] = 4

最终:

c
dp = [1, 1, 1, 2, 2, 3, 4, 4]

答案是:

c
max(dp) = 4

O(n²) 动态规划代码

c
#include <vector> // 引入 vector 容器,用来保存输入数组和 dp 数组
#include <algorithm> // 引入 algorithm,用来使用 max 函数
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    int lengthOfLIS(vector<int>& nums) // 定义函数,输入数组 nums,返回最长递增子序列长度
    { // lengthOfLIS 函数开始
        int n = nums.size(); // 获取数组长度
        if (n == 0) // 如果数组为空
        { // if 语句开始
            return 0; // 空数组没有递增子序列,所以返回 0
        } // if 语句结束
        vector<int> dp(n, 1); // 创建 dp 数组,每个位置初始为 1,因为每个数字自己就是长度为 1 的序列
        int answer = 1; // 创建答案变量,至少有一个数字时,最长长度至少是 1
        for (int i = 0; i < n; i++) // 枚举当前结尾位置 i
        { // 外层 for 循环开始
            for (int j = 0; j < i; j++) // 枚举 i 前面的每一个位置 j
            { // 内层 for 循环开始
                if (nums[j] < nums[i]) // 如果 nums[j] 小于 nums[i],说明 nums[i] 可以接在 nums[j] 后面
                { // if 语句开始
                    dp[i] = max(dp[i], dp[j] + 1); // 更新以 nums[i] 结尾的最长递增子序列长度
                } // if 语句结束
            } // 内层 for 循环结束
            answer = max(answer, dp[i]); // 用当前 dp[i] 更新全局最长长度
        } // 外层 for 循环结束
        return answer; // 返回最长递增子序列长度
    } // lengthOfLIS 函数结束
}; // Solution 类结束

为什么 dp[i] 初始是 1?

因为每个数字单独拿出来,都是一个递增子序列。

比如:

nums[i] = 7

哪怕前面没有任何数字能接上它,它自己也能形成:

[7]

长度就是:

1

所以:

dp[i] 初始值是 1

为什么判断条件是 < 不是 <=

因为题目是“递增子序列”,通常指严格递增。

也就是说:

1, 2, 3 可以
1, 2, 2 不可以

所以判断条件是:

c
nums[j] < nums[i]

如果题目问的是“非递减子序列”,才可能用:

c
nums[j] <= nums[i]

O(n log n) 优化思路

面试如果只会 O(n²),已经能过很多基础场景。 如果想更打动面试官,可以再说优化版本。

我们维护一个数组:

c
tails

它的含义是:

tails[k] 表示长度为 k + 1 的递增子序列中,最小可能的结尾值。

为什么要让结尾尽量小?

因为结尾越小,后面越容易接上更大的数字。

比如:

c
长度为 2 的递增子序列
[2, 5]
[2, 3]

显然 [2, 3] 更好,因为结尾 3 更小,后面更容易接 718101

O(n log n) 代码

c
#include <vector> // 引入 vector 容器,用来保存输入数组和 tails 数组
#include <algorithm> // 引入 algorithm,用来使用 lower_bound 函数
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    int lengthOfLIS(vector<int>& nums) // 定义函数,输入数组 nums,返回最长递增子序列长度
    { // lengthOfLIS 函数开始
        vector<int> tails; // 创建 tails 数组,tails[i] 表示长度为 i+1 的递增子序列的最小结尾值
        for (int x : nums) // 遍历 nums 中的每一个数字 x
        { // for 循环开始
            auto it = lower_bound(tails.begin(), tails.end(), x); // 找到 tails 中第一个大于等于 x 的位置
            if (it == tails.end()) // 如果没有找到,说明 x 比所有结尾值都大
            { // if 语句开始
                tails.push_back(x); // 把 x 放到 tails 末尾,表示最长长度可以增加 1
            } // if 语句结束
            else // 如果找到了第一个大于等于 x 的位置
            { // else 语句开始
                *it = x; // 用 x 替换这个位置,让相同长度的序列结尾尽量小
            } // else 语句结束
        } // for 循环结束
        return tails.size(); // tails 的长度就是最长递增子序列的长度
    } // lengthOfLIS 函数结束
}; // Solution 类结束

注意 tails 不一定是真实的 LIS

这一点也很重要。

tallstails 数组主要是为了求长度,它里面的数字不一定能直接组成原数组中的真实最长递增子序列。

它保证的是:

每种长度下,结尾值尽量小。

所以它可以高效得到长度。

如果面试官问:

能不能输出具体序列?

那就需要额外记录前驱下标,不是这个基础版本的重点。

复杂度

动态规划版本:

时间复杂度:O(n²)
空间复杂度:O(n)

二分优化版本:

时间复杂度:O(n log n)
空间复杂度:O(n)

面试高分回答

NOTE

最长递增子序列是典型动态规划问题。首先要明确子序列不要求连续,但必须保持原数组中的相对顺序。可以定义 dp[i] 表示以 nums[i] 结尾的最长递增子序列长度。对于每个位置 i,向前枚举所有 j < i,如果 nums[j] < nums[i],说明 nums[i] 可以接在以 nums[j] 结尾的递增序列后面,因此状态转移为 dp[i] = max(dp[i], dp[j] + 1)。所有 dp[i] 的最大值就是答案。基础写法时间复杂度是 O(n²),如果要优化,可以维护 tails 数组并用二分查找,把时间复杂度降到 O(n log n)

最长公共子序列

longest-common-subsequence

最长公共子序列是什么? 最长公共子序列,英文叫 Longest Common Subsequence,简称 LCS

它的意思是:

在两个字符串中,找一个最长的、两边都能按顺序找到的字符序列。

比如:

text1 = "abcde"
text2 = "ace"

它们的最长公共子序列是:

"ace"

长度是:

3

子序列是什么意思?

子序列可以不连续,但是顺序不能乱。

比如:

"abcde"

里面可以取出:

"a c e"

也就是:

c
"ace"

虽然中间跳过了 bd,但是 ace 的顺序没有变。

所以:

c
"ace""abcde" 的子序列
"aec" 不是 "abcde" 的子序列

因为 e 在原字符串里本来在 c 后面,不能倒过来。

它和最长公共子串的区别

这个一定要分清。

最长公共子序列:

可以不连续

最长公共子串:

必须连续

比如:

c
text1 = "abcde"
text2 = "ace"

最长公共子序列是:

"ace"

但最长公共子串只能是:

"a" 或 "c" 或 "e"

因为 "ace""abcde" 里不是连续的一段。

动态规划怎么定义?

我们定义:

dp[i][j] 表示 text1 的前 i 个字符 和 text2 的前 j 个字符 的最长公共子序列长度。

注意:

前 i 个字符不是下标 i

比如:

c
text1 = "abcde"

i = 3 表示:

c
"abc"

不是只看字符 text1[3]

最终答案是:

c
dp[text1.size()][text2.size()]

状态怎么转移?

假设现在比较:

c
text1[i - 1]
text2[j - 1]

为什么是 i - 1j - 1

因为 dp[i][j] 表示前 i 个字符,而字符串下标从 0 开始。

如果两个字符相等:

c
text1[i - 1] == text2[j - 1]

说明这个字符可以加入公共子序列:

c
dp[i][j] = dp[i - 1][j - 1] + 1

如果两个字符不相等:

c
text1[i - 1] != text2[j - 1]

说明这两个字符不能同时作为公共结尾,只能尝试丢掉其中一个:

c
丢掉 text1 当前字符:dp[i - 1][j]
丢掉 text2 当前字符:dp[i][j - 1]

取更大的:

c
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

C++ 代码

c
#include <string> // 引入 string,用来表示两个输入字符串
#include <vector> // 引入 vector,用来创建二维 dp 数组
#include <algorithm> // 引入 algorithm,用来使用 max 函数
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    int longestCommonSubsequence(string text1, string text2) // 定义最长公共子序列函数,输入两个字符串,返回 LCS 长度
    { // longestCommonSubsequence 函数开始
        int m = text1.size(); // 获取 text1 的长度
        int n = text2.size(); // 获取 text2 的长度
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0)); // 创建二维 dp 数组,多开一行一列用来表示空字符串
        for (int i = 1; i <= m; i++) // 从 text1 的第 1 个字符开始枚举
        { // 外层 for 循环开始
            for (int j = 1; j <= n; j++) // 从 text2 的第 1 个字符开始枚举
            { // 内层 for 循环开始
                if (text1[i - 1] == text2[j - 1]) // 如果当前两个字符相等
                { // if 语句开始
                    dp[i][j] = dp[i - 1][j - 1] + 1; // 当前字符可以加入公共子序列,所以从左上角状态加 1
                } // if 语句结束
                else // 如果当前两个字符不相等
                { // else 语句开始
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); // 当前字符不能同时选,取上方和左方的最大值
                } // else 语句结束
            } // 内层 for 循环结束
        } // 外层 for 循环结束
        return dp[m][n]; // 右下角就是两个完整字符串的最长公共子序列长度
    } // longestCommonSubsequence 函数结束
}; // Solution 类结束

为什么 dp 要多开一行一列?

因为我们需要处理空字符串情况。

比如:

text1 前 0 个字符
text2 前 j 个字符

这时公共子序列长度一定是:

0

所以:

c
dp[0][j] = 0
dp[i][0] = 0

多开一行一列之后,代码不用单独处理边界,直接从 1 开始循环就行。

以 "abcde" 和 "ace" 举例

c
text1 = "abcde"
text2 = "ace"

匹配过程大概是:

a 和 a 匹配,长度变成 1
c 和 c 匹配,长度变成 2
e 和 e 匹配,长度变成 3

所以最后:

c
dp[5][3] = 3

答案就是:

3

空间优化写法

二维数组更好理解。 但如果面试官问优化空间,可以用一维数组。

c
#include <string> // 引入 string,用来表示两个输入字符串
#include <vector> // 引入 vector,用来创建一维 dp 数组
#include <algorithm> // 引入 algorithm,用来使用 max 函数
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义 LeetCode 常见的 Solution 类
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    int longestCommonSubsequence(string text1, string text2) // 定义最长公共子序列函数,输入两个字符串,返回 LCS 长度
    { // longestCommonSubsequence 函数开始
        int m = text1.size(); // 获取 text1 的长度
        int n = text2.size(); // 获取 text2 的长度
        vector<int> dp(n + 1, 0); // 创建一维 dp 数组,dp[j] 表示当前行算到 j 时的 LCS 长度
        for (int i = 1; i <= m; i++) // 枚举 text1 的前 i 个字符
        { // 外层 for 循环开始
            int leftTop = 0; // leftTop 用来保存二维 dp 中的 dp[i - 1][j - 1]
            for (int j = 1; j <= n; j++) // 枚举 text2 的前 j 个字符
            { // 内层 for 循环开始
                int oldTop = dp[j]; // oldTop 保存更新前的 dp[j],也就是二维 dp 中的 dp[i - 1][j]
                if (text1[i - 1] == text2[j - 1]) // 如果当前两个字符相等
                { // if 语句开始
                    dp[j] = leftTop + 1; // 当前字符可以加入公共子序列,所以使用左上角值加 1
                } // if 语句结束
                else // 如果当前两个字符不相等
                { // else 语句开始
                    dp[j] = max(dp[j], dp[j - 1]); // 取上方 dp[j] 和左方 dp[j - 1] 的最大值
                } // else 语句结束
                leftTop = oldTop; // 更新 leftTop,供下一列使用
            } // 内层 for 循环结束
        } // 外层 for 循环结束
        return dp[n]; // dp[n] 就是最终的最长公共子序列长度
    } // longestCommonSubsequence 函数结束
}; // Solution 类结束

复杂度

二维 DP 写法:

时间复杂度:O(m * n)
空间复杂度:O(m * n)

一维优化写法:

时间复杂度:O(m * n)
空间复杂度:O(n)

其中:

c
m 是 text1 的长度
n 是 text2 的长度

面试高分回答

NOTE

最长公共子序列是经典二维动态规划问题。子序列可以不连续,但字符相对顺序不能改变。定义 dp[i][j] 表示 text1i 个字符和 text2j 个字符的最长公共子序列长度。如果 text1[i - 1] == text2[j - 1],说明当前字符可以作为公共子序列的一部分,状态转移为 dp[i][j] = dp[i - 1][j - 1] + 1。如果两个字符不相等,就只能舍弃其中一个当前字符,状态转移为 dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])。初始化时第 0 行和第 0 列表示空字符串,值都是 0。最终答案是 dp[m][n]

背包问题

knapsack-problem-dp

背包问题是什么? 背包问题是一类经典动态规划题。最常见的是 0/1 背包

有 n 个物品。
每个物品有重量 weight 和价值 value。
背包容量有限。
每个物品只能选一次。
问:在不超过背包容量的情况下,最多能装多少价值?

比如:

weights = [1, 3, 4]
values  = [15, 20, 30]
capacity = 4

最优选择是:

选重量 1、价值 15 的物品
选重量 3、价值 20 的物品

总重量:

1 + 3 = 4

总价值:

15 + 20 = 35

为什么叫 0/1 背包?

因为每个物品只有两种状态:

0:不选
1:选

也就是说:

每个物品最多只能拿一次。

这就是 0/1 背包

如果题目说:

每个物品可以无限次选择

那就是:

完全背包

动态规划怎么定义?

先讲最好理解的二维 DP。

定义:

dp[i][j] 表示:只看前 i 个物品,背包容量为 j 时,能获得的最大价值。

比如:

c
dp[2][4]

表示:

只看前 2 个物品,背包容量是 4,最多能拿多少价值。

状态怎么转移?

对于第 i 个物品,有两种选择。

第一种:不选当前物品。

c
dp[i][j] = dp[i - 1][j]

意思是:

当前物品不拿,那答案就等于只看前 i - 1 个物品时的答案。

第二种:选当前物品。

前提是:

当前容量 j 能装得下这个物品

也就是:

j >= weight[i - 1]

如果选当前物品:

dp[i][j] = dp[i - 1][j - weight[i - 1]] + value[i - 1]

意思是:

先给当前物品腾出重量
再加上当前物品的价值

所以最终:

dp[i][j] = max(不选当前物品, 选当前物品)

也就是:

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i - 1]] + value[i - 1])

二维 DP 代码

c
#include <vector> // 引入 vector 容器,用来保存重量数组、价值数组和 dp 数组
#include <algorithm> // 引入 algorithm,用来使用 max 函数
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义一个 Solution 类,方便面试或 LeetCode 风格调用
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    int knapsack01(vector<int>& weights, vector<int>& values, int capacity) // 定义 0/1 背包函数,输入重量、价值和背包容量
    { // knapsack01 函数开始
        int n = weights.size(); // 获取物品数量
        vector<vector<int>> dp(n + 1, vector<int>(capacity + 1, 0)); // 创建二维 dp 数组,多开一行表示没有物品的情况
        for (int i = 1; i <= n; i++) // 枚举前 i 个物品
        { // 外层 for 循环开始
            for (int j = 0; j <= capacity; j++) // 枚举当前背包容量 j
            { // 内层 for 循环开始
                dp[i][j] = dp[i - 1][j]; // 默认不选当前物品,继承上一行的最优结果
                if (j >= weights[i - 1]) // 如果当前容量 j 能装下第 i 个物品
                { // if 语句开始
                    int take = dp[i - 1][j - weights[i - 1]] + values[i - 1]; // 计算选择当前物品时能获得的价值
                    dp[i][j] = max(dp[i][j], take); // 在选和不选之间取最大值
                } // if 语句结束
            } // 内层 for 循环结束
        } // 外层 for 循环结束
        return dp[n][capacity]; // 返回看完所有物品且容量为 capacity 时的最大价值
    } // knapsack01 函数结束
}; // Solution 类结束

为什么数组下标是 i - 1

因为:

dp 的 i 表示前 i 个物品

但数组下标从 0 开始。

所以第 i 个物品对应数组下标:

i - 1

比如:

i = 1

表示第 1 个物品,它在数组中的下标是:

0

一维 DP 优化

二维 DP 好理解,但空间可以优化。

因为:

c
dp[i][j] 只依赖上一行 dp[i - 1][...]

所以可以用一维数组:

c
dp[j] 表示容量为 j 时的最大价值

0/1 背包一维代码

c
#include <vector> // 引入 vector 容器,用来保存重量数组、价值数组和 dp 数组
#include <algorithm> // 引入 algorithm,用来使用 max 函数
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义一个 Solution 类,方便面试或 LeetCode 风格调用
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    int knapsack01(vector<int>& weights, vector<int>& values, int capacity) // 定义 0/1 背包函数,输入重量、价值和背包容量
    { // knapsack01 函数开始
        int n = weights.size(); // 获取物品数量
        vector<int> dp(capacity + 1, 0); // 创建一维 dp 数组,dp[j] 表示容量为 j 时的最大价值
        for (int i = 0; i < n; i++) // 枚举每一个物品
        { // 外层 for 循环开始
            for (int j = capacity; j >= weights[i]; j--) // 倒序枚举容量,避免同一个物品被重复使用
            { // 内层 for 循环开始
                int take = dp[j - weights[i]] + values[i]; // 计算选择当前物品时能获得的价值
                dp[j] = max(dp[j], take); // 在选当前物品和不选当前物品之间取最大值
            } // 内层 for 循环结束
        } // 外层 for 循环结束
        return dp[capacity]; // 返回容量为 capacity 时的最大价值
    } // knapsack01 函数结束
}; // Solution 类结束

为什么 0/1 背包一维 DP 要倒序?

这是背包问题最容易考的点。

0/1 背包中:

每个物品只能用一次。

如果容量正序遍历:

for j 从小到大

那么当前物品刚更新过的 dp[j],可能马上又被后面的容量拿去用。 这样就等于:

同一个物品被用了多次

这就错了。

所以 0/1 背包必须倒序:

c
for j 从 capacity 到 weight[i]

这样可以保证:

c
dp[j - weight[i]]

还是上一轮物品的状态,不会重复使用当前物品。

完全背包和 0/1 背包区别

完全背包是:

每个物品可以无限次使用。

所以它的一维 DP 容量循环要正序。

0/1 背包:倒序
完全背包:正序

对比一下:

c
0/1 背包:for j = capacity 到 weight[i]
完全背包:for j = weight[i] 到 capacity

一句话记忆:

只能用一次,倒序防止重复用。
可以重复用,正序允许重复用。

复杂度

二维 DP:

c
时间复杂度:O(n * capacity)
空间复杂度:O(n * capacity)

一维优化:

c
时间复杂度:O(n * capacity)
空间复杂度:O(capacity)

其中:

c
n 是物品数量
capacity 是背包容量

面试高分回答

NOTE

背包问题是一类动态规划问题,最常见的是 0/1 背包。每个物品只有选和不选两种状态,目标是在容量限制内获得最大价值。可以定义 dp[i][j] 表示只看前 i 个物品、背包容量为 j 时的最大价值。对于第 i 个物品,如果不选它,状态来自 dp[i - 1][j];如果选它,前提是容量足够,状态来自 dp[i - 1][j - weight[i - 1]] + value[i - 1]。因此状态转移方程是两者取最大值。空间上可以从二维数组优化成一维数组,0/1 背包一维写法必须倒序遍历容量,防止同一个物品在同一轮被重复使用;完全背包则因为允许重复使用物品,所以容量要正序遍历。

最短路径 Dijkstra

Dijkstra 是什么? Dijkstra 是一种求最短路径的算法,常见问题是:

给你一张带权图。
给你一个起点 source。
求 source 到其他所有点的最短距离。

比如地图导航里:

点:城市 / 路口
边:道路
权重:距离 / 时间 / 消耗

Dijkstra 可以求:

从起点到每个点的最短距离

dijkstra-shortest-path

Dijkstra 适合什么图?

它适合:

边权非负的图

也就是每条边的权重都要满足:

weight >= 0

比如:

c
A -> B 距离 5
B -> C 距离 3
A -> C 距离 10

这些都可以。

但如果有负权边:

c
A -> B 权重 -2

就不适合用 Dijkstra。 负权边一般考虑:

c
Bellman-Ford
SPFA

核心思想

Dijkstra 的想法可以理解成:

从起点开始。
每次找当前距离起点最近的点。
用这个点去更新它能到达的邻居。
重复这个过程。

它维护一个数组:

c
dist[i]

表示:

从起点到 i 号点的当前最短距离。

一开始:

c
dist[source] = 0
其他 dist = INF

INF 可以理解成:

暂时还不知道怎么到达这个点

什么是松弛 relax?

松弛是 Dijkstra 里最重要的动作。

假设现在已经知道:

c
dist[u]

并且有一条边:

c
u -> v,权重是 w

那么我们可以尝试通过 u 去到 v

c
newDistance = dist[u] + w

如果:

c
newDistance < dist[v]

说明找到了一条更短的路,于是更新:

c
dist[v] = newDistance

这个过程就叫:

c
松弛 relax

为什么要用优先队列?

因为 Dijkstra 每次都要找:

当前 dist 最小的点

如果每次都遍历所有点找最小值,会比较慢。

所以常用:

优先队列 / 小根堆

它可以快速拿到当前距离最小的点。

C++ 里 priority_queue 默认是大根堆,所以要写成小根堆。

C++ 代码

下面代码默认是有向图。 如果是无向图,就把反向边也加进去。

c
#include <vector> // 引入 vector 容器,用来保存图、边和距离数组
#include <queue> // 引入 queue 头文件,用来使用 priority_queue 优先队列
#include <utility> // 引入 utility 头文件,用来使用 pair
#include <functional> // 引入 functional 头文件,用来使用 greater 比较器
using namespace std; // 使用标准命名空间,避免每次写 std::
class Solution // 定义一个 Solution 类,方便面试或 LeetCode 风格调用
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    vector<int> dijkstra(int n, vector<vector<int>>& edges, int source) // 定义 Dijkstra 函数,n 是点数,edges 是边,source 是起点
    { // dijkstra 函数开始
        vector<vector<pair<int, int>>> graph(n); // 创建邻接表,graph[u] 里面保存 u 能到达的点和边权
        for (vector<int>& edge : edges) // 遍历每一条边
        { // for 循环开始
            int from = edge[0]; // 取出边的起点
            int to = edge[1]; // 取出边的终点
            int weight = edge[2]; // 取出边的权重
            graph[from].push_back({to, weight}); // 加入一条有向边 from -> to,权重是 weight
        } // for 循环结束
        const int INF = 1000000000; // 定义一个很大的数,表示暂时不可达
        vector<int> dist(n, INF); // 创建距离数组,初始都设置为 INF
        dist[source] = 0; // 起点到自己的距离是 0
        priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq; // 创建小根堆,里面保存 {当前距离, 当前点}
        pq.push({0, source}); // 把起点加入优先队列
        while (!pq.empty()) // 只要优先队列不为空,就继续处理
        { // while 循环开始
            int currentDistance = pq.top().first; // 取出当前队列里最小的距离
            int currentNode = pq.top().second; // 取出当前距离对应的点
            pq.pop(); // 弹出队首元素
            if (currentDistance > dist[currentNode]) // 如果这个距离已经不是最新的最短距离
            { // if 语句开始
                continue; // 说明这是旧数据,直接跳过
            } // if 语句结束
            for (pair<int, int>& next : graph[currentNode]) // 遍历当前点能到达的所有邻居
            { // for 循环开始
                int nextNode = next.first; // 取出邻居节点编号
                int weight = next.second; // 取出当前边的权重
                int newDistance = dist[currentNode] + weight; // 计算从起点经过 currentNode 到 nextNode 的新距离
                if (newDistance < dist[nextNode]) // 如果新距离比原来的 dist[nextNode] 更短
                { // if 语句开始
                    dist[nextNode] = newDistance; // 更新 nextNode 的最短距离
                    pq.push({newDistance, nextNode}); // 把更新后的距离和节点加入优先队列
                } // if 语句结束
            } // for 循环结束
        } // while 循环结束
        return dist; // 返回起点到所有点的最短距离
    } // dijkstra 函数结束
}; // Solution 类结束

无向图怎么改?

如果题目是无向图,一条边:

c
u - v

表示:

c
u 可以到 v
v 也可以到 u

建图时要加两次:

c
graph[from].push_back({to, weight}); // 加入 from 到 to 的边
graph[to].push_back({from, weight}); // 加入 to 到 from 的边,因为无向图可以反过来走

为什么要跳过旧数据?

代码里有这一段:

c
if (currentDistance > dist[currentNode]) // 如果这个距离已经不是最新的最短距离
{ // if 语句开始
    continue; // 说明这是旧数据,直接跳过
} // if 语句结束

原因是:

c
priority_queue 里可能存在同一个点的多个距离版本。

比如一开始发现:

c
A 到 C = 10

后来又发现:

c
A 到 B 到 C = 3

那么队列里可能同时有:

c
(10, C)
(3, C)

显然 (3, C) 才是新的最短距离。 当以后弹出 (10, C) 时,它已经过期了,直接跳过。

为什么 Dijkstra 不能处理负权边?

Dijkstra 有一个核心前提:

每次从优先队列里取出的最小距离节点,它的最短距离就已经确定了。

这个结论依赖于:

所有边权都是非负数。

如果有负权边,可能会出现:

某个点已经被认为最短了
但之后通过一条负权边,又找到更短的路

这样 Dijkstra 的逻辑就被破坏了。

和 BFS 的区别

BFS 也能求最短路径,但 BFS 适合:

无权图
或者所有边权都一样的图

因为 BFS 默认每走一条边,代价都相同。

Dijkstra 适合:

有权图,并且边权非负

比如:

A -> B 权重 2
A -> C 权重 10
B -> C 权重 1

这种情况下,不能只看经过几条边少,还要看权重总和。

复杂度

如果使用:

邻接表 + 优先队列

时间复杂度通常是:

c
O((V + E) log V)

其中:

V 是点的数量
E 是边的数量

空间复杂度是:

c
O(V + E)

因为要保存图、距离数组和优先队列。

面试高分回答

IMPORTANT

Dijkstra 是用来求非负权图单源最短路径的算法。它维护一个 dist 数组,dist[i] 表示从起点到节点 i 的当前最短距离,初始时起点为 0,其他点为无穷大。算法每次通过小根堆取出当前距离最小的节点,然后用这个节点去松弛它的所有邻居。如果 dist[u] + weight < dist[v],说明经过 u 到达 v 更短,就更新 dist[v] 并把新状态加入优先队列。由于优先队列中可能存在旧距离,所以弹出时要判断当前距离是否已经过期。Dijkstra 的前提是边权非负,因为它依赖“当前弹出的最小距离节点已经确定最短”的性质。邻接表加优先队列实现时,时间复杂度通常是 O((V + E) log V)

A* 寻路基础

A* 寻路是什么? A* 是游戏开发里非常常见的寻路算法,通常用来解决:

从起点走到终点,怎样走总代价最小?

比如:

角色从当前位置走到目标点
怪物追踪玩家
战棋角色在格子地图中移动
RTS 单位绕开障碍物

A* 可以理解成:

Dijkstra + 方向感

Dijkstra 会稳稳地向四周扩散。 A* 会更倾向于朝终点方向搜索,所以很多场景下更快。

astar-pathfinding-basics

A* 的三个核心值

A* 里每个节点都会计算三个值:

g:从起点走到当前点,已经花了多少代价
h:从当前点走到终点,预计还要花多少代价
f:当前点的总评分

公式是:

f = g + h

意思是:

已经走过的代价 + 预计还要走的代价 = 这条路看起来的总代价

A* 每次优先选择:

f 最小的节点

因为它看起来最有希望到达终点。

g、h、f 举例

假设角色从 S 走到某个格子 A

S -> A 已经走了 5 格

那么:

g = 5

如果从 A 到终点 G,用估算方式觉得还要走 7 格:

h = 7

那么:

c
f = g + h = 5 + 7 = 12

A* 会比较不同格子的 f,优先扩展更小的。

Open List 和 Closed Set

A* 通常维护两个集合。

Open List

待检查的节点

可以理解成:

候选路线

每次从里面取出 f 最小的节点。

Closed Set

已经检查过的节点

可以理解成:

不用重复处理的节点

一个常见流程是:

1. 把起点加入 Open List
2. 每次取出 f 最小的点 current
3. 如果 current 是终点,寻路结束
4. 否则检查 current 的邻居
5. 如果邻居能走,并且找到更短路径,就更新它
6. 把 current 放入 Closed Set

什么是启发函数 h?

h 是 A* 的“预估距离”。

在四方向网格里,也就是只能:

上、下、左、右移动

常用:

曼哈顿距离

公式:

c
h = abs(x1 - x2) + abs(y1 - y2)

比如当前点是:

c
(1, 2)

终点是:

c
(5, 4)

那么:

c
h = abs(1 - 5) + abs(2 - 4)
h = 4 + 2
h = 6

如果是八方向移动,也就是可以斜着走,常用:

对角线距离
欧几里得距离

为什么 h 不能乱写?

如果 h 估得太大,A* 可能会错过真正的最短路径。

理想情况下,h 应该是:

乐观估计

也就是:

估计值不要超过真实最短距离

比如真实最短还要走 10,你的 h 最好不要估成 20。 否则算法可能被误导。

面试里可以说:

如果启发函数不高估真实代价,A* 可以保证找到最短路径。

A* 和 Dijkstra 的区别

Dijkstra:

只看 g

也就是:

谁离起点最近,就先处理谁

A*:

看 g + h

也就是:

既看已经走了多远,也看离终点大概还有多远

如果:

c
h = 0

那么:

A* 就退化成 Dijkstra

所以可以记:

Dijkstra 是没有方向感的 A*
A* 是加了启发函数的 Dijkstra

C++ 网格版 A* 代码

下面是一个基础版,适合面试讲思路。 设定:

c
0 表示可走
1 表示障碍
只能上下左右移动
每走一格代价为 1
#include <vector> // 引入 vector 容器,用来保存地图、距离和父节点
#include <queue> // 引入 queue 头文件,用来使用 priority_queue 优先队列
#include <cmath> // 引入 cmath 头文件,用来使用 abs 函数
#include <algorithm> // 引入 algorithm 头文件,用来使用 reverse 函数
using namespace std; // 使用标准命名空间,避免每次写 std::
struct Node // 定义节点结构体,用来放进优先队列
{ // Node 结构体开始
    int x; // 当前节点的行坐标
    int y; // 当前节点的列坐标
    int g; // 从起点走到当前节点的真实代价
    int h; // 从当前节点到终点的预估代价
    int f; // 当前节点的综合评分,f = g + h
}; // Node 结构体结束
struct Compare // 定义比较器,让 priority_queue 变成按 f 较小优先
{ // Compare 结构体开始
    bool operator()(const Node& a, const Node& b) // 重载括号运算符,用来比较两个节点
    { // operator 函数开始
        return a.f > b.f; // f 更小的节点优先级更高,所以这里写大于号形成小根堆效果
    } // operator 函数结束
}; // Compare 结构体结束
class Solution // 定义一个 Solution 类,方便面试或项目中调用
{ // Solution 类开始
public: // public 表示下面的函数可以被外部调用
    vector<pair<int, int>> astar(vector<vector<int>>& grid, pair<int, int> start, pair<int, int> goal) // 定义 A* 函数,输入地图、起点和终点
    { // astar 函数开始
        int rows = grid.size(); // 获取地图行数
        int cols = grid[0].size(); // 获取地图列数
        const int INF = 1000000000; // 定义一个很大的数,表示暂时不可达
        vector<vector<int>> gScore(rows, vector<int>(cols, INF)); // gScore[x][y] 表示从起点到这个格子的当前最小真实代价
        vector<vector<bool>> closed(rows, vector<bool>(cols, false)); // closed[x][y] 表示这个格子是否已经处理过
        vector<vector<pair<int, int>>> parent(rows, vector<pair<int, int>>(cols, {-1, -1})); // parent 用来记录路径从哪里来
        priority_queue<Node, vector<Node>, Compare> open; // 创建 Open List,小根堆按 f 从小到大弹出
        gScore[start.first][start.second] = 0; // 起点到起点的真实代价是 0
        int startH = heuristic(start.first, start.second, goal.first, goal.second); // 计算起点到终点的预估代价
        open.push({start.first, start.second, 0, startH, startH}); // 把起点加入 Open List
        vector<int> dx = {-1, 1, 0, 0}; // 定义四方向移动的行变化:上、下、左、右
        vector<int> dy = {0, 0, -1, 1}; // 定义四方向移动的列变化:上、下、左、右
        while (!open.empty()) // 只要 Open List 里还有待检查节点
        { // while 循环开始
            Node current = open.top(); // 取出 f 最小的节点
            open.pop(); // 从 Open List 中弹出这个节点
            if (closed[current.x][current.y]) // 如果这个节点已经处理过
            { // if 语句开始
                continue; // 跳过旧节点,避免重复处理
            } // if 语句结束
            closed[current.x][current.y] = true; // 标记当前节点已经处理过
            if (current.x == goal.first && current.y == goal.second) // 如果当前节点就是终点
            { // if 语句开始
                return buildPath(parent, start, goal); // 根据 parent 反向还原路径并返回
            } // if 语句结束
            for (int dir = 0; dir < 4; dir++) // 遍历四个移动方向
            { // for 循环开始
                int nx = current.x + dx[dir]; // 计算邻居节点的行坐标
                int ny = current.y + dy[dir]; // 计算邻居节点的列坐标
                if (!inBounds(nx, ny, rows, cols)) // 如果邻居节点越界
                { // if 语句开始
                    continue; // 越界格子不能走,直接跳过
                } // if 语句结束
                if (grid[nx][ny] == 1) // 如果邻居节点是障碍
                { // if 语句开始
                    continue; // 障碍格子不能走,直接跳过
                } // if 语句结束
                if (closed[nx][ny]) // 如果邻居节点已经处理过
                { // if 语句开始
                    continue; // 已处理节点不再重复处理
                } // if 语句结束
                int newG = gScore[current.x][current.y] + 1; // 计算从当前节点走到邻居节点的新 g 值
                if (newG < gScore[nx][ny]) // 如果这条路径比之前记录的路径更短
                { // if 语句开始
                    gScore[nx][ny] = newG; // 更新邻居节点的最小真实代价
                    parent[nx][ny] = {current.x, current.y}; // 记录邻居节点是从当前节点走过来的
                    int h = heuristic(nx, ny, goal.first, goal.second); // 计算邻居节点到终点的预估代价
                    int f = newG + h; // 计算邻居节点的综合评分
                    open.push({nx, ny, newG, h, f}); // 把邻居节点加入 Open List,等待后续处理
                } // if 语句结束
            } // for 循环结束
        } // while 循环结束
        return {}; // 如果 Open List 空了还没找到终点,说明不存在路径,返回空数组
    } // astar 函数结束
private: // private 表示下面的辅助函数只在类内部使用
    int heuristic(int x1, int y1, int x2, int y2) // 定义启发函数,计算两个格子的曼哈顿距离
    { // heuristic 函数开始
        return abs(x1 - x2) + abs(y1 - y2); // 返回四方向网格常用的曼哈顿距离
    } // heuristic 函数结束
    bool inBounds(int x, int y, int rows, int cols) // 定义边界判断函数
    { // inBounds 函数开始
        return x >= 0 && x < rows && y >= 0 && y < cols; // 判断坐标是否在地图范围内
    } // inBounds 函数结束
    vector<pair<int, int>> buildPath(vector<vector<pair<int, int>>>& parent, pair<int, int> start, pair<int, int> goal) // 定义路径还原函数
    { // buildPath 函数开始
        vector<pair<int, int>> path; // 创建 path 数组,用来保存最终路径
        pair<int, int> current = goal; // 从终点开始往父节点倒推
        while (current != make_pair(-1, -1)) // 只要当前节点不是无效节点
        { // while 循环开始
            path.push_back(current); // 把当前节点加入路径
            if (current == start) // 如果已经倒推到起点
            { // if 语句开始
                break; // 路径还原完成,退出循环
            } // if 语句结束
            current = parent[current.first][current.second]; // 沿着 parent 继续往前找
        } // while 循环结束
        reverse(path.begin(), path.end()); // 因为刚才是从终点往起点找,所以反转成起点到终点
        return path; // 返回最终路径
    } // buildPath 函数结束
}; // Solution 类结束

A* 的完整流程

可以按这个顺序记:

c
1. 起点加入 Open List
2. 从 Open List 取 f 最小的节点 current
3. 如果 current 是终点,路径找到
4. 把 current 放入 Closed Set
5. 遍历 current 的邻居
6. 跳过障碍、越界、已经关闭的节点
7. 计算 newG
8. 如果 newG 更小,就更新 g、h、f、parent
9. 把邻居加入 Open List
10. 重复直到找到终点或 Open List 为空

为什么要记录 parent?

A* 搜索时只是在找路。

但最后要返回路径,比如:

c
S -> A -> B -> C -> G

就必须知道:

每个节点是从哪个节点走过来的

所以每次发现更短路径时,要记录:

c
parent[neighbor] = current

找到终点后,从终点一路沿着 parent 倒推:

c
G -> C -> B -> A -> S

然后反转,就得到最终路径:

c
S -> A -> B -> C -> G

复杂度

如果用网格图,格子数量是:

c
V = rows * cols

边数量大约是:

c
E ≈ 4V

使用优先队列时:

c
时间复杂度:O(E log V)
空间复杂度:O(V)

在普通四方向网格中,也可以理解为:

c
时间复杂度:O(rows * cols * log(rows * cols))
空间复杂度:O(rows * cols)

面试高分回答

TIP

A* 是一种常用的启发式最短路径算法,适合游戏中的网格寻路或导航点寻路。它维护 ghf 三个值,其中 g 是从起点到当前点的真实代价,h 是当前点到终点的预估代价,f = g + h。算法每次从 Open List 中取出 f 最小的节点进行扩展,然后检查它的邻居,如果通过当前节点到达邻居的 g 更小,就更新邻居的 ghf 和父节点。Closed Set 用来避免重复处理已经确定的节点。最后找到终点后,通过 parent 从终点反向还原路径。A* 和 Dijkstra 的区别是多了启发函数 h,当 h = 0 时,A* 就退化成 Dijkstra。对于四方向网格,常用曼哈顿距离作为启发函数。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0