Lee 算法迷宫求解器





5.00/5 (9投票s)
MFC 和 Direct2D 中的 Lee 算法迷宫求解器。
目录
引言
1999年,我的团队在新加坡机器人比赛(SRG)中赢得了避障机器人(OAR)的一等奖。那已经是20年前了。我没有OAR的照片,但它看起来非常像微型鼠。微型鼠的迷宫是用墙壁构建的,而OAR的迷宫是用障碍物构建的。我们学校的微型鼠和OAR团队都使用 Lee算法 来解决迷宫。Lee算法在20世纪60年代用于路由单层印刷电路板(PCB),直到Google将其用作面试技术测试,它才在历史上留下了印记。我必须指出,根据我对算法的回忆,维基百科页面和热图中的描述似乎有些问题,按理说,热图在障碍物周围应该会发生变化。Lee算法是一个非常简单的算法,机器人从高值单元格移动到低值单元格。每个单元格的值由其4个相邻单元格的最小值加1确定。不考虑对角线单元格的值。
绿色单元格是起点,黄色单元格是终点。程序无法看到整个障碍物地图。它在探索过程中会更新地图中的障碍物信息。当点击开始按钮时,有两个运行过程:第一个是迷宫求解模式,第二个是优化路径模式。优化路径可能不是最短路径,因为如我所说,它没有完整的地图,优化路径是基于它之前探索过的路径。
最初在20年前用Turbo C++编写的模拟器是DOS版本的。这次,我用MFC和Direct2D写了一个Windows版本。Direct2D用起来令人惊讶地直观。文章的前半部分解释了Lee算法,后半部分讲解了Direct2D代码。
Lee算法用于迷宫求解
选择一个最大值 m
来表示障碍物,例如 0xFF
,而未被占用的单元格的默认值是 m-1
。为什么是 0xFF
?最大值取决于你的迷宫的单元格尺寸,或者(如果是PCB布线,则为网格)。一个16x16单元格的迷宫将是16*16=256(0x100
)。换句话说,选择一个在任何情况下都不可能超过的最大值。选择 0xFF
是因为嵌入式硬件的限制,因为二维数组是由 unsigned char
组成的,这是由于主板RAM有限。
- 将当前机器人设置在 cell[0][15]。
- 将目标单元格 cell[12][3] 的值设置为 0。
- 在移动到下一个单元格之前,执行以下步骤来确定下一个单元格。
- 将
changed
变量设置为false
。 - 如果单元格未被障碍物占据,则将所有空单元格初始化为
m-1
。 - 对每个空单元格执行以下操作,即跳过被障碍物占据的单元格。通过找到相邻单元格(上、下、左、右)的最小值并加一来计算当前单元格的值,如果新值不等于当前值,则将
changed
设置为true
。将新值设置为单元格的值。 - 如果
changed
为true
,则重复步骤4到7,否则执行步骤8。 - 移动到具有最小值的下一个相邻单元格。
- 如果当前单元格是目标单元格 cell[12][3],则停止,否则从步骤3开始。
上述伪代码未记录一些默认行为。如果前方单元格与左侧或右侧单元格具有相同的最小值,则倾向于直线前进而不是转弯。如果左侧或右侧单元格具有相同的最小值,则倾向于右转。这些默认设置没有对错之分,取决于迷宫。机器人可能右转导致死胡同,而左转可能导向终点。无论你选择哪种默认行为,都要坚持下去。
这是一个部分加权图(完整地图太大无法显示),地图中除目标单元格外,所有其他单元格均初始化为 0xFE
,而目标单元格设置为 0x00
。
FE|FE|FE|FE|FE|FE|FE
FE|FE|FE|FE|FE|FE|FE
FE|FE|FE|FE|FE|FE|FE
FE|FE|FE|00|FE|FE|FE
FE|FE|FE|FE|FE|FE|FE
FE|FE|FE|FE|FE|FE|FE
FE|FE|FE|FE|FE|FE|FE
初始化地图后,通过获取所有相邻单元格并找到它们之间的最小值,加一并将其设置为当前单元格的值来计算加权。生成的(部分)加权图如下所示。
06|05|04|03|04|05|06
05|04|03|02|03|04|05
04|03|02|01|02|03|04
03|02|01|00|01|02|03
04|03|02|01|02|03|04
05|04|03|02|03|04|05
06|05|04|03|04|05|06
如果存在此迷宫求解器无法解决的迷宫,请将其保存并发送给我,或在此论坛上给我留言。当然,如果你完全用障碍物围住黄色目标单元格,它将永远运行下去,直到你停止它。以下是当目标单元格被障碍物(用 0xFF
表示)包围时的加权图。
FE|FE|FE|FE|FE|FE|FE
FE|FE|FE|FE|FE|FE|FE
FE|FE|FF|FF|FF|FE|FE
FE|FE|FF|00|FF|FE|FE
FE|FE|FF|FF|FF|FE|FE
FE|FE|FE|FE|FE|FE|FE
FE|FE|FE|FE|FE|FE|FE
重要说明
经典Lee算法文献与本文的区别在于,起点和终点被互换了。经典论文说将起点设置为零,而不是终点。这就是为什么我使用目标单元格而不是终点来避免混淆。在PCB布线中,没有真正的起点和终点的概念,算法只是试图连接两个端点。
Direct2D 代码
在本节中,我们将检查使用的Direct2D代码。Direct2D、DirectWrite和Windows图像组件(WIC)是从头开始设计的,用于取代老化的GDI+,它们的方法调用非常直观,唯一的抱怨是类名有时非常冗长,这是描述性强所带来的副作用。Direct2D和DirectWrite基于组件对象模型(COM),但不需要 CoInitialize()
和 CoUninitialize()
调用。这个例外规则不适用于WIC,它需要COM运行时。在这个迷宫求解器应用程序中,我们只使用Direct2D,因为不需要文本和图像渲染。要使用Direct2D,我们需要Windows运行时库(WRL)的 ComPtr
智能指针,它在某种程度上类似于ATL的 CComPtr
(注意额外的 C
前缀),它们都通过增加引用计数来保持COM对象存活,通过 IUnknown
接口的 AddRef()
,并在引用计数减到零后通过 Release()
销毁它。下面是应用程序所需的头文件列表。
#include <wrl.h> // Windows Runtime Library for its ComPtr
#include <d2d1.h> // Direct2D v1 header
#include <stdexcept> // C++ exception class
#include "ComException.h"
#include "FactorySingleton.h" // Singleton to get Direct2D factories
接下来,我们导入Direct2D库进行链接。
// Direct2D import libraries
#pragma comment(lib, "d2d1")
我们有一个 FactorySingleton
来限制 ID2D1Factory
实例只有一个。
using namespace D2D1;
using namespace Microsoft::WRL;
class FactorySingleton
{
public:
static ComPtr<ID2D1Factory> GetGraphicsFactory();
private:
static ComPtr<ID2D1Factory> m_GraphicsFactory;
};
如果 m_GraphicsFactory
是 nullptr
,FactorySingleton::GetGraphicsFactory()
会创建工厂。
#include "FactorySingleton.h"
ComPtr<ID2D1Factory> FactorySingleton::m_GraphicsFactory;
ComPtr<ID2D1Factory> FactorySingleton::GetGraphicsFactory()
{
if (!m_GraphicsFactory)
{
D2D1_FACTORY_OPTIONS fo = {};
#ifdef DEBUG
fo.debugLevel = D2D1_DEBUG_LEVEL_INFORMATION;
#endif
HR(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED,
fo,
m_GraphicsFactory.GetAddressOf()));
}
return m_GraphicsFactory;
}
接下来是 ComException
和 TestResult
的声明。
#include <Windows.h>
#include <string>
void TestResult(const char* expression, HRESULT hr);
struct ComException
{
HRESULT const hr;
std::string where;
ComException(const char* expression, HRESULT const value) : where(expression), hr(value) {}
std::string Message() const;
};
定义了 TestResult()
和 ComException::Message()
的主体。TestResult()
实际上在 HR
宏中使用,用于检查失败的 HRESULT
。
#include "ComException.h"
void TestResult(const char* expression, HRESULT hr)
{
if (FAILED(hr)) throw ComException(expression, hr);
}
std::string ComException::Message() const
{
char buf[800];
memset(buf, 0, sizeof(buf));
sprintf_s(buf, "ComException hr:%x, Where:%s", hr, where.c_str());
std::string str = buf;
return str;
}
// Macro to test HRESULT and throw ComException for failed HRESULT
#define HR(expression) TestResult(#expression, (expression));
接下来,我们使用 ComPtr
智能指针声明渲染 target(RT)
成员对象和画笔。要在Direct2D中绘制内容,我们需要至少一个渲染目标。但为什么有两个渲染目标呢?m_BmpTarget
是基于位图的离屏RT。当绘制到 m_DCTarget
时,用户可能会看到不完整或部分的绘制,因此所有内容都绘制在离屏的 m_BmpTarget
上,然后 m_DCTarget
会 DrawImage()
这个位图。
ComPtr<ID2D1DCRenderTarget> m_DCTarget;
ComPtr<ID2D1BitmapRenderTarget> m_BmpTarget;
ComPtr<ID2D1SolidColorBrush> m_BmpBlackBrush;
ComPtr<ID2D1SolidColorBrush> m_BmpWhiteBrush;
ComPtr<ID2D1SolidColorBrush> m_BmpYellowBrush;
ComPtr<ID2D1SolidColorBrush> m_BmpGreenBrush;
ComPtr<ID2D1SolidColorBrush> m_BmpRedBrush;
Direct2D的设备相关资源,如画笔,与RT绑定。你不能在另一个RT上使用从一个RT创建的资源。CreateDeviceResources()
是一个创建画笔的函数。调用 ReleaseAndGetAddressOf()
来释放COM对象,并在一个指针的指针参数中返回 ComPtr
封装的原始指针成员的地址,因为即将创建一个新的 brush
对象并分配给这个参数。
void CreateDeviceResources(
ID2D1RenderTarget* target,
ComPtr<ID2D1SolidColorBrush>& brush,
D2D1_COLOR_F color)
{
HR(target->CreateSolidColorBrush(color,
brush.ReleaseAndGetAddressOf()));
}
接下来,展示了 m_DCTarget
和 m_BmpTarget
的创建。我们需要将 pixelFormat
指定为 DXGI_FORMAT_B8G8R8A8_UNORM
,以便与GDI的设备上下文(DC)格式兼容。这就是 m_DCTarget
渲染到DC的原因。Get()
用于返回 ComPtr
智能指针封装的RT的内部原始指针。
// Create a pixel format and initial its format
// and alphaMode fields.
// https://docs.microsoft.com/en-gb/windows/win32/direct2d/
// supported-pixel-formats-and-alpha-modes#supported-formats-for-id2d1devicecontext
D2D1_PIXEL_FORMAT pixelFormat = D2D1::PixelFormat(
DXGI_FORMAT_B8G8R8A8_UNORM,
D2D1_ALPHA_MODE_PREMULTIPLIED
);
D2D1_RENDER_TARGET_PROPERTIES props = D2D1::RenderTargetProperties();
props.pixelFormat = pixelFormat;
HR(FactorySingleton::GetGraphicsFactory()->CreateDCRenderTarget(&props,
m_DCTarget.ReleaseAndGetAddressOf()));
m_DCTarget->BindDC(pDC->GetSafeHdc(), &rect);
HR(m_DCTarget->CreateCompatibleRenderTarget(m_BmpTarget.ReleaseAndGetAddressOf()));
m_BmpTarget->SetAntialiasMode(D2D1_ANTIALIAS_MODE_PER_PRIMITIVE);
m_BmpTarget->SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_CLEARTYPE);
CreateDeviceResources(m_BmpTarget.Get(), m_BmpBlackBrush, COLOR_BLACK);
CreateDeviceResources(m_BmpTarget.Get(), m_BmpWhiteBrush, COLOR_WHITE);
CreateDeviceResources(m_BmpTarget.Get(), m_BmpYellowBrush, COLOR_YELLOW);
CreateDeviceResources(m_BmpTarget.Get(), m_BmpGreenBrush, COLOR_GREEN);
CreateDeviceResources(m_BmpTarget.Get(), m_BmpRedBrush, COLOR_RED);
如果你一直在想画笔的颜色是如何定义的,它们列在下面。D2D_COLOR_F
的颜色通道是RGBA格式,使用 float
类型。
D2D_COLOR_F const COLOR_WHITE = { 1.0f, 1.0f, 1.0f, 1.0f };
D2D_COLOR_F const COLOR_BLACK = { 0.0f, 0.0f, 0.0f, 1.0f };
D2D_COLOR_F const COLOR_YELLOW = { 0.99f, 0.85f, 0.0f, 1.0f };
D2D_COLOR_F const COLOR_GREEN = { 0.0f, 1.0f, 0.0f, 1.0f };
D2D_COLOR_F const COLOR_RED = { 1.0f, 0.0f, 0.0f, 1.0f };
要绘制一条白线,需要调用 DrawLine()
,并提供一个起点、一个终点和一个白色画笔。
D2D1_POINT_2F p0{ 0.0f, sum };
D2D1_POINT_2F p1{ 50.0f, sum };
m_BmpTarget->DrawLine(p0, p1, m_BmpWhiteBrush.Get());
要绘制一个白色的障碍物,需要调用 FillRectangle()
,并提供一个矩形和一个白色画笔。
auto rectTarget = RectF(0.0f, 0.0f, 10.0f, 10.0f);
m_BmpTarget->FillRectangle(&rectTarget, m_BmpWhiteBrush.Get());
避障机器人由一个绿色圆圈表示。要绘制一个圆形的椭圆,需要使用一个中心点和两个相等的 radiusX
和 radiusY
值来初始化 D2D1_ELLIPSE
。然后将 D2D1_ELLIPSE
对象连同其画笔一起传递给 FillEllipse()
。
D2D1_ELLIPSE ell;
ell.point = Point2F(5.0f, 5.0f);
ell.radiusX = 3.0f;
ell.radiusY = 3.0f;
m_BmpTarget->FillEllipse(ell, m_BmpGreenBrush.Get());
接下来,我们准备实现 OnPaint()
函数。如果 m_DCTarget
是 nullptr
,CreateDCTarget()
会同时创建 m_DCTarget
和 m_BmpTarget
。在任何绘图调用之前必须调用 BeginDraw()
,在所有绘图调用完成后必须调用 EndDraw()
。DrawMap()
和 DrawObstacles()
在 m_BmpTarget
上执行。然后 m_DCTarget
与DC绑定,这是每次 OnPaint()
调用都必须执行的操作。在 m_DCTarget
的 BeginDraw()
和 EndDraw()
之间,m_DCTarget
绘制 m_BmpTarget
的内部位图。如果 EndDraw()
因 D2DERR_RECREATE_TARGET
而失败,则重置 m_DCTarget
(表示销毁),Invalidate()
发送 WM_PAINT
消息,使 OnPaint()
再次被调用,此时 m_DCTarget
为空(将被重新创建)。
CPaintDC dc(this);
if (!m_DCTarget)
{
CreateDCTarget(&dc);
}
ComPtr<ID2D1Bitmap> bitmap;
m_BmpTarget->GetBitmap(bitmap.GetAddressOf());
CRect rectClient;
GetClientRect(&rectClient);
RECT rect;
rect.top = 0;
rect.bottom = rectClient.Height();
rect.left = SHIFT_LEFT;
rect.right = rectClient.Width();
m_BmpTarget->BeginDraw();
DrawMap();
DrawObstacles();
m_BmpTarget->EndDraw();
m_DCTarget->BindDC(dc.GetSafeHdc(), &rect);
m_DCTarget->BeginDraw();
m_DCTarget->DrawBitmap(bitmap.Get());
if (D2DERR_RECREATE_TARGET == m_DCTarget->EndDraw())
{
m_DCTarget.Reset();
Invalidate();
return;
}
最低操作系统应为Windows 7及以上版本,因为没有使用Windows 8及以上版本Direct2D的功能。玩这个程序玩得开心!
代码托管在 GitHub。
参考文献
- 维基百科上的Lee算法
- Kenny Kerr 的 Direct2D基础
历史
- 2024-03-04: 修复了优化路径,提供了障碍物地图的完整视图。
- 2021-12-04: 添加了10个新的可下载迷宫。注意: 优化路径基于第一次遍历的路径,可能存在未发现的更优路径。机器人没有完整的地图视图。
- 2020-01-01: 添加了“重要说明”部分
- 2019-12-22: 首次发布