二叉搜索树(BST)

二叉搜索树(Binary Search Tree, BST),也称为二叉排序树,是一种重要的数据结构。它将树形结构的灵活性与有序性结合起来,使得查找、插入和删除等操作的平均时间复杂度都能达到 O(logN)。

二分搜索算法,其底层逻辑恰好对应在一棵隐形的二叉搜索树上的查找过程。例如,对有序数组 [0, 5, 24, 34, 41, 58, 62, 64, 67, 69, 78] 进行二分搜索,其过程完全可以可视化为在一棵以 58(中间值)为根的BST上进行查找。

二叉搜索树(BST)_第1张图片

一、核心理论

1. 基本术语

基本概念:

  • 节点(Node):树的基本组成单位。
  • 根节点(Root):树顶端的节点,没有父节点。
  • 父节点(Parent):一个节点的上级节点。
  • 子节点(Child):一个节点的下级节点(分为左孩子和右孩子)。
  • 叶子节点(Leaf):没有子节点的节点。
  • 子树(Subtree):以某个节点为根的树(分为左子树和右子树)。
  • 兄弟节点(Sibling):拥有相同父节点的节点。
  • 深度/高度(Depth/Height):从根节点到最远叶子节点的路径长度。

2. BST的核心性质

一棵二叉树要成为BST,必须满足以下条件:

对于树中的任意一个节点,其左子树中所有节点的值都小于该节点的值,其右子树中所有节点的值都大于该节点的值。

这个性质保证了树的有序性。对一棵BST进行中序遍历,可以得到一个严格递增的有序序列。

3. 树的高度与节点数

对于一棵理想的平衡二叉树,其层数(高度)L 和节点总数 N 之间存在如下关系:

N=20+21+22+⋯+2(L−1)=N

2^L - 1 = N

2^L = N+1

由此可得:

L≈log(N+1)

这就是为什么BST相关操作的时间复杂度是 O(logN) 的原因——操作的路径长度基本不超过树的高度。

二、BST树的相关操作

节点结构:

#include 
using namespace std;

// 定义树的节点
struct Node {
    int data;
    Node* left;
    Node* right;

    // 构造函数
    Node(int val) : data(val), left(nullptr), right(nullptr) {}
};

1. 插入操作

非递归插入 n_insert

逻辑

  1. 如果树为空(rootnullptr),新节点就是根节点。
  2. 否则,从根节点开始,像查询一样向下遍历。
  3. 用一个parent指针记录当前节点的父节点。
  4. 当找到nullptr位置时,该位置就是新节点的插入点。根据新值与parent值的大小关系,将其链接到parentleftright
// 非递归插入操作
void n_insert(Node*& root, int val) {
    if (root == nullptr) {
        root = new Node(val);
        return;
    }

    Node* cur = root;
    Node* parent = nullptr;
    while (cur != nullptr) {
        parent = cur;
        if (val < cur->data) {
            cur = cur->left;
        } else if (val > cur->data) {
            cur = cur->right;
        } else {
            return; // 树中已存在相同的值,不插入
        }
    }

    // 循环结束后,parent指向要插入位置的父节点
    if (val < parent->data) {
        parent->left = new Node(val);
    } else {
        parent->right = new Node(val);
    }
}

递归插入 r_insert

逻辑

  1. 基准情况:如果当前节点为nullptr,说明找到了插入位置,创建新节点并返回。
  2. 递归步骤
    • 如果值小于当前节点,向左子树递归插入,并将返回值(可能是新节点或原始左孩子)赋给node->left
    • 如果值大于当前节点,向右子树递归插入,并将返回值赋给node->right
    • 返回当前节点,以保持树链接的完整性。
// 递归插入操作
Node* r_insert(Node* node, int val) {
    if (node == nullptr) {
        return new Node(val); // 找到插入位置,返回新节点
    }

    if (val < node->data) {
        node->left = r_insert(node->left, val);
    } else if (val > node->data) {
        node->right = r_insert(node->right, val);
    }
    
    // 如果 val == node->data,什么都不做,直接返回原节点
    return node;
}

2. 查询操作

非递归查询 n_query

逻辑:从根节点开始,根据值的大小关系,向左或向右移动,直到找到匹配节点或遇到nullptr

// 非递归查询操作
bool n_query(Node* root, int val) {
    Node* cur = root;
    while (cur != nullptr) {
        if (val == cur->data) {
            return true; // 找到了
        } else if (val < cur->data) {
            cur = cur->left;
        } else {
            cur = cur->right;
        }
    }
    return false; // 遍历结束没找到
}

递归查询 r_query

逻辑

  1. 基准情况:如果当前节点是nullptr,说明没找到;如果当前节点值匹配,说明找到了。
  2. 递归步骤:根据值的大小关系,在左子树或右子树中继续递归查找。
// 递归查询操作
bool r_query(Node* node, int val) {
    if (node == nullptr) {
        return false;
    }
    if (val == node->data) {
        return true;
    } else if (val < node->data) {
        return r_query(node->left, val);
    } else {
        return r_query(node->right, val);
    }
}

3. 删除操作

删除是BST中最复杂的操作。

