【图解算法】二叉树

289 阅读9分钟

二叉树相比链表难度更高些,面试中也是常客,尤其非递归遍历在一面中很常见。

问题描述

  1. 二叉树的前中后序遍历(含递归和非递归)。
  2. 找二叉树的后继结点。
  3. 二叉树的序列化和反序列化。
  4. 判断一棵二叉树是否是平衡二叉树。
  5. 判断一棵二叉树是否是搜索二叉树。
  6. 已知一棵完全二叉树,求其节点的个数。要求:时间复杂度低于O(N),N为这棵树的节点个数。

二叉树的前中后序遍历

二叉树的前中后序遍历是二叉树结构的最基本算法,采用递归的写法可以快速,简洁地实现该功能,但同时,由于递归方法过于简单,面试中往往会考察非递归版本

我们先来看下二叉树节点的结构:

struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;

    TreeNode() : left(nullptr), right(nullptr) {}
};

我们可以先来看下二叉树各种遍历顺序:

其实很好记,就是中间节点在最前面、中间和最后面输出,而左右的相对顺序是固定的。

二叉树遍历顺序

我们来看个图,可能会更加直观一些:

先序遍历顺序:

先序遍历

中序遍历顺序:

中序遍历

后序遍历顺序:

后序遍历

递归版本代码实现:

先序遍历:

void preOrder(TreeNode *root) {
    if (root == nullptr)
        return;
    cout << root->val << " "; // 输出控制
    preOrder(root->left);
    preOrder(root->right);
}

而中序遍历和后序遍历则只需修改输出控制的位置:

中序遍历:

void inOrder(TreeNode *root) {
    if (root == nullptr)
        return;
    inOrder(root->left);
    cout << root->val << " ";
    inOrder(root->right);
}

后序遍历:

void postOrder(TreeNode *root) {
    if (root == nullptr)
        return;
    postOrder(root->left);
    postOrder(root->right);
    cout << root->val << " ";
}

非递归版本

对于非递归版本,我们要熟悉栈的特性,在把递归函数转化为非递归函数的过程中,如何把握压栈弹栈的时机就很关键。

先序遍历:

如下图所示,蓝色代表入栈,红色代表出栈并且输出;

一开始先把根节点压栈,每次取栈顶元素的同时输出该元素,然后把栈顶元素的右孩子、左孩子分别入栈(如果有的话,为空则不用);直到栈为空则停止。

非递归先序遍历

代码如下:

void preOrderByStack(TreeNode *root) {
    if (root == nullptr)	// 边界判断
        return;
    stack<TreeNode *> nodeStack;
    nodeStack.push(root);
    while (!nodeStack.empty()) {
        TreeNode *top = nodeStack.top();	// 输出当前栈顶元素
        cout << top->val << " ";
        nodeStack.pop();
        if (top->right != nullptr) // 先压入右孩子
            nodeStack.push(top->right);
        if (top->left != nullptr)	// 其次压入左孩子
            nodeStack.push(top->left);
    }
		cout << endl;
}

后序遍历:

这里为什么不先讲中序遍历呢?因为后序遍历有一种非常trick的做法,我们知道先序遍历为中左右,而后序遍历为左右中,我们把后序遍历反过来,就是中右左,是不是发现和先序遍历有点像了?我们先序遍历采用了先压入右孩子再压入左孩子的方式得到了中左右的顺序,那么我们只要先压入左孩子,再压入右孩子,就能得到中右左的顺序,再用一个栈将这个结果逆序一下,就变成了我们想要的后序遍历了:左右中

void postOrderByStack(TreeNode *root) {
    if (root == nullptr)
        return;
    stack<TreeNode *> nodeStack;
    stack<TreeNode *> result;	// 使用一个栈将结果逆序
    nodeStack.push(root);
    while (!nodeStack.empty()) {
        root = nodeStack.top();
        nodeStack.pop();
        result.push(root); // 原本输出的地方先存到栈中

        if (root->left != nullptr) {
            nodeStack.push(root->left);
        }
        if (root->right != nullptr) {
            nodeStack.push(root->right);
        }
    }

    while (!result.empty()) { // 不断弹栈
        TreeNode * res = result.top();
        result.pop();
        cout << res->val << " ";
    }
    cout << endl;
}

中序遍历:

我们知道中序遍历的顺序是左中右,我们通过前面递归版本的情况可以了解到,对于某个节点来说,如果其左孩子存在,那么我们就得先打印其左孩子,反映到代码中,我们的当前节点只要有左孩子,就将其左孩子压栈,并且当前节点向其左孩子方向移动,直到当前节点为空,说明此时位于最左下方的节点的空左孩子处,那么接下来我们就需要弹栈获取栈顶,输出元素,然后移动到栈顶节点的右孩子处,结合下图理解过程:

