欢迎访问我的博客首页。
算法经验
1. 定义函数的规范
定义函数时要有良好的规范,虽然这些规范可能完全不影响代码功能,但规范的代码更能给面试官一个好印象。也能让代码读者更容易理解我们的代码,比如使用 const 既能告诉读者这个变量不需要改变,又能防止后期维护人员无意修改。
- 函数的参数尽量使用引用类型,尽量使用 const 类型。我写的代码因为尽量缩短参数列表,所以都没有使用 const。
- 函数体首先检查输入参数。
2. 算法的选择
处理问题时,要尽量选择效率高的算法,效率分为时间效率和空间效率,也称为复杂度。复杂度按 O ( 1 ) O(1) O(1)、 O ( l o g 2 n ) O(log_2^n) O(log2n)、 O ( n ) O(n) O(n)、 O ( n l o g 2 n ) O(nlog_2^n) O(nlog2n)、 O ( n 2 ) O(n^2) O(n2) 的形式递增。
- 如果问题是在有序序列中查找,应考虑设计复杂度为 O ( l o g 2 n ) O(log_2^n) O(log2n) 的算法。“有序”是很明显的依据。
- 如果要处理的数据的取值范围有限,也可能可以设计更好的算法。比如计数排序的时间复杂度是 O ( n ) O(n) O(n)。
- 如果没有什么特殊的信息,应考虑能不能设计时间复杂度为 O ( n ) O(n) O(n)、空间复杂度为 O ( 1 ) O(1) O(1) 的算法。
- 动态规划算法的时间复杂度至少是 O ( n ) O(n) O(n)。
- 递归的分治算法尽量不用。因为所有分治算法都可以用动态规划代替。动态规划用数组的每个元素保存一个子问题的解,而递归的分治算法用每层递归状态记录每个子问题的解,空间复杂度明显大于动态规划。如果划分的子问题有重复,分治算法效率更低。但像《正则表达式匹配》这样的问题,除了分治算法没有更好的方法,因为这个问题本身就比较复杂。
3. 二分查找
二分查找即能查找指定值,也能查找最值。查找最值就是查找小于(或小于等于)指定值的最大值、大于(或大于等于)指定值的最小值。查找指定值的循环条件用 while (start <= end),查找最值的循环条件用 while (start < end)。《单调递增最长子序列问题》的二分查找解法中,有一步是在严格递增序列中查找大于等于指定值的最小值。
二分查找算法使用边界收缩法。在旋转数组中查找时,边界收缩较复杂。《旋转数组》问题在 二分查找 1.2 节。
4. 递归算法
使用递归可以实现多种算法,比如回溯算法、分治算法。
4.1 函数调用栈
递归是利用函数调用栈实现的,操作系统负责管理函数调用栈。调用递归函数时,递归函数的地址以及数据(参数和函数体内的变量)被压入函数调用栈。执行递归函数时,栈顶的函数执行完被操作系统移出栈,新的栈顶函数继续执行,直至栈空。
1. 一调一的递归
调用递归函数时,操作系统把那层递归函数要处理的数据(或其指针、引用),或称为状态,保存在栈中,这是理解递归函数的关键。
以递归输出单向链表为例。递归函数全部入栈后,整个链表都保存在栈中,栈底保存了链表的表头,栈顶保存了链表的表尾。
如果想正序输出链表,只需在入栈过程中调用输出函数;如果想逆序输出链表,只需在出栈过程中调用输出函数。在入栈过程中调用输出函数就是在调用递归函数之前调用输出函数,在出栈过程中调用输出函数就是在调用递归函数之后调用输出函数。
例 3.1:正向输出链表和逆向输出链表。
// 正向输出链表。
void backward(listNode* list) {
if (list == nullptr)
return;
cout << list->data << endl;
backward(list->next);
}
// 逆向输出链表。
void forward(listNode* list) {
if (list == nullptr)
return;
forward(list->next);
cout << list->data << endl;
}
2. 一调多的递归
递归算法处理树、有向图等数据结构时,会出现一调多。最常见的例子就是《二叉树的递归遍历算法》。
4.2 回传结果
递归函数可以通过返回值回传结果,也可以通过引用(或指针)类型的形参回传结果。产生于入栈过程中的结果只能通过形参回传,因为在入栈过程中返回值会终止递归;产生于出栈过程中的结果即能通过形参回传,又能通过返回值回传。
1. 一调一的递归
算法 3.1:产生于入栈过程中的结果只能通过形参回传。
// 用形参回传产生于入栈过程中的结果。
void backward(type1 status_i, type2& res) {
if (condition)
return;
status_j, res = fun(status_i, res);
backward(status_j, res);
}
// 试图用返回值回传产生于入栈过程中的结果。
tpye2 backward(type1 status_i) {
if (condition)
return C;
status_j, res_j = fun(status_i, res_i); // 无法获取上层递归的返回值res_i。
return res_j; // 入栈过程中返回值会终止递归。
backward(status_j);
}
例 3.2:从头到尾累加链表中的元素。
// 用形参回传产生于入栈过程中的结果。
void backward(listNode* list, int& res) {
if (list == nullptr)
return;
res += list->data;
list = list->next;
backward(list, res);
}
算法 3.2:产生于出栈过程中的结果即能通过形参回传,又能通过返回值回传。
// 用形参回传产生于出栈过程中的结果。
void forward(type1 status_i, type2& res) {
if (condition)
return;
forward(status_j, res);
res = fun(status_i, res);
}
// 用返回值回传产生于出栈过程中的结果。
type2 forward(type1 status_i) {
if (condition)
return C;
res_j = forward(status_j);
res_i = fun(res_j, status_i);
return res_i;
}
例 3.3:从尾到头累加链表中的元素。
// 用形参回传产生于出栈过程中的结果。
void forward(listNode* list, int& res) {
if (list == nullptr)
return;
forward(list->next, res);
res += list->data;
}
// 用返回值回传产生于出栈过程中的结果。
int forward(listNode* list) {
if (list->next == nullptr)
return list->data;
int res_j = forward(list->next);
int res_i = res_j + list->data;
return res_i;
}
2. 一调多的递归
分治算法的结果产生于出栈过程中,所以分治算法的结果即能通过形参回传,又能通过返回值回传。例如《最长公共子序列》。
5. 回溯算法
回溯算法是指算法的主要步骤在函数调用栈出栈过程中执行的算法。
函数调用栈进栈过程中,程序执行点离主调函数越来越远,称为“去”;函数调用栈出栈过程中,程序执行点离主调函数越来越近,称为“回”。
很明显,所有递归过程都有“去”和“回”两个过程。通过只把在出栈过程中有操作的递归算法称为回溯算法。回溯算法的一般形式为:
// 一调一的递归。
type backtrack(status_i) {
if (condition)
return y_a;
backtrack(status_j); // 从状态a到状态b。
fun(status_i); // 处理状态a。
}
// 一调多的递归。
type backtrack(params_a) {
if (condition)
return y_a;
todo; // 处理状态a。
backtrack(params_b); // 从状态a到状态b。
todo; // 从状态b回到状态a。
backtrack(params_c); // 从状态a到状态c。
}
《逆向输出链表》的算法就是回溯算法的形式1。与回溯算法的形式 2 相关的问题有排列组合、二叉树的中序/后虚序递归遍、《LeetCode 22.括号生成》。
6. 动态规划算法与分治算法
动态规划算法的过程是 “由因及果”。即先解决小问题,再用小问题的解推导更大问题的解。
分治算法的过程是 “为果求因”,再 “由因及果”。即先处理大问题,找到大问题的解析解(关于小问题的解的函数),再用解决小问题获得的解计算出大问题的数值解。
例 6.1 已知 a 比 b 小 1 岁,b 比 c 小 2 岁,c 比 d 小 3 岁,d 是 17 岁,求 a 多少岁。
分析:大问题一般可以划分成多个子问题,形成 “一调多的递归”。本题中,大问题的规模并不比小问题大,只是更“深”,这是 “一调一的递归”。本题可以使用动态规划算法,通过 “由因及果” 解决;也可以使用分治算法,通过 “为果求因”,再 “由因及果” 解决。本题的 “因” 是 “d 是 17 岁”。
int DP(vector<int>& age_difference, int d = 17) {
int sz = age_difference.size();
int *dp = new int[sz];
memset(dp, 0, sizeof(int) * sz);
dp[0] = d - age_difference[0];
for (int i = 1; i < age_difference.size(); i++)
dp[i] = dp[i - 1] - age_difference[i];
int res = dp[sz - 1];
delete[] dp;
return res;
}
int DAC(vector<int>& age_difference, int person = 0) {
if (person == 4)
return 17;
return DAC(age_difference, person + 1) - age_difference[person];
}
可以看出,动态规划由 “d 是 17 岁” 向 “a 多少岁” 推导:d=17, c=14, b=12, a=11。分治法从 “a 比 b 小 1 岁” 得到 a=b-1,由 “b 比 c 小 2 岁” 得到 b=c-2,由 “c 比 d 小 3 岁” 得到 c=d-3。d 的值已知为 17,从而 c=14, b=12, a=11。
《二分查找》并不能算作分治算法,至少不是典型的分治算法。因为二分查找算法把大问题划分成最小问题后,解决了最小问题,算法就结束了。这个过程是单向的,只有 “划分”,没有 "合并”。如果非要类比双向的分治算法,在无序数组中查找最值才像一般的分治算法,这个过程类似归并排序。但算法复杂度是
n
l
o
g
2
n
nlog_2^n
nlog2n,还不如顺序查找。
一般情况下,分治算法和动态规划算法是可以相互转化的,因为它们都是把大问题划分成小问题。动态规划算法转换成分治算法比分治算法转换成动态规划算法更可行。既然可以使用动态规划算法或分治算法,就说明的要解决的问题可以划分成小问题。但有时候最小问题可能很多,以至于我们很难列举出所有最小问题,也就无法使用动态规划从最小问题开始处理。如《LeetCode 39.组合总数》、《LeetCode 10.正则表达式》以及很多树形数据结构的问题,因为无法直接从叶子节点开始处理。
因为动态规划算法可以保存先计算出的小问题的结果从而避免重复计算,且没有递归调用,所以一般优先选择动态规划算法。
分治算法的形式一般更简洁。而且由于分治算法从大问题遍历到所有小问题,可以保存中间结果,所以它不仅能告诉我们有多少种解决方案,还能告诉我们解决方案具体是什么。比如,寻找路径问题就很适合使用分治算法。再比如《整数划分》 问题,如果要输出所有划分,就只能使用分治算法;如果只输出所有划分有多少种,或所有划分中的最大乘积,使用分治算法就不如使用动态规划算法速度快。还比如《背包问题》,使用分治算法不仅能输出可以装得的最大价值,还能输出放哪些物品可以装得最大价值。
7. 动态规划算法
7.1 划分对象
在动态规划算法中,要划分的对象可能有一个也可能有多个。
在《三种背包问题》中,可以同时对背包容积、物品种类和每种物品的数量划分。《子串》问题中,可以同时对子串的起始位置和结束位置划分。《公共子串/子序列》问题中,可以同时对两个子串/子序列的结束位置划分。《整数划分》问题中,可以同时对整数和加数划分。要划分的对象有 n 个,就要使用 n 重循环。一般可以通过优化减少要划分的对象。比如《子串问题》中可以只对结束位置划分。
7.2 申请数组
动态规划算法需要额外的存储空间存储小问题的结果,而且额外存储空间的大小等于最小问题的数量,所以动态规划算法的时间复杂度和空间复杂度一般在数值上相等。但这并不是绝对的,比如使用动态规划处理《单调递增最长子序列》的时间复杂度是 O ( n 2 ) O(n^2) O(n2),空间复杂度是 O(n)。
动态规划需要使用数组存储小问题的解,下面给出 3 种创建数组的方式:
int *dp = new int[sz1 * sz2];
int *dp = (int*)malloc(sizeof(int) * sz1 * sz2);
vector<vector<int>> dp(sz1, vector<int>(sz2));
前两种方式创建的是一维数组,如果当作二维数组使用,只能用 dp[i * sz2 + j] 访问对于的二维数组 dp[i][j]。使用这样的堆内存,一定要记得及时释放。第三种方式创建的二维数组可以直接访问 dp[i][j],但不能使用 memset 快速置零,而且申请的栈内存大小很有限。
7.3 程序设计
int DP(vector<int>& data) {
if (data.size() == 0)
return 0;
vector<vector<int>> dp(H, vector<int>(W));
// todo.
for (int i = 0; i < H; i++) {
for (int j = 0; j < W; j++) {
dp[i][j] = fun(dp[i - deltai][j - deltaj]);
}
}
return dp[H - 1][W - 1];
}
7.4 最小问题的解
动态规划算法另一个要注意的地方是边界值的初始化。因为 dp[i] 的值一般都用 dp[i-1] 表示,所以要保证 i >= 1。通常把 dp 申请的大一点,用 dp[1] 对应数组的第一个元素,会使问题更简单。
7.5 优化
8. 分治算法
设计分治算法时,首先要考虑的是按照你的划分方法,由小问题的解能不能得到大问题的解。其次要考虑的是划分的小问题尽量不要重叠。而且出于平衡原则,划分时一般使用等分,比如《归并排序》、《最大子串和》问题都可以把大问题等量划分成若干子问题。但有时很难等分,比如《最长公共子串/子序列》、《LeetCode 10.正则表达式匹配》。我们通常把这样难以等分的问题按 1 和 n-1 划分。