逻辑
首先找到要删除的节点cur及其父节点parent

  1. 情况一:要删除的节点是叶子节点(没有孩子)
    • 直接将其父节点指向它的指针置为nullptr
  2. 情况二:要删除的节点只有一个孩子
    • 将其父节点指向它的指针,改为指向它的唯一孩子。
  3. 情况三:要删除的节点有两个孩子
    • 需要找到一个节点来“顶替”被删除节点的位置,且不破坏BST的性质。
    • 通常有两种选择:
      • 前驱节点:左子树中值最大的节点。
      • 后继节点:右子树中值最小的节点。
    • 用前驱(或后继)节点的值覆盖掉要删除节点的值。
    • 然后,问题转化为删除这个前驱(或后继)节点。由于前驱(或后继)节点最多只有一个孩子,这个问题就退化成了情况一或情况二。

非递归删除 n_remove

// 非递归删除操作
void n_remove(Node*& root, int val) {
    if (root == nullptr) return;

    Node* cur = root;
    Node* parent = nullptr;

    // 1. 找到要删除的节点(cur)及其父节点(parent)
    while (cur != nullptr && cur->data != val) {
        parent = cur;
        if (val < cur->data) {
            cur = cur->left;
        } else {
            cur = cur->right;
        }
    }

    if (cur == nullptr) return; // 没找到要删除的节点

    // 2. 如果要删除的节点有两个孩子 (情况三)
    if (cur->left != nullptr && cur->right != nullptr) {
        // 找前驱节点(左子树的最大节点)
        Node* pre = cur->left;
        Node* pre_parent = cur;
        while (pre->right != nullptr) {
            pre_parent = pre;
            pre = pre->right;
        }
        // 用前驱节点的值覆盖当前节点
        cur->data = pre->data;
        // 现在问题转化为删除前驱节点pre
        cur = pre;
        parent = pre_parent;
    }

    // 3. 处理情况一和情况二 (cur最多只有一个孩子)
    Node* child = nullptr;
    if (cur->left != nullptr) {
        child = cur->left;
    } else {
        child = cur->right;
    }

    if (parent == nullptr) { // 删除的是根节点
        root = child;
    } else if (parent->left == cur) {
        parent->left = child;
    } else {
        parent->right = child;
    }

    delete cur;
}

递归删除 r_remove

逻辑:函数的返回值是删除操作后子树的新根。

  1. 基准情况:如果nodenullptr,直接返回nullptr
  2. 递归查找:如果val不等于当前节点值,则在左子树或右子树递归删除,并更新node->leftnode->right
  3. 找到节点 (val == node->data):
    • 有两个孩子:找到前驱节点,用其值覆盖当前节点,然后在左子树中递归删除前驱节点。
    • 有一个或零个孩子:记录下孩子节点,删除当前节点,返回孩子节点作为新的子树根。
// 递归删除操作
Node* r_remove(Node* node, int val) {
    if (node == nullptr) {
        return nullptr;
    }

    if (val < node->data) {
        node->left = r_remove(node->left, val);
    } else if (val > node->data) {
        node->right = r_remove(node->right, val);
    } else { // 找到了要删除的节点
        // 情况3: 有两个孩子
        if (node->left != nullptr && node->right != nullptr) {
            Node* pre = node->left;
            while (pre->right != nullptr) {
                pre = pre->right; // 找到前驱节点
            }
            node->data = pre->data; // 用前驱的值覆盖
            node->left = r_remove(node->left, pre->data); // 在左子树中删除前驱
        } else { // 情况1或2: 零个或一个孩子
            Node* temp = node;
            if (node->left != nullptr) {
                node = node->left;
            } else {
                node = node->right;
            }
            delete temp;
        }
    }
    return node;
}

四、树的遍历实现

遍历是访问树中每一个节点一次的过程。L、V、R分别代表遍历左子树、访问当前节点、遍历右子树。

1. 前序遍历 (V -> L -> R)

递归前序 preOrder

// 递归前序遍历
void preOrder(Node* node) {
    if (node != nullptr) {
        std::cout << node->data << " "; // 访问根
        preOrder(node->left);           // 遍历左
        preOrder(node->right);          // 遍历右
    }
}

非递归前序 preOrder

思路
非递归遍历通常需要借助(Stack)来实现深度优先的逻辑。

  1. 将根节点root入栈。

  2. 当栈不为空时,循环执行:
    a. 弹出栈顶节点cur并访问它(打印值)。
    b. 关键:先将cur右孩子入栈(如果存在)。
    c. 再将cur左孩子入栈(如果存在)。

    之所以先压入右孩子,是因为栈是“后进先出”的,这样就能保证左孩子比右孩子先被弹出和访问,从而实现 V -> L -> R 的顺序。

#include  // 需要包含头文件

void n_preOrder(Node* root) {
    if (root == nullptr) {
        return;
    }
    std::stack<Node*> s;
    s.push(root);

    while (!s.empty()) {
        Node* cur = s.top();
        s.pop();
        
        std::cout << cur->data << " "; // V: 访问节点

        // 先将右孩子入栈
        if (cur->right != nullptr) {
            s.push(cur->right);
        }
        // 再将左孩子入栈,保证左孩子先被访问
        if (cur->left != nullptr) {
            s.push(cur->left);
        }
    }
}

2. 中序遍历 (L -> V -> R)

递归中序 inOrder

