【CSP-J】树上搜索(问题拆解+方案构建+思考过程)

在这篇文章中,我们将详细探讨并解决一个复杂的分类问题。该问题要求通过最优提问策略,以最少的提问次数确定某个名词所属的类别。我们将使用递归树算法来构建和优化这一解决方案。

本文将涵盖以下内容:

  1. 问题描述与分析
  2. 数据结构与变量说明
  3. 解决方案分解与实现
  4. 完整的 C++ 代码实现
  5. 时间与空间复杂度分析
  6. 总结与启发

1. 问题描述与分析

原题链接

问题背景: 我们面临一个分类问题,要求通过系统提出的问题,以最少的提问次数确定某个名词的类别。名词的分类结构为树形,除了根类别外,每个类别都有且仅有一个上级类别。所有名词都可以被归类到某个类别中,系统需要通过判断某个名词是否属于某类别或其子类别来逐步缩小候选范围,最终确定名词的类别。

问题目标: 实现最优的提问策略,以最少的提问次数确定名词所属的类别。

功能步骤

  1. 数据输入与树结构构建:读取输入数据,构建树结构,并将所有节点组织成父子关系。
  2. 权重计算:递归计算每个节点及其所有子节点的权重和,为后续的提问策略提供数据支持。
  3. 最佳提问策略的执行:通过遍历树结构,计算每个节点的权重差值,选择最佳提问节点。
  4. 动态调整树结构:根据用户的回答移除或恢复节点,以动态调整树结构。
  5. 输出结果:根据最佳提问节点的选择顺序输出最终结果。

2. 数据结构与变量说明

为了解决这个问题,我们设计了一些关键数据结构和变量。

数据结构
struct Node {
    long long Weight;         // 节点自身的权重,表示该类别的可能性大小
    long long WeightSum;      // 节点及其所有子节点的权重和,用于确定最佳提问节点
    long long Index;          // 节点的索引编号,唯一标识每个节点
    bool IsOut;               // 表示节点是否已被移除,主要用于动态调整树结构
    Node* Child;              // 指向第一个子节点的指针,用于遍历子节点
    Node* Brother;            // 指向兄弟节点的指针,用于横向遍历兄弟节点
    Node* Above;              // 指向父节点的指针,用于向上追溯父节点
    Node* LastChild;          // 指向最后一个子节点的指针,便于添加新子节点时快速定位
    Node* PrevBrother;        // 指向前一个兄弟节点的指针,便于在移除节点时调整指针

    // 构造函数,初始化节点信息
    Node(long long Weigh, long long i) : Weight(Weigh), Child(nullptr), LastChild(nullptr), Above(nullptr), Brother(nullptr), WeightSum(Weigh), PrevBrother(nullptr), Index(i) {}
};

变量说明
  • n:表示类别的总数。用于初始化所有类别的节点。
  • m:表示需要测试的类别数量。决定了程序中需要进行多少次查询。
  • Nodes:一个指针数组,用于存储所有节点对象。每个节点都是树中的一个类别。
  • RemovedParentsRemovedChilds:这两个数组分别存储被移除的节点及其父节点,用于在查询结束后恢复树的原始结构。
  • RemovedPairs:记录被移除的节点对数,便于恢复时使用。
  • CurMinCurIdx:用于在遍历过程中记录当前找到的最小权重差值及其对应的节点索引,以便选择最佳的提问节点。
  • CurTotalWeight:当前子树的总权重,用于计算每个节点的权重差值。

3. 解决方案分解与实现

我们将总问题拆解为以下子问题,并逐一分析和解决。

子问题1:数据输入与树结构构建

功能:读取输入数据,初始化节点,并构建父子关系。 算法与知识:基本的输入操作,指针操作。

实现过程中,我们首先需要快速地读取数据,并初始化每个节点。接着,我们将子节点与父节点链接起来,构建出完整的树结构。

子问题2:权重计算

功能:计算每个节点及其所有子节点的权重和。 算法与知识:递归算法,深度优先搜索。

通过递归遍历每个节点的子节点,我们能够计算出每个节点的总权重,这在后续的提问策略中非常关键。

子问题3:最佳提问策略的执行

功能:选择最佳的提问节点,以便通过最少的提问次数锁定目标类别。 算法与知识:动态规划,遍历算法。

我们需要遍历整个树结构,找到权重差值最小的节点作为最佳提问节点。

子问题4:动态调整树结构

