0

I'm writing an A* algorithm for Rush Hour (game) in order to learn the basics. For that I wrote a class named Board, which instances also act as the 'nodes' for the A* search.

For the algorithm to work, I generate (generate_moves()) the possible moves as new Board's and add them to the original board as children (std::priority_queue named children).

For some reason, the moment I decide to obtain a Boardfrom mystd::priority_queue childrencontainer withtop(), the pointer it has towards its parent (Board* parent), which is assigned previously during generation, turns into a pointer towards itself.

  1. Abbreviated generate_moves() function:
"for each possible move do"
...
Board newBoard = Board();
...
newBoard.parent = this; // I understand 'this' to always point towards the function caller.
children.push(newBoard); // Priority queue of the current board
...

The following images display the debugging process in the a_star() function (which takes the calling Board as root) after a single node expansion from root, while grabbing the very first 'child': before top() As you see, every element inside the open priority queue has the root parent, whose own parent is 'nullptr' (this is correct, root node has no parent).

custom score-comparing class Compare for the priority queues As you can see, everything is messed up already. From the very first comparison, the parent'parent pointer on every child now goes towards itself, instead of nullptr.

after top() (Board currNode) After the priority queue exits top(), The node is now messed up, having a self-referencing parent.

after top() (priority_queue open) The same goes for every Board inside the open priority queue, having a self-referencing parent.

I've been dabbling around this issue for a long time now. Any help would be deeply appreciated. If you require any additional context please let me know.