// 递归中序遍历
void inOrder(Node* node) {
    if (node != nullptr) {
        inOrder(node->left);            // 遍历左
        std::cout << node->data << " "; // 访问根
        inOrder(node->right);           // 遍历右
    }
}

非递归中序 n_inOrder

思路
中序遍历的非递归实现稍微复杂,因为它需要先处理完整个左子树才能访问根节点。

  1. 创建一个栈和一个指向当前节点的指针cur,初始时cur指向根节点。
  2. cur不为空或栈不为空时,循环执行:
    a. 一路向左:只要cur不为空,就将cur入栈,然后更新cur = cur->left
    b. 处理节点:当cur为空时,说明左边走到底了。此时从栈中弹出一个节点,访问它(打印值)。
    c. 转向右侧:访问后,将cur指向该节点的右孩子 (cur = popped_node->right),然后重复步骤a。
#include  // 需要包含头文件

void n_inOrder(Node* root) {
    std::stack<Node*> s;
    Node* cur = root;

    while (cur != nullptr || !s.empty()) {
        // 1. 将当前节点及其所有左孩子一路入栈
        while (cur != nullptr) {
            s.push(cur);
            cur = cur->left; // L
        }

        // 2. 左边到头了,从栈中取出一个节点并访问
        cur = s.top();
        s.pop();
        std::cout << cur->data << " "; // V

        // 3. 转向该节点的右子树,准备处理右子树
        cur = cur->right; // R
    }
}

3. 后序遍历 (L -> R -> V)

递归后续 postOrder

// 递归后序遍历
void postOrder(Node* node) {
    if (node != nullptr) {
        postOrder(node->left);          // 遍历左
        postOrder(node->right);         // 遍历右
        std::cout << node->data << " "; // 访问根
    }
}

非递归后续 n_postOrder

思路
直接实现L -> R -> V比较困难。可以转变思路:

  1. 后序遍历的顺序是 L -> R -> V
  2. 如果我们将这个顺序倒置,就得到 V -> R -> L
  3. V -> R -> L 这个顺序和前序遍历 V -> L -> R 非常相似!我们只需要在遍历孩子时,交换左右顺序即可。
  4. 所以,我们可以先用类似前序遍历的方法得到 V -> R -> L 的序列,将其存入另一个结果栈或vector中,最后再统一逆序输出。
#include 
#include 
#include  // for std::reverse

void n_postOrder(Node* root) {
    if (root == nullptr) {
        return;
    }
    
    std::stack<Node*> s;
    std::vector<int> result; // 用 vector 存储 V -> R -> L 的结果
    s.push(root);

    // 1. 实现 V -> R -> L 的遍历
    while (!s.empty()) {
        Node* cur = s.top();
        s.pop();
        result.push_back(cur->data);

        // 先压入左孩子
        if (cur->left != nullptr) {
            s.push(cur->left);
        }
        // 再压入右孩子,这样右孩子会先被弹出和访问
        if (cur->right != nullptr) {
            s.push(cur->right);
        }
    }

    // 2. 将 V -> R -> L 的结果逆序,得到 L -> R -> V
    std::reverse(result.begin(), result.end());

    // 3. 打印最终结果
    for (int val : result) {
        std::cout << val << " ";
    }
}

4. 层序遍历

层序遍历是一种广度优先搜索(BFS),它从上到下、从左到右逐层访问节点。递归实现稍显取巧,需要先计算树的高度,然后对每一层进行一次遍历。

计算树的高度

// 递归求二叉树高度
int get_height(Node* node) {
    if (node == nullptr) {
        return 0;
    }
    int left_height = get_height(node->left);
    int right_height = get_height(node->right);
    return left_height > right_height ? left_height + 1 : right_height + 1;
}

计算节点总数

// 递归求节点总数
int count_nodes(Node* node) {
    if (node == nullptr) {
        return 0;
    }
    int left = count_nodes(node->left);
    int right = count_nodes(node->right);
    return 1 + left + right;
}

递归层序遍历

// 打印指定层(level)的所有节点
void levelOrder_level(Node* node, int level) {
    if (node == nullptr) {
        return;
    }
    if (level == 0) {
        cout << node->data << " ";
    } else if (level > 0) {
        levelOrder_level(node->left, level - 1);
        levelOrder_level(node->right, level - 1);
    }
}

// 递归层序遍历
void levelOrder(Node* root) {
    int h = get_height(root);
    for (int i = 0; i < h; i++) {
        levelOrder_level(root, i);
    }
}

非递归层序遍历

思路
层序遍历是典型的广度优先搜索(BFS),其最佳实现是使用队列(Queue)。

  1. 创建一个队列,并将根节点root入队。
  2. 当队列不为空时,循环执行:
    a. 从队首取出一个节点cur并出队。
    b. 访问cur(打印值)。
    c. 将cur的左孩子入队(如果存在)。
    d. 将cur的右孩子入队(如果存在)。
#include  // 需要包含头文件

void n_levelOrder(Node* root) {
    if (root == nullptr) {
        return;
    }
    std::queue<Node*> q;
    q.push(root);

    while (!q.empty()) {
        Node* cur = q.front();
        q.pop();

        std::cout << cur->data << " ";

        if (cur->left != nullptr) {
            q.push(cur->left);
        }
        if (cur->right != nullptr) {
            q.push(cur->right);
        }
    }
}

五、测试

