复杂的树形数据结构

14
我正在为我们正在制作的类似于经典旧版《生化危机》的游戏设计物品系统。目前,我正在实现物品合并功能,其中您可以将不同的物品组合在一起以获得新的物品。复杂之处在于存在具有多个转换级别和每个级别的多个配对项的物品。让我来解释一下,假设我们有绿色、红色和蓝色草药。您不能将红色+蓝色组合在一起,但是您可以将G+B组合在一起,就会得到像GreenBlueHerb这样的东西,或者将G+R组合在一起,就会得到像GreenRedHerb这样的东西,现在如果您将这些结果之一与蓝色草药结合起来,您将获得GreyHerb。从这个例子中可以看出,绿色草药有两个转换级别,要达到第一个级别,有两个可能的配对项(红色或蓝色),从那一点开始到第二个级别,只有一个配对项(蓝色)。
因此,我想出了一个有趣的树形结构,覆盖了所有向下延伸的可能性,不仅仅是2层,层数越多,树形结构就越复杂。请看这个3层的示例,您在中间看到的三角形代表一个物品,周围的其他彩色形状代表它达到下一级的可能配对项:
(图片已省略)
有很多不同的组合方式,我可以先将我的物品与蓝色的物品组合,然后与红色的物品组合,然后再与绿色的物品组合,或者先将其与绿色的物品组合,然后再与红色的物品组合,然后再与蓝色的物品组合等等,以达到最终的级别。我想出了这个树形结构,表示所有可能的组合:
(图片已省略)
(右侧数字是#levels,左侧是每个级别的#nodes)但是,正如您所看到的,这是冗余的。如果您查看末端节点,它们应该全部为1,因为它们都指向相同的最终结果,即G+R+B。实际上,对于这种情况,总共有7种可能的状态,这是正确的树形结构:
(图片已省略)
这非常有意义,请注意节点数量的巨大差异。
现在我的问题是,对于这个问题什么是正确的数据结构? - 我相当确定没有内置的数据结构适用于此,因此我需要制作自己的自定义数据结构,我实际上已经做到了,并设法使其正常工作,但出现了一个问题。(值得一提的是,我从XML文件中获取节点信息,这些信息包括到达节点/级别所需的itemRequired以及该节点的名称是什么,例如:要达到RedGreenHerb状态,绿色草需要“需要”红色草,当这种组合发生时,“GreenHerb”的名称将更改为“RedGreenHerb”,如果你想知道“RedHerb”会发生什么,它只是消失了,我不再需要它)这是我的数据结构:
public struct TransData
{
    public TransData(string transItemName, string itemRequired)
    {
        this.transItemName = transItemName;
        this.itemRequired = itemRequired;
    }
    public string transItemName;
    public string itemRequired;
}

public class TransNode
{
    public List<TransNode> nodes = new List<TransNode>();
    public TransData data;
    public TransNode(TransNode node): this(node.data.transItemName, node.data.itemRequired) { }
    public TransNode(string itemName, string itemRequired)
    {
       data = new TransData(itemName, itemRequired);
    }
}

public class TransLevel
{
    public List<TransNode> nodes = new List<TransNode>();
    public TransNode NextNode { get { return nodes[cnt++ % nodes.Count]; } }
    int cnt;
}

public class TransTree
{    
    public TransTree(string itemName)
    {
        this.itemName = itemName;
    }
    public string itemName;
    public TransNode[] nodes;
    public List <TransLevel> levels = new List<TransLevel>();
    // other stuff...
}

让我来解释一下: TransTree 实际上是基本节点,起始处有项目名称(例如 GreenHerb),该树有多个级别(您在图片中看到的黑线),每个级别有多个节点,每个节点都携带一个新的项目数据和指向其他节点(子节点)的节点数。现在您可能会问为什么需要在 TransTree 类中放置节点列表?- 在展示如何从 XML 文件中获取数据之后,我将回答这个问题:
public TransTree GetTransItemData(string itemName)
{
    var doc = new XmlDocument();
    var tree = new TransTree(itemName);
    doc.LoadXml(databasePath.text);

    var itemNode = doc.DocumentElement.ChildNodes[GetIndex(itemName)];
    int nLevels = itemNode.ChildNodes.Count;
    for (int i = 0; i < nLevels; i++) {
       var levelNode = itemNode.ChildNodes[i];
       tree.levels.Add(new TransLevel());
       int nPaths = levelNode.ChildNodes.Count;
       for (int j = 0; j < nPaths; j++) {
            var pathNode = levelNode.ChildNodes[j];
        string newName = pathNode.SelectSingleNode("NewName").InnerText;
        string itemRequired = pathNode.SelectSingleNode("ItemRequired").InnerText;
        tree.levels[i].nodes.Add(new TransNode(newName, itemRequired));
       }
    }
    tree.ConnectNodes(); // pretend these two
    tree.RemoveLevels(); // lines don't exist for now
    return tree;

这里是一个XML示例,以便更清晰地说明:

ItemName -> Level -> Path(即节点)-> Path数据。

<IOUTransformableItemsDatabaseManager>
  <GreenHerb>
    <Level_0>
      <Path_0>
        <NewName>RedGreenHerb</NewName>
        <ItemRequired>RedHerb</ItemRequired>
      </Path_0>
      <Path_1>
        <NewName>BlueGreenHerb</NewName>
        <ItemRequired>BlueHerb</ItemRequired>
      </Path_1>
    </Level_0>
    <Level_1>
      <Path_0>
        <NewName>GreyHerb</NewName>
        <ItemRequired>BlueHerb</ItemRequired>
      </Path_0>
    </Level_1>
  </GreenHerb>
</IOUTransformableItemsDatabaseManager>

现在这样做的问题在于,节点之间没有连接,那么这意味着什么?如果一个项目需要走到某个级别,那么我们肯定不需要继续存储它没有走过的其他路径,为什么要把它们保留在内存中呢?(一旦你选择了一条路径,就不能回头了,必须一直沿着这条路走下去)我希望的是,当我取出一个节点时,所有其下的节点也会随之下降,这是有意义的,但目前我所做的方式类似于这样:

enter image description here

如您所见,节点之间没有连接,它们之间的联系在于它们所处的级别!这意味着,目前没有办法让我取出一个节点,以便它同时取出所有子节点。(如果没有节点之间的连接,这样做非常困难,并且会大大降低性能)这就引出了:
tree.ConnectNodes(); 
tree.RemoveLevels();

我首先连接节点,然后再移除层级?为什么?因为如果我不这样做,那么每个节点都有两个引用,一个来自其父节点,另一个来自当前层级。 现在ConnectNode实际上是针对我展示的长树,而不是优化后只有7个状态的树:

    // this is an overloaded version I use inside a for loop in ConnectNodes()
    private void ConnectNodes(int level1, int level2)
    {
        int level1_nNodes = levels[level1].nodes.Count;
        int level2_nNodes = levels[level2].nodes.Count;

        // the result of the division gives us the number of nodes in level2,
        // that should connect to each node in level1. 12/4 = 3, means that each
        // node from level1 will connect to 3 nodes from level2;
        int nNdsToAtch = level2_nNodes / level1_nNodes;
        for (int i = 0, j = 0; i < level2_nNodes; j++)
        {
            var level1_nextNode = levels[level1].nodes[j];
            for (int cnt = 0; cnt < nNdsToAtch; cnt++, i++)
            {
                var level2_nextNode = levels[level2].nodes[i];
                level1_nextNode.nodes.Add(new TransNode(level2_nextNode));
            }
        }
   }

这就是我想要在另一棵树上实现的目标,但我不知道怎么做。我想连接节点并形成我展示的第二棵树,相对于四层来说它更加简单,我甚至无法在绘图中连接节点!(当我尝试四层时)如果你仔细观察,你会发现它有些类似于二进制数字,以下是我的树的二进制形式:
001
010
100
011
101
110
111

每个‘1’代表一个实际物品,‘0’表示为空。在我的树中,'001'=蓝色,'010'=绿色,'100'=红色,'011'表示绿色+蓝色,... '111'=灰色(最终层级)。
现在我已经解释清楚了一切,首先:我的方法正确吗?如果不是,那么是什么呢? 如果是这样,那么我可以使用/制作哪种数据结构来实现?如果我想到的数据结构放在它们的位置上,如何将XML文件中的数据以一种连接节点的方式存储到我的数据结构中,以便每当我取出一个节点时,它会连同其子节点一起取出?
非常感谢您的帮助和耐心 :)
编辑:有趣的是,整个系统只用于游戏中仅出现一次的物品(被拾取一次)。这就是为什么每当我走一条路时,我会从内存中删除它,并且每当我拿起一个物品时,我会从数据库中删除它的条目,因为我不会再遇到它。
编辑:请注意,我不仅使用字符串来表示我的物品,它们还有很多其他属性。但在这种情况下,我只关心它们的名称,这就是为什么我处理字符串的原因。

你的问题是关于找到最佳数据结构还是解决问题的最佳方法? - Nicolas Voron
那么,最好的方法不应该来自于最适合它的最佳数据结构吗? - vexe
这取决于您所说的数据结构是什么意思。我的问题是“这是一种纯理论方法”,还是“这段代码必须易于维护以供未来开发使用”(例如真实的游戏项目)? - Nicolas Voron
所谓数据结构,是指将所有内容组合在一起的结构(节点如何通信/连接,使用的类/结构等)-“真实游戏项目”?真实程度有多高?我已经在为一个“真实”的游戏项目做这个了。当然,我希望它是可维护的。 - vexe
好的。这可能只是一个小项目,结束于您在帖子中描述的概念。我会给您提供一种替代方案,并解释为什么(对我来说)这是更好的方法。 - Nicolas Voron
1
我不会把这个项目 http://ioublog.wordpress.com/category/gallery/ 称为小项目 :) - vexe
2个回答

2

我不喜欢这个解决方案的原因:

  • 简单的解决方案是最好的解决方案
  • 难以维护,因为你的XML是基于图形的。
  • 没有真正发挥面向对象编程的优势。
  • 可能会引起bug。
  • 针对小问题可能会使用Reflection(我说小问题是因为如果你做这样一个游戏,你会面临更困难的问题;))。 这导致不必要的复杂性。

