YifanChen's Blog

一个专注技术的新手程序员

0%

简介

都柏林,素有美食荒漠、欧洲宁古塔之称。然而,经过本人和好友长期约饭探店的实地考察之后,发现都柏林的中餐馆门类齐全、品种多样,除去价格稍贵外相比于国内并无显著的短板和缺憾。因此,本人维护本帖用于记录都柏林吃喝之旅,并为读者提供一份尽可能详细的都柏林美食推荐清单。

本人向来赞同孔夫子“食不厌精,脍不厌细”的名言,因此,性价比是这份排行榜中相对次要的因素,食物的调味、口感、精细和新颖程度是更重要的。本帖中的五星代表着这个饭店的食物有独道之处,甚至超过了国内同种食物之最。四星代表着这个饭店的食物在味道或性价比方面至少有一个非常出众,而另一个也至少在平均水平之上。三星代表着值得一试,但并不惊艳。果腹则代表着仅供日常果腹之用,胜在量大管饱。心愿清单则是我早有耳闻,但还未成行的餐馆。本帖中推荐的餐馆会附带有谷歌地图的位置信息和一句简短的点评。本帖中也会提到一些国内的餐馆,主要分布在南昌、西安和合肥,都是本人曾经久居过的城市。本帖中评分点评随性,主观色彩浓重,愿博看官一笑。

五星

  1. 四川
  2. 川九香

四星

三星

果腹

国内

心愿清单

  1. 日料Omakase
  2. 西餐Shanahan’s on the Green

菜谱

下面是我目前已经掌握的菜肴做法。

青椒炒牛肉

https://www.xiaohongshu.com/explore/64084cf10000000014024bf6?xsec_token=ABjk8kDPjbnjQBy5ninATf_t4OFqDsNTj2tvDoDR8YAew=&xsec_source=pc_user

素炒小青菜

https://www.xiaohongshu.com/explore/649eb823000000001203d1d0?xsec_token=ABhF-yZeYWU_KFyde9iuIRaP4osN7hN84uBTDHM4muOJY=&xsec_source=pc_user

酸汤水饺

https://www.xiaohongshu.com/explore/640626770000000013030f32?xsec_token=ABKMjy9V_2e41wBdiSonEoj8xIax3g9thkyzM_zYygvEo=&xsec_source=pc_user

烤鸡腿

https://www.xiaohongshu.com/explore/61d80f75000000000102f662?xsec_token=ABFeZ243mB41AtUkgopJjbtnfNUjXvXk5X0vREX-Cr9-o=&xsec_source=pc_user

清汤面

https://www.xiaohongshu.com/explore/64ef0d47000000001e031fc3?xsec_token=ABhdjuqwDUBOmK31gzU44aiEzUHgQx9krLx1OMg7WbYlQ=&xsec_source=pc_user

辣椒炒鸡丁

https://www.xiaohongshu.com/explore/642ef0440000000013003052?xsec_token=ABP-oNHr9xdj_dfJ3PFdEz90JgCfpOKhxwZCwXod7BaKM=&xsec_source=pc_user

耗油生菜

https://www.xiaohongshu.com/explore/6331a4890000000017019ee3?xsec_token=ABvHsXQ8990EWiCV0UivQ0VJJbi921rouqm8fD0HcXocg=&xsec_source=pc_user

蛋炒饭

https://www.xiaohongshu.com/explore/6539c724000000001f036536?xsec_token=AByuVdQE_Yu5WBwBdOxBbp1JqGxwtjXagF-Ct3aIRyTB4=&xsec_source=pc_user

炒米粉

https://www.xiaohongshu.com/explore/63d11d5e000000002203b147?xsec_token=AB6hQFjxlcFe1hFOtuDSpjFvIpDPYH0fIElCl-xqe0uk8=&xsec_source=pc_search

排骨汤

https://www.xiaohongshu.com/explore/63b295f3000000001c0349e2?xsec_token=ABx6mW_uBfj9nVHk20uySHX_8uZaNhS6D0mnuGF2iL0IY=&xsec_source=pc_search

https://www.xiaohongshu.com/explore/654d46ab000000003103d1b1?xsec_token=ABC1d6ftIfXeIjYWoQ6zTAFYDNRBJPT1MTjZYuH7pO92o=&xsec_source=pc_search

链接

332.重新安排行程
51. N皇后
37. 解数独
总结

知识

51. N皇后

初始化一个vector<string>,其中的元素全是字符.(共有n行,每行是一个字符串,每个字符串由n个.构成):

1
vector<string> chessboard(n, string(n, '.'));

实现

今天这三道题都非常难,那么这么难的题,为啥一天做三道?

因为一刷也不求能把这么难的问题解决,所以一刷的时候,就了解一下题目的要求,了解一下解题思路,不求能直接写出代码,先大概熟悉一下这些题,二刷的时候,随着对回溯算法的深入理解,再去解决如下三题。

332.重新安排行程

本题的思路不难,但选择的数据结构和初始化、遍历等操作非常复杂,是一道难题。

本题需要一个特殊的数据结构来存储一个机场映射多个机场,机场之间要靠字母序排列的这种复杂关系,选择的数据结构是unordered_map<string, map<string, int>> targets。其具体的含义为unordered_map<出发机场, map<到达机场, 航班次数>> targets。在遍历 unordered_map<出发机场, map<到达机场, 航班次数>> targets的过程中,可以使用”航班次数”这个字段的数字做相应的增减,来标记到达机场是否使用过了。如果“航班次数”大于零,说明目的地还可以飞,如果“航班次数”等于零说明目的地不能飞了,而不用对集合做删除元素或者增加元素的操作。有如下代码:

1
2
3
4
// unordered_map<出发机场, map<到达机场, 航班次数>> targets
// 使用map的原因是为了让其key有序(字典序)
// 第一个unordered_map是为了存储出发机场和到达机场间的映射关系,第二个map是为了对到达机场按照字典序排序,且记录到达机场在输入数据中出现的次数
unordered_map<string, map<string, int>> targets;

本题的树形结构如下所示,以输入:[["JFK", "KUL"], ["JFK", "NRT"], ["NRT", "JFK"]为例:
332.重新安排行程1

对上述树形结构的解释:始终从JFK出发,输入中JFK可以到KUL或者NRT,因此可以有这两个选择。输入中没有以KUL作为出发点的航班,因此飞向KUL的那一枝结束。飞向NRT的一枝,输入中以NRT为出发点的航班的终点是JFK,因此有行程:JFK->NRT->JFK。输入中以JFK为出发点的航班的终点可以是KUL或者NRT,因此分为两枝。JKF已经飞过NRT,因此剪枝;JKFKUL构成了行程:JFK->NRT->JFK->KUL,三趟航班,形成中有四个机场,说明找到了结果。

通过上述分析,我们可以得出代码的终止条件:n张机票,即有n个航班,则行程中有n + 1个机场(机场可重复)时,收集结果。原因是行程是由若干个向量组成的,每个向量都是一个航班,行程是单向的,不会形成环。因此,若有n个向量(即n个航班),那么就会有n + 1个节点(即单个向量的首尾),即n + 1个机场。有如下代码:

1
2
3
4
5
6
vector<string> res; // 存放结果,即由n个航班拼接成的行程,其中有n + 1个机场

// ticketNum为票数,即航班数
if (result.size() == ticketNum + 1) {
return true;
}

在写单层递归逻辑前,需要先对res数组和targets数组进行初始化,代码如下所示:

1
2
3
4
for (const vector<string>& vec : tickets) {
targets[vec[0]][vec[1]]++; // 记录映射关系
}
result.push_back("JFK"); // 起始机场

tickets数组是输入,其类型是vector<vector<string>>。由于输入不可更改,且其中的每个元素的类型都是vector<string>,因此用类型为const vector<string>的变量对其进行遍历,这里的引用就不加都可以,不会影响运行结果。vec[0]为出发机场,vec[1]为到达机场。targets中存储的是出发机场与到达机场的映射关系。对一个出发机场,若输入中存在其的到达机场,则在targets中记录这个映射关系,且map<string, int>中的string存储到达机场(vec[1]),int存储次数(有出发机场和其对应的到达机场,则该int存1)。这实现了对每一个航线(从某个出发机场到某个目的地机场)的航班次数进行计数。

根据树形结构,可以写出单层递归的逻辑:

1
2
3
4
5
6
7
8
9
for (pair<const string, int>& target : targets[result[result.size() - 1]]) {
if (target.second > 0 ) { // 记录到达机场是否飞过了
result.push_back(target.first);
target.second--;
if (backtracking(ticketNum, result)) return true;
result.pop_back();
target.second++;
}
}

这里特别需要注意的是:

1
for (pair<const string, int>& target : targets[result[result.size() - 1]])

其含义为:result[result.size() - 1] 获取 result 向量的最后一个元素,即当前路径中最新的机场。然后,使用这个机场名称作为键,从 targets 映射中检索对应的内层 map,这个内层 map 包含所有从该机场出发的航班及其次数。for 循环遍历这个内层 map,即遍历从当前结果集中的最新机场可以直接到达的所有机场及对应的航班次数。一定要加上引用即 & target,因为后面有对 target.second 做减减操作,如果没有引用,单纯复制,这个结果就没记录下来,那最后的结果就不对了。加上引用之后,就必须在string前面加上const,因为map中的key是不可修改了,这就是语法规定了。

还需要注意本题的递归函数的返回值和参数:

1
bool backtracking(int ticketNum, vector<string>& result)

注意函数返回值用的是bool!因为我们只需要找到一个行程,就是在树形结构中唯一的一条通向叶子节点的路线。所以找到了这个叶子节点了直接返回。

拆分地写好了各部分的代码之后,整合起来就是本题的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public:
unordered_map<string, map<string, int>> targets;

bool backtracking(int ticketSum, vector<string>& res)
{
// 终止条件
if (ticketSum + 1 == res.size()) return true;

// 单层递归逻辑
// 以res中最新机场为出发点,遍历targets寻找可能的目的地
for (pair<const string, int>& target: targets[res[res.size() - 1]])
{
// target.second > 0说明目的地可用
if (target.second > 0)
{
// 处理节点
res.push_back(target.first);
target.second -- ;
// 递归
if (backtracking(ticketSum, res)) return true;
// 回溯
res.pop_back();
target.second ++ ;
}
}
return false;
}

vector<string> findItinerary(vector<vector<string>>& tickets) {
// 初始化res
vector<string> res;
res.push_back("JFK");

// 初始化targets
for (auto vec: tickets)
targets[vec[0]][vec[1]] ++ ;

// 调用递归函数并返回结果
backtracking(tickets.size(), res);
return res;
}
};

使用auto来简化上述代码,避免需要手写复杂的变量类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public:
// 提取输入数据中的信息:构造出发机场和到达机场间的映射,到达机场按照字典序排序并记录到达机场出现的次数
unordered_map<string, map<string, int>> targets;

bool backtracking(int ticketSum, vector<string>& res)
{
// 终止条件
if (ticketSum + 1 == res.size()) return true;

// 单层递归逻辑
for (auto& target: targets[res[res.size() - 1]])
{
if (target.second > 0)
{
target.second -- ;
res.push_back(target.first);
if (backtracking(ticketSum, res)) return true;
target.second ++ ;
res.pop_back();
}
}
return false;
}

vector<string> findItinerary(vector<vector<string>>& tickets) {
// 初始化res
vector<string> res;
res.push_back("JFK");
// 初始化targets
for (auto ticket: tickets) targets[ticket[0]][ticket[1]] ++ ;
backtracking(tickets.size(), res);
return res;
}
};

51. N皇后

本题是回溯中较难的题目。题意:给一个n*n的棋盘,在其中放n个皇后,要求同一行、同一列、同意斜线上不能有两个皇后,将放置皇后的结果返回。难点:之前讲的组合问题、分割问题、子集问题和排列问题,都是一个一维的集合按照条件输出若个子集,本题则是一个二维的集合(数组)。

首先想如何暴力枚举,以4*4的棋盘为例,需要4个for循环,每一行每个位置尝试放皇后,根据规则判断能否放皇后。回溯算法的本质和暴力枚举没有区别,但回溯算法用递归的方式控制嵌套for循环的层数。

本题的树形结构如下所示,以3*3的棋盘为例:
51.N皇后

第n层递归对应着尝试在第n行中放置皇后。3*3的N皇后问题是无解的,画其只是为了展现其树形结构。树的深度为棋盘的行数,树的宽度为棋盘的列数。接下来写本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 每个棋盘是一个二维数组,放置若干棋盘,因此需要三维数组
vector<vector<vector<int>>> res; // 三维数组收集所有可能的摆放结果

// chessboard为棋盘,n为棋盘的大小, row为行,第n层递归负责处理第n行,用row来记录当前处理到了第几行
void backtracking(vector<vector<int>> chessboard, int n, int row)
{
// 终止条件: 叶子节点收获结果
if (row == n)
{
res.push_back(chessboard); // 单层递归逻辑中会对合法性进行判断,保证放入res中的chessboard都是合法的
return;
}

// 单层递归逻辑
for (int i = 0; i < n; i ++ )
{
// 合法性判断
// 判断在第row行,第i个位置放皇后是否合法
if (isValid(row, i, chessboard, n))
{
// 放皇后
chessboard[row][i] = 'Q';
// 递归
backtracking(chessboard, n, row + 1); // 下一层递归, row->row + 1
// 回溯
chessboard[row][i] = '.';
}
}
}

总结:回溯法解决二维数组问题,第n层递归处理第n行,每层递归中遍历每一行中的每个元素。

在理解了本题的主题思路后,独立写出代码依然有难度,因为本题返回的变量类型是vector<vector<string>,chessboard的变量类型应该为vector<string>,对其进行初始化有一定难度。另外,isValid函数的实现我第一次写也有一定的困难,直接看文字版讲解。

我独立写出的本题的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Solution {
public:
// 结果集
vector<vector<string>> res;

bool isValid(vector<string>& chessboard, int n, int row, int col)
{
// 同一列中不能有两个皇后
for (int i = 0; i < row; i ++ )
if (chessboard[i][col] == 'Q') return false;

// 主对角线不能有两个皇后
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i -- , j -- )
if (chessboard[i][j] == 'Q') return false;

// 副对角线不能有两个皇后
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i -- , j ++ )
if (chessboard[i][j] == 'Q') return false;

return true;
}

// 传入棋盘,棋盘大小,行数(即递归层数)
void backtracking(vector<string>& chessboard, int n, int row)
{
// 终止条件
if (row == n)
{
res.push_back(chessboard);
return;
}

// 单层递归逻辑
for (int i = 0; i < n; i ++ )
{
// 合法性判断
if (isValid(chessboard, n, row, i))
{
// 放皇后
chessboard[row][i] = 'Q';
// 递归
backtracking(chessboard, n, row + 1);
// 回溯
chessboard[row][i] = '.';
}
}
}

vector<vector<string>> solveNQueens(int n) {
vector<string> chessboard(n, string(n, '.'));
backtracking(chessboard, n, 0);
return res;
}
};

需要特别注意的是,在放皇后时,每次只在一行中的某个位置放一个皇后,且放完后会回溯,因此同一行中不会出现两个皇后,因此不需要在isValid函数中对同一行中出现两个皇后的情况进行判断。另外,放皇后的顺序是从行数小放到行数大,从列数小放到列数大。在不同行中,行数小的位置会被优先尝试放置皇后。在同一行的不同列中,列数小的位置会被优先尝试放置皇后。因此,isValid函数中对同一列判断,只需要判断从0-row列;对主对角线判断,只需要判断i从row-1到0,j从col-1到0;对副对角线判断,只需要判断i从row - 1到0,j从col + 1到n(由于优先放小的行,所以i < row, j > col的位置可能已经放置了皇后)。

37. 解数独

给一个99的棋盘,用1-9填满这个棋盘,规则为:同一行中不能有重复的数字,同一列中不能有重复的数字,九宫格中不能有重复的数字。本题求出一个填满的方案即可。本题是回溯章节的难题,和上一题N皇后类似。但用N皇后的思路做本题做不出来。*本题比N皇后多一个维度。N皇后用for循环遍历行,递归遍历列。本题不仅行要放满数字,列也要放满数字,整个棋盘都要放满数字。

本题解法被称为二维递归,即两个for循环,下面是一个递归函数。一个for循环来遍历行,一个for循环来遍历列,这样才能确定一个空格。递归用来确定空格中应该放的数字。本题的树形结构如下所示:
37.解数独

现在开始写本题的代码,本题的代码其实并不复杂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 确定递归函数的返回值和参数
// 返回值为bool类型,原因是本题求数独的一个解即可,一旦棋盘被填满,立即返回
// 若一题有多个结果,多个结果散落在树形结构里,需要将树形结构全都搜索一遍,然后将结果放入结果集中,因此返回值为void类型
bool backtracking(vector<vector<char>>& board) // board要引用,这样才会修改board
{
// 本题不需要写终止条件,棋盘被填满后会直接return true,若无法满足填充规则,则会return false
// 两个for循环,一个遍历行,另一个遍历列
for (int i = 0; i < board.size(); i ++ )
for (int j = 0; j < board[0].size(); j ++ )
{
// 处理节点
// 棋盘中的点表示空格
if (board[i][j] == '.')
{
for (char k = '1'; k <= '9'; k ++ )
{
// 检查在board的(i, j)处放置数字k是否合法
if (isValid(i, j, k, board))
{
board[i][j] = k;
// 进入下一层递归
bool res = backtracking(board);
// 找到一个结果就立即停止搜索,返回
if (res == true) return true;
// 回溯
board[i][j] = '.';
}
}
return false; // 在空格处将9个数字都尝试了,无法填入,则说明该棋盘没有结果,返回false
}
}
// 若没有走到return false,则return true(若棋盘一直被填充直到被填满,则不会走if (board[i][j] == '.'))
return true;
}

根据上面的核心代码,我自己实现了isValid函数,写出了本题的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class Solution {
public:
bool isValid(int i, int j, char k, vector<vector<char>>& board)
{
// 检查第i行能否放入k
for (int j = 0; j < board[0].size(); j ++ )
if (board[i][j] == k)
return false;

// 检查第j列能否放入k
for (int i = 0; i < board.size(); i ++ )
if (board[i][j] == k)
return false;

// 检查九宫格能否放入k
int starti = i / 3 * 3;
int startj = j / 3 * 3;
int endi = starti + 2;
int endj = startj + 2;
for (int i = starti; i <= endi; i ++ )
for (int j = startj; j <= endj; j ++ )
if (board[i][j] == k)
return false;

return true;
}

bool backtracking(vector<vector<char>>& board)
{
for (int i = 0; i < board.size(); i ++ )
for (int j = 0; j < board[0].size(); j ++ )
{
if (board[i][j] == '.')
{
// 处理节点
for (char k = '1'; k <= '9'; k ++ )
{
if (isValid(i, j, k, board))
{
board[i][j] = k;
// 递归
bool res = backtracking(board);
if (res == true) return true;
// 回溯
board[i][j] = '.';
}
}
return false;
}
}
return true;
}

void solveSudoku(vector<vector<char>>& board) {
backtracking(board);
}
};

心得与备忘录

332.重新安排行程

  1. 本题的解题思路其实不难,画出树形结构后,是一道经典的回溯问题。本题的难点在于选用怎样的数据结构来有效地存放和处理输入的数据。
  2. 因为本题是hard题,且由于使用了较为复杂的数据结构,因此我觉得在hard中都算是难题,显著比N皇后困难。因此,第一遍学习本题,了解本题的思路和核心代码即可,不要求能够将本题完整地写出来。
  3. 本题的实现部分对代码进行了细致的拆分讲解,大致可以分为以下几个要点:

    • 用怎样的数据结构存储输入数据中出发机场和到达机场间的映射关系,且要求同一个出发机场的到达机场按照字典序排列,同时记录到达机场的使用次数
    • 如何对结果集和上面的数据结构进行初始化
    • 根据树形结构写出终止条件和单层搜索逻辑,并确定递归函数的返回值和传入的参数。本题递归函数的返回值是罕见的bool类型,而非void类型

    明确上述三个问题,即可理解本题的思路和实现细节,进而顺畅地写出本题的代码。

  4. 在初始化targets时,范围遍历可以直接采用auto类型的变量,避免需要手写复杂的变量类型。但在单层递归逻辑中遍历targets时,不能直接采用auto,因为for循环中涉及到了对遍历的值的修改操作,因此一定要使用引用,可以使用auto&