int main(){
    int arr[] = {58, 24, 67, 0, 34, 62, 69, 5, 41, 64, 78};
    Node* root = nullptr;

    // 使用递归插入构建BST
    for (int val : arr) {
        root = n_insert(root, val);
    }

    levelOrder(root);
    cout << endl;

    cout << r_query(root, 24) << endl;
    root = r_remove(root, 24);
    cout << r_query(root, 24) << endl;

    return 0;
}

六、BST树相关的问题

1. BST区间元素查找

问题描述
给定一棵二叉搜索树(BST)和一个闭区间 [k1, k2],找出并打印树中所有值在此区间内的节点。

解题思路
最直观的方法是遍历整棵树,然后判断每个节点的值是否在区间内。但这样做完全没有利用BST的有序性,效率不高。

更优的思路是进行一次剪枝中序遍历。我们知道中序遍历(L->V->R)能按升序访问节点。在遍历过程中,可以根据当前节点值与区间的关系,决定是否需要继续搜索子树:

  • 如果当前节点的值 cur->data 小于 k1,那么它的整个左子树的值都将小于k1,因此我们无需遍历左子树,只需继续在右子树中查找。
  • 如果当前节点的值 cur->data 大于 k2,那么它的整个右子树的值都将大于k2,因此我们无需遍历右子树,只需继续在左子树中查找。
  • 如果当前节点的值 cur->data 在区间内 (k1 <= cur->data <= k2),我们就访问(打印)这个节点,然后需要继续在左、右两个子树中查找其他可能符合条件的节点。

代码实现

#include 

// 递归查找在[k1, k2]区间内的所有节点值
void findInRange(Node* node, int k1, int k2, std::vector<int>& result) {
    if (node == nullptr) {
        return;
    }

    // 1. 如果当前节点值 > k1,说明左子树中可能有符合条件的节点
    if (node->data > k1) {
        findInRange(node->left, k1, k2, result);
    }

    // 2. 如果当前节点值在区间内,则记录下来
    if (node->data >= k1 && node->data <= k2) {
        result.push_back(node->data);
    }

    // 3. 如果当前节点值 < k2,说明右子树中可能有符合条件的节点
    if (node->data < k2) {
        findInRange(node->right, k1, k2, result);
    }
}

复杂度分析

  • 时间复杂度:O(H+K),其中 H 是树的高度,K 是位于区间内的节点数量。这远比遍历所有 N 个节点的 O(N) 方案要高效。
  • 空间复杂度:O(H),主要来自递归调用栈的深度。

2. 判断一棵二叉树是否为BST

问题描述
给定一棵二叉树,判断它是否是一棵有效的二叉搜索树。

解题思路
要严格满足BST的定义:

  1. 一个节点的左子树中所有节点的值都小于该节点的值。
  2. 一个节点的右子树中所有节点的值都大于该节点的值。
  3. 左右子树也必须分别是二叉搜索树。

一个常见的错误思路是:仅判断 node->left->data < node->datanode->right->data > node->data。这忽略了整个子树的约束(例如,一个节点的右孩子可能比它的祖先节点还要小)。

正确的解法有两种:

方法一:利用中序遍历的性质 (推荐)
一棵有效的BST,其中序遍历的结果必然是一个严格递增的序列。我们可以利用这个特性。
在进行中序遍历时,我们用一个变量 prev 记录前一个被访问的节点的值。每当访问一个新节点时,就将其值与 prev 比较。如果当前节点值小于或等于 prev,则说明它不是一棵BST。

代码实现 (中序遍历法)

// 辅助函数,prev 必须是引用,以便在递归中共享状态
bool isBST_inorder_util(Node* node, Node*& prev) {
    if (node == nullptr) {
        return true;
    }

    // 1. 检查左子树
    if (!isBST_inorder_util(node->left, prev)) {
        return false;
    }

    // 2. 检查当前节点与前一个节点的关系
    if (prev != nullptr && node->data <= prev->data) {
        return false;
    }
    prev = node; // 更新 prev 为当前节点

    // 3. 检查右子树
    return isBST_inorder_util(node->right, prev);
}

// 主调用函数
bool isBST(Node* root) {
    Node* prev = nullptr;
    return isBST_inorder_util(root, prev);
}

方法二:递归并传递有效范围
我们也可以通过递归来判断。对于每个节点,我们检查它的值是否在一个允许的 (min, max) 范围内。

  • 对于根节点,范围是 (-∞, +∞)
  • 当我们向左递归时,新的范围是 (min, parent->data),即左孩子的值必须小于其父节点。
  • 当我们向右递归时,新的范围是 (parent->data, max),即右孩子的值必须大于其父节点。

代码实现 (范围判断法)

#include  // For LONG_MIN, LONG_MAX

bool isBST_range_util(Node* node, long min_val, long max_val) {
    if (node == nullptr) {
        return true;
    }

    // 检查当前节点值是否在有效范围内
    if (node->data <= min_val || node->data >= max_val) {
        return false;
    }

    // 递归检查左右子树,并更新范围
    // 左子树的上限是当前节点的值
    // 右子树的下限是当前节点的值
    return isBST_range_util(node->left, min_val, node->data) &&
           isBST_range_util(node->right, node->data, max_val);
}

