基础算法框架
一、树
1.1 树的遍历
下面的遍历位置就是记录当前root的位置,打印位置不同就为不同位置的遍历
void travers(TreeNode root)
{
// 前序遍历需要的操作
travers(root.left);
// 中序遍历需要的操作
travers(root.right);
// 后序遍历需要的操作
}
- 层序遍历
void levelOrder(TreeNode* root) {
if (!root)
return;
queue <TreeNode*> q;
q.push(root);
while (!q.empty())
{
for (int i = 1; i <= q.size(); ++i)
{
auto node = q.front();
q.pop();
// 层序遍历代码位置
if (node->left)
q.push(node->left);
if (node->right)
q.push(node->right);
}
}
return ret;
}
1.2 二叉树
相关算法的思路:明确当前节点要做的事情,剩下往下递归遍历就可以了
- 1.二叉树所有节点加1:
void plusOne(TreeNode* root)
{
if(root == nullptr) return;
root->val += 1;
plusOne(root->left);
plusOne(root->right);
}
- 2.判断两棵二叉树是否完全相同:判断当前节点的数值是不是相同,递归下去子节点
- 3.二叉树最近公共祖先:后续遍历、当前节点等于q或者p返回当前节点否则递归后序遍历、左子树右子树节点返回的节点就是p或者q ,判断p或q是否相等,相等当前节点就是最近公共祖先、都为空返回空、一个为空返回不为空的那个。
1.3 二叉搜索树(BST)Binary Search Tree
重要思想:二叉搜索树的中序遍历就是有序的
- 1.判断BST合法性:判断当前节点是否 >= 左子树最大节点 && 当前节点是否 <= 右子树最大节点,递归下去左右子节点
bool isValidBST(TreeNode* root)
{
return isValidBST(root, nullptr, nullptr);
}
bool isValidBST(TreeNode* root, TreeNode* min, TreeNode* max)
{
if(!root) return false;
if(root->left != nullptr && root->val <= min->val) return false;
if(root->right != nullptr && root->val >= max->val) return false;
return isValidBST(root->left, min, root) && isValidBST(root->left, root, max);
}
- 2.在BST中查找一个数是否存在:当前节点与目标值相同表示存在,递归下去如果大于当前节点递归右子树、如果小于递归左子树
- 4.插入一个数:如果当前节点等于插入代表存在,找到空节点插入,然后递归下去,如果大于插到右子树、小于插到左子树
- 5.删除一个数:没有子节点:直接删除、有一个非空子节点:让子节点替代自己位置、有两个非空子节点:找到左子树最大位置或者右子树最小位置替代当前节点。
TreeNode* getmin(TreeNode* root)
{
while(root->left != nullptr)
root = root->left;
return root;
}
TreeNode* deleteNode(TreeNode* root, int key)
{
if(root == nullptr) return nullptr;
if(root->val == key)
{
if(!root->left) return root->right;
else if(!root->right) return root->left;
else
{
TreeNode* min = getmin(root->right);
root->val = min->val;
root->right = deleteNode(root->right, min->val);
}
}
else if(root->val > key)
root->left = deleteNode(root->left, key);
else if(root->val < key)
root->right = deleteNode(root->right, key);
return root;
}
1.4 完全二叉树、满二叉树
完全二叉树:每层节点都是靠左排列的
满二叉树:每一层都是满的
- 计算二叉树节点数目
- 满二叉树:节点数就是树的高度^2 - 1
- 完全二叉树:判断左右子树高度是否相同,不相同普通遍历计算节点数,相同按照满二叉树方法计算节点数
二、链表
- 链表的递归遍历框架
void traverse(ListNode* head)
{
// 前序遍历代码位置
traverse(head->next);
// 后序遍历代码位置
}
2.1 链表的几个基本操作
- 反转:把链表反转可以递归反转或者是迭代反转
- 双指针:快慢指针、分为一个先走一个后走,或者同时走步数不一样,或者两个指针的其他走法
链表题都是基于上面的两个操作
- 1.返回链表中点:快慢指针,一个走1步,一个走2步同时走,快指针==nullptr的时候慢指针的下一个节点就是终点
- 2.返回链表倒数第k个节点:快慢指针,一个先走k步,然后两个同时走,快指针== nullptr 的时候慢指针就是倒数第k个节点
- 3.判断链表存在环:快慢指针一个走1步,一个走2步同时走,相遇就存在环
- 4.两个链表返回交点:一个走a一个走b,然后任意一个走到结尾,再从对方的链表头开始走,相遇就是交点,不相遇就是没交点
- 5.合并两个有序链表:判断两个指针所指位置大小,然后迭代操作指针
- 6.环形链表入口点:先快慢指针相遇,然后一个从头出发,一个在相遇点出发,同时走,再相遇就是入口点
- 7.(复杂)链表复制:每个链表节点后面插入一个自己的节点,然后拆分链表
2.2 递归翻转链表
ListNode* reverse(ListNode* head)
{
if(head == nullptr || head->next == nullptr)
return head;
ListNode* last = reverse(head->next);
head->next->next = head;
head->next = nullptr;
return last;
}
- K 个一组翻转链表:递归调用
ListNode* reverse(ListNode* a, ListNode* b)
{
ListNode* pre = nullptr;
ListNode* cur = a;
ListNode* next = a;
while(cur != b)
{
next = cur->next;
cur->next = pre;
pre = cur;
cur = next;
}
return pre;
}
ListNode* reverseKGroup(ListNode* head, int k)
{
if(head == nullptr) return nullptr;
ListNode *a = head, *b = head;
for(int i = 0 ; i < k; ++i)
{
if(b == nullptr) return head;
b = b->next;
}
ListNode* newhead = reverse(a, b);
a->next = reverseKGroup(b, k);
return newhead;
}
三、回溯算法
3.1 回溯代码框架
回溯算法的框架就是一个N叉树的前序+后序遍历问题,实际上就是进行一个决策树遍历
整个核心点在于:路径,选择列表,结束条件
- 路径:就是选择一系列的结果
- 选择列表:在一个位置进行下一次选择时可以选择的列表
- 结束条件:选择结束的条件
最重要的就是for循环里面的遍历选择+递归
伪代码框架:
result = []
void backtrack(路径, 选择列表)
{
if(满足结束条件)
result.add(路径);
return;
for(选择:选择列表)
{
做个选择
把选择从选择列表里移除
backtrack(路径, 选择列表)
撤销选择
}
}
3.2 常见回溯问题
主要思路:确定递归终止条件、 如何选择 、 从哪里开始下一个选择
3.2.1 子集
- 思路:跟求递归树上遍历所有节点一样、下一次递归要从下一个数字开始,避免{2,1}和{1,2}重复,因为不需要再选择之前的数字
vector<vector<int>> result;
void backtrack(vector<int>& nums, int start, vector<int>& cur)
{
result.push_back(cur);
for(int i = start; i < nums.size(); ++i)
{
cur.push_back(nums[i]); // 做选择
back_track(nums, i + 1, track);
cur.pop_back(); // 撤销选择
}
}
3.2.2 组合
- 思路:加入答案的条件是当前vector的大小刚好等于k,如果大于返回,如果小于继续递归
vector<vector<int>> result;
void backtrack(int start, int n, int k, vector<int>& cur)
{
if(cur.size() == k)
result.push_back(cur);
else if(cur.size() > k)
return;
for(int i = start; i <= n; ++i)
{
cur.push_back(i); // 做选择
back_track(i + 1, n, k, track);
cur.pop_back(); // 撤销选择
}
}
可以变形成求一个数组中所有组合:
或者求一个数组中总和:组合总和
3.2.1 排列
- 思路:终止条件:当前数组大小跟原来数组大小一样、需要剔除cur中已经存在的数字,不再放入选择列表
vector<vector<int>> result;
void backtrack(vector<int>& cur,vector<int>& nums)
{
if(cur.size() == k)
result.push_back(cur);
for(int i = 0; i < nums.size(); ++i)
{
if(nums[i]已经在cur中)
continue;
cur.push_back(nums[i]); // 做选择
back_track(cur, nums);
cur.pop_back(); // 撤销选择
}
}
3.3 其他回溯问题
3.3.1 八皇后
- 思路: 和全排列很像,不过是循环中的条件要改成检测是否合法、递归的是每一行选择
- 选择条件:当前的选择是否满足之前的选择中列和斜线不冲突,就是当前位置能不能放皇后(另写一个检测函数判断是否合法,不合法continue)
- 选择一行:选完之后进入下一行、递归过后撤销选择
- 终止条件:当行数等于8的时候就是答案之一
3.3.2 解数独
- 思路:数独跟八皇后的区别就是要穷举完每一行,填数字,然后再穷举每一列填数字
- 合法条件:判断之前的行列九宫格是否已经有过该数字
- 注意:如果位置已经填过数字,跳过,进行下一次选择
- 可行解:for循环中的递归一旦找到可行解就不再递归,直接向上函数出栈全部为true
- 找不到可行解:如果全递归结束了以后依然找不到可行解就是没有可行解
3.3.3 括号的生成
- 思路:还是和排列很像,只不过合法值判断改变了、而且每次选择是在两种选择中选一个
合法条件:左括号数量一定小于等于右括号数量,且每种括号数量小于n
终止条件:括号数量 == 2*n的时候代表发现一个合法组合
四、搜索算法
4.1 深度优先搜索(DFS)
深度优先搜索涉及的算法挺广,像回溯算法可以算是深度优先搜索。
4.2 广度优先搜索(BFS)
把一些问题抽象成图,从一个点开始向四周阔山,BFS找到的路径一定是最短的,但是代价是空间复杂度比DFS大很多。
问题的本质就是在一个图中找到start和target之间最近的距离。
把每一轮决策放入队列里面,当前队列就是当前轮需要决策的点,然后决策的时候把相邻的决策加入队列后面,每次把所有相邻决策加完一次代表新的一轮决策也就是step+1;
伪代码:
int bfs(Node start, Node target)
{
queue<Node> q;
set<Node> visited;// 访问过点
// 加入起点
queue.push_back(start);
visited.insert(start);
while(!q.empty())
{
int sz = q.size(); // 上次选择以后的加入本次选择队列的长度
for(int i = 0; i < sz; ++i)
{
//出队本次选择
Node cur = q.front();
q.pop_front();
if(cur == target)
return step;
for(auto a: cur的相邻选择个数)
{
if(a不在visited)
{
a放入队列q里面;
a放入visited里面
}
}
}
step++;// 更新决策(步长/选择)次数
}
}
4.3 二分搜索
二分搜索的框架
int binarySearch(int[] nums, int target)
{
int left = 0, right = nums.length - 1;
while(进行搜索的条件left <= right)
{
int mid = left + (right - left) / 2;(或者右移1位也是除以2)
if(nums[mid] == target)
找到了
else if(nums[mid] < target)
目标点在中间右侧,缩小左边界
left = mid + 1;
else if(nums[mid] > target)
目标点在中间左侧,缩小右边界
right = mid - 1;
}
return ...;
}
重要的是搜索区间也就是left和right代表的区域是哪些:
- 1.如果初始化left = 0, right =size() - 1, 代表左闭右闭区间[left,right],也就是left,right都可以取值,当两个相等的时候**[mid,mid]代表区间还有一个mid数值**,所以当left>right的时候就结束搜索了,反过来讲就是left<=right的时候进行搜索while(left <= right)
- 2.如果初始化left=0,right=.size(),0可以取值,但是.size()不可以取值,也就是right不在区间内,代表左闭右开区间[left,right)那么在下面的搜索中[mid,mid)代表区间没有数值了因为不可能一个数既>=mid 又 <mid。所以当left == right的时候代表不能进行下一步搜索了,反过来讲就是left < right的时候才进行搜索while(left < right) 但是当left == right的时候还没找到就终止结果了,也就是说最后的mid数值我们还没有进行查找,那最后就应该再判断一下mid是否是搜索结果
上面的代码框架是第一种形式,如果要写第二种形式改一下边界就可以了
int binarySearch(int[] nums, int target)
{
int left = 0, right = nums.length;
while(进行搜索的条件left < right)
{
int mid = left + (right - left) / 2;(或者右移1位也是除以2)
if(nums[mid] == target)
找到了
else if(nums[mid] < target)
目标点在中间右侧,缩小左边界
left = mid + 1; // 这里左左闭右开区间所以mid已经搜索过了,收缩下个左边界应该把mid+1
else if(nums[mid] > target)
目标点在中间左侧,缩小右边界
right = mid; // 注意这里不能再把mid-1的原因是right边界不在搜索区间以内的。
}
if(left == target)
return left; // 最后一个left就是结果
else
return -1; //没找到
return ...;
}
4.3.1 寻找最左侧、最右侧第一个符合条件的
寻找最左侧就是找到以后不断缩小右边界,直到不符合条件跳出
if(nums[mid] == target)
找到了
right = mid; // 如果按照[left,right]形式搜索的话right = mid - 1
寻找最右侧就是找到以后不断缩小左边界,直到不符合条件跳出
if(nums[mid] == target)
找到了
left = mid + 1; // 当下一次不符合条件的时候,那本次循环的就是最左侧的,也就是应该返回left - 1
4.3.2 一种写法框架
上面的解释了边界条件和写法的关系,如果同一一种最简单方便的写法就是查找的是[left,right] 区间
- 基本二分
int binarySearch(int[] nums, int target)
{
int left = 0, right = nums.length - 1;
while(进行搜索的条件left <= right)
{
int mid = left + (right - left) / 2;(或者右移1位也是除以2)
if(nums[mid] == target)
找到了返回
else if(nums[mid] < target)
目标点在中间右侧,缩小左边界
left = mid + 1;
else if(nums[mid] > target)
目标点在中间左侧,缩小右边界
right = mid - 1;
}
return ...;
}
- 查找最左侧
int binarySearch(int[] nums, int target)
{
int left = 0, right = nums.length - 1;
while(进行搜索的条件left <= right)
{
int mid = left + (right - left) / 2;(或者右移1位也是除以2)
if(nums[mid] == target)
找到了不返回收缩右边界
right = mid - 1;
else if(nums[mid] < target)
目标点在中间右侧,缩小左边界
left = mid + 1;
else if(nums[mid] > target)
目标点在中间左侧,缩小右边界
right = mid - 1;
}
// 检查左边界越界
if(left >= nums.length || num[left] != target)
return -1;
return ...;
}
- 查找最右侧
int binarySearch(int[] nums, int target)
{
int left = 0, right = nums.length - 1;
while(进行搜索的条件left <= right)
{
int mid = left + (right - left) / 2;(或者右移1位也是除以2)
if(nums[mid] == target)
找到了不返回收缩左边界
left = mid + 1;
else if(nums[mid] < target)
目标点在中间右侧,缩小左边界
left = mid + 1;
else if(nums[mid] > target)
目标点在中间左侧,缩小右边界
right = mid - 1;
}
// 检查右边界越界
if(right < 0 || num[right] != target)
return -1;
return ...;
}
五、动态规划
5.1 动态规划思路
动态规划的问题一边形式是求最值,求动态规划的核心问题是穷举,但是因为穷举可能会有重叠子问题,所以可以记录dp[n],然后进行自下而上推导。从0到n的推导 dp[n] = dp[x] + other条件
整个核心点在于:状态,选择,dp数组的定义
- 状态:是指原问题和子问题的变量,比如凑零钱问题,给定面额,每种面额数量不限,唯一的状态就是拼凑出来的金额,也就是目标金额
- 选择:就是导致状态改变的行为,比如上诉的凑零钱,也就是我下次选择的面额会改变凑出来的金额
- dp数组/函数的定义:自上向下是一个递归函数,也就是从目标值出发往回推导,自下向上可以记录个数组,比如斐波那契数列我们可以保存dp[n-1],dp[n-2]就能得到dp[n]
- 伪代码:
// 定义dp数组 初始化base case
dp[0][0][..] = base case
// 进行状态转移
for(状态1 : 状态1集合)
{
for(状态2:状态2集合)
{
for...
dp[状态1][状态2][..] = 求最值(选择1,选择2...)
}
}
- 数组遍历
- 数组的遍历顺序一定是能够知道计算当前状态之前的所有所需状态
- 遍历的终点一定是存储结果的位置
5.2 最大子数组问题
- 思路:第i个位置之前的最大子数组之和就是dp[i-1] + nums[i],前提是dp[i-1] > 0 ;否则dp[i] = num[i]
- 当dp[i - 1] 小于0 的时候:dp[i] = dp[i - 1] + nums[i]
- 当dp[i - 1] 大于等于0 的时候:dp[i] = nums[i];
那整理成代码就是dp[i] = max(dp[i - 1] + nums[i] , nums[i]);
最后找到dp[i]里面最大的数字就可以了
int max = INT_MIN;
dp[0] = nums[0];
for(int i = 1; i < nums.size(); ++i)
{
dp[i] = std::max(dp[i - 1] + nums[i], nums[i]);
max = std::max(dp[i], max);
}
5.3 子序列问题
两个模板:一维数组、二维数组
- 一维dp数组
- 解决问题:最长递增子序列
- 二维dp数组
- 解决问题:最长公共子序列、编辑距离、最长回文子序列
- 涉及一个字符串:a[i…j]最长子递增/递减序列为dp[i][j]
- 涉及两个字符串:a1[i],a2[j]最长公共子序列为dp[i][j]
5.3.1 最长递增子序列问题
- 基本动态规划思路:
最长递增子序列是最基础的动态规划问题:题目为输入一个无序的整数数组,请找出最长递增子序列的长度。
定义dp数组:dp[i]表示nums[i]这个数位置也就是以nums[0…i]结尾的最长递增子序列的长度。
可以得出dp[i]就是第i个数之前所有比num[i]小的dp[j]值中最大的值+1 :dp[i] = max(dp[j] + 1 , dp[i]);
int max = 0;
for(int i = 0; i < nums.size(); ++i)
{
for(int j = 0; j < i; ++j)
{
if(nums[i] > nums[j])
dp[i] = std::max(dp[i], dp[j] + 1);
}
max = std::max(dp[i], max);
}
- 二分搜索法思路: 比作蜘蛛纸牌游戏:遍历到i的时候,第n张牌比之前的牌堆最底部的都小的话,只能新加一列牌堆,也就是说,要找到n张牌之前牌堆底部第一个比nums[i]大的数字,如果找不到,新加一个牌堆,所以我们要新加一个数组,保存牌堆底部。
1. 拓展信封嵌套问题
实际上就是最长递增子序列问题上升到二维层面。
先对宽度w进行升序,如果宽度相同,则把高度h降序,然后把所有高度h作为一个数组,找到最长递增子序列就可以了。
5.3.2 最长公共子序列问题
非常典型的二维动态规划问题,大部分比较困难的字符串问题都是这个套路(编辑距离)
对于dp[i][j]的定义:对于数组s1[0…(i-1)]和数组s2[0…(j-1)]他们的最长公共子序列是dp[i][j]
步骤:
- 1 初始化base case:让dp[0][…] dp[…]][0]都为0,因为这个两个数组中都代表一个是空字符串,代表没有公共子序列
- 2 状态转移方程:
- dp[i][j] = dp[i - 1][j - 1] + 1 前提s1[i] == s2[j]
- dp[i] [j] = max(dp[i][j-1],dp[i-1][j]) 当s1[i] != s2[j]的时候
vector<vector<int>> dp(s1.size() + 1,vector(s2.size() + 1, 0));
for(int i = 1; i <= s1.size(); ++i)
{
for(int j = 1; j <= s2.size(); ++j)
{
if(s1[i - 1] == s2[j - 1])
dp[i][j] = dp[i-1][j-1]+1;
else
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
1 编辑距离问题
编辑距离:两个字符串,对一个进行增、删、替换操作,最少操作几次能让两个字符串相等
思路:两个指针i,j分别指向字符串结尾,然后向前移动,一共有三种操作:
当s1[i] == s2[j]:不用操作dp[i][j] = dp[i - 1][j - 1]
当 s1[i] != s2[j]:增、删、改 三选一操作代价最小的
- dp数组定义:当s1[0…i] s2[0…j]的最小编辑距离是dp[i][j]
- 插入操作:相当于i不动,j向前移动一位,代表在i之后插入一个字符操作也就是dp[i][j -1]
- 删除操作:相当于j不动,i向前移动一位,代表删除i这个位置的字符dp[i-1][j]
- 替换:相当于i,j同时向前一位,代表i,j位置被替换成一样的字符 dp[i-1][j-1]
- base case:让dp[0][…]都为j dp[…]][0]都为i,代表一个字符为空的时候另外一个字符串最低操作为i
vector<vector<int>> dp(s1.size() + 1,vector(s2.size() + 1, 0));
for(int i = 1;i <= s1.size();++i)
dp[i][0] = i;
for(int i = 1;i <= s2.size();++i)
dp[0][i] = i;
for(int i = 1; i <= s1.size(); ++i)
for(int i = 1; i <= s1.size(); ++i)
{
for(int j = 1; j <= s2.size(); ++j)
{
if(s1[i - 1] == s2[j - 1])
dp[i][j] = dp[i-1][j-1];
else
dp[i][j] = min(min(dp[i-1][j], dp[i][j-1])), dp[i-1][j-1]) + 1;
}
}
5.3.3 最长回文子序列
-
dp数组定义:在字串s[i…j]中最长回文子序列长度为dp[i][j]
-
basecase:当i==j的时候代表只有一个字母,也就是最长回文子序列为1,i>j时没有字符串也就是0
-
转移方程:
-
dp[i][j] = dp[i+1][j-1] + 2 当s[i] == s[j]的时候
-
dp[i][j] = max(dp[i+1][j] , dp[i][j-1]) 当s[i]!=s[j]的时候
-
初始化的basecase,横轴为j,纵轴为i
1 0 1 0 0 1 0 0 0 1 0 0 0 0 1
所以已知dp[i-1][j]和dp[i][j-1]求dp[i][j]的遍历方式应该是反着遍历:自下而上从左到右
for(int i = s.size() - 2; i >= 0; --i)
{
for(int j = i + 1; j < s.size(); ++j)
{
if(s[i]==s[j])
dp[i][j] = dp[i+1][j-1]+2;
else
dp[i][j] = max(dp[i][j-1], dp[i+1][j]);
}
}
return dp[0][s.size()-1];
5.3.4 最小插入次数构造回文串
- dp数组定义:在字串s[i…j]中最最少进行dp[i][j]次插入才能变成回文串
- basecase:当i==j的时候代表只有一个字母,也就是插入0次就可以变成回文,所以dp[i][i] = 0,i>j时没有字符串也就是0
- 转移方程:
- 当s[i] == s[j]的时候:dp[i][j] = dp[i-1][j-1] ;
- 当s[i] == s[j]的时候:dp[i][j]=min(dp[i+1][j], dp[i][j-1]) + 1; 需要插入一次成为回文
所以 已知dp[i-1][j] dp[i][j-1] 求dp[i][j] 的遍历方式应该是反着遍历: 自下而上从左到右
for(int i = s.size() - 2; i >= 0; --i)
{
for(int j = i + 1; j < n; ++j)
{
if(s[i]==s[j])
dp[i][j] = dp[i+1][j-1];
else
dp[i][j] = min(dp[i][j-1], dp[i+1][j]) + 1;
}
}
return dp[0][s.size()-1];
5.4 扔鸡蛋问题
扔鸡蛋问题:k个鸡蛋最少扔几次能确定n层楼
- 思路:可以反过来想,k个鸡蛋扔m次,最多能确认几层楼
- dp数组定义:dp(k,m)为k个鸡蛋最多扔m次最多确定的楼层数,也就是说当dp(k,m) >=n层楼的时候,m为确定n层楼的最少次数
- basecase:0个鸡蛋或者扔0次能确定0层楼dp[…][0] = 0 dp[0][…] = 0
- 那么dp[k][m] = 蛋碎了确定的楼下数目 + 鸡蛋没碎确定的楼上数目 + 1(本层楼)
- 往下递归就是 dp[k][m] = dp[k - 1][m - 1] + dp[k][m - 1] + 1;
int superEggDrop(int k, int n)
{
int c = 0;
std::vector<std::vector<int>> vk(k + 1,vector(n + 1,0));
while(vk[k][c] < n)
{
c++;
for(int i = 1; i <= k; i++)
vk[i][c] = vk[i][c - 1] + vk[i - 1][c - 1] + 1;
}
return c;
}
5.5 戳气球问题
- dp数组定义:dp[i][j]代表区间i~j之间戳的最大值,开区间,也就是说,要算原数组需要在前后加一个虚拟的气球
- dp[i][j] = dp[i][k] + dp[k][j] + nums[i]*nums[k]*nums[j] // 其中k是数组i到j之间最后一个被戳破的气球,最后一个被戳破的也就是左右值为i,j位置的气球
- basecase:dp[i][j]需要保证dp[i][k]和dp[k][j]已经被算出来了,也就需要从下到上,从左到右遍历
- 返回值:就是dp[0][n + 1]
int maxCoins(vector<int>& nums)
{
int n = nums.size();
std::vector<int>point;
point.push_back(1);
point.insert(point.end(),nums.begin(), nums.end());
point.push_back(1);
std::vector<std::vector<int>> dp(n + 2, std::vector(n + 2,0));
for(int i = n ; i >= 0; --i)
{
for(int j = i + 1; j < n + 2; ++j)
{
for(int k = i + 1; k < j; ++k)
{
dp[i][j] = std::max(dp[i][j], dp[i][k] + dp[k][j] + point[i] * point[j] * point[k]);
}
}
}
return dp[0][n + 1];
}
5.6 背包问题
5.6.1 0-1背包问题
给你一个可装载重量为W的背包和N个物品,每个物品有重量和价值两个属性,背包最多能装多少价值的物品
- 思路分析:问题的状态有两个一个是背包的容量、一个是可选择的物品
- 选择就是选择装进背包或者选择不装进背包
- dp数组的定义:dp[i][w]对于前i个物品,当前背包容量为w,这种情况下可以装的最大价值为dp[i][w]
- 求解答案:dp[N][W]
- basecase:dp[0][…] = 0 dp[…][0] = 0(没有物品或者没有空间的时候能装的最大价值为0)
- 当物品装进背包的时候:dp[i][w] = dp[i - 1][w - wt[i - 1]] + val[i - 1];
- 没装进背包的时候:dp[i][w] = dp[i-1][w](继承前一个结果)
- 当背包容量不够只能选择不装进背包,当背包容量够,可以选择装进背包也可以选择不装进背包也就是选择两者价值最大的。
for(int i = 1; i <= N; ++i)
for(int j = 1; j <= W; ++j)
if(w - wt[i - 1] < 0) // 容量不够
dp[i][w] = dp[i - 1][w];
else
dp[i][w] = max(dp[i - 1][w] , dp[i - 1][w - wt[i - 1]]+ val[i - 1]);
分割等和子集问题
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
- 思路:这个题就是找到数组中几个数字的和能否是目标和,这个目标和就是整个数组和的一半。
5.6.2 子集背包问题
给一个数组判断能否选择其中的几个相加结果等于一个数
- dp数组定义:dp[i][j] 表示前i个数目标值为j的时候能否恰好找出
- 选择:选择加到cursum或者不选择加入当前cursum里
- dp[i][j] = dp[i -1][j] - num[i - 1]];
- dp[i][j] = dp[i- 1][j]
- 要求解的就是dp[i][j]恰好等于当前选择装或者选择不装的总价值
for(int i = 0 ; i < n; ++i)
for(int j = sum; j >= 0; --j)
if(j - nums[i] >= 0)
dp[j] = dp[j] || dp[j - nums[i]];
1 子集背包问题变形:目标和
目标和
给你一个整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个 表达式 :
例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-’ ,然后串联起来得到表达式 “+2-1” 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
- 思路1:回溯法
- 每次都有两个选择,然后每次选择+还是选择-
int ncount;
void back(vector<int>& nums, int target, int n)
{
if (n >= nums.size() )
{
if(target == 0)
ncount++;
}
else
{
back(nums, target - nums[n], n + 1);
back(nums, target + nums[n], n + 1);
}
}
- 思路2: 转移问题思路
记数组中所有“+”元素的总和为 Sum(A),所有"-"号的元素的总和为Sum(B),数组中所有元素的总和为Sum,Sum = Sum(A) + Sum(B)
Target= Sum(A) -Sum(B)
Sum(A) + Target = Sum(A) + Sum(A) - Sum(B)
Sum(A) + Sum(B) + Target = 2Sum(A)
Sum + Target = 2Sum(A);
(Sum +Target) / 2= Sum(A);
- 所以问题就可以转换为数组中存在几个子集A可以使A的元素和为Sum(A)
dp数组定义:dp[i][j]只在数组前i个中选择,目标和为j,最多有dp[i][j]中组合
求:dp[nums.size()][Sum(A)]
状态转移方程:dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]] 不选第i个数的目标和为j + 选了第i个数的目标和为j-nums[i - 1]
5.6.3 完全背包问题
就是背包问题的物品不限选择次数,可以一个物品放多个,和零钱问题一样
- dp数组定义:只使用前i个物品当前背包容量为j的时候,有dp[i][j]种方式装满背包(只有前i个面值的硬币,目标钱数为j,有dp[i][j]个方式凑齐)
- basecase:dp[0][…] dp[…][0] = 0。没物品或者没容量的时候0种方法
- dp[i][j] = dp[i-1][j] 当前想凑得钱数小于当前选择的硬币
- dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i - 1]] 当前想凑得钱数大于等于当前选择的硬币
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for(int i = 1; i <= coins.size(); ++i)
{
for(int j = coins[i - 1]; j <= amount; ++j)
{
if(j - coins[i - 1] != INT_MAX)
dp[j] = min(dp[j] , dp[j - coins[i - 1]]);
}
}
dp[amount] == INT_MAX) return -1;
return dp[amount];
六、其他算法
6.1 LRU(Least Recently Used)缓存淘汰算法
LRU 缓存
请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 LRUCache 类:
LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。
函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。
思路:哈希表+双向链表
hash <key, listnode>:哈希表保存key 和链表对应节点
list< node > 、node: key和value 链表节点中有key值,有value值,保存key值是为了找到哈希表对应的位置
- get:获取以后把当前获取的节点放到链表尾部
- insert:如果在链表中找到对应的值,修改对应的结点值,然后放到链表尾部
如果没找到,在链表尾部插入,保存到哈希表中,
如果数据个数大于capacity了,把链表头部的节点 - delete:删除hash表 list中的数据
6.2 双指针
分为两种:一种是快慢指针、一种是左右指针
- 快慢指针:
- 一个指针每次走1步、另一个指针每次走2步
解决的问题:判断链表是否有环、判断链表环的入口位置、链表最中间的一个节点 - 一个指针先走n步,第二个指针才开始走,每步的步长相同
解决的问题:链表倒数第k个节点、
变型:滑动窗口
两个指针同时走向一个方向走,每次做个选择,是走左指针还是走右指针,左右指针中间就是一个滑动窗口
- 左右指针:
- 一个从前向后走,一个从后向前走
解决问题:反转数组、二分搜索框架、
七、排序
7.1 冒泡排序
从前到后遍历n遍,从第j个节点开始,比较左右两个数字,不断交换位置,最外层循环是一共要遍历的次数
void bubblesort(std::vector<int> a)
{
for (int i = 0; i < a.size(); ++i)
{
bool bchange = false;
for (int j = 1; j < a.size(); ++j)
{
if (a[j] < a[j - 1])
{
swap(a[j], a[j - 1]);
bchange = true;
}
}
if (!bchange)
break;
}
}
7.2 插入排序
把第i个数字之前的数视为排序好的数组,然后把第i个数字插入到前i个数字排好的位置中,插入方式是从i开始遍历向前比较,然后左右交换。
void insertsort(std::vector<int> a)
{
for (int i = 0; i < a.size(); ++i)
{
for (int j = i; j > 0; --j)
{
if (a[j] < a[j - 1])
{
swap(a[j], a[j - 1]);
}
}
}
}
7.3 希尔
插入排序的改良,把数组分为几组数组,分组的方式是每组数字间隔为数组长度的一半,然后按组进行插入排序。所有组都排序好了之后重新分组,分组的间隔为上次间隔的一半,然后再进行排序,直到分组内数字间隔为1
void shellsort(std::vector<int>& a)
{
for (int gap = a.size() / 2; gap > 0; gap /= 2)
{
for (int i = gap; i < a.size(); ++i)
{
for (int j = i; j - gap >= 0; j -= gap)
{
if (a[j - gap] > a[j])
swap(a[j], a[j - gap]);
}
}
}
}
7.4 选择
外侧循环遍历数组每个数字,然后内循环从第i个之后选择最小的数字跟i进行交换。
void selectsort(std::vector<int> a)
{
for (int i = 0; i < a.size(); ++i)
{
int min = i;
for (int j = i + 1; j < a.size(); ++j)
{
if (a[j] < a[min])
min = j;
}
swap(a[min], a[i]);
}
}
7.5 快排
每次排序找一个基准数字,比基准大的数字放到基准数字右边,比基准数字小的放到基准数字左边
void quicksort(std::vector<int>& a, int l, int r)
{
if (l + 1 >= r)
return;
int first = l, last = r - 1, key = a[first];
while (first < last)
{
while (first < last && a[last] >= key)
last--;
a[first] = a[last];
while (first < last && a[first] <= key)
first++;
a[last] = a[first];
}
a[first] = key;
quicksort(a, l, first);
quicksort(a, first + 1, r);
}
7.6 归并
需要一个辅助数组双指针把两个数组变成一个数组,二分法划分数组
void merge(std::vector<int>& a, int l, int mid, int r)
{
std::vector<int> left(a.begin() + l, a.begin() + mid + 1);
std::vector<int> right(a.begin() + mid + 1, a.begin() + r + 1);
int ileft = 0, iright = 0;
left.insert(left.end(), numeric_limits<int>::max());
right.insert(right.end(), numeric_limits<int>::max());
for (int i = l; i <= r; ++i)
{
if (left[ileft] < right[iright])
{
a[i] = left[ileft];
ileft++;
}
else
{
a[i] = right[iright];
iright++;
}
}
}
void mergesort(std::vector<int>& a, int front, int end)
{
if (front >= end)
return;
int mid = (front + end) / 2;
mergesort(a, front, mid);
mergesort(a, mid + 1, end);
merge(a, front, mid, end);
}
7.7 堆排序
建立一个堆,堆是个完全二叉树,堆的父节点都比子节点大,一个节点的两个子节点是x*2+1 和 x*2 +2 ,一个节点的父节点是 x/2,从数组n/2开始,每个节点都有自己的父子节点,进行heapify操作,操作完之后,把a[n]跟a[0]进行交换,再重新对a[0]进行heapify
void heapify(std::vector<int>& a, int n, int i)
{
if (i >= n)
return;
int c1 = 2 * i + 1;
int c2 = 2 * i + 2;
int max = i;
if (c1 < n && a[c1] > a[max])
max = c1;
if (c2 < n && a[c2] > a[max])
max = c2;
if (max != i)
{
swap(a[max], a[i]);
heapify(a, n, max);
}
}
void heapsort(std::vector<int>& a)
{
int n = a.size() - 1;
for (int i = n / 2; i >= 0; --i)
{
heapify(a, n, i);
}
for (int i = n ; i >= 0; --i)
{
swap(a[i], a[0]);
heapify(a, i, 0);
}
}
7.8 计数排序
一个数组的所有数值都在一定范围内,统计这个范围,然后创建一个新的数组统计每个元素出现的次数,统计好之后遍历一下统计数组,按照次数再放回原数组。
- 代码步骤
- 找出待排序的数组中最大和最小的元素
- 统计数组中每个值为num的元素出现的次数,存入统计数组下标为num的位置,出现一次该对应位置的数字加1
- 使用计数数组反向填充原数组
void countingsort(std::vector<int>& a)
{
int max = *max_element(a.begin(), a.end());
int min = *min_element(a.begin(), a.end());
std::vector<int> count(max - min + 1, 0);
for (int& n : a)
{
count[n - min]++;
}
int i = 0;
for (int n = min; n <= max; ++n)
{
while (count[n - min] > 0)
{
a[i++] = n;
count[n - min]--;
}
}
}