51. N皇后

  1. 本题的新奇之处在于:之前用回溯法解决过组合、切割、子集、排列问题,处理的对象都是一维数组,N皇后问题处理的对象却是二维数组

  2. 本题的原理实际上非常简单,理解本题的树形结构即可:

    51.N皇后

  3. 由上述树形结构可知,树的宽度是棋盘的列数,树的深度是棋盘的行数。据此,可以轻松地写出backtracking函数的终止条件和单层递归逻辑。

  4. 对棋盘合法性的判断其实是比较容易写错的。按照以下标准验证棋盘是否合法,两皇后:

    • 不能同行

    • 不能同列

    • 不能同斜线 (主对角线和副对角线)

    isValid函数中,不能同行的判断不需要写。因为在单层搜索的过程中,每一层递归,只会选for循环(也就是同一行)里的一个元素,且后序还会回溯释放掉这个元素。因此只需要写不能同列、不能同主对角线、不能同副对角线的判断。这三个判断的书写依据如下图所示。

    n_queen_revised_2.png

    当我们尝试在(row, col)处放置皇后时,只有绿色部分可能在之前被放置过皇后。原因是:递归到当前层,只有行数<row的格点上可能被放置过皇后。根据三条黄线,可以方便地写出三个判断。

  5. 本题的时间复杂度O(n!),空间复杂度O(n)。

    • 时间复杂度:由于回溯法的本质依然是暴搜,因此,在棋盘的第一行,有n种放置皇后的方式;第二行最多有n - 1种,依次类推,得到时间复杂度为O(n!)。
    • 空间复杂度即为树的深度,即为棋盘的行数,故空间复杂度为O(n)。

37. 解数独

  1. 和之前的递归题目不同,本题的递归是二维递归。一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性。
  2. 本题递归函数的函数返回值类型为bool类型。原因是本题只需要找到一个解,就立即返回。如果需要搜索整棵二叉树,找到所有的解,然后将结果记录在结果集中,那么递归函数的返回值类型为void
  3. 本题不需要写终止条件。因为在递归逻辑中,如果找到了满足条件的解,就会直接return true。如果某个空格中无论填入哪个数字都无法满足条件,就会直接return false
  4. 注意return truereturn false的位置。前者放在递归函数末尾,意思是若棋盘一直被填充直到被填满,则不会走if (board[i][j] == '.'),就return true。后者放在for (char k = '1'; k <= '9'; k ++ )的结束之处,意思是在空格处将9个数字都尝试了,无法填入,则说明该棋盘没有结果,return false
  5. 时间复杂度:O(9^m) , m是’.’的数目。空间复杂度:$O(n^2)$,原因是递归的深度是n^2(原因是第一层for循环代表树的宽度,后面两层for循环代表了树的深度。由于本题中数独的长宽固定为9,因此本题中的n = 9)。
  6. 回溯的题目到此结束,总体来说比较简单,有统一的模板,但每个题目又有一些需要注意的小细节。

链接

491.递增子序列
46.全排列
47.全排列 II

知识

491.递增子序列

46.全排列

47.全排列 II

初次尝试

491.递增子序列

本题的关键:递增、至少两个元素、去重。但本题存在一个很大的问题,就是去重的时候不能对原数组进行排序,否则会打乱原数组的顺序,以以下测试样例为例:

1
2
Input: nums = [4,4,3,2,1]
Output: [[4,4]]

一旦顺序被打乱,实际输出和理论输出就会不同,会多出很多递增的子序列。

本题和90.子集II非常像,但又很不一样,很容易掉坑里。直接看卡尔的讲解吧。

46.全排列

本题是排列问题,不需要startIndex,但我写不出代码,直接看卡尔的讲解。经过卡尔的提示用used数组避免重复取元素后,我独立写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, vector<int>& used)
{
// 终止条件
if (path.size() == nums.size())
{
res.push_back(path);
return;
}

// 单层递归逻辑
for (int i = 0; i < nums.size(); i ++ )
{
if (used[i] == 1) continue;
// 处理节点
path.push_back(nums[i]);
used[i] = 1;
// 递归
backtracking(nums, used);
// 回溯
path.pop_back();
used[i] = 0;
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<int> used(nums.size(), 0);
backtracking(nums, used);
return res;
}
};

47.全排列 II

本题中的数组中会有重复元素,因此本题需要去重逻辑。本题相当于40.组合总和II去重逻辑和46.全排列的结合。我先尝试用set去重。我独立写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, vector<int>& used)
{
// 终止条件
if (path.size() == nums.size())
{
res.push_back(path);
return;
}

unordered_set<int> uset;
for (int i = 0; i < nums.size(); i ++ )
{
if (uset.find(nums[i]) != uset.end() || used[i] == 1) continue;
// 处理节点
uset.insert(nums[i]);
used[i] = 1;
path.push_back(nums[i]);
// 递归
backtracking(nums, used);
// 回溯
used[i] = 0;
path.pop_back();
}
}

vector<vector<int>> permuteUnique(vector<int>& nums) {
vector<int> used(nums.size(), 0);
backtracking(nums, used);
return res;
}
};

再接着尝试写出used数组去重的代码。我独立写出了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, vector<int>& used)
{
// 终止条件
if (path.size() == nums.size())
{
res.push_back(path);
return;
}

for (int i = 0; i < nums.size(); i ++ )
{
if (used[i] == 1 || i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;
// 处理节点
used[i] = 1;
path.push_back(nums[i]);
// 递归
backtracking(nums, used);
// 回溯
used[i] = 0;
path.pop_back();
}
}

vector<vector<int>> permuteUnique(vector<int>& nums) {
vector<int> used(nums.size(), 0);
sort(nums.begin(), nums.end());
backtracking(nums, used);
return res;
}
};

由于本题中nums[i]的数据范围在-10-10之间,所以可以不用set去重,而是用数组去重(将数据范围-10-10映射到数组下标范围0-20),这样效率更高,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, vector<int>& used)
{
// 终止条件
if (path.size() == nums.size())
{
res.push_back(path);
return;
}

// 数组去重
int hash[21] = {0};

for (int i = 0; i < nums.size(); i ++ )
{
if (used[i] == 1 || hash[nums[i] + 10]) continue;
// 处理节点
hash[nums[i] + 10] = 1;
used[i] = 1;
path.push_back(nums[i]);
// 递归
backtracking(nums, used);
// 回溯
used[i] = 0;
path.pop_back();
}
}

vector<vector<int>> permuteUnique(vector<int>& nums) {
vector<int> used(nums.size(), 0);
backtracking(nums, used);
return res;
}
};

实现

491.递增子序列

列出递增子序列,子序列元素数量大于等于2。有以下测试样例:

1
2
Input: [4, 7, 6, 7]
Output: [4, 7, 7], [4, 7], [4, 6], [4, 6, 7], [6, 7], [7, 7]

要求不能有重复的子序列,因此需要去重。本题和90.子集II,只不过要求元素有顺序,且元素个数大于等于2。实际上,本题的细节和90有很大区别本题的去重思路不可以沿用先排序再去重的做法,因为会改变原数组中元素的顺序,导致递增子序列的改变。例如对上述测试样例排序后,递增子序列会包括[4, 6, 7, 7],实际上原本的输出不包含[4, 6, 7, 7]

所有的回溯算法都是深搜,所有的深搜都可以说是递归。画本题的树形结构:
491. 递增子序列1

去重为树层去重。现在开始写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
vector<int> path; // 单个结果
vector<vector<int>> res; // 结果集

void backtracking(vector<int>& nums, int startIndex)
{
// 子集类问题可以不写终止条件,具体原因参见78/90
if (path.size() >= 2) res.push_back(path); // 子集中元素数量大于等于2

unordered_set<int> uset; // set去重

// 单层递归逻辑
for (int i = startIndex; i < nums.size(); i ++ )
{
// 剪枝条件1:所取元素小于子序列最后一个元素,此时要求子序列非空,否则path.back()会报错
// 剪枝条件2:用set做树层去重
if (!path.empty() && nums[i] < path.back() || uset.find(nums[i]) != uset.end()) continue;

// 处理节点
// 记录每一层取的数(for循环中除去递归部分外都是横向遍历的),每一层不能重复取相同的数
uset.insert(nums[i]);
path.push_back(nums[i]);
// 递归
backtracking(nums, i + 1);
// 回溯
path.pop_back();
}
}

为什么没有对uset做回溯操作?
原因:上述代码中,每进入一层递归,都会重新定义一个uset。因此uset就是确保同一层中没有取到相同的元素,在进入下一层递归时,uset会被刷新。因此uset并不会对树枝去重,只会对树层去重。而used数组需要做回溯,因为used数组记录的是元素是否在path中被使用过,因此path中加减元素都需要对used数组进行修改。

本题的去重方式也可以应用于40.组合总和II和90.子集II。本题也可以用数组取代set进行去重,用数组的效率会更高些。

本题的完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, int startIndex)
{
// 收集结果
if (path.size() >= 2) res.push_back(path);
unordered_set<int> uset;

// 单层递归逻辑
for (int i = startIndex; i < nums.size(); i ++ )
{
// 剪枝条件1:所取元素小于子序列最后一个元素,此时要求子序列非空,否则path.back()会报错
// 剪枝条件2:用set做树层去重
// cpp中&&的优先级高于||,因此是先与后或,不需要给剪枝条件1加括号
if (!path.empty() && nums[i] < path.back() || uset.find(nums[i]) != uset.end()) continue;
// 处理节点
uset.insert(nums[i]);
path.push_back(nums[i]);
// 递归
backtracking(nums, i + 1);
// 回溯
path.pop_back();
}
}
vector<vector<int>> findSubsequences(vector<int>& nums) {
backtracking(nums, 0);
return res;
}
};

由于本题nums[i]的数据范围很小,因此可以用数组做去重,运行效率也更高。只需要将set替换为普通数组,然后做一个偏移(数值-100-100映射到数组下标0-200上)即可。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, int startIndex)
{
if (path.size() > 1) res.push_back(path);
int cnt[201] = {0};

for (int i = startIndex; i < nums.size(); i ++ )
{
if (!path.empty() && nums[i] < path.back() || cnt[nums[i] + 100] == 1) continue;
cnt[nums[i] + 100] = 1;
path.push_back(nums[i]);
backtracking(nums, i + 1);
path.pop_back();
}
}

vector<vector<int>> findSubsequences(vector<int>& nums) {
backtracking(nums, 0);
return res;
}
};

46.全排列

题目中明确说了给定的集合中没有重复元素,因此不用去重。

排列相对于组合的区别:[2, 1], [1, 2]是同一个组合,但是两个排列。排列是强调元素顺序的,组合不强调元素顺序。接下来画本题的树形结构。

46.全排列

排列问题中也需要用到used数组,用于标记哪些元素被使用过,因为在排列问题中同一个元素不能重复使用。组合问题中是用startIndex来避免取同一个元素和避免产生相同组合的情况。树的深度由nums的长度来控制。

used数组用来标记哪些元素被取过,取过的元素不能重复取,但所有没取过的元素都可以重复取,而不需要像组合问题那样用startIndex来控制只能取当前元素的下一个元素。具体的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
vector<int> path; // 放单个结果
vector<vector<int>> res; // 结果集

void backtracking(vector<int>& nums, vector<int>& used)
{
// 终止条件
if (path.size() == nums.size())
{
res.push_back(path); // 收获结果
return;
}

// 单层递归逻辑
// 排列问题不需要startIndex,只要不重复取同一个元素即可
for (int i = 0; i < nums.size(); i ++ )
{
if (used[i] == 1) continue; // 不重复取同一个元素
// 处理节点
used[i] = 1;
path.push_back(nums[i]);
// 递归
backtracking(nums, used);
// 回溯
used[i] = 0;
path.pop_back();
}
}

47.全排列 II

上一题给定的集合中没有重复元素,本题给定的集合中有重复元素。以[1, 1, 2]为例,如果依然用上一题的办法求解本题,结果集中会出现两个[1, 1, 2],因此本题需要做去重。如果对去重的思路不够了解,可以看40.组合总和II。回溯的所有题目中,去重的逻辑都是相同的。本题等于排列+去重。但排列问题中的去重会有些与之前不同的地方

画出本题的树形结构:
47.全排列II1

used数组做树层去重前,记得对nums数组进行排序。本题中的used数组有两个作用:

  • 避免同一个元素被重复使用
  • 做树层去重

接下来写具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, vector<int>& used)
{
// 终止条件
if (path.size() == nums.size())
{
res.push_back(path);
return;
}

// 单层递归逻辑
for (int i = 0; i < nums.size(); i ++ )
{
// 树层去重
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;
// 同一个元素不能被重复取,因此取过的数直接跳过
if (used[i] == 1) continue;

// 处理节点
used[i] = 1;
path.push_back(nums[i]);
// 递归
backtracking(nums, used);
// 回溯
used[i] = 0;
path.pop_back();
}
}

细节:

1
2
// 树层去重
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;

可以通过

1
2
// 树枝去重
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 1) continue;

也可以通过。这意味着树层去重和树枝去重都可以解决本题。但树层去重的效率更高,树层去重会剪掉更多分支,而树枝去重要一直到最后一个树枝才会列出所有的结果。因此还是推荐树层去重的写法。以[1, 1, 1]为例,画出树层去重和树枝去重的树形结构:
47.全排列II2

47.全排列II3

很清晰的看到,树层上对前一位去重非常彻底,效率很高,树枝上对前一位去重虽然最后可以得到答案,但是做了很多无用搜索。

时间复杂度和空间复杂度分析:

  • 时间复杂度: 最差情况所有元素都是唯一的,时间复杂度为$O(n!\times n)$。 对于n个元素一共有n!中排列方案。而对于每一个答案,我们需要$O(n)$去复制最终放到res数组。
  • 空间复杂度: $O(n)$。空间复杂度即为回溯树的深度,其取决于nums数组中有多少个元素。

心得与备忘录

491.递增子序列

关键点:set去重->剪枝->数组去重取代set去重

  1. 本题和90.子集II乍一看非常相似,但细节上有很大不同,解决本题时不能有惯性思维。

  2. 之前去重的方法都是利用used数组,这意味着需要对nums数组进行排序。在本题中,如果对nums数组进行排序,就会打乱原数组中元素的顺序,导致递增子序列发生改变。因此,本题不能用used数组去重,而需要用set去重。因为用set去重不需要对原数组排序。

  3. 本题有两个剪枝条件:

    • 剪枝条件1:若所取元素小于子序列最后一个元素,则需要剪枝。此时要求子序列非空,否则path.back()会报错。剪枝条件1的原因是本题要求子序列是递增的。

    • 剪枝条件2:用set做树层去重

    两个剪枝条件用||相连。

  4. 为什么不需要对set进行回溯?

    每进入一层递归,都会重新定义一个set。因此set就是确保同一层中没有取到相同的元素。在进入下一层递归时,set会被刷新(重新定义)。因此set并不会对树枝去重,只会对树层去重。而used数组需要做回溯,因为used数组记录的是元素是否在path中被使用过,因此path中加减元素都需要对used数组进行相应的修改。

  5. 本题的去重方法也可以应用于40.组合总和II和90.子集II。

  6. 由于本题nums[i]的数据范围很小,因此可以用数组做去重,运行效率也更高。只需要将set替换为普通数组,然后做一个偏移(数值-100-100映射到数组下标0-200上)即可。

  7. 本题的时间和空间复杂度分别为$O(n\times2^n)$和$O(n)$。同90和78。

46.全排列

  1. 排列问题和组合问题的两大区别:
    • 每层都是从0开始搜索而不是startIndex
    • 需要used数组记录path里都放了哪些元素了
  2. 不需要startIndex的原因:[1, 2][2, 1]是同一个组合,但却是不同的排列,因此排列问题不能像组合问题那样从当前元素的下一个元素开始取,而是要取nums数组中所有当前没有取过的元素。
  3. 需要used数组的原因:used数组记录了此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。
  4. 终止条件为path.size() == nums.size()nums数组的大小限制了树的深度。
  5. 本题的时间复杂度为$O(n!)$,空间复杂度为$O(n)$。时间复杂度的原因是有$n$个元素的nums数组共有$n!$种排列。空间复杂度的原因是递归的深度(即树的深度)为$n$。

47.全排列 II

  1. 本题相当于40.组合总和II(树层去重)和46.全排列的结合。
  2. 本题的去重有三种写法:set去重、数组去重、used数组去重。三种写法我都在初次尝试中给出了。
  3. used数组去重前,一定要记得对nums数组进行排序。另外两种去重写法则不需要对nums数组进行排序。
  4. 由于本题是在叶子节点处收集结果,因此需要终止条件。
  5. 本题的时间复杂度为$O(n!\times n)$,空间复杂度为$O(n)$。具体原因参见实现部分。
  6. 本题用树层去重和树枝去重都可以,具体原因参见实现部分。但树层去重的效率远高于树枝去重,因此采用一贯以来的used数组树层去重写法即可,不要纠结树枝去重的原理和合理性。

34. 在排序数组中查找元素的第一个和最后一个位置

本题是整数二分的加强版。本题的要点为:

  1. 写两个函数,分别寻找target的左边界和右边界。本题的区间定义为左闭右闭。

  2. 寻找左边界,说明target在[left, mid]之间,因此在[left, mid]中更新左边界。寻找右边界,说明target在[mid, right]之间,因此在[mid, right]中更新右边界。

  3. 寻找左边界,就要在nums[mid] == target的时候更新right,然后将right赋给左边界。寻找右边界,就要在nums[mid] == target的时候更新left,然后将left赋给右边界。

  4. 实际上的左右边界是mid,而非rightleft,因此在主函数中需要将左边界+1,恢复为mid;将右边界-1,也恢复为mid。也可以直接让左右边界是mid,这样就不需要加1减1,参见我的第二种写法。

  5. target的三种情况:

    • target在数组范围的右边或者左边
    • target 在数组范围中,且数组中存在target
    • target 在数组范围中,且数组中不存在target
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      // 左闭右闭写法
      class Solution {
      public:
      // 寻找左边界
      // 说明target在[left, mid]之间
      int findLeftBorder(vector<int>& nums, int target)
      {
      int leftBorder = -2;
      int left = 0, right = nums.size() - 1;

      while (left <= right)
      {
      int mid = left + (right - left) / 2;
      // 在[left, mid]中更新左边界
      if (nums[mid] >= target)
      {
      right = mid - 1;
      leftBorder = right;
      }
      else left = mid + 1;
      }
      return leftBorder;
      }

      // 寻找右边界
      // 说明target在[mid, right]之间
      int findRightBorder(vector<int>& nums, int target)
      {
      int rightBorder = -2;
      int left = 0, right = nums.size() - 1;

      while (left <= right)
      {
      int mid = left + (right - left) / 2;
      if (nums[mid] > target) right = mid - 1;
      // 在[mid, right]中更新右边界
      else
      {
      left = mid + 1;
      rightBorder = left;
      }
      }
      return rightBorder;
      }

      vector<int> searchRange(vector<int>& nums, int target) {
      int leftBorder = findLeftBorder(nums, target);
      int rightBorder = findRightBorder(nums, target);

      // target在数组范围的右边或者左边
      if (leftBorder == -2 || rightBorder == -2) return {-1, -1};
      // target 在数组范围中,且数组中存在target
      if (rightBorder - leftBorder > 1) return {leftBorder + 1, rightBorder - 1};
      // target 在数组范围中,且数组中不存在target
      return {-1, -1};
      }
      };