// 主调用函数
bool isBST_range(Node* root) {
    // 使用 long 类型避免节点值恰好是 INT_MIN 或 INT_MAX 的边界问题
    return isBST_range_util(root, LONG_MIN, LONG_MAX);
}

复杂度分析

  • 时间复杂度:O(N),因为两种方法都需要访问每个节点一次。
  • 空间复杂度:O(H),递归栈的深度。

3. 判断子树问题

问题描述
给定两棵二叉树 T1T2,判断 T2 是否是 T1子结构。所谓子结构,是指在 T1 中是否存在一个节点,其下的子树与 T2 有着完全相同的结构和节点值。

解题思路
这个问题可以分解为两个步骤:

  1. 判断两棵树是否相同:我们需要一个辅助函数 isIdentical(node1, node2),用来判断以 node1node2 为根的两棵树是否完全一样。
  2. 遍历T1查找匹配点:我们需要遍历 T1 的每一个节点。对于 T1 中的每一个节点,我们都将其作为根,调用 isIdentical 函数,与 T2 进行比较。如果任何一次比较返回 true,那么 T2 就是 T1 的子结构。

代码实现

// 辅助函数:判断两棵树是否完全相同
bool isIdentical(Node* r1, Node* r2) {
    // 如果两棵树都为空,则它们相同
    if (r1 == nullptr && r2 == nullptr) {
        return true;
    }
    // 如果只有一棵树为空,则它们不同
    if (r1 == nullptr || r2 == nullptr) {
        return false;
    }
    // 如果节点值不同,则它们不同
    if (r1->data != r2->data) {
        return false;
    }
    // 递归地比较左右子树
    return isIdentical(r1->left, r2->left) && isIdentical(r1->right, r2->right);
}

// 主函数:判断 T2 是否是 T1 的子结构
bool isSubstructure(Node* T1, Node* T2) {
    // 空树是任何树的子结构
    if (T2 == nullptr) {
        return true;
    }
    
    if (T1 == nullptr) {
		    return false;
    }

    // 检查以 T1 为根的树是否与 T2 相同
    if (isIdentical(T1, T2)) {
        return true;
    }

    // 如果不相同,则递归地在 T1 的左子树和右子树中寻找 T2
    return isSubstructure(T1->left, T2) || isSubstructure(T1->right, T2);
}

复杂度分析

  • 时间复杂度:O(MtimesN),其中 MT1 的节点数,NT2 的节点数。在最坏情况下,我们需要对 T1 中的每个节点都完整地比较一次 T2
  • 空间复杂度:O(H_1+H_2),取决于两棵树的递归深度。

4. 二叉树的最近公共祖先 (LCA)

求两个节点的最近公共祖先节点

问题描述
给定一棵二叉树和树中的两个节点 pq,找到这两个节点的最近公共祖先(Lowest Common Ancestor, LCA)。

LCA的定义:在一个树 T 中,节点 pq 的最近公共祖先是树 T 中同时拥有 pq 作为后代的最深节点(这里我们允许一个节点是它自己的后代)。

在上图中,节点 0 和 41 的LCA是 24。节点 64 和 78 的LCA是 67。

方法一:针对普通二叉树的递归解法

对于一棵普通的二叉树(不一定是BST),可以使用一种非常优雅的后序遍历递归方法。

解题思路
我们从根节点开始递归,对于任意一个节点 cur

  1. 基准情况:如果 cur 为空,或者 cur 就是 pq 中的一个,那么 cur 就是一个潜在的LCA,我们返回 cur
  2. 递归查找:我们分别在 cur 的左、右子树中递归查找 pq
    • left_lca = lca(cur->left, p, q)
    • right_lca = lca(cur->right, p, q)
  3. 判断结果
    • 如果 left_lcaright_lca 都非空,这意味着 pq 分别位于 cur 的左右两侧,那么 cur 本身就是LCA。
    • 如果只有 left_lca 非空,这意味着 pq 都在左子树中,那么LCA也必然在左子树中,返回 left_lca
    • 如果只有 right_lca 非空,同理,返回 right_lca
    • 如果两者都为空,说明 pq 都不在以 cur 为根的子树中。

代码实现

// 针对普通二叉树的LCA查找
Node* findLCA_for_BinaryTree(Node* root, Node* p, Node* q) {
    // 基准情况:如果树为空,或者找到了p或q,就返回当前节点
    if (root == nullptr || root == p || root == q) {
        return root;
    }

    // 在左右子树中递归查找
    Node* left_lca = findLCA_for_BinaryTree(root->left, p, q);
    Node* right_lca = findLCA_for_BinaryTree(root->right, p, q);

    // 如果p和q分别在左右子树,那么当前root就是LCA
    if (left_lca != nullptr && right_lca != nullptr) {
        return root;
    }

    // 如果p和q都在左子树,则LCA在左子树,返回left_lca
    // 如果都在右子树,则LCA在右子树,返回right_lca
    return (left_lca != nullptr) ? left_lca : right_lca;
}

复杂度分析

  • 时间复杂度:O(N),其中 N 是树的节点总数,因为最坏情况下我们需要访问所有节点。
  • 空间复杂度:O(H),其中 H 是树的高度,主要消耗于递归调用栈。

方法二:针对二叉搜索树(BST)的优化解法

如果给定的树是一棵BST,可以利用其节点值的有序性来大大优化查找过程,将时间复杂度降低到对数级别。

