《算 · 法》题目解析篇(1):最近公共祖先问题,线段树,最长回文子序列

🧑‍💻博主名称:鱼子星_

✅数据结构专栏:【数据结构】

✅算法竞赛专栏:【算法竞赛】

✅C++系列专栏:【C++从零开始系列】


Hi~欢迎来到鱼子星的《算·法》篇章,本文为题目解析篇,主要讲解3个算法题,涉及到算法的知识有:

  • 递归
  • 动态规划
  • 线段树
题目链接 算法知识
LeetCode - 236.二叉树的最近公共祖先 递归,栈
LeetCode - 516.最长回文子序列 动态规划,字符串
【洛谷】P2184 贪婪大陆 线段树

一. 二叉树的最近公共祖先

题目描述

给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。

最近公共祖先的定义为:"对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。"

示例 1:

输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1

输出:3

解释:节点 5 和节点 1 的最近公共祖先是节点 3 。


提示:

  • 树中节点数目在范围 2, 1e5 内。
  • -1e9 <= Node.val <= 1e9
  • 所有 Node.val 互不相同 。
  • p != q
  • p 和 q 均存在于给定的二叉树中。

题目解析

查找最近公共祖先,首先要确定什么情况算找到了公共祖先,情况有两种:

  • 当p或q为根节点时,且另外一个节点为该根节点的孩子节点。如下图所示,假设此时要找5和4的最近公共祖先。这两个节点的公共祖先只有5和5到根节点的路径上所有的节点,那么此时最近的公共祖先就是5了。
  • 当p和q分别在一个节点的左右两边,该节点就是p和q的最近公共祖先。当p和q的关系不满足情况一时,就只有这种情况,此时树中只有节点5满足情况二,所以5就是6和7的最近公共祖先。

解法一:暴力查找节点+递归

因为题目中说了p和q一定存在且不相等,所以核心思路就是找上面的两种情况。我们不妨写一个查找节点的函数Find,对于当前节点,查找其左子树和右子树是否存在p和q,情况有3种:

  • p和q分别在当前节点的左右子树,那么当前节点就是它们的最近公共祖先,返回该节点即可。
  • p和q都在当前节点的左子树,那么最近公共祖先肯定在当前节点的左子树。我们递归去当前节点的左子树继续查找,查找的结果直接返回即可。
  • p和q都在当前节点的右子树,那么最近公共祖先肯定在当前节点的右子树。递归去右子树继续查找,查找到的结果直接返回即可。

我们在代码中还可以加一句特殊的判断来应对情况一。如果当前节点为q或者p时,直接返回当前的节点。如果节点为nullptr,那也直接返回。

cpp 复制代码
class Solution {
public:
    bool find(TreeNode* root, TreeNode* x)
    {
        if(root == nullptr)
            return false;
        
        if(root == x)
            return true;
        
        return find(root->left, x) || find(root->right, x);
    }
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        if(root == nullptr || root == p || root == q)
            return root;
        
        bool qfindLeft = find(root->left, q);
        bool qfindright = !qfindLeft;

        bool pfindLeft = find(root->left, p);
        bool pfindright = !pfindLeft;

        // p和q分别在root的左右,说明root就是它们的最近公共祖先
        if((qfindLeft && pfindright) || (qfindright && pfindLeft))
        {
            return root;
        }
        else if(qfindLeft && pfindLeft)
        {
            return lowestCommonAncestor(root->left, p, q);
        }
        else // qfindright != nullptr && pfindright != nullptr
        {
            return lowestCommonAncestor(root->right, p, q);
        }
    }
};

时间复杂度 : O ( n 2 ) O(n^2) O(n2)

空间复杂度 : O ( n ) O(n) O(n)

解法二:路径查找+相交链表问题

对于节点p和节点q,我们可以分别将它们到根节点的路径保存下来,于是,问题就变成了查找这两条路径的第一个相同的节点,该节点就是p和q的最近公共祖先

如何保存p和q到根节点的路径?我们可以从根节点开始往下查找节点p和节点q,对于当前节点,如果p或者q在其子树中,那就保存当前节点,反之不保存。因为是自顶向下查找,但是需要的路径是自底向上的,所以我们使用栈(stack)来保存路径。