我写出了以下的变式代码。在这个代码里,通过mid来更新左右边界。这样若找到了左右边界,则直接返回左右边界即可,不需要做加1减1的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 左闭右闭写法
class Solution {
public:
// 寻找左边界
// 说明target在[left, mid]之间
int findLeftBorder(vector<int>& nums, int target)
{
int leftBorder = -2;
int left = 0, right = nums.size() - 1;

while (left <= right)
{
int mid = left + (right - left) / 2;
// 在[left, mid]中更新左边界
if (nums[mid] >= target)
{
right = mid - 1;
leftBorder = mid;
}
else left = mid + 1;
}
return leftBorder;
}

// 寻找右边界
// 说明target在[mid, right]之间
int findRightBorder(vector<int>& nums, int target)
{
int rightBorder = -2;
int left = 0, right = nums.size() - 1;

while (left <= right)
{
int mid = left + (right - left) / 2;
if (nums[mid] > target) right = mid - 1;
// 在[mid, right]中更新右边界
else
{
left = mid + 1;
rightBorder = mid;
}
}
return rightBorder;
}

vector<int> searchRange(vector<int>& nums, int target) {
int leftBorder = findLeftBorder(nums, target);
int rightBorder = findRightBorder(nums, target);

// target在数组范围的右边或者左边
if (leftBorder == -2 || rightBorder == -2) return {-1, -1};
// target 在数组范围中,且数组中存在target
if (rightBorder - leftBorder >= 0) return {leftBorder, rightBorder};
// target 在数组范围中,且数组中不存在target
return {-1, -1};
}
};

278.第一个坏版本

我独立写出了以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// The API isBadVersion is defined for you.
// bool isBadVersion(int version);

class Solution {
public:
int firstBadVersion(int n) {
int left = 1, right = n;

while (left < right)
{
int mid = left + (right - left) / 2;
if (isBadVersion(mid) == 0) left = mid + 1;
else right = mid;
}
return left;
}
};

在本题中,尽管是左闭右闭的写法,但循环的条件应该为left < right,因为当left = right时,实际上就锁定了第一个坏版本,循环就应当结束。这种题目当出现超时,要着重检查是不是循环的条件不对。

和本题同样的题目:输入一个数组,比如[0, 0, 0, 1, 1, 1, 1],找到第一个为1的数的下标,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <vector>
using namespace std;

int firstBadVersion(vector<int> arr)
{
int left = 0, right = arr.size() - 1;

while (left < right)
{
int mid = left + (right - left) / 2;
if (arr[mid] == 1) right = mid;
else left = mid + 1;
}
return left;
}

int main()
{
vector<int> arr = {0, 0, 0, 1, 1, 1, 1, 1};
cout << firstBadVersion(arr) << endl;
return 0;
}

27. 移除元素

本题直接采用(快慢)双指针解法即可。一遍过,但需要注意不要在for循环中重复定义指针j。本题的暴力做法甚至比双指针做法更复杂,也更容易写错。相向双指针做法暂时不用管。

977.有序数组的平方

暴力解法非常简单,也能通过测试。我先在纸上模拟了双指针的过程,然后独立写出了如下的双指针代码,时间和空间复杂度都是$O(n)$。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 双指针经典题
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
int size = nums.size() - 1;
vector<int> res(nums.size(), 0);

for (int i = 0, j = size; i <= j; )
{
if (nums[i] * nums[i] <= nums[j] * nums[j])
{
res[size] = nums[j] * nums[j];
j -- ;
size -- ;
}
else
{
res[size] = nums[i] * nums[i];
i ++ ;
size -- ;
}
}

return res;
}
};

不要追求把代码写得过度简洁,而导致可能的问题,宁可把代码写长一些,也要让代码清楚地表达算法思想。

209.长度最小的子数组

一时想不起来具体怎么写了,只记得遍历整个数组的同时,有数划入窗口,该数被累加到和中,有数划出窗口,则该数被从和中减去。看了我自己的笔记后,我独立写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int len = INT_MAX;
int s = 0; // 滑动窗口的和
int i = 0; // 起始位置

// j为终止位置
for (int j = 0; j < nums.size(); j ++ )
{
s += nums[j]; // 终止位置划入

while (s >= target)
{
int sub = j - i + 1;
if (sub < len) len = sub;
s -= nums[i]; // 起始位置从和中滑出
i ++ ; // 起始位置从滑动窗口中滑出
}
}
if (len == INT_MAX) return 0;
else return len;
}
};

需要注意:

  • i为起始位置,j为终止位置

  • 循环中,终止位置先划入。若和大于等于目标值,则先更新最小长度,再将起始位置划出。

  • 起始位置的值需要先从和中滑出,起始位置再从滑动窗口中滑出。顺序不可颠倒。

  • for循环中是while循环,而非if判断

  • 数组中的每个元素至多被滑入一次再滑出一次,因此时间复杂度是$O(2n)$,即$O(n)$。

59.螺旋矩阵II

我记得本题是个模拟题。但实在记不得怎么做了,看以前的笔记。复习完后,我写出了本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public:
vector<vector<int>> generateMatrix(int n) {
vector<vector<int>> res(n, vector<int>(n, 0));

int i, j;
int startx = 0, starty = 0;
int offset = 1, cnt = 1;
int loop = n / 2;

while (loop -- )
{
for (j = starty; j < n - offset; j ++ )
res[startx][j] = cnt ++ ;
for (i = startx; i < n - offset; i ++ )
res[i][j] = cnt ++ ;
for (j = n - offset; j > starty; j -- )
res[i][j] = cnt ++ ;
for (i = n - offset; i > startx; i -- )
res[i][j] = cnt ++ ;
offset ++ ;
startx ++ ;
starty ++ ;
}
if (n % 2) res[n / 2][n / 2] = cnt;
return res;
}
};

需要注意:

  1. 画图理解(记住本图,就可以写出这道题的代码)

    Snipaste_2024-01-26_06-26-17.png

  2. 顺时针转圈,转多少圈可以填满整个二维数组?从(0, 0)的位置开始转圈,终止的位置为中心(n/2, n/2)。每转一圈横纵坐标均加1,因此一共转了n/2圈。

  3. 切记遵守循环不变量原则,所有边都是左闭右开的。所以是j < n - offset,且offset的初始值为1,因为右边界是开的。

  4. startx, starty, offset每转一圈都要加1。

  5. 定义二维数组的方式是将一位数组复制行数遍。

  6. 若n为奇数,则最后记得向二维数组的中心填入最后一个数。

189.旋转数组

首先我写出了可以正常运行但会超时的代码:

1
2
3
4
5
6
7
8
9
10
class Solution {
public:
void rotate(vector<int>& nums, int k) {
while (k -- )
{
reverse(nums.begin(), nums.end());
reverse(nums.begin() + 1, nums.end());
}
}
};

不超时的代码我写不出来,看卡尔的讲解。

本题的代码如下所示:

1
2
3
4
5
6
7
8
9
class Solution {
public:
void rotate(vector<int>& nums, int k) {
k = k % nums.size();
reverse(nums.begin(), nums.end());
reverse(nums.begin(), nums.begin() + k);
reverse(nums.begin() + k, nums.end());
}
};

本题其实原理不难,类似于旋转字符串的题目,总结如下:

  1. 首先反转整个数组,这样在不考虑顺序的情况下,就将两段数字放在了正确的位置上。
  2. 然后反转前k个数,将前k个数的顺序调整正确。
  3. 最后反转剩下的数,将剩下的数的顺序调整正确。
  4. 需要注意的是,若k > nums.size(),则右移k % nums.size()即可,因为右移nums.size()次相当于没有改变原数组。
  5. 不要对nums.end()进行加减操作,nums.end()不指向一个特定的元素(不要下意识地以为其指向最后一个元素后面的紧邻的位置),对其进行加减操作会导致未定义的随机行为。对nums.begin()进行操作就没有这个问题。因此反转的第三步不要写成reverse(nums.end() - k - 1, nums.end())

153.寻找旋转数组中的最小值

应该是先要将其恢复为有序的数组,然后返回有序数组的第一个元素即可。本题应该结合了二分法和旋转数组。我直接看题解吧。

虽然但是,本题用暴力做法也可以通过:

1
2
3
4
5
6
7
class Solution {
public:
int findMin(vector<int>& nums) {
sort(nums.begin(), nums.end());
return nums[0];
}
};

上述算法的时间复杂度是O(nlogn)。用二分法应该可以将时间复杂度优化为O(logn)。

本题的二分做法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 左闭右闭写法
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1;

// 循环的终止条件:left = right。此时必然已经找到了数组中的最小值
while (left < right)
{
int mid = left + (right - left) / 2;
// 中间数字大于右边数字,比如[3,4,5,1,2],则左侧是有序上升的,最小值在右侧
if (nums[mid] > nums[right]) left = mid + 1;
// 中间数字小于右边数字,比如[6,7,1,2,3,4,5],则右侧是有序上升的,最小值在左侧
// 以[6, 7, 1, 2, 3, 4]为例,mid = 2, right = 2,即恰好在[left, mid]中取到最小值1
// 若right = mid - 1,则[left, right]会错过最小值
else if (nums[mid] < nums[right]) right = mid;
// 中间数字等于右边数字,则说明数组中只有一个元素,返回该元素即可
// 也可以直接写作else right = mid;
}
return nums[left];
}
};

本题延续了二分法的思路和代码形式,但细节和二分法略有不同,需要注意复习。

本题的思路:

  • 本题是左闭右闭写法,区间为[left, right]

  • 数组中的最小值要么在数组的右侧,要么在数组的左侧

  • 数组的最小值在数组右侧的情况:[3, 4, 5, 1, 2]。数组的最小值在数组左侧的情况:[6, 7, 1, 2, 3, 4, 5]
  • 若数组的最小值在数组的右侧,由于nums[mid] > nums[right],因此nums[mid]必然不可能是数组的最小值,因此left = mid + 1
  • 对于剩下的情况,即nums[mid] <= nums[right],数组的最小值在数组的左侧。由于可能存在nums[mid] = nums[right]的情况,因此nums[mid]可能是最小值,因此有right = mid
  • 记住始终是nums[mid]nums[right]比较。始终是中间和右边比!

本题的另一种思路(更推荐这种,因为这种思路可以推广到33)

  • nums[mid]nums[right]的关系可以分为大于,等于,小于三种情况
  • nums[mid] == nums[right]时,中间的数字等于最右边的数字,说明数组中只有一个元素,此时返回nums[left]即可,这种情况不需要考虑
  • nums[mid] > nums[right]时,例如[3, 4, 5, 1, 2]。数组的最小值在数组的右侧,nums[mid]必定不为最小值,因此有left = mid + 1
  • nums[mid] < nums[right]时,数组的最小值在数组的左侧。例如[6, 7, 1, 2, 3, 4, 5],也有可能是[6, 7, 1, 2, 3, 4],此时mid = 2, right = 2,即恰好在[left, mid]中取到最小值1。若right = mid - 1,则[left, right]会错过最小值,因此right = mid

154.寻找旋转数组中的最小值II

本题的思路:

  • 延续上题的思路,nums[mid]nums[right]的关系可以分为大于,等于,小于三种情况
  • nums[mid] > nums[right]nums[mid] < nums[right]的情况同上
  • 由于数组中可以有重复的元素,因此需要考虑nums[mid] == mums[right]的情况,例如[2,3,1,1,1]或者[4,1,2,3,3,3,3]。此时,重复值nums[right]可能是最小值,也可能最小值在重复值的左侧,因此right左移一位:right -= 1

本题的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1;

while (left < right)
{
int mid = left + (right - left) / 2;
// [5, 6, 7, 1, 2]
if (nums[mid] > nums[right]) left = mid + 1;
// [7, 1, 2, 3, 4]
else if (nums[mid] < nums[right]) right = mid;
else right -= 1;
}
return nums[left];
}
};

33.搜索旋转排序数组

我对本题的初步思路:先找到最小的那个点,然后分别对两段单调递增的区间用二分法进行搜索。根据这个原理,我独立写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Solution {
public:
// 二分查找有序数组中的数
// 左闭右闭写法
int searchTarget(vector<int>& nums, int left, int right, int target)
{
while (left <= right)
{
int mid = left + (right - left) / 2;
if (nums[mid] > target) right = mid - 1;
else if (nums[mid] < target) left = mid + 1;
else return mid;
}
return -1;
}

int search(vector<int>& nums, int target) {
// 先找到最小的数字, 下标为left
int left = 0, right = nums.size() - 1;

while (left < right)
{
int mid = left + (right - left) / 2;
// nums[mid] nums[right], [4, 5, 6, 7, 0, 1, 2]
if (nums[mid] > nums[right]) left = mid + 1;
else right = mid;
}

int leftIndex = searchTarget(nums, 0, left - 1, target);
int rightIndex = searchTarget(nums, left, nums.size() - 1, target);

if (leftIndex == -1 && rightIndex == -1) return -1;
else if (leftIndex == -1) return rightIndex;
else if (rightIndex == -1) return leftIndex;
return -1;
}
};

时间复杂度也是$O(logn)$。

更简单的写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 左闭右闭写法
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;

// 本质是查找target,因此是小于等于。若是查找最小值,则是小于
while (left <= right)
{
int mid = left + (right - left) / 2;

// 第一种情况,直接找到
if (nums[mid] == target) return mid;

// 由于第一种情况已经讨论过nums[mid] == target,因此第二三种情况不用再讨论
// 第二种情况,数组最小值在右侧, [left, mid]为有序
if (nums[mid] > nums[right])
{
// target在[left, mid](有序)内
if (target >= nums[left] && target < nums[mid]) right = mid - 1;
// target在无序区间内
else left = mid + 1;
}

// 第三种情况,数组最小值在左侧,[mid, right]为有序
else
{
// target在[mid, right]区间内
if (target > nums[mid] && target <= nums[right]) left = mid + 1;
// target在无序区间内
else right = mid - 1;
}
}
return -1;
}
};

分三种情况讨论:

  • 直接在mid处找到target
  • 数组最小值在右侧, [left, mid]为有序
    • target在[left, mid]有序区间内
    • target在剩余的无序区间内
  • 数组最小值在左侧,[mid, right]为有序
    • target在[mid, right]有序区间内
    • target在剩余的无序区间内

81.搜索旋转排序数组II

本题依然可以用老思路:找到最小值点,将区间划分为两个单调区间,然后分别在两个单调区间中进行搜索。本题实际上不可以这样做,因为本题中数组的元素可以重复,可能存在不止一个最小值点。

看了答案后,发现本题有两种写法,第一种:在循环内部跳过数组左侧和右侧的重复元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public:
bool search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;

while (left <= right) {
// 跳过数组左侧的重复元素
while (left < right && nums[left] == nums[left + 1]) left++;
// 跳过数组右侧的重复元素
while (left < right && nums[right] == nums[right - 1]) right--;

int mid = left + (right - left) / 2;
if (nums[mid] == target) return true;

// 判断有序部分
if (nums[mid] >= nums[left]) { // 左侧有序
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
} else { // 右侧有序
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
return false;
}
};

第二种,在循环外部直接删去数组尾部与数组头部重复的元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:
bool search(vector<int>& nums, int target) {
// 移除重复的末尾元素以减少干扰
// 可以处理如下情况:[1, 0, 1, 1, 1], [1, 2, 2, 2, 2, 0, 1]
// nums.front() == nums.back()时,可能数组右边有序,也可能左边有序
// 也可写作nums[0] == nums[nums.size() - 1]
while (nums.size() > 1 && nums.front() == nums.back()) nums.pop_back();

int left = 0, right = nums.size() - 1;

while (left <= right)
{
int mid = left + (right - left) / 2;

if (nums[mid] == target) return true;

// [3, 4, 5, 1, 2]
if (nums[mid] > nums[right])
{
// 有序区间[left, mid]
if (target >= nums[left] && target < nums[mid]) right = mid - 1;
// 无序区间[mid, right]
else left = mid + 1;
}
else
{
// 有序区间[mid, right]
if (target > nums[mid] && target <= nums[right]) left = mid + 1;
// 无序区间[left, mid]
else right = mid - 1;
}
}
return false;
}
};

注意:需要先移除重复的末尾元素以减少干扰,再给leftright赋值。

建议采用第二种写法,因为第二种写法相当于在33.搜索旋转排序数组的基础上仅仅添加了移除重复的末尾元素的代码。这道题相当与上一题区别在于这道题包含了重复元素,其实影响到的是,当左端点和右端点相等时,无法判断mid在左半边有序数组还是右半边有序数组,所以只需要一直pop直到左端点和右端点不相等就可以了。

442. 数组中重复的数据

448. 找到所有数组中消失的数字

只有当数字的范围和数组的大小相等,或者有一定偏移关系时,才可以用原地哈希。本题的数字范围1-n,本题的数组中有n个元素,数组下标的范围是0-n-1。这种原地哈希算法适用于和正整数有关,且数字范围和数组长度有关的题目里,映射之后能利用映射关系(下标和值一一对应)来找到解。

对于本题,本质就是将原数组的下标为nums[i] - 1处放上nums[i],最终希望达到的效果是nums[nums[i] - 1] == nums[i]。本题的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
vector<int> findDisappearedNumbers(vector<int>& nums) {
vector<int> res;

// 将nums[i]放到下标为nums[i] - 1的位置上
// 由于原来下标为nums[i] - 1的位置上可能有数,因此需要将该数暂存到nums[i]上
// 之后可以通过while循环将再将该数放到合适的位置上去
// 可以举例子来模拟,即可以弄清楚这个过程
for (int i = 0; i < nums.size(); i ++ )
{
while (nums[nums[i] - 1] != nums[i])
{
int idx = nums[i] - 1;
int tmp = nums[idx];
nums[idx] = nums[i];
nums[i] = tmp;
}
}

for (int i = 0; i < nums.size(); i ++ )
{
// 若nums[i]上的数字不为i + 1,则说明该数字缺失,将其插入结果集中
if (nums[i] != i + 1)
res.push_back(i + 1);
}

return res;
}
};

本题的精简注释版本如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
vector<int> findDisappearedNumbers(vector<int>& nums) {
vector<int> res;
int n = nums.size();

for (int i = 0; i < n; i ++ )
{
// 确保将nums[i]放到下标为nums[i] - 1的位置上
while (nums[nums[i] - 1] != nums[i])
{
int idx = nums[i] - 1; // 即将占用的元素的下标
int tmp = nums[idx]; // 暂存下标为idx处的元素,因为其即将被nums[i]占用
nums[idx] = nums[i]; // 将nums[i]放到下标为nums[i] - 1的位置上
nums[i] = tmp; // 将原来数组中下标为nums[i] - 1的数暂存到位置i
}
}

for (int i = 0; i < n; i ++ )
if (nums[i] != i + 1) res.push_back(i + 1);

return res;
}
};

while循环中的顺序:先写:

1
2
int idx = nums[i] - 1;
nums[idx] = nums[i];

确保nums[i]被放在了下标为nums[i] - 1处。

再将原本下标为idx处的元素缓存下来,暂存到下标i处:

1
2
int tmp = nums[idx];
nums[i] = tmp;

由此构成完整的代码:

1
2
3
4
int idx = nums[i] - 1;
int tmp = nums[idx];
nums[idx] = nums[i];
nums[i] = tmp;

442. 数组中重复的数据

本题依然可以用448的原地哈希法完成,唯一地不同在于,448是将i + 1插入res数组中,本题是将nums[i]插入res数组中,举一个实际的例子即可理解为什么是将nums[i]插入结果集中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
vector<int> findDuplicates(vector<int>& nums) {
vector<int> res;
int n = nums.size();

for (int i = 0; i < n; i ++ )
{
while (nums[nums[i] - 1] != nums[i])
{
int idx = nums[i] - 1;
int tmp = nums[idx];
nums[idx] = nums[i];
nums[i] = tmp;
}
}

for (int i = 0; i < n; i ++ )
{
if (nums[i] != i + 1) res.push_back(nums[i]);
}
return res;
}
};