我喜欢这个解决方案的原因:

  • 你已经完美地理解了问题。每个项目都有一系列与其他对象的转换列表。现在问题是如何表示(而不是存储)它们。

如果需要我的意见(仅代表个人观点,您的解决方案也很好):从节点的角度使用面向对象编程。 因此,如果要将其附加到数据结构,则树将变成一个状态机(就像您在谈论路径一样;)。

public class InventoryObject
{
    protected Dictionnary<Type, InventoryObject> _combinations = new Dictionnary<Type, InventoryObject>();

    public InventoryObject() {}       

    public InventoryObject Combine(InventoryObject o)
    {
       foreach (var c in _combinations)
          if (typeof(o) == c.Key)
            return c.Value

       throw new Exception("These objects aren't combinable");
    }
}

public class BlueHerb : InventoryObject
{
    public Herb()
    {
       _combinations.Add(RedHerb, new BlueRedHerb());
       _combinations.Add(GreenHerb, new BlueGreenHerb());
    }
}

public class BlueRedHerb: InventoryObject
{
    public BlueRedHerb()
    {
       _combinations.Add(GreenHerb, new GreyHerb());
    }
}

然后只需调用BlueHerb.Combine(myRedHerb);即可得到结果。您也可以执行BlueHerb.Combine(myStone);并轻松进行调试。

