【说明】
在模板中关于框架的部分使用伪代码表示,在具体题目实现算法时可能会采用 C++ 或 python,请读者自行注意
1. 指针
1.1 快慢指针
【核心思想】
慢指针每次移动一格,快指针每次移动 k k k 格
【典型问题分析——弗洛伊德的兔子和龟】
弗洛伊德的兔子和龟针对的是有环链表的问题,比如
主要解决两个问题:
① 判断有环无环
bool hasCycle(ListNode *head) {
ListNode *tortoise = head;
ListNode *hare = head;
while(hare!=NULL && hare->next!=NULL)
{
tortoise = tortoise->next;
hare = hare->next->next;
if(tortoise==hare) return true;
}
return false;
② 找到环的入口
ListNode *detectCycle(ListNode *head) {
if(head==NULL) return NULL;
ListNode *intersect = getintersect(head);
if(intersect==NULL) return NULL;
ListNode *ptr1 = head;
ListNode *ptr2 = intersect;
while(ptr1!=ptr2)
{
ptr1 = ptr1->next;
ptr2 = ptr2->next;
}
return ptr1;
【拓展】
但是如果就只问有环链表,显得太基础 ( ̄_, ̄ )
于是,就有了一些抽象有环链表的问题,但是这些问题大多数都有一个明显的特性,就是在移动的过程中会出现一个环,这个环可以是一组周期出现的数字,也可以就是沿线性表移动过程中出现的环
所以只要出现这个明显的特性就可以想到弗洛伊德的龟和兔子
【典型问题分析——对链表中的结点进行操作】
快慢指针还有一种常见的使用情况就是对链表中的结点进行操作。这时,快慢指针的移动方式可能因题而异,但最终的目的都是当快指针不能再移动时,慢指针将刚好到达期望的位置
比如取链表中间结点
题目描述
class Solution {
public:
ListNode* middleNode(ListNode* head) {
ListNode* fast = head;
ListNode* slow = head;
while (fast != nullptr && fast->next != nullptr)
{
fast = fast->next->next;
slow = slow->next;
}
return slow;
}
};
题目描述
解法分析
稍微需要注意的就是这里的慢指针应该停在倒数第 N 个结点的前一个,方便删除
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyhead = new ListNode(-1);
dummyhead->next = head;
ListNode* slow = dummyhead;
ListNode* fast = dummyhead;
for (int i = 0;i < n + 1; i++) fast = fast->next;
while (fast!=nullptr)
{
slow = slow->next;
fast = fast->next;
}
ListNode* delnode = slow->next;
low->next = delnode->next;
delete delnode;
return dummyhead->next;
}
};
1.2 双指针
【典型问题分析——相交链表】
题目描述
解法分析
找到两个单链表相交的起始结点,比如下面链表的相交结点为 c 1 c1 c1
算法的思路很简单,就是:
- 初始化 h a = h e a d A , h b = h e a d B ha = headA, hb = headB ha=headA,hb=headB,开始遍历
- 若 A A A 链短, h a ha ha 会先到达链表尾,当 h a ha ha 到达末尾时,重置 h a ha ha 为 h e a d B headB headB;同样的,当 h b hb hb 到达末尾时,重置 h b hb hb 为 h e a d A headA headA
- 当 h a ha ha 与 h b hb hb 相遇时,必然就是两个链表的交点
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode *ha = headA, *hb = headB;
while(ha != hb)
{
ha = ha==NULL?headB:ha->next;
hb = hb==NULL?headA:hb->next;
}
return ha;
1.3 左右指针
1.3.1 二分法
左右指针最典型的一个用法就是二分法,但是吧,二分法思路很简单,细节是魔鬼 ━━∑( ̄□ ̄*|||━━
下面是摘自 《labuladong 的算法小抄》中的一首打油诗,可以帮助记忆二分搜索的细节
我们先给出二分法的框架,然后慢慢对应着上面的打油诗解释二分搜索
int binarySearch(vector<int> nums, int target) {
int left = 0, right = ...;
while(...) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}
两个细节:
- 不要出现 else,而是把所有情况用 else if 写清楚
- 我们使用 l e f t + ( r i g h t − l e f t ) / 2 left + (right - left) / 2 left+(right−left)/2 而是 ( l e f t + r i g h t ) / 2 (left + right) / 2 (left+right)/2 ,主要考虑如果 l e f t left left 和 r i g h t right right 都很大时,那么直接相加就会爆掉
(1) 寻找一个数——基本二分搜索
int binarySearch(vector<int> nums, int target) {
int left = 0;
int right = nums.size() - 1; // 猜数的时候,这里有变化
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] == target) return mid;
else if (nums[mid] < target) left = mid + 1;
else if (nums[mid] > target) right = mid - 1;
}
return -1;
}
解释:搜索一个元素时,搜索区间两端闭
初始化时,right == nums.size()-1 其代表的搜索区间是 [left, right],如果写成 right == nums.size() 则其代表的搜索区间是 [left, right)
解释:while 条件带等号,否则需要打补丁
while(left <= right) 的终止条件是 left == right + 1 ,写成区间的形式就是 [right + 1, right],或者带个具体的数字进去 [3, 2],可见这时候区间为空,循环即可以停止
while(left < right) 的终止条件是 left == right,写成区间的形式就是 [left, right],或者带个具体的数字进去 [2, 2],这时候区间非空,还有一个数 2,但此时 while 循环终止了。也就是说这区间 [2, 2] 被漏掉了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的,所以需要打个补丁
while(...){
...
}
return nums[left] == target ? left : -1;
解释:mid 必须要减一,因为区间两端闭
刚才明确了搜索区间这个概念,而且该算法的搜索区间是两端都闭的,即 [left, right],那么当我们发现索引 mid 不是要找的 target 时,下一步应该去搜索哪里呢?
当然是去搜索 [left, mid-1] 或者 [mid+1, right] 对不对?因为 mid 已经搜索过,应该从搜索区间中去除
(2) 寻找左侧边界的二分搜索
考虑有序数组 nums = [1, 2, 2, 2, 3],target = 2,如果我们想得到 target 的左侧边界,即索引 1,那么可以按照下面的代码进行处理
int left_bound(vector<int> nums, int target) {
if (nums.size() == 0) return -1;
int left = 0;
int right = nums.size(); // 注意
while (left < right) { // 注意
int mid = left + (right - left) / 2;
if (nums[mid] == target) right = mid;
else if (nums[mid] < target) left = mid + 1;
else if (nums[mid] > target) right = mid;
}
if(left==nums.size()) return -1;
return nums[left]==target? left:-1;
}
解释:左闭右开最常见,其余逻辑便自明
搜索区间写成左闭右开只是一种普遍的方法,如果你想写成其他的形式也行
解释:while 要用小于号,这样才能不漏掉
用相同的方法分析,因为 right = nums.size() 而不是 nums.size() - 1,因此每次循环的『搜索区间』是 [left, right) 左闭右开
while(left < right) 终止的条件是 left == right,此时搜索区间 [left, left) 为空,所以可以正确终止
解释:if 相等别返回,利用 mid 锁边界
该算法之所以能搜索左侧边界,关键在于对于 nums[mid] == target 这种情况的处理:
if (nums[mid] == target) right = mid;
可见,找到 target 时不要立即返回,而是缩小搜索区间的上界 right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的
解释:mid 加一或减一,要看区间开或闭
这个很好解释,因为我们的搜索区间是 [left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步的搜索区间应该去 mid 分割成两个区间,即 [left, mid) 或 [mid + 1, right)
解释:索引可能超边界,if 检查最保险
我们先理解一下这个左侧边界有什么特殊含义:
比如对于有序数组 nums = [2,3,5,7], target = 1,算法会返回 0,含义是:nums 中小于 1 的元素有 0 个
再比如说 nums = [2,3,5,7], target = 8,算法会返回 4,含义是:nums 中小于 8 的元素有 4 个
综上可以看出,函数的返回值(即 left 变量的值)取值区间是闭区间 [0, nums.size()],所以我们简单添加两行代码就能在正确的时候 return -1:
while (left < right) {
...
}
// target 比所有数都大
if (left == nums.size()) return -1;
// target 比所有数都小
return nums[left] == target ? left : -1;
(3) 寻找右侧边界
int right_bound(vector<int> nums, int target) {
if (nums.size() == 0) return -1;
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
left = mid + 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
if(left==0) return -1;
return nums[left-1]==target? (left-1) : -1;
}
解释两个点:
1. 为什么这个算法能够找到右侧边界?
关键在于
if (nums[mid] == target) left = mid + 1;
当 nums[mid] == target 时,不要立即返回,而是增大搜索区间的下界 left,使得区间不断向右收缩,达到锁定右侧边界的目的
2. 为什么最后返回 left - 1 而不像左侧边界的函数,返回 left ?而且我觉得这里既然是搜索右侧边界,应该返回 right 才对
首先,while 循环的终止条件是 left == right,所以 left 和 right 是一样的,若非要体现右侧的特点,返回 right - 1 好了
至于为什么要减一,这是搜索右侧边界的一个特殊点,关键在这个条件判断:
if (nums[mid] == target) left = mid + 1; // 这样想: mid = left - 1
因为我们对 left 的更新必须是 left = mid + 1,就是说 while 循环结束时,nums[left] 一定不等于 target 了,而 nums[left-1] 可能是 target
【典型问题分析——小张刷题计划】
题目描述
解法
给定一个数组,将其划分成 m 份,使得每份元素之和最大值最小】
常见算法流程
【典型问题分析——唯一重复数字】
1.3.2 滑窗
滑窗也是一个典型的双指针问题,对于解决子串问题十分有效,大致的框架如下,其时间复杂度为 O ( N ) \mathcal O(N) O(N),比一般的暴力搜索方法要高效很多
【算法框架】
int left = 0, right = 0;
while (right < s.size()) {
// 增大窗口
window.add(s[right]);
right++;
while (window needs shrink) {
// 缩小窗口
window.remove(s[left]);
left++;
}
}
实际上滑窗问题的难点不在于算法的思路,而是各种细节问题,下面是一个更详细的 C++ 框架,体现了很多的实现细节问题
void SlidingWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) nedd[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s.size())
{
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...
// debug
printf("window: [%d, %d)\n", left, right);
// 判断左侧窗口是否要收缩
while (window needs shink)
{
// d 是将溢出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
需要特别指出的有两点:
- 往往两个
...
处所表示的左移右移更新操作是完全对称的 - 通过 debug 处的代码可以知道我们的窗口是
[left, right)
这样一个左开右闭的区间
【经典问题分析——最小覆盖子串】
题目描述
解法
分两步解决:
- 不断增加 right 直到窗口包含 T 中的所有字符,此时我们得到的是一个可行解
- 停止增加 right,转而不断增加 lef 优化上面得到的可行解,直到窗口不再符合要求,每次增加 left 的时候就进行 新一轮的更新
- 重复上面两步,直到 right 达到 S 的尽头
接着,解释一下上面框架出现的 needs 和 windows,这两个哈希表其实就是计数器,needs 记录 T 中字符出现的次数,windows 记录窗口中对应 T 字符出现的次数
另外,我们再解释一下框架中还出现的一个标志变量 valid,valid 表示窗口中满足 need 条件的字符个数,如果 valid == need.size()
就说明窗口以满足条件
class Solution {
public:
string minWindow(string s, string t) {
unordered_map<char, int> need, window;
for (auto c: t) need[c]++;
int left = 0, right = 0;
int valid = 0;
// 记录最小覆盖子串的起始索引及长度
int start = 0, len = INT_MAX;
while (right < s.size())
{
char c = s[right];
right++;
if (need.count(c))
{
window[c]++;
if (window[c] == need[c]) valid++;
}
while (valid == need.size())
{
if (right - left < len)
{
start = left;
len = right-left;
}
char d = s[left];
left++;
if (need.count(d))
{
if (window[d] == need[d]) valid--;
window[d]--;
}
}
}
return len == INT_MAX? "" : s.substr(start, len);
}
};
【经典问题分析——字符串排列】
题目描述
解法
如果明白上题了,这题就很简单,就是判断 s2 中是否存在一个 s1 子串,注意此时窗口应该比 s1 长才是一个可行解,然后左移来优化
class Solution {
public:
bool checkInclusion(string s1, string s2) {
unordered_map<char, int> need, window;
for (auto c: s1) need[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s2.size())
{
char c = s2[right];
right++;
if (need.count(c))
{
window[c]++;
if (window[c] == need[c]) valid++;
}
while (right - left >= s1.size())
{
if (valid == need.size()) return true;
char d = s2[left];
left++;
if (need.count(d))
{
if(window[d] == need[d]) valid--;
window[d]--;
}
}
}
return false;
}
};
【经典问题分析——找所有字母异位词】
题目描述
解法
所谓的字母异位词都是噱头,其实就是 window 和 need 完全一致
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
unordered_map<char, int> need, window;
for (char c: p) need[c]++;
int left= 0, right = 0;
int valid = 0;
vector<int> res;
while (right < s.size())
{
char c = s[right];
right++;
if (need.count(c))
{
window[c]++;
if (window[c] == need[c]) valid++;
}
while (right - left >= p.size())
{
if (valid == need.size()) res.push_back(left);
char d = s[left];
left++;
if (need.count(d))
{
if (window[d] == need[d]) valid--;
window[d]--;
}
}
}
return res;
}
};
题目描述
解法
这道题不是完全套框架,但是也很简单,不需要维护 need 了,只用一个 window,当 window[c] > 1
说明窗口中存在重复字符,此时就应该滑动窗口了
class Solution {
public:
int lengthOfLongestSubstring(string s) {
unordered_map<char, int> window;
int left = 0, right = 0;
int res = 0;
while (right < s.size())
{
char c = s[right];
right++;
window[c]++;
while (window[c]>1)
{
char d = s[left];
left++;
window[d]--;
}
res = max(res, right -left);
}
return res;
}
};
2. 回溯
【核心思想】解决一个回溯问题,实际上就是一个决策树的变脸过程
所以,我们对于一个回溯问题只考虑 3 个问题,
- 『路径』:已经作出的选择
- 『选择列表』:当前可以做的选择
- 『结束条件』:到达决策树底层,无法再做选择的条件
【算法框架】
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径,选择列表)
撤销选择
核心是 for 循环里面的递归如何处理,在递归调用之前做选择,在递归调用之后撤销选择
【经典问题分析——全排列问题】
这里作出了一个简化,我们讨论的全排列的问题不包含重复的数字
假如给定的序列为 [1, 2, 3]
,则很容易知道全排列的回溯树如下图所示
我们也称这棵回溯树是决策树,因为我们在每个结点上都会作出一种决策,比如说我们在红色的结点上,我们可以选择 1 的分支,也可以选择 3 的分支
然后我们解释一下什么是选择列表和路径,看下下面的图很容易就理解
结合树的遍历就很容易理解,回溯函数其实就是一个游走指针,遍历整个回溯树,不是很理解的话看下下面的图吧
回溯算法的一大特点是,不存在像动态规划一样的重叠子问题可以优化,所以回溯算法就是纯暴力穷举,复杂度一半都很高
【经典问题分析——N 皇后问题】
题目描述
解法
N 皇后问题是一个经典的回溯问题,按照上面的描述,我们需要确定三个东西,
- 路径:board 中小于 row 的对那些已经成功放置了皇后的行
- 选择列表:第 row 行的所有列都是放置皇后的选择
- 结束条件:row 超过 board 的最后一行
明确了这三点我们就很容易写出解决 N 皇后问题的回溯算法啦
class Solution {
public:
vector<vector<string>> res;
vector<vector<string>> solveNQueens(int n) {
vector<string> board(n, string(n, '.'));
backtrace(board, 0);
return res;
}
void backtrace(vector<string>& board, int row){
if (row == board.size())
{
res.push_back(board);
return;
}
int n = board[row].size();
for (int col = 0; col < n; col++)
{
if (!isValid(board, row, col)) continue;
board[row][col] = 'Q';
backtrace(board, row + 1);
board[row][col] = '.';
}
}
bool isValid(vector<string>& board, int row, int col){
int n = board.size();
// Check for col
for (int i = 0; i < n; i++)
if (board[i][col] == 'Q') return false;
// Check for top right
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++)
if (board[i][j] == 'Q') return false;
// Check for top left
for (int i = row - 1, j = col - 1; i >= 0 && j >=0; i--, j--)
if (board[i][j] == 'Q') return false;
return true;
}
};
但是有些时候我们可能只需要一种可行的解法就行,而避免找出所有可行解是假复杂度太高。此时,简单修改一下代码就好
bool backtrace(vector<string>& board, int row){
if (row == board.size())
{
res.push_back(board);
return true;
}
int n = board[row].size();
for (int col = 0; col < n; col++)
{
if (!isValid(board, row, col)) continue;
board[row][col] = 'Q';
if (backtrace(board, row + 1)) return true;
board[row][col] = '.';
}
return false;
}
3. 搜索
3.1 BFS
我们脱离开树来说一说 BFS 和 DFS,它们两者最大的区别就在于:『BFS 找到的路径一定是最短的, 代价是空间复杂度比 DFS 高很多』
这句话很关键,想想树的层次遍历和先序 / 后续遍历很容易就理解关于空间复杂度的描述;再想想不带权值的无向图中找起终点的最短路径,显然是调用 BFS 最简单,这样也很容易理解对路径最短的描述了
研究搜索的问题,包括树,我们处理的数据结构都是图,这一点是十分明确的
下面我们就直接给出算法框架
【算法框架】
def BFS(start, target):
q = []
visited = set()
q.append(start)
visited.add(start)
step = 0
while q:
n = len(q)
for _ in range(n):
cur = q.pop(0)
if cur is target: return step
for x in cur.adj():
q.append(x)
visited.add(x)
step += 1
我们还是从树的问题说起,因为这是最经典,然后再给出一道实际问题抽象成图处理的问题
【经典问题分析——二叉树的最小深度】
题目描述
解法
其实这道题超级简单,就是层遍历,只要当前层出现叶结点了就可以提前结束
class Solution {
public:
int minDepth(TreeNode* root) {
if (!root) return 0;
return BFS(root);
}
int BFS(TreeNode* root){
queue<TreeNode*> q;
int depth = 1;
q.emplace(root);
while (!q.empty())
{
int n = q.size();
for (int i = 0; i < n; ++i)
{
TreeNode* cur = q.front();
q.pop();
if (!cur->left && !cur->right) return depth;
if (cur->left) q.emplace(cur->left);
if (cur->right) q.emplace(cur->right);
}
depth++;
}
return -1;
}
};
当然这段道题也可以用 DFS 解,『DFS 同样可以寻找最短路径』,做法就是:递归的处理每一个结点,分别计算每一个非叶子结点的左右子树的最小叶子节点深度
【经典问题分析——打开转盘锁】
题目描述
解法
这道题说起来不难,把穷举每种情况的过程想成是一棵搜索树,出现重复的分支不要、出现 deadends 的分支不要,剪完枝后在搜索树上早最小,不就是 BFS
对于两种剪枝的情况,只要设一个 visited 集合就好了,先将 deadends 插入到 visited 中,自然就不会出现关于 deaends 的分支了
另外要说明的一个点是如何模拟转盘上下拨动一位,我们可以使用数学表达式 N e i g h b o u r N u m s = ( N u m ± 1 + 10 ) % 10 NeighbourNums = (Num\pm1 + 10)\%10 NeighbourNums=(Num±1+10)%10
class Solution {
public:
int openLock(vector<string>& deadends, string target) {
unordered_set<string> visited;
visited.insert(deadends.begin(), deadends.end());
if (visited.count("0000")) return -1;
int ans = 0;
queue<string> q;
q.push("0000");
while (!q.empty())
{
int n = q.size();
for (int i = 0; i < n; i++)
{
string tmp = q.front();
q.pop();
if (tmp == target) return ans;
for(int j = 0; j < 4; j++)
{
for (int k = -1; k < 2; k += 2)
{
char y = (tmp[j] - '0' + 10 + k) % 10 + '0';
string x = tmp;
x[j] = y;
if(!visited.count(x))
{
q.push(x);
visited.insert(x);
}
}
}
}
ans++;
}
return -1;
}
};
3.2 双向 BFS 优化
BFS 算法有一种稍微高级一点点的优化思路:双向 BFS,可以进一步提高算法的效率
区别: 传统的 BFS 框架就是从起点开始向四周扩散,遇到终点时停止;而双向 BFS 则是从起点和重点同时开始扩散,当两边有交集的时候停止
我们看两幅图来理解一下为什么双向 BFS 会快一些
很明显在第一幅图中,按照传统的 BFS 算法我们将会遍历整棵树才能得出结果;而双向 BFS 其实只遍历到树的一半就出现了交集,这是通过简单的加法我们就可以得出最短距离,这就是双向 BFS 快一些的原因
不过,双向 BFS 也有局限——我们必须提前知道终点的位置
【经典问题分析——打开转盘锁】
对于二叉树的最小深度,因为我们不能事先知道终点的位置,所以没有办法使用双向 BFS 进行优化。而打开转盘锁问题不一样,我们知道确切的终点,于是将代码稍加修改就好了
class Solution {
public:
int openLock(vector<string>& deadends, string target) {
set<string> deads;
set<string> q1;
set<string> q2;
set<string> visited;
int step = 0;
for(auto s: deadends) deads.insert(s);
q1.insert("0000");
q2.insert(target);
while(!q1.empty() && !q2.empty())
{
set<string> tmp;
for(auto cur: q1)
{
if(deads.count(cur)) continue;
if(q2.count(cur)) return step;
visited.insert(cur);
for(int j = 0; j < 4; j++)
{
for (int k = -1; k < 2; k += 2)
{
char y = (cur[j] - '0' + 10 + k) % 10 + '0';
string x = cur;
x[j] = y;
if (!visited.count(x)) tmp.insert(x);
}
}
}
step++;
q1 = q2;
q2 = tmp;
}
return -1;
}
};
注意,双向 BFS 不再使用队列,而是使用哈希表方便快速判断两个集合是否有交集
另外的一个技巧是,交换 q1 和 q2 的内容,这时只要默认扩散 q1 就相当于轮流扩散 去
和 q2
对于双向 BFS 还有一个优化,就是在 while 循环开始时做一个判断
// ...
while (!q1.empty() && !q2.empty())
{
if (q1.size() > q2.size())
{
// 交换 q1 和 q2
temp = q1;
q1 = q2;
q2 = temp;
}
// ...
}
按照 BFS 的逻辑,队列(集合)中的元素越多,扩散后新的队列(集合)的元素就越多。在双向 BFS 中,如果我们每次都选择一个较小的集合进行扩散,那么占用的空间增长速度也会慢一些,效率就会高一些
强调一点: 无论传统 BFS 还是双向 BFS, 无论做不做优化, 从大 O 衡量标准来看, 时间复杂度都是⼀样的。 只能说双向 BFS 是⼀种 trick, 算法运行的速度会相对快⼀点
3.3 二维矩阵的 DFS
下面我们先直接给出二维矩阵的 DFS 递归框架,不难,就是在一个节点的上下左右进行递归
【算法框架】
// 二维矩阵遍历框架
void dfs(vector<vector<int>> grid, int i, int j, vector<bool> visited) {
int m = grid.size(), n = grid[0].size();
if (i < 0 || j < 0 || i >= m || j >= n) {
// 超出索引边界
return;
}
if (visited[i][j]) {
// 已遍历过 (i, j)
return;
}
// 前序:进入节点 (i, j)
visited[i][j] = true;
dfs(grid, i - 1, j); // 上
dfs(grid, i + 1, j); // 下
dfs(grid, i, j - 1); // 左
dfs(grid, i, j + 1); // 右
// 后序:离开节点 (i, j)
// visited[i][j] = true;
}
在处理二维数组时我们有一个小技巧就是使用方向数组来处理上下左右的遍历,这时框架还是相似的
// 方向数组,分别代表上、下、左、右
vector<vector<int>> dirs = {{-1,0}, {1,0}, {0,-1}, {0,1}};
void dfs(vector<vector<int>> grid, int i, int j, vector<bool> visited) {
int m = grid.size(), n = grid[0].size();
if (i < 0 || j < 0 || i >= m || j >= n) {
// 超出索引边界
return;
}
if (visited[i][j]) {
// 已遍历过 (i, j)
return;
}
// 进入节点 (i, j)
visited[i][j] = true;
// 递归遍历上下左右的节点
for (auto d : dirs) {
int next_i = i + d[0];
int next_j = j + d[1];
dfs(grid, next_i, next_j);
}
// 离开节点 (i, j)
// visited[i][j] = true;
}
二维矩阵的 DFS 最好解决的就是各种岛屿问题,我们下面举例看下。关于岛屿的问题还有另外一些常用的方法,如 BFS,这个和 DFS 差别不大,就是递归的顺序不一样;还有一种做法是使用并查集,这就牵扯到并查集框架的问题,我们在另外一部分算法模板中再详细介绍
【经典问题分析——岛屿数量】
题目描述
解法
我们把遍历过的岛屿块就直接用 0 标记了,这种算法叫做 FloodFill,主要就是省事,避免维护一个 visited 数组
class Solution {
public:
int numIslands(vector<vector<char>>& grid) {
int res = 0;
int m = grid.size(), n = grid[0].size();
for (int i = 0; i < m; i++)
{
for (int j = 0; j < n; j++)
{
if (grid[i][j] == '1')
{
res++;
dfs(grid, i, j);
}
}
}
return res;
}
void dfs(vector<vector<char>>& grid, int i,int j)
{
int m = grid.size(), n = grid[0].size();
if (i < 0 || j < 0 || i >= m || j >= n) return;
if (grid[i][j] == '0') return;
grid[i][j] = '0';
dfs(grid, i + 1, j);
dfs(grid, i, j + 1);
dfs(grid, i - 1, j);
dfs(grid, i, j - 1);
}
};
【经典问题分析——统计封闭岛屿的数目】
题目描述
解法
注意这道题在符号上对岛屿和海洋的定义是不同的,另外所有在二位矩阵边界上的岛屿都不可能是被海包围的孤岛,抓住这两点问题就解决了
class Solution {
public:
int closedIsland(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
for (int j = 0; j < n; j++)
{
dfs(grid, 0, j);
dfs(grid, m - 1, j);
}
for (int i = 0; i < m; i++)
{
dfs(grid, i, 0);
dfs(grid, i, n - 1);
}
int res = 0;
for (int i = 0; i < m; i++)
{
for (int j = 0; j < n; j++)
{
if (grid[i][j] == 0)
{
res++;
dfs(grid, i, j);
}
}
}
return res;
}
void dfs(vector<vector<int>>& grid, int i,int j){
int m = grid.size(), n = grid[0].size();
if (i < 0 || j < 0 || i >= m || j >= n) return;
if (grid[i][j] == 1) return;
grid[i][j] = 1;
dfs(grid, i + 1, j);
dfs(grid, i, j + 1);
dfs(grid, i - 1, j);
dfs(grid, i, j - 1);
}
};
【经典问题分析——岛屿的最大面积】
题目描述
解法
这道题主要就是在 DFS 的时候添加一个计数,方法还是一样 FloodFill 算法
class Solution {
public:
int maxAreaOfIsland(vector<vector<int>>& grid) {
int res = 0;
int m = grid.size(), n = grid[0].size();
for (int i = 0; i < m; i++)
{
for (int j = 0; j < n; j++)
{
int tmp = dfs(grid, i, j);
res = res > tmp ? res : tmp;
}
}
return res;
}
int dfs(vector<vector<int>>& grid, int i,int j){
int m = grid.size(), n = grid[0].size();
if (i < 0 || j < 0 || i >= m || j >= n) return 0;
if (grid[i][j] == 0) return 0;
grid[i][j] = 0;
return dfs(grid, i + 1, j) + dfs(grid, i, j + 1) +
dfs(grid, i - 1, j) + dfs(grid, i, j - 1) + 1;
}
};
【经典问题分析——统计子岛屿】
题目描述
解法
子岛屿的说法换一下这道题就没什么难点了,如果 2 中存在一片陆地,在 1 中对应位置是海水,那么就把 2 中这个岛屿淹了,最后 2 中剩下的岛屿自然是 1 的子岛屿
class Solution {
public:
int countSubIslands(vector<vector<int>>& grid1, vector<vector<int>>& grid2) {
int m = grid1.size(), n = grid1[0].size();
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
if (grid1[i][j] == 0 && grid2[i][j] == 1)
dfs(grid2, i, j);
int res = 0;
for (int i = 0; i < m; i++)
{
for (int j = 0; j < n; j++)
{
if (grid2[i][j] == 1)
{
res++;
dfs(grid2, i, j);
}
}
}
return res;
}
void dfs(vector<vector<int>>& grid, int i,int j){
int m = grid.size(), n = grid[0].size();
if (i < 0 || j < 0 || i >= m || j >= n) return;
if (grid[i][j] == 0) return;
grid[i][j] = 0;
dfs(grid, i + 1, j);
dfs(grid, i, j + 1);
dfs(grid, i - 1, j);
dfs(grid, i, j - 1);
}
};
3.4 二叉树的遍历
前面讲过 BFS、DFS,这些都是树和图中常用的搜索方法。这一节我们主要来讲一下二叉树的搜索。一般在二叉树里面我们不说深搜、广搜,而是说先序后序或中序遍历,每种遍历方法不同的是结点在遍历中的访问时间, 下面给出一张图来回忆一下,熟悉数据结构的都不陌生,请时刻记住这张图,它是你选择不同遍历方法解题的核心
![](https://i-blog.csdnimg.cn/blog_migrate/12611d5d64cb9ef6021a302afbd386d0.png)
我们这里做一个简单的总结:
- 一般不是特定中序或后序具有更大优势的情况下,我们都采用前序遍历,写起来方便易读
- 中序一般用在二叉搜索树上,这时遍历的结果会是一个有序数组
- 后序一般用在递归上,如果发现题目和子树有关,那大概率就要在后序位置写代码
一般来说,二叉树题目的递归解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着回溯算法核心框架和动态规划核心框架
一种通用的思考过程是:是否可以通过遍历一遍二叉树得到答案?如果不能的话,是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?
二叉树的最大深度这道题就是典型的题目,可以通过两种思路解决
【经典问题分析——二叉树的最大深度】
题目描述
解法
我们可以通过一次遍历用一个外部变量记录每个结点所在的深度,由此就可以得到最大的深度
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
int maxDepth(TreeNode* root) {
int res = 0, depth = 0;
traverse(root, res, depth);
return res;
}
void traverse(TreeNode* root, int& res, int& depth){
if (root == nullptr)
{
res = res > depth ? res : depth;
return;
}
depth++;
traverse(root->left, res, depth);
traverse(root->right, res, depth);
depth--;
}
};
同时,我们也可以按照动态规划的思路来做,一棵二叉树的最大深度可以通过每棵子树的最大高度推出
class Solution {
public:
int maxDepth(TreeNode* root) {
if (root == nullptr) return 0;
int left_max = maxDepth(root->left);
int right_max = maxDepth(root->right);
return max(left_max, right_max) + 1;
}
};
我们再看一道利用后续遍历递归的题目
【经典问题分析——二叉树中的最大路径和】
题目描述
解法
class Solution {
public:
int maxPathSum(TreeNode* root) {
if (root == nullptr) return 0;
int res = INT_MIN;
onSideMax(root, res);
return res;
}
int onSideMax(TreeNode* root, int& res){
if (root == nullptr) return 0;
int left_max_sum = max(0, onSideMax(root->left, res));
int right_max_sum = max(0, onSideMax(root->right, res));
int path_max_sum = root -> val + left_max_sum + right_max_sum;
res = max(res, path_max_sum);
return max(left_max_sum, right_max_sum) + root -> val;
}
};
最后,我们讨论下二叉树的层遍历,算法框架如下,和 BFS 是类似的
// 输入一棵二叉树的根节点,层序遍历这棵二叉树
void levelTraverse(TreeNode* root) {
if (root == nullptr) return;
queue<TreeNode*> q;
q.push(root);
// 从上到下遍历二叉树的每一层
while (!q.empty()) {
int sz = q.size();
// 从左到右遍历每一层的每个节点
for (int i = 0; i < sz; i++) {
TreeNode* cur = q.pop();
// 将下一层节点放入队列
if (cur->left != null) {
q.push(cur->left);
}
if (cur->right != null) {
q.push(cur-?right);
}
}
}
}
这里面 while 循环和 for 循环分管从上到下和从左到右的遍历:
【经典问题分析——在每个树行中找最大值】
题目描述
解法
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
vector<int> largestValues(TreeNode* root) {
vector<int> res;
if (root == nullptr) return res;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()){
int n = q.size();
int level_max = INT_MIN;
for (int i = 0; i < n; i++)
{
TreeNode* tmp = q.front();
q.pop();
level_max= max(level_max, tmp->val);
if (tmp->left) q.push(tmp->left);
if (tmp->right) q.push(tmp->right);
}
res.push_back(level_max);
}
return res;
}
};
4 链表
链表是个老生常谈的问题,这里我们做一个小结。关于链表还有一部分内容是在指针那一块
【经典问题分析——合并两个有序链表】
题目描述
解法
解法有递归和迭代两种,这里我们介绍迭代,还有一种递归的方法应该很容易想到,可以参考 LeetCode 21 - 合并两个有序链表
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
ListNode newhead(0);
ListNode* p = &newhead;
while (list1 != nullptr && list2 != nullptr)
{
if (list1->val > list2->val)
{
p->next = list2;
list2 = list2->next;
}
else
{
p->next = list1;
list1 = list1->next;
}
p = p->next;
}
p->next = list1 ? list1 : list2;
return newhead.next;
}
};
【经典问题分析——合并K个有序链表】
题目描述
解法
这里会用到一个数据结构叫做优先级队列,也就是二叉堆,关于二叉堆我们不需要自己手动从底层实现,调用下 C++ STL 中的 priority_queue
关于这道题还有一种分治的解法,可以参考 LeetCode 23 - 合并k个排序链表
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
struct cmp{
bool operator()(ListNode* a, ListNode* b)
{
return a->val > b->val;
}
};
ListNode* mergeKLists(vector<ListNode*>& lists) {
priority_queue<ListNode*, vector<ListNode*>, cmp> heapk;
for (auto p: lists)
if (p != nullptr) heapk.push(p);
ListNode* phead = new ListNode(-1);
ListNode* pcur = phead;
while (!heapk.empty())
{
ListNode* top = heapk.top();
heapk.pop();
pcur->next = top;
pcur = pcur->next;
if (top->next!=nullptr) heapk.push(top->next);
}
return phead->next;
}
};
弹出操作时,比较操作的代价会被优化到 O ( log k ) \mathcal O(\log k) O(logk)。同时,找到最小值节点的时间开销仅仅为 O ( 1 ) \mathcal O(1) O(1)
前缀和
二维前缀和
二维前缀和矩阵中的每一个格子记录的是「以当前位置为区域的右下角(区域左上角恒定为原数组的左上角)的区域和」,那么二维前缀和矩阵就可以按照如下图所示的方法生成
因此当我们要求
(
x
1
,
y
1
)
(x1, y_1)
(x1,y1) 作为左上角,
(
x
2
,
y
2
)
(x_2, y_2)
(x2,y2) 作为右下角 的区域和的时候,可以直接利用前缀和数组快速求解:
s
u
m
[
x
2
]
[
y
2
]
−
s
u
m
[
x
1
−
1
]
[
y
2
]
−
s
u
m
[
x
2
]
[
y
1
−
1
]
+
s
u
m
[
x
1
−
1
]
[
y
1
−
1
]
sum[x_2][y_2] - sum[x_1 - 1][y_2] - sum[x_2][y_1 - 1] + sum[x_1 - 1][y_1 - 1]
sum[x2][y2]−sum[x1−1][y2]−sum[x2][y1−1]+sum[x1−1][y1−1]
这道题是 LeetCode 304. 二维区域和检索 - 矩阵不可变,其答案可以视为二维前缀和的一个框架
class NumMatrix {
public:
vector<vector<int>> sum;
NumMatrix(vector<vector<int>>& matrix) {
int n = matrix.size();
if (n > 0)
{
int m = matrix[0].size();
sum.resize(n + 1, vector<int>(m + 1, 0));
for (int i = 1 ; i <= n; i++)
for (int j = 1; j <= m; j++)
sum[i][j] = sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1] + matrix[i - 1][j - 1];
}
}
int sumRegion(int row1, int col1, int row2, int col2) {
row1++, row2++, col1++, col2++;
return sum[row2][col2] - sum[row1 - 1][col2] - sum[row2][col1 - 1] + sum[row1 - 1][col1 -1];
}
};
/**
* Your NumMatrix object will be instantiated and called as such:
* NumMatrix* obj = new NumMatrix(matrix);
* int param_1 = obj->sumRegion(row1,col1,row2,col2);
*/
【经典问题分析——图片平滑器】
题目描述
解法
这道题我们定义 ( a , b ) = ( i − 1 , j − 1 ) (a, b) = (i - 1, j - 1) (a,b)=(i−1,j−1) 和 ( c , d ) = ( i + 1 , j + 1 ) (c, d) = (i + 1, j + 1) (c,d)=(i+1,j+1) 表示每个九宫格的左上和右下,为了防止超出原矩阵,我们需要将 ( a , b ) (a, b) (a,b) 和 ( c , d ) (c, d) (c,d) 与边界做比较
同时,我们一般习惯对
n
×
m
n\times m
n×m 的矩阵创建
(
n
+
1
)
×
(
m
+
1
)
(n+1)\times(m+1)
(n+1)×(m+1) 的二维前缀和矩阵,这里考虑到下图红色矩阵的情况,我们将二维前缀和矩阵申请为
(
n
+
2
)
×
(
m
+
2
)
(n+2)\times(m+2)
(n+2)×(m+2)
class Solution {
public:
vector<vector<int>> imageSmoother(vector<vector<int>>& img) {
int n = img.size(), m = img[0].size();
vector<vector<int>> sum(n + 2, vector<int>(m + 2, 0));
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
sum[i][j] = sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1] + img[i - 1][j - 1];
vector<vector<int>> ans(n, vector<int>(m));
for (int i = 0; i < n; i++)
{
for (int j = 0; j < m; j++)
{
int a = max(0, i - 1), b = max(0, j - 1);
int c = min(n- 1, i + 1), d = min(m - 1, j + 1);
int cnt = (c - a + 1) * (d - b + 1);
int tot = sum[c + 1][d + 1] - sum[a][d + 1] - sum[c + 1][b] + sum[a][b];
ans[i][j] = tot / cnt;
}
}
return ans;
}
};