功能:根据用户的回答调整树结构,确保剩余候选类别的正确性。 算法与知识:指针操作,树的修剪与恢复。

通过动态调整树结构,我们可以更有效地缩小候选范围,最终确定目标类别。


4. 完整的 C++ 代码实现

以下是完整的代码实现,包含了详细的注释,以帮助理解每一步的原理和作用。

/*
    特点: 
        双向关系的构建和维护,
        getchar()的方式实现快速输入,
        维护树结构,
        恢复树结构,
        二分思想的变种应用,
        成员初始化列表的初始化
        DFS遍历,
        动态规划式记忆化递归的计算权重之和.
        模拟用户过程
*/
#include <iostream>
#include <vector>
#include <algorithm>
#include <cmath>
#include <limits.h>
using namespace std;

// 快速输入函数,用于读取数据
inline long long read() {
    long long x = 0, f = 1;
    char c = getchar();
    while (c < '0' || c > '9') {
        if (c == '-') f = -1;
        c = getchar();
    }
    while (c >= '0' && c <= '9') {
        x = x * 10 + c - '0';
        c = getchar();
    }
    return x * f;
}

// 定义树节点结构体
struct Node {
    long long Weight;         // 节点自身的权重
    long long WeightSum;      // 节点及其所有子节点的权重和
    long long Index;          // 节点的索引编号
    bool IsOut;               // 节点是否已经被移除
    Node* Child, * Brother, * Above, * LastChild, * PrevBrother;  // 节点与其他节点的关系指针

    // 构造函数,初始化节点信息
    Node(long long Weigh, long long i) : Weight(Weigh), Child(nullptr), LastChild(nullptr), Above(nullptr), Brother(nullptr), WeightSum(Weigh), PrevBrother(nullptr), Index(i) {}
};

// 节点数组,保存所有节点
Node* Nodes[2010];

// 将子节点链接到父节点,构建树结构
void LinkNode(Node* Parent, Node* Child) {
    if (!Parent || !Child) return;  //如果父子指针是null 拦截
    Child->Above = Parent;          //构建孩子映射父亲
    if (!Parent->Child) {           //如果父亲未有儿子
        Parent->Child = Child;      //将第一个孩子给child指针
        Parent->LastChild = Child;  //将第一个孩子给LastChild指针
        return;                     //函数结束
    }
    Parent->LastChild->Brother = Child;     //父亲的最后一个儿子后面,添加新儿子,       实现原本最后一个元素 映射到 新元素
    Child->PrevBrother = Parent->LastChild; //新元素的前一位兄弟,是原本最后一个元素    实现新元素,映射到,原本最后一个元素
    Parent->LastChild = Child;              //将新的元素作为最后一个儿子,(双向映射关系已经构建完毕,可以交接指针)
}

// 递归计算每个节点及其所有子节点的权重和
long long AccWeights(Node* CurRoot) {
    if (!CurRoot) return 0;     //拦截
    CurRoot->WeightSum = CurRoot->Weight;   //总权重的计算,先实现自身权重
    Node* CurNode = CurRoot->Child;         //CurNode节点,是当前节点的,第一个子节点的指针
    while (CurNode) {                       //遍历所有子节点
        CurRoot->WeightSum += AccWeights(CurNode);  //给当前节点的总权重做累加,递归实现 
        CurNode = CurNode->Brother;                 //去找子节点的兄弟,不管是否存在,在下次进入while循环时,会检查的
    }
    return CurRoot->WeightSum;                      //返回当前节点的总权重
}

// 检查目标节点是否存在于某个节点的子树中(就当做是判断是否在这个子树中)
/*
*   因为在计算权重差值的时候,就已经确定了目标提问节点在哪里了
*   现在的问题只是,在计算机已经有最佳提问策略的情况下,通过对最佳提问节点,进行判断,目标值是否在此子树结构中,以此缩小数据的搜索范围.
*/

bool HasTarIndex(Node* Root, long long Tar) {       
    if (!Root) return 0;
    else if (Root->Index == Tar) return 1;
    Node* CurNode = Root->Child;
    while (CurNode) {
        if (HasTarIndex(CurNode, Tar)) return 1;
        CurNode = CurNode->Brother;
    }
    return 0;
}