I've tried:

  • Replacing priority_queue with my own type, but I end up dealing with the issues of 'const'-required values, for which I end up creating what's basically the same class.
  • Giving the parent pointer as a constructor parameter. Didn't change a thing.
  • Instead of using a copy of the OPEN priority_queue for some comparisons that require popping (I don't want to do that to the original), I also tried popping the original and then pushing the nodes back in, as the 'copying' bit could've been the issue. Didn't change a thing.
  • I changed the Compare function from comparing reference values (Board&) to normal values (Board), as the issue seems to be happening during comparison. Didn't change a thing.

EDIT: Here's my attempt at a "minimal reproducible example". Not sure if consistent due to use of rand() to simplify some functions but it showcases the exact same issue:

#pragma once
#include <iostream>
#include <bitset>
#include <vector>
#include <queue>
#include <stdexcept>

using namespace std;

class SimplifiedBoard {
public:
    bitset<36> board;
    class Compare {
    public:
        bool operator() (SimplifiedBoard& a, SimplifiedBoard& b) {
            return (a.fscore < b.fscore);
        }
    };
    int fscore;
    priority_queue<SimplifiedBoard, vector<SimplifiedBoard>, Compare> children;
    SimplifiedBoard* parent;

    SimplifiedBoard() {
        fscore = 0;
        parent = nullptr;
    }

    // Move generator
    void generateMoves() {
        int arbitraryChildren = 5;
        for (int i = 0; i < arbitraryChildren; i++) {
            SimplifiedBoard newBoard = SimplifiedBoard();
            newBoard.fscore = (int)rand() / 100;
            // Assign parent to child
            newBoard.parent = this;
            // Add new child
            children.push(newBoard);
        }
    }

    // A*
    vector<SimplifiedBoard> aStar(SimplifiedBoard& start) {
        priority_queue<SimplifiedBoard, vector<SimplifiedBoard>, Compare> open = {};
        vector<SimplifiedBoard> closed = {};
        start.fscore = 0;
        start.parent = nullptr;
        open.push(start);

        // Loop
        while (!open.empty()) {
            // ***BUG HAPPENS here (2nd cycle, after root node has been expanded and closed)****
            SimplifiedBoard currNode = open.top(); open.pop();
            if (currNode.parent != nullptr &&
                currNode.parent->parent != nullptr &&
                currNode.parent == currNode.parent->parent) {
                cout << "Self-referential bug!" << endl;
            }
            // *** Here, in 2nd cycle, currNode.parent suddenly references a node which references itself ***
            
            // Child generation. f,g,h scores and 'parent' assigned inside function
            currNode.generateMoves();

            // For each child check OPEN/CLOSE and and see if I add to OPEN
            auto childrenCopy = currNode.children;
            for (int i = 0; i < (int)currNode.children.size(); i++) {
                bool isWorse = false;
                SimplifiedBoard currChildNode = childrenCopy.top(); childrenCopy.pop();

                // Victory?
                if (currChildNode.isVictory()) {
                    closed.push_back(currChildNode);
                    // Path reconstruction
                    vector<SimplifiedBoard> path = {};
                    SimplifiedBoard currPathNode = currChildNode;
                    // Ciclo child->parent
                    while (currPathNode.parent != nullptr) {
                        path.push_back(currPathNode);
                        currPathNode = *currPathNode.parent;
                    }
                    // Insert root
                    path.push_back(currPathNode);
                    // Return path
                    return path;
                }
                // OPEN
                auto openCopy = open;
                for (int j = 0; j < (int)open.size(); j++) {
                    SimplifiedBoard currOpenNode = openCopy.top(); openCopy.pop();
                    if (currChildNode.fscore <= currOpenNode.fscore) {
                        isWorse = true; break;
                    }
                }
                if (isWorse) { continue; }

                // CLOSED
                for (int j = 0; j < (int)closed.size(); j++) {
                    ;
                    if (currChildNode.fscore <= closed[j].fscore) {
                        isWorse = true; break;
                    }
                }
                if (isWorse) { continue; }

                //
                open.push(currChildNode);
            }
            // Close the node
            closed.push_back(currNode);
        }
        // return empty if can't find goal
        return vector<SimplifiedBoard>();
    }

    // Check victory
    bool isVictory() {
        if ((int)rand() % 50 == 5) { return true; }
        else { return false; }
    }
};

int main() {
    // Debug_example
    srand((int)time(NULL));
    SimplifiedBoard b = SimplifiedBoard();
    cout << "If it gets stuck the bug is happening:" << endl;
    vector<SimplifiedBoard> res = b.aStar(b);
    for (int i = 0; i < (int)res.size(); i++) {
        cout << res[i].fscore << endl;
    }
}
  • 3
    Can you put together a [mcve] that demonstrates the issue? – Retired Ninja Jun 17 '23 at 00:13
  • @RetiredNinja I'll try. – Gabriel Balassa Turu Jun 17 '23 at 00:16
  • Does `Board` have any other constructors, e.g. move ctor/copy ctor? – o_oTurtle Jun 17 '23 at 01:18
  • @o_oTurtle Nope. Only a constructor for the initial values. I'll be adding a MRE in a second. – Gabriel Balassa Turu Jun 17 '23 at 01:27
  • The output I got from your example is `Fscores:`. What is the expected output? How does this demonstrate the described issue? Could you better focus your example code by changing the output to demonstrate *"the pointer it has towards its parent [...] turns into a pointer towards itself"* instead of demonstrating the result of your A* search? – JaMiT Jun 17 '23 at 02:09
  • @JaMiT Ok, I'll try. In the meantime: The desired output is for the algorithm to finish (every time). The current output is the program getting stuck in a self-referencing loop. The debugging images display how the parent pointer changes after `top()`. – Gabriel Balassa Turu Jun 17 '23 at 02:52
  • @GabrielBalassaTuru `SimplifiedBoard` violates the [rule of 3](https://stackoverflow.com/questions/4172722/what-is-the-rule-of-three), yet you are storing these in a `std::vector` which will make copies. Instead of a raw pointer for the parent, better to use `std::unique_ptr parent;` or if that doesn't work `std::shared_ptr parent;`. – PaulMcKenzie Jun 17 '23 at 03:31
  • @GabrielBalassaTuru *"The debugging images display"* -- images are not a great way to convey this sort of information. Better would be a few lines like `std::cout << "after top() (Board currNode) = " << currNode.parent << '\n';` so that you can demonstrate the issue with the program's output (edited into the question as text). This makes your result easier to reproduce, and makes the question accessible to people for whom images are blocked (for whatever reason). – JaMiT Jun 17 '23 at 04:03
  • Additional thought: if the problem is at a known point in the code where `currNode.parent` happens to equal `currNode`, then at that point in the code add the lines `if ( currNode.parent == currNode ) { std::cout << "Cycle detected at currNode " << currNode << '\n'; std::exit(1); }`. This should break any infinite loops. (Keep in mind that breaking your A* search is fine, as long as the issue with the parent pointer is preserved.) – JaMiT Jun 17 '23 at 04:17
  • @JaMiT I edited the code as to point where the bug happens and output a message. – Gabriel Balassa Turu Jun 17 '23 at 17:18
  • @GabrielBalassaTuru Better. You are, though, still holding yourself back by trying to do an A* search instead of focusing on demonstrating the error. Your current example is 129 lines. I got that down to 67 by removing extraneous details. 1) Removed randomness. *Every score is 10, and victory never happens. Add a limit to the number of iterations.* 2) The problem requires a child be added to `open`, so assume each child is added to `open` (no need to check conditions). 3) Only one child needed. 4) Remove `fscore`, `bitset`, `closed`, and other now-unused code. – JaMiT Jun 17 '23 at 20:00
  • Another thing I did to your example was change `aStar` to return `bool`. After outputting *"Self-referential bug!"*, I added `return false;`. The other return statements became `return true;` (we only care if the bug occurred; the result of the search is not relevant to your question). Then the main function went down to `int main() { // Debug_example SimplifiedBoard b; if (b.aStar(b)) cout << "Bug did not happen?" << endl; }` Just an example of what you can do when you focus your [mre] (a useful debugging tool) on the issue rather than preserving your program. – JaMiT Jun 17 '23 at 20:09
  • @JaMiT Oh, I understand. It just feels like deleting more would obscure what I was attempting to do, and/or downright make it not bug-reproducible. Though, it is true that it's mostly irrelevant to the actual bug (I believe, it's not like I understand the actual nature of what exactly is wrong). – Gabriel Balassa Turu Jun 18 '23 at 00:47
  • The problem is using a raw pointer . The object pointed to by `this` stops existing (e.g. when the vector is resized or moved) and then the pointer dangles. If you're going to store raw pointers in an object you need to be extremely sure that the object will not outlive the object that the pointer is pointing to – M.M Jun 30 '23 at 05:36