我尽可能简单地说明了示例。为了使代码更加清晰(例如使用类Herb、类CombinedHerb、使用LINQ查询等),还可以进行许多修改。


实际上,你刚才想到的方法正是我要处理“HealthItems”的方式 - 一般来说:游戏中出现多次的物品,其中一个原因是它们不需要数据库,因为数量有限(草药、药品、注射器等)。我可以通过代码预定义所有这些内容。而且,我确实有很多面向对象编程(继承、接口等),只是我没有展示出来。此外,你提到的字典部分,我也有一个类似于你的字典。 - vexe
但是对所有“其他”类型的项目都这样做只是很多手动工作,我可以使用一般解决方案来涵盖“其他”类别的项目。请查看此线程以获取有关我的项目的更多信息 http://gamedev.stackexchange.com/questions/59884/c-item-system-design-approach-should-i-use-abstract-classes-interfaces-or-vir?noredirect=1#comment106153_59884 - 我有一个想法,我会看看自己是否能回答。我也会研究你的想法。 - vexe
此外,我数据库方法的好处是现在连游戏设计师/艺术家都可以设置配对/关卡/路径了。我已经为他制作了一个简单的C#应用程序,供他与之交互并用于生成XML文件。 - vexe
好的,现在有意义了。你知道在C#中可以自动生成类吗(就像你的XML一样)? - Nicolas Voron
不幸的是,这里确实是这样。我希望我能充分告诉你,但这超出了范围。我甚至不知道询问这个相对简单的问题会构成这么大的问题。非常感谢您的帮助,我很感激。我只是在寻求想法,在看看你说的关于生成类是否有所帮助。 - vexe
显示剩余3条评论