// 移除节点并断开其与父节点及兄弟节点的连接
void RemoveNode(Node* TarNode) {
    if (!TarNode) return;

    //消除前驱节点对本节点的映射
    if (!TarNode->PrevBrother) TarNode->Above->Child = TarNode->Brother;    //如果不存在前兄弟节点,则将父亲节点的第一个孩子指针child,指向下一个兄弟节点 ,如果不存在,证明已经没有后继子节点,也就是结束.
    else TarNode->PrevBrother->Brother = TarNode->Brother;                  //构建 我的前驱节点 对 我的后继节点的映射关系
    
    //消除本节点对后续节点的映射
    if (!TarNode->Brother) TarNode->Above->LastChild = TarNode->PrevBrother;//如果没有后续兄弟,则将父亲节点的最后一个孩子指针,指向前驱兄弟,如果前驱兄弟不存在,则证明无法为父子双方构建关系.(到这里为止,已经不会出现没有前驱兄弟的情况)
    else TarNode->Brother->PrevBrother = TarNode->PrevBrother;              //构建 我的后节点 对 我的前驱节点的映射关系

    //至此,已经完成我的前驱与后续节点的,双向映射关系,即本节点已经无后事可交代,可指向空指针
    TarNode->Above = nullptr;       //我对  父亲  的映射 断开
    TarNode->Brother = nullptr;     //我对后继兄弟的映射 断开
    TarNode->PrevBrother = nullptr; //我对前驱兄弟的映射 断开
}

// 用于记录被移除的节点及其父节点的数组
long long RemovedParents[2010]{ -1 }, RemovedChilds[2010]{ -1 };
long long RemovedPairs = 0;


/*
*恢复之前移除的节点,重建树结构
    为什么是不直接保存一个树结构的副本呢?
    会占据更多的空间资源,因此不能全部都复制一遍

    最佳方案是什么?
    按需求,对树结构进行拆分,实际利用空间大小只有一个树结构的大小,因为不要的子树,会暂时断开联系,变成树林.

    那么复原之后的树结构唯一吗?
        1.每层结构中的兄弟节点的出现顺序不唯一,但是不影响结果;
        2.每个子节点对应的父亲节点还是原来的父亲,这个并没有改变
        因此,这是可行的方案!
*/
void ReBuild() {
    //给定父子节点,构建新的二叉树关系.
    for (long long i = 0; i < RemovedPairs; ++i) LinkNode(Nodes[RemovedParents[i]], Nodes[RemovedChilds[i]]);
    RemovedPairs = 0;
}

// 计算某个节点的权重差值
long long GetDelta(Node* TarNode, long long& CurTotalWeight) {
    if (!TarNode) return 114514;            // 拦截
    else if (!TarNode->Above) return TarNode->WeightSum;    //没有父亲节点,返回总权重,根节点
    return abs(CurTotalWeight - 2 * TarNode->WeightSum);    //当前节点与子节点的权重差值 = 整个有效树结构的每个节点的权重总和 - 2 * 当前节点与子节点的权重总和
}

// 遍历树结构中的所有节点,找到最佳提问节点
void Bianli(Node* CurRoot, long long& CurTotalWeight, long long& CurMin, long long& CurIdx) {
    if (!CurRoot) return;       //拦截
    long long Delta = GetDelta(CurRoot, CurTotalWeight);    //获取根节点的权重差值
    if (Delta < CurMin || (CurMin == Delta && CurRoot->Index < CurIdx)) {   //比已有记录的最小权重还小 || 权重差值相等&&比已有记录的最小编号还小
        CurMin = Delta;             // 更新记录_最小权重
        CurIdx = CurRoot->Index;    // 更新记录_最小编号
    }
    Node* CurNode = CurRoot->Child; // 代理当前节点的子节点
    while (CurNode) {               //代理子节点还存在,则进入循环
        Bianli(CurNode, CurTotalWeight, CurMin, CurIdx);    //继续往下遍历子节点
        CurNode = CurNode->Brother;                         //代理子节点,搜索字节点的后继兄弟???为什么没有前驱节点的权重计算呢???
    }
}

