每次做题前最好将题目在纸上模拟一遍, 写出文字版代码流程后再进行代码编写
本文算法思想和流程主要来自代码随想录
正在更新中...
数组
704. 二分查找
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1;
while (left <= right){
int middle = left + ((right - left)/2);
if (target < nums[middle]){
right = middle - 1; // 因为两边都是闭环,所以middel可以-1
} else if (nums[middle] < target){
left = middle + 1;
} else{
return middle;
}
}
return -1;
}
};
27. 移除元素
思考过程:
双指针法: 在一个for循环里维护两个指针,
当遍历到不是目标值时两个指针同时向前移动,在这种情况下每次都会将快指针的值复制给慢指针.
当遍历到目标值时, 慢指针停下, 快指针向前移动一格
ps: 数组的指针就是下标
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int slowIdex = 0;
for (int fastIdex=0; fastIdex<nums.size(); fastIdex++) { //快指针一直向前
if (val != nums[fastIdex]) { // 当快指针是目标值时才不会进入
nums[slowIdex] = nums[fastIdex];
slowIdex++; // 每次复制完自加1
}
}
return slowIdex; // 慢指针就是不同的个数, 快慢之差是相同的个数
}
};
977.有序数组的平方
思考过程:
题目给的数组是一个递增数组(包括有数组相等的情况), 那么平方之后最大的数会在两端, 最小的数会在中间, 用双指针同时从两端向中间遍历, 将遍历到的数组放入新数组中(移动哪一个指针取决于哪边的平方元素较大, )
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
int k = nums.size() - 1; // 指向新数组的最后一个位置
vector<int> result(nums.size(), 0); // 创建一个大小为num.size(), 初始化为0的数组
for (int i = 0,j = nums.size() - 1; i <= j; ) { // for循环最后一步可以不要
if (nums[i] * nums[i] < nums[j] * nums[j]) {
result[k--] = nums[j] * nums[j];
//k--;
j--;
}
else { // >= 的情况
result[k--] = nums[i] * nums[i];
//k--;
i++;
}
}
return result;
}
};
209.长度最小的子数组
思考过程:
同样适用双指针法,
快指针在一个for循环里一直向前, 每次向前会将数组元素的值加到sum中,
当sum大于target时, 触发条件判断(当前长度是否小于之前的子数组长度), 如果大于则更新长度, 同时将慢指针向前一位, sum减去慢指针之前的指向的值(直到sum小于target)
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int result = INT32_MAX;
int sum = 0;
int subLength = 0;
for (int j = 0, i = 0; j < nums.size(); j++) {
sum += nums[j];
while (sum >= target) {
subLength = j - i + 1;
result = result < subLength ? result : subLength;
sum -= nums[i++];
}
}
return result == INT32_MAX ? 0 : result; // 如果result没有被更新则返回0
}
};
59.螺旋矩阵II
思考过程:
使用i和j分别控制矩阵位置, 在每一条边用左开右闭的原则, 直到loop(表示一圈)结束
class Solution {
public:
vector<vector<int>> generateMatrix(int n) {
int i = 0, j = 0;
vector<vector<int>> result(n, vector<int>(n, 0)); //定义矩阵
int loop = n / 2; // 这里是整除, 需要考虑奇数
int offset = 1; // 控制每一圈的长度
int count = 1; // 需要填充的数值
int startx = 0, starty = 0; // 记录每次开始时候的位置
while(loop--) {
i = startx;
j = starty;
for (j; j < n - offset; j++) { // 注意for循环定义格式
result[i][j] = count++;
}
for (i; i < n - offset; i++) {
result[i][j] = count++;
}
for (j; j > startx; j--) {
result[i][j] = count++;
}
for (i; i > starty; i--) {
result[i][j] = count++;
}
startx++;
starty++;
offset++; // 每一圈结束时更新
}
if (n % 2) { // 取n的模
result[startx][starty] = count;
}
return result;
}
};
链表
203.移除链表元素
思考过程:
定义一个虚拟的头结点 dummy node, 后面所有的元素都可以按照一个代码逻辑删除, 如果不定义的话需要单独处理头结点
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;
ListNode* cur = dummyHead;
while(cur->next != nullptr) {
if (cur->next->val == val) {
ListNode* tmp = cur->next;
cur->next = cur->next->next; // 指向后面的指针
delete tmp;
} else {
cur = cur->next;
}
}
return dummyHead->next;
}
};
707.设计链表
画图可以解决
这题主要是C++语法
class MyLinkedList {
public:
struct LinkNode {
int val;
LinkNode* next;
LinkNode(int val): val(val), next(nullptr) {} // 构造链表函数, 使用传入的val初始化val, 同时初始化next为nullptr
};
MyLinkedList() { // 链表的初始化函数
_dummyHead = new LinkNode(0); // 创建一个新的虚拟头结点
_size = 0;
}
int get(int index) {
if (index < 0 || index > _size - 1) return -1;
LinkNode* cur = _dummyHead;
index += 1;
while (index--) {
cur = cur->next;
}
return cur->val;
}
void addAtHead(int val) {
LinkNode* newNode = new LinkNode(val);
newNode->next = _dummyHead->next;
_dummyHead->next = newNode;
_size++;
}
void addAtTail(int val) {
LinkNode* newNode = new LinkNode(val);
LinkNode* cur = _dummyHead;
while(cur->next != nullptr) {
cur = cur->next;
}
cur->next = newNode;
newNode->next = nullptr;
_size++;
}
void addAtIndex(int index, int val) {
if (index < 0 || index > _size) return;
LinkNode* newNode = new LinkNode(val);
LinkNode* cur = _dummyHead;
while(index--) {
cur = cur->next;
}
newNode->next = cur->next;
cur->next = newNode;
_size++;
}
void deleteAtIndex(int index) {
if (index < 0 || index > _size - 1) return;
LinkNode* cur = _dummyHead;
while(index--){
cur = cur->next;
}
LinkNode* tmp = cur->next;
cur->next = cur->next->next;
delete tmp;
//delete命令指示释放了tmp指针原本所指的那部分内存,
//被delete后的指针tmp的值(地址)并非就是NULL,而是随机值。也就是被delete后,
//如果不再加上一句tmp=nullptr,tmp会成为乱指的野指针
//如果之后的程序不小心使用了tmp,会指向难以预想的内存空间
tmp=nullptr;
_size--;
}
private: // 必须得先定义这两个, 相当于有一个储存空间, 才能调用MyLinkedList初始化函数
int _size;
LinkNode* _dummyHead;
};
/**
* Your MyLinkedList object will be instantiated and called as such:
* MyLinkedList* obj = new MyLinkedList(); // 创建一个MyLinkedList对象, 是指针类型
* int param_1 = obj->get(index);
* obj->addAtHead(val);
* obj->addAtTail(val);
* obj->addAtIndex(index,val);
* obj->deleteAtIndex(index);
*/
206.反转链表
思考过程:
使用双指针, 定义两个指针cur与pre, 同时移动两个指针并且调换指针方向, 终止条件为cur == nullptr
需要定一个tmp来保存cur->next, 因为不保存的话会找不到接下来移动的位置
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* tmp = nullptr;
ListNode* cur = head;
ListNode* pre = nullptr;
while(cur) {
tmp = cur->next;
cur->next = pre;
pre = cur;
cur = tmp;
}
return pre;
}
};
二叉树
226.翻转二叉树 (优先掌握递归)
只要把每一个节点的左右孩子翻转一下,就可以达到整体翻转的效果, 关键在于使用什么方式遍历整颗树
使用前序遍历和后序遍历都可以(手推是一样的逻辑),唯独中序遍历不方便,因为中序遍历会把某些节点的左右孩子翻转了两次!
使用递归的方式遍历最为简洁
我们来看一下递归三部曲:
1. 确定递归函数的参数和返回值
返回TreeNode* 类型的根节点指针(root), 输入的也是根节点(root)
2. 确定终止条件
当节点为空时返回
3. 确定单层递归的逻辑
因为是前序遍历,所以先进行交换左右孩子节点,然后反转左子树,反转右子树。
class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
if (root == nullptr) return root;
swap(root->left, root->right); // 交换左右子树. 只在“中”节点处处理
invertTree(root->left);
invertTree(root->right);
return root;
}
};
101. 对称二叉树
给你一个二叉树的根节点 root
, 检查它是否轴对称。
思考过程:
1. 题目给的预定义函数 “bool isSymmetric(TreeNode* root)”, 传入的是根节点指针, 需要去判断这个结点的左子树和右子树是否对称.
2. 在判断“第二层子树”是否对称中, 需要分成两个部分:
- 左子树的外侧子树与右子树的外侧子树是否对称 (递归点出现, 与1中的对称判断操作逻辑一致)
- 左子树的内侧子树与右子树的内侧子树是否对称
3. 考虑到递归需要传入两个参数(左右子树), 所以需要重新定义一个函数
递归三部曲
class Solution {
public:
// 返回的是传入的两个子树是否对称,
// 需要先判断子树的内外侧是否对称, 才能得出子树是否对称 (后序遍历)
bool compare(TreeNode* left, TreeNode* right) { // 第一步: 确定返回值和输入
// 第二步: 确定终止条件
if (left == nullptr && right == nullptr) return true; // 传入结点都为空
else if (left != nullptr && right == nullptr) return false; // 传入结点有一边是空而另一边不是
else if (left == nullptr && right != nullptr) return false;
else if (left->val != right ->val) return false; // 传入结点数值不相等
// 第三步: 递归点出现, 需要判断内外侧是否对称
bool outside = compare(left->left, right->right); // 判断两颗树的外侧是否对称
bool inside = compare(left->right, right->left); // 判断两颗树的内侧是否对称
bool issame = outside && inside; // 合并内外侧对称结果
return issame;
}
bool isSymmetric(TreeNode* root) {
if (root == nullptr) return true;
return compare(root->left, root->right);
}
};
104.二叉树的最大深度
思考过程:
高度和深度的区别: 根节点的深度是1, 叶子结点的高度是1
1. 题目给的函数为 “int maxDepth(TreeNode* root)”, 给一个结点求这棵树的最大深度
2. 分别对左右子树求最大深度(递归点出现), 取左右子树的最大深度加一, 作为当前结点的深度返回
class Solution {
public:
int maxDepth(TreeNode* root) {
if (root == nullptr) return 0; // 终止条件
// 递归逻辑
int leftdepth = maxDepth(root->left);
int righdepth = maxDepth(root->right);
int maxdepth = max(leftdepth, righdepth) + 1;
return maxdepth;
}
};
111.二叉树的最小深度
思考过程:
与最大深度差不多, 如果把深度看作是高度(由于终止条件返回值是0), 这题会好求很多
class Solution {
public:
int minDepth(TreeNode* root) {
// 如果是return 0, 那求的应该是当前节点到叶子结点的最小高度(如果是深度的话不好设置终止条件)
if (root == nullptr) return 0; // 终止条件, 现在看到只有return 0 可以用
int leftdepth = minDepth(root->left);
int rightdepth = minDepth(root->right);
// 排除根节点的左右孩子有一个为nullptr的情况, (题目设定要求)
if (root->left == nullptr && root->right != nullptr) {
return rightdepth + 1;
}
else if (root->left != nullptr && root->right == nullptr) {
return leftdepth + 1;
}
int mindepth = min(leftdepth, rightdepth) + 1;
return mindepth;
}
};
110.平衡二叉树
平衡二叉树定义: 树中的任意一个结点的左右子树高度差不超过1.
思考过程:
题目给的预定义函数为 :bool isBalanced(TreeNode* root).
1. 给一个根节点判断这颗树是否为平衡二叉树,
2. 根节点层: 如果任意一颗子树不是平衡二叉树(根据下一层的返回值判断), 则当前结点不是平衡二叉树(返回-1). 如果高度差大于1(需要一个函数求高度)则不是平衡二叉树.
3. 第二层: 与2是一样的逻辑(递归点出现)
4. 发现可以通过求高度的函数的返回值判断, 是否左右子树为平衡二叉树, 这样就不需要用原本的预定义函数“isBalanced” . 当高度差大于1是返回-1
class Solution {
public:
// 求当前结点的高度
int getHight (TreeNode* root) {
if (root == nullptr) return 0; // 终止条件, 返回0表示下面求高度
int lefthight = getHight(root->left); // 求左右子树的高度, 如果有一个为-1则直接返回
if (lefthight == -1) return -1;
int righthight = getHight(root->right);
if (righthight == -1) return -1;
int result;
if (abs(lefthight-righthight) > 1) result = -1; // 高度差大于1
else if (abs(lefthight-righthight) <= 1) { //高度差小于等于1, 当前结点的高度为两子树中较大的那个+1
result = max(lefthight,righthight) + 1;
}
return result;
}
bool isBalanced(TreeNode* root) {
if (root == nullptr) return true; // 空结点的情况
int hight = getHight(root);
if (hight == -1) return false;
else return true;
// return getHeight(root) == -1 ? false : true;
// 更加简洁的写法
}
};
写代码过程中没有特别思考是哪种遍历方式, 最后发现是后序遍历
257. 二叉树的所有路径
根节点到二叉树中的每一个结点有且只有唯一一条路径(意思是路径是唯一的)
思考过程:
1. 以指针指向的结点为当前结点, 同时有一个路径随着指针实时更新, 如果当前结点为叶子结点时, 将当前的路径加入到结果中.
2. 所以需要有一个遍历二叉树的方式, (通过递归遍历比较方便), 接下来就是递归三步曲.
3. 由于题目给的预定义函数与递归第一步确定输入之间大概率不一致, 所以之后写递归都会重新写一个函数(最大深度和最小深度没有重新写函数).
class Solution {
public:
// 直接修改path和result, 这样就定义一个void函数
void traversal(TreeNode* cur, vector<int>& path, vector<string>& result) {
path.push_back(cur->val); // 更新路径到当前指针位置
// 当遇到叶子结点时返回, 没有思考为什么不是空结点(可能更麻烦一点)
if (cur->left == nullptr && cur->right == nullptr) {
string spath;
for (int i = 0; i < path.size() - 1; i++) { // 到倒数第二个为止
spath += to_string(path[i]);
spath += "->";
}
spath += to_string(path[path.size() - 1]);
result.push_back(spath);
return; // void 函数可以return空
}
if (cur->left) { // 这里判断了是否是空结点
traversal(cur->left, path, result);
path.pop_back(); // 回溯, 更新路径到当前指针位置
}
if (cur->right) {
traversal(cur->right, path, result);
path.pop_back();
}
}
vector<string> binaryTreePaths(TreeNode* root) {
vector<int> path;
vector<string> result;
if (root == nullptr) return result;
traversal(root, path, result);
return result;
}
};
404.左叶子之和
思考过程:
1. 当这个结点是叶子结点同时是其父结点的左孩子, 才计数
左右孩子都为空时判断为叶子结点, 在父结点处处理左孩子信息
2. 通过一个函数()分别计算当前结点的左子树和右子树, 再将左右子树的和相加作为本结点的计数返回(递归第一步根题解给的一致, 所以不用另外写函数)
3. 再往下一样的逻辑(递归点出现)
class Solution {
public:
// 计算当前结点的左叶子之和
int sumOfLeftLeaves(TreeNode* root) {
if (root == nullptr) return 0;
if (root->left == nullptr && root->right == nullptr) return 0; // 叶子结点没有左叶子之和
int leftnum = sumOfLeftLeaves(root->left);
if (root->left && !root->left->left && !root->left->right) { // 当左孩子是叶子结点的情况
leftnum += root->left->val;
}
int rightnum = sumOfLeftLeaves(root->right);
int sum = leftnum + rightnum;
return sum;
}
};
222.完全二叉树的节点个数
思考过程:
1. 利用完全二叉树的特性, 如果是满二叉树, 则可以通过公式计算结点数量(2^深度-1)
2. 给定一个结点, 分别判断其当前是否为满二叉树, 如果是直接计算, 如果不是, 则向将其孩子作为新的结点, 再次判断其“当前树”是否为满二叉树(递归点出现)
3. 以当前结点为树的结点个数为: 左右子树的结点树之和+1,
class Solution {
public:
// 计算以当前结点为树的结点个数
int countNodes(TreeNode* root) {
if (root == nullptr) return 0;
TreeNode* left = root->left;
TreeNode* right = root->right;
int leftDepth = 0, rightDepth = 0;
while(left) {
left = left->left;
leftDepth += 1;
}
while(right) {
right = right->right;
rightDepth += 1;
}
if (leftDepth == rightDepth) { // 判断当前树是否是满二叉树
return (2<<leftDepth) - 1;
}
int leftTreeNum = countNodes(root->left); // 左
int rightTreeNum = countNodes(root->right); // 右
int result = leftTreeNum + rightTreeNum + 1; // 中
return result;
}
};
513.找树左下角的值
思考过程:
要点: 保证优先左边搜索,然后记录深度最大的叶子节点的值,此时就是树的最后一行最左边的值。(当第一次碰到最大深度时记录)
在遍历结点时(遍历可用递归)同时维护一个当前结点的深度
class Solution {
public:
int maxDepth = INT_MIN; // 全局变量最大深度, int变量的最小值
//int Depth; // 当前深度
int value;
void traversal(TreeNode* cur, int Depth) {
if (!cur->left && !cur->right) { // 终止条件, 当是叶子结点时
if (Depth > maxDepth) { // 因为是全局变量, 所以只会记录第一次碰到深度大于maxDepth时的值
maxDepth = Depth;
value = cur->val;
}
return;
}
if (cur->left) {
Depth++; // 在下一次递归前更新深度
traversal(cur->left, Depth);
Depth--; // 因为没有返回值, 所以在接下来遍历右孩子前要更新当前结点深度
}
if (cur->right) {
Depth++;
traversal(cur->right, Depth);
Depth--;
}
return;
}
int findBottomLeftValue(TreeNode* root) {
traversal(root, 1);
return value;
}
};
112. 路径总和
给你二叉树的根节点 root
和一个表示目标和的整数 targetSum
。判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum
。如果存在,返回 true
;否则,返回 false
。
思考过程:
1. 需要一种遍历方式来遍历整一颗二叉树, 同时维护在当前指针位置的数值和(如果将相加变为逐层递减, 那么只需要在叶子结点处判断是否为0)
2. 在叶子结点处判断是否为0, 如果为0则刚好存在返回true, 否则返回false
3. 预定义一个函数, 目的是查看是否以当前结点为树的树中, 是否存在这么一个叶子结点
class Solution {
public:
bool traversal(TreeNode* cur, int count) { // 当前指针与当前计数(指的是减去当前结点后的值)
if (!cur->left && !cur->right && count == 0) { //终止条件, 当找到叶子结点
return true;
}
if (!cur->left && !cur->right && count != 0){ // 当前叶子结点不是要找的结点
return false;
}
bool left_result = false, right_result = false; // 如果没有预先定义可能包含任何值(之前内存中的值)
if (cur->left) { // 因为终止条件是叶子结点而不是空结点, 所以需要一个空指针判断
count = count - cur->left->val;
left_result = traversal(cur->left, count);
count = count + cur->left->val;
}
if (cur->right) {
count = count - cur->right->val; // 在遍历下一个孩子结点是更新count
right_result = traversal(cur->right, count);
count = count + cur->right->val;
}
return left_result || right_result;
}
bool hasPathSum(TreeNode* root, int targetSum) {
if (root == nullptr) return false;
return traversal(root, targetSum - root->val);
}
};
106.从中序与后序遍历序列构造二叉树
以 后序数组的最后一个元素为切割点,先切中序数组,根据中序数组,反过来再切后序数组。一层一层切下去,每次后序数组最后一个元素就是节点元素。
来看一下一共分几步:
-
第一步:如果数组大小为零的话,说明是空节点了。
-
第二步:如果不为空,那么取后序数组最后一个元素作为节点元素。
-
第三步:找到后序数组最后一个元素在中序数组的位置,作为切割点
-
第四步:切割中序数组,切成中序左数组和中序右数组 (顺序别搞反了,一定是先切中序数组)
-
第五步:切割后序数组,切成后序左数组和后序右数组(根据中序数组的左子树元素个数)
-
第六步:递归处理左区间和右区间
class Solution {
public:
// 给定中序数组和后序数组, 返回一个结点(以结点为根的二叉树)
TreeNode* traversal(vector<int>& inorder, vector<int>& postorder) {
if (postorder.size() == 0) return nullptr; // 第一步
int rootValue = postorder[postorder.size() - 1]; // 第二步
TreeNode* root = new TreeNode(rootValue); // 创建一个结点, 动态分配内存
if (postorder.size() == 1) return root; // 叶子结点。 ?
// 找到中序遍历分割点
int delimiterIdex;
for (delimiterIdex = 0; delimiterIdex < inorder.size(); delimiterIdex++) {
if (inorder[delimiterIdex] == rootValue) break; // 使用break来记录分割点index
}
// postorder.begin()是一个迭代器, 返回一个类似指针的东西
// 切割中序遍历, 根据分割点, 注意括号前后开闭, 现在是[)
vector<int> leftInorder(inorder.begin(), inorder.begin() + delimiterIdex);
vector<int> rightInorder(inorder.begin() + delimiterIdex + 1, inorder.end());
// 去掉后序遍历的最后一个元素
postorder.resize(postorder.size() - 1);
// 注意是左闭右开
vector<int> leftPostorder(postorder.begin(), postorder.begin()+leftInorder.size());
vector<int> rightPostorder(postorder.begin()+leftInorder.size(), postorder.end());
root->left = traversal(leftInorder, leftPostorder);
root->right = traversal(rightInorder, rightPostorder);
return root;
}
TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) {
if(inorder.size() == 0 || postorder.size() == 0) return nullptr;
return traversal(inorder, postorder);
}
};
654.最大二叉树
思考过程:
1. 给定一个数组, 按照规则返回一个结点, 结点是二叉树的根节点
2. 遍历数组找到最大元素的下标, 构造一个新结点, 以最大元素为分割为左数组和右数组, 接下来一样的流程(递归点出现), 直到叶子结点为止.
class Solution {
public:
TreeNode* construct(vector<int>& nums) {
TreeNode* node = new TreeNode;
if (nums.size() == 1) { // 终止条件, 当数组还剩下一个时一定为叶子结点
node->val = nums[0];
return node;
}
int maxValue = 0;
int maxIndex = 0;
for (int Index=0; Index<nums.size();Index++) { // 找到根节点的值与index
if (nums[Index]>maxValue) {
maxValue = nums[Index];
maxIndex = Index;
}
}
node->val = maxValue;
if(maxIndex>0) { // 确保有一个元素
vector<int> newvec(nums.begin(), nums.begin()+maxIndex);
node->left = construct(newvec); //左闭右开[)
}
if(maxIndex<nums.size()-1) { // 确保有一个元素
vector<int> newvec(nums.begin()+maxIndex+1, nums.end());
node->right = construct(newvec);
}
return node;
}
TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
return construct(nums);
}
};
617.合并二叉树
思考过程:
1. 选择一种遍历方式(递归点出现)同时遍历两颗树, 如果遇到一个空结点, 则将另一颗树当前位置的子树整颗保存
class Solution {
public:
// 给定两颗树的根节点, 将其合并, 返回新树的根节点
TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
// 当两个都是nullptr是也是返回nullptr
if (root1 == nullptr) return root2; //终止条件, 当一个为空结点时直接返回另一颗子树
if (root2 == nullptr) return root1;
TreeNode* node = new TreeNode();
node->val = root1->val + root2->val;
node->left = mergeTrees(root1->left, root2->left);
node->right = mergeTrees(root1->right, root2->right);
return node;
}
};
700.二叉搜索树中的搜索
给定二叉搜索树(BST)的根节点和一个值。 你需要在BST中找到节点值等于给定值的节点。 返回以该节点为根的子树。 如果节点不存在,则返回 NULL。
思考过程:
函数的返回值为node, 输入为根节点和value
查看当前结点是否等于value, 如果相等直接返回结点, 如果不相等则根据大小向左或者右遍历, 接下来一样的过程(递归点出现)
class Solution {
public:
TreeNode* searchBST(TreeNode* root, int val) {
if (root == nullptr) return nullptr; //终止条件
if (root->val == val) return root;
TreeNode* result;
if (val<root->val) {
result = searchBST(root->left, val);
}
else {
result = searchBST(root->right, val);
}
return result;
}
};
98.验证二叉搜索树
思考过程:
通过中序遍历(递归点出现)的方式以此遍历二叉树, 同时记录前一个结点的值(或者直接记录指针), 这里需要用一个全局变量
class Solution {
public:
TreeNode* pre_node = nullptr;
bool isValidBST(TreeNode* root) {
if (root == nullptr) return true; // 终止条件, 空结点返回true, 遇到空结点说明前面都正确, 也返回true
bool left = isValidBST(root->left);
if (pre_node!=nullptr && pre_node->val >= root->val) { // 只能先写return false, 反过来很麻烦
return false;
}
else {
pre_node = root;
}
bool right = isValidBST(root->right);
return left && right;
}
};
530.二叉搜索树的最小绝对差
由于二叉搜索树的特性, 跟上题一样, 使用两个指针遍历, 保存全局最小值
class Solution {
public:
int result = INT_MAX;
TreeNode* pre_node = nullptr;
void traversal(TreeNode* root) {
if (root == nullptr) return;
traversal(root->left);
if (pre_node != nullptr) {
result = min(result, abs(pre_node->val - root->val));
}
pre_node = root; // 处理中逻辑的时候更新
traversal(root->right);
}
int getMinimumDifference(TreeNode* root) {
traversal(root);
return result;
}
};
501.二叉搜索树中的众数
思考过程:
与上一题逻辑一致,
维护全局变量maxCount, count, pre_node, result, 在处理中结点时, 当计数大于最大计数时将result清空, 同时加入新的元素
class Solution {
private:
int maxCount = 0, count = 0;
TreeNode* pre_node = nullptr;
vector<int> result;
void traversal(TreeNode* root) {
if (root == nullptr) return;
traversal(root->left);
// 更新当前结点的count
if (pre_node == nullptr) { //第一个结点
count = 1;
} else if (pre_node->val == root->val) { // 当前结点与前一个结点值相同
count += 1;
} else { // 值不同
count = 1;
}
if (maxCount == count) { // 与最大count相等则保存当前值
result.push_back(root->val);
} else if (count > maxCount) { // 大于最大count则更新
maxCount = count;
result.clear();
result.push_back(root->val);
}
pre_node = root;
traversal(root->right);
}
public:
vector<int> findMode(TreeNode* root) {
traversal(root);
return result;
}
};
236. 二叉树的最近公共祖先
思考过程:
1. 需要返回的是一个结点, 函数目的是返回最小公共祖先(但处理逻辑有区别, 在没遇到最小公共祖先之前返回的是目标值, 遇到之后返回最小公共祖先)
2. 当遍历到目标结点时将目标结点(当前结点为当前祖先)返回, 否则返回nullptr (“中”的处理逻辑)
3. 当有一遍不为空, 则返回不为空的值(单个的原结点 或 两个的最小祖先)
4. 如果两个都为空返回空
5. 如果两边都不为空返回当前结点(最小公共祖先找到)
6. 适合后序遍历(递归点出现)
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (root == nullptr) return nullptr;
if (root == p || root == q) return root; // 终止条件
TreeNode* left = lowestCommonAncestor(root->left, p, q);
TreeNode* right = lowestCommonAncestor(root->right, p, q);
if (left && !right) {
return left;
}
if (right && !left) {
return right;
}
if (!left && !right) return nullptr;
else return root;
}
};
235. 二叉搜索树的最近公共祖先
思考过程:
因为是有序树,所以 如果 中间节点是 q 和 p 的公共祖先,那么 中节点的数组 一定是在 [p, q]区间的。即 中节点 > p && 中节点 < q 或者 中节点 > q && 中节点 < p。
那么只要从上到下去遍历,遇到 cur节点是数值在[p, q]区间中则一定可以说明该节点cur就是p 和 q的公共祖先。
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (root == nullptr) return nullptr;
if (p->val<root->val && q->val<root->val) {
TreeNode* left = lowestCommonAncestor(root->left, p, q); // 返回最近公共祖先
return left;
}
if (p->val>root->val && q->val>root->val) {
TreeNode* right = lowestCommonAncestor(root->right, p, q);
return right;
}
return root; // 在中间的情况找到最小公共祖先
}
};
701.二叉搜索树中的插入操作
思考过程:
其实可以不考虑题目中提示所说的改变树的结构的插入方式。
只要按照二叉搜索树的规则去遍历,遇到空节点就插入节点就可以了。
class Solution {
public:
TreeNode* insertIntoBST(TreeNode* root, int val) {
if (root == nullptr) {
TreeNode* node = new TreeNode(val); // 遍历到空结点, 说明已经到插入位置
return node;
}
if (val < root->val) {
root->left = insertIntoBST(root->left, val);
}
else if (val > root->val) {
root->right = insertIntoBST(root->right, val);
}
return root;
}
};
450.删除二叉搜索树中的节点
思考过程:
有以下五种情况:
- 第一种情况:没找到删除的节点,遍历到空节点直接返回了
- 找到删除的节点
- 第二种情况:左右孩子都为空(叶子节点),直接删除节点, 返回NULL为根节点
- 第三种情况:删除节点的左孩子为空,右孩子不为空,删除节点,右孩子补位,返回右孩子为根节点
- 第四种情况:删除节点的右孩子为空,左孩子不为空,删除节点,左孩子补位,返回左孩子为根节点
- 第五种情况:左右孩子节点都不为空,则将删除节点的左子树头结点(左孩子)放到删除节点的右子树的最左面节点的左孩子上,返回删除节点右孩子为新的根节点。
class Solution {
public:
TreeNode* deleteNode(TreeNode* root, int key) {
if (root == nullptr) return nullptr;
if (root->val == key){
if (!root->left && !root->right) { // 第二种情况, 是叶子结点
delete root;
return nullptr;
}
else if (!root->left && root->right) { // 第三种情况, 左孩子为空
auto retNode = root->right; // 自动判断赋值是什么类型
delete root;
return retNode;
}
else if (root->left && !root->right) { // 第四种情况, 🈶孩子为空
auto retNode = root->left;
delete root;
return retNode;
}
else {
TreeNode* cur = root->right; // 第五种情况, 找到删除结点右子树中最左边的值
while(cur->left) { // 只要左子树存在就往左边找
cur = cur->left;
}
cur->left = root->left;
auto retNode = root->right;
delete root;
return retNode;
}
}
if (root->val > key) root->left = deleteNode(root->left, key);
if (root->val < key) root->right = deleteNode(root->right, key);
return root;
}
};
669. 修剪二叉搜索树
思考过程:
函数传入根节点、左右区间(左闭右闭), 返回修建后的根节点
1. 如果当前节点小于左区间, 那么删除当前结点以及左子树, 同时向右子树递归, 返回右子树删除后的子节点
2. 如果当前节点大于右区间, 那么删除当前结点以及右子树, 同时向左递归, 返回左子树删除后结点
class Solution {
public:
TreeNode* trimBST(TreeNode* root, int low, int high) {
if (root == nullptr) return nullptr;
if (root->val < low) {
TreeNode* right = trimBST(root->right, low, high);
return right;
}
else if (root->val > high) {
TreeNode* left = trimBST(root->left, low, high);
return left;
}
root->left = trimBST(root->left, low, high); // 将下一层的返回值接住
root->right = trimBST(root->right, low, high);
return root;
}
};
108.将有序数组转换为二叉搜索树
思考过程:
函数输入为数组、左右区间(左闭右闭)(自定义), 输出为根节点
数组使用&符号来直接操作原数组, 区间控制接下来递归的数组长度
每次取中间结点为当前结点的值, 如果是偶数就取中间两个值较左的值
class Solution {
private:
TreeNode* traversal(vector<int>& nums, int left, int right) {
if (left > right) return nullptr; // 当左下标大于右下标时返回空
int mid = (left + right) / 2;
TreeNode* node = new TreeNode(nums[mid]);
node->left = traversal(nums, left, mid - 1);
node->right = traversal(nums, mid + 1, right);
return node;
}
public:
TreeNode* sortedArrayToBST(vector<int>& nums) {
return traversal(nums, 0, nums.size() - 1); // 数组下标从0开始, 这里不是创建数组
}
};
538.把二叉搜索树转换为累加树
思考过程:
通过 右中左 的顺序遍历二叉树, 在 中 的时候将前面的数值与当前数值相加,
需要一个全局变量记录前一个结点的数值
class Solution {
public:
int pre = 0;
void traversal(TreeNode* root) {
if (root == nullptr) return;
traversal(root->right);
root->val += pre;
pre = root->val;
traversal(root->left);
}
TreeNode* convertBST(TreeNode* root) {
traversal(root);
return root;
}
};
回溯算法
第77题. 组合
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
你可以按 任何顺序 返回答案。
思考过程:
将题目抽象成树形结构, 在树的结点处用for循环来遍历当前结点的所有取值可能性, 在深度上使用递归的逻辑
在形成树形结构后, 以叶子结点为终止条件, 根节点到叶子结点上的路径就是当前组合, 维持一个与当前指针同样位置的全局变量的path(回溯).
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(int n, int k, int startIdx) { // 表示当前开始的数值
if (path.size() == k) { // path的长度与想要的一致就是叶子结点
result.push_back(path);
return;
}
for (int i = startIdx; i <= n; i++) {
path.push_back(i); // 在进入下一次递归前更新
backtracking(n, k, i + 1);
path.pop_back(); // 当前子树递归完毕, 将path更新与当前位置对应
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
216.组合总和III
思考过程:
与77题 组合 是一样的逻辑, 只不过需要在终止条件时判断是否需要将当前路径加入到结果中
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(int k, int targetSum, int curSum, int startIdx) {
if (path.size() == k) {
if (curSum == targetSum) result.push_back(path);
return;
}
for (int i = startIdx; i <= 9; i++) {
path.push_back(i);
curSum += i;
backtracking(k, targetSum, curSum, i + 1);
path.pop_back();
curSum -= i;
}
}
public:
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 0, 1);
return result;
}
};
17.电话号码的字母组合
思考过程:
将问题抽象成一颗树, 每一层用for循环遍历数组对应的字母集, 用递归来控制树的深度, 使用index来控制每一层for循环使用的是哪一个字母集.
class Solution {
private:
const string letterMap[10] {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
vector<string> result;
string path;
void backtracking(const string& digits, int index) {
if (index == digits.size()) { // index等于输入字符串长度时到叶子结点, 从0开始
result.push_back(path);
return;
}
int digit = digits[index] - '0'; // 利用ACSII码值相减可以变成数字
string letters = letterMap[digit];
for (int i = 0; i < letters.size(); i++) {
path.push_back(letters[i]);
backtracking(digits, index + 1); // 注意这里更新输入数字串的index
path.pop_back();
}
return;
}
public:
vector<string> letterCombinations(string digits) {
if (digits.size() == 0) {
return result;
}
backtracking(digits, 0);
return result;
}
};
39. 组合总和
给你一个 无重复元素 的整数数组 candidates
和一个目标整数 target
,找出 candidates
中可以使数字和为目标数 target
的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates
中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
思考过程:
与 77. 组合 一样的逻辑, 区别在与这题可以重复选取, 体现在树形结构为加入path之后还是可以选择当前元素作为path, 维护一个与path同时的curSum, 当大于等于的时候作为终止条件(控制树的深度).
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates, int target, int curSum, int startIdx) {
if (curSum > target) return;
if (curSum == target) {
result.push_back(path);
return;
}
for (int i = startIdx; i < candidates.size(); i ++) {
path.push_back(candidates[i]);
curSum += candidates[i];
backtracking(candidates, target, curSum, i); // 与前几题区别在与startIdx
path.pop_back();
curSum -= candidates[i];
}
return;
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtracking(candidates, target, 0, 0);
return result;
}
};
40.组合总和II
思考过程:
先对数组进行一个排序的操作, 以递增的方式排序: sort函数. (为什么?)
1. 对第一个数进行详尽的搜索, 终止条件为curSum大于等于target, 等于时将路径加入,
2. 如果第二个数与第一个数相等, 这时再对第二个数进行详尽的搜索, 这部分搜索过程已经在第一个数的子树被搜索, 所以这颗子树是重复的, 需要被剪枝!
3. 通过一个used数组来记录当前数值是否在 树层/树枝上使用过, 如果为true则表示在上几层被使用过(树枝上被使用, 已经被判断过是否加入path). 如果是false则表示还没有被使用, 是当前层for循环需要判断使用的.
4. 如果当前数值与前一个数值相同, 且前一个数值是当前层for循环需要使用的(used数组值为false), 则直接continue跳过该子树, 跳到for循环到下一个数值上
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates, int target, int curSum, int startIdx, vector<bool>& used) {
if (curSum > target) return;
if (curSum == target) {
result.push_back(path);
return;
}
// 剪枝操作
for (int i = startIdx; i < candidates.size() && curSum + candidates[i] <= target; i++) {
// used[i - 1] == true,说明同一树枝(上面几层的路径上)candidates[i - 1]使用过
// used[i - 1] == false,说明同一树层candidates[i - 1]使用过, 表示在同一层
// 要对同一树层使用过的元素进行跳过
if (i > 0 && candidates[i] == candidates[i-1] && used[i-1] == false) {
continue;
}
curSum += candidates[i];
path.push_back(candidates[i]);
used[i] = true;
backtracking(candidates, target, curSum, i + 1, used);
curSum -= candidates[i];
path.pop_back();
used[i] = false;
}
}
public:
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
vector<bool> used(candidates.size(), false);
// 首先把给candidates排序,让其相同的元素都挨在一起。
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0, used);
return result;
}
};
131.分割回文串
思考过程:
回溯方式就是最基本的回溯过程, 在每次for循环分割的时候查看分割的字符串是否是回文串, 如果不是就直接continue. 如果是则将该子串加入到path中
如果在for循环中遇到叶子结点, 则判断是否是回文串, 如果是将path加入result中,
终止条件为 “切割完毕” ---起始切割位置大于了总字符串的长度
class Solution {
private:
vector<vector<string>> result;
vector<string> path;
bool isPalindrome(const string& s, int start, int end) {
for (int i = start, j = end; i <= j; i++, j--) {
if (s[i] != s[j]) return false;
}
return true;
}
void backtracking(const string& s, int startIdx) {
if (startIdx > s.size()) return;
for(int i = startIdx; i < s.size(); i++) {
if (isPalindrome(s, startIdx, i)) { // 判断当前位置是否为回文串
// string substr (size_t pos, size_t len) const;
string str = s.substr(startIdx, i - startIdx + 1);
path.push_back(str);
} else {
continue;
}
if (i == s.size() - 1) { // 遇到叶子结点
result.push_back(path);
}
backtracking(s, i + 1);
path.pop_back();
}
}
public:
vector<vector<string>> partition(string s) {
backtracking(s, 0);
return result;
}
};
93.复原IP地址
思考过程:
正常的回溯切割过程
1. 在每次for循环切割时判断当前切割子串是否合法, 如果合法则继续向下递归, 如果不合法则直接break for循环
2. 在判断合法后, 在切割位置加入点“.” 同时维护一个pointNum表示已经加入点“.”的个数
3. 终止条件为pointNum == 3, 则判断剩下的子串是否合法, 如果合法则直接将修改后字符串放入result中
4. 需要判断的合法条件: 0开头数字、非数字、大于255
class Solution {
private:
vector<string> result;
bool isVaild(const string& s, int start, int end) { // []左闭右闭
if (start > end) return false;
if (s[start] == '0' && start != end) return false; // 以0开头,同时长度大于1
int sumNum = 0;
for (int i = start; i <= end; i++) {
if (s[i] < '0' || s[i] > '9') return false; // 非数字, 用ASCII码
sumNum = sumNum * 10 + (s[i] - '0');
if (sumNum > 255) return false; // 大于255
}
return true;
}
void backtracking(string& s, int startIdx, int pointNum) { // 需要修改, 不能用const
if (pointNum == 3) {
if (isVaild(s, startIdx, s.size() - 1)) {
result.push_back(s);
}
return;
}
for (int endIdx = startIdx; endIdx < s.size(); endIdx++) { // [] 左闭右闭
if(isVaild(s, startIdx, endIdx)) {
// vector_name.insert (position, val);, 位置需要输入的是字符串开始处指针指向的位置
s.insert(s.begin() + endIdx + 1, '.');
pointNum += 1;
backtracking(s, endIdx + 2, pointNum); // 由于insert, 所以+2
pointNum -= 1;
// vector_name.erase(position);
s.erase(s.begin() + endIdx + 1);
} else {
break;
}
}
}
public:
vector<string> restoreIpAddresses(string s) {
backtracking(s, 0, 0);
return result;
}
};
78.子集
思考过程:
还是维护与当前结点处的path, 在每一个结点处都将path加入result中, root结点也要加入
终止条件为 剩余集合为空---startrIdx >= nums.size(), 其实等于就可以返回了, 大于可能是为了防止其他异常情况
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, int startIdx) {
if (startIdx >= nums.size()) return;
for (int i = startIdx; i < nums.size(); i++) {
path.push_back(nums[i]);
result.push_back(path);
backtracking(nums, i + 1);
path.pop_back();
}
}
public:
vector<vector<int>> subsets(vector<int>& nums) {
result.push_back(path); // 加入空集
backtracking(nums, 0);
return result;
}
};
90.子集II
思考过程:
使用used数组进行树层去重, 树枝上使用过(上面几层)则标为true, 否则为false,
上面一层使用过(true)在当前层还是可以使用(同样数字), 如果是false则表示需要在当前层for循环遍历, 同样的数字需要去重
去重操作都需要sort
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, int startIdx, vector<bool>& used) {
result.push_back(path); // 每次在进入之前加入结果
if (startIdx >= nums.size()) return;
for (int i = startIdx; i < nums.size(); i++) {
if (i > 0 && nums[i-1] == nums[i] && used[i-1] == false) {
continue;
}
path.push_back(nums[i]);
used[i] = true;
backtracking(nums, i + 1, used);
used[i] = false;
path.pop_back();
}
return;
}
public:
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
sort(nums.begin(), nums.end());
vector<bool> used(nums.size(), false);
backtracking(nums, 0, used);
return result;
}
};
491.递增子序列
思考过程:
1. 由于题目要求返回的是非递减子序列, 所以不能将数组进行排序(如果排序了就不是序列)
2. 将树形图画出来后, 发现需要对树层进行去重, 由于没有排序所以不能使用used数组, 使用unordered set记录同一树层是否有出现过重复元素
3. 在for循环内还需要对“非递减”进行判断, 如果当前for循环的值小于path中最后一个元素, 则直接continue
4. 在path大于等于2个元素的结点, 将path加入result
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, int startIdx) {
if (path.size() > 1) result.push_back(path);
if (startIdx >= nums.size()) return;
unordered_set<int> uset;
for (int i = startIdx; i < nums.size(); i++) {
// 如果集合中不存在某个键,则 find() 函数将返回指向 end() 的迭代器,否则将返回指向该键位置的迭代器。
// end()指向元素结束后的迭代器
if (!path.empty() && path.back() > nums[i] || uset.find(nums[i]) != uset.end()) {
continue;
}
uset.insert(nums[i]);
path.push_back(nums[i]);
backtracking(nums, i + 1);
path.pop_back();
}
}
public:
vector<vector<int>> findSubsequences(vector<int>& nums) {
backtracking(nums, 0);
return result;
}
};
46.全排列
思考过程:
由于是排列问题, 所以不能是哟startIdx, 每次for循环都要从第一个元素开始, 使用used数组来表示有哪些元素已经在树枝上被使用, 被使用的跳过
在叶子结点(终止条件时), 将path加入到result中
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, vector<bool>& used) {
// path大小与nums大小一样表示已经到叶子结点
if (nums.size() == path.size()) {
result.push_back(path);
}
for (int i = 0; i < nums.size(); i++) {
if(used[i] == true) continue;
path.push_back(nums[i]);
used[i] = true;
backtracking(nums, used);
used[i] = false;
path.pop_back();
}
}
public:
vector<vector<int>> permute(vector<int>& nums) {
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
47.全排列 II
思考过程:
与上一题思路差不多, 由于要输出不重复元素, 所以需要树层去重
这里没有要求序列, 所以可以使用sort排序, 再用used数组进行树层去重
同时used数组来判断是否在树枝上使用过(需要跳过)
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, vector<bool>& used) {
// path大小与nums大小一样表示已经到叶子结点
if (nums.size() == path.size()) {
result.push_back(path);
}
for (int i = 0; i < nums.size(); i++) {
if(used[i] == true) continue;
if(i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) continue;
path.push_back(nums[i]);
used[i] = true;
backtracking(nums, used);
used[i] = false;
path.pop_back();
}
}
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
vector<bool> used(nums.size(), false);
sort(nums.begin(), nums.end());
backtracking(nums, used);
return result;
}
};
51. N皇后
思考过程:
本质上是嵌套n个for循环来查找每一行的位置是否合法(根据上面几层的Q位置判断当前位置是否能放Q), 如果合法则将Q放在当前层当前位置, 不合法直接跳过
终止条件为递归到最后一层
合法判断: 不能在同一行, 不能在同一列, 不能在45度, 不能在135度
class Solution {
private:
vector<vector<string>> result;
void backtracking(int n, int row, vector<string>& chessboard) {
if (row == n) {
result.push_back(chessboard);
return;
}
for (int col = 0; col < n; col++) {
if (isValid(row, col, chessboard, n)) {
chessboard[row][col] = 'Q';
backtracking(n, row + 1, chessboard);
chessboard[row][col] = '.';
}
}
return;
}
bool isValid(int row, int col, vector<string>& chessboard, int n) {
// 不需要考虑行重复的情况
// 列重复
for (int i = 0; i < n; i++) {
if (chessboard[i][col] == 'Q') return false;
}
// 45度往左上
for (int i = row, j = col; i >= 0 && j >= 0; i--, j--) { // for循环同时有两个变量要用&&逻辑
if (chessboard[i][j] == 'Q') return false;
}
// 往右下
// for (int i = row, j = col; i < n, j < n; i++, j++) {
// if (chessboard[i][j] == 'Q') return false;
// }
// 往右上
for (int i = row, j = col; i >= 0 && j < n; i--, j++) {
if (chessboard[i][j] == 'Q') return false;
}
// 往左下
// for (int i = row, j = col; i < n, j >= 0; i++, j--) {
// if (chessboard[i][j] == 'Q') return false;
// }
return true;
}
public:
vector<vector<string>> solveNQueens(int n) {
vector<string> chessboard(n, string(n, '.'));
backtracking(n, 0, chessboard);
return result;
}
};
贪心算法
455.分发饼干
思考过程:
为了满足更多的小孩,就不要造成饼干尺寸的浪费。
大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的。
这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。
可以尝试使用贪心策略,先将饼干数组和小孩数组排序。
然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量。
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int result = 0;
int idx = s.size() - 1;
for (int i = g.size() - 1; i >= 0; i--) {
// 先指向最大的饼干, 如果能满足才向前走一位
if (idx >= 0 && s[idx] >= g[i]) { // 需要确保idx大于等于0, 必须写在前面先判断
idx--;
result++;
}
}
return result;
}
};
376. 摆动序列
思考过程:
局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值。
整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列。
局部最优推出全局最优,并举不出反例,那么试试贪心!
(为方便表述,以下说的峰值都是指局部峰值)
实际操作上,其实连删除的操作都不用做,因为题目要求的是最长摆动子序列的长度,所以只需要统计数组的峰值数量就可以了(相当于是删除单一坡度上的节点,然后统计长度)
这就是贪心所贪的地方,让峰值尽可能的保持峰值,然后删除单一坡度上的节点
定义两个int类型, prediff, curdiff分别表示前一对的差值与当前对的差值, 当差值符号不同时表示有峰值
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int prediff = 0, curdiff = 0;
int result = 0;
for (int i = 0; i < nums.size() - 1; i++) { // 双指针注意越界
curdiff = nums[i + 1] - nums[i];
if ((curdiff > 0 && prediff <= 0) || (curdiff < 0 && prediff >= 0)) {
result++;
prediff = curdiff; // 在摆动的时候更新prediff
}
}
return result + 1; // 结点两端需要加1
}
};
53. 最大子序和
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。 子数组 是数组中的一个连续部分。
思考过程:
用一个for循环来遍历nums, 逐步做累加操作
贪心贪的是哪里呢?
如果 -2 1 在一起,计算起点的时候,一定是从 1 开始计算,因为负数只会拉低总和,这就是贪心贪的地方!
局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素 “连续和”只会越来越小。这样会有好多个局部累加和
全局最优:选取最大“连续和”
局部最优的情况下,并记录最大的“连续和”,可以推出全局最优。
画图最直观
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int result = INT_MIN;
int curSum = 0;
for (int i = 0; i < nums.size(); i++) {
curSum += nums[i];
if (curSum > result) result = curSum;
if (curSum < 0) curSum = 0;
}
return result;
}
};
122.买卖股票的最佳时机 II
思考过程:
将最终利润分解成每天的利润,
如在“1”买入在“10”卖出, 将其分解为在“1”买入在“5”卖出,同时在“5”买入在“10”卖出
假如第 0 天买入,第 3 天卖出,那么利润为:prices[3] - prices[0]。
相当于(prices[3] - prices[2]) + (prices[2] - prices[1]) + (prices[1] - prices[0])。
此时就是把利润分解为每天为单位的维度,而不是从 0 天到第 3 天整体去考虑!
从图中可以发现,其实我们需要收集每天的正利润就可以,收集正利润的区间,就是股票买卖的区间,而我们只需要关注最终利润,不需要记录区间。
那么只收集正利润就是贪心所贪的地方!
局部最优:收集每天的正利润,全局最优:求得最大利润。
局部最优可以推出全局最优,找不出反例,试一试贪心!
class Solution {
public:
int maxProfit(vector<int>& prices) {
int result = 0;
for (int i = 0; i < prices.size() - 1; i++) {
result += max(prices[i + 1] - prices[i], 0);
}
return result;
}
};
55. 跳跃游戏
思考过程:
刚看到本题一开始可能想:当前位置元素如果是 3,我究竟是跳一步呢,还是两步呢,还是三步呢,究竟跳几步才是最优呢?
其实跳几步无所谓,关键在于可跳的覆盖范围!
不一定非要明确一次究竟跳几步,每次取最大的跳跃步数,这个就是可以跳跃的覆盖范围。
这个范围内,别管是怎么跳的,反正一定可以跳过来。
那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!
每次移动取最大跳跃步数(得到最大的覆盖范围),每移动一个单位,就更新最大覆盖范围。
贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围),整体最优解:最后得到整体最大覆盖范围,看是否能到终点。
局部最优推出全局最优,找不出反例,试试贪心!
class Solution {
public:
bool canJump(vector<int>& nums) {
int cover = 0; // 最大覆盖范围的下标
for (int i = 0; i <= cover; i++) { // 当前下标大于最大cover时没有意义
cover = max(nums[i] + i, cover);
if (cover >= nums.size() - 1) return true; // 当cover到最后一个时, 返回true
}
return false;
}
};
45.跳跃游戏 II
思考过程:
还是使用上一题的覆盖范围
所以真正解题的时候,要从覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最少步数!
这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖。
如果移动下标达到了当前这一步的最大覆盖最远距离了,还没有到终点的话,那么就必须再走一步来增加覆盖范围,直到覆盖范围覆盖了终点。
因为下一步的覆盖范围会在遍历上一步覆盖范围时更新成最远距离, 在这个距离里只需要再跳一步就能达到下一个覆盖距离中任意位置.
class Solution {
public:
int jump(vector<int>& nums) {
if (nums.size() == 1) return 0; // 只有一个元素不用跳
int jumpNum = 0;
int curCover = 0, nextCover = 0;
for (int i = 0; i < nums.size(); i++) {
nextCover = max(nums[i] + i, nextCover);
if (i == curCover) { // 需要走下一步
jumpNum++;
curCover = nextCover; // 更新curCover
if (curCover >= nums.size() - 1) break;
}
}
return jumpNum;
}
};
1005.K次取反后最大化的数组和
思考过程:
贪心的思路,局部最优:让绝对值大的负数变为正数,当前数值达到最大,整体最优:整个数组和达到最大。局部最优可以推出全局最优。
那么如果将负数都转变为正数了,K依然大于0,此时的问题是一个有序正整数序列,如何转变K次正负,让 数组和 达到最大。
那么又是一个贪心:局部最优:只找数值最小的正整数进行反转,当前数值和可以达到最大(例如正整数数组{5, 3, 1},反转1 得到-1 比 反转5得到的-5 大多了),全局最优:整个 数组和 达到最大。
那么本题的解题步骤为:
- 第一步:将数组按照绝对值大小从大到小排序,注意要按照绝对值的大小
- 第二步:从前向后遍历,遇到负数将其变为正数,同时K--
- 第三步:如果K还大于0,那么反复转变数值最小的元素,将K用完
- 第四步:求和
class Solution {
//可以直接通过类名调用 static 成员函数,而不需要创建类的实例。如 Solution::cmp
static bool cmp(int a, int b) { // static变量可以避免类实例化,
return abs(a) > abs(b);
/* 记忆方式: 当第一个参数比第二个小,需要将第一个参数需要排在第二个参数前面时返回true,
反之返回 false。
系统默认a<b时返回true,于是从小到大排。*/
}
public:
int largestSumAfterKNegations(vector<int>& nums, int k) {
sort(nums.begin(), nums.end(), cmp); // 第一步, 按照绝对值从大到小排列
for(int i = 0; i < nums.size(); i++) { // 第二步
if (nums[i] < 0 && k > 0) {
nums[i] = -nums[i];
k--;
}
}
if (k % 2) nums[nums.size() - 1] *= -1; // 第三步
int result = 0;
for (int an : nums) result += an; // c++中一种遍历容器的方式, 第四步
return result;
}
};
134. 加油站
思考过程:
将思路抽象为每个站点到下一个站点会剩下多少油
使用for循环对这个数组进行遍历, 如果累加剩余油量为负数, 那么起始位置一定不在之前遍历的范围内(因为题目保证值唯一), 所以将i+1作为新的起点
从起始位置到终点的区间累加一定是正数(如果存在能够绕一圈)(这个起始位置是唯一值)
同时需要累加所有的剩余油量, 如果全部剩余油量为负数(说明前面的负区间绝对值比后面正区间绝对值大), 那么返回-1表示不存在, 反之为存在返回最后正区间初始值.
class Solution {
public:
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
int start = 0;
int totalSum = 0; // 总剩余
int curSum = 0; // 当前位置到上一个负区间结束点之间到的和
for (int i = 0; i < gas.size(); i++) {
totalSum += gas[i] - cost[i];
curSum += gas[i] - cost[i];
if (curSum < 0) {
start = i + 1;
curSum = 0;
}
}
if (totalSum < 0) return -1;
return start;
}
};
135. 分发糖果
思考过程:
先从左向右遍历, 只保证右孩子比左孩子评分大的情况, 如果右比左大, 则糖果数量为左的数量加1, 否则都为1.
再从右向左遍历, 保证左孩子评分比右孩子评分大的情况, 同时需要保证第一种情况, 如果左比右大, 则糖果数量为右的数量加1, 接下来只需要对这两种情况分配的糖果取较大的那个, 就能同时满足两种情况.
最后再从头遍历第二次分配的糖果得到总数
class Solution {
public:
int candy(vector<int>& ratings) {
vector<int> candy1(ratings.size(), 0);
candy1[0] = 1;
for (int i = 1; i < ratings.size(); i++) {
if (ratings[i] > ratings[i - 1]) {
candy1[i] = candy1[i - 1] + 1;
} else {
candy1[i] = 1;
}
}
vector<int> candy2(ratings.size(), 0);
candy2[ratings.size() - 1] = max(1, candy1[ratings.size() - 1]);
for (int j = ratings.size() - 2; j >= 0; j--) {
if (ratings[j] > ratings[j + 1]) {
candy2[j] = max(candy2[j + 1] + 1, candy1[j]);
} else {
candy2[j] = max(1, candy1[j]);
}
}
int result = 0;
for (int i = 0; i < ratings.size(); i++) result += candy2[i];
return result;
}
};
860.柠檬水找零
思考过程:
3种情况 :
1. 客户给5面额, 直接收下
2. 客户给10面额, 查看是否有一个5能够找零, 如果可以则找零, 不行则直接返回false
3. 客户给20面额, 优先考虑是否有一个5和一个10找零, 再查看是否有三个5找零, 如果可以则找零, 不行则直接返回false
class Solution {
public:
bool lemonadeChange(vector<int>& bills) {
map<string, int> money{{"five", 0}, {"ten", 0}, {"twenty", 0}};
for (int i = 0; i < bills.size(); i++) {
if (bills[i] == 5) money["five"] += 1;
if (bills[i] == 10) {
if (money["five"] > 0) { // 找零
money["ten"] += 1;
money["five"] -= 1;
}
else return false;
}
if (bills[i] == 20) {
if (money["ten"] > 0 && money["five"] > 0) { // 找零
money["twenty"] += 1;
money["ten"] -= 1;
money["five"] -= 1;
} else if (money["five"] >= 3) {
money["twenty"] += 1;
money["five"] = money["five"] - 3;
} else {
return false;
}
}
}
return true;
}
};
406.根据身高重建队列
思考过程:
本题有两个维度,h和k,看到这种题目一定要想如何确定一个维度,然后再按照另一个维度重新排列。如果两个维度一起考虑一定会顾此失彼。
两个维度的排序都试过之后, 发现先根据身高h从大到小排序更好, 如果遇到一个人的k不满足当前情况, 则将这个人插入到之前第k个人之后, (实际上不同考虑是否满足k, 遍历排序之后的队列, 每次按照k值插入即可)
因为插入的这个人身高一定比之前所有人低, 无论插哪里都可以满足要求
排序的时候还需要考虑: 如果两个人的身高一样, 那么较小的k排在前面.
如果k较小的在后面, 前一个相同值插入后, 当前相同值可能会插到前一个相同值前面, 会影响前一个相同值的k(题目要求k个大于等于的人) (纸上模拟即可推出)
class Solution {
static bool cmp(vector<int>& a, vector<int>& b) {
if (a[0] == b[0]) return a[1] < b[1];
return a[0] > b[0];
}
public:
vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
sort(people.begin(), people.end(), cmp);
vector<vector<int>> que;
for (int i = 0; i < people.size(); i++) {
int position = people[i][1];
que.insert(que.begin() + position, people[i]);
}
return que;
}
};
452. 用最少数量的箭引爆气球
思考过程:
先根据气球的左边数值, 按照从小到大排列
如果当前气球的左边数值小于等于上一个气球的右边数值, 那么说明一只箭可以射穿两个气球, 此时将lastRight更新为min(当前气球右边值, lastRight)---->表示如果下一个气球也能用这一只箭射穿的话, 应该取决于前面重叠气球的最小右边值
如果大于上一个气球的右边数值, 说明需要多一只箭, 然后更新右边数值为当前气球的右边数值
class Solution {
private:
static bool cmp(vector<int>& a, vector<int>& b) {
//if (a[0] == b[0]) return a[1] < b[1];
return a[0] < b[0];
}
public:
int findMinArrowShots(vector<vector<int>>& points) {
if (points.size() == 0) return 0;
sort(points.begin(), points.end(), cmp);
int result = 1;
int lastRight = points[0][1];
for (int i = 1; i < points.size(); i++) { // 从第二个气球开始
if (points[i][0] > lastRight) {
result++;
lastRight = points[i][1]; // 更新上一个气球右区间
} else {
lastRight = min(lastRight, points[i][1]);
}
}
return result;
}
};
435. 无重叠区间
思考过程:
跟打气球的思路差不多,
如果当前区间左边界大于等于lastRight, 则表示这两个区间不重叠, 此时更新lastRight为当前区间右边界
如果当前区间左边界小于lastRight, 则表示这两个区间重叠, 需要删除当重叠区间加1, lastRight更新为min(当前区间右值, lastRight)------>表示删除那个区间范围较大的那个
class Solution {
private:
static bool cmp(vector<int>& a, vector<int>& b) {
return a[0] < b[0];
}
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
if (intervals.size() == 0) return 0;
sort(intervals.begin(), intervals.end(), cmp);
int result = 0;
int lastRight = intervals[0][1];
for(int i = 1; i < intervals.size(); i++) {
if (intervals[i][0] < lastRight) {
result++;
lastRight = min(intervals[i][1], lastRight);
} else {
lastRight = intervals[i][1];
}
}
return result;
}
};
763.划分字母区间
思考过程:
先使用一个for循环遍历字符串, 记录字符串中每一个元素的最远出现位置(这里可以用hash表)
再使用for循环遍历字符串, 每次遍历一个元素都查看当前元素的最远位置, 用来更新当前区间需要达到的最远位置,
如果当前位置刚好等于当前区间最远位置, 那么在此处分割, 记录分割的区间长度加入数组
class Solution {
public:
vector<int> partitionLabels(string s) {
int hash[27] = {0}; // 定义hash表长度, 27是为了防止下标越界
for (int i = 0; i < s.size(); i++) {
hash[s[i] - 'a'] = i; // 按照与‘a'的偏移量作为唯一字符
}
vector<int> result;
int left = 0, right = 0;
for (int i = 0; i < s.size(); i++) {
right = max(right, hash[s[i] - 'a']); // 传进来先更新
if (i == right) {
result.push_back(i - left + 1);
left = i + 1;
}
}
return result;
}
};
56. 合并区间
思考过程:
流程跟前面的重叠区间差不多, 区别在与判断有重叠区间后的处理流程
这里的处理流程是更新为当前区间和上一个区间右边界的最大值, 可以直接在result中更新这个右边界.
class Solution {
private:
static bool cmp(vector<int>& a, vector<int>& b) {
return a[0] < b[0];
}
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
vector<vector<int>> result;
if (intervals.size() == 0) return result;
sort(intervals.begin(), intervals.end(), cmp);
result.push_back(intervals[0]); // 将第一个区间直接放入result
for (int i = 1; i < intervals.size(); i++) {
if (intervals[i][0] <= result.back()[1]) {
result.back()[1] = max(result.back()[1], intervals[i][1]); // 发现重叠区间, 只更新右边界
} else {
result.push_back(intervals[i]);
}
}
return result;
}
};
738.单调递增的数字
思考过程:
可以直接使用暴力解法, 但是超时
从后往前遍历, 如果遇到某两位数字不是递增的情况, 则将前一位数字减1, 后一位数字往后的所有数字都变为9
class Solution {
public:
int monotoneIncreasingDigits(int n) {
string strNum = to_string(n); // 将数字转为string
int flag = strNum.size(); // 用于记录从哪一位往后全变成9
for (int i = strNum.size() - 2; i >= 0; i--) {
if (strNum[i] > strNum[i + 1]) {
flag = i + 1;
strNum[i]--; // 直接操作ascii码
}
}
for (int i = flag; i < strNum.size(); i++) {
strNum[i] = '9';
}
return stoi(strNum); // 将string转为int
}
};
968.监控二叉树
思考过程:
为了让摄像头的覆盖范围被尽可能使用, 应该让叶子结点的父结点装摄像头, 从下往上遍历, 只能使用后序遍历
设定二叉树结点的三种状态:
1. 无覆盖: 0
2. 有摄像头: 1
3. 有覆盖: 2
终止条件为当前结点为空结点, 此时需要返回2, 才能满足算法设计要求(让叶子结点的父结点装摄像头)
情况分析:
1. 如果两个叶子结点都为2 , 则当前结点还未被覆盖---返回0
2. 如果叶子结点中有一个有摄像头(1), 返回2---当前结点被覆盖
3. 如果叶子结点中有一个为未被覆盖(0), 则在当前结点装摄像头---返回1
4. 根节点状态: 如果整颗树遍历完后发现根节点还是0, 则需要在根节点额外添加一个摄像头
class Solution {
private:
int result = 0;
int traversal(TreeNode* root) {
if (root == nullptr) return 2;
int left = traversal(root->left);
int right = traversal(root->right);
// 情况1
// 左右节点都有覆盖
if (left == 2 && right == 2) return 0;
// 情况2
// left == 0 && right == 0 左右节点无覆盖
// left == 1 && right == 0 左节点有摄像头,右节点无覆盖
// left == 0 && right == 1 左节点有无覆盖,右节点摄像头
// left == 0 && right == 2 左节点无覆盖,右节点覆盖
// left == 2 && right == 0 左节点覆盖,右节点无覆盖
else if (left == 0 || right == 0) {
result++;
return 1;
}
// 情况3
// left == 1 && right == 2 左节点有摄像头,右节点有覆盖
// left == 2 && right == 1 左节点有覆盖,右节点有摄像头
// left == 1 && right == 1 左右节点都有摄像头
// 其他情况前段代码均已覆盖
else return 2;
}
public:
int minCameraCover(TreeNode* root) {
int rootNum = traversal(root);
if (rootNum == 0) result++;
return result;
}
};
动态规划
动态规划算法不需要确定具体在某个时间点怎么做(与回溯的区别, 回溯会确定具体怎么做), 只需要确定状态的转移(同时使用这个思想定义dp数组)
509. 斐波那契数
思考过程:
动规五步曲:
1. 确定dp数组以及下标的含义
dp[i]的定义为:第i个数的斐波那契数值是dp[i]
2. 确定递推公式
状态转移方程 dp[i] = dp[i - 1] + dp[i - 2];
3. dp数组如何初始化
dp[0] = 0; dp[1] = 1;
4. 确定遍历顺序
dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的
5. 打印/举例推导dp数组
class Solution {
public:
int fib(int n) {
if (n < 0) return -1;
if (n <= 1) return n;
vector<int> dp(n + 1);
dp[0] = 0; dp[1] = 1;
for (int i = 2; i <= n; i++){
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
70. 爬楼梯
思考过程:
爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。
那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。
所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。
推导得出这题是斐波那契数列
class Solution {
public:
int climbStairs(int n) {
if (n < 0) return -1;
if (n <= 2) return n;
vector<int> dp(n + 1);
dp[0] = 1; dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
746. 使用最小花费爬楼梯
思考过程:
动规五步曲:
1. 确定dp数组以及下标的含义
dp[i]的定义:到达第i台阶所花费的最少体力为dp[i]。
2. 确定递推公式
可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]。
dp[i - 1] 跳到 dp[i] 需要花费 dp[i - 1] + cost[i - 1]。
dp[i - 2] 跳到 dp[i] 需要花费 dp[i - 2] + cost[i - 2]。
一定是选最小的,所以dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
3. dp数组如何初始化
根据题目描述, 站在第一或者二台阶不需要消耗, 所以初始化 dp[0] = 0,dp[1] = 0;
4. 确定遍历顺序
本题是从前往后
5. 举例/打印推导dp数组
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
vector<int> dp(cost.size() + 1);
dp[0] = 0; dp[1] = 0;
for (int i = 2; i <= cost.size(); i++) {
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
return dp[cost.size()];
}
};
62.不同路径
思考过程:
1. 确定dp数组以及下标的含义
dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。
2. 确定递推公式
想要求dp[i][j],只能有两个方向来推导出来,即dp[i - 1][j] 和 dp[i][j - 1]。
此时在回顾一下 dp[i - 1][j] 表示啥,是从(0, 0)的位置到(i - 1, j)有几条路径,dp[i][j - 1]同理。
那么很自然,dp[i][j] = dp[i - 1][j] + dp[i][j - 1],因为dp[i][j]只有这两个方向过来。
3. dp数组初始化
题目要求只能向右或者向下走, 所以第一行和第一列的所有格子都只有一种方式达到, 所以全部初始化为1
4. 确定遍历顺序
根据递推公式, 顺序为从上往下, 从左往右
5. 举例/答应递推数组
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m, vector<int>(n, 0)); //dp数组
for (int i = 0; i < m; i++) { // 初始化
dp[i][0] = 1;
}
for (int j = 0; j < n; j++) {
dp[0][j] = 1;
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
};
63. 不同路径 II
思考过程:
与上一题的区别在与:
递推公式: 添加一个条件, 如果当前位置是障碍的话直接跳过(不更新)
初始化: 在第一行与第一列进行初始化的时候, 遇到障碍就直接停止
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size();
int n = obstacleGrid[0].size();
if (obstacleGrid[0][0] || obstacleGrid[m - 1][n - 1]) return 0; //起始位置和终止位置有障碍直接返回0
vector<vector<int>> dp(m, vector<int> (n, 0)); //创建数组的时候初始化为0
for (int i = 0; i < m && !obstacleGrid[i][0]; i++) dp[i][0] = 1;
for (int j = 0; j < n && !obstacleGrid[0][j]; j++) dp[0][j] = 1;
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (!obstacleGrid[i][j]) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
}
return dp[m - 1][n - 1];
}
};
343. 整数拆分
思考过程:
1. 确定dp数组以及下标含义
dp[i]表示将i拆分后能得到的最大数
2. 确定递推公式
用for循环遍历所有比i小的数字(j)
dp[i] = max(j * (i - j), j * dp[i - j], dp[i])
max中的dp[i]表示for循环j的上一个值条件下的最大数, 另外两个分别表示拆分成两个数和拆分成两个以上数的最大值
3. dp数组初始化
dp[0] = 0; dp[1] = 0; dp[2] = 1; , 下标0与1没有意义
4. 确定遍历顺序
从前往后遍历
5. 举例/打印dp数组
class Solution {
public:
int integerBreak(int n) {
vector<int> dp(n + 1);
dp[0] = 0; dp[1] = 0; dp[2] = 1;
for (int i = 3; i <= n; i++) {
for (int j = 1; j <= i/2; j++){
dp[i] = max(max(j * (i - j), j * dp[i - j]), dp[i]); //max函数只能有两个值
}
//cout << dp[i] << endl;
}
return dp[n];
}
};
96.不同的二叉搜索树
思考过程:
1. 确定dp数组(dp table)以及下标的含义
dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]。
2. 确定递推公式
dp[i] += dp[j - 1] * dp[i - j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量
3. dp数组初始化
dp[0] = 1; dp[1] = 1; dp[2] = 2;
4. 确定遍历顺序
从前向后
5. 举例/打印dp数组
class Solution {
public:
int numTrees(int n) {
vector<int> dp(n + 1);
dp[0] = 1; dp[1] = 1;
for(int i = 2; i <= n; i++) {
for (int j = 1; j <= i; j++){
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
}
};
01背包理论基础
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
思考过程:(01背包中的价值最大)
1. 确定dp数组以及下标的含义
dp[ i ][ j ]代表使用容量大小为j的背包, 在物品0-i中任意选择, 所能获得的最大价值
2. 确定递推公式
想要求dp[ i ][ j ]时, 只有两种情况能够推得
1. 当物品 i 的大小 > 背包j的总容量时: 意思是物品i根本放不进背包j中, 那么背包j中只能放0 - (i-1) 中的任意物品, 那么dp[ i ][ j ] = dp[ i - 1 ][ j ].
2. 当物品 i 能够放进背包 j 中时: 这里考虑两种情况---将物品 i 放进背包和不把物品 i 放进背包 两个之间哪个价值更大
(1). 不把物品 i 放进背包: 相当于背包中只能放0 - (i-1) 中的任意物品(与第一种情况的公式一样)
(2). 将物品 i 放进背包: 这里确定 i 一定放进背包中, 所以此时的公式为 dp[ i ][ j ] = dp[ i - 1][ j - weight[ i ] ] + value[ i ], 这里的最优解为当容量为 j - weight[ i ]当背包中放进 0 - (i-1) 中的任意物品时的最优解, 然后再加上物品 i 本身的价值 value[ i ]
(1)中其实包括了物品 i 的大小 > 背包j的总容量时的情况, 因为本身就放不下的话跟不把物品 i 放进背包的情况 是一样的, 所以dp[ i ][ j ]只需考虑是否将物品 i 放进背包, 考虑这两种情况的值哪个更大就选哪个
所以递归公式: dp[ i ][ j ] = max(dp[ i - 1 ][ j ], dp[ i - 1 ][ j - weight[ i ] ] + value[ i ]);
在图中就是当前格子由正上方的格子和左上方的一个格子推导来
3. dp数组的初始化
数组应该初始化为这样, 手推一下就行(这是从第二个物品开始遍历)
如果从第一个物品开始遍历则全部初始化为0
4. 确定遍历顺序
从左往后, 从上往下(在物品和背包for循环嵌套上可以颠倒)
5. 举例/打印dp数组
在第二步分析过
void test_2_wei_bag_problem1() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagweight = 4;
// 二维数组
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
// 初始化
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
cout << dp[weight.size() - 1][bagweight] << endl;
}
int main() {
test_2_wei_bag_problem1();
}
01背包理论基础(滚动数组)
可以将上一题的二维数组压缩成一位数组
思考过程:
考虑递推公式: dp[ i ][ j ] = max(dp[ i - 1 ][ j ], dp[ i - 1 ][ j - weight[ i ] ] + value[ i ]);
如果将 i - 1行的数值拷贝到第 i 行, 然后可以直接在第 i 行上处理数据
1. 确定dp数组以及下标的含义
在一维dp数组中,dp[ j ]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
2. 确定递推公式
dp[ j ] = max(dp[ j ], dp[ j - weight[ i ] + value[ i ]]);
这由二维递推公式直接推导而来
3. dp数组初始化
如果背包 j 的容量大于物品 0, 则全部初始化为value[ 0 ]; 否则全部初始化为0(从第二个物品开始遍历)
全部初始化为0, 表示所有背包在0个物品的条件下所能获得的最大价值
然后从第一个物品开始遍历
4. 确定遍历顺序
此时需要从右往左遍历背包, 外层for循环是从0开始遍历物品.(这里不能颠倒)
因为在二维时是由正上方和左上方推导来, 一维时变成由自身和左边的一个格子推导而来(这两个格子的值不能被当前遍历物品 i 的for循环更新, 需要保持遍历物品 i - 1的更新)
5. 举例/打印dp数组
void test_1_wei_bag_problem() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 初始化
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_1_wei_bag_problem();
}
416. 分割等和子集 (01背包是否能装满)
给你一个 只包含正整数 的 非空 数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
思考过程: (01背包是否能装满)
将背包用 i 个数值填充, 每次for循环遍历物品都保证背包中是最大价值(重量与价值相等, 所以只要确定背包容量, 价值不会超过容量), 当遍历到某个物品时背包中的最大价值恰好等于target, 则返回true, 否则返回false
或者是等全部物品遍历完, 背包中最大价值不会超过容量, 所以此时如果最大价值恰好等于target, 则返回true, 否则返回false. 上面一种情况是这种情况的剪枝.
使用01背包解决这个问题需要明确四点:
- 背包的体积为sum / 2
- 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
- 背包如果正好装满,说明找到了总和为 sum / 2 的子集。
- 背包中每一个元素是不可重复放入。
使用一维数组的01背包解决这个问题
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int i = 0; i < nums.size(); i++) {
sum += nums[i];
}
if (sum % 2) return false; //如果和为奇数直接返回
int target = sum / 2;
// dp[i]中的i表示背包内总和
// 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
// 总和不会大于20000,背包最大只需要其中一半,所以10001大小就可以了
vector<int> dp(10001, 0);
// j从target开始表示我们只关心这个容量的背包, 大于这个数值的背包我们不关心
// 这里j要大于等于nums[i],
// 在公式中表示数组不能越界, 在理论中表示背包容量小于当前物品重量, 那么当前物品一定放不下
for (int i = 0; i< nums.size(); i++){
for (int j = target; j >= nums[i]; j--){
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
}
if(dp[target] == target) return true;
}
return false;
}
};
1049.最后一块石头的重量II (01背包容量能装多少装多少)
有一堆石头,每块石头的重量都是正整数。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:
如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。
最后,最多只会剩下一块石头。返回此石头最小的可能重量。如果没有石头剩下,就返回 0。
思考过程:(01背包容量能装多少装多少)
本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了。
流程上与 “分割等和子集” 一样, 区别在与返回时的写法
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
vector<int> dp(15001, 0);
int sum = 0;
for (int i = 0; i < stones.size(); i++) {
sum += stones[i];
}
int target = sum / 2;
for (int i = 0; i < stones.size(); i++) {
for (int j = target; j >= stones[i]; j--){
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - dp[target] * 2;
}
};
494.目标和 (填满背包有多少种方式)
给定一个非负整数数组,a1, a2, ..., an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。
返回可以使最终数组和为目标数 S 的所有添加符号的方法数。
示例:
- 输入:nums: [1, 1, 1, 1, 1], S: 3
- 输出:5
解释:
- -1+1+1+1+1 = 3
- +1-1+1+1+1 = 3
- +1+1-1+1+1 = 3
- +1+1+1-1+1 = 3
- +1+1+1+1-1 = 3
一共有5种方法让最终目标和为3。
思考过程:(填满背包有多少种方式)
将数组分成正数和负数两个集合, 用positive和negative表示
positive + negative = sum
positive - negative = target
可以推导出 positive = (sum + target) / 2
将其抽象成背包问题就是给定positive大小的背包, 填满这个背包有多少种方式
如果positive不能整除说明找不到一种方式, 直接返回0
动规五步曲:
1. 确定dp数组以及下标的含义
dp[ i ] 表示容量为 i 的背包, 填满这个背包有多少种方式
2. 确定递推公式
只要确定使用nums[i],另外使用在nums[i]之前的数字, 凑成dp[j]就有dp[j - nums[i]] 种方法。
例如:dp[j],j 为5,
- 已经有一个1(nums[i]) 的话(可使用数字小于等于1),有 dp[4]种方法 凑成 容量为5的背包。
- 已经有一个2(nums[i]) 的话(可使用数字小于等于2),有 dp[3]种方法 凑成 容量为5的背包。
- 已经有一个3(nums[i]) 的话(可使用数字小于等于3),有 dp[2]种方法 凑成 容量为5的背包
- 已经有一个4(nums[i]) 的话(可使用数字小于等于4),有 dp[1]种方法 凑成 容量为5的背包
- 已经有一个5 (nums[i])的话(可使用数字小于等于5),有 dp[0]种方法 凑成 容量为5的背包
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。
dp[ j ] += dp[ j - nums[ i ] ]
3. dp数组初始化
在0个数字的情况下, 装满所有背包有0种方法, 所以全部初始化为0(从第一个数字开始遍历)
在1个数字的情况下, 所有是这个数字的倍数的背包有1种方法, 其他为0种方法(从第二个数字开始遍历)
为了能够从第一个数字开始遍历, 同时能够按照递推公式推导出下一种情况, dp[0] 需要为1, 其他为0
4. 确定遍历顺序
01背包问题从后向前遍历
5. 推导/打印递推dp数组
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for (int i = 0; i < nums.size(); i++) {
sum += nums[i];
}
if (abs(target) > sum) return 0; //target比sum还大时没有方案
if ((sum + target) % 2) return 0;
int positive = (sum + target) / 2; // 背包大小
vector<int> dp(positive + 1, 0);
dp[0] = 1; // 初始化
for (int i = 0; i < nums.size(); i++) {
for (int j = positive; j >= nums[i]; j--){
dp[j] += dp[j - nums[i]];
}
}
return dp[positive];
}
};
474.一和零 (二维滚动数组的01背包)
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。
请你找出并返回 strs 的最大子集的大小,该子集中 最多 有 m 个 0 和 n 个 1 。
如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。
示例 1:
-
输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3
-
输出:4
-
解释:最多有 5 个 0 和 3 个 1 的最大子集是 {"10","0001","1","0"} ,因此答案是 4 。 其他满足题意但较小的子集包括 {"0001","1"} 和 {"10","1","0"} 。{"111001"} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。
思考过程: (二维滚动数组的01背包)
第一反应是可以用回溯算法暴力求解, 背包问题是回溯算法的优化
将strs中的字符串看成是物品, m与n看成是背包的容量, 将物品装入的个数看成是物品价值, 问题转化为装满这个背包最多能有多少个物品
动规五步曲:
1. 确定dp数组以及下标的含义
dp[ i ][ j ] 表示背包容量为i个1, j个0, 所能装下的最大物品个数
2. 确定递归公式
一维滚动数组公式: dp[ j ] = max(dp[ j ], dp[ j - weight[ i ] + value[ i ]]);
此时的二维滚动数组可以扩展而来 : dp[ i ][ j ] = max(dp[ i ][ j ], dp[ i - x][ j - y] + 1);
x表示当前字符串1的个数, y表示0的个数
3. dp数组初始化
求最大价值问题是0下标与非0下标都初始化为0 (在滚动数组理论基础部分分析过)
4. 确定遍历顺序
求最大价值问题是背包容量从后向前, 这里有两个维度的容量, 先遍历哪一个都可以(可以画图理解)
5. 举例/打印dp数组
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
vector<vector<int>> dp(m + 1, vector(n + 1, 0));
for (string str:strs) { // c++中遍历数组的方式
int oneNum = 0, zeroNum = 0;
for (char c: str) { // 当前字符串的01个数(重量)
if (c == '1') oneNum += 1;
else zeroNum += 1;
}
for (int i = m; i >= zeroNum; i--) {
for (int j = n; j >= oneNum; j--) {
dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
}
}
}
return dp[m][n];
}
};
完全背包理论基础
思考过程:
动规五步曲:
1. 确定dp数组以及下标的含义
dp[ i ][ j ] 表示在容量为j的背包下, 从物品0 - i中任意选择任意个(0 - 无穷大)
2. 确定递归函数
从先确定当前物品是否能放进背包j中开始, 有两种情况
1. 当物品 i 的大小 > 背包j的总容量时: 意思是物品i根本放不进背包j中, 那么背包j中只能放0 - (i-1) 中的任意物品, 那么dp[ i ][ j ] = dp[ i - 1 ][ j ].
2. 当物品 i 能够放进背包 j 中时: 这里考虑两种情况---将物品 i 放进背包和不把物品 i 放进背包 两个之间哪个价值更大
(1). 不把物品 i 放进背包: 相当于背包中只能放0 - (i -1) 中的任意物品(与第一种情况的公式一样)
---------------以上部分与01背包的推导过程一致----------------
(2). 在背包中放进k个物品 i, k大于等于1: 这里保证一定会放进一个物品i,
公式为: dp[ i ][ j ] = dp[ i ][ j - weight[ i ] ] + value[ i ],
dp[ i ][ j - weight[ i ] ]表示在背包容量为j - weight[ i ]的情况下在0 - i个物品中任意选择
3. dp数组初始化
与01背包一样初始化为0
4. 确定遍历顺序
此时状态来自两个方向, 正上方与左边某一个格子, 所以要从左到右, 从上到下(物品背包顺序可以颠倒)
5. 举例/打印dp数组
一维滚动数组情况:
公式压缩为: dp[ j ] = max(dp[ j ], dp[ j - weight[ i ] + value[ i ]]), 在公式上与01背包没有区别
但是在遍历顺序上, 完全背包在遍历背包大小时需要从左向右遍历(根据二维数组的状态转移方向)
// 先遍历物品,在遍历背包
void test_CompletePack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_CompletePack();
}
关于物品和背包的for循环嵌套顺序:
完全背包可以调换(可以从二维dp数组看出, 然后压缩为一维也是一样)
518.零钱兑换II (完全背包组合问题)
使用 完全背包 解决这个问题
思考过程:
1. dp数组以及下标的含义
dp[j]:凑成总金额j的货币组合数为dp[j]
2. 确定递推公式
组合问题的递推公式为: dp[j] += dp[j - coins[i]]
二维dp数组的01背包组合问题递推公式: dp[ i ][ j ] += dp[ i - 1 ][ j - coins[ i ]]
当前格子来自上面一行往左的某些格子
二维dp数组的完全背包组合问题递推公式: dp[ i ][ j ] += dp[ i ][ j - coins[ i ]]
当前格子来自当前行往左的某些格子
3. dp数组初始化
与01背包的组合问题初始化一样, dp[ 0 ] = 1, 其他非0下标初始化为0
4. 确定遍历顺序
01背包组合问题是从背包大往小遍历(由二维向一维的压缩过程得出)
完全背包组合问题是从背包小往大遍历(由二维向一维的压缩过程得出)
两个问题的外层for循环都是遍历物品
5. 举例/打印dp数组
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount + 1, 0);
dp[0] = 1;
for (int i = 0; i < coins.size(); i++) {
for (int j = coins[i]; j <= amount; j++) { // 与01背包反过来, 背包不能小于物品值
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
};
377. 组合总和 Ⅳ (完全背包排列问题)
给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。
示例:
- nums = [1, 2, 3]
- target = 4
所有可能的组合为: (1, 1, 1, 1) (1, 1, 2) (1, 2, 1) (1, 3) (2, 1, 1) (2, 2) (3, 1)
请注意,顺序不同的序列被视作不同的组合。
所以这题实际上求的是排列数(强调顺序)
思考过程:
一个数字可以被使用多次, 所以是完全背包问题
与完全背包的组合问题的区别在于:
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
其他都一样(同样需要保证背包容量大于当前物品, 排列问题由if语句控制, 组合问题由for循环里的范围控制)
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target + 1, 0);
dp[0] = 1;
for (int i = 0; i <= target; i++) {
for (int j = 0; j < nums.size(); j++){
// 由于先遍历背包再遍历物品, 需要考虑背包容量大于当前物品的条件
// 当背包容量小于当前物品时, 相当于不做改变, 直接继承上一状态
// 后面的条件是leetcode 有一个测试案例两个数相加大于int max ? 我也不懂为什么要这么做
if (i >= nums[j] && dp[i] < INT_MAX - dp[i - nums[j]]) {
dp[i] += dp[i - nums[j]];
}
}
}
return dp[target];
}
};
70. 爬楼梯(进阶版)(如何将问题转化为背包问题)
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬至多m (1 <= m < n)个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
输入描述:输入共一行,包含两个正整数,分别表示n, m
输出描述:输出一个整数,表示爬到楼顶的方法数。
输入示例:3 2
输出示例:3
提示:
当 m = 2,n = 3 时,n = 3 这表示一共有三个台阶,m = 2 代表你每次可以爬一个台阶或者两个台阶。
此时你有三种方法可以爬到楼顶。
- 1 阶 + 1 阶 + 1 阶段
- 1 阶 + 2 阶
- 2 阶 + 1 阶
思考过程:
将需要爬到顶顶n个台阶看作背包容量, 将m看成是由m个物品, 爬楼梯时使用的物品的先后顺序不一样表示有两种方法, 物品可以使用无限次, 所以这是一个 完全背包的排列问题
先遍历背包再遍历物品
与 “组合综合IV” 是一样的逻辑代码
322. 零钱兑换 (完全背包最小价值问题)
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
思考过程:
根据题意这是一个完全背包问题, 将硬币的个数看作是价值, 每个硬币的价值为1, 问题转化为填满背包的最小价值
所以递推公式使用: dp[ j ] = min (dp[ j ], dp[ j - weight[ i ] + value[ i ]])
动规五步曲:
1. dp数组以及下标的含义
dp[ i ]表示填满容量为i的背包最少价值为dp[ i ]
2. 确定递推公式
dp[ j ] = min (dp[ j ], dp[ j - weight[ i ] + value[ i ]])
这里使用min而不是max是因为我们求的是最少价值
3. dp数组初始化
在求最大价值时是dp[0] = 0, 非0下标也为0, 这是为了递推公式能够顺利进行
在求最小价值时dp[0] = 0, 非0下标需要初始化为int_max, 也是为了递推公式顺利进行
在代码处初始化为int_max - 1是为了防止int类型溢出
4. 确定遍历顺序
完全背包价值问题的 物品背包顺序可以颠倒, 默认先遍历物品在遍历背包
完全背包在遍历背包时从小到大
5. 举例/打印dp数组
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, 0);
for (int i = 1; i < dp.size(); i++) {
dp[i] = INT_MAX - 1;
}
for (int i = 0; i < coins.size(); i++) {
for (int j = coins[i]; j <= amount; j++) {
dp[j] = min(dp[j], dp[j - coins[i]] + 1);
}
}
if (dp[amount] == INT_MAX - 1) return -1; // 等于初始化值表示没有找到, 所以没有更新
return dp[amount];
}
};
279.完全平方数 (完全背包最小价值问题)
给你一个整数 n
,返回 和为 n
的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1
、4
、9
和 16
都是完全平方数,而 3
和 11
不是。
思考过程:
将n看作背包容量, 完全平方数看作是物品, 物品可以使用无限次, 所以这是一个完全背包问题
将完全平方数的数量看作是价值, 本题求的是最小价值
所以跟 “322. 零钱兑换” 思路一致
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX - 1);
dp[0] = 0;
for (int i = 0; i <= n; i++) {
int num = i * i;
for (int j = num; j <= n; j++) {
dp[j] = min(dp[j], dp[j - num] + 1);
}
}
return dp[n];
}
};
139.单词拆分 (没必要硬套背包)
给你一个字符串 s
和一个字符串列表 wordDict
作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s
则返回 true
。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
思考过程:
这题可以使用回溯算法暴力搜索
动态规划看作是回溯算法的优化
动规五步曲:
1. 确定dp数组以及下标的含义
dp[ i ] 表示对于字符串长度为 i 的串, true表示能够被字典组成, false表示不能
2. 确定递推公式
递推公式: if ([ j, i ] 这个区间的子串出现在字典里 && dp[ j ]是true) 那么 dp[i] = true。
所以需要一个for循环来遍历总字符串长度, 然后使用另一个嵌套for循环来遍历当前字符串的子串, 查看所有子串是否符合递推公式
3. dp数组初始化
首先考虑能否将递推公式进行下去, 然后再考虑含义
dp[0] 应该为true, 假设刚好遇到第一个能够被找到的单词, 递推公式if语句后半部分应该为true才能进行下去
非0下标应该初始化为false, 因为没有设为false的规则, 如果全部都是true的话, 最后dp数组所有位置都是true
4. 确定遍历顺序
根据递推公式应该从字符串从前向后遍历
5. 举例/打印dp数组
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end()); // 定义一个set储存字典
vector<bool> dp(s.size() + 1, false);
dp[0] = true;
for (int i = 1; i <= s.size(); i++) { // 遍历总字符串长度, 从第一个位置开始遍历
for (int j = 0; j < i; j++) { // 遍历当前字符串i的子串
string word = s.substr(j, i - j); // substring(起始位置, 长度)
if (wordSet.find(word) != wordSet.end() && dp[j]){
dp[i] = true;
}
}
}
return dp[s.size()];
}
};
关于多重背包,你该了解这些!
有N种物品和一个容量为V 的背包。第 i 种物品最多有 Mi 件可用,每件耗费的空间是 Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。
多重背包和01背包是非常像的, 为什么和01背包像呢?
每件物品最多有 Mi 件可用,把 Mi 件摊开,其实就是一个01背包问题了。
思考过程:
01背包递推公式: dp[ j ] = max(dp[ j ], dp[ j - weight[ i ] + value[ i ]])
而多重背包就是当前放进k件当前物品是最优, 那么需要再来一个for循环来遍历当前物品的个数, 递推公式变为: dp[ j ] = max(dp[ j ], dp[ j - k * weight[ i ] + k * value[ i ]])
#include<iostream>
#include<vector>
using namespace std;
int main() {
int bagWeight,n;
cin >> bagWeight >> n;
vector<int> weight(n, 0);
vector<int> value(n, 0);
vector<int> nums(n, 0);
for (int i = 0; i < n; i++) cin >> weight[i];
for (int i = 0; i < n; i++) cin >> value[i];
for (int i = 0; i < n; i++) cin >> nums[i];
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < n; i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
// 以上为01背包,然后加一个遍历个数
// 遍历个数, 同时保证数组不会越界(实际意义是k个物品超过容量后就放不下了, 等于前一个状态值, 所以不用更新直接继承)
for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) {
dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
}
}
}
cout << dp[bagWeight] << endl;
}
背包问题总结
根据动规五步曲分析区别:
1. dp数组以及下标
具体题目具体分析
2. 确定递推公式
背包总体有两类问题(递推公式区别)
1. 求价值问题
最大价值: dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
最小价值: dp[j] = min(dp[j], dp[j - weight[i]] + value[i])
2. 求排列组合问题(装满背包有多少种方式)
使用同一种递推公式: dp[j] += dp[j - nums[i]]
区别在于遍历顺序
(1). 求组合问题: 先遍历物品再遍历背包
(2). 求排列问题: 先遍历背包再遍历物品
3. dp数组初始化
经验之谈 :
最大价值: dp[0] = 0, 非0下标为0
最小价值: dp[0] = 0, 非0下标为INT_MAX - 1
组合问题: dp[ 0 ] = 1, 其他非0下标初始化为0
排列问题: dp[ 0 ] = 1, 其他非0下标初始化为0
只需记住最小价值和方式问题即可, 当然也可以根据递推公式手推
4. 确定遍历顺序
01背包与完全背包主要区别
1. 01背包 (遍历背包时是从大到小)
01背包在二维dp数组中, 关于物品与背包for循环的先后顺序可以颠倒
在一维dp数组中, 只能先遍历物品, 再遍历背包, 同时在遍历背包时是从大到小
2. 完全背包 (遍历背包时是从小到大)
完全背包在二维和一维数组中, 物品与背包顺序可以颠倒(求价值问题)
同时遍历背包是从小到大
排列组合问题区别在“2”中描述
5. 举例/打印dp数组
多重背包问题大厂面试不用掌握
198.打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
思考过程:
这题用动态规划
动规五步曲:
1. 确定dp数组以及下标的含义
dp[ i ] 表示在前面0 - i个房间中偷取到的最大金额 (只考虑是否偷i, 不一定会偷i)
2. 确定递推公式
在遍历到第 i 家时, 有两种情况
(1). 偷第 i 家: dp[ i ] = dp[ i - 2 ] + nums[ i ]
(2). 不偷第 i 家: dp[ i ] = dp[ i - 1 ] (这里只考虑是否偷第i - 1家, 而不是一定偷)
所以递推公式为 dp[ i ] = max( dp[ i - 2 ] + nums[ i ], dp[ i - 1 ] )
3. dp数组初始化
dp[ 0 ] = nums[ 0 ], dp[ 1 ] = max( nums[ 0 ], nums[ 1 ] ) ---- 手推得出
其他位置初始化为什么都可以, 因为后面会被覆盖
4. 确定遍历顺序
从前向后
5. 举例/打印dp数组
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.size() == 0) return 0;
if (nums.size() == 1) return nums[0];
vector<int> dp(nums.size() + 1, 0);
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
for (int i = 2; i < nums.size(); i++) {
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[nums.size() - 1];
}
};
213.打家劫舍II
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
思考过程:
与 “打家劫舍I” 的区别在于这个问题是圈而不是线性的
所以一共有三种情况 (这里是确定偷不偷)
(1). 不偷首位元素
(2). 偷首元素
(3). 偷尾元素
但是dp数组的定义是考虑0 - i家的最大金额,
将情况二、三改成“考虑首元素”与“考虑尾元素”, 那么这两种情况中一定在递推中考虑了第一种情况,.
如在“考虑尾元素”情况中, 最后一个位置的状态依赖于“第一种情况”的状态
所以问题变成两种情况
在0 - ( i - 1)中的最大金额
在1 - i 中的最大金额
使用 “打家劫舍I” 中的逻辑分别计算这两种情况, 然后取最大值
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.size() == 0) return 0;
if (nums.size() == 1) return nums[0];
int result1 = robLinear(nums, 0, nums.size() - 2);
int result2 = robLinear(nums, 1, nums.size() - 1);
return max(result1, result2);
}
// 打家劫舍1
int robLinear(vector<int>& nums, int start, int end) {
if (start == end) return nums[start];
vector<int> dp(nums.size() + 1, 0);
dp[start] = nums[start]; // 注意dp数组下标含义
dp[start + 1] = max(nums[start], nums[start + 1]);
for (int i = 2; i <= end; i++) {
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[end];
}
};
337.打家劫舍 III
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root
。
除了 root
之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。
给定二叉树的 root
。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。
思考过程:
看到二叉树的题目优先使用递归, 动态规划实际上也是一种递归
1. 确定递归函数的参数以及返回值
输入的是一个根节点root, 同时返回选择偷当前结点(0)和不偷当前结点(1)的最高金额值(这实际上就是dp数组)
2. 确定终止条件
当遍历到叶子结点时为终止, (或者遍历到空结点终止也可以)
3. 单层递归逻辑
使用后序遍历得到左孩子和右孩子的值后, 计算偷当前结点的最大值和不偷当前结点的最大值
如果选择偷当前结点, 那么就不能偷孩子结点
如果选择不偷当前结点, 那么就在偷孩子结点与不偷孩子结点中找到最大值
class Solution {
public:
// 长度为0的数组, 0:偷, 1:不偷
vector<int> robTree(TreeNode* root) {
if (root == nullptr) return vector<int> {0, 0};
vector<int> left = robTree(root->left);
vector<int> right = robTree(root->right);
// 偷当前结点
int val1 = left[1] + right[1] + root->val;
// 不偷当前结点, 可以选择是否偷孩子结点
int val2 = max(left[0], left[1]) + max(right[0], right[1]);
return {val1, val2};
}
int rob(TreeNode* root) {
vector<int> result = robTree(root);
return max(result[0], result[1]);
}
};
121. 买卖股票的最佳时机
给定一个数组 prices
,它的第 i
个元素 prices[i]
表示一支给定股票第 i
天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0
。
思考过程:
本题只能买卖一只股票, 而且买卖只有一次, 所以很自然就会有暴力和贪心的解法
但这里使用动态规划的解法
动规五步曲:
1. dp数组以及下标的含义
由于动态规划算法不需要确定具体在哪一天买入和哪一天卖出, 只需要确定当前的状态与之前的状态有什么关系.
所以dp数组需要定义两个状态---当前持有股票的最大金额和当前不持有股票的最大金额
dp[ i ][ 0 ] 表示当前持有股票的最大金额 (可能在第 i 天买入, 也可以在第 i 天之前买入)
dp[ i ][ 1 ]表示当前不持有股票的最大金额 (可能在第 i 天卖出, 也可以在第 i 天之前卖出)
2. 确定递推公式
对于dp[ i ][ 0 ]有两种情况, 在第 i 天买入和在第 i 天之前买入, 在这里取最大值
因为初始化金额为0而且只能买一次, 所以这里直接是 - price[ i ]
dp[ i ][ 0 ] = max(-price[ i ], dp[ i - 1 ][ 0 ]);
对于dp[ i ][ 1 ]也有两种情况, 在第 i 天卖出和在第 i 天之前卖出
dp[ i ][ 1 ] = max(dp[ i - 1 ][ 0 ] + price[ i ], dp[ i - 1][ 1 ]);
3. dp数组初始化
初始化现金为0
dp[ 0 ][ 0 ] = 0 - price[ 0 ], dp[ 0 ][ 1 ] = 0
4. 确定遍历顺序
从前向后
5. 举例/ 打印dp数组
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
if (len == 0) return 0;
vector<vector<int>> dp(len + 1, vector<int> (2));
dp[0][0] = -prices[0];
dp[0][1] = 0;
for (int i = 1; i < len; i++) {
dp[i][0] = max(-prices[i], dp[i - 1][0]); // 买入之前都是0
dp[i][1] = max(dp[i - 1][0] + prices[i], dp[i - 1][1]);
}
return dp[len - 1][1]; // 最后一天一定卖出
}
};
也可以多定义一个状态: 当天不操作. 这样递推公式能够跟“II”一致
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
if (len == 0) return 0;
vector<vector<int>> dp(len + 1, vector<int> (3));
dp[0][0] = -prices[0];
dp[0][1] = 0;
dp[0][2] = 0; // 当天不操作
for (int i = 1; i < len; i++) {
dp[i][2] = dp[i - 1][2]; // 不操作需要写在最前面
dp[i][0] = max(dp[i - 1][2]-prices[i], dp[i - 1][0]); // 买入之前都是0
dp[i][1] = max(dp[i - 1][0] + prices[i], dp[i - 1][1]);
}
return dp[len - 1][1]; // 最后一天一定卖出
}
};
122.买卖股票的最佳时机II
本题可以买卖多次
思考过程:
因为可以买卖多次但是只有一只股票, 所以只需要改变一下递推公式
买卖股票的最佳时间I递推公式:
dp[ i ][ 0 ] = max(-price[ i ], dp[ i - 1 ][ 0 ]);
dp[ i ][ 1 ] = max(dp[ i - 1 ][ 0 ] + price[ i ], dp[ i - 1][ 1 ]);
在第 i 天持有股票的状态中, 如果在第 i 天买入, 此时不知道是否在第 i 天之前有没有买入卖出, 所以买入状态应该是由第 i - 1 天不持有股票推导而来, 而不是直接由0金额推导而来
递推公式修改为:
dp[ i ][ 0 ] = max(dp[ i - 1][ 1 ] - price[ i ], dp[ i - 1 ][ 0 ]);
dp[ i ][ 1 ] = max(dp[ i - 1 ][ 0 ] + price[ i ], dp[ i - 1][ 1 ]); (这里未变化)
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
if (len == 0) return 0;
vector<vector<int>> dp(len + 1, vector<int> (2));
dp[0][0] = -prices[0];
dp[0][1] = 0;
for (int i = 1; i < len; i++) {
dp[i][0] = max(dp[i - 1][1] - prices[i], dp[i - 1][0]);
dp[i][1] = max(dp[i - 1][0] + prices[i], dp[i - 1][1]);
}
return dp[len - 1][1]; // 最后一天一定卖出
}
};
123.买卖股票的最佳时机III
本题买卖股票最多有两次
思考过程:
动规五步曲:
1. 确定dp数组以及下标的含义
由于这里最多买卖两次, 所以一天有五个状态
(1). 没有操作
(2). (正在)第一次持有
(3). (正在)第一次不持有
(4). (正在)第二次持有
(5). (正在)第二次不持有
2. 确定递推公式
dp[i][0] = dp[i - 1][0]
dp[i][1] = max(dp[i-1][0] - prices[i], dp[i - 1][1]);
dp[i][2] = max(dp[i - 1][1] + prices[i], dp[i - 1][2])
dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] - prices[i]);
dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i]);
根据分析得出
3. dp数组初始化
这里初始化很重要, 为了递推公式能够顺利进行, 得到初始化为
dp[0][0] = 0;
dp[0][1] = -prices[0];
dp[0][2] = 0; // 这里理解为当前买卖一次
dp[0][3] = -prices[0]; // 当天买卖一次后再买入
dp[0][4] = 0; // 当天买卖两次
4. 确定遍历顺序
从前向后
5. 举例/打印dp数组
这一步非常重要, 用于排查递推公式和初始化是否有错
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
if (len == 0) return 0;
vector<vector<int>> dp(len, vector<int> (5, 0));
dp[0][1] = -prices[0];
dp[0][3] = -prices[0];
for (int i = 1; i < prices.size(); i++) {
dp[i][0] = dp[i - 1][0];
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
dp[i][2] = max(dp[i - 1][2], dp[i - 1][1] + prices[i]);
dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] - prices[i]);
dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i]);
}
// 这两种都可以, 其实在状态4中以及包含了2的状态, 将2的状态看作当前再买卖一次就变成4
return dp[len - 1][4];
//return max(dp[len - 1][2], dp[len - 1][4]);
}
};