解题思路
从根节点开始遍历,对于当前节点 cur

  • 如果 pq 的值都小于 cur 的值,说明它们的LCA必然在 cur左子树中。
  • 如果 pq 的值都大于 cur 的值,说明它们的LCA必然在 cur右子树中。
  • 如果一个节点的值小于 cur,另一个大于 cur,或者其中一个节点就是 cur,那么当前节点 cur 就是 pq 分叉的地方,即它们的最近公共祖先。

这个过程就像是在树中寻找一个“分叉点”,这个分叉点就是LCA。这个算法可以用迭代实现,非常高效。

代码实现

// 针对BST的LCA查找 (迭代法)
Node* findLCA_for_BST(Node* root, Node* p, Node* q) {
    if (root == nullptr || p == nullptr || q == nullptr) {
        return nullptr;
    }

    Node* cur = root;
    while (cur != nullptr) {
        // 如果p和q都比当前节点小,去左子树找
        if (p->data < cur->data && q->data < cur->data) {
            cur = cur->left;
        } 
        // 如果p和q都比当前节点大,去右子树找
        else if (p->data > cur->data && q->data > cur->data) {
            cur = cur->right;
        } 
        // 否则,当前节点就是分叉点,即LCA
        else {
            return cur;
        }
    }
    return nullptr; // 理论上对于有效的p和q,不会执行到这里
}

复杂度分析

  • 时间复杂度:O(H),其中 H 是树的高度。对于一棵平衡的BST,复杂度为 O(logN)。我们只需要从根节点向下走一条路径即可。
  • 空间复杂度:O(1),因为我们使用的是迭代法,没有额外的空间开销。

5. 二叉树的镜像翻转

问题描述
给定一棵二叉树,将其变换为原树的镜像。即,对于树中的任意一个节点,它的左、右子节点进行互换。

解题思路
这是一个非常适合用递归解决的问题。我们可以采用后序遍历(左右根)的思路,自底向上地进行翻转。

  1. 递归翻转左子树:先将当前节点的左子树完全变成镜像。
  2. 递归翻转右子树:再将当前节点的右子树完全变成镜像。
  3. 交换当前节点的左右孩子:当左右子树都已经是镜像后,我们只需交换当前节点的 leftright 指针,就完成了当前层的翻转。

代码实现
该函数会直接修改原树,并返回翻转后的树的根节点。

C++

// 翻转二叉树,使其成为自身的镜像
Node* mirrorTree(Node* root) {
    // 基准情况:如果节点为空,无需操作
    if (root == nullptr) {
        return nullptr;
    }

    // 1. 递归翻转左子树
    mirrorTree(root->left);
    
    // 2. 递归翻转右子树
    mirrorTree(root->right);

    // 3. 交换当前节点的左右孩子
    Node* temp = root->left;
    root->left = root->right;
    root->right = temp;

    return root;
}

复杂度分析

  • 时间复杂度:O(N),我们需要访问树中的每一个节点一次。
  • 空间复杂度:O(H),递归调用的深度,H 是树的高度。

6. 判断二叉树是否镜像对称

问题描述
给定一棵二叉树,判断它是否是轴对称的(即,它本身是否是自身的镜像)。

解题思路
这个问题不能简单地通过遍历来解决。我们需要判断根节点的左子树是否与根节点的右子树“互为镜像”。

这需要一个辅助函数 isMirror(t1, t2),用来判断 t1t2 这两棵树是否互为镜像。
isMirror 的判断逻辑如下:

  1. 如果 t1t2 都为空,它们是镜像的。
  2. 如果其中一个为空,另一个不为空,或者它们的值不相等,那它们肯定不是镜像。
  3. 最关键的一步:t1的左子树 必须和 t2的右子树 互为镜像,并且 t1的右子树 必须和 t2的左子树 互为镜像。

代码实现

C++

// 辅助函数:判断 t1 和 t2 是否互为镜像
bool isMirror(Node* t1, Node* t2) {
    // 基准情况:两棵树都为空,是镜像
    if (t1 == nullptr && t2 == nullptr) {
        return true;
    }
    // 如果一个为空一个不为空,或者节点值不同,则不是镜像
    if (t1 == nullptr || t2 == nullptr || t1->data != t2->data) {
        return false;
    }
    // 递归地判断:t1的左子树是否是t2右子树的镜像
    //            并且t1的右子树是否是t2左子树的镜像
    return isMirror(t1->left, t2->right) && isMirror(t1->right, t2->left);
}

// 主函数:判断一棵树是否轴对称
bool isSymmetric(Node* root) {
    if (root == nullptr) {
        return true;
    }
    return isMirror(root->left, root->right);
}

复杂度分析

  • 时间复杂度:O(N),我们需要比较树中的每一个节点一次。
  • 空间复杂度:O(H),递归调用的深度。

7. 从前序与中序遍历序列重建二叉树

问题描述
假设输入一棵二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。

核心思路
这是一个非常经典的题目,其解法完全依赖于两种遍历方式的特性:

  • 前序遍历 (VLR):序列的第一个元素永远是当前树(或子树)的根节点
  • 中序遍历 (LVR):在序列中找到根节点后,它左边的所有元素都属于左子树,右边的所有元素都属于右子树。