非递归中序遍历

我们再结合代码看看:

void inOrderByStack(TreeNode *root) {
    stack<TreeNode *> nodeStack;
    while (!nodeStack.empty() || (root != nullptr)) {
        if (root != nullptr) {	// 当前节点非空,压栈后向左移动
            nodeStack.push(root);
            root = root->left;
        } else { // 当前节点为空,弹栈输出后向右移动
            root = nodeStack.top();
            nodeStack.pop();
            cout << root->val << " ";
            root = root->right;
        }
    }
    cout << endl;
}

找二叉树的后继节点

这道题会使用一个带父指针的二叉树结构:

struct TreeNode {
    int val;
    TreeNode *parent; // 新增父亲指针
    TreeNode *left;
    TreeNode *right;

    TreeNode() : parent(nullptr), left(nullptr), right(nullptr) {}
};

首先要理解概念,所谓的后继节点,是指当前节点在中序遍历中的下一个节点,前驱节点也类似,是当前节点在中序遍历中的前一个节点。

有同学可能会说,这很简单啊,我先一直向上拿到根节点,再跑一次中序遍历,要拿到这个节点的后继节点岂不是轻而易举?确实,但是这样效率会很低,我们需要的是你只根据这个节点就能快速拿到后继节点。

这要怎么做呢?

我们首先来分析下有哪几种情况:

二叉树后继节点

TreeNode *findNextNode(TreeNode *node) {
  	if (node == nullptr) return nullptr;
    if (node->right != nullptr) { // 右孩子非空,返回右子树的最左孩子
        TreeNode *next = node->right;
        while (next->left != nullptr) {
            next = next->left;
        }
        return next;
    } else { // 右孩子为空,则返回第一个在节点右边的祖先节点
        TreeNode *parent = node->parent;
        while (parent != nullptr && parent->left != node) {
            node = parent;
            parent = node->parent;
        }
        return parent;
    }
}

同样的,找前驱节点也是类似,如果左孩子非空,那么找左子树中最右的节点,如果左孩子为空,则找祖先节点中第一个在其左边的节点。

二叉树的序列化和反序列化

实际上和遍历差不多,我们以递归版的先序遍历为例子:

这里我们把null节点用#表示,每个节点之间用_分隔。

string serializePre(TreeNode *root) {
    if (root == nullptr) return "#_";
    string val = to_string(root->val) + "_";
    val += serializePre(root->left);
    val += serializePre(root->right);
    return val;
}

反序列化:

TreeNode *deserializePre(string str) {
    queue<string> nodeQueue;
    // 此部分将字符串根据 _ 切割,然后放入队列中
    char *temp = strtok(str2char(str), "_");
    while (temp != nullptr) {
        nodeQueue.push(temp);
        temp = strtok(NULL, "_");
    }
    return reconPreNode(nodeQueue); // 递归反序列化
}

这里我在leetcode中发现使用strtok函数可能导致heap-buffer-overflow的问题,因此这里提供另一种虽然看似不够strtok优雅,但可以避免出错的方法:

TreeNode *deserialize(string str) {
    queue<string> nodeQueue;
    // 此部分将字符串根据 _ 切割,然后放入队列中
    string temp;
    while (str.length() > 0) {
        temp = str.substr(0, str.find('_'));
        nodeQueue.push(temp);
        str = str.substr(str.find('_') + 1, str.length()); // 左开右闭区间
    }
    return reconPreNode(nodeQueue); // 递归反序列化
}

这里用到了一个辅助函数,把 string 转为 char*:

char *str2char(string &str) {
    char *data;
    data = (char *) malloc((str.length() + 1) * sizeof(char));
    str.copy(data, str.length(), 0);
    return data;
}

先序遍历反序列化:

TreeNode *reconPreNode(queue<string> &nodeQueue) {
    string str = nodeQueue.front();
    nodeQueue.pop();
    if (strcmp(str.c_str(), "#") == 0) { // 当前字符串为#,说明为空节点
        return nullptr;
    }
    TreeNode *node = new TreeNode(stoi(str));
    node->left = reconPreNode(nodeQueue);
    node->right = reconPreNode(nodeQueue);
    return node;
}

使用层序遍历遍历的话就是一个广度优先搜索,比较简单,这里就不啰嗦了。

判断一棵二叉树是否是平衡二叉树

首先我们需要理解平衡二叉树的概念:对于树中的任意节点,其左子树与右子树的高度差小于或等于1。

用图说话:

平衡二叉树和不平衡二叉树

可以看到,左边为平衡二叉树,不论是1,还是2,其左右子树高度差都不超过1,但是右边的二叉树在以节点2时就不平衡了,节点2的左子树高度为0,右子树高度为2,高度差为2,因此整棵二叉树就不是平衡二叉树;尽管我们看到节点1时左子树高度为3,右子树高度为2,高度差为1是平衡的,但因为前面节点2已经不平衡了,不满足任意节点的要求,因此右边的二叉树为不平衡二叉树。

