引言
需要读者先做完对应题目(至少看过答案)以后再看总结归纳,否则会一脸懵逼。
通用建议:
题目一般只给一两个输入示例,这是远远不够的。我们思考的时候经常会被示例给束缚(脑子里只对着那个示例想)。如果自己在做题的时候,在纸上多构造几个示例,对比(找不同)和归纳(找相同),会大大增加解决的概率。
leetcode.98 搜索二叉树的遍历
题目链接:https://leetcode.com/problems...
一种简单直接方法是,在递归的过程中维护上界和下界要求,可以很快解决。
比较有价值的是另一种方法,它避免了在遍历的过程中维护上界和下界,它揭示了中序遍历的巧妙特性:
- 搜索二叉树,如果从上往下俯视,看到的是一个有序数组:
- 中序遍历搜索二叉树,它的遍历顺序恰好是节点大小排序(即,俯视图的数组顺序)。
- 在遍历过程中,可以维护一个全局变量
prev
,可以回看上一个节点(即,恰好比当前节点“小一点”的节点)。
可以结合leetcode.94来复习二叉树的中序遍历(迭代+栈的方式,以及Morris无栈遍历)。
leetcode.146 哈希表和队列的使用(实现LRU)
题目链接:https://leetcode.com/problems...
这题的难点在于,要求时间复杂度为O(1),因此不要想着自己构造各种风骚(复杂)的数据结构,直接用哈希表+链表暴力解决。考察对数据结构(即STL,哈哈)的掌握。
将时间复杂度为O(1)的关键在于利用数据结构的特性:
- 哈希表插入、删除、查找只需O(1)的时间
- 链表的插入、删除只需要O(1)的时间(前提是已经找到对应项的指针)
- 利用以上两点,通过哈希表来保存链表的每一项的指针,就可以在O(1)的时间内删除链表中的任意项
- 链表可以当做一个FIFO队列使用,拿到“Least Recently Used”的数据
经典题目two sum也考察了哈希表的使用。
leetcode.581 在扫描数组的过程中收集信息
题目连接:https://leetcode.com/problems...
忽略暴力解法,直接考虑O(n)时间的解法。很多题目的O(n)的解法,其关键在于你能不能在一趟扫描的过程中收集关键的信息。这道题揭示了2种在扫描过程中收集信息的技巧:
- 用上升栈(单调栈),即,遇到比top大的数字才入栈,否则忽略(或者弹栈,使top变小)。下降栈同理。上升栈的特点是,它同时保留了先后关系和大小关系的信息。这种解法的空间复杂度一般为O(n)。
- 用变量,保存最大值、最小值、累积值、关键指针。变量法的典型用途是“找最值”,如果你的问题可以拆解为最值查找,那么很可能可以使用变量法。变量法的空间复杂度一般为O(1)。
- 如果问题具有对称性,需要考虑逆序扫描,甚至2个方向同时扫描。
- 动态规划。见下面的“leetcode.41”解析。
- 滑动窗口。适合“找区间”问题,并且区间内部的顺序不重要。见下面的“leetcode.438”解析。
- 单调队列。见下面的“leetcode.239”解析。
- 哈希表。将扫描到的元素存入哈希表中,会丢失输入数组中的顺序信息。但是它的优势在于,在后面扫描的时候,能够更快地找到之前遇到的相同元素(聚类)。
上升栈(单调栈)
第一种解法是用上升栈(我一开始没想到,看了答案才知道)。如果current比top大就入栈,否则不断弹栈,找到最后入栈的、比current小的数字,再将current入栈。找到的数字就是左边界的候选。右边界的找法和左边界同理,从右往左扫描。
在不断弹栈然后入栈的过程中,相当于丢弃了在此之前比current大的数字。因此弹栈的过程相当于不断在问:“在top之前,比top恰好小一些的数字是谁”。要根据这个特性,把上升栈用在合适的题目上。同样能用上升栈解决的问题:leetcode.84 通过上升栈找到左右两侧的较小元素、leetcode.85、leetcode.739。
变量法
第二种解法是用变量法,空间复杂度低至O(1)。
要想到这种解法,关键在于分析题目,将题目拆解为最值的查找。
在顺序扫描的过程中,我们能找到若干个【不在正确位置上的的数字】,这其中值最小的数字,就代表答案数组的左边界。同理,逆序扫描可以找到答案数组的右边界。
leetcode.41 以数据值为下标,将数据存入数组
题目连接:https://leetcode.com/problems...
这题的坑点在于,题目要求写了“uses constant extra space”,然后我的思维被局限在了变量法,想了很久以后遂放弃,结果看答案以后,发现答案是要改变输入数组的,并且答案算法在改变输入数组以后无法复原。
严格来说确实没毛病,确实没有使用“额外”空间。这题主要的价值在于这个教训。
打破思维局限以后,解法其实非常简单。本质是以数据值为下标,将数据放在数组中,每次存储的时间开销为O(1)。无需深入讨论。我所看到的最简洁答案。
leetcode.448也是需要在输入数组中保存添加额外信息的问题。前面提到的Morris无栈遍历也是一种“无需额外空间”的算法。不过Morris无栈遍历是可以在遍历完成以后复原数据结构的。
leetcode.287 的其中一种解法,以数据值为下标,将数组看作一个链表,巧妙地把原问题转换成一个链表找环的问题。
leetcode.138 原地增强链表(无额外空间开销)
题目链接:https://leetcode.com/problems...
和上面的 leetcode.41 类似,这一题也通过修改输入数据结构来解决,不过这一题很巧妙,最后可以将数据结构复原。
这一题增强链表的方式是,在每一个链表节点后面都增加一个节点,用来存储增加的字段。这种方式的好处是,无需拷贝已有节点,指向原始节点的指针依然可用,并且可以通过这个指针来访问新增的字段。
这种思路打破了通常的思维局限,它给我的启发是:不必为一个数据结构中的每一项都赋予同样的意义,你可以以一个数据结构为底层,封装出一个更高层次的数据结构。当你push一个新的元素时,你可以一同push很多相关信息,只要你后面取出来的时候,能将所有相关的信息一起取出来即可。
比如对于一个存储坐标的stack,内部数据结构可以定义为stack<int>
,每次push一个新坐标(x,y)
的时候分别push x 和 y到内部stack;每次pop的坐标时候就从内部stack pop两次,得到x和y。
leetcode.155也有巧用这种思路的解法。
leetcode.234 反转链表及其应用
题目链接:https://leetcode.com/problems...
这题考察以下关键点:
- 检查回文,等价于从链表中间出发,一个指针往左走,一个指针往右走,对比2个指针的值
- 通过快指针和慢指针来找到链表中点(快指针每次走2步)
- 使用O(n)时间以及O(1)内存来反转链表,使得后面我们的指针能够“走回来”
class Solution
{
public:
bool isPalindrome(ListNode *head)
{
ListNode *fast = head, *slow = head, *prev = NULL, *tmp;
bool odd = false;
while (fast)
{
// 快指针每次前进2步
fast = fast->next;
if (!fast)
{
// 链表有奇数个node
odd = true;
break;
}
fast = fast->next;
// 慢指针每次前进1步,且在前进过程中反转链表
tmp = slow->next;
slow->next = prev;
prev = slow;
slow = tmp;
}
ListNode *p1, *p2;
p1 = prev;
if (odd)
p2 = slow->next;
else
p2 = slow;
prev = slow;
while (p1 && p2)
{
// p1和p2从中间出发,p1往左走,p2往右走
if (p1->val != p2->val)
return false;
p2 = p2->next;
// p1在往回走的过程中,将链表恢复原状
tmp = p1->next;
p1->next = prev;
prev = p1;
p1 = tmp;
}
return true;
}
};
leetcode.152 动态规划找数组
题目链接:https://leetcode.com/problems...
给一个数组,求最值的问题,很多时候是有O(n)解法的。因此遇到这种问题优先考虑前面所总结的数组扫描技巧。这一题介绍上一节没有详述的数组扫描技巧:动态规划。动态规划(DP)是典型的“在扫描输入的构成中构造数据结构,使得我们能更快地完成后续扫描的计算”。
我过去与动态规划相关的文章
要启发自己得到动态规划的解法,需要对问题进行逆向思考:从最终结果出发,推演结果的形成过程(它是如何从【子结果】加工得到的)。
对于这一题,最终结果是【乘积最大的数组】。虽然我们不知道具体是如何,但是这个答案暗示着子结构:
如果已经知道【右边界下标为k的数组】的乘积最大值max_p
和最小值min_p
,我们就能够计算出【右边界下标为k+1的数组】的乘积最大值和最小值。
很多“找数组”的问题都具有这种子结构,以数组右边界来定义子问题。比如更简单的动态规划问题:https://leetcode.com/problems...
if (nums[i] >= 0)
{
max_p = max(max_p * nums[i], nums[i]);
min_p = min(min_p * nums[i], nums[i]);
}
else
{
int temp = max_p;
max_p = max(min_p * nums[i], nums[i]);
min_p = min(temp * nums[i], nums[i]); // 用旧的max_p
}
那么将k从0迭代到n-1,我们在此过程中计算出的max_p,最大的就是最终答案。
leetcode.221 动态规划优化空间开销
题目链接:https://leetcode.com/problems...
容易出bug的点:
-
将二维的dp数组优化为一维后,当你读取
dp[i-1]
的时候,读取的是本轮计算的结果,而不是上一轮计算的dp结果。本来想要读取
2d[i-1][j-1]
,实际读取的是2d[i][j-1]
- 做2层循环来遍历矩形的时候,哪一层循环在外面,哪一层循环在里面,会决定遍历的路线(横向扫描还是纵向扫描),遍历路线选错可能会造成答案出错。
同类题目:leetcode.62
leetcode.33 & 34 && 300 双指针边界情况练习
题目链接:
这三题通过迭代的方式来实现二分查找,能够同时考察双指针的使用和边界情况的考虑。
分治算法经常需要“将一个区间切分为2个子区间”,这种时候需要额外注意边界情况,避免死循环、数组越界的发生。假设初始区间的左边界为left
,右边界为right
,且left < right
,目标切分点命名为mid
。以下讨论同时适用于右边界为开区间和闭区间的场景。
-
如果mid的取值可能是left,则mid不能作为右区间的左边界。否则会出现死循环。
- mid只能放到左区间,或单独处理。即
[left, mid] [mid+1, right)
或[l, mid-1] [mid] [mid+1, r)
- 对比
array[left]
和array[mid]
的时候要考虑left==mid
这种边界情况 - 如果mid的计算方式为
(left+right)/2
,则属于这种情况
- mid只能放到左区间,或单独处理。即
-
如果mid的取值可能是right,则mid不能作为左区间的右边界。否则会出现死循环。
- mid只能放到右区间,或单独处理。即
[left, mid-1] [mid, right)
或[l, mid-1] [mid] [mid+1, r)
- 对比
array[right]
和array[mid]
的时候要考虑right==mid
这种边界情况
- mid只能放到右区间,或单独处理。即
- 如果mid既有可能是left又有可能是right,则采用最稳妥的划分方式:
[l, mid-1] [mid] [mid+1, r)
- 如果你之前没有处理掉left==right的情况,则对比
array[left]
array[right]
array[mid]
三者的时候都要小心,它们可能是同一个元素。 - 不要访问
array[mid+1]
,有可能出现数组越界。 - 绝大部分情况下可以访问
array[mid]
(除非mid可能为right、且right为开区间)。 - 小技巧,
mid=(left+right)/2
会偏向甚至等于left,而mid=(left+right+1)/2
会偏向甚至等于right。
建议提前处理掉left==right
和left+1==right
的情况,减轻心智负担。
第二题的解法尤其能体现双指针的操控技巧:
class Solution
{
public:
vector<int> searchRange(vector<int> &nums, int target)
{
if (nums.size() == 0)
return vector<int>{-1, -1};
int l = 0, r = nums.size() - 1;
while (l < r)
{
int m = (l + r) / 2;
if (nums[m] < target)
l = m + 1;
else
r = m;
}
if (nums[l] != target)
return vector<int>{-1, -1};
int res_l = l;
r = nums.size() - 1; // 不需要重置l
while (l < r)
{
// 提前排除只有2个元素的数组,使得m必定在l和r之间
// if (l + 1 == r)
// {
// if (nums[r] == target)
// l = r;
// else
// r = l;
// break;
// }
// 除了提前排除数组以外,还可以m = (l + r + 1) / 2使m偏向右边
int m = (l + r + 1) / 2;
if (nums[m] <= target)
l = m;
else
r = m - 1;
}
return vector<int>{res_l, l};
}
};
leetcode.300使用二分查找来从有序数组中找到首个大于等于k的元素:
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int size = nums.size();
vector<int> dp(size+1, INT_MAX);
dp[0] = INT_MIN;
int max_len = 0;
for (int& num: nums) {
int l = 0, r = size;
while (l < r) {
int mid = (l+r)>>1;
int pivot = dp[mid];
if (pivot < num) l = mid+1;
else r = mid;
}
dp[r] = num;
max_len = max(max_len, r);
}
return max_len;
}
};
leetcode有人总结了能用双指针解决的问题的特征。
leetcode.11 双指针查找最大面积
题目链接:https://leetcode.com/problems...
题目要求找出面积最大的区间。面积是一种收到2个变量影响的属性:宽度和高度。我们要查找一个目标,它使得二元函数(size=w*h
)达到最大值。这种时候我们有以下枚举候选人的方式:
- 先使得其中一个变量(w)达到最大,计算此时能达到的最大面积。它是最终结果的候选者。
- 枚举思路:接下来我们尝试找到面积可能更大的区间(即下一个候选者)。假设上一个枚举的区间中,左边界left是高度瓶颈h,那么后面搜索的空间就不包含这个左边界。因为以left为左边界的容器,高度必定不超过h,且宽度更小,面积不可能比现在更大。
- 现在,问题的搜索空间更小了(数组已经剔除了左边界),在这个搜索空间完成同样的问题,找出下一个候选者。
- 按照以上枚举思路,我们在此过程中一定能枚举出面积最大的区间。
实现代码:
class Solution {
public:
int maxArea(vector<int>& height) {
// 宽度最大的时候
int left = 0, right = height.size()-1;
int max_size = 0;
// 枚举【所有】【可能】比当前容器大的情况
// 在宽度不断缩小的过程中,新的容器的h必须比之前大,才有可能面积更大
while (left < right) {
// 计算当前面积
int h = min(height[left], height[right]);
int size = h * (right-left);
max_size = max(max_size, size);
// 如果height[left] <= h,那么以left为左边界的容器,高度也不超过h,且宽度比现在还小,面积不可能比现在更大
while(left < right && height[left] <= h) left++;
while(left < right && height[right] <= h) right--;
}
return max_size;
}
};
双指针在这题目里面就是一种最优的枚举方式,它能高效、持续地修剪搜索空间。
leetcode.142 列方程找指针关系
题目链接:https://leetcode.com/problems...
这道题有点硬核,需要列方程找出指针之间的关系。
解决这题的前置条件是,你知道如何判断链表中是否包含环(使用快指针和慢指针)。这个问题对应于leetcode.141。
在此基础上,快指针和慢指针相遇以后,你所拥有的信息是不足以求出答案的(你最多只能知道当前走了多少步、环的周长是多少,你无法知道相遇点在环中的位置)。
你需要从这个相遇点继续推进慢指针,同时从head再启动一个慢指针。通过列方程可以知道,这两个指针必定会在环起始处相遇。
class Solution
{
public:
ListNode *detectCycle(ListNode *head)
{
if (!head || !head->next)
return NULL;
ListNode *slow = head->next, *fast = head->next->next;
int step = 1;
while (true)
{
if (!fast || !fast->next)
return NULL;
slow = slow->next;
fast = fast->next->next;
++step;
if (fast == slow)
break;
}
// c'为环周长,k为相遇位置,x为正整数,step为相遇的步数,s为head与环之间的距离。
// 2*step = s+xc'+k (快指针所走的距离列一个方程)
// step = s+k (慢指针所走的距离列一个方程)
//==>
// step = xc
// =>
// (方程左右两边同时加s)
// step+s = xc'+s
// 这个方程意味着,如果慢指针再走s步,那么它走的步数就等价于xc'+s,
// 此时它的位置必定在环入口
// 那么我们在继续走s步之前,从入口处发出一个慢指针p3,
// 那么p3必定会与慢指针在环入口处相遇
ListNode *cur = slow, *res = head;
while (cur != res)
{
cur = cur->next;
res = res->next;
}
return res;
}
};
这题和一道小学奥数题:烧香计时 有异曲同工之妙。需要在某些关键节点重新发起一个指针,让这个指针在可预期的位置与另一个指针相遇。
leetcode.60也是类似烧香计时的问题。相对简单一些。
leetcode.139 构造图来处理输入
题目链接:https://leetcode.com/problems...
这题最高效的算法是,将字典构造成一棵前缀树,然后在扫描输入字符串的时候,根据扫描到的字符,在树中游走。
前缀树的特点是它能高效地存储一个字典,未来可以快速查询。在字典中搜索的过程等价为在前缀树中游走的过程。
我的最终代码:
#include <vector>
#include <map>
#include <iostream>
#include <list>
using namespace std;
struct Node
{
bool done;
map<char, Node *> children;
Node() : done(false) {}
};
class Solution
{
public:
bool wordBreak(string s, vector<string> &wordDict)
{
Node *root = new Node();
for (auto word : wordDict)
{
insert_tree(root, word);
}
list<Node *> pointers;
pointers.push_back(root);
bool has_complete = false;
for (char c : s)
{
if (pointers.empty())
return false;
has_complete = false;
auto old_begin = pointers.begin();
for (auto it = pointers.begin(); it != pointers.end(); ++it)
{
if ((*it)->children.count(c) == 0)
{
// will be erased
continue;
}
else
{
auto next_p = (*it)->children[c];
// move p to next
pointers.push_front(next_p);
if (next_p->done)
has_complete = true; // should start a new p
}
}
if (has_complete)
pointers.push_front(root);
// erase old pointers
pointers.erase(old_begin, pointers.end());
}
return has_complete;
}
private:
void insert_tree(Node *&root, string &word)
{
Node *cur = root;
for (char c : word)
{
map<char, Node *> &m = cur->children;
if (m.count(c) == 0)
m[c] = new Node();
cur = m[c];
}
cur->done = true;
}
};
leetcode.438 滑动窗口找区间
题目链接:https://leetcode.com/problems...
这题虽然只要求返回起始下标,但本质上还是在找区间。
对于找区间的问题,并且对于目标区间内的元素顺序没有要求,可以使用滑动窗口。滑动窗口也是一种在扫描输入过程中收集信息的方法。它适合用来收集统计性的信息,与内部元素顺序无关的信息。
每次扫描,只需要删掉窗口左边的元素、增加窗口右边的元素,然后根据这两个元素的变化来更新统计信息。
class Solution
{
public:
vector<int> findAnagrams(string s, string p)
{
vector<int> ret;
int s_s = s.size(), p_s = p.size();
if (s_s < p_s)
return ret;
map<char, int> need;
for (int i = 0; i < p_s; ++i)
{
if (need.count(p[i]) == 0)
need[p[i]] = 1;
else
need[p[i]]++;
}
for (int i = 0; i < p_s; ++i)
{
if (need.count(s[i]) > 0)
need[s[i]]--;
}
if (!has_need(need))
ret.push_back(0);
for (int i = p_s; i < s_s; ++i)
{
char left = s[i - p_s], right = s[i];
if (need.count(left) > 0)
need[left]++;
if (need.count(right) > 0)
need[right]--;
// 当前区间符合要求的充要条件:
// need[x] == 0
if (!has_need(need))
ret.push_back(i - p_s + 1);
}
return ret;
}
bool has_need(map<char, int> &need)
{
for (auto p : need)
{
if (p.second != 0)
return true;
}
return false;
}
};
leetcode.239 单调队列(滑动窗口内的最大值)
题目链接:https://leetcode.com/problems...
单调队列与单调栈有异曲同工之妙。
- 上升栈回答的问题是:“在指定元素a的前面(即在a之前加入),恰好比a小一些的元素是谁”。
- 下降队列回答的问题是:“在指定元素a的后面(即在a之后加入),恰好比a小一些的元素是谁”。
拿上升队列陈述(下降队列同理),它支持以下操作,都是 O(1) 时间开销:
- push 加入元素
- pop 弹出最早加入的元素
- getMax 获取队列内最大的元素
对于滑动窗口问题,恰好符合"弹出最早加入的元素",因此滑动窗口取最值问题使用单调队列来解决。
class Solution
{
public:
vector<int> maxSlidingWindow(vector<int> &nums, int k)
{
vector<int> res;
list<int> ls;
for (int i = 0; i < nums.size(); ++i)
{
while (!ls.empty() && ls.back() < nums[i])
{
ls.pop_back();
}
ls.push_back(nums[i]);
if (i >= k - 1)
{
if (i >= k && ls.front() == nums[i - k])
ls.pop_front();
res.push_back(ls.front());
}
}
return res;
}
};
虽然有嵌套循环,但是内部的while循环实际上总共最多只能执行n轮(顶多每个元素被pop一次)。因此算法时间复杂度为O(n)。
leetcode.295 利用堆来找中位数
题目链接:https://leetcode.com/problems...
既然只需要找出中位数,那么排序肯定不是最快的方案,因为它会做很多额外的工作。
这种只要“找出排序后第k位”的问题都要考虑是否能用堆来解决。堆的特点是始终能在最短时间内找出最大的数字,而不做任何额外的工作。
struct cmp
{
bool operator()(int a, int b)
{
return a > b;
}
};
class MedianFinder
{
priority_queue<int> left;
priority_queue<int, vector<int>, cmp> right;
public:
/** initialize your data structure here. */
MedianFinder()
{
}
void addNum(int num)
{
if (left.size() <= right.size())
left.push(num);
else
right.push(num);
if (left.size() > 0 && right.size() > 0 && left.top() > right.top())
{
right.push(left.top());
left.pop();
left.push(right.top());
right.pop();
}
}
double findMedian()
{
if (left.size() == right.size())
return (left.top() + right.top()) / (double)2;
return left.top();
}
};
类似问题:leetcode.215 找到第k大数字
leetcode.416 动态规划解决背包问题
题目链接:https://leetcode.com/problems...
这题表面上是问你能不能划分等和数组,实际上是在问你能不能找到一组元素的和为sum/2
。很多与子集元素之和有关的题目本质上都是背包问题。背包问题的通用解法:使用使用数组dp来存储所有可能得到的和,dp[i]
表示和为i的子集数量。
因此这题能够解决的前提是,能够确定子集和的范围(即dp数组的长度),并且dp数组长度在可接受范围内。
所以这题实际是0/1背包问题。这道题相对于普通背包问题的特殊之处在于,背包的容量并不是题目给定的。而是要根据输入数组计算sum/2
。
class Solution
{
public:
bool canPartition(vector<int> &nums)
{
int sum = 0;
for (auto &num : nums)
{
sum += num;
}
if (sum % 2 != 0)
return false;
sum = sum / 2;
vector<bool> dp(sum + 1, false);
dp[0] = true;
for (auto num : nums)
{
for (int i = sum; i >= 0; --i)
{
if (i - num >= 0)
dp[i] = dp[i] || dp[i - num];
}
}
return dp[sum];
}
};
有意思的是,这个算法还可以使用bitset来实现(思路完全一样,只不过使用了更高效的数据结构):
class Solution {
public:
bool canPartition(vector<int>& nums) {
const int MAX_NUM = 100;
const int MAX_ARRAY_SIZE = 200;
bitset<MAX_NUM * MAX_ARRAY_SIZE / 2 + 1> bits(1);
int sum = 0;
for (auto n : nums) {
sum += n;
bits |= bits << n;
}
return !(sum % 2) && bits[sum / 2];
}
};
leetcode.494 也是一个变种的背包问题。一般的背包问题,对于某个元素,可以选择{拿取它,不拿取它};但是这个变种问题的选择是{拿+1倍它,拿-1倍它}。本质上还是一样的。
leetcode.560 前缀和求差得到区间和
题目链接:https://leetcode.com/problems...
区间和的算法:
先遍历一趟输入数组,计算前缀和sum,使得sum[i]
为0~i项之和。有了前缀和数组,对于任何区间都能在O(1)时间内求和:sum[i]-sum[j]
。
题目要求区间和为k的所有区间,等价于在sum
数组里面找到两个元素xy,使得y-x==k
。这个问题与leetcode.1 Two Sum类似,使用哈希表来解决。
class Solution
{
public:
int subarraySum(vector<int> &nums, int k)
{
int count = 0;
int size = nums.size();
// 前缀和 => 有多少个
unordered_map<int, int> sums;
sums[0] = 1;
for (int i = 0, s = 0; i < size; ++i)
{
// 0~i项之和
s += nums[i];
// 如果前面已经有前缀和为s - k,那么就存在区间和为k(k =(s)-(s-k))
if (sums.count(s - k))
count += sums[s - k];
// 将前缀和记录到哈希表中
if (sums.count(s))
sums[s]++;
else
sums[s] = 1;
}
return count;
}
};
leetcode.437同样是通过【前缀和之差】来求区间和的问题,同样使用哈希表来查找需要的前缀和。leetcode.437在这题的基础上结合了DFS,难度更高一些。leetcode.238同样是前缀累积的思路,只不过累积方式是求积而不是求和。
leetcode.309 存在多种状态的动态规划
题目链接:https://leetcode.com/problems...
这题是典型的多种状态动态规划,对于每一天都存在多种状态:已买入、已卖出。因此需要2个dp数组,其中dp1[i]
表示第i天处于已买入状态的最高利润,其中dp2[i]
表示第i天处于已卖出状态的最高利润。
这两个状态存在以下推导关系:
// 要么保持昨天的买入状态,要么今天买入
// 注意今天买入的前提是,前天处于卖出状态,并且昨天没有买入
buyed[i] = max(buyed[i-1], selled[i-2]-prices[i]);
// 要么保持昨天的卖出状态,要么今天卖出
selled[i] = max(selled[i-1], buyed[i-1]+prices[i]);
完整代码:
class Solution {
public:
int maxProfit(vector<int>& prices) {
int size = prices.size();
if (size <= 1) return 0;
vector<int> selled(size, 0);
vector<int> buyed(size, 0);
buyed[0] = -1 * prices[0];
selled[0] = 0;
buyed[1] = max(buyed[0], -1 * prices[1]);
selled[1] = max(0, buyed[0]+prices[1]);
for (int i = 2; i < size; ++i) {
// 要么保持昨天的买入状态,要么今天买入
// 注意今天买入的前提是,前天处于卖出状态,并且昨天没有买入
buyed[i] = max(buyed[i-1], selled[i-2]-prices[i]);
// 要么保持昨天的卖出状态,要么今天卖出
selled[i] = max(selled[i-1], buyed[i-1]+prices[i]);
}
return selled[size-1];
}
};
因为我们只用了dp数组昨天和前天的数据,所以可以将空间开销优化到O(1)级别:
class Solution {
public:
int maxProfit(vector<int>& prices) {
int size = prices.size();
if (size<=1) return 0;
int prepreselled = 0;
int prebuyed = max(-1*prices[0], -1 * prices[1]);
int preselled = max(0, -1*prices[0]+prices[1]);
for (int i = 2; i < size; ++i) {
int tmp1 = max(prebuyed, prepreselled-prices[i]);
int tmp2 = max(preselled, prebuyed+prices[i]);
prepreselled = preselled;
prebuyed = tmp1;
preselled = tmp2;
}
return preselled;
}
};
如果题目的状态更加复杂一些,可以通过画状态机来理清思路。参考这个题解)。
这题的变种:leetcode.714,也是多状态DP,不过更加简单一些。
leetcode.394 用栈来解析嵌套结构
题目链接:https://leetcode.com/problems...
栈、递归、嵌套结构有着密不可分的联系。看到题目中的3[a2[c]]
就应该本能地朝递归或者栈的方向去思考。
class Solution {
public:
string decodeString(string s) {
int i = 0;
string res;
stack<pair<string, int>> sta;
while (i < s.size()) {
if (s[i] >= '0' && s[i] <= '9') {
int len = 1;
while(s[i+len] >= '0' && s[i+len] <= '9') {
len++;
}
// times to repeat
int times = stoi(s.substr(i, len));
// save current prefix into stack
sta.push({res, times});
res = "";
i = i+len+1;
} else if (s[i] == ']') {
auto p = sta.top();
sta.pop();
int times = p.second;
string tmp(res);
for (int k = 1; k < times; ++k) {
res += tmp;
}
res = p.first + res;
i++;
} else {
res += s[i++];
}
}
return res;
}
};
递归的解法参考leetcode讨论。
leetcode.287 用集合与集合相互对比,快速缩小搜索空间
题目链接:https://leetcode.com/problems...
已知数组项是连续的整数、并且只有其中一个整数有重复。我们可以通过一轮扫描,对1~k范围的整数计数,如果数量大于k,说明重复的整数在1~k范围内;否则重复的整数在k~n范围内。这样,我们就能把搜索空间缩小一半。
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int size = nums.size();
int left = 1, right = size-1, mid;
while (left < right) {
mid = (left+right)/2;
int count = 0;
for (auto num: nums) {
if (num <= mid) count++;
}
if (count > mid) {
right = mid;
} else {
left = mid+1;
}
}
return left;
}
};
这个问题有点像一道小学奥数题:天平称小球。如果一个一个小球地对比,需要对比的次数会很多。更高效的办法是:每一次测量,都对比2个小球的集合,将答案锁定到一个更小的集合中,就能够很快找到答案。
先将搜索空间划分为2个集合,然后用集合与集合相互对比(而不是个体之间相互对比),将搜索空间减半。
leetcode.215的一种解法思路与之类似。
leetcode.49 通过计数排序将字符串降维
题目链接:https://leetcode.com/problems...
这道题对于每个字符串的顺序并不关心,只关心它们的字符组成。我们需要将相同字符组成(但不同字符顺序)的字符串映射到同一个地方。这个时候就需要数据降维,丢弃字符串的顺序信息,仅仅保留字符组成信息。
计数排序就是一种字符串降维手段,abcba
降维成{a:2,b:2,c:1}
,它的字符串表示为a2b2c1
。因此,abcba
就能与bbaac
映射到同一个降维字符串。
用降维后的字符串作为hash key,就能把相同字符组成的字符串group到一起。
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> hashmap;
for (auto& str: strs) {
hashmap[count_hash(str)].push_back(str);
}
vector<vector<string>> res;
for (auto& p: hashmap) {
res.push_back(p.second);
}
return res;
}
string count_hash(string& str) {
vector<int> vec(26, 0);
for(auto& c: str) {
vec[c-'a']++;
}
string res;
for (int i = 0; i < 26; ++i) {
if (vec[i]==0) continue;
res += (i+'a');
res += to_string(vec[i]);
}
return res;
}
};
当元素的范围是确定的时候(比如都是小写字母),计数排序非常高效。
leetcode.169 投票算法找出众数
题目链接:https://leetcode.com/problems...
投票算法将所有元素划分为两个阵营:众数阵营、杂数阵营,然后让这两个阵营对拼消耗,最终剩下的必定是众数阵营的元素。在已经确定输入中存在众数的前提下,它能够用O(n)时间、O(1)空间找出众数,是最高效的算法。
class Solution {
public:
int majorityElement(vector<int>& nums) {
int count = 1, maj = nums[0];
for (int i = 1; i < nums.size(); ++i) {
if (maj == nums[i]) {
count++;
} else if (--count < 0) { maj = nums[i]; count = 1; }
}
return maj;
}
};
这题另外一个巧妙的思路,留意到这一点:众数必定是中位数,问题变成了找中位数,对应leetcode.215,可以使用堆也可以使用分治法,时间复杂度为O(nlogn)。
另外一个巧妙的思路:将每个数字看作32个bit位,则众数具有的bit位必定出现超过size/2,众数不具有的bit位必定出现少于size/2。对于每个比特位,我们扫描一次数组,就能知道众数是否具有这个比特位。
class Solution {
public:
int majorityElement(vector<int>& nums) {
int size = nums.size(), half_size = size/2, maj = 0;
for (unsigned int i = 0, bit = 1; i < 32; ++i, bit<<=1) {
int count = 0;
for (int num : nums) {
if (num & bit) {
count++;
}
}
if (count > half_size) maj |= bit;
}
return maj;
}
};
leetcode.347 找出前k大的数字(桶排序或堆排序)
题目链接:https://leetcode.com/problems...
第一趟扫描,通过维护一个哈希表,能够知道每个元素的出现频次。
重点在于如何按频次拿出k个数据:
- 通用方法:堆排序(优先级队列)。这里的一个技巧是,不要因为要找最大的频次就用最大堆,因为这样的话堆的大小会达到nums.size。用最小堆,可以始终保持堆的大小不超过k:
class cmp {
public:
bool operator() (pair<int, int>& a, pair<int, int>& b) {
return a.second > b.second;
}
};
class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> num_count;
for (auto& num: nums) {
num_count[num]++;
}
priority_queue<pair<int, int>, vector<pair<int, int>>, cmp> que;
for (auto& p: num_count) {
que.push(p);
if (que.size() > k) {
que.pop();
}
}
vector<int> res;
for (int i = 0; i < k; ++i) {
auto p = que.top();
que.pop();
res.push_back(p.first);
}
return res;
}
};
- 特殊方法:桶排序。时间效率高,但是这种方法只有在预先知道排序字段的范围的时候才能使用。在这个问题中,排序的字段是频次,频次取值范围 <= nums.size。
class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> num_count;
for (auto& num: nums) {
num_count[num]++;
}
vector<vector<int>> bucket(nums.size()+1, vector<int>());
for (auto& p: num_count) {
bucket[p.second].push_back(p.first);
}
vector<int> res;
for (int i = nums.size(); i > 0; --i) {
if (bucket[i].empty()) continue;
for (auto num: bucket[i]) {
res.push_back(num);
}
if (res.size() >= k) break;
}
return res;
}
};
leetcode.647 回文字符串检测
题目链接:https://leetcode.com/problems...
为了枚举所有回文字符串,我们先枚举回文的中心,从中心开始向2边扩展边界,当两边不相等时停止。
如果通过枚举回文的左右边界,那么耗时会高一些。因为枚举中心的方式始终从一个回文扩展出更长的回文,相当于dp。
class Solution {
public:
int countSubstrings(string s) {
int size = s.size();
int count = 0;
for (int i = 0; i < size; ++i) {
int left = i, right = i;
while (left >= 0 && right < size && s[left]==s[right]) {
count++;
left--;
right++;
}
left = i, right = i+1;
while (left >= 0 && right < size && s[left]==s[right]) {
count++;
left--;
right++;
}
}
return count;
}
};
类似问题:leetcode.131。它基于这个问题,先处理一遍输入s,提前检查回文,构造出一个查询table,table[i][j]
表示i~j范围内是否是回文。在后续计算的过程中,枚举出一个子串的时候,只需要查询这个table来判断回文。
leetcode.22 动态规划生成嵌套括号
题目链接:https://leetcode.com/problems...
这题用递归+回溯的话相对比较简单直接。这里我们讨论另一种方案:动态规划。
括号字符串是一种嵌套结构,其中中隐藏者子问题结构:
一个包含n个括号的字符串,它可以表示为:'(' + 包含x个括号的字符串 + ')' + 包含n-1-x个括号的字符串
x的取值范围为[0,n-1]
。
class Solution {
public:
vector<string> generateParenthesis(int n) {
vector<vector<string>> dp(n+1, vector<string>());
dp[0].push_back("");
for (int i = 1; i <=n; ++i) {
for (int j = 0; j < i; ++j) {
vector<string> &left = dp[j], &right = dp[i-1-j];
for (auto& l: left) {
for (auto& r: right) {
dp[i].push_back('('+l+')'+r);
}
}
}
}
return dp[n];
}
};
类似问题:leetcode.46。同样有递归回溯、动态规划的解法。
leetcode.136 异或运算找落单数字
题目链接:https://leetcode.com/problems...
这题的难点在于想到从比特位的角度来看待int类型。如果一个比特位经过偶数次反转(异或运算),那么它的结果是原始比特;如果经过了奇数次反转(异或运算),那么它的结果是原始比特的反转。
class Solution {
public:
int singleNumber(vector<int>& nums) {
unsigned int bit = 0;
for (auto& num: nums) {
bit ^= num;
}
return (int) bit;
}
};
与比特位相关的问题:leetcode.338
leetcode.210 dfs拓扑排序
题目链接:https://leetcode.com/problems...
bfs的解法比较容易想到,但是dfs的解法相对来说没那么直接。dfs拓扑排序的含义是:你删掉一个节点时,如果发现某个相邻节点的入度变为0,那么下一个应该访问这个节点。
对于拓扑排序的问题使用dfs时,不应该进入环,而是仅仅访问入度为0的节点。
迭代解法:
class Solution {
public:
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> edges(numCourses, vector<int>());
vector<int> in_d(numCourses, 0);
for (auto& p: prerequisites) {
edges[p[1]].push_back(p[0]);
in_d[p[0]]++;
}
stack<int> to_check;
for (int i = numCourses-1; i >= 0; --i) {
if (in_d[i] == 0) to_check.push(i);
}
vector<int> res;
while (!to_check.empty()) {
int top = to_check.top(); to_check.pop();
res.push_back(top);
for (int& next: edges[top]) {
if (--in_d[next] == 0) {
to_check.push(next);
}
}
}
if (res.size() != numCourses) return {};
return res;
}
};
递归dfs:
class Solution {
public:
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> edges(numCourses, vector<int>());
vector<int> in_d(numCourses, 0);
for (auto& p: prerequisites) {
edges[p[1]].push_back(p[0]);
in_d[p[0]]++;
}
vector<int> to_check;
for (int i = 0; i < numCourses; ++i) {
if (in_d[i] == 0) to_check.push_back(i);
}
vector<int> res;
for (int& checking: to_check) dfs(edges, in_d, checking, res);
if (res.size() != numCourses) return {};
return res;
}
void dfs(vector<vector<int>> &edges, vector<int> &in_d, const int &cur, vector<int> &res) {
res.push_back(cur);
for (int& connected: edges[cur]) {
if (--in_d[connected] == 0)
dfs(edges, in_d, connected, res);
}
}
};
leetcode.378 有序矩阵中的第k位
题目链接:https://leetcode.com/problems...
这里的有序矩阵是指每一行、每一列都已经有序的矩阵。
我们可以用O(m+n)来定位某个值在矩阵中的位置,见题目leetcode.240。
寻找第k位的问题一般有以下思路:
- 快排变种,每次partition,可以确定一个元素e在排序后数组中的位置,如果这个位置恰好为k,那么e就是问题的答案。否则,我们对左边或右边的partition继续执行partition。见leetcode.215
- 堆排序,依次取出矩阵中的最小元素,第k个被取出的元素就是题目的答案。
- 桶排序,对于已知元素取值范围的问题可以使用。用空间换时间。见前面的leetcode.347解析。
- 二分查找(迭代法)。这个方法比较难想到,并且使用条件比较苛刻。仅仅当问题性质允许我们不断优化猜测,快速缩小搜索空间的时候可以使用。流程是做出猜测->优化猜测->优化猜测……。下面会详述。
这题的搜索空间是一个矩阵,并且元素取值范围是整个int,所以优先考虑第二种办法:堆排序。
最简单粗暴的办法:直接把矩阵的所有元素加入一个最小堆中,不断取出最小元素,取出的第k个即为答案。
但是这一点也没有利用到“有序”矩阵的特性。观察到这一点:一个元素是最小元素的必要条件是,它左边和上面的元素都已经被取出。我们可以利用必要条件来优化查找空间,仅仅当一个元素达成最小元素的必要条件时,才将它加入堆中。
class cmp {
public:
vector<vector<int>>& matrix;
cmp(vector<vector<int>>& matrix0): matrix(matrix0) {}
bool operator() (pair<int, int>& p1, pair<int, int>& p2) {
return matrix[p1.first][p1.second] > matrix[p2.first][p2.second];
}
};
class Solution {
public:
int kthSmallest(vector<vector<int>>& matrix, int k) {
int n = matrix.size();
vector<vector<int>> has_smaller(n, vector<int>(n, 2));
for (int i = 1; i < n; ++i) {
has_smaller[i][0] = 1;
has_smaller[0][i] = 1;
}
has_smaller[0][0] = 0;
priority_queue<pair<int, int>, vector<pair<int,int>>, cmp> heap(matrix);
heap.push({0,0});
int count = k;
while (!heap.empty()) {
auto top = heap.top(); heap.pop();
if (--count == 0) {
return matrix[top.first][top.second];
}
if (top.first+1 < n) {
if (--has_smaller[top.first+1][top.second] == 0)
heap.push({top.first+1, top.second});
}
if (top.second+1 < n) {
if (--has_smaller[top.first][top.second+1] == 0)
heap.push({top.first, top.second+1});
}
}
return -1;
}
};
二分查找
这题还有另一个更加难想到的办法:二分查找。它的使用条件比较苛刻。仅仅当问题性质允许我们不断优化猜测,快速缩小搜索空间的时候可以使用。
对于有序矩阵,它的最小和最大数字分别是左上角和右下角。问题就转化成在[min, max]
中找到一个数字,使得它恰好比矩阵中的k个元素大。
这个二分查找的过程很像是数学中的迭代法:每次迭代,我们猜测一个数字mid,计算矩阵中有多少个元素小于等于mid(得益于有序矩阵的性质,这个步骤可以在O(n)内完成),然后优化我们的猜测,进入下一轮迭代。当无法继续优化猜测的时候,我们就得到答案了(因为我们知道在[min, max]
范围内肯定有一个数字满足)。
class Solution {
public:
int kthSmallest(vector<vector<int>>& matrix, int k) {
int n = matrix.size();
int l = matrix[0][0], r = matrix[n-1][n-1];
while (l!=r) {
int mid = (l+r)/2;
// 计算矩阵中有多少个元素小于等于mid
int y = 0, x = n-1, cnt = 0;
// 时间复杂度为O(n),因为(y, x)坐标只会往左、往下移动
while (y < n) {
while (x >= 0 && matrix[y][x] > mid) x--;
cnt += x+1;
y++;
}
if (cnt < k) {
l = mid+1;
} else {
r = mid;
}
}
return l;
}
};
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。