cpp 复制代码
class Solution {
public:
    bool getPath(TreeNode* root, TreeNode* x, stack<TreeNode*>& st)
    {
        if(root == nullptr)
            return false;
		//先将当前节点加入在路径中,后续再判断是否要删除
        st.push(root);

        if(root == x)
            return true;
        
        if(getPath(root->left, x, st))
            return true;
        
        if(getPath(root->right, x, st))
            return true;
		// 走到这里说明x节点不在当前节点的子树中
		// 弹出当前节点
        st.pop();
        return false;
    }
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        if(root == nullptr)
            return root;
        
        stack<TreeNode*> qPath;
        stack<TreeNode*> pPath;

        // 找到从根节点到q和p节点的路径
        getPath(root, q, qPath);
        getPath(root, p, pPath);
		
		// 先让两条路径保持一样的长度
        while(qPath.size() != pPath.size())
        {
            if(qPath.size() > pPath.size())
                qPath.pop();
            else 
                pPath.pop();
        }
		
		// 直接两条路径同时删除找第一个相同的节点
        while(qPath.top() != pPath.top())
        {
            qPath.pop();
            pPath.pop();
        }

        return qPath.top();
    }
};

时间复杂度 : O ( n ) O(n) O(n)

空间复杂度 : O ( n ) O(n) O(n)

二. 最长回文子序列

题目描述

给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。

子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。

示例 1:

输入s = "bbbab"

输出4

解释:一个可能的最长回文子序列为 "bbbb" 。


提示:

  • 1 <= s.length <= 1000
  • s 仅由小写英文字母组成

题目解析

经验:遇到回文串子序列这类关键字的题目,一般都是使用区间dp来解决。

解法一:动态规划

讲解动态规划的题目,我们一般按照动态规划的三部曲:

  • 状态表示
    dp[i][j]表示字符串s的 i , j i, j i,j区间的子串的最长回文子序列的长度
  • 状态转移方程
    • s[i] == s[j],且不使用si和sj配对:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1);
    • s[i] == s[j],且使用si和sj配对:dp[i][j] = max(dp[i][j], dp[i + 1][j - 1] + 2);
      分析:si和sj配对后,形成了一对新的回文序列,且这对回文序列包裹了s[i + 1] ~ s[j - 1]中最长回文子序列。
    • s[i] != s[j],此时只有一种情况:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1);
  • 最终结果dp[0][s.size() - 1];
cpp 复制代码
class Solution {
public:
    int longestPalindromeSubseq(string s) {
        int n = s.size();
        vector<vector<int>> f(n, vector<int>(n, 0));

        for(int i = 0; i < n; i++) f[i][i] = 1;

        for(int len = 2; len <= n; len++)
        {
            for(int i = 0; i + len - 1 < n; i++)
            {
                int j = i + len - 1;
                f[i][j] = max(f[i + 1][j], f[i][j - 1]);
                if(s[i] == s[j])
                {
                    f[i][j] = max(f[i][j], f[i + 1][j - 1] + 2);
                }
            }
        }
        return f[0][n - 1];
    }
};

三. 贪婪大陆

题目背景

面对蚂蚁们的疯狂进攻,小 FF 的 Tower defence 宣告失败......人类被蚂蚁们逼到了 Greed Island 上的一个海湾。现在,小 FF 的后方是一望无际的大海,前方是变异了的超级蚂蚁。小 FF 还有大好前程,他可不想命丧于此, 于是他派遣手下最后一批改造 SCV 布置地雷以阻挡蚂蚁们的进攻。

题目描述

小 FF 最后一道防线是一条长度为 n n n 的战壕,小 FF 拥有无数多种地雷,而 SCV 每次可以在 L , R L, R L,R 区间埋放同一种不同于之前已经埋放的地雷。由于情况已经十万火急,小 FF 在某些时候可能会询问你在 L ′ , R ′ L',R' L′,R′ 区间内有多少种不同的地雷,他希望你能尽快的给予答复。

输入格式

第一行为两个整数 n n n 和 m m m, n n n 表示防线长度, m m m 表示 SCV 布雷次数及小 FF 询问的次数总和。

接下来有 m m m 行,每行三个整数 q , l , r q,l,r q,l,r:

  • 若 q = 1 q=1 q=1,则表示 SCV 在 l , r l, r l,r 这段区间布上一种地雷;
  • 若 q = 2 q=2 q=2,则表示小 FF 询问当前 l , r l, r l,r 区间总共有多少种地雷。

输出格式

对于小 FF 的每次询问,输出一个答案(单独一行),表示当前区间地雷种数。

输入输出样例 #1

输入 #1

复制代码
5 4
1 1 3
2 2 5
1 2 4
2 3 5

输出 #1

复制代码
1
2