对原地哈希可进行总结:

  • 情景:数组的长度为n,数组中元素的范围为[1, n]

  • 若是找缺失的数字,则插入结果集的是索引下标+1;若是找出现了两遍的数字,则插入结果集的是元素的值nums[i]

  • 使用代码块:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    for (int i = 0; i < n; i ++ )
    {
    while (nums[nums[i] - 1] != nums[i])
    {
    int idx = nums[i] - 1;
    int tmp = nums[idx];
    nums[idx] = nums[i];
    nums[i] = tmp;
    }
    }

    对数组进行原地哈希后,数组中出现过的数字nums[i]会被重新放置在下标为nums[i] - 1的位置上。范围为[1, n]但数组中没出现过的数字nums[j],其本来应该放置在下标为nums[j] - 1处,但由于没有出现过,现在下标为nums[j] - 1处放置了原数组中的重复元素。这是因为循环的条件nums[nums[i] - 1] != nums[i],当未填满的位置填入了重复元素后,while循环也会终止。例如,对[4, 3, 2, 2, 3, 1]进行原地哈希,结果为[1, 2, 3, 4, 3, 2],原数组中出现过的1, 2, 3, 4被放置在下标为0, 1, 2, 3的位置上,原数组中没有出现过5, 6,因此下标为4,5处放置了原数组中重复的元素2, 3。

  • 原地哈希法的时间复杂度都为O(n),空间复杂度都为O(1)

  • 为什么是 O(n) 时间复杂度?

    每个元素在整个过程中最多被处理两次(一次是放置在正确位置,一次是在最终遍历中检查),因此总体时间复杂度是 O(2n)==O(n)。

41. 缺失的第一个正数

本题的思路和448、442相同,只不过while循环多了限制条件,同时返回值时需要考虑一种特殊情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();

for (int i = 0; i < n; i ++ )
{
// 为避免nums[i] - 1超出索引的范围,需要对nums[i]的大小进行限制
// 0 <= nums[i] - 1 <= n - 1,因此1 <= nums[i] <= n
// 不需要对此范围之外的数进行操作,也无法用原地哈希法操作它们,因为它们会超出索引范围
while (nums[i] >= 1 && nums[i] <= n && nums[nums[i] - 1] != nums[i])
{
// 这四行代码可以简写为swap(nums[nums[i] - 1], nums[i]);
int idx = nums[i] - 1;
int tmp = nums[idx];
nums[idx] = nums[i];
nums[i] = tmp;
}
}

for (int i = 0; i < n; i ++ )
{
if (nums[i] != i + 1)
return i + 1;
}

// 特殊情况, nums = [1], 上面的循环不会返回结果,此时返回n + 1即可
return n + 1;
}
};

本题中,数的个数为n个,但数的范围不在[1, n]中。需要返回缺失的第一个正整数。虽然乍一看不完全符合上题总结的原地哈希法的使用条件,但加上限制条件的原地哈希法依然可以被应用于解决本题。

203.移除链表元素

本题不能这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

for (ListNode* cur = dummyHead; cur != NULL; cur = cur->next)
{
if (cur->next != NULL && cur->next->val == val)
cur->next = cur->next->next;
}
return dummyHead->next;
}
};

这样写会导致删除节点后,cur 指针向后移动到了 cur->next->next,从而可能跳过了紧接着的需要删除的节点。比如:

1
1 -> 2 -> 2 -> 3, target = 2

上述写法会导致第三个节点2不能被删除。

本题应当用while循环写,对一个节点,如果是目标节点,则将其删除,否则,向后移动一个节点,不能同时既删除节点又后移一位。本题正确的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

ListNode* cur = dummyHead;
while (cur->next != NULL)
{
// 如果既删又后移,则会漏掉节点
if (cur->next->val == val) cur->next = cur->next->next; // 要么删
else cur = cur->next; // 要么后移
}
return dummyHead->next;
}
};

本题的完整主函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>
using namespace std;

struct ListNode
{
int val;
ListNode* next;
ListNode(int x): val(x), next(NULL) {}
};

ListNode* remove(ListNode* head, int val)
{
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

ListNode* cur = dummyHead;
while (cur->next != NULL)
{
if (cur->next->val == val) cur->next = cur->next->next;
else cur = cur->next;
}

return dummyHead->next;
}

int main()
{
// head = [1,2,6,3]
ListNode* node1 = new ListNode(1);
ListNode* node2 = new ListNode(2);
ListNode* node3 = new ListNode(6);
ListNode* node4 = new ListNode(3);
node1->next = node2;
node2->next = node3;
node3->next = node4;

ListNode* head = remove(node1, 3);
for (ListNode* cur = head; cur != NULL; cur = cur->next) cout << cur->val << ' ';
cout << endl;
return 0;
}

构造链表时,也可以采用函数写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <iostream>
using namespace std;

struct ListNode
{
int val;
ListNode* next;
ListNode(int x): val(x), next(NULL) {}
};

ListNode* appendNode(ListNode*& head, int val)
{
// 头为空,则将新节点作为头节点
if (head == NULL) head = new ListNode(val);
// 头不为空,则遍历到链表最后一个节点,将新节点添加到最后一个节点之后
else
{
ListNode* cur = head;
while (cur->next != NULL) cur = cur->next;
cur->next = new ListNode(val);
}
return head;
}

ListNode* remove(ListNode* head, int val)
{
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

ListNode* cur = dummyHead;
while (cur->next != NULL)
{
if (cur->next->val == val) cur->next = cur->next->next;
else cur = cur->next;
}

return dummyHead->next;
}

int main()
{
// head = [1,2,6,3]
ListNode* node = NULL;
appendNode(node, 1);
appendNode(node, 2);
appendNode(node, 6);
appendNode(node, 3);

ListNode* head = remove(node, 3);
for (ListNode* cur = head; cur != NULL; cur = cur->next) cout << cur->val << ' ';
cout << endl;
return 0;
}

在定义链表时,特别要注意下面用来赋值的这句话:ListNode(int x): val(x), next(NULL) {}

707.设计链表

本题的细节很多,需要特别注意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// get函数的复杂写法
int get(int index) {
if (index < 0 || index > _size - 1) return -1;

LinkedList* cur = _dummyHead;
index ++ ;
while (cur != NULL)
{
cur = cur->next;
index -- ;
if (index == 0) break;
}
return cur->val;
}

本题的完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class MyLinkedList {
public:
struct LinkedList
{
int val;
LinkedList* next;
LinkedList(int x): val(x), next(NULL) {}
};

MyLinkedList() {
_dummyHead = new LinkedList(0);
_size = 0;
}

int get(int index) {
if (index < 0 || index > _size - 1) return -1;

LinkedList* cur = _dummyHead->next;
while (index -- ) cur = cur->next;
return cur->val;
}

void addAtHead(int val) {
LinkedList* head = new LinkedList(val);
head->next = _dummyHead->next;
_dummyHead->next = head;
_size ++ ;
}

void addAtTail(int val) {
LinkedList* tail = new LinkedList(val);
LinkedList* cur = _dummyHead;
while (cur->next != NULL) cur = cur->next;
cur->next = tail;
_size ++ ;
}

void addAtIndex(int index, int val) {
if (index < 0 || index > _size) return;
LinkedList* newNode = new LinkedList(val);
LinkedList* cur = _dummyHead;
while (index -- ) cur = cur->next;
newNode->next = cur->next;
cur->next = newNode;
_size ++ ;
}

void deleteAtIndex(int index) {
if (index < 0 || index > _size - 1) return;
LinkedList* cur = _dummyHead;
while (index -- ) cur = cur->next;
cur->next = cur->next->next;
_size -- ;
}

private:
LinkedList* _dummyHead;
int _size;
};

注意事项:

  1. 带下划线的变量表示类中的变量,而非局部变量

  2. 记得在private中定义类中的变量

  3. 注意插入节点时先更新后面的边,再更新前面的边

  4. 别忘记_size ++ / _size —

  5. 注意对参数index进行判断

  6. while (index -- ) cur = cur->next的意思是,首先判断index是否大于0,是,则index = index - 1,然后执行cur = cur->next

206.反转链表

我记得有递归写法,迭代写法,先尝试实现迭代写法,其本质是双指针。记住下面的图,即可写出本题的双指针法的代码:
leetcode206.png

注意:pre从NULL开始,cur在NULL结束。

一个经典的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* pre = NULL;
ListNode* cur = head;

while (cur->next)
{
ListNode* tmp = cur->next;
cur->next = pre;
pre = cur;
cur = tmp;
}
return cur;
}
};

这样写的结果是导致未将列表的最后一个节点(即反转后的头节点)的 next 指针正确设置。

本题的递归写法其实更加好写,但其空间复杂度为O(n),高于双指针写法:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
ListNode* reverseList(ListNode* head) {
// 终止条件
if (head == NULL || head->next == NULL) return head;

ListNode* last = reverseList(head->next);
head->next->next = head;
head->next = NULL;
return last;
}
};

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

首先尝试用双指针做法解决本题。直接看答案,记住本题的方法。其实不需要双指针,本题是一道单纯的模拟题,但要搞清楚循环终止条件。看过博客后,我写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;
ListNode* cur = dummyHead;

// 终止条件:分别对应有偶数个节点和有奇数个节点
while (cur->next && cur->next->next)
{
// 存1
ListNode* tmp = cur->next;
// 存3
ListNode* tmp1 = cur->next->next->next;
// d->2
cur->next = cur->next->next;
// 2->1
cur->next->next = tmp;
// 1->3
tmp->next = tmp1;

// 后移两位
cur = cur->next->next;
}
return dummyHead->next;
}
};

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

本题的笨办法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;
ListNode* cur = dummyHead;

int num = -1;
// 计算节点数目
while (cur)
{
cur = cur->next;
num ++ ;
}

// 倒数第n个节点是正数第num - n个节点
cur = dummyHead;
while (num > n) // 不可以写成(num - n) --
{
cur = cur->next;
num -- ;
}
cur->next = cur->next->next;
return dummyHead->next;
}
};

看博客,复习巧妙解法。本题的巧妙解法是快慢双指针。快指针先移动n位,然后快慢指针同时移动,直到快指针移动到链表的最后一个节点。此时,慢指针就指向了需要删除的节点。据此,我写出了本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

ListNode* fast = dummyHead;
ListNode* slow = dummyHead;
// && fast可加可不加,因为本题有限制n<=链表长度,若无此限制,则必须加,否则会出现空指针异常
while (n -- && fast) fast = fast->next;
while (fast->next)
{
fast = fast->next;
slow = slow->next;
}
slow->next = slow->next->next;
return dummyHead->next;
}
};

也可以让快指针先移动n + 1步,然后快慢指针同时移动,直到快指针移动到链表的NULL节点。

160.相交链表

本题拿到我没什么思路,直接看以前的博客。本题的思路:首先计算两个链表的长度,将较长的链表作为链表a,较短的链表作为链表b。然后a链表从sizea - sizeb处开始, b链表从0处开始遍历,直到找到二者的交汇点。本质上体现的是一种末尾对齐的思想。示意图和代码如下所示:

面试题02.07.链表相交_2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode* cura = headA, *curb = headB;
int sizea = 0, sizeb = 0;

// 计算a的长度
while (cura)
{
cura = cura->next;
sizea ++ ;
}

// 计算b的长度
while (curb)
{
curb = curb->next;
sizeb ++ ;
}

// 让a为较长的链表, b为较短的链表
if (sizea < sizeb)
{
swap(sizea, sizeb);
swap(headA, headB);
swap(cura, curb);
}

// a链表从sizea - sizeb处开始, b链表从0处开始
cura = headA, curb = headB;
int delta = sizea - sizeb;
while (delta -- ) cura = cura->next;

while (curb)
{
if (cura == curb) return cura;
else
{
cura = cura->next;
curb = curb->next;
}
}
return NULL;
}
};

时间复杂度O(n + m),空间复杂度O(1)。

142.环形链表II

我记得本题是用快慢指针解决的,快指针一次走两格,慢指针一次走一格,二者必然会在节点处相遇。我还记得公式怎么推导,但具体的思路记不清楚了,看博客。看完博客后,我写出了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode* fast = head, * slow = head;

// fast != NULL保证fast->next不报空指针异常
// fast->next != NULL保证fast->next->next不报空指针异常
while (fast && fast->next)
{
fast = fast->next->next;
slow = slow->next;

if (fast == slow)
{
ListNode* node1 = head, * node2 = fast;
while (node1 != node2)
{
node1 = node1->next;
node2 = node2->next;
}
return node1;
}
}
return NULL;
}
};

本题的思路:快慢指针必然会在环中的某处相交,且慢指针在进入环的第一圈中就会和快指针相交。记下交点,让一个指针从起点开始走,另一个指针从交点开始走,二者相交的位置就是环的入口。详细的数学推导和细节见博客。

时间复杂度O(n),空间复杂度O(1)。

242.有效的字母异位词

本题简单,用数组做哈希,用数组统计一个字符串中各个字母出现的次数,然后遍历另一个字符串,在数组中做减减操作,最后判断数组中的所有元素是否都为0。我独立写出了本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
bool isAnagram(string s, string t) {
if (s.size() != t.size()) return false;

vector<int> hash(26, 0);

for (int i = 0; i < s.size(); i ++ )
{
int tmp1 = s[i] - 'a';
hash[tmp1] ++ ;
int tmp2 = t[i] - 'a';
hash[tmp2] -- ;
}

for (int i = 0; i < 26; i ++ )
{
if (hash[i]) return false;
}
return true;
}
};

需要注意,数组的长度为26,而非s.size(),因为s和t中只含有26个英文字母。可以不用vector,用int类型的数组即可。

本题更简洁的版本的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
bool isAnagram(string s, string t) {
if (s.size() != t.size()) return false;

int hash[26] = {0};

for (int i = 0; i < s.size(); i ++ )
{
hash[s[i] - 'a'] ++ ;
hash[t[i] - 'a'] -- ;
}

for (int i = 0; i < 26; i ++ )
if (hash[i]) return false;

return true;
}
};

349. 两个数组的交集

由于本题数据范围的限制,可以用数组做哈希,也可以用unordered_set做哈希。我首先写出了数组做哈希的写法(数组索引的范围与元素取值的范围相同),数组做哈希非常快:

数组哈希,数组去重

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
int hash1[1010] = {0};
int hash2[1010] = {0};
vector<int> res;

for (int i = 0; i < nums1.size(); i ++ )
hash1[nums1[i]] ++ ;

for (int i = 0; i < nums2.size(); i ++ )
hash2[nums2[i]] ++ ;

for (int i = 0; i < 1010; i ++ )
{
if (hash1[i] && hash2[i]) res.push_back(i);
}
return res;
}
};

尝试用unordered_set做本题。我不记得怎么用unordered_set做了,也忘记了unordered_set的基本做法,复习博客。

数组哈希,set去重

可以将数组和set结合,这样只需要一个数组即可完成本题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> res; // 用于结果集去重
int hash[1010] = {0};

for (int i = 0; i < nums1.size(); i ++ )
hash[nums1[i]] ++ ;

for (int i = 0; i < nums2.size(); i ++ )
if (hash[nums2[i]]) res.insert(nums2[i]);

return vector<int>(res.begin(), res.end());
}
};

这里的set只是起到了去重的功能,没有起到哈希的功能,哈希的任务还是由数组承担了。注意如何将set转换为vector输出,直接vector<int> (res.begin(), res.end())

set哈希,set去重

也可以完全用set做本题,set既用来做哈希,又用来去重,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 完全用set做本题
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> res;

unordered_set<int> s1(nums1.begin(), nums1.end());

for (int i = 0; i < nums2.size(); i ++ )
if (s1.find(nums2[i]) != s1.end()) res.insert(nums2[i]);

return vector<int> (res.begin(), res.end());
}
};

202. 快乐数

我记得本题有个巧妙的做法。本题使用的数据结构应该是set。我直接看博客复习本题的写法。我错误的根本原因还是对本题的算法思路理解不清晰。在明确了思路后,我写下了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
bool isHappy(int n) {
unordered_set<int> s;

while (1)
{
int sum = 0; // 用于存储各位数字的平方和

while (n)
{
int digit = n % 10;
sum += digit * digit;
n = n / 10;
}

if (sum == 1) return true;
if (s.find(sum) != s.end()) return false;
n = sum;
s.insert(sum);
}
}
};

本题的思路其实很简单,关键在于对平方和的计算和分类讨论(分为三类)开一个死循环,计算n的各位数字的平方和。若平方和为1,则是快乐数。若平方和在set中出现过,则说明进入了死循环,不是快乐数。否则,将平方和加入到set中,将sum赋给n,进入下一重循环

时间复杂度和空间复杂度都是O(logn),详情参见博客。

1. 两数之和

本题要用map解决。用map的key存储索引,map的value存储nums中的值。首先将数组中的值依次存入map中,然后再在map中搜索target - nums[i],若找到,则返回一对索引。本题思路我是清楚的,但由于忘了map的一些写法,因此复习博客。

实际上,我对本题的理解还是不够深刻。应该是用map的key存储数组中的值,map的value存储数组中的元素的下标,因为我们的目的是快速查找值是否出现过,被快速查找的对象应该被作为key。

先查再插

看完博客后,我写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> m;

for (int i = 0; i < nums.size(); i ++ )
{
int t = target - nums[i];
if (m.find(t) != m.end())
{
auto it = m.find(t);
return {i, it->second};
}
m.insert({nums[i], i});
}
return {};
}
};

本题的思路为:先查后插。先查现有的map中有无target - nums[i],有,则将其索引和i一起加入结果集。无,则将遍历到的元素继续插入map中。这样天然的可以防止同一个元素被取出两次

记住map的一些用法:

  • m.insert({nums[i], i})
  • m.find(key) != m.end()
  • auto it = m.find(t); int value = it->second;

插完再查

本题更复杂版本的代码,由于没有先查后插,导致要对找到的索引进行判断,其不能等于当前遍历到的索引,否则会导致同一个数字被使用了两次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> m;

for (int i = 0; i < nums.size(); i ++ )
m.insert({nums[i], i});

for (int i = 0; i < nums.size(); i ++ )
{
int t = target - nums[i];
auto it = m.find(t);
if (it != m.end() && it->second != i) return {i, it->second};
}
return {};
}
};

压缩字符串(面试真题)

aaaabb压缩为a4b2,将abcde保持原样不动。我独立写出了以下的代码,可以通过所有的测试样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>

using namespace std;

string compress(string s)
{
if (s.size() == 1) return s;

string res;
char tmp = s[0];
int size = 1;

for (int i = 1; i < s.size(); i ++ )
{
res += tmp;
while (i < s.size() && s[i] == s[i - 1])
{
i ++ ;
size ++ ;
}

if (size > 1) res += to_string(size); // 也可以写成res += '0' + size;
if (i < s.size()) tmp = s[i];
size = 1;
}

// 处理最后一个字符
if (s[s.size() - 1] != s[s.size() - 2]) res += s[s.size() - 1];

return res;
}

int main()
{
// 将aaabb转换为a3b2输出
// 将abcde原样输出
string s = "aaa";
string res = compress(s);
cout << res << endl;
}

其实没有必要在for循环中嵌套while循环,直接用一个for循环就可以搞定。以下的写法为推荐写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <string>

using namespace std;

// aaabb->a3b2
string compress(string s)
{
if (s.size() == 0 || s.size() == 1) return s;

string res;
char tmp = s[0];
int size = 1;

for (int i = 1; i < s.size(); i ++ )
{
// 出现相同字母
if (s[i] == s[i - 1]) size ++ ;
// 出现不同字母
else
{
// 将上一个字符和其出现次数(>1)插入res中
res += tmp;
if (size > 1) res += to_string(size);
// 恢复现场
tmp = s[i];
size = 1;
}
}

// 处理字符串的最后一位
res += tmp;
if (size > 1) res += to_string(size);

return res;
}

int main()
{
string s = "aaaanbv";
string res = compress(s);
cout << res << endl;
}

本题的关键在于分两种情况讨论:出现相同的字母/出现不同的字母,最后记得处理字符串的最后一位

通过本题,记住常用操作——将数字转换为字符:to_string(size)

可以写出上述操作的逆过程的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <string>

using namespace std;

bool isdigit(char s)
{
if (s >= '0' && s <= '9') return true;
return false;
}

string decompress(string s)
{
string res;

// s的第一个元素必为字母,从第二个元素开始可能为数字
// 一对对处理,先处理字母,再处理数字(可能有,也可能没有)
for (int i = 0; i < s.size(); i ++ )
{
// 处理字母
char tmp = s[i];

// 处理数字,计算count
int count = 0;
while (i + 1 < s.size() && isdigit(s[i + 1]))
{
count = count * 10 + s[i + 1] - '0';
i ++ ;
}

// 字母加入结果集
if (count == 0) res += tmp;
// 若有数字,则将字母重复数字遍
else while (count -- ) res += tmp; // 也可调用函数res.append(count, tmp);
}
return res;
}