// 处理每个测试类别的查询过程
int main() {
    long long n = read(), m = read(); // 读取类别总数和测试类别数

    // 初始化节点并读取权重
    for (long long i = 1; i <= n; ++i) Nodes[i] = new Node(read(), i);

    // 读取每个节点的父节点编号,并链接节点
    for (long long i = 2; i <= n; ++i) LinkNode(Nodes[read()], Nodes[i]);

    long long Index = 0;

    // 处理每个测试类别
    for (long long i = 1; i <= m; ++i) {    //动态数据 作为循环条件,等执行到这个位置,已经在前面通过输入数据,确定了m的大小
        RemovedPairs = 0;// 消除的父子节点的对数
        Index = read();  // 读取测试的目标类别(下一个测试目标对象,等待用户的输入即可,因此实际上并没有存储任何目标对象的编号,又节省了空间)
        Node* CurRoot = Nodes[1];  // 从根节点开始处理

        // 继续提问直到只剩下一个子类别(只有将问题问到最后一个才结束,除非找到正确答案)
        while (CurRoot->Child) {                         // 通过提问策略来改变任务根节点,缩小提问范围
            long long SumWeight = AccWeights(CurRoot);   // 计算当前子树的权重和
            long long CurMin = SumWeight, CurIdx = 0;    // 初始化当前最小差值和索引
            Bianli(CurRoot, SumWeight, CurMin, CurIdx);  // 选择出最佳的提问策略(函数会通过指针传递的方式,将最佳提问节点的编号给CurIndex)

            cout << CurIdx << ' ';        // 输出最佳提问节点编号

            // 根据用户的回答(是否包含目标节点)调整树结构
            if (HasTarIndex(Nodes[CurIdx], Index)) {    // 为什么是问是否包含目标节点呢?
                CurRoot = Nodes[CurIdx];  // 如果目标节点在当前子树中,则进入子树继续提问(只是进入该子树中,并没有断开整个树结构)(能实现逻辑删除就用)
            }
            else {
                // 否则移除该节点并保存移除的父子关系
                // 消除该节点与父节点之间的双向映射关系.并将父子分别保存在数组中,一对的存在(实际上也可以通过存储键值对的方式 用哈希map实现,可能会更好)
                RemovedParents[RemovedPairs] = Nodes[CurIdx]->Above->Index;
                RemovedChilds[RemovedPairs] = Nodes[CurIdx]->Index;
                ++RemovedPairs;
                RemoveNode(Nodes[CurIdx]);  //消除父子节点之间的双向映射关系后,直接对
            }
        }
        ReBuild();  // 恢复被移除的节点
        cout << '\n';
    }

    // 释放节点内存
    for (long long i = 1; i <= n; ++i) delete Nodes[i];

    return 0;
}
/*
    工作原理:
        先找到最合适的权重差值的节点编号
        根据编号确定子树位置
        对该子树位置遍历,去查找有没有合适的节点,如果有则保留本子树,如果无舍弃本子树
        (至此,已经树结构的缩小范围)
        可以再次进行搜索最佳的权重差值节点.

    过程模拟的本质:
        目的 找出最佳提问策略,通过(用户的回答)==(用户知道这个子树结构中存在或不存在目标编号),以此调整对树结构的搜索范围
        模拟 找出最佳提问策略,通过搜索目标编号节点是否在此树结构中,以此作为缩小检索范围的判断依据.

    节省空间的原理:
        减小树结构的分支: 采用父子节点断链,将对应的父子关系存储在两个容器中,即可实现父子节点之间的简单双向映射关系,不会丢失
        用户输入数据    : 我等待用户输入一个数据,我就处理一个数据
        快速输入数据    : 使用inline关键字内联,并且使用getchar()直接对缓冲区进行操作,减少运行时间

*/

5. 时间与空间复杂度分析

时间复杂度

  • 树的构建与权重计算:构建树的复杂度为 O(n),每个节点的权重计算需要遍历所有子节点,因此递归计算权重的时间复杂度为 O(n)。
  • 提问策略执行:在每次提问时,我们需要遍历整个树结构,时间复杂度为 O(n)。总的复杂度为 O(n * m),其中 m 是测试的类别数。

空间复杂度

  • 空间需求:主要是存储节点和临时变量的空间,空间复杂度为 O(n)。

6. 总结与启发

在这篇文章中,我们通过分解问题、设计数据结构、实现算法,成功解决了名词分类问题。这一过程为我们提供了几个重要的启发:

  1. 复杂树结构的管理:通过灵活使用指针与结构体,我们可以有效地管理复杂的树结构,适应多变的查询需求。
  2. 递归与动态调整:递归计算和动态调整树结构的方式,使得我们能够在面对多变的输入条件时,保持准确性和灵活性。
  3. 最优选择策略:通过遍历与计算权重差值,我们能够在复杂的决策树中找到最优的提问策略,最大限度地减少提问次数。

这些方法和思想不仅适用于本题目,还可以推广到其他涉及复杂数据结构和决策的场景中。希望这篇文章能够为大家提供有价值的参考与帮助!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值