基础概念:
树
先从最基础的树说起,本身是一种层次结构的抽象数据类型,用于表示元素之间的层级关系。树结构由节点(Node)和边(Edge)组成
- 根节点(Root Node):树的顶端节点,只有一个根节点。根节点没有父节点。
- 子节点(Child Node):根节点以下的节点。每个节点都可以有多个子节点。
- 父节点(Parent Node):与子节点相连的节点。如果一个节点B是节点A的子节点,那么A就是B的父节点。
- 叶子节点(Leaf Node):没有子节点的节点,称为叶子节点。
- 内部节点(Internal Node):除根节点和叶子节点之外的节点称为内部节点。
特点
-
深度(Depth):从根节点到某个节点的路径长度(这里计量的是线段数,比如线性的三个点,从第一个点到第三个点,深度为2),根节点的深度为0。
- 高度(Height):从某个节点到叶子节点的最长路径称为该节点的高度,叶子节点的高度为0。
- 度(Degree):节点拥有的子节点的个数,树的度是所有节点中最大的度。
稍微过一下就好,之后用起来就会很熟练了
二叉树
在学习前面的内容(栈,队列)后,我在看到二叉树之后自然而然的产生了一项疑惑——二叉树也是一种容器适配器吗?
实际上这是错误的认知,容器适配器依赖于底层的顺序容器(如vector
、deque
或list
),而二叉树底层是用链式结构来解决问题。
简单的底层实现如下:一个元素+两个分别指向左右节点的指针。
struct TreeNode{
int val;
TreeNode* left;
TreeNode* right;
TreeNode(int x, TreeNode* leftChild, TreeNode* rightChild) : val(x), left(leftChild), right(rightChild) {
};
};
多种遍历方式
学习参考:代码随想录 (programmercarl.com)
面向习题:
可以使用不同遍历方式来完成上述三道习题
递归遍历:
递归通常用于将问题分解成更小的子问题,直到问题足够小可以直接解决。每个递归函数都在处理一部分问题,最终通过合并子问题的解,得到整个问题的解。
关于递归,我们在写代码前要抓住三点来思考:什么是一个规模足够小的单次循环,我要在这个单次循环里处理哪些内容(应该传入哪些元素),我要在什么时候中止这个循环。
递归调用的方向:在每次递归中,将当前状态传入下一次递归调用,并确保递归的规模逐步缩小,最终达到基准条件。
递归体的处理对象:当前递归调用中处理哪些内容?应该传入哪些参数来递归调用下一步?通常这些参数包括当前节点、或问题的当前状态——在二叉树中,我要处理的参数即为——当前指针,原来接收当前指针指向数据的动态数组(问题需要的即为该数组);
递归终止条件:什么时候停止递归(基准条件)?这是递归停止的判断标准——当当前指针指向NULL时,此为结束条件,结束循环。
void travelsal(TreeNode* cur, vector<int>& vec) {
if (cur == NULL) {
return;
}
vec.push_back(cur->val);//前序遍历,之后的中序/后序都是调整下面三行的位置
travelsal(cur->left, vec);
travelsal(cur->right, vec);
}
完整代码:
class Solution {
public:
void travelsal(TreeNode* cur, vector<int>& vec) {
if (cur == NULL) {
return;
}
vec.push_back(cur->val);//前序遍历,之后的中序/后序都是调整下面三行的位置
travelsal(cur->left, vec);
travelsal(cur->right, vec);
}
vector<int> preorderTraversal(TreeNode* root) {
vector<int> result;
travelsal(root, result);
return result;
}
};
迭代遍历:
递归本质上依赖系统调用栈来管理函数调用,而迭代则需要我们手动管理这种栈行为。
迭代的三大要素:
- 手动管理的栈(或队列):用于保存当前的计算状态或节点位置。
- 循环控制:用
while
或for
循环来实现不断推进的逻辑,直到处理完所有节点。 - 当前状态更新:每次循环迭代都要明确下一步的操作,例如是移动到下一个节点,还是从栈中取出一个节点继续处理。
值得注意的是——迭代遍历的前后序的写法与中序的写法略有不同,此时先给出前序遍历的方式。
1)创建所需容器
stack<TreeNode*> stc;
//显式调用栈,用于保存与弹出
vector<int> vec;
//用于接收弹出的数据
2)最小循环
一开始找树,找到树后确定位置——开始循环
vector<int> preOrderTraversal(treeNode* cur){
if(cur == NULL){
return result;
}
//此处一开始传入的是树的根节点,就跟链条的头结点一样,要是一开始就没有,那自然是返回空的内容
stc.push(cur);//代表找到了树的位置
while(!stc.empty()){
treeNode* node = stc.top();
stc.pop();
result.push_back(node->val)
//上面这一段是弹出与接收,后面的则是左右节点的压入
if(node->right){
stc.push(node->right);
}
if(node->left){
stc.push(node->left);
}
}
//循环结束,此时可返回结果
return result;
}
完整实现如下
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
stack<TreeNode*> stc;
vector<int> result;
if (root == NULL) return result;
stc.push(root);
while (!stc.empty()) {
TreeNode* node = stc.top(); // 中
stc.pop();
result.push_back(node->val);
if (node->right) stc.push(node->right); // 右(空节点不入栈)
if (node->left) stc.push(node->left); // 左(空节点不入栈)
}
return result;
}
};
中序遍历:
我们要先访问的是二叉树顶部的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(也就是在把节点的数值放进result数组中),这就造成了处理顺序和访问顺序是不一致的。
学习参考:代码随想录 (programmercarl.com)
关键点是在while循环中加一个逻辑判断:
while (cur != NULL || !stc.empty()) {
if (cur != NULL) { // 指针来访问节点,访问到最底层
stc.push(cur); // 将访问的节点放进栈
cur = cur->left; // 左
} else {
cur = stc.top(); // 从栈里弹出的数据,就是要处理的数据(放进result数组里的数据)
stc.pop();
result.push_back(cur->val); // 中
cur = cur->right; // 右
}
}
return result;
}
二者区别
这里大家可能跟我一样有疑惑——递归和迭代看起来不都是执行一个最小单元的内容吗,他们之间的区别到底在哪呢?
-
递归和迭代的确在不断重复处理一个最小的基本单元,它们都通过这种重复来解决问题。你可以将递归看作是系统调用栈自动管理的“循环”,而迭代是通过显式的循环结构来达到类似的效果。
-
主要区别在于状态管理和控制方式:
- 递归使用系统调用栈来管理状态和控制递归调用。——状态变化依赖函数调用的自我嵌套。每次函数调用都会创建一个新的“上下文”(局部变量、函数参数等),而这些状态通过递归的深度来进行管理。每个函数调用都会自动保存上一次调用的状态。
-
迭代则通过手动管理状态,使用显式循环控制整个流程。——程序员需要显式管理状态,通过变量、栈、队列或数组来存储当前的状态。比如在二叉树的迭代遍历中,程序员需要手动使用栈来保存当前节点及其子节点。
我们可以可以把递归看成是一种“隐式的循环”,而迭代是一种“显式的循环”。掌握这两者的关键在于理解如何管理当前状态,以及如何通过“不断缩小问题规模”来逐步解决问题。