int main()
{
string s = "a56b12";
string res = decompress(s);
cout << res << endl;
}

本题的关键在于字母和数字成对出现(当然数字可能没有),成对地处理字母和数字,将它们成对地放入res中。

454.四数相加II

本题用哈希做的时间复杂度应该为O(n^2)。先枚举nums1和nums2中所有元素之和的组合,然后再在nums3和nums4中查找所有元素之和为-nums1[i] - nums2[j]的情况。由于涉及到索引,所以要用map,map的key存数值,map的value存索引。value似乎要存一组索引,比如(i, j),我忘记怎么写了,看下博客。

实际上,应该是用map的key存储和,map的value存储出现这个和的次数。据此,我写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int, int> m;

for (int i = 0; i < nums1.size(); i ++ )
for (int j = 0; j < nums2.size(); j ++ )
m[nums1[i] + nums2[j]] ++ ;

int count = 0;
for (int i = 0; i < nums3.size(); i ++ )
{
for (int j = 0; j < nums4.size(); j ++ )
{
if (m.find(-nums3[i] - nums4[j]) != m.end())
count += m.find(-nums3[i] - nums4[j])->second;
}
}
return count;
}
};

更简洁的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int, int> m;

for (int num1: nums1)
for (int num2: nums2)
m[num1 + num2] ++ ;

int count = 0;
for (int num3: nums3)
for (int num4: nums4)
if (m.find(-num3 - num4) != m.end())
count += m[-num3 - num4];

return count;
}
};

注意:

  • 用map的key存储和,map的value存储出现这个和的次数
  • map的key不可重复,map的value可以重复。本题中的map起到一个将相同的和归拢,并用value统计其出现次数的作用
  • cpp中的map中的value是支持++操作的,且value可以通过key直接索引到,就像普通的数组那样
  • 时间和空间复杂度均为$O(n^2)$,空间复杂度为$O(n^2)$是两数组的数字各不相同,产生了$n^2$种组合。

383. 赎金信

本题用数组做哈希就可以,因为对象就是26个小写英文字母。据此,我写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
int cnt[26] = {0};

for (int i = 0; i < magazine.size(); i ++ )
cnt[magazine[i] - 'a'] ++ ;

for (int i = 0; i < ransomNote.size(); i ++ )
cnt[ransomNote[i] - 'a'] -- ;

for (int num: cnt)
if (num < 0) return false;
return true;
}
};

终极优化版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
int cnt[26] = {0};

// Each letter in magazine can only be used once in ransomNote
if (ransomNote.size() > magazine.size()) return false;

for (char m: magazine)
cnt[m - 'a'] ++ ;

for (char r: ransomNote)
{
cnt[r - 'a'] -- ;
if (cnt[r - 'a'] < 0) return false;
}
return true;
}
};

链接

93.复原IP地址
78.子集
90.子集II

知识

93.复原IP地址

  1. cpp中的string是有pop_back方法的,用于弹出字符串中的最后一个元素。

  2. 字符串中在i的后面插入一个逗点

    1
    s.insert(s.begin() + i + 1 , '.');  
  3. 删除特定位置处的逗点

    1
    s.erase(s.begin() + i + 1);       

初次尝试

93.复原IP地址

我尝试按照131.分割回文串的思路做本题,也写出了相应的代码,但运行结果和答案相差很大,而且代码非常复杂。我来看看卡尔的解法,看看如何写出正确而简单地处理这种字符串类型的回溯题的代码。

78.子集

据卡尔说,子集问题,就是收集树形结构中,每一个节点的结果。 整体代码其实和回溯模板都是差不多的。 对于本题的树形结构,我有一个想法:以1, 2, 3为例,首先选中1前面的空位,则要收集空和123。然后选中1,则要收集1和23。然后选中2,则要收集2和13。然后选中3,则要收集3和12。共有8个子集。但本题的代码我写不出来,直接看卡尔的视频讲解。

90.子集II

本题是40.组合总和II再加上78.子集。利用40题的去重办法(树层去重,用used数组,即if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0)),利用78题的子集问题的解法(主要是在所有节点而不仅仅是叶子节点上收集答案)。据此,我独立写出了本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, int startIndex, vector<int>& used)
{
res.push_back(path);

// 终止条件
if (startIndex >= nums.size()) return;

// 单层递归逻辑
for (int i = startIndex; i < nums.size(); i ++ )
{
// 树层去重
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;
// 处理节点
path.push_back(nums[i]);
used[i] = 1;
// 递归
backtracking(nums, i + 1, used);
// 回溯
path.pop_back();
used[i] = 0;
}
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
vector<int> used(nums.size(), 0);
sort(nums.begin(), nums.end()); // 一定记得要对nums排序
backtracking(nums, 0, used);
return res;
}
};

实现

93.复原IP地址

合法的IP地址:

  • 每个整数位于 0 到 255 之间组成
  • 数字前不能有0,即不能有先导0
  • 不能出现非0-9的字符

因此本题不仅需要对字符串进行切割,还要对子串进行合法性的判断。本题在回溯算法的切割问题中是一道较有难度的题。做了131.分割回文串后,再来做本题,会易于理解一些。使用回溯算法暴力枚举分割的每一种情况。画树形结构图。

20201123203735933.png

画出了上述树形图后,写代码还会有疑惑:

  • 如何模拟切割线

  • 怎么固定切割线,再在剩余的字符串中进行切割

  • 切割出的子串如何表达

接下来写具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
vector<string> res;

// startIndex表示下一层递归切割的位置,即切割线
// 一个IP需要有三个逗点进行分割,pointSum用于统计逗点的数量, pointSum决定了树的深度
void backtracking(string& s, int startIndex, int pointSum)
{
// 终止条件
// 每次加逗点,都是对其前面的子串做合法性判断
// 此时还需要专门对最后一个子串做合法性判断,最后一个子串合法了,才能将整个IP地址加入结果集中
// isvalid用于判断一个子串是否合法:数字前不能有0,数字在0-255之间,子串中不能有非法字符
if (pointSum == 3) {
if (isvalid(s, startIndex, s.size() - 1)) // 左闭右闭
{
res.push_back(s); // s会在后面被修改,具体来说是被切割并加上逗点
return;
}
}

// 单层搜索逻辑
for (int i = startIndex; i < s.size(); i ++ )
{
// 切割后,对产生的第一个子串的合法性进行判断。子串的区间:[startindex, i]
if (isvalid(s, startIndex, i))
{
// 进入下一层递归前,需要在子串后面加上逗点
// 将.插入到s.begin() + i的后面,故传入的参数是s.begin() + i + 1
s.insert(s.begin() + i + 1, '.');
pointSum += 1; // 逗点数量+1

// 递归
// 由于给字符串s额外加了一个逗点,因此是i + 2(本来是i + 1)
backtracking(s, i + 2, pointSum);

// 回溯
s.erase(s.begin() + i + 1); // 删除s中插入的逗点
pointSum -= 1;
}
}
}

上述代码的精妙之处在于,就是在原来的字符串s的基础上进行修改,修改就是在合适的位置上添加逗点。本题的关键在于如何模拟切割的过程。切割的过程本质上和组合问题的取数的过程是一样的。另外还需要对子串进行合法性的判断,子串是[startIndex, i]。子串合法后再加上逗点。

根据上述核心代码,我独立写出了解决本题的完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 直接在s的基础上添加逗号,得到可能的IP地址
class Solution {
public:
vector<string> res;

// 判断区间[start, end]的合法性
// 三个要求:1. 没有非数字的字符
// 2. 在0-255之间
// 3. 没有先导0
bool isvalid(string& s, int start, int end)
{
if (start > end) return false;

string tmp = s.substr(start, end - start + 1);

// 先导0
if (tmp.size() > 1 && tmp[0] == '0') return false;

int sum = 0;
int d = 1;

for (int i = tmp.size() - 1; i >= 0; i -- )
{
// 非数字的字符
if (tmp[i] < '0' || tmp[i] > '9') return false;
sum += (tmp[i] - '0') * d;
d = d * 10;
if (sum > 255) return false;
}
return true;
}

// startIndex为分割线,dotSum为逗点数目
void backtracking(string& s, int startIndex, int dotSum)
{
// 终止条件
if (dotSum == 3)
{
// 对第四段(s的最后一段)做合法性判断
if (isvalid(s, startIndex, s.size() - 1))
{
res.push_back(s);
}
return;
}

// 单层搜索逻辑
// 区间[startIndex, i]
for (int i = startIndex; i < s.size(); i ++ )
{
// 对区间合法性进行判断
if (isvalid(s, startIndex, i))
{
// 合法,则插入逗点
s.insert(s.begin() + i + 1, '.');
dotSum ++ ;

// 递归,本区间终止于i, 故下一个区间开始于i + 2
backtracking(s, i + 2, dotSum);

// 回溯
s.erase(s.begin() + i + 1);
dotSum -- ;
}
}
}

vector<string> restoreIpAddresses(string s) {
backtracking(s, 0, 0);
return res;
}
};

isvalid函数可以写的更简洁更自然:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool isvalid(string& s, int start, int end)
{
if (start > end) return false;

// 先导0
if (s[start] == '0' && start != end) return false;

int num = 0;
for (int i = start; i <= end; i ++ )
{
// 非数字字符
if (s[i] < '0' || s[i] > '9') return false;
// 在0-255之间
num = num * 10 + s[i] - '0';
if (num > 255) return false;
}
return true;
}
  • 时间复杂度: $O(3^4)$,IP地址最多包含4个数字,每个数字最多有3种可能的分割方式(1位,2位,3位);则搜索树的最大深度为4,每个节点最多有3个子节点(对应每个数字可能是1位,2位,3位的情况)。
  • 空间复杂度: $O(n)$,原因如下:
    • 递归栈:递归的深度固定,最多为4,因为IP地址由四部分组成。但这并不直接决定空间复杂度,因为递归深度很小。
    • 存储当前解:在递归过程中,需要存储当前正在构建的IP地址,这需要额外的空间。此外,每次递归调用时,都可能创建字符串的子串来表示IP地址的某一部分。字符串的最大长度为输入字符串的长度n,因此需要额外的空间来存储这些子串。
    • 输出解的集合:输出的解的数量并不直接影响空间复杂度的理论计算,但实际上会使用额外空间来存储所有可能的IP地址。然而,这些空间通常不计入空间复杂度计算中,因为它不依赖于递归过程中的临时存储需求。

78.子集

之前讲的组合问题、分割问题,我们都是在树形结构的叶子节点上收获结果,因此在终止条件处收获结果。可以画出如下的树形结构:

78.子集

观察如上树形结构,发现我们想收获的结果其实在每一个节点中。因此不是在终止条件中收获结果,而是每进入一层递归就将单个结果放入结果集中。现在开始对着树形结果写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 一维数组存放单个结果
vector<int> path;
// 二维数组作为结果集
vector<vector<int>> res;

// startIndex:下一层递归从哪里开始取数
void backtracking(vector<int> nums, int startIndex)
{
// 每进入一层递归,都要将当前的path放入结果集中
// 因为要将叶子节点的集合放入结果集中,然后再结束,因此先有本逻辑,再有终止条件
res.push_back(path);

// 终止条件:走到叶子节点,叶子节点的剩余集合都为空
// 本终止条件可以不写,因为单层搜索逻辑中的for循环已经对startIndex的大小进行了限制
if (startIndex >= nums.size()) return;

// 单层搜索逻辑
for (int i = startIndex; i < nums.size(); i ++ )
{
// 每进入一层递归,都要收获当前节点的结果,放入单个结果数组中
path.push_back(nums[i]);
// 进入下一层递归
backtracking(nums, i + 1);
// 回溯
path.pop_back();
}
}

不写终止条件的写法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, int startIndex)
{
res.push_back(path);

for (int i = startIndex; i < nums.size(); i ++ )
{
path.push_back(nums[i]);
backtracking(nums, i + 1);
path.pop_back();
}
return;
}

vector<vector<int>> subsets(vector<int>& nums) {
backtracking(nums, 0);
return res;
}
};

90.子集II

与78的区别:给的集合中允许有重复的元素,因此需要对重复子集去重。本题的关键在于去重,本题是子集+组合总和II(树层去重)的结合,并没有新的知识点。

本题的树形结构。used数组用于记录某个元素是否出现过。因为去重要让大小相邻的元素挨在一起,因此需要先对数组进行排序。本题的去重是树层去重(树层上相邻的元素如果相等,则不能重复取,否则会得到重复的子集),树枝不需要去重。

90.子集II

现在开始写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
vector<int> path; // 单个结果
vector<vector<int>> res; // 结果集

void backtracking(vector<int>& nums, int startIndex, vector<int>& used)
{
// 终止条件不需要写,在for循环中实际上已经限制了startIndex的大小
res.push_back(path); // 收获结果,需要在每个节点都收获结果

// 单层递归逻辑
for (int i = startIndex; i < nums.size(); i ++ )
{
// 树层去重, used[i - 1] == 0意味着第i - 1个元素是第i个元素的回溯
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] = 0) continue;
// 处理节点
path.push_back(nums[i]);
used[i] = 1;
// 递归
backtracking(nums, i + 1, used);
// 回溯
path.pop_back();
used[i] = 0;
}
}

回溯中的去重逻辑都这么写。本题去重也可以用startIndex和i进行比较来实现,但这种去重写法并不通用,遇到排列问题时依然要用used数组的写法进行去重。去重的写法掌握used数组写法即可。

本题的时间和空间复杂度和78相同。时间复杂度: $O(n\times2^n)$,空间复杂度: $O(n)$。

本题也可以用哈希法去重,但时间复杂度更高,虽然也能够通过所有测试样例且不超时,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, int startIndex)
{
res.push_back(path);
unordered_set<int> s;

for (int i = startIndex; i < nums.size(); i ++ )
{
// set去重
if (s.find(nums[i]) != s.end()) continue;

// 处理节点
s.insert(nums[i]);
path.push_back(nums[i]);
// 递归
backtracking(nums, i + 1);
// 回溯
path.pop_back();
}
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
sort(nums.begin(), nums.end()); // set去重依然需要排序
backtracking(nums, 0);
return res;
}
};

因此,本题的去重写法有三种:

  • used数组去重
  • startIndex去重
  • set去重

掌握第一种即可。第一种是思路最清晰也最通用的。

本题像78一样,也可以不写终止条件,因为startIndex的大小在for循环中已经得到了限制。精简版本的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, int startIndex, vector<int>& used)
{
res.push_back(path);

for (int i = startIndex; i < nums.size(); i ++ )
{
// 去重
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;
// 处理节点
path.push_back(nums[i]);
used[i] = 1;
// 递归
backtracking(nums, i + 1, used);
// 回溯
path.pop_back();
used[i] = 0;
}
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
vector<int> used(nums.size(), 0);
sort(nums.begin(), nums.end());
backtracking(nums, 0, used);
return res;
}
};

心得与备忘录

93.复原IP地址

  1. 本题是131.分割回文串的加强版。因为和131同样是用回溯法求解的分割问题,所以基本原理是相同的,比如startIndex用于作为分割线,分割的区间是[startIndex, i]
  2. 本题的终止条件和131有显著地不同。131的终止条件是startIndex移动到字符串的末尾,而本题的终止条件是添加了3个逗点,且最后一段区间是合法的。3个逗点的终止条件也限制了树的深度。
  3. 一般处理字符串的问题都比较复杂。本题对字符串处理的精妙之处在于直接在原本的字符串s上进行修改,添加逗点,作为分隔符从而得到合法的IP地址。本题还用到了两个和字符串有关的STL,分别是inserterase函数。
  4. 本题对区间合法性的判断较为复杂,共有3个要求:
    • 段位以0为开头的数字不合法
    • 段位里有非正整数字符不合法
    • 段位如果大于255了不合法
    • 段位若大于255,则立即判断为不合法,return false。若完成for循环后再对num进行判断,可能出现整数型变量溢出
  5. 本题的时间复杂度:$O(3^4)$,空间复杂度:$O(n)$
  6. 本题的细节比较多,比较容易写错,属于回溯法解决分割问题中的难题,需要不断复习。

78.子集

  1. 子集是收集树形结构中树的所有节点的结果。而组合问题、分割问题是收集树形结构中叶子节点的结果。
  2. 子集也是一种组合问题,因为它的集合是无序的,子集{1,2}和子集{2,1}是一样的。那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始。
  3. 先收集结果集,再写终止条件的原因:当递归到叶子节点时,要先将叶子节点的结果放入结果集中,再终止,因此先写收集结果集的逻辑,再写终止条件。否则叶子节点的结果无法被放入结果集中。
  4. 本题也可以不写终止条件,因为单层递归逻辑的for循环中实际上限制了startIndex的大小,因此最后return即可。但初学者还是建议写终止条件,和标准的回溯模板保持一致。
  5. 本题的时间复杂度: $O(n\times2^n)$,空间复杂度: $O(n)$。时间和空间复杂度的分析同77.组合

90.子集II

  1. 本题是40.组合总和II与78.子集这两题的结合。

  2. 40的精华在于去重(树层去重),78的精华在于在每个节点处都收集结果(而不是像组合、分割问题那样在叶子节点,即终止条件处收集结果),而是在递归函数的开始处(进入递归相当于进入一个节点)收集结果。本题结合了两题的精华。

  3. 树层去重掌握used数组写法即可,具体代码为:

    1
    if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;
  4. 树层去重前,需要对nums数组进行排序。
  5. 本题的时间和空间复杂度和上一题(78)相同。
  6. 去重共有三种写法,掌握思路最清晰也最通用的used数组写法即可。
  7. 本题像78一样,也可以不写终止条件,因为startIndex的大小在for循环中已经得到了限制。

链接

39.组合总和
40.组合总和II
131.分割回文串

知识

40.组合总和II

创建一个和a数组大小相同的b数组,将其中的元素全部置为0。

1
2
vector<int> a;
vector<int> b(a.size(), 0);

131.分割回文串

substr(i, j) 会从索引 i 开始,取长度为 j 的子字符串。

void backtracking(const string& s, int startIndex)中使用const的原因:

  1. 防止修改const 关键字确保 s 字符串在 backtracking 函数中不会被修改。这是一种安全措施,可以防止函数意外地更改输入数据,从而保持数据的完整性。在处理函数参数时,尤其是在不应该或不需要修改输入的情况下,使用 const 可以提供这种保护。
  2. 接口设计:在函数原型中使用 const 声明参数可以向函数的用户清楚地表明这个参数是用来输入数据的,不应该被函数改变。这有助于提高代码的可读性和可维护性,使得其他开发者更容易理解每个函数的作用和行为。

初次尝试

39.组合总和

本题是集合里元素可以用无数次,那么和组合问题的差别,其实仅在于startIndex上的控制。本题若是想不重不漏,则下一层遍历的起始位置应该与上一层取出的数相同。而对于组合问题,下一层遍历的起始位置应该是上一层取出的数的下一个(因为组合问题中的元素不能重复使用)。据此,我写出了以下的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& candidates, int target, int s, int startIndex)
{
if (s > target) return;

// 终止条件
if (s == target)
{
res.push_back(path);
return;
}

// 单层搜索逻辑
for (int i = startIndex; i < candidates.size(); i ++ )
{
// 处理节点
s += candidates[i];
path.push_back(candidates[i]);
// 递归
backtracking(candidates, target, s, i);
// 回溯
s -= candidates[i];
path.pop_back();
}
return;
}

vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtracking(candidates, target, 0, 0);
return res;
}
};

40.组合总和II

本题我能顺畅地写出不加去重的版本,如下所示。但对去重没有思路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& candidates, int target, int s, int startIndex)
{
// 终止条件
if (s > target) return;

if (s == target)
{
res.push_back(path);
return;
}

// 单层搜索逻辑
for (int i = startIndex; i < candidates.size(); i ++ )
{
// 处理节点
path.push_back(candidates[i]);
s += candidates[i];
// 递归
backtracking(candidates, target, s, i + 1);
// 回溯
path.pop_back();
s -= candidates[i];
}
}

vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
backtracking(candidates, target, 0, 0);
return res;
}
};

对以下测试样例会出现报错:

1
2
3
4
5
6
7
8
candidates =
[10,1,2,7,6,1,5]
target =
8
Output
[[1,2,5],[1,7],[1,6,1],[2,6],[2,1,5],[7,1]]
Expected
[[1,1,6],[1,2,5],[1,7],[2,6]]

很明显,上述代码是需要去重的。

131.分割回文串

拿到本题,我没有思路,因为没有做过分割问题,直接看卡尔的讲解。

实现

39.组合总和

本题与组合问题的区别:集合中的元素可以重复选取,组合中元素的数量不加限定。集合中都是正整数(若有0,则会进入死循环),且集合中没有重复的元素(这意味着不用做去重的操作)。

本题通过和来限制树的深度,而组合问题通过组合中元素的数量来限制树的深度。本题的树形结构如下所示:
Snipaste_2024-05-03_08-36-04.png

由于集合中的元素可以重复使用,因此下一层的集合中应该包括本层选取的元素。现在开始写本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
vector<vector<int>> res;
vector<int> path;

// 可以用sum表示组合的和,也可以不用sum,让target不断做减法,直到target == 0
// startIndex用于设置下一层递归的起点
void backtracking(vector<int>& candidate, int target, int sum, int startIndex)
{
// 终止条件
if (sum > target) return;
if (sum == target)
{
res.push_back(path);
return;
}

// 单层搜索的逻辑
for (int i = startIndex; i < candidate.size(); i ++ )
{
// 处理节点
path.push_back(candidate[i]);
sum += candidate[i];
// 递归,注意下一层的startIndex是从i开始,因为集合中的元素可以重复选取
backtracking(candidate, target, sum, i);
// 回溯
sum -= candidate[i];
path.pop_back();
}
}

上述代码和回溯算法的模板是类似的。本题依然可以做剪枝的操作。具体来说,是对for循环进行剪枝。对candidate数组进行排序后,若某个分支的和大于target,那么就没必要对其后面的分支进行搜索了。加入剪枝操作的完整代码如下所示(注意添加了注释的部分,就是实现剪枝的具体代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& candidates, int target, int s, int startIndex)
{
if (s > target) return;
if (s == target)
{
res.push_back(path);
return;
}

// 剪枝操作
for (int i = startIndex; i < candidates.size() && s + candidates[i] <= target; i ++ )
{
s += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, s, i);
s -= candidates[i];
path.pop_back();
}
return;
}

vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end()); // 排序
backtracking(candidates, target, 0, 0);
return res;
}
};

剪枝操作总结:对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i],相当于把下一层组合可能的sum从小到大扫了过去)已经大于target,就可以结束本轮for循环的遍历

  • 时间复杂度: $O(n \times 2^n)$,注意这只是复杂度的上界,因为剪枝的存在,真实的时间复杂度远小于此。本题的时间复杂度分析同77. 组合。

  • 空间复杂度: $O(target)$

为何是$O(target)$:

  1. 递归栈深度: 空间复杂度首先取决于递归调用的最大深度,因为这直接影响了调用栈的大小。在组合总和问题中,你可以多次选择同一个数字,直到其和超过目标值 target 或恰好等于 target。最糟糕的情况发生在选择了最小元素直到达到 target 时,这种情况下,递归的最大深度大约是 target / min(candidates)。如果最小的候选数很小,理论上递归的深度可以接近 target
  2. 路径存储: 在递归过程中,我们还需要存储当前的组合路径(即当前选取的数字集合)。在最坏的情况下,即当所有选取的数字加起来等于 target 时,路径的长度也可以接近于 target / min(candidates)。尽管路径的具体长度依赖于候选数字的大小,但在分析空间复杂度时,我们考虑最坏情况,即多次选取最小值,使得路径长度和递归深度都接近于 target

40.组合总和II

本题差别:本题的集合中有重复的元素(之前的所有组合问题的集合中都没重复元素),不能有重复的组合。这说明我们要去重。另外,集合中的元素在组合中只能使用一次,这需要用一个变量进行控制。

一种朴素的想法:用之前的方法搜索组合,搜索出若干组合,其中肯定有重复的。用map或者set进行去重,输出去重后的所有组合。本方法实现起来较麻烦,且特别容易超时。

接下来介绍在搜索的过程中直接去重的方法:使用过的元素不重复使用。为了讲清楚本题的去重过程,卡尔自创了两个词汇:树层去重,树枝去重。去重要考虑到这两个维度。接下来画树形图,从两个维度看如何去重。去重前还需要对集合进行排序。去重需要一个数组used来告诉我们哪些元素使用过,哪些元素没用过。用过的元素的下标在used中对应的值为1,没用过的元素的下标在used中对应的值为0。
20230310000918.png

上述树除去used数组外的基本部分,还是下一层第一个取的数是上一层取的数往后挪一位(即backtracking(candidates, target, s, i + 1))。这样的目的是避免重复。对于树枝(树往深度方向走),是可以重复取值的,因为取的是一个集合中不同位置的数值相同的元素。对于树层(同一层树往横向走),不可以重复取值,必然会与之前的某个组合重复。对集合排序的目的就是将值相邻的元素放在一起,若同一层的两个分支的值相同,那么靠左边的分支会包含靠右边的分支的所有情况。因此去重的关键在于树层去重。具体的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
vector<int> path;
vector<vector<int>> res;

// 本代码的重点在于树层去重的过程
// used数组用于标记某个元素是否使用过,用过1,没用过0
// 调用本函数前需要对集合做排序,目的是让值相同的元素在位置上相邻,方便做树层去重
void backtracking(vector<int> nums, int targetSum, int sum, int startIndex, vector<int> used)
{
// 终止条件
if (sum > targetSum) return;
if (sum == targetSum)
{
res.push_back(path);
return;
}

// 单层搜索逻辑
// for循环是在同一层遍历各个节点,因此接下来就要写树层去重的逻辑
for (int i = startIndex; i < nums.size(); i ++ )
{
// 树层去重, i > 0的目的是让i - 1 >= 0,也可以写作i > startIndex
// used[i - 1] == 0对应于上面树的情况,就是第1个1没用,直接用了第2个1,此时重复读取,需要树层去重
// 若nums[i] == nums[i - 1] && used[i - 1] == 1,则说明是树枝的状态,由于不需要树枝去重,所以此时不需要去重
// 后续在回溯算法中遇到去重问题并使用used数组时,基本都是这种写法
if (i > startIndex && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;
// 收集元素
path.push_back(nums[i]);
sum += nums[i];
used[i] = 1;
// 递归
backtracking(nums, targetSum, sum, i + 1, used);
// 回溯
path.pop_back();
sum -= nums[i];
used[i] = 0;
}
}

可以用used数组进行去重,也可以用startIndex进行去重,这里不再深入讲解。用startIndex去重比较抽象,因此理解用used数组去重即可,更易于理解且通用。本题的关键在于理解去重的思路。

本题的完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& candidates, int target, int s, int startIndex, vector<int> used)
{
// 终止条件
if (s > target) return;
if (s == target)
{
res.push_back(path);
return;
}

// 单层搜索逻辑
for (int i = startIndex; i < candidates.size(); i ++ )
{
// 树层去重
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == 0) continue;
// 处理节点
path.push_back(candidates[i]);
used[i] = 1;
s += candidates[i];

// 递归
backtracking(candidates, target, s, i + 1, used);

// 回溯
path.pop_back();
used[i] = 0;
s -= candidates[i];
}
}

vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
// used数组用于标记candidates数组中的元素是否使用过,因此used数组大小应该与candidates数组大小保持相同
vector<int> used(candidates.size(), 0);
sort(candidates.begin(), candidates.end()); // 别忘记排序
backtracking(candidates, target, 0, 0, used);
return res;
}
};

时间复杂度: $O(n \times 2^n)$。同77.组合和39.组合总和。
空间复杂度:$O(n)$。原因:树的最大深度为n(同candidates数组的长度)。

131.分割回文串

aab。有两种分割方案:aa|b和a|a|b。本题要求我们返回所有的分割方案。如何使用回溯算法解决这个问题?

分割问题和组合问题非常相似。例如abcdef,对组合问题,如果选择了a,则在bcdef中选择下一个字母;如果选择了b,则在cdef中选择下一个字母。同理,对于分割问题,如果分割了a,则接下来分割bcdef。再分割b,则接下来分割cdef。接下来画分割问题的树形结构。

131.分割回文串.jpg

切割线到了字符串的末尾,则切割完毕。结果都在叶子节点。画树形结构较为简单,具体的代码实现中有几个难点,现在开始写具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
vector<string> path;
vector<vector<string>> res;

// 注意传入的变量类型是const string,再加上引用
// startIndex控制下一次切割的位置
void backtracking(const string& s, int startIndex)
{
// 终止条件
// 切割线到字符串的末尾,则终止
// 切割线用startIndex表示
if (startIndex >= s.size())
{
// 将判断是否是回文的逻辑放入单层搜索的逻辑中
// 因此终止条件中的path都是符合回文条件的
res.push_back(path);
return;
}

// 单层搜索的逻辑
for (int i = startIndex; i < s.size(); i ++ )
{
// 如何用代码表示切割出的子串
// 切割的子串:[startIndex, i],左闭右闭的区间
// 用于判断是否回文的函数
if (isPalindrome(s, startIndex, i)) // 传入字符串,子串的起始位置,子串的终止位置
{
path.push_back(子串); // 是回文,则将子串放入path中
}
else continue;
// 递归
backtracking(s, i + 1); // 下一层切割点从上一层切割点的下个位置开始,否则会重复
// 回溯
path.pop_back();
}
}

isPalindrome函数用双指针算法可以轻松实现。注意本题的两个细节:

  • startIndex是切割线

  • 如何表示子串的范围:[startIndex, i]

完整的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Solution {
public:
vector<string> path;
vector<vector<string>> res;

// 判断[start, end]是否是回文子串
bool isPalindrome(const string& s, int start, int end)
{
for (int i = start, j = end; i <= j; i ++ , j -- )
if (s[i] != s[j])
return false;
return true;
}

void backtracking(const string& s, int startIndex)
{
// 终止条件
if (startIndex >= s.size())
{
res.push_back(path);
return;
}

// 单层搜索逻辑
for (int i = startIndex; i < s.size(); i ++ )
{
// 是回文子串,则将其加入path中
if (isPalindrome(s, startIndex, i))
path.push_back(s.substr(startIndex, i - startIndex + 1));
else continue;

// 递归
backtracking(s, i + 1);

// 回溯
path.pop_back();
}
}

vector<vector<string>> partition(string s) {
backtracking(s, 0);
return res;
}
};

  • 时间复杂度: $O(n \times 2^n)$,时间复杂度同77.组合、39.组合总和、40.组合总和II。

  • 空间复杂度: $O(n^2)$,原因解释如下:

    1. 递归栈的空间:最深的递归发生在当字符串每个字符都被分割时,因此递归深度最大为$n$(其中$n$是字符串的长度)。每一层递归需要保存当前索引和路径,这些额外的空间可以认为是常数级别的。
    2. 路径存储空间 (pathres):
      • path 变量在最坏情况下(每个字符都独立成一个回文串时)会存储$n$个元素。
      • res 变量存储的是所有可能的分割方案。在极端情况下,如输入字符串完全由相同字符组成(例如 “aaaa”),分割方案的数量和其中每个方案的长度都可能接近$n$。但通常来说,我们只计算这个变量直接占用的空间,即指针或引用的空间,这通常也是$O(n^2)$,因为每个回文分割的保存都可能需要一个长度为 的$n$字符串的复制。
    3. 辅助空间
      • 检查回文所用的额外空间是常量级的,不随输入大小变化。

    将以上所有考虑结合,整个算法的空间复杂度主要由存储所有分割方案的数组 res 决定。由于每个分割方案可能包含多个字符串,而每个字符串又可能需要$O(n)$的空间,因此在最坏情况下,这部分的空间复杂度为$O(n⋅k)$,其中 $k$是分割方案的数量,这在极端情况下可以达到$O(n^2)$。

心得与备忘录

39.组合总和

  1. 本题通过target来限制树的深度,而77. 组合通过组合中元素的个数来限制树的深度。
  2. 本题是集合里元素可以用无数次,那么和组合问题的差别,其实仅在于startIndex上的控制。本题若是想不重不漏,则下一层遍历的起始位置应该与上一层取出的数相同。而对于组合问题,下一层遍历的起始位置应该是上一层取出的数的下一个(因为组合问题中的元素不能重复使用)。
  3. 本题的时间复杂度:$O(n \times 2^n)$,空间复杂度:$O(target)$。
  4. 本题可以进行剪枝操作。具体来说,是对for循环进行剪枝。对candidate数组进行排序后,若某个分支的和大于target,那么就没必要对其后面的分支进行搜索了。体现在代码上,就是对总集合排序之后,如果下一层的sum(就是本层的sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。本题的剪枝不好想,要多加注意

40.组合总和II

  1. 本题的难点:集合有重复元素,但组合不能重复。

  2. 本题需要对组合去重,但不能在搜索完整棵树后用哈希法去重,容易超时。需要在搜索的过程中去重,这需要用到used数组。其中用过的元素标记为1,没用过的元素标记为0。

  3. 去重:只需要树层去重(树的同一层若两元素值相同,则右侧的值所在的路径必然被包含在左侧的值所在的路径中),不需要树枝去重(集合中的元素值可以相同,每个元素均可以使用一次,因此不需要对树枝去重)。

  4. 本题不可忽视的几个细节:

    • 集合需要进行排序,这是为了将值相同的元素放在集合中相邻的位置,便于树层去重

    • used数组的大小需要与candidates数组保持相同,因为其是用来标记candidates数组中元素的使用情况的

    • 注意树层去重的代码的写法,建议结合实际例子(实现中的图片)进行理解

      1
      2
      // 树层去重
      if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == 0) continue;
      • candidates[i] == candidates[i - 1] && used[i - 1] == 0说明同一树层上相邻的两个元素相同,此时需要进行树层去重
      • used[i - 1] == 1,说明同一树枝上的candidates[i - 1]被使用过(同一树枝从上往下遍历,未进行回溯,因此candidates[i - 1]始终被标记为被使用过,即used[i - 1] = 1
      • used[i - 1] == 0,说明同一树层上的candidates[i - 1]被使用过(同一树层从左往右经历过回溯的过程:先对candidates[i - 1]所在的树枝从上往下遍历,然后回溯,再对candidates[i]所在的树枝从上往下遍历。在回溯的过程中,candidates[i - 1]被重新标记为未被使用过,即used[i - 1] = 0
  5. 本题的去重代码不好写,同时细节较多需要注意。因此本题容易写错,需要时常复习。

  6. 后续在回溯算法中遇到去重问题并使用used数组时,基本都是这种写法:if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == 0) continue;

  7. 本题也可以用startIndex进行去重,但比较难理解,因此不要求掌握。

  8. 本题可以像39.组合总和一样进行剪枝操作,只需要在for循环中对i加上限制条件:s + candidates[i] <= target即可。

131.分割回文串

  1. 首先,切割问题其实本质和组合问题是相同的。

    组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个…..。接着选取一个b后,再从cdef中再去选取第二个,以此类推。

    切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段…..。接着从b那里切下去,在cded中再去切割第二段,以此类推。

    可以观察本题的树形结构图,能够更加直观地理解切割问题和组合问题的相似。

  2. 什么是切割线?

    递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。

  3. 终止条件:切割线startIndex移动到了字符串的末尾,即startIndex >= s.size()

  4. 如何截取子串?[startIndex, i]之间的字符串就是子串。用substr函数截取即可。需要判断子串是否是回文串,是则放入path中,不是则continue

  5. 使用最简单的双指针算法即可写出判断字符串是否是回文串的函数。

  6. 本题的空间复杂度$O(n^2)$。是极端情况下的空间复杂度,原因参见本题的实现部分。

  7. 从主函数传入的参数,在定义其他函数时若需要这个参数,则需要将其设置为const类型。目的是防止其他函数对这个参数的修改,同时向函数的用户清楚地表明这个参数是用来输入数据的。不加const不影响代码的正常运行,但加了const后代码更加规范。

链接

216.组合总和III
17.电话号码的字母组合

初次尝试

216.组合总和III

针对本题,我沿用上题77. 组合的代码,只是在终止条件中添加了条件:sum(path) == n,并在单层搜索逻辑中将终止条件改为i = 9,即可解决本题。据此,我独立写出了本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

int sum(vector<int> path)
{
int s = 0;
for (int i = 0; i < path.size(); i ++ )
s += path[i];
return s;
}

void backtracking(int k, int n, int startIndex)
{
// 终止条件
if (path.size() == k && sum(path) == n)
{
res.push_back(path);
return;
}

// 单层搜索逻辑
for (int i = startIndex; i <= 9; i ++ )
{
// 处理节点
path.push_back(i);
// 递归
backtracking(k, n, i + 1);
// 回溯
path.pop_back();
}
return;
}

vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 1);
return res;
}
};

本题应该也是可以进行剪枝优化的。首先的要求当然还是k个数,因此i最大只能取到9 - k + path.size() + 1,即10 - k + path.size()。但这样会导致TLE(超时),原因尚不清楚。直接看卡尔的讲解。

17.电话号码的字母组合

本题应该还是属于组合问题的范畴。先尝试画出本题的树形结构。相当于每个数字对应三个字母,第一个数字对应的三个字母和第二个数组对应的三个字母间进行组合。若有n个数字,则有3n个字母,放入一个string a中,第一个字母从a[0]-a[2]中取,第二个字母从a[3]-a[5]中取,以此类推。相当于依然是一个组合问题,只不过每一层递归for循环的开始和结束是不固定的,需要用上述规则进行更新。据此,我尝试独立写出本题的代码框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public:
vector<string> res;
string path;
string all;

// 将digits按照数字和字母间的对应关系转换为all字符串
void transfer(string digits)
{
for (char c: digits)
{
if (c == '2') all += "abc";
else if (c == '3') all += "def";
else if (c == '4') all += "ghi";
else if (c == '5') all += "jkl";
else if (c == '6') all += "mno";
else if (c == '7') all += "pqrs";
else if (c == '8') all += "tuv";
else all += "wxyz";
}
}

void backtracking(string digits, int startIndex, int endIndex)
{
// 终止条件
if (path.size() == digits.size()) res.push_back(path);

// 单层递归逻辑
for (int i = startIndex; i <= endIndex; i ++ )
{
// 处理节点
path += all.substr(startIndex, endIndex);
// 递归
backtracking(digits, startIndex, endIndex);
// 回溯
path -= all.substr(startIndex, endIndex);
}
}

vector<string> letterCombinations(string digits) {

}
};

遇到的难点:startIndexendIndex不好确定,因为部分数字不止对应三个字母。直接看卡尔的讲解。果然涉及字符串的题目都不好做啊。

实现

216.组合总和III

[1, 9]。和为n,个数为k的所有组合。本题和上题77. 组合的区别:限制和为n,集合是固定的(1-9),因此相当于在77. 组合的基础上加了一个和的限制。组合不强调元素间的顺序,排列强调元素间的顺序。暴力做法,当k=2时,两层for循环遍历1-9,找到两个相加等于n的数。暴力的想法代码没法写,所有要用回溯算法。回溯算法也是暴力的方式,只不过回溯算法通过递归的方式帮助我们控制for循环的嵌套层数,递归n层即相当于模拟了n层嵌套的for循环。

本题的树形结构如下所示:
Snipaste_2024-05-02_03-55-06.png

树的深度为k,树的宽度是当前层的集合中的元素的个数。现在来写具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
vector<int> path;
vector<vector<int>> res;
int sum;

// sum为当前路径已有的和, 将其与targetSum(即n)做一个比较,相等即符合题目的要求
// 本题的startIndex用途同77. 组合中的startIndex,初始值为1
void backtracking(int targetSum, int k, int sum, int startIndex)
{
// 终止条件
if (path.size() == k)
if (targetSum == sum)
res.push_back(path);

// 单层搜索的逻辑
for (int i = startIndex; i <= 9; i ++ )
{
// 处理节点
sum += i;
path.push_back(i);
// 递归
backtracking(targetSum, k, sum, i + 1);
// 回溯
sum -= i;
path.pop_back();
}
}

