Using another algorithm ?
Until now, I used the DFS algorithm but I could use the
Breadth-First Search or the
Iterative Deepening DFS algorithms instead. Both have the advantage over DFS to find the shortest path. This is not a requirement in my case and the result of my final algorithm shows that 90% of paths found are the shortest ones, but that could deserve a try. In fact, there's no chance for these algorithms to beat my DFS version: the main difference between the BFS and DFS algorithms is the use of a queue instead of a stack; that prevents from writing BFS in a recursive way and we already saw than using a List is too costly so it's not worth trying. The IDDFS algorithm is a variant of the DFS algorithm where the recursion depth is increased by a loop each time that the current maximum depth returns no result. It is more suited for trees than mazes as it can go multiple times through the same paths. If you try to implement it, you will have to reinitialize the Visited array for each new maximum depth so you can easily imagine the huge penalty on the performance for medium and long paths.
DFS is far from perfect:
So we keep the DFS algorithm. But is there a mean to improve it? Look at how it proceeds: it starts by exploring the cell on the left of the starting position. It continues to the left until it meets the edge of the maze or a wall (a blocking cell). If blocked, it goes back and explores the cell on the right of the current position. It's where we come from so checking Visited will always return True. That seems to be a stupid move. So why do we start always to the left? And why do we go right if blocked? Because the DFS algorithm is not specifically designed for a maze and is far from perfect in this case. The sequence of search directions is arbitrary. In a tree, there's no mean to decide which branch to check first because there's not a predictable topology, just a succession of parent/child. In the navigation grid of a 2D game, you know right from the start the relative position of the goal (it can be on the left, on the right, above, etc.) and the distance between two cells not directly connected. Indeed, you can compute easily the distance between the starting cell and the destination, which is impossible in a tree. This can be used to orient the search. If the starting position is on the left of the goal, then moving right first is smarter than moving left. That can be wrong as the path can turn around the starting location before reaching the destination, but it has a higher probability to succeed. So I'm going to add some code to my DFS to select the appropriate sequence of search directions being given the relative positions at start. This code, that speeds up the algorithm by taking adaptative and approximate decisions based on probabilities, is called a heuristic.
A very simple heuristic:
Currently, the sequence is Left-Right-Top-Bottom. A better sequence would be Left-Top-Bottom-Right. I create a recursive function for this sequence (I take the current recursive function and I move the condition checking the right cell at the end) and I add three other functions for the following sequences: Right-Top-Bottom-Left, Top-Left-Right-Bottom and Bottom-Left-Right-Top. It's not ideal; in Left-Top-Bottom-Right, we have Bottom after Top while the contrary could be better, so the vertical choice is completely arbitrary, but it's a very simple approach, easy to implement. The sequence selection is based on the horizontal and vertical distances and is done by this code:
Code:
Sub SolveMaze8 As Boolean
'Initializes the Visited array
For X = 0 To SizeX - 1
For Y = 0 To SizeY - 1
Visited(X, Y) = False
Next
Next
'Selects the axis to follow
Dim DifX As Int = StartX - DestX
Dim DifY As Int = StartY - DestY
Dim Horizontal As Boolean = Abs(DifX) > Abs(DifY)
'Selects a sequence for the chosen axis
If Horizontal Then
If DifX > 0 Then
Return Recursive_SolveMaze_Left(StartX, StartY)
Else
Return Recursive_SolveMaze_Right(StartX, StartY)
End If
Else If DifY > 0 Then
Return Recursive_SolveMaze_Bottom(StartX, StartY)
Else
Return Recursive_SolveMaze_Top(StartX, StartY)
End If
End Sub
I run the benchmark a few times to be sure that the randomness of the maze creation does not favor a certain direction.
Results:
SolveMaze6 = 15.308 ms / Worst = 0.131 ms
SolveMaze8 = 13.407 ms / Worst = 0.132 ms
---
SolveMaze6 = 15.411 ms / Worst = 0.092 ms
SolveMaze8 = 13.506 ms / Worst = 0.088 ms
---
SolveMaze6 = 12.69 ms / Worst = 0.071 ms
SolveMaze8 = 11.262 ms / Worst = 0.073 ms
---
SolveMaze6 = 14.944 ms / Worst = 0.122 ms
SolveMaze8 = 13.516 ms / Worst = 0.122 ms
It's better in all cases but not very impressive.
Optimization of the heuristic:
In SolveMaze8, I use the Abs function which is known to be slow in many languages. I replace it by:
Code:
Dim Horizontal As Boolean
If DifX > 0 Then
If DifY > 0 Then
Horizontal = DifX > DifY
Else
Horizontal = DifX > -DifY
End If
Else
If DifY > 0 Then
Horizontal = -DifX > DifY
Else
Horizontal = -DifX > -DifY
End If
End If
As this code is called only once, the change should not have a significant effect. However, on all runs, I can see a very slight improvement.
Another optimization could be to re-compute the distance after each exploration to select the best sequence once again, but that would make the code too complex. It's better to rewrite everything with the same heuristic.
Optimization comes to a limit:
My DFS variant with four different sequences runs 2x faster than my original DFS implementation and 1.6x faster than my reference (SolveMaze2). The worst time is very good (close to 0.1 ms most of the time) so this is really usable in a game. I don't think that trying to improve again the DFS implementation is really necessary in most cases. And is it possible? B4A is not a language where you have many ways to write something and the algorithm is fairly simple.
However, a significant improvement could be achieved by using an array of bytes combining the values of the Blocked and Visited arrays to access the memory only once (it's what I did in my game) but the Blocked array initialization was always done until now outside the timing frame so I prefer to keep the two boolean arrays for the tutorial.
Another optimization could be to replace Visited by a single-dimensional array, but as this optimization can be applied to all algorithms of this tutorial and makes the code more complex and less readable, I will try it later.
The current version is good enough for many cases but not for my game where 200 units can be on screen at the same time. I want a smooth execution on all kind of devices so the fastest is the best. A new algorithm integrating the heuristic to minimize the number of visited cells is the next step in my search of the fastest solution. We saw above that starting in a chosen direction was a good idea, but the algorithm was unable to select the closest cell to the goal once blocked so the benefit was small. The purpose of the new algorithm is to select as often as possible the search direction with the highest probability of success.
My new algorithm:
Code:
Sub SolveMaze_FLMAlgo As Boolean
'Sets the initial state of the elements of the Visited array
For X = 0 To SizeX - 1
For Y = 0 To SizeY - 1
Visited(X, Y) = False
Next
Next
'Runs the recursive algo
Return Recursive_Algo(StartX, StartY)
End Sub
Sub Recursive_Algo(X As Int, Y As Int) As Boolean
'Marks as visited
Visited(X, Y) = True
'If the goal is reached...
If X = DestX AND Y = DestY Then Return True
'Computes the horizontal and vertical distances
Dim DistX As Int = DestX - X
Dim DistY As Int = DestY - Y
'Selects the axis to follow
Dim Horizontal As Boolean
If DistX > 0 Then
If DistY > 0 Then
Horizontal = DistX > DistY
Else
Horizontal = DistX > -DistY
End If
Else
If DistY > 0 Then
Horizontal = -DistX > DistY
Else
Horizontal = -DistX > -DistY
End If
End If
'Selects the next cell to visit
If Horizontal Then
'The horizontal axis is explored first
If DistX > 0 Then 'On the left -> to the right
Dim Xright As Int = X + 1
If Xright < SizeX AND Blocked(Xright, Y) = False AND Visited(Xright, Y) = False _
AND Recursive_Algo(Xright, Y) Then Return True
Else 'On the right -> to the left
Dim Xleft As Int = X - 1
If Xleft >= 0 AND Blocked(Xleft, Y) = False AND Visited(Xleft, Y) = False _
AND Recursive_Algo(Xleft, Y) Then Return True
End If
'Vertical axis
If DistY > 0 Then 'Too low -> to the top
Dim Ytop As Int = Y + 1
If Ytop < SizeY AND Blocked(X, Ytop) = False AND Visited(X, Ytop) = False _
AND Recursive_Algo(X, Ytop) Then Return True
Dim Ybottom As Int = Y - 1
If Ybottom >= 0 AND Blocked(X, Ybottom) = False AND Visited(X, Ybottom) = False _
AND Recursive_Algo(X, Ybottom) Then Return True
Else 'Too high -> to the bottom
Dim Ybottom As Int = Y - 1
If Ybottom >= 0 AND Blocked(X, Ybottom) = False AND Visited(X, Ybottom) = False _
AND Recursive_Algo(X, Ybottom) Then Return True
Dim Ytop As Int = Y + 1
If Ytop < SizeY AND Blocked(X, Ytop) = False AND Visited(X, Ytop) = False _
AND Recursive_Algo(X, Ytop) Then Return True
End If
'Last possible direction
If DistX > 0 Then
Dim Xleft As Int = X - 1
If Xleft >= 0 AND Blocked(Xleft, Y) = False AND Visited(Xleft, Y) = False _
AND Recursive_Algo(Xleft, Y) Then Return True
Else
Dim Xright As Int = X + 1
If Xright < SizeX AND Blocked(Xright, Y) = False AND Visited(Xright, Y) = False _
AND Recursive_Algo(Xright, Y) Then Return True
End If
Else
'The vertical axis is explored first
If DistY > 0 Then 'Too low -> to the top
Dim Ytop As Int = Y + 1
If Ytop < SizeY AND Blocked(X, Ytop) = False AND Visited(X, Ytop) = False _
AND Recursive_Algo(X, Ytop) Then Return True
Else 'Too high -> to the bottom
Dim Ybottom As Int = Y - 1
If Ybottom >= 0 AND Blocked(X, Ybottom) = False AND Visited(X, Ybottom) = False _
AND Recursive_Algo(X, Ybottom) Then Return True
End If
'Horizontal axis
If DistX > 0 Then 'On the left -> to the right
Dim Xright As Int = X + 1
If Xright < SizeX AND Blocked(Xright, Y) = False AND Visited(Xright, Y) = False _
AND Recursive_Algo(Xright, Y) Then Return True
Dim Xleft As Int = X - 1
If Xleft >= 0 AND Blocked(Xleft, Y) = False AND Visited(Xleft, Y) = False _
AND Recursive_Algo(Xleft, Y) Then Return True
Else 'On the right -> to the left
Dim Xleft As Int = X - 1
If Xleft >= 0 AND Blocked(Xleft, Y) = False AND Visited(Xleft, Y) = False _
AND Recursive_Algo(Xleft, Y) Then Return True
Dim Xright As Int = X + 1
If Xright < SizeX AND Blocked(Xright, Y) = False AND Visited(Xright, Y) = False _
AND Recursive_Algo(Xright, Y) Then Return True
End If
'Last possible direction
If DistY > 0 Then
Dim Ybottom As Int = Y - 1
If Ybottom >= 0 AND Blocked(X, Ybottom) = False AND Visited(X, Ybottom) = False _
AND Recursive_Algo(X, Ybottom) Then Return True
Else
Dim Ytop As Int = Y + 1
If Ytop < SizeY AND Blocked(X, Ytop) = False AND Visited(X, Ytop) = False _
AND Recursive_Algo(X, Ytop) Then Return True
End If
End If
Return False
End Sub
The algorithm seems a bit complex but it is not. It's just a mix between the DFS algorithm and my heuristic. What does it do? It tries to detect which cell around the current cell is the closest to the goal. It goes to this cell, detects which cell is the closest to the goal, and continues until the destination is reached.
My code can generate 8 different sequences: Left-Top-Bottom-Right, Left-Bottom-Top-Right, Top-Left-Right-Bottom, Top-Right-Left-Bottom, Right-Top-Bottom-Left, etc., and can select a new sequence for each recursive call.
To fully understand it, let's take an example. If the horizontal distance between DestX (column of the destination) and X (column of the current cell) is -10, that means that X is bigger than DestX and thus is located on the right of the destination. If DestY - Y = 8, then the starting cell is located below the destination. Moving to the left and to the top should reduce the distance to the goal and be the smartest move. But, as diagonal moves are not allowed, the algorithm has to order these two moves. To decide which will be first, it compares the absolute values of distances. 10 is greater than 8, so there are more cells to cross on the horizontal axis than on the vertical axis. The horizontal axis is selected. Why? A particular case helps to understand: if DestY - Y = 0 then the current cell is on the same row as the destination. X - DestX cannot be equal to 0 at the same time or the goal is reached and we exit the recursive function. So the only axis to follow is the horizontal one, the one with the biggest delta. This rule can be applied to all situations.
Let's continue. On the horizontal axis, the difference is negative so the algorithm checks the left cell first. If this is a dead end, it goes back and explores the top cell (8 > 0). If it's also a dead end, it explores the remaining cells: the bottom one, then the right. Let's suppose that the bottom cell is not visited and not blocked so the function is called recursively to explore this cell. New distances are computed and the biggest absolute distance defines the prefered axis, then the sign of the distance indicates the direction to follow on the axis. A new sequence is started. If it leads nowhere, we return where we were before going to the bottom and we explore the last cell, on the right.
The benchmark result for this new algorithm speaks by itself:
SolveMaze2 = 22.787 ms / Worst = 0.175 ms
SolveMaze9 = 13.29 ms / Worst = 0.137 ms
SolveMaze_FLMAlgo = 10.097 ms / Worst = 0.149 ms
---
SolveMaze2 = 23.041 ms / Worst = 0.138 ms
SolveMaze9 = 13.418 ms / Worst = 0.088 ms
SolveMaze_FLMAlgo = 10.277 ms / Worst = 0.12 ms
---
SolveMaze2 = 19.107 ms / Worst = 0.125 ms
SolveMaze9 = 11.261 ms / Worst = 0.073 ms
SolveMaze_FLMAlgo = 8.965 ms / Worst = 0.096 ms
With a single-dimensional array:
The drawback of multidimensional arrays is that they require an internal computation to locate the data in memory with the given indices, while a single-dimensional array uses directly the given index. To do the computation, it is necessary to get the length of dimensions, which is not a free operation. Moreover, you have to pass multiple arguments to functions and store multiple indices so there should be an obvious benefit to convert all multidimensional arrays to single-dimensional ones. Let's check whether it's true.
I convert Visited to a single-dimensional array. Two consequences: I have to initialize it linearly with a single loop, which is an advantage, but I have to add a few computations because DestX, DestY, StartX, StartY and the Blocked array are not suited for this change:
Code:
Sub SolveMaze_FLMAlgo_SDA As Boolean
'Sets the initial state of the elements of the Visited array
For Index = 0 To VisitedSDA.Length - 1
VisitedSDA(Index) = False
Next
'Runs the recursive SDA algo
DestIndex = DestX + (DestY * SizeX)
InvSizeX = 1 / SizeX
Return Recursive_FLMAlgo_SDA(StartX + (StartY * SizeX))
End Sub
Sub Recursive_FLMAlgo_SDA(Index As Int) As Boolean
'Marks as visited
VisitedSDA(Index) = True
'If the goal is reached...
If Index = DestIndex Then Return True
'Computes the horizontal and vertical distances
Dim Y As Int = Index * InvSizeX
Dim X As Int = Index - (Y * SizeX)
Dim DistX As Int = DestX - X
Dim DistY As Int = DestY - Y
'Selects the axis to follow
Dim Horizontal As Boolean
If DistX > 0 Then
If DistY > 0 Then
Horizontal = DistX > DistY
Else
Horizontal = DistX > -DistY
End If
Else
If DistY > 0 Then
Horizontal = -DistX > DistY
Else
Horizontal = -DistX > -DistY
End If
End If
'Selects the next cell to visit
Dim NewIndex As Int
If Horizontal Then
'The horizontal axis is explored first
If DistX > 0 Then 'On the left -> to the right
If X <> SizeX - 1 AND Blocked(X + 1, Y) = False Then
NewIndex = Index + 1
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
Else 'On the right -> to the left
If X <> 0 AND Blocked(X - 1, Y) = False Then
NewIndex = Index - 1
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
End If
'Vertical axis
If DistY > 0 Then 'Too low -> to the top
If Y <> SizeY - 1 AND Blocked(X, Y + 1) = False Then
NewIndex = Index + SizeX
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
If Y <> 0 AND Blocked(X, Y - 1) = False Then
NewIndex = Index - SizeX
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
Else 'Too high -> to the bottom
If Y <> 0 AND Blocked(X, Y - 1) = False Then
NewIndex = Index - SizeX
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
If Y <> SizeY - 1 AND Blocked(X, Y + 1) = False Then
NewIndex = Index + SizeX
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
End If
'Last possible direction
If DistX > 0 Then
If X <> 0 AND Blocked(X - 1, Y) = False Then
NewIndex = Index - 1
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
Else
If X <> SizeX - 1 AND Blocked(X + 1, Y) = False Then
NewIndex = Index + 1
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
End If
Else
'The vertical axis is explored first
If DistY > 0 Then 'Too low -> to the top
If Y <> SizeY - 1 AND Blocked(X, Y + 1) = False Then
NewIndex = Index + SizeX
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
Else 'Too high -> to the bottom
If Y <> 0 AND Blocked(X, Y - 1) = False Then
NewIndex = Index - SizeX
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
End If
'Horizontal axis
If DistX > 0 Then 'On the left -> to the right
If X <> SizeX - 1 AND Blocked(X + 1, Y) = False Then
NewIndex = Index + 1
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
If X <> 0 AND Blocked(X - 1, Y) = False Then
NewIndex = Index - 1
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
Else 'On the right -> to the left
If X <> 0 AND Blocked(X - 1, Y) = False Then
NewIndex = Index - 1
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
If X <> SizeX - 1 AND Blocked(X + 1, Y) = False Then
NewIndex = Index + 1
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
End If
'Last possible direction
If DistY > 0 Then
If Y <> 0 AND Blocked(X, Y - 1) = False Then
NewIndex = Index - SizeX
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
Else
If Y <> SizeY - 1 AND Blocked(X, Y + 1) = False Then
NewIndex = Index + SizeX
If VisitedSDA(NewIndex) = False AND Recursive_FLMAlgo_SDA(NewIndex) Then
Return True
End If
End If
End If
End If
Return False
End Sub
This code introduces a new optimization: instead of dividing Index by SizeX to get Y, I multiply it by the pre-computed result of 1/SizeX because on many CPU multiplications of floats are faster than divisions. Depending on the computation and on the CPU, the difference can go over 100%. In other cases, the difference is next to none, so it's a safe optimization.
The big question with this code is: does the overhead of the added computations remove the benefit of using a single-dimensional array? Let's see the results on different devices with different Android versions:
Moto G (KitKat):
SolveMaze_FLMAlgo_MDA = 10.097 ms / Worst = 0.149 ms
SolveMaze_FLMAlgo_SDA = 8.365 ms / Worst = 0.089 ms
---
Kindle Fire HD (FireOS based on ICS):
SolveMaze_FLMAlgo_MDA = 8.965 ms / Worst = 0.096 ms
SolveMaze_FLMAlgo_SDA = 7.643 ms / Worst = 0.094 ms
---
Huawei Honor (Gingerbread):
SolveMaze_FLMAlgo_MDA = 11.197 ms / Worst = 0.172 ms
SolveMaze_FLMAlgo_SDA = 9.889 ms / Worst = 0.134 ms
---
Nexus 7 (Lollypop):
SolveMaze_FLMAlgo_MDA = 7.393 ms / Worst = 0.078 ms
SolveMaze_FLMAlgo_SDA = 5.968 ms / Worst = 0.064 ms
Let's see if I get the same improvement with a previous solution with no heuristic, for example SolveMaze6:
SolveMaze6_MDA = 15.308 ms / Worst = 0.131 ms
SolveMaze6_SDA = 14.368 ms / Worst = 0.089 ms
The use of a SDA proves to be faster and I do appreciate the reduction in the worst time in all cases.
Now, the needs of my game are satisfied and I can stop there. I have no other idea anyway and I'm quite sure that all other optimizations will be very marginal. A gain of 1% or 2% does not really interest me, especially if I have to spend a few hours to get it.
In brief:
Between the Wikipedia version of the DFS algorithm converted to B4A and the final code using a SDA, there were many changes that increased the performance by more than 3. At the algorithm level, I removed what's useless for my case, moved the check of the Blocked and Visited arrays before the recursive call and added a heuristic. At the language level, I avoided two pitfalls: the Not operator and the Abs function, I replaced a division by a multiplication and I converted the Visited array to a SDA. Without these changes at the language level, my new algorithm would not have been very interesting. Look at the numbers with and without these optimizations:
SolveMaze_FLMAlgo Optimized(SDA) = 9.206 ms / Worst = 0.111 ms
SolveMaze_FLMAlgo Unoptimized(MDA,Not,Abs) = 21.602 ms / Worst = 0.297 ms
So a good algorithm is not enough for performance, a good implementation is also very important.
General tips:
Optimization is a vast subject and it needs more than a tutorial (and more than a book) to be fully covered. So here are just a few general tips:
- Optimizing is not the thing to do last, to improve the existing code, as so many people think. It's something to consider at all steps of your development. Replacing an algorithm or a data structure at the end, for example, can be rather complicated, with a few unexpected consequences.
- Optimizing is not mandatory. From an user point of view, a function that runs in 60ms and a function that runs in 30ms provide exactly the same experience. You should feel concerned by optimization only when the level of performance is not acceptable enough on the slowest device that you target or when you face problems, e.g. an OutOfMemory error.
- Optimizing means testing (a lot). Never trust what you read and even what you think. Try. Something that is true in most cases can be totally wrong in your specific case.
- To help you make choices during the development, and to be able to evaluate a change in your code, it is important to have tools or functions to profile the speed of your code or the speed of your database queries, to know how much memory or battery is used, to simulate some scenarios (do I have enough memory when the worst case occurs? is the speed acceptable when all units are on screen?), etc.
- These libraries will help you profile your functions, implement a cache (an optimization mean overlooked by many developers) or know the memory available to your application: Cache, StrictMode, NanoTime.
- When you profile the speed of your code, don't forget that many things in the background can alter the execution duration of your code, e.g. the antivirus application or the garbage collector, so repeat the same test many times to get an average time.
- Give an explicit type to all variables and functions that return a value. Declaring MyNumber, for example, with "Dim MyNumber" or "Dim MyNumber = 8" is the best way to definitely kill the performance of your application.
Faster code:
The little project at the end of this tutorial shows how to replace the Not operator and a few math functions (Abs, Atan2, Division, Floor, Round, Max, Power) by faster variants. Note that there's no faster alternative for the Sqrt function but, as this function is commonly used to compute a distance, you don't need to apply it when you compare two distances.