131. 分割回文串
分析
首先分割的方案可能有指数级别, 假设每一个字符都是相同的aaaaa, n个字母, 那么有n - 1个位置可以分割, 每个位置可以选择分/ 部分, 所以整个分割方案是2^n
发现分割方案是指数级别, 那么直接爆搜
爆搜的时候, 需要加一些优化, 因为爆搜的时候需要一段一段来搜
每次搜的时候, 会搜当前i位置开始的回文串, 比如搜到[i, j], 但是需要判断[i, j]区间是否是回文串,需要快速的判断原字符串的某个子段是否是回文串
如果说直接做, 可以线性扫描. 其实可以预处理下, f[i][j]数组, 表示原字符串的 [i, j]是否是回文串, 那这样判断的时候直接判断f数组就可以了O(1)
f[i][j] 计算是可以递推出来的, 递推的话很快, O(n^2)
递推的时候需要注意算f[i][j]的时候f[i + 1][j - 1]已经被计算出来了
所以应该从小到达枚举j
yxc评价本题
优化是一个比较常用的优化, 通过预处理, 来减少后面操作的时间
code
class Solution {
public:
vector<vector<bool>> f;
vector<vector<string>> ans;
vector<string> path;
vector<vector<string>> partition(string s) {
int n = s.size();
f = vector<vector<bool>> (n, vector<bool>(n));
for (int j = 0; j < n; j ++ )
for (int i = 0; i <= j; i ++ )
if (i == j) f[i][j] = true;
else if (s[i] == s[j]){
if (i + 1 > j - 1 || f[i + 1][j - 1]) f[i][j] = true;
}
dfs(s, 0);
return ans;
}
void dfs(string& s, int u){
if (u == s.size()){
ans.push_back(path);
}else {
for (int i = u; i < s.size(); i ++ )
if (f[u][i]){
path.push_back(s.substr(u, i - u + 1));
dfs(s, i + 1);
path.pop_back();
}
}
}
};
132. 分割回文串 II
分析
跟上一题一样, 不过是返回最少的分割次数 <–>最少分成多少部分
分割的次数 = 分成的部分 - 1
通过上一题的分析, 可以发现分割的方案是指数级别的, 需要进行优化
同样g数组g[i][j]来定义[i,j]是否是回文串
g[i][j] = (s[i] == s[j]) && g[i + 1][j - 1], 先让j从小到大循环, 那么计算j的时候, g[i + 1][j - 1]一定会被算出来, 因为j - 1在j前面
yxc评价
预处理是dp很重要的思想, 如果不进行预处理, 在dp中check回文串, 要O(n), 总的时间复杂度会变成O(n^3)
code
class Solution {
public:
int minCut(string s) {
int n = s.size();
s = ' ' + s;
vector<vector<bool>> g(n + 1, vector<bool>(n + 1));
for (int j = 1; j <= n; j ++ )
for (int i = 1; i <= j; i ++ )
if (i == j) g[i][j] = true;
else if (s[i] == s[j]){
if (i + 1 > j - 1 || g[i + 1][j - 1]) g[i][j] = true;
}
vector<int> f(n + 1, 1e9);
f[0] = 0; // 注意一定要初始化
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= i; j ++ )
if (g[j][i])
f[i] = min(f[i], f[j - 1] + 1); // f[j - 1]
return f[n] - 1;
}
};
133. 克隆图
分析
要将所有的点复制一遍, 再将所有的边复制一遍
根据题意,
- 先将所有的点复制一遍
- 复制所有边 (注意边的双向的)
克隆每一个点, 图的遍历有2种方式, bfs/dfs
遍历图与遍历树只有1点不同
遍历树的话, 没有环, 所以不会有重复的点
遍历图的话, 可能有重复的点, 判重就可以了
还需要定义映射关系, 原点->新点, 因为答案需要返回映射关系中的点
时间复杂度: 复制所有点O(n), 复制所有边O(m), 所以时间复杂度是线性的O(n + m)
图解释:
蓝色表示大哥, 红色表示小弟, 题意就是大哥之间有连线, 那么大哥的小弟与大哥的小弟之间也要有连线
code
/*
// Definition for a Node.
class Node {
public:
int val;
vector<Node*> neighbors;
Node() {
val = 0;
neighbors = vector<Node*>();
}
Node(int _val) {
val = _val;
neighbors = vector<Node*>();
}
Node(int _val, vector<Node*> _neighbors) {
val = _val;
neighbors = _neighbors;
}
};
*/
class Solution {
public:
unordered_map<Node*, Node*> hash;
Node* cloneGraph(Node* node) {
if (!node) return NULL;
dfs(node);
for (auto [s, d] : hash){ // [s, d] 表示hash[s] = d
for (auto ver : s->neighbors)
d->neighbors.push_back(hash[ver]);// push ver对应的小弟
}
return hash[node];
}
void dfs(Node* node){
hash[node] = new Node(node->val);
for (auto ver : node->neighbors) // 搜当前点的邻点
if (hash.count(ver) == 0) // 避免重复, 只有不在hash表中的邻点, 才继续搜
dfs(ver);
}
};
134. 加油站
分析
这题有一个针对这道题本身的做法, 非常的快, 也不需要开O(n)队列
本质上是贪心, 思路是先去枚举, 在对枚举做优化
枚举起点n次, 判断有没有出现负数的情况
比如从第i个起点开始, 首先判断下, 从第i个起点开始, 在第i个油站, 加的油量能否行驶到 i + 1油站, 如果可以的话, 先走到i + 1, 然后再在第i + 1的油站加油, 然后继续判断能否走到第 i + 2个油站, 以此类推, 看看从第i个加油站能否转一圈, 如果都可以满足, 说明它是成立的, 否则说明是不成立的
假如从第i个油站开始, 走到第j个油站失败了, 无法走到第 j + 1个油站了, 即从第j个油站剩余的油量left + g_j < cj, 那么就说明第i个起点是不合法的, 然后再枚举第i + 1个起点
如果直接这样暴力做的话, 起点n个, 每次最坏枚举n次, O(n^2)
然后我们可以发现这个过程可以优化
我们可以发现从第i个油站开始, 最多可以走到第j个站
考虑下[i + 1, j], 如果从[i + 1, j]任选一个起点, 那么是不是也一定无解呢?
随便枚举个点, 比如说k, 那么从第k个点开始走, 有没有可能转一圈呢?
因为从第i个站开始走, 可以走到第k个站, 那么表示从i到k后还有剩余的油量, 即使有剩余的油量, 在第k站加油了, 也无法到达j + 1, 那么从第k个站开始走, 毫无油量, 不可能到j + 1, 更不可能走一圈了
因此我们在枚举的时候, 可以将中间的任何一个起点过滤掉
如果说发现从第i个站, 最多走到第j个站, 那么下一次枚举的时候直接从第j + 1个站开始枚举可以了
每个站最多枚举1次, 时间O(n), 额外空间O(1)
单调队列时间O(n), 空间O(n),但是单调队列可以解决其他很多问题
联动题
单调队列解法O(n) Acwing1088.旅行问题(算法提高课)
通用做法
code
i指标的写法, 先写成for (int i = 0; i < n; i ++ )
然后写到后面, 发现i 再往后跳j + 1的位置开始枚举,
将for (int i = 0; i < n; i ++ )
改成for (int i = 0; i < n;)
, 然后循环里填写i = i + j + 1
同理j指标的写法, 先写for (int j = 0; j < n; j ++ )
然后发现外面要用到j, 修改成for (j = 0; j < n; j ++ )
, 然后j填写到外面
class Solution {
public:
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
int n = gas.size();
for (int i = 0, j; i < n; ){// 枚举起点
int left = 0;
for (j = 0; j < n; j ++ ){ // j表示从i开始走几个站, 0个站, ... , n个站
int k = (i + j) % n;
left += gas[k] - cost[k];
if (left < 0) break;
}
if (j == n) return i;
i = i + j + 1;
}
return -1;
}
};
单调队列思想
环上任何一段, 都可以表示成一圈, 将一段复制一遍放到后面, 就是环了
比方从i开始走, 走长度是n的这一段, 走的过程中, 油量有没有可能是负数,
判断这一段有没有负数, 就是判断这一段最小值有没有负数, 本质上就是求长度是n的区间的最小值是否小于0, 相当于是滑动窗口求最小值的问题
显然可以用单调队列来优化
135. 分发糖果
分析
假设给每个小朋友分的糖果数量为
f
i
f_i
fi
那么求的是ans =
∑
f
i
\sum f_i
∑fi最小
考虑每个
f
i
f_i
fi, 可以考虑从每个格子开始, 每次往两边走, 但是只能往较低的分值走, 那么
f
i
f_i
fi >= 最长的步数
f
i
f_i
fi >= 最长的步数的解释:
图中小朋友最多走3步, 那么就最少需要4颗糖
因此假设可以求得
s
i
:
最
多
走
几
步
s_i:最多走几步
si:最多走几步
那么可以发现
f
i
≥
s
i
f_i \geq s_i
fi≥si
因此ans
≥
∑
s
i
\geq \sum s_i
≥∑si, 另外可以发现把
f
i
=
s
i
f_i = s_i
fi=si, 是满足要求, 相邻两个小朋友, 分值高的分到糖果比分值低的小朋友糖果多
反证法: 如果相邻两个小朋友分值为x, y, x > y, 但是 s x < s y s_x < s_y sx<sy, 那就不对了, 因为 s y s_y sy 表示从y往下走最多走几步, 那么x可以先走到y, 再从y开始走, 因此 s x > s y s_x > s_y sx>sy, 矛盾
因此
f
i
=
s
i
f_i = s_i
fi=si是一个合法的解, 然后我们证明了ans
≥
∑
s
i
\geq \sum s_i
≥∑si, 并且ans 可以取到
∑
s
i
\sum s_i
∑si, 因此
∑
s
i
\sum s_i
∑si就是最小值
因此这一题就是求下
s
i
s_i
si就可以了, 转化为滑雪问题, 可以用记忆化搜索的方式来求
看下
s
i
s_i
si两边, 两种情况去max
这样保证每个数只会算1次, O(n)
联动题
acwing 901.滑雪(该题的二维版本)
code
f[i]表示最小步数
class Solution {
public:
vector<int> f; // 每个小朋友最多可以走的步数
vector<int> w; // 小朋友的分值
int n;
int candy(vector<int >& ratings) {
n = ratings.size();
w = ratings;
f.resize(n, -1); // 初始化为-1, 表示没有计算过
int res = 0;
for (int i = 0; i < n; i ++ ) res += dp(i);
return res;
}
int dp(int x){
if (f[x] != -1) return f[x];
f[x] = 1; // 小朋友至少1颗糖
if (x && w[x] > w[x - 1]) f[x] = max(f[x], dp(x - 1) + 1); // 递归
if (x + 1 < n && w[x + 1] < w[x]) f[x] = max(f[x], dp(x + 1) + 1); // 递归
return f[x];
}
};
136. 只出现一次的数字
分析
考察的是异或运算
- 交换律 a^b = b^a
- 结合律
- x ^ x = 0
因为异或具有交换律, 相同数异或等于0, 因此将所有的数异或一下, 那么就只剩下出现次数为1的数了
扩展版本(思考题)
数组中有两个数出现1次, 其他数都出现2次, 求这两个数是啥
剑指offer 73. 数组中只出现一次的两个数字
code
class Solution {
public:
int singleNumber(vector<int>& nums) {
int res = 0;
for (auto& x : nums) res ^= x;
return res;
}
};
137. 只出现一次的数字 II
分析
DFA(状态自动机)
只需要统计32位的每一位, 搜有数的这一位1出现了多少次, 如果出现了3的倍数, 那么就说明只出现一次的那个数这一位是0; 否则(按照题目数据, 一定是3的倍数+ 1), 那个只出现1次的数的这1位一定是1
可以对每一位用状态机,模3余0的状态,模3余1的状态,模3余2的状态。来一个数如果这一位是0就留在这个状态,否则就0-1-2-0-1-2这样变化。
因为要表示3个状态,所以至少要有两个二进制位,让00表示0,01表示1,10表示2,只要设计一种运算让它满足上面的转换就
这种构造出来的运算是:
o
n
e
=
(
o
n
e
∧
x
)
&
(
¬
t
w
o
)
t
w
o
=
(
t
w
o
∧
x
)
&
(
¬
o
n
e
)
one = (one \wedge x) \& (¬two) \\ two = (two \wedge x) \& (¬one)
one=(one∧x)&(¬two)two=(two∧x)&(¬one)
one和two都用32位的int
,这样每个数的32位都能并行计算。
计算完成后, 寻找只出现1次的数, 就是每位并行计算后出现次数1位的状态, 就是one
该题状态机的含义: 当前这位(个位/ 十位/ 百位…/n位)1的个数%3的状态
每次来一个数, 都可以进行相应的转移
评论
lyt666 7个月前 回复
补充一下:状态01是由two、one共同决定,所以要找余1的位应该找two为0并且one为1的位,但循环结束two为0,所以one就是要找的结果
code
class Solution {
public:
int singleNumber(vector<int>& nums) {
int one = 0, two = 0;
for (auto& x: nums){
one = (one ^ x) & (~two);
two = (two ^ x) & (~one);
}
return one;
}
};
138. 复制带随机指针的链表
分析
可以像133.一样的如下步骤进行复制
- 复制点
- 复制边
yxc:上一题的做法就不讲了, 时间O(n), 空间O(n)
取巧
有一个取巧的方式, 省掉hash表
克隆完一个小弟(红色点), 然后删除原来大哥(蓝色点)之间的边, 将小弟(红色点)加到大哥之间
然后p的小弟p’的random指针的话, 可以根据p(大哥)的random(q)->next找到
新建一个虚拟头节点, 每次将当前点插入到虚拟头节点的后面, 然后修复原来的单链表(即:将大哥指向大哥), 以此类推(大哥指向大哥)
这样就抽出链表中的小弟了
code
/*
// Definition for a Node.
class Node {
public:
int val;
Node* next;
Node* random;
Node(int _val) {
val = _val;
next = NULL;
random = NULL;
}
};
*/
class Solution {
public:
Node* copyRandomList(Node* head) {
for (auto p = head; p; p = p->next->next){ // 复制小弟
auto q = new Node(p->val);
q->next = p->next;
p->next = q;
}
for (auto p = head; p; p = p->next->next){// 复制random
if (p->random)
// 小弟->random = 大哥->random->next
p->next->random = p->random->next;
}
// 分离两个链表
auto dummy = new Node(-1), tail = dummy;
for (auto p = head; p; p = p->next){
auto q = p->next;
tail = tail->next = q;
p->next = q->next; // 恢复原状
}
return dummy->next;
}
};
其中分离链表的操作也可以这样写
tail = tail->next = p->next; // 找到小弟
p->next = p->next->next; // 越过小弟
139. 单词拆分
分析
拆分的方式有很多种, 一个字符串拆分成很多个字母
n个字母, n - 1个空隙, 每个空隙可以选择要不要切分,那就
2
n
−
1
2^{n - 1}
2n−1种情况, 指数级别
直觉上和 131.分割回文串很像,
考虑能不能用dp去做
发现状态表示完全一样
f[i] 表示s[1~i]所有的合法划分方案
以最后一个单词为分割点
最后一个单词如果是1~i的话, 算第1类
2~i的话, 算第2类
…
i~i, 第i类
以第k类为例, [k, i]
可以分成两部分[1, k - 1], [k, i]
整个这类是否存在划分方案, 只需要看前面[1, k - 1]是否存在划分方案, 会发现前面这部分恰好就是f[k - 1]
所以[k, i]是否存在合法方案就只需要看下f[k - 1]就可以了, 但是前提是[k, i]在字典中出现过
时间复杂度:
状态计算是O(n)的, 每个状态在计算的时候, 一共有n次循环, 因为需要计算1~i, 2~i, … ,i~i, 所以n次
O(n^2)
注意
判断某一段是否在字典中出现是否出现过, 大部分人用的是hash表 + substr函数
unordered_set<string> dict(wordDict.begin(), wordDict.end());
dict.find(s.substr(j, i - j))
hash表的增删改查与字符串长度成正比, 这样写的话,判断的话是O(n)的, 这样的话总的时间复杂度是O(n^3)
如果想要做到O(n^2)的话, 需要额外的优化
优化
字符串O(1)的做法: trie树(平均O(1)), 字符串哈希, kmp
s = ’ ’ + s;的用意
因为f数组的边界是f[0], 需要将第0个位置空出来, 因此需要对原字符串做偏移
code
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
typedef unsigned long long ULL;
const int P = 131;
unordered_map<ULL> hash;
for (auto& word : wordDict){
ULL h = 0;
for (auto c : word) h = h * 10 + c;
hash.insert(h);
}
int n = s.size();
vector<bool> f(n + 1);
f[0] = true; // 表示原字符串一个都没有, 因此也是true
s = ' ' + s; // 下标改成1开头
for (int i = 0; i < n; i ++ )
if (f[i]){ // 计算当前状态可以更新哪些后面的状态, 因为字符串哈希计算方式是p * 10 + 后面的字符的值, 算起来比较方便
ULL h = 0;
for (int j = i + 1; j <= n; j ++ ){
h = h * P + s[j];
if (hash.count(h)) f[j] = true;
}
}
return f[n];
}
};
也可以从后往前枚举, 也方便计算hash值
f[i] 表示[i ~ n]是否存在合法的方案
那么[i ~ n]可以划分成[i ~i], [i ~ i + 1] , … [i ~ n]
以当前段[i, j]为例子
f[i, j]合法的话, 在+上f[j + 1]合法, 那么f[i]合法,
最后返回f[0]即可
yxc的代码
140. 单词拆分 II
分析
返回所有的划分方案
爆搜下划分方案
需要加一个优化
为了判断当前分支是否合法, 需要快速的判断后面一部分能否合法划分
所以要预处理f[i]: s[i ~ n]能否划分出来每一部分, 使得每一部分都在单词列表中出现过
预处理dp和上一题一模一样
因为爆搜是指数级别, 因此hash表不用像上一题优化, O(n^3)和指数级别差不了太多
for循环中
i >= 0 可以写成 ~i
i >= 1 可以写成 i
注意1:
因为dfs中要判断[i + 1, n]这段是否合法, 所以处理f的时候, f[i]只能定义成[i ~n]为合法区间, f的定义不能考虑[0 ~ i]区间的合法性
因此遍历的时候, 确定[i , n]的状态, 需要考虑[i, j] + f[j + 1]
注意2:
path
实参 只能为""
, 中间不能有空格, 表示空字符串
code
class Solution {
public:
vector<bool> f;
vector<string> ans;
unordered_set<string> hash;
int n;
vector<string> wordBreak(string s, vector<string>& wordDict) {
n = s.size();
f.resize(n + 1);
for (auto word : wordDict) hash.insert(word);
f[n] = true;
for (int i = n - 1; i >= 0; i -- )
for (int j = i; j < n; j ++ )
if (hash.count(s.substr(i, j - i + 1)) && f[j + 1]) // 注意这里是[i, j] + f[j + 1]
f[i] = true;
dfs(s, 0, "");
return ans;
}
void dfs(string& s, int u, string path){
if (u == n){
path.pop_back(); // 取出path末尾的多余的1个空格
ans.push_back(path);
}else {
for (int i = u; i < n; i ++ )
if (hash.count(s.substr(u, i - u + 1)) && f[i + 1])
dfs(s, i + 1, path + s.substr(u, i - u + 1) + ' ');
}
}
};