完整的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(int k, int n, int startIndex, int sum)
{
// 终止条件
if (path.size() == k && sum == n)
res.push_back(path);

// 单层搜索逻辑
for (int i = startIndex; i <= 9; i ++ )
{
// 处理节点
sum += i;
path.push_back(i);
// 递归
backtracking(k, n, i + 1, sum);
// 回溯
sum -= i;
path.pop_back();
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 1, 0);
return res;
}
};

接下来对上述代码进行剪枝优化。第一个剪枝在于满足targetSum的要求。剪枝代码放在终止条件之前:

1
2
if (sum > targetSum)
return;

还有一个剪枝(满足集合中元素个数的要求),和77.组合中的剪枝是相同的。当前组合中有path.size()个元素,还需要k - path.size()个元素,因此i的最大起始位置为9 - (k - path.size()) + 1。因此:

1
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i ++ )

加上完整的剪枝优化后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(int k, int n, int startIndex, int sum)
{
// 剪枝操作1
if (sum > n) return;

// 终止条件
if (path.size() == k && sum == n)
res.push_back(path);

// 单层搜索逻辑
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i ++ ) // 剪枝操作2
{
// 处理节点
sum += i;
// if (sum > n) return;也可以放在此处
path.push_back(i);
// 递归
backtracking(k, n, i + 1, sum);
// 回溯
sum -= i;
path.pop_back();
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 1, 0);
return res;
}
};

我发现,若仅仅进行剪枝操作2,但不进行剪枝操作1,程序就会报错:TLE。

我还发现,尽管if (sum > n) return;放在sum += i之后,程序可以通过测评。但正统的写法应当为在剪枝前,先把回溯给做了,否则可能会漏掉满足要求的组合(程序没有进行回溯,就试图去寻找新的满足要求的组合了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 当然这个剪枝也可以放在调用递归之前,只不过要记得把回溯操作给做了
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
// 处理节点
sum += i;
path.push_back(i);
if (sum > targetSum) { // 剪枝操作
sum -= i; // 剪枝之前先把回溯做了
path.pop_back(); // 剪枝之前先把回溯做了
return;
}
// 递归
backtracking(targetSum, k, sum, i + 1);
// 回溯
sum -= i;
path.pop_back();
}

本题的时间和空间复杂度同77.组合的时空复杂度。

17.电话号码的字母组合

电话拨号盘,每个数字代表一个字符串。首先需要做映射。将输入的字符串(一串数字)映射为对应的字符串。可以用map或者二维数组做映射,这里使用二维数组。数组中的每个元素是字符串。

1
2
3
4
5
6
7
8
9
10
11
12
string letterMap[10] = {
" ", // 0
" ", // 1
abc, // 2
def, // 3
ghi, // 4
jkl, // 5
mno, // 6
pqrs, // 7
tuv, // 8
wxyz, // 9
}

这样拿到digits中的数字,将其作为下标放入字符串,即可得到数字对应的字符串(举例:letterMap[2] = "abc")。

暴力做法:输入两个数字,则要进行两重for循环。输入n个数字,则要进行n重for循环。此时想到用回溯算法进行暴力求解。回溯算法可通过递归的方式实现对for循环的嵌套。以输入2,3为例,尝试画出本题的树形结构:
Snipaste_2024-05-02_22-19-15.png

结果就在树形结构的叶子节点中。树的深度是输入数字的个数,树的宽度由每一个数字对应的字符串的长度控制。现在尝试写本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
string s; // 用于存储单个结果
vector<string> res; // 收获结果集

// index用于标识传入的字符串digits在当前递归中遍历到哪一个字符(实际上是数字)了
// startIndex一般用于一个集合中求组合,避免得到重复的组合
// 本题是在多个集合中各取一个元素出来做组合,因此不需要startIndex来帮助控制集合中之前遍历过哪些元素
void backtracking(string digits, int index)
{
// 终止条件
// index指向digits的最后一位的下一位,才终止。若index指向digits的最后一位,其后应该还有处理最后一位的逻辑
if (index == digits.size())
{
res.push_back(s); // 收获结果
return;
}

// 单层搜索逻辑
// 取出digits中的数字
int digit = digits[index] - '0'; // 字符转换为数字
// 找出digit对应的字符串
string letter = letterMap[digit];
// 遍历digit对应的字符串
for (int i = 0; i < letter.size(); i ++ )
{
// 处理节点
s.push_back(letter[i]);
// 下一层递归,index后移一位
backtracking(digits, index + 1);
// 回溯
s.pop_back();
}
}

本题看似复杂,但画图理解后逻辑清晰,代码也不长。

可以将代码写得更简洁,递归函数传入三个参数:void backtracking(string digits, int index, string s),然后单层搜索逻辑的三行代码写成一行:backtracking(digits, index + 1, s + letter[i]);s的值本身并没有改变,这就是将回溯的过程隐藏在参数中了。

本题完整可运行的程序如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Solution {
public:
string s; // 存储单个组合
vector<string> res; // 结果集

// 存储数字和字符串之间的映射关系
vector<string> letterMap = {
" ", // 0
" ", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz" // 9
};

// index用于标记当前层遍历到了digits中的哪个位置
void backtracking(string digits, int index)
{
// 终止条件
if (index == digits.size())
{
res.push_back(s);
return; // 这个return不能去掉,否则程序会报错
}

// 单层递归逻辑
int digit = digits[index] - '0'; // 取出当前层的数字
string letter = letterMap[digit]; // 取出当前层需要遍历的字符串
for (int i = 0; i < letter.size(); i ++ )
{
// 处理节点
s.push_back(letter[i]);
// 向下一层递归
backtracking(digits, index + 1);
// 回溯
s.pop_back();
}
return; // 这个return可要可不要,但为了和回溯法模板保持一致,因此还是加上
}

vector<string> letterCombinations(string digits) {
if (digits.empty()) return res; // 必须加上这句话,特判digits为空的情况
backtracking(digits, 0);
return res;
}
};

简化后的写法(隐藏回溯逻辑):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
public:
string s;
vector<string> res;

vector<string> all = {
"",
"",
"abc",
"def",
"ghi",
"jkl",
"mno",
"pqrs",
"tuv",
"wxyz"
};

void backtracking(string digits, int index, string s)
{
if (index == digits.size())
{
res.push_back(s);
return;
}

// 单层搜索逻辑
int digit = digits[index] - '0';
string letter = all[digit];
for (int i = 0; i < letter.size(); i ++ )
backtracking(digits, index + 1, s + letter[i]);
}

vector<string> letterCombinations(string digits) {
if (digits.empty()) return res;
backtracking(digits, 0, s);
return res;
}
};

时间复杂度分析

对于每个按键,电话按键可能对应不同数量的字母:

  • 按键 2, 3, 4, 5, 6, 8 每个都对应 3 个字母。
  • 按键 7 和 9 对应 4 个字母。

如果输入的字符串中有 m 个按键对应 4 个字母,n 个按键对应 3 个字母,那么所有可能的组合数量是 4^m * 3^n。因为这是回溯算法的常见分析模式,每一步选择会进入下一层递归,直到达到输入字符串的长度。在每一层递归中,根据当前按键可能的字母数量,我们有不同的选择分支。

因此,整个算法需要考虑的总路径数或调用次数是 O(4^m * 3^n)

空间复杂度分析

空间复杂度主要由两部分构成:

  1. 递归调用栈:最大深度为输入字符串的长度,即 m + n。然而,这通常认为是 O(m+n),不是主要的空间消耗部分。

  2. 输出存储空间:存储所有可能组合的空间,这是算法的主要空间消耗。每个组合都是一个新的字符串,因此需要的总空间是与生成的组合数量相同,即 O(4^m * 3^n)

如果空间复杂度中不计入输出存储空间,则空间复杂度是O(m+n)。若计入,则是O(4^m * 3^n)。

心得与备忘录

216.组合总和III

  1. 本题的思路和77.组合的完全相同,只不过加了限制条件:组合中所有元素之和为n。
  2. 本题有两种写法。第一种是我在初次尝试中的写法,最大限度地沿用了77.组合的代码,只不过另外实现了一个sum函数来统计path数组中所有元素之和,并在终止条件中与n进行比较。这种写法存在一个巨大的缺陷,就是无法进行剪枝。因为只有实现了剪枝操作1:if (sum > n) return;后,才能实现剪枝操作2:for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i ++ ),而写法1的递归函数的参数中没有sum,因此剪枝操作1必然无法实现,这导致剪枝操作2也无法实现(强行添加剪枝操作2,程序直接报错TLE(超时))。
  3. 本题的第二种写法更为正统,递归函数传入的参数中包含了sum,即当前path数组中元素之和。需要特别注意的是,处理节点的过程和回溯过程是一一对应的,sum在处理有加,在回溯就要有减。
  4. 基于本题的第二种写法,可以对代码进行两种剪枝操作。剪枝操作1:if (sum > n) return;。该操作可以放在终止条件之前,也可以放在单层搜索逻辑中处理节点时对sum的计算之后(具体细节详见实现部分,建议不要纠结这里的细节)。剪枝操作2:for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i ++ )。这和77.组合中的剪枝操作完全相同。
  5. 只有实现了剪枝操作1后,才能实现剪枝操作2。若单独实现剪枝操作2,会导致程序超时(TLE)。

17.电话号码的字母组合

  1. 本题的两大创新之处indexletterMap。前者用于表示遍历digits遍历到了哪一位,后者用于表示数字和字符串之间的映射关系。

  2. 画出树形结构对于解决回溯法问题的帮助:确定树形结构的宽度,可以确定单层搜索逻辑中的for循环怎么写;确定树形结构的深度,可以确定单层搜索逻辑中的递归部分怎么写。

    Snipaste_2024-05-02_22-19-15.png

    在本题中,树的当前层中的各个节点是letters中的各个元素:

    1
    2
    int digit = digits[index] - '0';
    string letters = letterMap[digit];

    树的深度是digits.size(),可通过index + 1不断向树的下一层递归。

  3. 如果输入的字符串中有 m 个按键对应 4 个字母,n 个按键对应 3 个字母,则本题的时间复杂度和空间复杂度都是$O(4^m \times 3^n)$。

  4. 本题看题意较为麻烦,但如果能画出树形结构,同时学会使用vector<string>来存储数字和字符串之间的映射关系,然后通过index来取出特定数字对应的字符串,就可以写出简明而清晰的代码。

  5. 写本题代码时,最好参照回溯法的模板代码,不要省略return,否则可能导致报错。另外,在主函数中要特判digits为空的情况。

链接

理论基础
77. 组合

知识

理论基础

什么是回溯法

回溯算法比较抽象,因此较难。

回溯和递归是相辅相成的,有递归就会有回溯。回溯的逻辑一般隐藏在递归函数的下面。二叉树在递归的过程中会有回溯的操作,只不过有的题目显式地用到了回溯,有的题目没有显式地用回溯。回溯函数一般指递归函数,因为没有一个函数可以完全用来回溯。

回溯法的效率:回溯法是纯暴力的搜索,并不是一个高效的算法。如果想让回溯法高效一些,可以加一些剪枝的操作。部分题能够用暴搜做出来就不错了,用层层嵌套的for循环都根本搜不出来,要依靠回溯法才能把所有结果搜出来。

使用原因以及解决的问题

回溯法能解决的问题:

  • 组合问题。比如给定集合1234,找出长度为2的组合。
  • 排列问题。排列强调元素的顺序,而组合不强调元素的顺序。比如集合12,求其组合,只有12一种组合;但若求其排列,有12/21两种排列。
  • 子集问题。比如给定集合1234,问该集合有多少子集。
  • 切割问题。比如给一个字符串,问有几种切割方式。或者加一些特定的条件,比如给一个字符串,如何切割才能保证它的子串都是回文子串,问有几种切割方式。
  • 棋盘问题。比如N皇后、解数独。

如何理解回溯法

回溯法特别抽象。想要清晰地了解回溯法,最好将其抽象为一个图形结构所有的回溯法都可以抽象为一个树形结构,确切的说是N叉树。原因:回溯就是递归的过程,递归有终止。N叉树的宽度是在回溯法中处理的集合的大小,一般用for循环遍历。树的深度是递归的深度,因为递归必有终止,递归会层层向上返回。后序讲解具体问题时,都会将具体问题抽象为对应的树形结构,方便大家理解。

回溯模板

一般来说,回溯法的递归函数都是没有返回值的,即void。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// backtracking为习惯的起名
// 回溯法的参数一般较多,一开始时一般无法确定所有参数,写具体逻辑时遇到想用的参数,再添加参数即可
void backtracking(参数)
{
// 终止条件。到终止条件时,一般来说就可以收集结果了
// 除去子集问题和部分棋盘问题,我们一般在叶子节点收集结果
// 对于子集问题,则需要在每个节点处收集结果
if (终止条件)
{
(叶子节点处)收集结果 // 例如将子集12从数组中放入结果集中
return;
}

// 单层搜索的逻辑
// for循环用于遍历本层集合中的每个元素,对应一个节点的所有子节点
for (集合元素)
{
处理节点 // 例如将子集12放进数组中
递归函数 // 树形结构往深处走
回溯操作 // 撤销对节点的处理,例如从子集12中弹出2,再加入3,才能得到新的子集13。同理,还需要撤销3加入4,得到14的组合。没有回溯操作,就会一直加入新的元素,就不会求出所有的组合情况。
}
return;
}

每道回溯法的题目都有各自的特点,但最终的代码都离不开上述模板的风格。

总结

后序会用本期视频讲解的理论知识求解具体问题,就可以加深对本期视频讲解的理论知识的理解。

初次尝试

77. 组合

根据回溯模板,我写下了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:
vector<vector<int>> res; // 结果集
vector<int> tmp; // 暂时存储数据

void backtracking(int n, int k)
{
// 终止条件
if ()
{
res.push_back(tmp);
return;
}

// 单层递归逻辑
for (int i = 0; i < n; i ++ )
{
// 处理节点
tmp.push_back(i);
k -- ;
// 递归函数
backtracking(n, k);
// 回溯操作
k ++ ;
}
return;
}

vector<vector<int>> combine(int n, int k)
{
backtracking(n, k);
return res;
}
};

尽管和最终版本的代码已经非常接近,但是我不知道怎么写终止条件,也无法确定传入的参数是否齐全。先来看卡尔的讲解。

实现

77. 组合

组合中的元素是无序的。给定集合1234,找出所有大小为2的组合。有12,13,14;23,24;34。暴力做法:两层for循环。

1
2
3
for (int i = 0; i < size; i ++ )
for (int j = i + 1; j < size; j ++ )
cout << i << ' ' << j << endl;

两层for循环即可求解k=2下的所有组合。三层for循环即可求解k=3下的所有组合。若k很大,则要嵌套很多层for循环。因此直接用for循环是无法解决本题的,就需要用到回溯算法。

回溯算法也是一个纯暴力的方式,模拟的也是嵌套for循环的过程。回溯算法是利用递归来控制有多少层for循环。递归里的每一层都是一个for循环。嵌套n层for循环即相当于递归n层

所有的回溯法都可以抽象为一个树形结构。以本题为例,画回溯算法搜索的过程。

Snipaste_2024-05-01_19-07-25.png

上述树形结构的叶子节点就是要求的所有组合。第一次取数是从集合中依次取,第二次取数是取第一次取的数的后面一个数,这样才能保证不重不漏。通过传入参数startIndex来实现这点,即控制每次搜索的起始位置。

二叉树的题目有递归三部曲,回溯算法也有回溯三部曲(回溯函数即递归函数,二者不作区分):

  • 递归函数的参数和返回值,返回值一般为void
  • 确定递归的终止条件
  • 单层搜索(递归)的逻辑

根据回溯三部曲对着上述图示写组合问题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 一个组合存放在一个一维数组中,名为path
// 还需要一个二维数组res,将所有集合放在一起,作为结果集返回
// 上述两个数组可以作为加引用的参数,也可以作为全局变量。但参数不宜过多,会影响代码的可读性,因此将它们放入全局变量中
vector<int> path;
vector<vector<int>> res;

// n是集合中数的个数,k是组合的大小
// startIndex的作用:本层递归结束后,下一层递归如何知道从哪个数开始取,就要用到startIndex
// startIndex是本层递归搜索的起始位置,初始时为1,即从1开始搜索
void backtracking(int n, int k, int startIndex)
{
// 终止条件,到叶子节点时,收集结果并返回
// path的大小为k,说明找到了大小为k的组合,将之放入结果集并返回
if (path.size() == k)
{
res.push_back(path); // 收集结果
return;
}

// 单层搜索的逻辑
// 每层都是一个for循环,起始点从startIndex开始
for (int i = startIndex, i <= n; i ++ )
{
// 处理节点
path.push_back(i);
// 递归过程, i + 1才能让下一层递归从下一个节点开始搜索
backtracking(n, k, i + 1);
// 回溯过程。不可以一直往里加,得到一个结果后需要弹出旧的元素
path.pop_back();
}
}

时间复杂度:$O(n \times 2^n)$
空间复杂度:$O(n)$
原因:

  • 对时间复杂度

    • 解空间的大小

      对于组合问题,如生成一个集合的所有子集,解空间包含了该集合的所有可能子集。对于包含$n$个元素的集合,其子集总数是$2^n$。这是因为每个元素在每个子集中都有出现或不出现两种可能,因此,子集的总数是$2^n$。

    • 每个解的生成时间

      虽然生成每个子集看起来很快,但是实际上,为了构建每个子集,可能需要遍历所有元素来决定每个元素是否包含在当前子集中,这需要$O(n)$的时间。因此,对于所有子集,生成时间是每个子集的生成时间与子集总数的乘积,即$O(n \times 2^n)$。

  • 对空间复杂度
    在讨论空间复杂度时,我们通常关注的是算法在执行过程中需要额外分配的空间量。对于回溯算法来说,主要的空间消耗源于两部分:递归调用的栈空间和用于存储当前解的路径(在此例中为 path)。以下是详细分析:

    • 递归栈空间
      在回溯算法中,递归的深度决定了栈空间的使用量。在这个特定的问题中(从 n 中选择 k 个数的组合),递归的最大深度是 k,因为每一层递归对应于选择一个元素,直到选择了 k 个元素。因此,递归栈的空间复杂度是 O(k)。

    • 存储当前解的路径空间
      path 变量用于存储当前的部分解,即已选择的元素集合。因为一次最多选择 k 个元素,所以 path 的最大长度也是 k。因此,存储 path 所需的空间也是 O(k)。

    • 结果集 result
      虽然result用来存储所有可能的组合,其大小可以达到组合数$C(n, k)$,但在分析空间复杂度时,我们通常不把输出空间计算在内,因为这部分空间是用来存储算法的最终结果,而非算法执行过程中的临时数据。如果包括result的空间,空间复杂度确实是$O(C(n, k))$,但这不是额外的空间,而是算法结果的必要空间。

本题的完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public:
vector<vector<int>> res; // 结果集
vector<int> path; // 存放一个集合

void backtracking(int n, int k, int startIndex)
{
// 终止条件
if (path.size() == k)
{
res.push_back(path); // 收集结果
return;
}

// 单层搜索逻辑
for (int i = startIndex; i <= n; i ++ )
{
// 处理节点
path.push_back(i);
// 递归函数,下一层搜索从i + 1开始
backtracking(n, k, i + 1);
// 回溯操作
path.pop_back();
}
return;
}

vector<vector<int>> combine(int n, int k)
{
backtracking(n, k, 1);
return res;
}
};

本题的代码并不复杂,基本是按照回溯法的模板来的。本题还可以做剪枝。回溯法是一个纯暴力的算法,想优化就要做剪枝。接着详细讲如何做剪枝,以及回溯算法常见的剪枝套路

组合问题的剪枝操作

回溯算法是一种纯暴力的算法,优化就是做剪枝,但也改变不了其暴力算法的本质。如何做剪枝操作?

以n=4, k = 4为例。

Snipaste_2024-05-01_21-14-10.png

对取2,取3和取4的分支,可以做剪枝。若不做剪枝,回溯算法会搜索整个树形结构,因此剪枝的效果非常明显。剪枝的代码该怎么写?

1
2
3
4
5
6
7
8
9
// 剪枝操作在单层搜索逻辑中
// for循环的逻辑在当前层的节点上,i对应于节点的每一个孩子,即for循环遍历了当前节点的所有孩子
// 要对节点的子孩子进行剪枝,因此对for循环的范围里进行优化即可
for (int i = startIndex; i <= n; i ++ )
{
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 沿着当前分支继续往下搜索
path.pop_back(); // 回溯操作
}

