前言
此篇博客是学习笔记,根据Hello算法学习。GitHub链接:https://github.com/krahets/hello-algo
搜索
二分查找
二分查找 是一种基于分治策略的高效搜索算法。它利用数据的有序性,每轮减少一半搜索范围,直到找到目标元素或搜索区间为空为止。
// binary_search.cpp
int binarySearch(vector<int> &nums, int target) {
int i=0, j=nums.size()-1;
while (i<=j) {
int m = i + (j-i) / 2;
if (nums[m] < target)
i = m + 1;
else if (nums[m] > target)
j = m - 1;
else
return m;
}
return -1;
}
根据左开右闭,左闭右开或者都开都闭进行选择。
二分查找插入点
给定一个长度为n的有序数组 nums 和一个元素 target ,数组不存在重复元素。现将 target 插入到数组 nums 中,并保持其有序性。若数组中已存在元素 target ,则插入到其左方。请返回插入后 target 在数组中的索引。
// binary_search_insertion.cpp
int binarySearchInsertion(vector<int> &nums, int target) {
int i=0, j=nums.size() - 1;
while (i <= j) {
int m = i + (j - i) / 2;
if(nums[m] < target) {
i = m + 1;
} else if (nums[m] > target) {
j = m - 1;
} else {
return m;
}
}
return i;
}
数组中有重复元素
/* 二分查找插入点(存在重复元素) */
int binarySearchInsertion(vector<int> &nums, int target) {
int i = 0, j = nums.size() - 1; // 初始化双闭区间 [0, n-1]
while (i <= j) {
int m = i + (j - i) / 2; // 计算中点索引 m
if (nums[m] < target) {
i = m + 1; // target 在区间 [m+1, j] 中
} else if (nums[m] > target) {
j = m - 1; // target 在区间 [i, m-1] 中
} else {
j = m - 1; // 首个小于 target 的元素在区间 [i, m-1] 中
}
}
// 返回插入点 i
return i;
}
二分查找边界
给定一个长度为n的有序数组nums,数组可能包含重复元素。请返回数组中最左一个元素target的索引。若数组中不包含该元素,则返回-1。
// binary_search_edge.cpp
int binarySearchLeftEdge(vector<int> &nums, int target) {
// 等价于查找target的插入点
int i = binarySearchInsertion(nums, target);
if (i == nums.size() || nums[i] != target) {
return -1;
}
return i;
}
哈希优化策略
我们常常通过将线性查找替换为哈希查找来降低算法的时间复杂度。
线性查找:以时间换空间
给定一个整数数组 nums 和一个目标元素 target ,请在数组中搜索“和”为 target 的两个元素,并返回它们的数组索引。返回任意一个解即可。
// 暴力枚举
vector<int> twoSumBruteForce(vector<int> &nums, int target) {
int size = num.size();
// 两层循环,时间复杂度 O(n^2)
for (int i=0; i<size; i++) {
for (int j=0; j<size; j++) {
if (nums[i] + nums[j] == target):
return {i, j};
}
}
return {};
}
哈希查找:以空间换时间
借助一个哈希表,键值对分别为数组元素和元素索引。
vector<int> twoSumHashTable(vector<int> &nums, int target) {
int size = nums.size();
// 空间复杂度 O(n)
unordered_map<int, int> dic;
// 时间复杂度 O(n)
for (int i=0; i<size; i++) {
if (dic.find(target - nums[i]) != dic.end()) {
return {dic[target - nums[i]], i};
}
dic.emplace(nums[i], i);
}
return {};
}
重识搜索算法
暴力搜索
线性搜索
广度优先搜索/深度优先搜索
自适应搜索
二分查找
哈希查找
树查找
排序
选择排序【selection sort】
开启一个循环,每轮从未排序区间选择最小的元素,将其放在已排序区间的末尾。
void selectionSort(vector<int> &nums) {
int n = nums.size();
for (int i=0; i<n; i++) {
int k = i;
for (int j=i+1; j<n; j++) {
if (nums[j] < nums[k])
k = j;
}
swap(nums[i], nums[k]);
}
}
冒泡排序【bubble sort】
void bubbleSort(vector<int> &nums) {
for (int i = nums.size() - 1; i > 0; i--) {
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j+1]) {
swap(nums[j], nums[j+1]);
}
}
}
}
效率优化
void bubbleSortWithFlag(vector<int> &nums) {
for (int i=nums.size() - 1; i > 0; i--) {
bool flag = false;
for (int j=0; j<i; j++) {
if (nums[j] > nums[j+1]) {
swap(nums[j], nums[j+1]);
flag = true;
}
}
if (!flag)
break;
}
}
插入排序【insertion sort】
// insertionSort.cpp
void insertionSort(vector<int> &nums) {
for (int i=1; i<nums.size(); i++) {
int base = nums[i], j=i-1;
while (j >= 0 && nums[j] > base) {
nums[j + 1] = nums[j];
j--;
}
nums[j+1] = base;
}
}
快速排序【quick sort】
void swap(vector<int> &nums, int i, int j){
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
int partition(vector<int> &nums, int left, int right) {
int i = left, j = right;
while(i<j){
while (i < j && nums[j] >= nums[left])
j--;
while (i < j && nums[i] <= nums[left])
i++;
swap(nums, i, j);
}
swap(nums, i, left);
return i;
}
void quickSort(vector<int> &nums, int left, int right) {
if (left >= right)
return;
int pivot = partition(nums, left, right);
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
归并排序
/* 合并左子数组和右子数组 */
// 左子数组区间 [left, mid]
// 右子数组区间 [mid + 1, right]
void merge(vector<int> &nums, int left, int mid, int right) {
// 初始化辅助数组
vector<int> tmp(nums.begin() + left, nums.begin() + right + 1);
// 左子数组的起始索引和结束索引
int leftStart = left - left, leftEnd = mid - left;
// 右子数组的起始索引和结束索引
int rightStart = mid + 1 - left, rightEnd = right - left;
// i, j 分别指向左子数组、右子数组的首元素
int i = leftStart, j = rightStart;
// 通过覆盖原数组 nums 来合并左子数组和右子数组
for (int k = left; k <= right; k++) {
// 若“左子数组已全部合并完”,则选取右子数组元素,并且 j++
if (i > leftEnd)
nums[k] = tmp[j++];
// 否则,若“右子数组已全部合并完”或“左子数组元素 <= 右子数组元素”,则选取左子数组元素,并且 i++
else if (j > rightEnd || tmp[i] <= tmp[j])
nums[k] = tmp[i++];
// 否则,若“左右子数组都未全部合并完”且“左子数组元素 > 右子数组元素”,则选取右子数组元素,并且 j++
else
nums[k] = tmp[j++];
}
}
/* 归并排序 */
void mergeSort(vector<int> &nums, int left, int right) {
// 终止条件
if (left >= right)
return; // 当子数组长度为 1 时终止递归
// 划分阶段
int mid = (left + right) / 2; // 计算中点
mergeSort(nums, left, mid); // 递归左子数组
mergeSort(nums, mid + 1, right); // 递归右子数组
// 合并阶段
merge(nums, left, mid, right);
}
堆排序
/* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
void siftDown(vector<int> &nums, int n, int i) {
while (true) {
// 判断节点 i, l, r 中值最大的节点,记为 ma
int l = 2 * i + 1;
int r = 2 * i + 2;
int ma = i;
if (l < n && nums[l] > nums[ma])
ma = l;
if (r < n && nums[r] > nums[ma])
ma = r;
// 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
if (ma == i) {
break;
}
// 交换两节点
swap(nums[i], nums[ma]);
// 循环向下堆化
i = ma;
}
}
/* 堆排序 */
void heapSort(vector<int> &nums) {
// 建堆操作:堆化除叶节点以外的其他所有节点
for (int i = nums.size() / 2 - 1; i >= 0; --i) {
siftDown(nums, nums.size(), i);
}
// 从堆中提取最大元素,循环 n-1 轮
for (int i = nums.size() - 1; i > 0; --i) {
// 交换根节点与最右叶节点(即交换首元素与尾元素)
swap(nums[0], nums[i]);
// 以根节点为起点,从顶至底进行堆化
siftDown(nums, i, 0);
}
}
桶排序
/* 桶排序 */
void bucketSort(vector<float> &nums) {
// 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
int k = nums.size() / 2;
vector<vector<float>> buckets(k);
// 1. 将数组元素分配到各个桶中
for (float num : nums) {
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
int i = num * k;
// 将 num 添加进桶 bucket_idx
buckets[i].push_back(num);
}
// 2. 对各个桶执行排序
for (vector<float> &bucket : buckets) {
// 使用内置排序函数,也可以替换成其他排序算法
sort(bucket.begin(), bucket.end());
}
// 3. 遍历桶合并结果
int i = 0;
for (vector<float> &bucket : buckets) {
for (float num : bucket) {
nums[i++] = num;
}
}
}
计数排序
通过统计元素数量来实现排序,通常应用于整数数组。
void countingSortNaive(vector<int> &nums) {
int m=0;
for (int num:nums) {
m = max(m, max);
}
vector<int> counter(m + 1, 0);
for(int num:nums) {
counter[num]++;
}
int i = 0;
for(int num = 0; num < m+1; num++) {
for (int j = 0; j < counter[num]; j++, i++) {
nums[i] = num;
}
}
}
基数排序
「基数排序 radix sort」的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。
/* 获取元素 num 的第 k 位,其中 exp = 10^(k-1) */
int digit(int num, int exp) {
// 传入 exp 而非 k 可以避免在此重复执行昂贵的次方计算
return (num / exp) % 10;
}
/* 计数排序(根据 nums 第 k 位排序) */
void countingSortDigit(vector<int> &nums, int exp) {
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
vector<int> counter(10, 0);
int n = nums.size();
// 统计 0~9 各数字的出现次数
for (int i = 0; i < n; i++) {
int d = digit(nums[i], exp); // 获取 nums[i] 第 k 位,记为 d
counter[d]++; // 统计数字 d 的出现次数
}
// 求前缀和,将“出现个数”转换为“数组索引”
for (int i = 1; i < 10; i++) {
counter[i] += counter[i - 1];
}
// 倒序遍历,根据桶内统计结果,将各元素填入 res
vector<int> res(n, 0);
for (int i = n - 1; i >= 0; i--) {
int d = digit(nums[i], exp);
int j = counter[d] - 1; // 获取 d 在数组中的索引 j
res[j] = nums[i]; // 将当前元素填入索引 j
counter[d]--; // 将 d 的数量减 1
}
// 使用结果覆盖原数组 nums
for (int i = 0; i < n; i++)
nums[i] = res[i];
}
/* 基数排序 */
void radixSort(vector<int> &nums) {
// 获取数组的最大元素,用于判断最大位数
int m = *max_element(nums.begin(), nums.end());
// 按照从低位到高位的顺序遍历
for (int exp = 1; exp <= m; exp *= 10)
// 对数组元素的第 k 位执行计数排序
// k = 1 -> exp = 1
// k = 2 -> exp = 10
// 即 exp = 10^(k-1)
countingSortDigit(nums, exp);
}
分治
分而治之,通常基于递归实现。
分治搜索策略
int dfs(vector<int> &nums, int target, int i, int j) {
if (i > j) {
return -1;
}
int m = (i+j) / 2;
if (nums[m] < target) {
return dfs(nums, target, m+1, j);
} else if (nums[m] > target) {
return dfs(nums, target, i, m-1);
} else {
return m;
}
}
int binarySearch(vector<int> &nums, int target) {
int n = nums.size();
return dfs(nums, target, 0, n-1);
}
构建树问题
TreeNode *dfs(vector<int> &preorder, unordered_map<int, int> &inorderMap, int i, int l, int r) {
if (r - l < 0)
return null;
TreeNode *root = new TreeNode(preorder[i]);
int m = inorderMap[preorder[i]];
root->left = dfs(preorder, inorderMap, i+1, l, m-1);
root->right = dfs(preorder, inorderMap, i+1+m-l, m+1, r);
return root;
}
TreeNode *buildTree(vector<int> &preorder, vector<int> &inorder) {
unordered_map<int, int> inorderMap;
for(int i=0; i<inorder.size();i++) {
inorderMap[inorder[i]] = i;
}
TreeNode *root = dfs(preorder, inorderMap,0, 0, inorder.size()-1);
return root;
}
汉诺塔问题
void move(vector<int> &src, vector<int> &tar) {
int pan = src.back();
src.pop_back(pan);
}
void dfs(int i, vector<int> &src, vector<int> &buf, vector<int> &tar) {
if (i == 1) {
move(src, tar);
return;
}
dfs(i - 1, src, tar, buf);
move(src, tar);
dfs(i - 1, buf, src, tar);
}
void solveHanota(vector<int> &A, vector<int> &B, vector<int> &C) {
int n = A.size();
dfs(n, A, B, C);
}
回溯
回溯算法是一种通过穷举来解决问题的方法,他的核心思想是从一个状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,知道找到解或者尝试了所有可能的选择都无法找到解为止。
给定一个二叉树,搜索并记录所有值为7的节点,请返回节点列表。
// 前序遍历
void preOrder(TreeNode *root) {
if (root == nullptr) {
return;
}
if (root->val == 7) {
res.push_back(root);
}
preOrder(root->left);
preOrder(root->right);
}
尝试和回退
// 前序遍历:尝试与回退
void preOrder(TreeNode *root) {
if (root == nullptr) {
return;
}
path.push_back(root);
if (root->val == 7) {
// 记录解
res.push_back(path);
}
preOrder(root->left);
preOrder(root->right);
// 回退
path.pop_back();
}
剪枝
// 前序遍历:剪枝
void preOrder(TreeNode *root) {
if (root == nullptr || root->val == 3) {
return;
}
path.push_back(root);
if (root->val == 7) {
res.push_back(path);
}
preOrder(root->left);
preOrder(root->right);
path.pop_back();
}
全排列问题
全排列问题是回溯算法的一些典型应用。它的定义是在给定一个集合的情况下,找出这个集合中元素的所有可能的排列。
无相等元素的情况
void backtrack(vector<int> &state, const vector<int> &choices, vector<bool> &selected, vector<vector<int>> &res) {
if (state.size() == choices.size()) {
res.push_back(state);
return;
}
for (int i=0; i<choices.size(); i++) {
int choice = choices[i];
if (!selected[i]) {
selected[i] = true;
state.push_back(choice);
backtrack(state, choices, selected, res);
selected[i] = false;
state.pop_back();
}
}
}
//全排列
vector<vector<int>> permutationI(vector<int> nums) {
vector<int> state;
vector<bool> selected(nums.size(), false);
vector<vector<int>> res;
backtrack(state, nums, selected, res);
return res;
}
考虑相等元素的情况
/* 回溯算法:全排列 II */
void backtrack(vector<int> &state, const vector<int> &choices, vector<bool> &selected, vector<vector<int>> &res) {
// 当状态长度等于元素数量时,记录解
if (state.size() == choices.size()) {
res.push_back(state);
return;
}
// 遍历所有选择
unordered_set<int> duplicated;
for (int i = 0; i < choices.size(); i++) {
int choice = choices[i];
// 剪枝:不允许重复选择元素 且 不允许重复选择相等元素
if (!selected[i] && duplicated.find(choice) == duplicated.end()) {
// 尝试:做出选择,更新状态
duplicated.emplace(choice); // 记录选择过的元素值
selected[i] = true;
state.push_back(choice);
// 进行下一轮选择
backtrack(state, choices, selected, res);
// 回退:撤销选择,恢复到之前的状态
selected[i] = false;
state.pop_back();
}
}
}
/* 全排列 II */
vector<vector<int>> permutationsII(vector<int> nums) {
vector<int> state;
vector<bool> selected(nums.size(), false);
vector<vector<int>> res;
backtrack(state, nums, selected, res);
return res;
}
子集和问题
给定一个正整数数组nums和一个目标正整数target,请找出所有可能的组合,使得组合中的元素和等于target。给定数组无重复元素,每个元素可以被选取多次。请以列表的形式返回这些组合,列表中不应包含重复组合。
无重复元素的情况
void backtrack(vector<int> &state, int target, int total, vector<int> &choices, vector<vector<int>> &res) {
if (total == target) {
res.push_back(state);
return;
}
for (size_t i = 0; i < choices.size(); i++) {
if (total + choices[i] > target) {
continue;
}
state.push_back(choices[i]);
backtrack(state, target, total + choices[i], choices, res);
state.pop_back();
}
}
vector<vector<int>> subsetSumINaive(vector<int> &nums, int target) {
vector<int> state;
int total = 0;
vector<vector<int>> res;
backtrack(state, target, total, nums, res);
return res;
}
重复子集剪枝
/* 回溯算法:子集和 I */
void backtrack(vector<int> &state, int target, vector<int> &choices, int start, vector<vector<int>> &res) {
// 子集和等于 target 时,记录解
if (target == 0) {
res.push_back(state);
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
for (int i = start; i < choices.size(); i++) {
// 剪枝一:若子集和超过 target ,则直接结束循环
// 这是因为数组已排序,后边元素更大,子集和一定超过 target
if (target - choices[i] < 0) {
break;
}
// 尝试:做出选择,更新 target, start
state.push_back(choices[i]);
// 进行下一轮选择
backtrack(state, target - choices[i], choices, i, res);
// 回退:撤销选择,恢复到之前的状态
state.pop_back();
}
}
/* 求解子集和 I */
vector<vector<int>> subsetSumI(vector<int> &nums, int target) {
vector<int> state; // 状态(子集)
sort(nums.begin(), nums.end()); // 对 nums 进行排序
int start = 0; // 遍历起始点
vector<vector<int>> res; // 结果列表(子集列表)
backtrack(state, target, nums, start, res);
return res;
}
考虑重复元素的情况
给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的元素和等于 target 。给定数组可能包含重复元素,每个元素只可被选择一次。请以列表形式返回这些组合,列表中不应包含重复组合。
//相等元素剪枝
/* 回溯算法:子集和 II */
void backtrack(vector<int> &state, int target, vector<int> &choices, int start, vector<vector<int>> &res) {
// 子集和等于 target 时,记录解
if (target == 0) {
res.push_back(state);
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
// 剪枝三:从 start 开始遍历,避免重复选择同一元素
for (int i = start; i < choices.size(); i++) {
// 剪枝一:若子集和超过 target ,则直接结束循环
// 这是因为数组已排序,后边元素更大,子集和一定超过 target
if (target - choices[i] < 0) {
break;
}
// 剪枝四:如果该元素与左边元素相等,说明该搜索分支重复,直接跳过
if (i > start && choices[i] == choices[i - 1]) {
continue;
}
// 尝试:做出选择,更新 target, start
state.push_back(choices[i]);
// 进行下一轮选择
backtrack(state, target - choices[i], choices, i + 1, res);
// 回退:撤销选择,恢复到之前的状态
state.pop_back();
}
}
/* 求解子集和 II */
vector<vector<int>> subsetSumII(vector<int> &nums, int target) {
vector<int> state; // 状态(子集)
sort(nums.begin(), nums.end()); // 对 nums 进行排序
int start = 0; // 遍历起始点
vector<vector<int>> res; // 结果列表(子集列表)
backtrack(state, target, nums, start, res);
return res;
}
N皇后问题
根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。给定n个皇后和一个n*n大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
/* 回溯算法:N 皇后 */
void backtrack(int row, int n, vector<vector<string>> &state, vector<vector<vector<string>>> &res, vector<bool> &cols,
vector<bool> &diags1, vector<bool> &diags2) {
// 当放置完所有行时,记录解
if (row == n) {
res.push_back(state);
return;
}
// 遍历所有列
for (int col = 0; col < n; col++) {
// 计算该格子对应的主对角线和副对角线
int diag1 = row - col + n - 1;
int diag2 = row + col;
// 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state[row][col] = "Q";
cols[col] = diags1[diag1] = diags2[diag2] = true;
// 放置下一行
backtrack(row + 1, n, state, res, cols, diags1, diags2);
// 回退:将该格子恢复为空位
state[row][col] = "#";
cols[col] = diags1[diag1] = diags2[diag2] = false;
}
}
}
/* 求解 N 皇后 */
vector<vector<vector<string>>> nQueens(int n) {
// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
vector<vector<string>> state(n, vector<string>(n, "#"));
vector<bool> cols(n, false); // 记录列是否有皇后
vector<bool> diags1(2 * n - 1, false); // 记录主对角线是否有皇后
vector<bool> diags2(2 * n - 1, false); // 记录副对角线是否有皇后
vector<vector<vector<string>>> res;
backtrack(0, n, state, res, cols, diags1, diags2);
return res;
}
动态规划
动态规划是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。
问题求解步骤
- 描述决策
- 定义状态
- 建立dp表
- 推导状态转移方程
- 确定边界条件
给定一个n*m的二维网格grid,网格中的每个单元格包含一个非负整数,表示该单元格的代价。机器人以左上角单元格为起始点,每次只能向下或向右移动一步,直到到达右下角单元格。请返回从左上角到右下角的最小路径和。
第一步:思考每轮的决策,定义状态,得到dp表
从当前格子向下或向右一步。当前状态包含行索引和列索引,记为[i, j];
状态对应的子问题:从起始点[0, 0]走到[i, j]的最小路径和,记为dp[i, j];
至此,我们得到了二维dp矩阵,其尺寸与输入网格grid相同。
第二步:找出最优子结构,进而推导出状态转移方程
对于状态【i, j】,它只能从上边格子[i-1, j]和左边格子[i, j-1]转移而来。因此最优子结构为:到达[i, j]的最小路径和由[i-1, j]的最小路径和与[i, j-1]的最小路径和,这两者较小的那一个决定。
dp[i, j] = min (dp[i-1, j], dp[i, j-1]) + grid[i, j]
第三步:确定边界条件和状态转移顺序
首行的状态只能从左边的状态得到,首列的状态只能从上边的状态得到,因此首行i=0和首列j=0是边界条件。
由于每个格子是由其左方格子和上方格子转移而来,因此我们使用循环来遍历矩阵。
根据分析,我们可以直接写出动态规划代码。然而子问题求解是一种从顶到底的思想,因此按照“暴力搜索-》记忆化搜索-》动态规划”的顺序实现更加符合思维习惯。
暴力搜索
// min_path_sum.cpp
int minPathSumDFS(vector<int> &grid, int i, int j) {
// 若为左上角单元格,则终止搜索
if (i == 0 &7 j == 0) {
return grid[0][0];
}
// 若行列索引越界,则返回+∞
if (i < 0 || j < 0) {
return INT_MAX;
}
// 计算从左上角到 (i-1, j) 和 (i, j-1) 的最小路径代价
int left = minPathSumDFS(grid, i-1, j);
int up = minPathSumDFS(grid, i, j-1);
return min(left, up) != INT_MAX ? min(left, up) : INT_MAX;
}
记忆化搜索
引入一个和网格grid相同尺寸的记忆列表mem,用于记录各个子问题的解,并将重叠子问题进行剪枝。
// min_path_sum.cpp
int minPathDFSMem(vector<vector<int>> &grid, vector<vector<int>> &mem, int i, int j) {
if (i == 0 && j == 0) {
return grid[0][0];
}
if (i < 0 || j < 0) {
return INT_MAX;
}
if (mem[i][j] != -1) {
return mem[i][j];
}
int left = minPathSumDFSMem(grid, mem, i-1, j);
int up = minPathSumDFSMem(grid, mem, i, j-1);
min[i][j] = min(left, up) != INT_MAX ? min(left, up) + grid[i][j] : INT_MAX;
return mem[i][j];
}
动态规划
// int minPathSumDP(vector<vector<int>> &grid) {
int n = grid.size(), m = grid[0].size();
// 初始化dp表
vector<vector<int>> dp(n, vector<int>(m));
dp[0][0] == grid[0][0];
for (int j = 1; j<m; j++) {
dp[0][j] == dp[0][j-1] + grid[0][j];
}
for (int i = 1; i<n; i++) {
dp[i][0] == dp[i-1][0] + grid[i][0];
}
for (int i=0; i<n; i++) {
for (int j=0; j<m; j++) {
dp[i][j] = min(dp[i][j-1], dp[i-1][j]) + grid[i][j];
}
}
return dp[n-1][m-1];
}
空间优化
// 空间优化后的动态规划
int minPathSumDPComp(vector<vector<int>> &grid) {
int n = grid.size(), m = grid[0].size();
// 初始化dp表
vector<int> dp(m);
// 状态转移:首行
dp[0] = grid[0][0];
for (int j=1; j<m; j++) {
dp[j] = dp[j-1] + grid[0][j];
}
// 状态转移:其余行
for (int i=1; i<n; i++){
dp[0] = dp[0] + grid[i][0];
for (int j=1; j<m; j++) {
dp[j] = min(dp[j-1], dp[j]) + grid[i][j];
}
}
return dp[m-1];
}
01背包问题
给定n个物品,第i个物品的重量为wgt[i-1]、价值为val[i-1],和一个容量为cap的背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。
第一步:思考每轮的决策,定义状态,从而得到dp表;
对于每个物品来说,不放入背包,背包容量不变;放入背包,背包容量减小。
状态定义:当前物品编号i和剩余背包容量,记为[i, c]。
前i个物品在剩余容量为c的背包中的最大价值,记为dp[i, c]。
待求解的是dp[n, cap] 需要一个尺寸为(n+1)* (cap+1) 的二维dp表。
第二步:找出最优子结构,进而推导出状态转移方程;
不放入物品i:背包容量不变,状态变化为【i-1, c】;
放入物品i:背包容量减小wgt[i-1], 价值增加val[i-1],状态变化为【i-1, c-wgt[i-1]】。
最优子结构:最大价值dp[i, c]等于不放入物品i和放入物品i两种方案中的价值更大的那一个。
dp[i, c] = max(dp[i-1, c], dp[i-1, c-wgt[i-1]] + val[i-1])
第三步:确定边界条件和状态转移顺序;
当无物品或无剩余背包容量时最大价值为0,即首列dp[i, 0]和首行dp[0, i]都等于0;
暴力搜索
// 01背包
int knapsackDFS(vector<int> &wgt, vector<int> &val, int i, int c) {
if (i == 0 || c == 0) {
return 0;
}
// 若超过背包容量,则只能不放入背包
if (wgt[i - 1] > c) {
knapsackDFS(wgt, val, i - 1, c);
}
int no = knapsackDFS(wgt, val, i-1, c);
int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i-1]) + val[i-1];
return max(no, yes);
}
记忆化搜索
/* 0-1 背包:记忆化搜索 */
int knapsackDFSMem(vector<int> &wgt, vector<int> &val, vector<vector<int>> &mem, int i, int c) {
// 若已选完所有物品或背包无容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
// 若已有记录,则直接返回
if (mem[i][c] != -1) {
return mem[i][c];
}
// 若超过背包容量,则只能不放入背包
if (wgt[i - 1] > c) {
return knapsackDFSMem(wgt, val, mem, i - 1, c);
}
// 计算不放入和放入物品 i 的最大价值
int no = knapsackDFSMem(wgt, val, mem, i - 1, c);
int yes = knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1];
// 记录并返回两种方案中价值更大的那一个
mem[i][c] = max(no, yes);
return mem[i][c];
}
动态规划
/* 0-1 背包:动态规划 */
int knapsackDP(vector<int> &wgt, vector<int> &val, int cap) {
int n = wgt.size();
// 初始化 dp 表
vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
// 状态转移
for (int i = 1; i <= n; i++) {
for (int c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
// 若超过背包容量,则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[n][cap];
}
空间优化
/* 0-1 背包:空间优化后的动态规划 */
int knapsackDPComp(vector<int> &wgt, vector<int> &val, int cap) {
int n = wgt.size();
// 初始化 dp 表
vector<int> dp(cap + 1, 0);
// 状态转移
for (int i = 1; i <= n; i++) {
// 倒序遍历
for (int c = cap; c >= 1; c--) {
if (wgt[i - 1] <= c) {
// 不选和选物品 i 这两种方案的较大值
dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[cap];
}
完全背包
每个物品可以重复选择
/* 完全背包:动态规划 */
int unboundedKnapsackDP(vector<int> &wgt, vector<int> &val, int cap) {
int n = wgt.size();
// 初始化 dp 表
vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
// 状态转移
for (int i = 1; i <= n; i++) {
for (int c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
// 若超过背包容量,则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[n][cap];
}
类似的:零钱兑换问题
编辑距离问题
输入两个字符串 s 和 t,返回将 s 转换为 t 所需的最少编辑步数。
你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、替换字符为任意一个字符。
/* 编辑距离:动态规划 */
int editDistanceDP(string s, string t) {
int n = s.length(), m = t.length();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
// 状态转移:首行首列
for (int i = 1; i <= n; i++) {
dp[i][0] = i;
}
for (int j = 1; j <= m; j++) {
dp[0][j] = j;
}
// 状态转移:其余行列
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s[i - 1] == t[j - 1]) {
// 若两字符相等,则直接跳过此两字符
dp[i][j] = dp[i - 1][j - 1];
} else {
// 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
dp[i][j] = min(min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1;
}
}
}
return dp[n][m];
}
贪心【greedy algorithm】
基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期望获得全局最优解。
动态规划会根据当前阶段的所有决策来考虑当前决策,并使用过去子问题的解来构建当前子问题的解。
贪心算法不会重新考虑过去的决策,二十一路向前的进行贪心选择,不断缩小问题范围,直至问题被解决。
零钱兑换
给定 n 种硬币,第 i 种硬币的面值为 coins[i-1] ,目标金额为 amt ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 -1 。
给定目标金额,我们贪心地选择不大于且最接近它的硬币,不断循环该步骤,直至凑出目标金额为止。
//coin_change_greedy.py
int coinChangeGreedy(vector<int> &coins, int amt) {
int i = coins.size() - 1;
int count = 0;
while (amt > 0) {
while (i > 0 && coins[i] > amt) {
i--;
}
amt -= coins[i];
count++;
}
return amt == 0? count : -1;
}
贪心算法不仅操作直接,实现简单,而且通常效率也很高;但是对于某些硬币面值组合,贪心算法并不能找到最优解。
可以保证找到最优解,可以找到近似最优解。
贪心典型例题
- 硬币找零问题
- 区间调度问题
- 分数背包问题
- 股票买卖问题
- 霍夫曼编码
- dijkstra算法
分数背包问题
给定n个物品,第i个物品的重量为wgt[i-1]、价值为val[i-1],和一个容量为cap的背包。每个物品只能选择一次,单可以选择物品的一部分,价值根据选择的重量比例计算,问在不超过背包容量下背包中物品的最大价值。
class Item {
public:
int w;
int v;
Item(int w, int v) : w(w), v(v) {}
};
double fractionalKnapsack(vector<int> &wgt, vector<int> &val, int cap) {
vector<Item> items;
for (int i=0; i<wgt.size(); i++) {
items.push_back(Item(wgt[i], val[i]));
}
sort(items.begin(), items.end(), [](Item &a, Item &b) { return (double)a.v / a.w > (double)b.v / b.w});
double res = 0;
for (auto &item : items) {
if (item.w <= cap) {
res += item.v;
cap -= item.w;
} else {
res += (double)item.v / item.w * cap;
break;
}
}
return res;
}
最大容量问题
输入一个数组 ht ,数组中的每个元素代表一个垂直隔板的高度。数组中的任意两个隔板,以及它们之间的空间可以组成一个容器。
容器的容量等于高度和宽度的乘积(即面积),其中高度由较短的隔板决定,宽度是两个隔板的数组索引之差。
请在数组中选择两个隔板,使得组成的容器的容量最大,返回最大容量。
int maxCapacity(vector<int> &ht) {
int i=0, j = ht.size()-1;
int res = 0;
while (i < j) {
int cap = min(ht[i], ht[j]) * (j-i);
res = max(res, cap);
if (ht[i] < ht[j]) {
i++;
} else {
j--;
}
}
return res;
}
最大切分乘积问题
给定一个正整数,将其切分为至少两个正整数的和,求切分后所有整数的乘积最大是多少。
// 最大切分乘积
int maxProductCutting(int n) {
if (n <= 3) {
return 1 * (n-1);
}
int a = n/3;
int b = n%3;
if (b == 1) {
return (int)pow(3, a-1) * 2 * 2;
}
if (b == 2) {
return (int)pow(3, a) * 2;
}
return (int)pow(3, a);
}