按照年初制定的学习计划,今年在在跟进自己感兴趣的方向外,我希望能够花一部分时间在巩固基础知识上,包括C++/Python,数据结构与算法、操作系统等。
其中,数据结构与算法一直是我个人技术栈的一个短板,优先从此开始,我给自己定的学习思路主要是以LeetCode刷题为主线进行查漏补缺。
另外,我在刷LeetCode的同时还刷了一遍B站左神的教程一周刷爆LeetCode,是一套很不错的刷题教程,推荐大家学习。
归并排序先让左侧部分有序,再让右侧部分有序,通过 merge \text{merge} merge操作将左右有序数组再合并到一起, merge \text{merge} merge操作通过两个指针实现,整个过程使用递归加辅助数组实现,时间复杂度为 O ( N l o g ( N ) ) O(Nlog(N)) O(Nlog(N)),空间复杂度为 O ( N ) O(N) O(N)。归并排序的额外空间复杂度可以降为 O ( 1 ) O(1) O(1),但是非常难,原地归并排序会让时间复杂度变成 O ( N 2 ) O(N^2) O(N2)。
void mergeSort(vector<int>& nums, int start, int end) {
if (start >= end) {
return;
}
int mid = (start + end) / 2;
mergeSort(nums, start, mid);
mergeSort(nums, mid + 1, end);
merge(nums, start, mid, end);
}
void merge(vector<int>& nums, int start, int mid, int end) {
vector<int> tmps;
tmps.resize(nums.size(), 0);
int i = start;
int j = mid+1;
int k = start;
while(i <= mid && j <= end) {
if (nums[i] < nums[j]) {
tmps[k] = nums[i];
i++;
} else {
tmps[k] = nums[j];
j++;
}
k++;
}
if(i > mid) {
while(j <= end) {
tmps[k] = nums[j];
j++;
k++;
}
}
if(j > end) {
while(i <= mid) {
tmps[k] = nums[i];
i++;
k++;
}
}
for(int m = start; m <= end; m++) {
nums[m] = tmps[m];
}
}
快速排序从数列中挑出一个元素,称为 pivot \text{pivot} pivot,遍历一遍数据将所有元素比 pivot \text{pivot} pivot小的摆放在基准前面,所有元素比 pivot \text{pivot} pivot大的摆在基准的后面,这个称为 partition \text{partition} partition操作; partition \text{partition} partition操作具体如下:定义一个开头处的小于等于区域和一个结尾处的大于等于区域, i i i 从 0 0 0 向 N N N 遍历,(1)如果遍历到的值小于 num \text{num} num,将 i i i 位置和小于等于区域的下一个数交换,并将 i i i 移动到下一个位置(相等);(2)如果遍历到的值大于 num \text{num} num,则直接将 i i i 移动到下一个位置;(3)如果遍历到的值大于 num \text{num} num,将 i i i 位置和大于等于区域的前一个数交换, i i i 位置不动(未考察)。
递归地把小于基准值元素的子数列和大于基准值元素的子数列排序;快速排序要使用随机位置数进行 partition \text{partition} partition操作快排的时间复杂度才为 O ( N l o g ( N ) ) O(Nlog(N)) O(Nlog(N)),空间复杂度 O ( l o g ( N ) ) O(log(N)) O(log(N))。快速排序是不稳定的,可以做到稳定,但是非常难。
void quickSort(vector<int>& nums, int start, int end) {
if (start >= end) {
return;
}
vector<int> p = partition(nums, start, end);
quickSort(nums, start, p[0]-1);
quickSort(nums, p[1]+1, end);
}
vector<int> partition(vector<int>& nums, int start, int end) {
int t = rand() % (end - start + 1) + start;
swap(nums, t, end);
int pivot = nums[end];
int l = start;
int r = end - 1;
int i = start;
while(i <= r) {
if (nums[i] < pivot) {
swap(nums, l, i);
l++;
i++;
} else if (nums[i] > pivot) {
swap(nums, r, i);
r--;
} else {
i++;
}
}
swap(nums, end, r+1);
return {l, r, i};
}
堆排序的基础逻辑是完全二叉树,节点 i i i的左节点为 2 × i + 1 2\times i+1 2×i+1,右节点为 2 × i + 2 2\times i+2 2×i+2,父节点为 ( i − 1 ) / 2 (i-1)/2 (i−1)/2。实现一个大根堆最基础需要实现两个方法: heapInsert \text{heapInsert} heapInsert和 heapify \text{heapify} heapify,其中 heapInsert \text{heapInsert} heapInsert方法指的是在插入一个新的节点时,需要将新节点不断和自己的父节点进行大小比较,如果小则交换的过程,时间复杂度维 O ( l o g ( N ) ) O(log(N)) O(log(N)); heapify \text{heapify} heapify方法指的是堆弹出最大值(堆顶)后,将最后一个数字放到堆顶,再将堆顶数字不断和左右孩子比较,如果小则交换的过程,时间复杂度 O ( l o g ( N ) ) O(log(N)) O(log(N))。所谓堆排序指的是先把数据从头到尾进行一遍 heapInsert \text{heapInsert} heapInsert,再不断弹出堆顶进行 heapify \text{heapify} heapify。时间复杂度为 O ( N l o g ( N ) ) O(Nlog(N)) O(Nlog(N)),空间复杂度 O ( 1 ) O(1) O(1)。
void heapSort(vector<int>& nums) {
for (int i = 0; i < nums.size(); i++) {
heapInsert(nums, i);
}
int heapSize = nums.size() - 1;
while(heapSize > 0) {
swap(nums, heapSize, 0);
heapSize--;
heapify(nums, 0, heapSize);
}
}
void heapInsert(vector<int>& nums, int index) {
while(index > 0 && nums[(index - 1) / 2] < nums[index]) {
swap(nums, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
void heapify(vector<int>& nums, int index, int heapSize) {
int left = index * 2 + 1;
while(left <= heapSize) {
int tmp = (left + 1 <= heapSize && nums[left] < nums[left+1]) ? left + 1 : left;
tmp = nums[index] < nums[tmp] ? tmp : index;
if (tmp == index) {
return;
} else {
swap(nums, tmp, index);
index = tmp;
left = index * 2 + 1;
}
}
}
有很多题都是以此为基础展开的,例如小和问题、逆序对问题、荷兰国旗问题等
二分查找的基本方法是取 start \text{start} start和 end \text{end} end两个位置, N / 2 N/2 N/2的位置大于目标,则 start = N / 2 + 1 \text{start}=N/2+1 start=N/2+1,反之则 end = N / 2 − 1 \text{end}= N/2 - 1 end=N/2−1,依次类推,直到 start \text{start} start和 end \text{end} end接触,时间复杂度为 O ( l o g ( N ) ) O(log(N)) O(log(N)),使用递归实现。
二分查找同样有许多变题,例如开根号问题、翻转有序数组超等
prim算法:以节点的角度出发,从某一个节点出发,将与其关联的边进行解锁,从解锁的边里选择权值最小的边拓展节点集合,将与拓展节点关联的边进行解锁,再次从解锁的边里选择权值最小的边依次往下。所有解锁的边放入小根堆进行排序,就可以做到每次取到权值最小的边,节点使用集合进行管理可以避免重复取到相同的节点。
const int INF = 1000000000;
int prim(const vector<vector<int>>& graph, int start, vector<bool>& vis, vector<int>& dist) {
int n = graph.size();
fill(vis.begin(), vis.end(), false);
fill(dist.begin(), dist.end(), INF);
dist[start] = 0;
int sum = 0;
for (int i = 0; i < n; i++) {
// choose the next node
int next = -1;
int minDist = INF;
for (int j = 0; j < n; j++) { // this step can be replaced by heap
if (!vis[j] && dist[j] < minDist) {
next = j;
minDist = dist[j];
}
}
// no more next node
if (next == -1) {
break;
}
// visit the next node and update the dist list
vis[next] = true;
sum += minDist; // get the sum of the dist
for (int j = 0; j < n; j++) {
if (!vis[j] && graph[next][j] != INF && graph[next][j] < dist[j]) {
dist[j] = graph[next][j];
}
}
}
return sum;
}
kruskal算法:以边的角度出发,对边的权重进行排序,然后从小到大依次选择不会形成环的边。是否形成环这个判断可以通过并查集实现,将节点作为并查集的对象,如果边对应的两个节点属于同一个集合则会形成环。
struct edge {
int u, v;
int cost;
edge(int x, int y, int c): u(x), v(y), cost(c) {};
}
bool cmp(edge a, edge b) {
return a.cost < b.cost;
}
int findFather(vector<int>& father, int x) {
int a = x;
while (x != father[x]) {
x = father[x]; // find father
}
while (a != father[a]) {
int tmp = a;
a = father[a];
father[tmp] = x; // set father
}
return x;
}
void kruskal(int n, vector<edge>& edges) { // n is size of nodes
int m = edges.size();
vector<int> father(n);
for (int i = 0; i < n; i++) {
father[i] = i;
}
int sum = 0;
int numEdge = 0;
sort(edges.begin(), edges.end(), cmp);
for (int i = 0; i < m; i++) {
int U = findFather(father, edges[i].u);
int V = findFather(father, edges[i].v);
if (U != V) { // avoid circle
father[U] = V; // union
sum += edges.cost;
numEdge++;
if (numEdge == m - 1) {
break;
}
}
}
if (numEdge != m - 1) {
return -1;
} else {
return sum;
}
}
Dijkstra算法:通常应用在权值没有负数的边,使用一张哈希表记录从开始节点到各个节点的距离,每次从这张哈希表中选取距离最小的节点进行拓展,将拓展节点关联的边对表进行更新,拓展后的节点后序不再更新。这个距离表使用哈希表或者堆实现,已经遍历过的节点使用集合实现。
const int INF = 1000000000;
void dijkstra(const vector<vector<int>>& graph, int start, vector<bool>& vis, vector<int>& dist) {
int n = graph.size();
fill(vis.begin(), vis.end(), false);
fill(dist.begin(), dist.end(), INF);
dist[start] = 0;
for (int i = 0; i < n; i++) {
// choose the next node
int next = -1;
int minDist = INF;
for (int j = 0; j < n; j++) { // this step can be replaced by heap
if (!vis[j] && dist[j] < minDist) {
next = j;
minDist = dist[j];
}
}
// no more next node
if (next == -1) {
break;
}
// visit the next node and update the dist list
vis[next] = true;
for (int j = 0; j < n; j++) {
if (!vis[j] && dist[next] + graph[next][j] < dist[j]) {
dist[j] = dist[next] + graph[next][j];
}
}
}
}
dijkstra算法和prim算法是非常类似的,dijkstra考虑的是累计的距离,而prim算法考虑的是局部最近的距离。
KMP算法:首先需要通过 getNextArray \text{getNextArray} getNextArray方法获得 str2 \text{str2} str2的 next \text{next} next数组, next [ i ] \text{next}[i] next[i]的含义是 str2 \text{str2} str2中 0 0 0到 i − 1 i-1 i−1位置中的最长相等的前缀和后缀,其中前缀一定不包括 i − 1 i-1 i−1位置字符,后缀一定不包括 0 0 0位置字符。然后使用 i 1 i1 i1和 i 2 i2 i2分别遍历两个数组,如果 str2 [ i 1 ] = = str2 [ i 2 ] \text{str2}[i1] == \text{str2}[i2] str2[i1]==str2[i2],则分别挪到下一位;否则判断 next \text{next} next数组中 i 2 i2 i2位置是否为 − 1 -1 −1,如果是说明 i 2 i2 i2已经到达 next \text{next} next数组最前端,此时 i 1 i1 i1挪到下一位,如果 next \text{next} next数组中 i 2 i2 i2的位置不为 − 1 -1 −1,则将 i 2 i2 i2置为 next [ i 2 ] \text{next}[i2] next[i2]。KMP算法将时间复杂度 getNextArray \text{getNextArray} getNextArray方法为 O ( M ) O(M) O(M),遍历数组的时间复杂度为 O ( N ) O(N) O(N),总的时间复杂度为 O ( N ) O(N) O(N)。
vector<int> getNextArray(const string& str) {
int n = str.size();
if(n == 1) {
return {-1}
}
vector<int> next(n, 0);
next[0] = -1;
next[1] = 0;
int i = 2;
int k = 0;
// 下面这个过程可以想象成,前缀是一个不断伸长变短的过程,k是匹配好的前缀的下一个字符位置
// 后缀的结尾永远在i-1位置,如果匹配上了则前缀变长,k往后移,反之前缀就根据next将k进行回溯
while(i < n) {
if (str[i - 1] == str[k]) { //
k++;
next[i] = k; // k后移之后同时表示了匹配好的前缀长度
i++;
} else {
if (k == 0) { // k到0位置后说明已经前缀中已经没有匹配好的前缀的
next[i] = 0;
i++;
} else {
k = next[k];
// 这个比较难理解,举个例子 [...A...][...B...]k[...C...][...D...]i-1
// [...A...][...B...]和[...C...][...D...]为匹配好的前缀和后缀
// [...A...][...B...]为前缀中匹配好的前缀和后缀,因此[...A...]和[...D...]也是可以匹配上的
// 如果k和i-1处字符没有匹配上,此时就应该尝试[...A...]k和[...D...]i-1
}
}
}
return next;
}
int kmp(const string& str1, const string& str2) {
vector<int> next = getNextArray(str2);
int i1 = 0;
int i2 = 0;
while(i1 < str1.size() && i2 < str.size()) {
if (str1[i1] == str2[i2]) { // 匹配成功了一起往后移动
i1++;
i2++;
} else {
if (next[i2] == -1) { // 说明i2前面已经灭有前缀了
i1++;
} else {
i2 = next[i2]; // 这个next过程和getNextArray中的next过程原理是一样的
}
}
}
return i2 == str2.size() ? i1 - i2 : -1;
}
Manacher算法:使用 # \# #将原始字符扩展,例如 123 123 123扩展为 # 1 # 2 # 3 # \#1\#2\#3\# #1#2#3#,扩展的目的主要是为,Manacher算法与直接暴力计算的区别是会在遍历过程中记录历史中所有回文串所能覆盖的最远位置 R R R以及该位置对应的回文子串的回文中心 C C C,根据 R R R和 C C C就可以得到该回文子串的左边界 L L L,在接下来的遍历过程中,判断新的位置 i i i是否在 L L L到 R R R覆盖范围内,
(1)如果不在,则暴力计算以 i i i为中心的回文半径,更新 R R R和 C C C。
(2)如果在,则 i i i的回文半径直接 C C C为中心对称位置 j j j位置的回文半径,如果 j j j的回文区域在 L L L到 R R R的范围内或者超过了 L L L到 R R R范围,则 i i i的回文半径和 j j j的回文半径相同,如果 j j j的回文左边界 L L L正好重叠则需要进一步判断 i i i的回文半径是否可以进一步扩大,同时更新 R R R和 C C C。
Manacher算法在具体实现时可以直接求一个不需要检查范围,然后统计往外扩。时间复杂度从 O ( N 2 ) O(N^2) O(N2)降为了 O ( N ) O(N) O(N)
int manancher(string s) {
string str = "#";
for (const auto c : s) {
str += c;
str += '#';
}
int n = str.size();
std::cout<<str<<std::endl;
int R = 0; // 具备回文属性的右边界
int C = 0; // 右边界对应的中心点
int maxLen = 0;
string ret;
vector<int> len(n, 0);
for (int i = 0; i < n; i++) {
if (i < R) {
len[i] = min(len[2 * C - i], R - i);
} else {
len[i] = 1;
}
while(i + len[i] < n && i - len[i] >= 0) {
if (str[i + len[i]] == str[i - len[i]]) {
len[i]++;
} else {
break;
}
}
if (i + len[i] > R) {
R = i + len[i];
C = i;
}
maxLen = max(len[i], maxLen);
}
return maxLen - 1;
}
template<class T>
class Element {
public:
Element(T val): val(val);
private:
T val;
}
template<class T>
class UnionFindSet {
public:
UinionFindSet(const std::vector<T>& list) {
for (const auto& val: list) {
Element<T> element(val);
elementMap[val] = element;
fathreMap[element] = element;
sizeMap[element] = 1;
}
}
// 给定一个element,往上一只找,把代表元素返回
Element<T>* findHead(Element<T> element) {
std::stack<Element<T>> path;
while (element != fatherMap[element]) {
path.push.push(element);
element = fatherMap[element];
}
while (!path.empty()) {
fatherMap[path.pop()] = element; // 这里很巧妙的使用了一个栈将集合中所有元素的代表元素设置为同一个
}
return element;
}
bool isSameSet(T a, T b) {
if (elementMap.count(a) != 0 && elementMap.count(b) != 0) {
return findHead(elementMap[a]) == findHead(element[b]); // 判断两个集合的代表元素是否为同一个
}
return false;
}
void union(T a, T b) {
if (elementMap.count(a) != 0 && elementMap.count(b) != 0) {
Element<T> aFather = findHead(elementMap[a]); // 找到两个集合的代表元素
Element<T> bFather = findHead(elementMap[b]);
if (aFather != bFather) {
Element<T> big = sizeMap[aFather] >= sizeMap[bFather] ? aFather : bFather;
Element<T> small = big == aFather ? bFather : aFather;
fatherMap[small] = big; // 将代表元素置换即将两个集合合并
sizeMap[big] = sizeMap[small] + sizeMap[big];
sizeMap[small] = 0;
}
}
}
private:
std::unordered_map<T, Element<T>> elementMap;
std::unordered_map<Element<T>, Element<T>> fathreMap;
std::unordered_map<Element<T>, int> sizeMap;
}
在C++中,有容器 std::priority_queue \text{std::priority\_queue} std::priority_queue,其提供了 push \text{push} push, pop \text{pop} pop, top \text{top} top等接口,定义如下:
//升序队列,小顶堆
priority_queue <int,vector<int>,greater<int> > q;
//降序队列,大顶堆
priority_queue <int,vector<int>,less<int> >q;
//greater和less是std实现的两个仿函数(就是使一个类的使用看上去像一个函数。其实现就是类中实现一个operator(),这个类就有了类似函数的行为,就是一个仿函数类了)
std::priority_queue \text{std::priority\_queue} std::priority_queue容器的底层实现是在 std::vector \text{std::vector} std::vector上使用了堆算法将vector中元素构造成堆的结构,我们也可以自己使用方法使得 vector \text{vector} vector变成堆结构,
vector<int> a;
make_heap(a.begin(),a.end(), less<int>() );//建立大根堆
make_heap(a.begin(),a.end(), greater<int>() );//建立小根堆
push_heap(a.begin(),a.end());//将最后一个元素插入堆中(堆自动调整)
pop_heap(a.begin(),a.end());//将第一个元素从堆中删去(堆自动调整),并放到最后
堆有些经典的问题有字符串中出现频次前 K K K的字符,哈夫曼编码问题等
花费 costs [ i ] \text{costs}[i] costs[i],利润 profits [ i ] \text{profits}[i] profits[i], k k k个可以完成的项目个数, m m m为初始资金,问串行做项目如何获得最大钱数
基于堆实现,按照花费组织小根堆,按照利润组织大根堆,先将所有项目放到花费小根堆中去,然后根据手头持有的资金逐个把所有能做的项目放到利润大根堆中去,将大根堆中的堆顶不断累计就可以得到最大钱数。
在C++中,有 std::map \text{std::map} std::map和 std::set \text{std::set} std::set的底层实现为有序表, std::map \text{std::map} std::map实现了 std::unordered_map \text{std::unordered\_map} std::unordered_map中相同的接口,map内部所有的数据都是有序的, std::map \text{std::map} std::map的查询、插入、删除操作的时间复杂度都是 O ( l o g n ) O(logn) O(logn)。此外, std::map \text{std::map} std::map的 key \text{key} key需要定义 operator < \text{operator <} operator <,对于一般的数据类型已被系统实现,若是用户自定义的数据类型,则要重新定义该操作符。 std::set \text{std::set} std::set集合访问操作时间复杂度是log(N)的,所涉及的操作并、交、差等,即STL提供的如交集 set_intersection() \text{set\_intersection()} set_intersection()、并集 set_union() \text{set\_union()} set_union()、差集 set_difference() \text{set\_difference()} set_difference()和对称差集 set_symmetric_difference() \text{set\_symmetric\_difference()} set_symmetric_difference(),都需要进行大量的比较工作,此时 std::unordered_set \text{std::unordered\_set} std::unordered_set比 std::set \text{std::set} std::set更有优势。
有序表支持哈希表相关操作且所有的操作时间复杂度为 O ( l o g ( N ) ) O(log(N)) O(log(N)),相关的有序表结构右红黑树、AVL树、Size Balance Tree、Skiplist(跳表)
给定两个数组 money \text{money} money和 hard \text{hard} hard表示工作的报酬和难度,给定另一个表示每个人能力的数组 arr \text{arr} arr,问每个人选择工作后获得的报酬
先按照工作难度由大到小排,同样难度的工作,报酬由大到小排序,然后只保留难度递增报酬递增的工作即构建了有序表。然后每个人查询能满足能力的工作报酬即可。
从左往右的尝试模型,关注 i i i 位置结尾,或者 i i i 位置开头的情况,或者看 i i i 联合 i + 1 i+1 i+1, i + 2 i+2 i+2的情况,填表往往是上到下,或者下到上,左到右,右到左。
最长有效括号
一维动态规划, d p [ i ] dp[i] dp[i]表示以 i i i结尾的最长有效括号,公式如下:
当 s [ i ] = ′ ) ′ s[i]=')' s[i]=′)′且 s [ i − 1 ] = ′ ( ′ s[i-1]='(' s[i−1]=′(′: d p [ i ] = d p [ i − 1 ] + 2 dp[i] = dp[i-1] + 2 dp[i]=dp[i−1]+2当 s [ i ] = ′ ) ′ , s [ i − 1 ] = ′ ) ′ s[i]=')',s[i-1]=')' s[i]=′)′,s[i−1]=′)′且 s [ i − d p [ i − 1 ] − 2 ] = = ′ ( ′ s[i - dp[i-1] -2]=='(' s[i−dp[i−1]−2]==′(′: d p [ i ] = d p [ i − 1 ] + d p [ i − d p [ i − 1 ] − 2 ] + 2 dp[i] = dp[i-1] + dp[i -dp[i-1]-2]+2 dp[i]=dp[i−1]+dp[i−dp[i−1]−2]+2
最长递增子序列
一维动态规划, d p [ i ] dp[i] dp[i]个位置表示以 i i i为结尾的最长递增子序列的长度,每次都去找 0 0 0到 i − 1 i-1 i−1位置上满足条件的最大值,时间复杂度为 O ( N 2 ) O(N^2) O(N2)。如果想优化为 O ( N l o g ( N ) ) O(Nlog(N)) O(Nlog(N)),可以添加一个新的 ends \text{ends} ends数组记录,第 i i i个位置表示长度为 i i i的递增子序列的最小值。 ends \text{ends} ends数组是有序的,找 0 0 0到 i − 1 i-1 i−1位置上满足条件的最大值的步骤可以修改为在 ends \text{ends} ends上二分查找小于当前值的最大值。
没有重复字符的最长子字串
一维动态规划, d p [ i ] dp[i] dp[i]表示以 i i i结尾的最长子串的长度,那么该子串的长度取决于两个边界:一是 i i i位置字符上一个位置,可以使用哈希表记录,二是 i − 1 i-1 i−1位置结尾的最长子串的长度,二者选择较长者。
矩阵中最大正方形
二维动态规划,dp[i][j]表示以 ( i , j ) (i, j) (i,j)结尾的最大正方形: d p [ i ] [ j ] = m i n ( d p [ i − 1 ] [ j − 1 ] , m i n ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) ) dp[i][j] = min(dp[i-1][j -1], min(dp[i-1][j], dp[i][j-1])) dp[i][j]=min(dp[i−1][j−1],min(dp[i−1][j],dp[i][j−1]))
完全平方数:求和为 n n n的最少完全平方数数量
一维动态规划, d p [ i ] dp[i] dp[i]表示和为 i i i的最少完全平方数数量,动态规划模型为: d p [ i ] = 1 + min j = 1 ⌊ ⌋ d p [ i − j 2 ] dp[i]=1+\min _{j=1}^{\lfloor\sqrt{ }\rfloor} dp\left[i-j^2\right] dp[i]=1+j=1min⌊⌋dp[i−j2]
从 L – R L–R L–R范围上的尝试模型,关注 L L L和 R R R的情况,填表格式非常固定,主对角,副对角,倒回来填;
拿纸牌游戏,绝顶聪明的两个玩家从左或右取纸牌,最终分数高的胜利,问给定一个数组谁会赢
先手函数 F \text{F} F为 m a x ( arr [ L ] + S ( arr , L + 1 , R ) , arr [ R ] + S ( arr , L , R − 1 ) ) max(\text{arr}[L] + \text{S}(\text{arr}, L+1, R), \text{arr}[R] + \text{S}(\text{arr}, L, R-1)) max(arr[L]+S(arr,L+1,R),arr[R]+S(arr,L,R−1))基本情况是 L = = R L==R L==R时返回 arr [ L ] \text{arr}[L] arr[L],后手函数 S \text{S} S为 m i n ( F ( arr , L + 1 , R ) , F ( arr , L , R − 1 ) ) min(\text{F}(\text{arr}, L+1, R), \text{F}(\text{arr}, L, R-1)) min(F(arr,L+1,R),F(arr,L,R−1))基本情况是 L = = R L==R L==R时返回 0 0 0,因此此时先手已经拿过了。.如果需要修改为动态规划,需要使用两张二维表,两张二维表来回迭代更新。
给定"假", “真”, “与”、“或”、"异或"五种字符组成的字符串,给定一个布尔值结果,问一共有多少种括号给定方式使得字符串输出给定的结果
递归函数 func(L, R, desire) \text{func(L, R, desire)} func(L, R, desire), L \text{L} L为其左侧的字符串, R \text{R} R为右侧的字符串, desire \text{desire} desire为目标结果,返回值为有多少种可能性,遍历过程仅遍历字符串的运算符,可以修改为二维动态规划。
多样本位置对应的尝试模型, 2 2 2个样本,一个样本做行,一个样本做列,关注i和j对应位置的情况,先填边界,再填中间;
编辑距离问题:给定字符串 str1 \text{str1} str1和 str2 \text{str2} str2,给定插入、删除和替换一个字符的代价,求从 str1 \text{str1} str1到 str2 \text{str2} str2的最小代价
二维动态规划, d p [ i ] [ j ] dp[i][j] dp[i][j]表示前缀长 i i i的字符串编辑成前缀长 j j j的字符串的代价,那么有如下情况可以讨论:
str1 [ i ] = str2 [ j ] \text{str1}[i] = \text{str2}[j] str1[i]=str2[j]: d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] dp[i][j] = dp[i-1][j -1] dp[i][j]=dp[i−1][j−1] str1 [ i ] ! = str2 [ j ] \text{str1}[i] != \text{str2}[j] str1[i]!=str2[j]:
删除 str1 \text{str1} str1第 i i i个字符,再变为 str2 \text{str2} str2的 0 0 0到 j j j的字符串 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + cost(delete) dp[i][j] = dp[i-1][j] + \text{cost(delete)} dp[i][j]=dp[i−1][j]+cost(delete)将 str1 \text{str1} str1第 i i i个字符变为 str2 \text{str2} str2的 0 0 0到 j − 1 j-1 j−1的字符串,再补充第 j j j个字符: d p [ i ] [ j ] = d p [ i ] [ j − 1 ] + cost(add) dp[i][j] = dp[i][j-1] + \text{cost(add)} dp[i][j]=dp[i][j−1]+cost(add)将 str1 \text{str1} str1第 i − 1 i-1 i−1个字符变为 str2 \text{str2} str2的 0 0 0到 j − 1 j-1 j−1的字符串,再替换第 j j j的字符串 d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + cost(replace) dp[i][j] = dp[i-1][j-1] + \text{cost(replace)} dp[i][j]=dp[i−1][j−1]+cost(replace)
业务限制类的尝试模型,比如走棋盘,固定的几个方向可以走,先填边界,再填中间。
跳马问题,从 ( 0 , 0 ) (0, 0) (0,0)以 k k k步跳到 ( a , b ) (a, b) (a,b)位置有多少种跳法
递归的想法比较直接,如果要修改为动态规划需要使用三维表。
关于背包系列和Combination Sum系列之前有写过一个博客数据结构与算法总结——背包问题与组和问题,但是当时感觉更多的是死记硬背,没有彻底理解这个问题,这里来重新整理下:
背包问题
01背包:有 N N N 件物品和一个容量为 V V V 的背包。放入第 i i i 件物品耗费的费用是 C i 1 C_i^1 Ci1,得到的价值是 W i W_i Wi。求解将哪些物品装入背包可使价值总和最大
完全背包:有 N N N 种物品和一个容量为 V V V 的背包,每种物品都有无限件可用。放入第 i i i 种物品的费用是 C i C_i Ci,价值是 W i W_i Wi。求解:将哪些物品装入背包,可使这些物品的耗费的费用总和不超过背包容量,且价值总和最大。
多重背包:有 N N N 种物品和一个容量为 V V V 的背包。第 i i i 种物品最多有 M i M_i Mi 件可用,每件耗费的空间是 C i C_i Ci,价值是 W i W_i Wi。求解将哪些物品装入背包可使这些物品的耗费的空间总和不超过背包容量,且价值总和最大。
背包问题是一个从左往右的尝试模型,通常考虑的方式是假设 0 0 0到 i − 1 i-1 i−1的尝试方式已经确定,判断第 i i i个物品取或者不取后剩下的容量 v v v,
对于01背包: d p [ i , v ] = max { d p [ i − 1 , v ] , d p [ i − 1 , v − C i ] + W i } dp[i, v]=\max \left\{dp[i-1, v], dp\left[i-1, v-C_i\right]+W_i\right\} dp[i,v]=max{dp[i−1,v],dp[i−1,v−Ci]+Wi}对于完全背包: d p [ i , v ] = max { d p [ i − 1 , v − k C i ] + k W i ∣ 0 ≤ k C i ≤ v } dp[i, v]=\max \left\{dp\left[i-1, v-k C_i\right]+k W_i \mid 0 \leq k C_i \leq v\right\} dp[i,v]=max{dp[i−1,v−kCi]+kWi∣0≤kCi≤v}对于多重背包: d p [ i , v ] = max { d p [ i − 1 , v − k ∗ C i ] + k ∗ W i ∣ 0 ≤ k ≤ M i } dp[i, v]=\max \left\{dp\left[i-1, v-k * C_i\right]+k * W_i \mid 0 \leq k \leq M_i\right\} dp[i,v]=max{dp[i−1,v−k∗Ci]+k∗Wi∣0≤k≤Mi}硬币问题可以看做是背包问题的变种:
给定一堆硬币面值,给定一个目标值,问最少使用多少硬币可以组成
递归函数为 func(vector
给定一堆硬币面值(每张有任意张),给定一个目标值,问最少使用多少硬币可以组成
递归函数为 func(vector
int coinChange(vector<int>& coins, int amount) {
int n = coins.size();
dp.resize(amount, -1);
return core(coins, amount);
}
int core(vector<int>& coins, int rest, vector<int>& dp) {
if (rest < 0) {
return -1;
}
if (rest == 0) {
return 0;
}
if (dp[rest - 1] != -1) {
return dp[rest - 1];
}
int min = INT_MAX;
for (int& coin : coins) {
int tmp = core(coins, rest - coin, dp);
if (tmp >=0 && tmp < min) {
min = tmp + 1;
}
}
dp[rest - 1] = (min == INT_MAX) ? -1 : min;
return dp[rest - 1];
}
可以看到,这个递归过程主要还是和用第几个硬币取或者不取后剩下的容量有关,修改为动态规划后如下代码如下:
int coinChange(vector<int>& coins, int amount) {
int Max = amount + 1;
vector<int> dp(amount + 1, Max);
dp[0] = 0;
for (int i = 1; i <= amount; ++i) {
for (int j = 0; j < (int)coins.size(); ++j) {
if (coins[j] <= i) {
dp[i] = min(dp[i], dp[i - coins[j]] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
};
下面这种机器人走法问题也可以看做是背包问题的一种变种:
给定一个区域长度 N N N,机器人每次可以往左或者往右走 1 1 1个长度,走 K K K步从 S S S位置走向 E E E问一共有多少种走法
递归函数为 func(int N, int E, int rest, int cur) \text{func(int N, int E, int rest, int cur)} func(int N, int E, int rest, int cur),其中 N \text{N} N和 E \text{E} E即为固定参数长度和最终位置, rest \text{rest} rest为剩下的步数, cur \text{cur} cur为当前的位置,返回值为方法数。如果 rest = = 0 \text{rest}==0 rest==0时,如果 cur = = E \text{cur} == \text{E} cur==E则返回 1 1 1,否则返回 0 0 0。同时还要考虑 cur = = 1 \text{cur} == 1 cur==1和 cur = = N \text{cur} == \text{N} cur==N的特殊情况。基于此加入一个 K × N \text{K}\times\text{N} K×N的二维表记录递归过程中的值就可以修改为一个二维动态规划。
Combination Sum问题
Combination Sum I:给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。 candidates 中的数字可以无限制重复被选取。
Combination Sum II:给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用一次。
Combination Sum III:找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
Combination Sum IV:给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。
股票问题
股票问题都是求最大利润,但是不同的题型条件不同,如下:
股票 I:给定一个数组,它的第 i i i 个元素是一支给定股票第 i i i 天的价格,你最多只允许完成一笔交易。
int max_profit = 0;
for(int i = 1; i < prices.size(); i++){
dp = max(0, prices[i] - prices[i-1] + dp);
max_profit = max(max_profit, dp);
}
其中 d p [ i ] dp[i] dp[i]代表在第 i i i天卖出时的最大获益。
股票 II:给定一个数组,它的第 i i i 个元素是一支给定股票第 i i i 天的价格,你可以尽可能地完成更多的交易。
dp1[0] = -prices[0];
for(int i = 1; i < n; i++){
dp0[i] = max(dp0[i-1], dp1[i-1] + prices[i]);
dp1[i] = max(dp1[i-1], dp0[i-1] - prices[i]);
}
其中 d p 0 [ i ] dp0[i] dp0[i]: 第 i i i天结束,手头没有股票时的最大利润, d p 1 [ i ] dp1[i] dp1[i]: 第 i i i天结束,手头有股票时的最大利润。
股票 III:给定一个数组,它的第 i i i 个元素是一支给定股票第 i i i 天的价格,你最多可以完成 两笔 交易。
dp1[0][0] = -prices[0]; dp1[0][1] = -prices[0]; // i = 0
for(int i = 1; i < n; i++){
dp1[i][0] = max(dp1[i-1][0], -prices[i]); // j = 0
// j = 1
dp0[i][1] = max(dp0[i-1][1], dp1[i-1][0] + prices[i]); // 保持 or 卖出
dp1[i][1] = max(dp1[i-1][1], dp0[i-1][1] - prices[i]); // 保持 or 买入
// j = 2
dp0[i][2] = max(dp0[i-1][2], dp1[i-1][1] + prices[i]); // 保持 or 卖出
// dp1[i][2] = max(dp0[i-1][2], dp0[i-1][2] - prices[i]); // 保持 or 买入
}
股票 IV:给定一个数组,它的第 i i i 个元素是一支给定股票第 i i i 天的价格,你最多可以完成 k k k 笔交易。
for(int j = 0; j <= k; j++) dp1[0][j] = -prices[0]; // i = 0
for(int i = 1; i < n; i++){
dp1[i][0] = max(dp1[i-1][0], -prices[i]); // j = 0
for(int j = 1; j <= k; j++){ // j > 0
dp0[i][j] = max(dp0[i-1][j], dp1[i-1][j-1] + prices[i]); // 保持 or 卖出
dp1[i][j] = max(dp1[i-1][j], dp0[i-1][j] - prices[i]); // 保持 or 买入
}
}
股票 V:给定一个数组,它的第 i i i 个元素是一支给定股票第 i i i 天的价格,你可以尽可能地完成更多的交易,卖出股票后,你无法在第二天买入股票 ,冷冻期为 1 1 1 天。
dp1[0] = - prices[0]; // i = 0
for(int i = 1; i < n; i++){
dp0[i] = max(dp0[i-1], dp1[i-1] + prices[i]);
dp1[i] = max(dp1[i-1], cool[i-1] - prices[i]);
cool[i] = max(cool[i-1], dp0[i-1]);
}
其中 d p 0 [ i ] dp0[i] dp0[i]: 第 i i i天结束,手头没有股票时的最大利润, d p 1 [ i ] dp1[i] dp1[i]: 第 i i i天结束,手头有股票时的最大利润, c o o l [ i ] cool[i] cool[i]: 第 i i i天结束且第 i i i天处于冷冻期时的最大利润
股票 VI:给定一个数组,其中第 i i i 个元素代表了第 i i i 天的股票价格 ;非负整数 f e e fee fee 代表了交易股票的手续费用,你可以无限次地完成交易,但是你每笔交易都需要付手续费。
dp1[0] = - prices[0]; // i = 0
for(int i = 1; i < n; i++){
dp0[i] = max(dp0[i-1], dp1[i-1] + prices[i] - fee);
dp1[i] = max(dp1[i-1], dp0[i-1] - prices[i]);
}
打家劫舍问题
打家劫舍问题的基本定义是不能强两个连续的房子,但是房子的排布形式不同
打家劫舍 I:房子排列成数组。
非常简单的动态规划问题:
d p [ i ] = max ( d p [ i − 2 ] + n u m s [ i ] , d p [ i − 1 ] ) d p[i]=\max (d p[i-2]+n u m s[i], d p[i-1]) dp[i]=max(dp[i−2]+nums[i],dp[i−1])其中 d p [ i ] dp[i] dp[i]表示第 i i i个房子是否被抢。
打家劫舍 II:房子排列成圆圈。
在打劫1的基础上补充两个条件,即抢第一个房子则不抢最后一个房子和抢最后一个房子则不抢第一个房子。
打家劫舍 III:房子排列成树。
采用树形DP的套路解,动态规划修改为:
路径和问题
路径和 I:判断是否存在根节点到叶节点和为 targetSum \text{targetSum} targetSum的路径
路径和 II:找到所有根节点到叶节点和为 targetSum \text{targetSum} targetSum的路径
路径和 I 和路径和 II 都采用递归的方式求解即可。
路径和 III:找到所有和为 targetSum \text{targetSum} targetSum的路径
路径和 III 同样使用递归的方式求解,但是在递归的过程中需要通过一个哈希表记录所有出现过的前缀和的数量
咖啡杯问题:数组 arr \text{arr} arr表示第 i i i咖啡机跑咖啡的时间, N N N表示有多少个人和咖啡,每个人多会排队等且只喝一杯, a a a表示洗咖啡杯的时间,杯子只能一杯一杯洗, b b b表示自然挥发干净的时间,问开始和到喝完的最短时间
分为两个阶段:
先计算重咖啡的时间,使用小根堆对 ( x , y ) (x,y) (x,y)进行排序,其中 x x x表示启动时间, y y y表示工作时间,按照 x + y x+y x+y进行排序,这一部分是贪心算法,有点类似于会议室安排。使用一个数记录不同的人喝完开始洗咖啡杯的时间。
根据每个人开始洗咖啡杯的时间,使用暴力递归的方法对 a a a和 b b b两种选择尝试,使用 c c c记录到第 i i i个人洗时杯子什么时候空闲,递归函数为 func(vector
判断给定数组是否可以通过调整做到相邻两个数字相乘时 4 4 4的倍数
遍历数组统计三类数的个数:奇数个数 a a a、 2 2 2因子的偶数个数 b b b和 4 4 4因子的偶数个数 c c c
如果 b = = 0 b == 0 b==0,那么“奇-偶-奇-偶-奇”的摆法为临界状态,也就是说如果 a = = 1 a == 1 a==1,则 c > = 1 c >= 1 c>=1,如果 a > 1 a > 1 a>1,则 c > = a − 1 c >= a - 1 c>=a−1;
如果 b ! = 0 b != 0 b!=0,那么将 2 2 2因子的偶数到一起,然后再按照“偶-奇-偶-奇”的摆法为临界状态, a = = 1 a == 1 a==1,则 c > = 1 c >= 1 c>=1,如果 a > 1 a > 1 a>1,则 c > = a c >= a c>=a。
定义 s = " a " ; m = s s="a";m=s s="a";m=s,操作一 m = s ; s = s + s m=s;s=s+s m=s;s=s+s,操作二 s = s + m s=s+m s=s+m,如何使用最少的操作数将 s s s拼接到长度 n n n
如果 n n n为质数,则只使用操作二最少(可以反证如果不只调用操作二,则构建的一定不是质数);如果 n n n不是质数,则假设 n n n是有若干个质数因子组成,每个质数因子都可以调用操作二操作最少,因此最少操作数为所有质数相乘减4。
给定全是小写字母的字符串,删除多余字符,让结果字符串的字典序最小
先设计一张词频表,统计字符串中每个字符出现的次数。然后,从头到尾遍历,每遇到一个字符,该词频减 1 1 1,直到遍历到某个字符的词频减完为 0 0 0时,此时从前面遍历过的所有字符中找出一个字典序最小的,确定下来一个字符,然后将选择位置的左边全部删除,后面所有这个字符也全部删除。再统计词频,继续下一轮。我们对上述操作的理解是在前面选择最小字典序的一个字符,有两种可能:
1、正好是当前词频为0的字符,则此时,其他字符词频不为0,后面还有,因此不会漏掉。
2、不是当前词频为0的字符,但是,词频为0的字符在最后的位置,因此,删除左边不可能漏掉。
给定有 x x x和 . . .构成的数组, . . .的位置可以放灯,每个灯影响相邻的一个位置,问最少放多少灯可以将 . . .的位置都点亮
i i i从头到尾遍历每一个位置,假定每个 i i i位置都不受 0 0 0到 i − 1 i-1 i−1位置如何放灯的影响,如果 i i i位置为 x x x,则 i = i + 1 i=i+1 i=i+1,如果 i i i位置为 . . .,则判断 i + 1 i + 1 i+1位置是否为 x x x,如果是,则 i = i + 2 i=i+2 i=i+2,如果不是,则认为将灯放在 i + 1 i+1 i+1位置是更合理的,那么 i + 2 i+2 i+2位置也会被影响,则 i = i + 3 i=i+3 i=i+3。
洗衣机问题,给定数组表示每个洗衣机上现有衣服数量,每次移动一件,相邻洗衣机才可以移动,问组少多少轮次能够移成平均状态
先判断衣服总数除以洗衣机总数是否可以整除,假定 i i i位置左侧洗衣机需要的衣服数量为 a a a,右侧洗衣机需要的衣服数量为 b b b,分情况讨论:(1) a b ab ab都负,那么至少需要移动 a + b a+b a+b轮;(2) a b ab ab都正,则至少需要移动 max ( a , b ) \text{max}(a,b) max(a,b)轮;(3) a b ab ab一正一负,则至少需要 max ( a , b ) \text{max}(a,b) max(a,b)轮,遍历所有的位置,最大的轮数为结果。
长度为 N N N的数据 arr \text{arr} arr进行切分,求左部分最大值减右部分最大值的绝对值尽量大
记全局最大值为 max \text{max} max,当 max \text{max} max划分进左半部分时,要求右侧最大值最小,那么右侧右一定包含 N − 1 N-1 N−1位置数,所以这种情况下相减最大绝对值为 max − arr [ N − 1 ] \text{max}-\text{arr}[N-1] max−arr[N−1],反之为 max − arr [ 0 ] \text{max}-\text{arr}[0] max−arr[0],因此取两者的最大值即可。
两个集合 A A A和 B B B,从一个集合 A A A中取出一个元素放到另外一个集合 B B B中,操作后两个集合的平均值都大于操作前,问题可以进行多少次这种操作
判断两个集合的平均值分为小于、等于和大于三种情况,将集合中的数分为小于、等于和大于三种类型进行讨论。最后只有 A A A的平均值大于 B B B时,从 A A A中取小于 A A A平均值大于 B B B平均值的数才能使得两个集合的平均值都增大,然后采用贪心的策略从满足要求的最小的开始挪。
给定有 x x x和 . . .构成的数组, . . .的位置可以放灯,每个灯影响相邻的一个位置,问最少放多少灯可以将 . . .的位置都点亮
i i i从头到尾遍历每一个位置,假定每个 i i i位置都不受 0 0 0到 i − 1 i-1 i−1位置如何放灯的影响,如果 i i i位置为 x x x,则 i = i + 1 i=i+1 i=i+1,如果 i i i位置为 . . .,则判断 i + 1 i + 1 i+1位置是否为 x x x,如果是,则 i = i + 2 i=i+2 i=i+2,如果不是,则认为将灯放在 i + 1 i+1 i+1位置是更合理的,那么 i + 2 i+2 i+2位置也会被影响,则 i = i + 3 i=i+3 i=i+3。
使用 6 6 6和 8 8 8两个袋子分 n n n个苹果,问能否分成功,如果能最少能用多少个袋子
奇数直接返回失败,先用8袋子分,分不了了再用6袋子分即可。但是通过分析可以得到,用8袋子分的可能性由多到少排列,每种可能性都需要计算 8 8 8袋子分完剩余的袋子是否可以用 6 6 6袋子,但当 8 8 8袋子分完剩余的苹果数超过 24 24 24就不需要再考虑,直接返回失败,原因是大于 24 24 24的数如果 6 6 6袋子能分,一定可以先用 8 8 8袋子分掉。