1 Answers1

1

Simplifications

Before getting into what's going on, let's simplify your example. In its current form, your question is only applicable to others doing an A*-search, but the problem has nothing to do with searching. Generalizing and simplifying makes the question applicable to a wider audience.

One simplification, that I would expect a question to incorporate, involves realizing that the problem occurs when a node is added to open. So there is no need to test if a node should be added; it must be added to reproduce the bug, so add it. This greatly simplifies the code. Not only is the chunk of code testing close/open/victory eliminated, but also supporting code like isVictory() is eliminated. As a corollary, the score (and randomness) can be eliminated – let the priority queue think all nodes are equivalent. (I suppose you could replace the priority queue with a vector at this point, but I'll hold off on that.) Simplify, compile, run, and verify the bug still manifests!

There are other simplifications that I think also should have been incorporated into the question's code, but their impact is smaller. (Some are minor enough that I did not bother with them.) There are also some simplifications that I would not expect from someone who does not know the answer. These are helpful, though, so I've incorporated them in the code below. Perhaps the most notable of these simplifications is that I don't store children in the node. To reproduce the bug, it is enough to have a node point to its parent; going from parent to child is not necessary. This makes it reasonable to inline generateMoves(), a function that helped obscure the situation.

To further clarify the situation, I've added some diagnostic output at key locations. (Knowing which locations are key is easier when you know the answer. Still, knowing which locations are potentially key is a useful debugging skill.)

#include <iostream>
#include <queue>

using namespace std; // <-- Not recommended, but I'll leave it alone

class SimplifiedBoard {
public:
    bool operator< (const SimplifiedBoard&) const {
        return false;
    }
    SimplifiedBoard* parent;

    SimplifiedBoard(SimplifiedBoard* parent = nullptr) : parent(parent) {}

    // Return true on success, false on the bug.
    bool aStar(SimplifiedBoard& start) {
        // Initial setup
        priority_queue<SimplifiedBoard> open = {};
        open.push(start);

        // Loop (with an arbitrary cap)
        while (!open.empty() && open.size() < 50) {
            // Remove the top of the priority queue.
            SimplifiedBoard currNode = open.top(); open.pop();
            std::cout << "Address of currNode: " << &currNode << "\n";

            // ***BUG seen here****
            if (currNode.parent != nullptr &&
                currNode.parent == currNode.parent->parent) {
                std::cout << "Address of parent:   " << currNode.parent << "\n";
                return false;
            }
            
            // Add a child to OPEN.
            open.push(SimplifiedBoard(&currNode));
        }
        return true;
    }
};

int main() {
    SimplifiedBoard b;
    if (b.aStar(b))
        cout << "Success!\n";
    else
        cout << "Self-referential bug!\n";
}

Here is the output:

Address of currNode: 0x7fff3a547408
Address of currNode: 0x7fff3a547408
Address of parent:   0x7fff3a547408
Self-referential bug!

Analysis

Notice the first two lines of output. Even though currNode is a new object each iteration, its address remains the same. While this is not guaranteed, it is typical. This is ingredient one.

The second ingredient is the construction of child nodes. What is assigned as the parent of these nodes? If you work through the question's code, you should see that the parent of the children is currNode (the *this for the call to generateMoves()). In the simplified version, this is easier to see, with the expression SimplifiedBoard(&currNode). So every child node has its parent set to currNode. While this is a different object each iteration, we just saw that its address does not change. Every child is given the same address for its parent. Looking familiar?

I should note that the parent pointer becomes a dangling pointer at the end of the current iteration of the loop. That means the behavior of your code is undefined, so your results are not guaranteed. However, your results are typical.

Now to wrap up what happens, think about what happens to currNode in the second iteration. The top node of the priority queue is copied into currNode. In the first iteration, its parent pointer is null, but in the second iteration, its parent pointer is &currNode. This is the loop you see. The parent of currnode is currNode, as demonstrated in the third line of output. Once you realize this, you can simplify your test for the bug. Instead of going to currNode.parent->parent, you could simply check currNode.parent == &currNode and skip the null check.

So it's not so much that "the pointer it has towards its parent [...] turns into a pointer towards itself", but that the node is copied to where the parent pointer points. It's not this->parent that changes, but this.

Solution

So what can you do about this? The basic problem is that you create short-lived copies, when you need long-lived objects. A solution is to not create the copies.

Rather than creating new objects, your aStar function could use pointers to the existing objects. If object lifetime is a concern, you could use std::shared_ptr, but given the context, raw (non-owning) pointers are probably sufficient. Just be sure that all children are created before obtaining any of their addresses. Adding and removing elements from a container sometimes (depending on the container) invalidates pointers to other elements of that container.

I'll add emphasis on "your aStar function". The open and closed containers are what should store pointers instead of objects. What you do with the data members of SimplifiedBoard is a separate concern. And, yes, when you stop making copies, you will need to go back to storing children in the parent. This is a promising sign.

Here is an example of using pointers. This code does not exhibit the self-referential bug (but I left in the diagnostics).

#include <iostream>
#include <vector>

struct Node {
    Node* parent = nullptr;
    std::vector<Node> children; // <-- Not necessarily pointers

    void generateMoves() {
        // Only one child in this example
        children.emplace_back(this);
        // If not on a recent enough compiler version and C++20:
        // children.emplace_back(Node{.parent = this, .children = {}});
    }
};

// Return true on success, false on the bug.
bool aStar(Node& start) {
    std::vector<Node*> open{&start}; // <-- Pointers

    // Loop (with an arbitrary cap)
    while (open.size() < 50) {
        Node* currNode = open.back();
        std::cout << "Address in currNode: " << currNode << "\n";

        // ***BUG was seen here****
        if (currNode->parent == currNode) {
            std::cout << "Address of parent:   " << currNode->parent << "\n";
            return false;
        }

        // Add a child to OPEN.
        currNode->generateMoves();
        open.emplace_back(&currNode->children[0]);
    }
    return true;
}

int main() {
    Node b;
    if (aStar(b))
        std::cout << "Success!\n";
    else
        std::cout << "Self-referential bug!\n";
}
JaMiT
  • 14,422
  • 4
  • 15
  • 31
  • Thank you for your answer. While I'm no absolute newbie to C++ it's really hard to get used to pointers when coming from garbage-collecting languages, specially when the issue becomes obscured by standard containers (which one usually abuses freely as though garbage collection was never gone and pointers weren't a thing). – Gabriel Balassa Turu Jul 22 '23 at 02:58
  • **Note**: The latter 'fixed' code is throwing me a `Error C2664 'Node::Node(const Node &)': el argumento 1 no puede convertirse de '_Ty' a 'const Node &` error due to the `this` keyword. Not really sure why. Replaced it with `Node()`. – Gabriel Balassa Turu Jul 22 '23 at 03:47
  • @GabrielBalassaTuru *"The latter 'fixed' code is throwing me a [error]"* -- Could be that your compiler has incomplete C++20 support or that it is compiling for an earlier standard. (A quick check suggests this code needs gcc 10 or clang 17.) A C++17 version is `children.emplace_back(Node{.parent = this, .children = {}});`. – JaMiT Jul 22 '23 at 16:56
  • Right. I'm using VS2019 still. – Gabriel Balassa Turu Jul 23 '23 at 21:01