重建步骤(递归)

  1. 从前序遍历序列中取出第一个元素,创建它作为当前子树的根节点。
  2. 在中序遍历序列中找到这个根节点的位置。
  3. 根据根节点在中序遍历中的位置,可以确定左子树和右子树的节点数量。
  4. 利用这些信息,可以分别确定左、右子树在前序和中序序列中的范围。
  5. 递归地调用此过程,为当前根节点构建左、右子树,并连接起来。

优化:每次都在中序序列中线性查找根节点的位置效率较低。我们可以预先用一个哈希表(unordered_map)存储中序序列中每个值对应的索引,将查找时间从 O(N) 降为 O(1)。

代码实现

#include 
#include 

// 辅助函数,执行实际的递归构建
// preorder/inorder: 遍历序列
// pre_start, pre_end: 当前处理的前序序列的起止索引
// in_start, in_end: 当前处理的中序序列的起止索引
// map: 中序遍历的值->索引的映射,用于快速查找
Node* buildTreeHelper(const std::vector<int>& preorder, const std::vector<int>& inorder,
                      int pre_start, int pre_end, int in_start, int in_end,
                      std::unordered_map<int, int>& map) {
    // 基准情况:序列为空
    if (pre_start > pre_end || in_start > in_end) {
        return nullptr;
    }

    // 1. 创建根节点 (前序序列的第一个元素)
    int root_val = preorder[pre_start];
    Node* root = new Node(root_val);

    // 2. 在中序序列中找到根的位置
    int in_root_idx = map[root_val];
    int left_subtree_size = in_root_idx - in_start;

    // 3. 递归构建左子树
    root->left = buildTreeHelper(preorder, inorder,
                                pre_start + 1, pre_start + left_subtree_size,
                                in_start, in_root_idx - 1,
                                map);

    // 4. 递归构建右子树
    root->right = buildTreeHelper(preorder, inorder,
                                 pre_start + left_subtree_size + 1, pre_end,
                                 in_root_idx + 1, in_end,
                                 map);

    return root;
}
      
// 主函数
Node* buildTree(const std::vector<int>& preorder, const std::vector<int>& inorder) {
    if (preorder.empty() || inorder.empty()) {
        return nullptr;
    }

    // 预处理,构建中序遍历的哈希映射
    std::unordered_map<int, int> map;
    for (int i = 0; i < inorder.size(); ++i) {
        map[inorder[i]] = i;
    }

    return buildTreeHelper(preorder, inorder, 0, preorder.size() - 1, 0, inorder.size() - 1, map);
}

关于BST的特别说明
buildTree 函数对于任何二叉树都适用。如果明确知道要重建的是一棵 BST,其实还有一个更简单的方法:因为BST的中序遍历就是其所有节点排序后的结果,所以我们只需要前序遍历序列就足够了。我们可以遍历前序序列,将每个元素依次插入到一棵新的、初始为空的BST中,最终得到的树就是重建后的BST。但“从两种遍历重建树”是更具普适性的经典算法。

复杂度分析 (使用哈希表优化后)

  • 时间复杂度:O(N),因为每个节点只被处理和创建一次。
  • 空间复杂度:O(N),主要用于存储哈希表和递归栈(最坏情况下为 O(N),平均为 O(logN))。

8. 完全二叉树的节点个数

完全二叉树的定义

一棵深度为 k 的二叉树,如果它的第 1 层到第 k−1 层的节点都排满了,并且第 k 层的节点都从左到右连续地排列,那么这棵树就是完全二叉树。满二叉树是一种特殊的完全二叉树。

算法思路:

  1. 计算树的左子树和右子树的高度(完全二叉树的高度一路向左即可求出)。
  2. 如果左子树的高度等于右子树的高度:这说明左子树是一个满二叉树
    • 左子树的节点数可以由公式 2^左子树高度−1 直接得出。
    • 总节点数 = 1 (根节点) + (2^左子树高度−1) (左子树) + 递归计算右子树的节点数。
  3. 如果左子树的高度大于右子树的高度:这说明右子树是一个满二叉树
    • 右子树的节点数可以由公式 2^右子树高度−1 直接得出。
    • 总节点数 = 1 (根节点) + (2^右子树高度−1) (右子树) + 递归计算左子树的节点数。

这个算法的关键在于,每次递归调用都至少有一边的子树可以直接通过高度计算出节点数,而不需要完全遍历,从而大大减少了计算量。

#include 
#include  // For pow()

// 定义二叉树节点 (同上)
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

/**
 * @brief 计算以 node 为根的树的高度 (沿着左子节点)
 * * @param node 根节点
 * @return int 树的高度
 */
int getHeight(TreeNode* node) {
    int height = 0;
    while (node) {
        height++;
        node = node->left;
    }
    return height;
}

/**
 * @brief 利用完全二叉树性质高效计算节点个数
 * * @param root 树的根节点
 * @return int 节点的总数
 */