2

好的,终于在摸索了几个小时并仔细研究整个系统之后,昨天我想出了一个点子,实施了它,效果非常好 :) 我刚刚测试了三级转换,它就像魔术一样奏效(图片只显示了一个组合,但我向你保证,所有组合都可以工作)。

enter image description here

让我来分享我的解决方案。我对之前的系统进行了几处调整,先说一下这些调整,再解释为什么做出每一处调整。

  • 我改变了每个节点存储的数据。在之前的方法中,我让每个节点依赖于前一个节点,现在,节点需求直接来自根节点。

以下是现在结构的样子:

public struct TransData
{
    public TransData(string itemName, List <string> itemsRequired)
    {
        this.itemName = itemName;
        this.itemsRequired = itemsRequired;
    }
    public string itemName;
    public List <string> itemsRequired;
}

Node构造函数:

public TransNode(string itemName, List <string> itemsRequired)
{
    data = new TransData(itemName, itemsRequired);
}

现在处理需求的一个例子:

Necklace L.1_A: Requires BlueGem
Necklace L.1_B: Requires GreenGem
Necklace L.1_C: Requires RedGem
Necklace L.2_A: Requires BlueGem AND GreenGem
Necklace L.2_B: Requires GreenGem AND RedGem
Necklace L.2_C: Requires RedGem AND BlueGem
Necklace L.3_A: Requires RedGem AND BlueGem AND GreenGem

XML现在是:

<IOUTransformableItemsDatabaseManager>
    <Necklace>
        <Level_0>
            <Path_0>
                <NewName>RedNecklace</NewName>
                <ItemsRequired>
                    <Item_0>Red</Item_0>
                </ItemsRequired>
            </Path_0>
            <Path_1>
                        .......
            </Level_0>
        <Level_1>
            <Path_0>
                <NewName>RedGreenNecklace</NewName>
                <ItemsRequired>
                    <Item_0>Red</Item_0>
                    <Item_1>Green</Item_1>
                </ItemsRequired>
            </Path_0>
            <Path_1>
                  .....
        </Level_1>
        <Level_2>
            <Path_0>
                <NewName>RedGreenBlueNecklace</NewName>
                <ItemsRequired>
                    <Item_0>Red</Item_0>
                    <Item_1>Green</Item_1>
                    <Item_2>Blue</Item_2>
                </ItemsRequired>
            </Path_0>
        </Level_2>
    </Necklace>
</IOUTransformableItemsDatabaseManager>
  • 我向树中添加了一个root节点,并删除了它的nodes列表,这样更加合理。以前的nodes列表现在等同于root.nodes

现在树的样子如下:

public class TransTree
{
    //public string itemName;
    //public List <TransNode> nodes;
    public List <TransLevel> levels = new List<TransLevel>();
    public TransNode root { private set; get; }
    public TransTree(string itemName)
    {
    //  this.itemName = itemName;
        root = new TransNode(itemName, null);
    }
}
  • 为什么我进行了第一个更改?(要求)因为这使我能够在节点之间进行一些交流,从而使我最终连接它们的方式与我想要的方式完全相同(就像我在问题中上面连接7个状态树的方式)。如何实现呢?- 在我的项链示例中,L.1_A和L.2_A之间的联系是什么?答案是,L.1_A有L.2_A的一个要求,即蓝色宝石,它们都有共同点,这导致我们得出结论:

每当两个中间隔着一个级别的节点有共同之处时,上一级节点应指向上面的节点。

所以我所做的只是在一个级别上遍历所有节点,并将该级别的每个节点与下一个级别的每个节点进行比较,如果第一个级别的节点具有下一个级别的节点具有的要求,则存在连接:

public void ConnectNodes()
{
   for (int i = 0; i < levels.Count - 1; i++)
       ConnectNodes(i, i + 1);
   ConnectRoot();
}
private void ConnectNodes(int level1, int level2)
{
    int level1_nNodes = levels[level1].nodes.Count;
    int level2_nNodes = levels[level2].nodes.Count;
    for (int i = 0; i < level1_nNodes; i++)
    {
        var node1 = levels[level1].nodes[i];
        for (int j = 0; j < level2_nNodes; j++)
        {
            var node2 = levels[level2].nodes[j];
            foreach (var itemReq in node1.data.itemsRequired)
            {
                if (node2.data.itemsRequired.Contains(itemReq))
                {
                    node1.nodes.Add(node2);
                    break;
                }
            }
        }
     }
}

请注意非常重要的一行代码:
ConnectRoot();

public void ConnectRoot()
{
    foreach (var node in levels[0].nodes)
       root.nodes.Add(node);
}

你明白了吗?- 我只是将根节点连接到第一层的节点,然后将第一层的节点连接到2,2连接到3等等。 当然,在清除层之前,我必须这样做。 这没有改变:

// fetching the data from the xml file
    tree.ConnectNodes();
    tree.RemoveLevels();

为什么我要进行第二次修改?(在树中添加了一个根节点) 好处显而易见,有了根节点更有意义。但我实现这个根节点的主要原因是为了轻松地清除我不需要的节点。我在我的问题中提到过,每当我选择一条路径/节点时,同一级别的节点应该消失,因为那就是它,我已经选择了一条路,不能回头。有了手边的根节点,现在我可以很容易地说:

tree.SetRoot(nodeTakenToTransform);

不需要遍历我没有选择的节点并将它们置空,只需将树的根更改为我选择的节点即可。这样做还有一个额外的好处,就是使我不必将我的树视为某种链表(要访问树下面的节点,我必须通过根节点、根节点和我想要访问的节点之间的所有节点以及最后到达目的地)。在每次转换后,根节点向下移动一级,现在我只需要访问根节点的节点即可。
还有一个问题未解决。这是我自己制作的字典中处理项的方法,当一个项目发生变化时,它会“通知”字典采取必要的操作(更新其键、值等)。
public void Notify_ItemTransformed(TransTree itemTree, TransNode nodeTaken)
{
   var key = new Tuple<string, string>(itemTree.root.data.itemName, nodeTaken.data.itemsRequired[0]);
   itemTree.SetRoot(nodeTaken);
   itemTree.UpdateNodesRequirements(itemTree.root); // take note here
   RemoveKey(key);
   RegisterItem(itemTree);
}
itemTree.UpdateNodesRequirements(itemTree.root) 是什么意思?
我的第一个更改引入了一个问题。例如:当我到达 L.1_A 时,我已经拥有 BlueGem 了,否则我就不会达到这个等级。但问题是,所有在这个级别之后需要BlueGem的节点,即使我已经拥有它,现在仍然要求它!他们不应该再要求它,也就是说,BlueGreenNecklace 现在只需要一个GreenGem - 这就是为什么现在我必须通过从我的新根开始递归向下来 更新 那些节点的要求。
tree.SetRoot(nodeTaken);
tree.UpdateNodesRequirements(tree.root);

这是方法:
public void UpdateNodesRequirements(TransNode node)
{
    foreach (var n in node.nodes)
    {
        if (n.data.itemsRequired.Contains(root.data.itemsRequired[0]))
            n.data.itemsRequired.Remove(root.data.itemsRequired[0]);
        if (n.nodes != null && n.nodes.Count > 0)
            UpdateNodesRequirements(n);
    }
}

我想说的是,“亲爱的下一级节点,如果你需要我已经拥有的东西,请删除该要求”- 就是这样 :) 希望你喜欢我的文章 XD- 我会在问题更新中放置我的下一个视频演示。
就性能而言?嗯,我可以在几个地方进行优化,但目前为止,使用3个级别进行测试非常好,没有任何降低。我怀疑我的游戏设计师会提出需要超过4个级别的东西,但即使他这样做了,我的代码应该足够快,如果不行,我可以在适当的位置使用“yield return null”来将工作分配到不同的帧上(我正在使用Unity3D)。
我真正喜欢这个解决方案的原因是,它适用于各种变换树,即使它不对称,也适用于nPaths和nLevels :)
感谢任何试图帮助并花时间阅读这篇文章的人。-- Vexe

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接