现在的具体问题是:如何缩小i的范围?
总共要选取k个元素,当前选取了path.size()个元素,还剩下k - path.size()个元素有待选取。因此i至多从n - (k - path.size()) + 1开始。加1的原因是起始位置包括了startIndex。也可以举具体的例子。以n=4, k=3, path.size()=0为例,i至多从2开始枚举(这意味着i可以从1开始枚举,也可以从2开始枚举,但若从3开始枚举,则必然搜索不到大小为3的组合)。因此对上述代码修改为:

1
for (int i = startIndex; i <= n - (k - path.size()) + 1; i ++ )

这样可以确保在搜索的范围中可以找到大小为k的组合。剪枝的操作改动本处即可,代码其他地方不需要做改动。

上述剪枝操作在回溯算法的剪枝操作中特别常见。大部分回溯算法的剪枝操作都是在i的范围里做文章,即缩小i的范围

心得与备忘录

77. 组合

  1. 本题的一个直接的想法是用k层for循环暴力解决,然而由于k的大小不确定,有待输入,因此需要用回溯法。回溯算法也是一个纯暴力的方式,模拟的也是嵌套for循环的过程。回溯算法是利用递归来控制有多少层for循环。递归里的每一层都是一个for循环。嵌套n层for循环即相当于递归n层
  2. 所有回溯法的题目都可以抽象为一个树形结构,本题的树形结构参加实现部分。
  3. 基于本题的树形示意图,以及回溯法的模板代码,就可以写出本题的具体代码。需要特别注意的是,本题的递归函数需要传入三个参数,除去n和k外,还需要传入startIndex。这是因为每层递归的起始点都需要在上一层递归起始点的基础上加1,因此需要一个参数来标明当前这层递归的起始点。
  4. 本题可以进行剪枝优化。

组合问题的剪枝操作

  1. 可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。如果for循环选择的起始位置之后的元素个数已经不足我们需要的元素个数了,那么就没有必要搜索了

  2. 注意代码中i,就是for循环里选择的起始位置。

    1
    for (int i = startIndex; i <= n; i++) 
  3. 接下来看一下优化过程如下(可以画线段图理解):

    • 已经选择的元素个数:path.size();
    • 还需要的元素个数为: k - path.size();
    • 在集合n中至多要从该起始位置: n - (k - path.size()) + 1,开始遍历。若i超过了这个最大的起始位置,则组合中凑不齐k个元素

    为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。

    举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。从2开始搜索都是合理的,可以是组合[2, 3, 4]。

  4. 上述剪枝操作在回溯算法的剪枝操作中特别常见。大部分回溯算法的剪枝操作都是在i的范围里做文章,即缩小i的范围

链接

669. 修剪二叉搜索树
108.将有序数组转换为二叉搜索树
538.把二叉搜索树转换为累加树
总结篇

初次尝试

669. 修剪二叉搜索树

本题据说比增加和删除节点更难,我拿到后没有思路,直接看卡尔的讲解。

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

虽然这是道简单题,但我也没想出来怎么做。

538.把二叉搜索树转换为累加树

本题的基本思路是:累加树中的新节点(除叶子节点外)是其本身加上其右子树的所有节点之和。叶子节点如果是他父节点的左孩子,则值为他的父节点的新值减去原本父节点的值。叶子节点若是他父节点的右孩子,则值为他的父节点的新值加上叶子节点原本的旧值。要计算右子树的值,应当用双指针算法加上中序遍历。

本题还有另一种思路,更加简单粗暴。直接将二叉搜索树转换为一个递增的数组。然后某个节点的新值就是从其本身到数组末尾的所有元素之和。根据上述原理,我写下了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public:
vector<int> nums;

// 将二叉搜索树通过中序遍历转换为有序的数组
void traversal(TreeNode* root)
{
if (root == NULL) return;

traversal(root->left); // 左
nums.push_back(root->val); // 中
traversal(root->right); // 右
}

// 更新节点的值
void sum(TreeNode* root)
{
if (root == NULL) return;
for (int i = 0; i < nums.size(); i ++ )
{
if (nums[i] > root->val)
root->val += nums[i];
}
}

// 通过层次遍历,遍历每个节点,依次更新所有节点的值
TreeNode* convertBST(TreeNode* root) {
traversal(root);
if (root == NULL) return NULL;

queue<TreeNode*> q;
q.push(root);

while (q.size())
{
TreeNode* node = q.front(); q.pop();
sum(node);
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
return root;
}
};

虽然从原理上来说,上述做法应该是没有问题的,但因为并发修改之类的问题,上述代码的实际运行结果和预期就是不同。我搞不清楚为什么,暂且记录下来。直接看卡尔的讲解。

实现

669. 修剪二叉搜索树

给二叉搜索树,给定范围,在范围内修剪二叉搜索树,使得二叉搜索树中所有节点的数值都在范围内。本题不仅要删除不止一个节点,还要改变树的结构。

450.删除二叉搜索树中的节点的原理:通过递归,从当前层往上一层返回值,上一层的左/右孩子来接住返回值,达到删除节点的效果。

常见的误区:常见的错误代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 返回修剪后二叉树的根节点
TreeNode* traversal(TreeNode* root, int low, int high)
{
// 终止条件
if (root == NULL) return NULL;
// 修剪二叉搜索树
// 当前遍历的节点不在[low, high]的范围内
// 这样写很明显是错误的,若根节点的两个子节点都返回NULL,那么二叉树就只剩下根节点了,其他节点全部被删除
// 实际上根节点的两棵子树中都可能有范围内的节点
if (root->val < low || root->val > high) return NULL;

root->left = traversal(root->left, low, high);
root->right = traversal(root->right, low, high);
return root;
}

正确的思路为:若发现某个节点小于范围的左边界,那么该节点的右子树中可能有范围内的节点,因为该节点中右子树的值都要大于该节点的值。因此尽管要删除这个节点,但还需要继续在其右子树中遍历,来挑出其中符合条件的节点。现在来写正确的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 返回修剪后二叉树的根节点
TreeNode* traversal(TreeNode* root, int low, int high)
{
// 本题的终止条件和删除二叉搜索树中的节点的终止条件类似,分类讨论,先发现要删除的节点然后完成删除操作
// 终止条件1
if (root == NULL) return NULL;

// 终止条件2
// root节点的值小于左边界,但其右子树中可能有符合要求的节点,故应该继续向右遍历
if (root->val < low)
{
// root节点的右子树在修剪后的根节点
TreeNode* right = traversal(root->right, low, high);
return right; // 向上返回该根节点
}

// 终止条件3
// root节点的值大于右边界,但其左子树中可能有符合要求的节点,故应该继续向左遍历
if (root->val > high)
{
// root节点的左子树在修剪后的根节点
TreeNode* left = traversal(root->left, low, high);
return left; // 向上返回该根节点
}

// 单层递归逻辑,分别修剪根节点的左右子树,然后将修剪后的左右子树接上去
root->left = traversal(root->left, low, high);
root->right = traversal(root->right, low, high);

return root; // root在终止条件中处理了
}

本题的代码其实不复杂,特别是相比于450.删除二叉搜索树中的节点。那题的终止条件需要分5种情况讨论,本题的终止条件只需要分3种情况讨论。本题也有迭代写法。本题掌握递归法即可。本题代码量不多,但很考察大家对二叉树移除节点和二叉搜索树特性的理解。

我独立写下了精简注释版本的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
TreeNode* trimBST(TreeNode* root, int low, int high) {
// 终止条件1
if (root == NULL) return NULL;

// 终止条件2,root节点的值小于左边界
// 此时root节点的右子树中依然可能有符合要求的节点在,因此还需对右子树进行修剪
if (root->val < low)
{
TreeNode* right = trimBST(root->right, low, high);
return right;
}

// 终止条件3,root节点的值大于右边界
// 此时root节点的左子树中依然可能有符合要求的节点在,因此还需对左子树进行修剪
if (root->val > high)
{
TreeNode* left = trimBST(root->left, low, high);
return left;
}

// 单层递归逻辑
// 用来接住3个终止条件的返回值
root->left = trimBST(root->left, low, high);
root->right = trimBST(root->right, low, high);
return root;
}
};

本题的迭代法代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 迭代法
class Solution {
public:
TreeNode* trimBST(TreeNode* root, int low, int high) {
if (root == NULL) return NULL;

// 确保root在[low, high]的区间内
// 以下写法可以避免死循环
while (root && (root->val < low || root->val > high))
{
if (root->val < low) root = root->right;
else root = root->left;
}

TreeNode* cur = root;

// 确保cur的左子树中没有小于low的节点
while (cur)
{
while (cur->left && cur->left->val < low)
{
cur->left = cur->left->right;
}
cur = cur->left;
}

cur = root; // 恢复cur

// 接着检查cur的右子树中没有大于high的节点
while (cur)
{
while (cur->right && cur->right->val > high)
{
cur->right = cur->right->left;
}
cur = cur->right;
}

return root;
}
};

迭代法原理不复杂,但代码非常容易写错,因此不推荐迭代写法。

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

要构造的二叉搜索树是平衡二叉树。做这个要求的原因是任何有序数组都能够轻易构造成链式的二叉搜索树。

构造二叉树的一般思路:在数组中选取一个中间节点,将数组分为左区间和右区间。递归遍历左区间,构成左子树。递归遍历右区间,构成右子树。

解题思路:root节点选取为数组中间位置的节点。因为只有这样选才可以保证左右区间中节点的数量相同,构造的二叉树才是平衡二叉树。再根据二叉搜索树的性质:中节点的值大于左子节点,小于右子节点来构造二叉搜索树。构造出的二叉搜索树的结构不唯一。对于数组中有偶数个元素的情况,root节点可以选取为中间偏左那个节点,也可以选取为中间偏右那个节点。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 注意引用&。如果每层递归不用引用,就需要在内存空间中重复复制数组,导致程序的性能很差
// 使用引用后,递归遍历时都在同一个内存地址里操作数组
// 区间左右边界的定义很重要,此处对区间的定义是左闭右闭
TreeNode* traversal(vector<int>& nums, int left, int right)
{
// 终止条件:非法区间
if (left > right) return NULL;

int mid = (left + right) / 2; // 数组下标相加不可能爆内存

// 构造二叉树的根节点
TreeNode* root = new TreeNode(nums[mid]);
// 利用左区间构造左子树
root->left = traversal(nums, left, mid - 1); // 因为是左闭右闭的区间,所以right = mid - 1
// 同理,利用右区间构造右子树
root->right = traversal(nums, mid + 1, right); // 因为是左闭右闭的区间,所以left = mid + 1

return root;
}

TreeNode* sortedArrayToBST(vector<int>& nums)
{
// 区间定义左闭右闭,因此right = nums.size() - 1
TreeNode* root = traversal(nums, 0, nums.size() - 1);
return root;
}

本代码并不复杂。本题也可用迭代法实现,但较为复杂。本题优先掌握递归法即可。

本题的精简版本代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
// 区间左闭右闭
TreeNode* traversal(vector<int>& nums, int left, int right)
{
// 终止条件
if (left > right) return NULL;

// 构造root节点
int mid = (left + right) / 2;
TreeNode* root = new TreeNode(nums[mid]);

// 构造左子树
root->left = traversal(nums, left, mid - 1);
// 构造右子树
root->right = traversal(nums, mid + 1, right);

return root;
}

TreeNode* sortedArrayToBST(vector<int>& nums) {
TreeNode* root = traversal(nums, 0, nums.size() - 1);
return root;
}
};

538.把二叉搜索树转换为累加树

换个思路:给一个有序的数组,将其变成一个累加数组。倒序遍历即可,将前一个节点加到本节点中。倒序遍历有序数组,本质就是按照右中左的顺序遍历二叉搜索树。将前一个节点的值加到本节点中,就需要用到双指针。pre指针指向前一个节点,cur指针指向当前节点。现在开始写递归法的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int pre = 0; // 记录前一个节点的数值

// 由于要遍历整个二叉树,在遍历的过程中去更新节点数值即可,因此不需要返回值
void traversal(TreeNode* cur)
{
// 终止条件
if (cur == NULL) return;

// 单层递归逻辑:右中左
traversal(cur->right); // 右
cur->val += pre; // 中
pre = cur->val; // 移动pre
traversal(cur->left); // 左
}

在对二叉搜索树的遍历不够熟悉的情况下,可以将二叉搜索树想象成一个有序的数组。

将pre定义为指针也是可行的,代码会略微复杂,因为要判断指针是否为空:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
TreeNode* pre = NULL;

void traversal(TreeNode* cur)
{
if (cur == NULL) return;

// 右
traversal(cur->right);
// 中
if (pre) cur->val += pre->val;
pre = cur;
// 左
traversal(cur->left);
}

TreeNode* convertBST(TreeNode* root) {
traversal(root);
return root;
}
};

本题用迭代法也可以做,而且是迭代法的模板题,但我用迭代法写本题总是容易写错。因此还是推荐递归法。

心得与备忘录

669. 修剪二叉搜索树

  1. 本题乍一看非常简单,结果就是掉进常见误区中,即发现一个节点不在区间内,就直接返回NULL。这样的问题在于以该节点为根节点的子树中可能有满足条件的节点。如果直接返回NULL,相当于把可能满足条件的节点一并删除了。
  2. 从常见误区中爬出来,又会觉得本题非常难,因为似乎要调整二叉树的结构。其实本题不需要像450.删除二叉搜索树中的节点那样分五种情况讨论来调整二叉树的结构。本题只需要在终止条件中分出三种情况讨论:

    • root节点为空,则返回空
    • root节点小于区间左边界,则root节点的右子树中可能存在符合要求的节点。此时调用递归函数对root节点的右子树进行修剪,将修剪后右子树的头节点向上返回。
    • root节点大于区间右边界,则root节点的左子树中可能存在符合要求的节点。此时调用递归函数对root节点的左子树进行修剪,将修剪后左子树的头节点向上返回。

    最后在单层递归逻辑中,分别让root节点的左右指针接住修剪后的左右子树即可。终止条件负责返回,单层递归逻辑负责接收。

  3. 本题的基本原理和450.删除二叉搜索树中的节点相同,都是通过递归函数的返回值来移除节点,然后在单层递归逻辑中接住上一层递归的返回值。
  4. 本题的迭代法思路简单(终止条件->确保root在[low, high]的区间内->确保cur的左子树中没有小于low的节点->接着检查cur的右子树中没有大于high的节点),但代码非常容易写错(while循环中套着while,循环条件写得不对容易出现死循环),因此不推荐。还是建议老老实实地用递归法完成本题。

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

  1. 构造二叉树的一般思路:取数组最中间的元素作为二叉树的root节点。利用数组的左区间构造root节点的左子树,利用数组的右区间构造root节点的右子树。
  2. 在数组中元素个数为偶数时,数组最中间的元素有两个。此时,选取这两个元素中的任意一个作为root节点都可以。这样会构造出两棵不同的二叉搜索树,因此本题的结果不唯一。
  3. 本题递归函数的传入参数为数组和左右下标。在构造二叉树的时候尽量不要重新定义左右区间数组,而是用下标来操作原数组。
  4. int mid = (left + right) / 2最好写成int mid = left + (right - left) / 2。原因是两个整数相加可能会超出整数的最大范围。本题虽然采取第一种写法没事,但要有清醒的意识,避免出事。

  5. 注意循环不变量原则:区间要么一直保持为左闭右闭,要么一直保持为左闭右开。这关乎到终止条件的具体写法、递归时传入的区间下标以及主函数中调用递归函数时传入的下标。

  6. 本题的递归写法思路非常简单:先写终止条件,再取数组最中间的元素作为二叉树的root节点,再利用数组的左区间构造root节点的左子树,最后利用数组的右区间构造root节点的右子树,最后返回root节点即可。
  7. 本题的迭代写法代码比较复杂,不要求掌握。还是优先掌握递归写法。

538.把二叉搜索树转换为累加树

  1. 先想如何把有序(递增)数组变为累加数组:倒序遍历数组,然后用双指针算法即可,即当前元素的新值等于当前元素的旧值加上前一个元素的值。根据这个思路解决本题。二叉搜索树通过中序遍历可以转换为有序数组,倒序遍历数组即相当于反中序遍历二叉搜索树。对树中节点值的累加也是通过双指针实现的。
  2. 本题的递归函数不需要返回值,原因:由于要遍历整个二叉树,在遍历的过程中去更新节点数值即可,因此不需要返回值。
  3. 本题的pre指针可以是整数类型的变量,也可以是指针类型的变量。若采用整数类型的变量,可以避免对指针是否为空的判断(整数的初始值为0),因此采用整数类型的变量作为pre更加方便。
  4. 本题是迭代的模板题,但我用迭代法写本题总是容易写错。因此还是推荐递归法。

总结篇

  1. 解决二叉树类题目的基本方法是递归法。一般使用了递归三部曲来分析题目,看到二叉树,看到递归,都应该想:返回值、参数是什么?终止条件是什么?单层逻辑是什么?

  2. 大多数题也都有迭代解法,但是一般代码更长也更容易写错,可以用于提升自己。

二叉树类的题目可以分为以下几类:

二叉树的遍历方式

深度优先遍历
二叉树:前中后序递归法:递归三部曲初次亮相
二叉树:前中后序迭代法(一):通过栈模拟递归
二叉树:前中后序迭代法(二)统一风格

广度优先遍历
二叉树的层序遍历:通过队列模拟

求二叉树的属性

二叉树:是否对称
递归:后序,比较的是根节点的左子树与右子树是不是相互翻转

二叉树:求最大深度
递归:后序,求根节点最大高度就是最大深度,通过递归函数的返回值做计算树的高度
迭代:层序遍历

二叉树:求最小深度
递归:后序,求根节点最小高度就是最小深度,注意最小深度的定义
迭代:层序遍历

二叉树:求有多少个节点
递归:后序,通过递归函数的返回值计算节点数量
迭代:层序遍历

二叉树:是否平衡
递归:后序,注意后序求高度和前序求深度,递归过程判断高度差

二叉树:找所有路径
递归:前序,方便让父节点指向子节点,涉及回溯处理根节点到叶子的所有路径

二叉树:递归中如何隐藏着回溯
详解二叉树:找所有路径中递归如何隐藏着回溯

二叉树:求左叶子之和
递归:后序,必须三层约束条件,才能判断是否是左叶子。

二叉树:求左下角的值
递归:顺序无所谓,优先左孩子搜索,同时找深度最大的叶子节点。
迭代:层序遍历找最后一行最左边

二叉树:求路径总和
递归:顺序无所谓,递归函数返回值为bool类型是为了搜索一条边,没有返回值是搜索整棵树。

二叉树的修改与构造

翻转二叉树
递归:前序,交换左右孩子

构造二叉树
递归:前序,重点在于找分割点,分左右区间构造

构造最大的二叉树
递归:前序,分割点为数组最大值,分左右区间构造

合并两个二叉树
递归:前序,同时操作两个树的节点,注意合并的规则

求二叉搜索树的属性

二叉搜索树中的搜索
递归:二叉搜索树的递归是有方向的
迭代:因为有方向,所以迭代法很简单

是不是二叉搜索树
递归:中序,相当于变成了判断一个序列是不是递增的

求二叉搜索树的最小绝对差
递归:中序,双指针操作

求二叉搜索树的众数
递归:中序,清空结果集的技巧,遍历一遍便可求众数集合

二叉搜索树转成累加树
递归:中序,双指针操作累加

二叉树公共祖先问题

二叉树的公共祖先问题
递归:后序,回溯,找到左子树出现目标值,右子树节点目标值的节点。

二叉搜索树的公共祖先问题
递归:顺序无所谓,如果节点的数值在目标区间就是最近公共祖先

二叉搜索树的修改与构造

二叉搜索树中的插入操作
递归:顺序无所谓,通过递归函数返回值添加节点

二叉搜索树中的删除操作
递归:前序,想清楚删除非叶子节点的情况

修剪二叉搜索树
递归:前序,通过递归函数返回值删除节点

构造二叉搜索树
递归:前序,数组中间节点分割