说明/提示

  • 对于 30 % 30\% 30% 的数据, 0 ≤ n 0 \le n 0≤n, m ≤ 1000 m \le 1000 m≤1000。
  • 对于 100 % 100\% 100% 的数据, 0 ≤ n 0 \le n 0≤n, m ≤ 10 5 m \le 10^5 m≤105。

题目解析

本题就是经典的区间修改和查询的题目,可以使用线段树来解决。

解法一:线段树

使用线段树时,首先要考虑要维护区间的什么信息,这里最容易想到的就是:维护每个区间地雷的种数。但是这个思路是不行的,很容易举出反例。例如,分别在 1 , 3 3 , 4 1, 33, 4 1,33,4,询问 2 , 3 2, 3 2,3一共有几种地雷,这种情况下就解不出来了。

我们从最终的结果来考虑,一个区间的地雷种数其实可以由下面的公式得出:

区间[left, right]的地雷种数 = [1, right]的开始地雷的种数 − - − [1, left - 1]的结束地雷的种数

证明 :假设 1 , r i g h t 1, right 1,right的开始地雷有x种, 1 , l e f t − 1 1, left-1 1,left−1的结束地雷有y种,那x - y的地雷种数就是所有从 1 , r i g h t 1, right 1,right区间开始且没有在 1 , l e f t − 1 1, left - 1 1,left−1结束的地雷种数,这些地雷一定都和 l e f t , r i g h t left, right left,right区间有交集,所以x - y就是答案。

所以我们需要维护的区间信息为:一个区间开始地雷的种数和结束地雷的种数。代码中我们使用cnt0表示开始地雷的种数,cnt1表示结束地雷的种数。

cpp 复制代码
#include<iostream>

using namespace std;

#define lc p << 1
#define rc p << 1 | 1

const int N = 1e5 + 10;

struct Node
{
	int l, r, cnt[2];
}tr[N * 4];

int n, m;

void pushup(int p)
{
	tr[p].cnt[0] = tr[lc].cnt[0] + tr[rc].cnt[0];
	tr[p].cnt[1] = tr[lc].cnt[1] + tr[rc].cnt[1];
}

void build(int p, int l, int r)
{
	tr[p] = {l, r, 0, 0};
	if(l == r) return;
	
	int mid = (l + r) >> 1;
	build(lc, l, mid);
	build(rc, mid + 1, r);
	pushup(p);
}

void modify(int p, int x, int k)
{
	int l = tr[p].l, r = tr[p].r;
	if(l == r && r == x)
	{
		tr[p].cnt[k]++;
		return;
	}
	
	int mid = (l + r) >> 1;
	if(x <= mid) modify(lc, x, k);
	else modify(rc, x, k);
	pushup(p);
}

int query(int p, int l, int r, int k)
{
	int x = tr[p].l, y = tr[p].r;
	if(l <= x && r >= y)
	{
		return tr[p].cnt[k];
	}
	
	int mid = (x + y) >> 1;
	int sum = 0;
	if(l <= mid) sum += query(lc, l, r, k);
	if(r > mid) sum += query(rc, l, r, k);
	return sum;
}

int main()
{
	cin >> n >> m;
	
	build(1, 1, n);
	
	while(m--)
	{
		int q, l, r;
		cin >> q >> l >> r;
		if(q == 1)
		{
			modify(1, l, 0);
			modify(1, r, 1);
		}
		else
		{
			cout << query(1, 1, r, 0) - query(1, 1, l - 1, 1) << endl;
		}
	}
	return 0;
} 
相关推荐
雾喔1 小时前
算法练习5
算法·代理模式
问商十三载1 小时前
RAG 检索召回越多越好?2026 信噪比模型深度解析
人工智能·算法·机器学习
额恩661 小时前
ResearchPilot 第三阶段总结报告
python·算法
Hi李耶1 小时前
【LeetCode】9-回文数
算法·leetcode·职场和发展
红叶舞2 小时前
基于线段树的数据结构
数据结构·python·算法
断点之下2 小时前
从“理扑克牌”到“分组优化”——直接插入排序与希尔排序详解
数据结构·算法·排序算法
起个破名想半天了2 小时前
算法与数据结构之DFS深度优先遍历
数据结构·算法·深度优先
李伟_Li慢慢2 小时前
凸包算法
人工智能·算法
徐礼昭|商派软件市场负责人2 小时前
AI Agent 蜂群协作与经验基因化:从单体执行到自组织系统的工程路径
大数据·人工智能·算法·智能体·多agent