原题: leetcode
这道题提供若干种解法。之所以如此, 原因是和软院同学王佳晚上的一次会话。因为一开始我写不出基于回溯思想的非递归的实现,想找王佳请教一下。聊完之后, 经受点拨, 感觉确实收益颇深!写来记录一下。
解题最重要的, 是理解什么才"正向的"解题思路。 找到一个可以表示问题空间的模型,或者理解题目本身具有的性质,或者是能把问题抽象成数学或状态方程。 借助这样的理解,可能会把问题A转化成问题B. 再借助问题B有关的知识,方法去解题。
以给求定集合A的所有子集问题为例,可以有好几种理解该问题的模型/方案:
- 迭代加深的构造二叉树, 按层遍历, 构造出的叶子节点就是要求的答案;
- 回溯法, 以给定集合的索引作为变量的递归的深度优先搜索,当走完满足一个子集的条件, 记录一个解, 返回(回溯点)上一层,尝试下一个索引,直到索引走完集合的个数;
- 基于二进制性质的位操作的思想;
- 动态规划的思想: 初始状态是空集和势为1的子集, 然后自底向上的状态方程是, 用当前势为i的子集集合和给定集合A的每个元素做并集, 求出下一个i+1的势的子集集合, 求并集的过程中, 很可能需要类似并查集的那种判断同一个集合的操作实现; 直到求到i+1等于A的势n;
对于解题,至关重要的是,想出上述的宏观方案. 在方案不清晰不明确的情况下, 思考代码如何写是没有意义的。通用性的方法或知识, 例如递归, 贪心,动态规划, 回溯等等, 包括其他的数学知识,都是引导我们找到解题方案的借鉴手段。借鉴手段的知晓与否, 熟悉程度好坏是影响题解的一个因素; 但我们需要理解问题本身的性质与解体方法框架之间的差别,能够通过正向的分析找到方案。不能生搬硬套, 看到某些特征就硬性套用某些方法, 或者在该方法框架下的状态参数没有分析清晰之前就着急开始码代码。一定需要对某个方案明确到知道如何借助怎么样的手段找到解, 例如, 当第一个方案我们分析出所有n层的叶子节点集合就是我们要的解。我们才应该开始准备写代码。
我们还应该能区分,宏观方案中的那些细节是影响代码实现的,哪些不是.比如基于二进制的位操作实现里decimal to string的转换具体实现可以不考虑, 但如果回溯法中,递归函数的状态参数如果不分析清楚,甚至不手动画一画递归过程的多叉树, 理解回溯点的必要的回溯撤销动作需求是什么,那么即便是知道回溯法可以解决问题,那么你的回溯法方案是没有分析清晰到位的,必然无法考虑开始写代码, 更没法去写回溯法的非递归的实现.
在刷题的过程中,往往不可避免的需要去看官网的题解. 在这个过程中, 比"知道具体某一个题,某一类题应该是用什么方案去解"更重要的是: 了解总结为什么得到这个解的方案,如何"正向的"去分析出这个解的方案. 避免死记硬背生搬硬套.
当一个方案方法,走不通,可以尝试换一种思路,当然你知识越丰富,可供你选择的方案就更多.例如, 本题求子集的例子,就可以用不同的思路去分析,不同的方案的实施的代码就大相径庭.
总之, 这个正向的分析得到对解题来说足够明确清晰的方案的过程是至关重要的. 这个过程, 是头脑现有技能搜索与逻辑性思考的风暴,是具有创造性的过程.
迭代加深构造的二叉树按层遍历
- 迭代加深的构造二叉树, 按层遍历, 构造出的叶子节点就是要求的答案.
对给定集合A的任一元素, 它们在所有子集的情况是2种, 要么在, 要么不在; 所以以每一个元素为迭代, 从虚拟的根节点root开始可以如下建立二叉树:
对集合{a,b,c}元素, 构造二叉树, 选取第一个元素a, 子集的方案是包括a或不包括a, 构造出第一层孩子; 针对元素b, 在已有二叉树叶子节点集合上构造, 选择b或不选择b, 构造出第二层孩子节点, 依次类推。可以看到, 第n层孩子叶子节点, 即是n个元素的集合A的解集合。本题如能求得第n层的叶子节点,即是需要求解的解集合。
由于这可树并不是已经构成过存在的, 我们的目的是得到第n层的叶子节点集合.在当前思路方案下, 用DFS方法去深度搜索是困难的,也不容易找到递进和回归的条件. 用BFS方法, 第i个元素对应第i层的孩子的个数是2^i, 那么用队列求得i+1层的叶子的方法比较直观。
#include <iostream>
#include <cmath>
#include <vector>
using namespace std;
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> output;
unsigned int size = nums.size();
// empty set;
output.push_back({});
if (size == 0) return output;
output.push_back({ nums[0] });
if (size == 1) return output;
for (int i = 1; i < size; ++i) {
int j = pow(2, i);
int m = nums[i];
while (j--) {
auto output_beg = output.begin();
vector<int> q = *output_beg;
output.erase(output_beg);
output.push_back(q);
vector<int> q2(q);
q2.push_back(m);
output.push_back(q2);
}
}
return output;
}
int main() {
vector<int> nums[] = {1, 2, 3};
auto output = subsets(nums);
for (auto list : output) {
std::cout << "{";
for (auto beg = list.begin(); beg != list.end(); ++beg) {
std::cout << *beg;
if (beg + 1 != list.end()) {
cout << ",";
}
}
std::cout << "}" << std::endl;
}
}
迭代加深
- 第二个迭代加深的方法,是官网上的解法; 其后面的本质也是类似上述的二叉树, 但在循环中迭代加深的实现比较简洁。关键点还是需要能够从正面想到此题的解法;
vector<vector<int> subsets(int nums[]) {
vector<vector<int>> output;
vector<int> empty = {};
output.push_back({});
for (auto num : nums) {
vector<vector<int>> layer;
for (auto it : output) {
vector<int> v(it);
v.push_back(num);
layer.push_back(v);
}
for (auto it : layer) {
output.push_back(it);
}
}
return output;
}
回溯法
- 回溯法参考: 回溯算法/DFS深度优先搜索(递归与非递归实现)
递归版本
求子集可以映射为用回溯法访问隐式图。robot是递归函数, 每选择一个子集A的元素, 隐式图状态切换。对于求势为k的子集的递归过程是如下过程:
// 势为k的子集个数
//
robot(i):
如果栈内的长度等于k, 记录
对A的索引i循环 0 -> n
- 当前A[i]入栈;
- robot(i+1); // 相当于针对i求所有的元素组合可能
- 回溯 A[i]弹栈, 尝试下一个索引;
下面为上述递归实现的版本。 官网上没有剪枝,应该在记录子集的位置return去冗余;
#include <iostream>
#include <vector>
class Solution {
public:
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> output;
unsigned int n = nums.size();
for (int k = 0; k <= n; ++k) {
robot(0, k, new vector<int>(), output, nums);
}
return output;
}
void robot(int first, int k,
vector<int>* curr,
vector<vector<int>>& output, vector<int>& nums) {
if (curr->size() == k) {
output.push_back(*curr);
return; // 官网上没有这个return, 应该return, 剪枝掉了很多不必要的状态;
}
for (int i = first; i < nums.size(); ++i) {
curr->push_back(nums[i]);
robot(i + 1, k, curr, output, nums);
curr->pop_back();
}
}
};
非递归版本
来到了前几天纠结的部分 - 回溯法的非递归实现; 如果不清晰理解递归的版本, 就无法写出非递归版本.同时,从代码实现也可以看到, 基于思路方案的不同, 实现的代码,用到的辅助的技巧与数据结构, 真的是大相径庭。 可以看到, 迭代加深的第一个方法用到了BFS思路和队列的辅助, 本题回溯法是DFS的思想, 非递归实现用手工栈辅助。所以,解题方案的思路及其正确性清晰性,才是最重要的东西;
此题, 基于回溯法的递归的执行过程, 实际上是一个如下图的多叉树:
用栈的状态代表这个递归过程的多叉树的robot函数的每次执行. 注意:
- 需要生成一个fake_root入栈元素,来保证栈的当前元素为1时,也需要弹栈,并尝试对下一个索引的集合元素入栈再去执行后续的尝试,所以在最外围
while (stack.size())
的框架下, 需要生成一个fake_root的节点; - 非递归的访问这个多叉树的过程是个类似中序或后序周游二叉树的非递归实现的过程,
-
- while 循环, 向左走, 增加索引指针, 直到stack.size()等于k + 1, 记录一个解, 弹栈一次, 继续从索引指针的当前位置压栈, 重复上述过程, 直到索引指针大于集合的势n;
-
- 弹栈一次, 返回上一层, 从弹出的node结构中取得上层的索引指针位置,增加索引指针; 重复走1;
-
向左走k步得到一个解类似非递归后序二叉树的向左走直到叶子节点, 然后此题需要依次访问同层的叶子节点, 通过更新索引指针; 但当回溯到上一层时,需要根据上一层的进行到的索引位置回复现场,从哪里开始。注意在递归的解法的函数里, 形参是索引index, 意味着在每一层的函数执行里, 是包含有这个索引信息的,当递归的返回阶段,这个信息在上一层的递归函数里是可见的。所以这个信息应该被加到入栈的元素里,只有这样在弹栈时才能重新获得,继续后面的迭代执行。
所以可见, 在用栈辅助数据结构时, 需要问问自己
- 栈中元素的结构是什么?
- 入栈出栈的时机是什么?
#include <iostream>
#include <vector>
struct Node {
int value;
int idx;
Node(int v, int i): value(v), idx(i) {}
};
class Solution {
public:
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> output;
unsigned int n = nums.size();
// empty set;
output.push_back(vector<int>());
for (int k = 1; k <= n; ++k) {
robot(k, output, nums);
}
return output;
}
void robot(int k,
vector<vector<int>>& output, vector<int>& nums) {
vector<Node*> stack;
unsigned int n = nums.size();
unsigned int j = 0;
// fake root node
stack.push_back(new Node(INT_MAX, -1));
while (stack.size()) {
while (j < n) {
int h;
while ((h = stack.size()) != k + 1 && j < n) {
stack.push_back(new Node(nums[j], j));
j++;
}
if (h == k + 1) {
vector<int> record;
for (auto beg = stack.begin() + 1; beg != stack.end(); ++beg) {
record.push_back((*beg)->value);
}
output.push_back(record);
stack.pop_back();
}
}
if (stack.size()) {
j = (stack.back())->idx;
stack.pop_back();
}
j++;
}
}
};
基于位操作
基于位操作这个是knuth的思路的版本,也是针对求子集比较直观的。
从整数0 - 2^n -1的范围内, 每个数的二进制形式对应一个子集,其排列组合的所有可能正好对应了所有子集的可能。因此在一个0 - 2^1的loop内就可以搞定;
有个技巧需要留意, 即当把某个数直接用ToBinary()的函数转成string时, 很小的数不会是n位的,这样达不到记录n位情况的目的; 使用了一个技巧用值2^n的数和每个索引的数值bit or, 再对转换得到的string取substr即可;
#include <cmath>
#include <iostream>
#include <vector>
using namespace std;
std::string ToBinary(int n) {
std::string r;
// int m = n;
while (n != 0) {
std::string t;
t = (n % 2 == 0 ? "0" : "1") + r;
r = t;
n /= 2;
}
// cout << "n=" << m << ", s=" << r << ", s.size=" << r.size() << endl;
return r;
}
std::string ToBinaryString(int n, int limit) {
int m = n | limit;
std::string s = ToBinary(m);
return s.substr(1);
}
void robot(vector<vector<int>>& output, vector<int>& nums) {
unsigned int n = nums.size();
int m = pow(2, n);
for (int i = 0; i < m;++i) {
std::string s = ToBinaryString(i, m);
// cout << "i=" << i << ", s=" << s << endl;
vector<int> record;
for (unsigned int j = s.size(); j > 0; --j) {
char c = s[j - 1];
// cout << "c=" << c << ", j=" << j << endl;
if (c == '1')
record.push_back(nums[j - 1]);
}
output.push_back(record);
}
}
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> output;
robot(output, nums);
return output;
}
int main() {
vector<int> nums{1, 2, 3, 4};
vector<vector<int>> output = subsets(nums);
for (auto a : output) {
cout << "[";
for (auto beg = a.begin(); beg != a.end(); beg++) {
cout << *beg;
if (beg + 1 == a.end()) {
cout << "";
} else {
cout << ",";
}
}
cout << "]" << endl;
}
}
以上方法在leetcode都提交通过。
此题, 动态规划思路的方法我就不再写了。