当我们遇到二叉树相关的题目时,可以优先考虑递归解法,很多题目都可以用递归的方式快速解决,我们需要的,就是做好递归的边界判断:

bool isBalanceTree(TreeNode *root) {
  if (!isBalanceTree(root->left) || !isBalanceTree(root->right))
    return false;
  int leftHeight = getHeight(root->left);
  int rightHeight = getHeight(root->right);
  if (abs(leftHeight - rightHeight) < 2)
    return true;
  else
    return false;
}

注意,上面的代码是我看到题目后不假思索写下的伪代码,这个思路是很容易想到的,但是同时,有很多点没有考虑好。

接下来我们进行修改,既然是递归函数,我们就应该有边界,什么时候返回,什么时候退出:

if (root == nullptr) return true; // 判断边界

接下来,我们还可以想到,获取高度是需要递归的,而判断是否平衡二叉树也是需要递归的,那么我们能否将它们融合在一起呢?既然想尝试融合,那么就得修改返回值,我们用-1来表示不平衡:

int isBalanceTree(TreeNode *root) {
	if (root == nullptr) return 0; // 判断边界  
  if (isBalanceTree(root->left) == -1 || isBalanceTree(root->right) == -1)
    return -1;
  int leftHeight = isBalanceTree(root->left);
  int rightHeight = isBalanceTree(root->right);
  if (abs(leftHeight - rightHeight) < 2)
    return max(leftHeight, rightHeight);
  else
    return -1;
}

这样虽然能实现功能了,但对调用方而言不是很清晰,所以我们需要再封装一下:

int getHeight(TreeNode *root) {
    if (root == nullptr) return 0; // 判断边界
    int leftHeight = getHeight(root->left);
    if (leftHeight == -1) return -1;	// 将合并的判断拆分,减少部分无用的递归
    int rightHeight = getHeight(root->right);
    if (rightHeight == -1) return -1;
    return (abs(leftHeight - rightHeight) < 2) ? max(leftHeight, rightHeight) : -1;
}

bool isBalanceTree(TreeNode *root) {
    return getHeight(root) != -1; // 只要不等于 -1,则说明为平衡二叉树
}

判断一棵二叉树是否是二叉搜索树

首先我们需要理解一下二叉搜索树:对于树中的任意节点,左孩子小于当前节点,当前节点小于右孩子。(此处暂不考虑重复值的节点)换句话说,当我们使用中序遍历输出这棵二叉树时,得到的序列为单调递增序列。

我们直观地看图:

平衡二叉树与两种非平衡二叉树

左边为一棵平衡二叉树的例子,右边则是两种不平衡的情况。

接下来我们就要思考解决思路,最简单的方法,首先遍历一遍二叉树得到序列,然后再遍历一遍序列验证是否单调递增。

敏锐的同学可能会发现了,这里验证序列的过程和遍历二叉树的过程实际上是可以合二为一的。

实际上,我们只需再前面所说的中序遍历的基础上修改两个地方即可:

bool isSearchTree(TreeNode *root) {
    stack<TreeNode *> nodeStack;
    int pre = INT32_MIN; // pre 初值赋为最小值
    while (!nodeStack.empty() || root != nullptr) {
        if (root != nullptr) {
            nodeStack.push(root);
            root = root->left;
        } else {
            root = nodeStack.top();
            nodeStack.pop();
            if (pre > root->val) { // 原本输出的 root->val 的地方改为判断是否递增
                return false;
            } else {
                pre = root->val;
            }
            root = root->right;
        }
    }
    return true;
}

判断一棵二叉树是否为完全二叉树

首先我们简单理解下完全二叉树:从上到下,从左到右的层序遍历中,每个位置的序号与其节点的编号对应,n个节点正好对应到编号n。

我们画个图来加深理解:

完全二叉树与非完全二叉树

再直观点说,每一层, 前面只要有空节点,那么后面以及下面的层中,就不能有节点了,这样才能满足完全二叉树的条件。

那么我们怎么判断一棵树是否完全二叉树呢?

首先我们使用层序遍历来遍历每一个节点,对于每一个节点,都只有下面四种情况:

每个节点可能存在的四种情况

很显然,如果当前节点有右孩子而没有左孩子,那么这棵树肯定不是完全二叉树;而当该节点有左孩子没有右孩子,或者没有孩子的时候,接下来的节点都应该没有孩子节点。

我们先实现一个层序遍历:

void levelOrder(TreeNode *root) {
    if (root == nullptr) return false;
    queue<TreeNode *> nodeQueue;
    nodeQueue.push(root);
    while (!nodeQueue.empty()) {
        root = nodeQueue.front();
        cout << root->val << " ";
        nodeQueue.pop();
        if (root->left != nullptr)
            nodeQueue.push(root->left);
        if (root->right != nullptr)
            nodeQueue.push(root->right);
    }
}

接下来,我们需要做几处修改,首先是用一个变量flag记录状态,接下来是把输出值的地方修改为判断节点的类型:

bool isCompleteTree(TreeNode *root) {
    if (root == nullptr) return false;
    queue<TreeNode *> nodeQueue;
    bool flag = false;
    nodeQueue.push(root);
    while (!nodeQueue.empty()) {
        root = nodeQueue.front();
      	nodeQueue.pop();
      	if (flag) { // 说明前面已经开启了叶子节点模式,接下来的节点必须均为叶子节点
            if (root->left != nullptr || root->right != nullptr)
            	return false;
        }
      	// 左孩子为空,右孩子非空,直接返回 false
        if (root->left == nullptr && root->right != nullptr)
            return false;
      	// 左孩子非空,右孩子为空,说明接下来的节点均需为叶子节点
        if (root->left != nullptr && root->right == nullptr)
            flag = true;
      	// 该节点为叶子节点,说明接下来的节点均需为叶子节点
        if (root->left == nullptr && root->right == nullptr)
            flag = true;
        if (root->left != nullptr)
            nodeQueue.push(root->left);
        if (root->right != nullptr)
            nodeQueue.push(root->right);
    }
    return true;
}

显然,上面的if显得很冗余,我们可以进行简化:

if (root->left != nullptr && root->right == nullptr)
  flag = true;
if (root->left == nullptr && root->right == nullptr)
  flag = true;

这两行的判断中,实际上root->left的判断已经不重要了 ,只要root->right为空,那么flag就应该置为true,结合右非空就push到队列中的判断,可以得到:

 if (root->right != nullptr) {
   nodeQueue.push(root->right);
 } else {
   flag = true;
 }

此外,下面两个判断的处理是一致的,都是return false

if (flag) {
  if (root->left != nullptr || root->right != nullptr)
    return false;
}
if (root->left == nullptr && root->right != nullptr)
  return false;

我们也可以将它们合并到一起:

if ((flag && (root->left || root->right)) || (!root->left && root->right) )
  return false;

整理得到:

bool isCompleteTree(TreeNode *root) {
    if (root == nullptr) return false;
    queue<TreeNode *> nodeQueue;
    bool flag = false;
    nodeQueue.push(root);
    while (!nodeQueue.empty()) {
        root = nodeQueue.front();
      	nodeQueue.pop();
      	if ((flag && (root->left || root->right)) || (!root->left && root->right) )
          return false;
        if (root->left)
          nodeQueue.push(root->left);
        if (root->right) {
          nodeQueue.push(root->right);
        } else {
          flag = true;
        }
    }
    return true;
}

求完全二叉树节点个数

【题目】已知一棵完全二叉树,求其节点的个数。

【要求】时间复杂度低于O(N),N为这棵树的节点个数。

【解析】题目要求我们的时间复杂度低于O(N),说明我们不能使用遍历的方式去求,必须在某些地方做优化。

这里就涉及了满二叉树的一些特性了。

我们先来说下满二叉树:叶子节点只存在于最下一层。假设层数为N,其节点个数为2^N-1

画个图就明白了:

满二叉树

接下来我们来看看如何求解:

如下图所示,我们首先看图的左边,我们用一个变量来记录整棵二叉树的总高度totalHeight(只需一直向左孩子遍历),然后求右子树的总高度rightHeight,如果totalHeight == rightHeight + 1,说明右子树也到达了最后一层,即左子树是满二叉树。那么左子树的节点个数就为2 ^ rightHeight - 1,右子树的节点个数只需递归求解即可。

那么如果右子树没到达最后一层呢?我们看图的右边,因为右子树没到达最后一层,所以右子树也是一棵满二叉树,那么右子树的节点个数2 ^ rightHeigh - 1,左子树的节点个数只需递归求解。

递归求解

理解了上面的过程,接下来写代码就很容易了:

int getHeight(TreeNode *root) { // 获取完全二叉树高度
    int height = 0;
    while (root != nullptr) {
        root = root->left;
        height++;
    }
    return height;
}

int countCBT(TreeNode *root) { // 计算完全二叉树节点个数
    if (root == nullptr) return 0;
    int totalHeight = getHeight(root);
    int rightHeight = getHeight(root->right);
    if (totalHeight == rightHeight + 1) { // 左子树为满二叉树
        return (1 << rightHeight) + countCBT(root->right);
    } else { // 右子树为满二叉树
        return countCBT(root->left) + (1 << rightHeight);
    }
}