int countNodes_Optimized(TreeNode* root) {
    if (root == nullptr) {
        return 0;
    }

    int leftHeight = getHeight(root->left);
    int rightHeight = getHeight(root->right);

    if (leftHeight == rightHeight) {
        // 左子树是满二叉树
        // 节点总数 = 根节点(1) + 左子树节点数 + 右子树节点数
        // 左子树节点数 = 2^leftHeight - 1
        // 所以总数 = 1 + (pow(2, leftHeight) - 1) + countNodes_Optimized(root->right)
        //          = pow(2, leftHeight) + countNodes_Optimized(root->right)
        return (1 << leftHeight) + countNodes_Optimized(root->right); // (1 << leftHeight) 相当于 2^leftHeight
    } else {
        // 右子树是满二叉树
        // 节点总数 = 根节点(1) + 左子树节点数 + 右子树节点数
        // 右子树节点数 = 2^rightHeight - 1
        // 所以总数 = 1 + countNodes_Optimized(root->left) + (pow(2, rightHeight) - 1)
        //          = pow(2, rightHeight) + countNodes_Optimized(root->left)
        return (1 << rightHeight) + countNodes_Optimized(root->left); // (1 << rightHeight) 相当于 2^rightHeight
    }
}

时间复杂度分析:

在countNodes_Optimized函数中,首先计算左右子树的高度,这需要 O(logN) 的时间。然后,递归地对一个子树进行处理。每次递归,问题规模都会减半。因此,总的时间复杂度为 O((logN)*(logN))。这比 O(N) 的遍历法要高效得多。

9. 判断二叉树是否是平衡树

  • 如果子树是平衡的,则返回其真实高度(一个非负整数)。
  • 如果子树是不平衡的,则返回一个特殊标记(例如 1),表示“不平衡”。

算法流程如下:

  1. 基本情况:如果当前节点为空 (nullptr),说明它是一棵空树,是平衡的,高度为 0。返回 0
  2. 递归调用
    • 递归地对左子树调用 checkHeight,得到左子树的高度 leftHeight
    • 剪枝:如果 leftHeight1,说明左子树已经不平衡了,整棵树必定不平衡,无需继续判断,直接返回 1
    • 递归地对右子树调用 checkHeight,得到右子树的高度 rightHeight
    • 剪枝:如果 rightHeight1,说明右子树不平衡,同样直接返回 1
  3. 判断当前节点
    • 在当前节点,检查左右子树的高度差:abs(leftHeight - rightHeight)
    • 如果高度差大于 1,说明以当前节点为根的子树不平衡,返回 1
    • 如果高度差不大于 1,说明以当前节点为根的子树是平衡的。它的高度是 1 + max(leftHeight, rightHeight)。返回这个高度值。

时间复杂度为 O(N)。空间复杂度为 O(H),其中 H 是树的高度,用于递归栈空间(最坏情况下为 O(N))。

    int checkHeight(TreeNode* node) {
        // 基本情况:空树是平衡的,高度为 0。
        if (node == nullptr) {
            return 0;
        }

        // 递归计算左子树的高度
        int leftHeight = checkHeight(node->left);
        // 剪枝:如果左子树已经不平衡,直接返回 -1
        if (leftHeight == -1) {
            return -1;
        }

        // 递归计算右子树的高度
        int rightHeight = checkHeight(node->right);
        // 剪枝:如果右子树已经不平衡,直接返回 -1
        if (rightHeight == -1) {
            return -1;
        }

        // 检查当前节点的平衡性
        if (std::abs(leftHeight - rightHeight) > 1) {
            return -1; // 不平衡
        } else {
            // 平衡,返回当前子树的高度
            return 1 + std::max(leftHeight, rightHeight);
        }
    }
  
    **bool isBalanced(TreeNode* root) {
		    return checkHeight(root) != -1;
    }**

10. 寻找中序遍历的倒数第K个节点

问题描述
给定一棵二叉树和一个整数 K,找出在中序遍历(LVR)序列中,排在倒数第 K 位的那个节点。

解题思路
方法的核心在于逆向思维

  • 标准中序遍历是 LVR(左 -> 根 -> 右),得到的是升序序列。
  • 倒数第 K 个,就是从后往前数的第 K 个。
  • 如果我们能让遍历顺序反过来,变成 RVL(右 -> 根 -> 左),那么得到的就是一个降序序列。
  • 此时,原问题 “寻找LVR的倒数第K个节点” 就等价于 “寻找RVL的正数第K个节点”。

这样,只需要对树进行一次 RVL 遍历,并在过程中计数,数到第 K 个时,就是我们的答案。

void findKthToLastHelper(TreeNode* node, int& k, TreeNode*& result) {
    // 基准情况:节点为空,或者已经找到了结果,就没必要继续了
    if (node == nullptr || result != nullptr) {
        return;
    }

    // 1. (R) 优先遍历右子树
    findKthToLastHelper(node->right, k, result);

    // 2. (V) 访问当前节点
    // 只有在结果还未找到时才进行处理
    if (result == nullptr) {
        // 每访问一个节点,k就减1
        k--;
        // 如果 k 减到 0,说明当前节点就是我们要找的第 k 个
        if (k == 0) {
            result = node; // 记录结果
            return;        // 提前返回,剪枝
        }
    }

    // 3. (L) 最后遍历左子树
    findKthToLastHelper(node->left, k, result);
}

TreeNode* findKthToLast(TreeNode* root, int k) {
    // 处理无效输入
    if (root == nullptr || k <= 0) {
        return nullptr;
    }

    TreeNode* result = nullptr;
    findKthToLastHelper(root, k, result);
    return result;
}

你可能感兴趣的:(数据结构,算法,c++)