图着色问题,很容易理解。
我们看的地图不同地区和国家之间有共用的边界的时候,总是用两种不同的颜色来表示两个区域,以便于一眼就能识别出两者的差异。
我们这里说的图着色问题,并不是地图的着色问题,也不涉及四色定理一类的。此图非彼图,Graph!=Map,尽管众多的关于地图着色的问题也可以抽象到图着色的问题上来,但我们此时并不关心这一点。我们说的图,是这种东西。下面的PNG图像是用一个叫DOT的语言描述并用DOT软件自动生成的:
我们要求这个由节点和边组成的图,用边相连的两个节点具有不同的颜色。就像这个(PNG图像所描绘的)图一样。
知道的人知道,这又是一个NP问题,具体是NP-Complete还是NP-Hard我就不清楚了。
这个问题现在大家是怎么解的呢?
比如从1开始,把节点1画成蓝色,和它相邻的节点2,3,4,都用和1不同的颜色。比如都用绿色,这时候2,3,4,都是绿色。可是看到节点3的时候,发现3和4都是绿色显然不行,而且2和3也都是绿色,当然也不行,相邻的节点不能用一样的颜色。
这种情况显然是不允许的。现在的算法看到这种情况,就回到上一步(称为回溯),至少不能让2,3,4都标上绿色。可是我们知道,如果这个图相当的大,回溯的可能性也相当的大,因为不知道新遇到的节点是不是已经标注颜色,也不知道新节点是否会和先前标注的颜色冲突,所以回溯会经常发生,而这种尝试的次数,也是指数级别的,所以才叫NP问题。
让我们再想一遍,为啥要回溯呢?因为要标注的新节点的颜色,可能会和周围的颜色相冲突,比如我们要求用5种颜色来标注这个图上的节点,但是现在这个节点周围的节点已经占据了所有的五种颜色,那么新节点只能用第六种颜色,才能避免和周围任何一个节点的颜色一样。
可是我们怎么知道这件事什么时候会发生,发生的时候又是怎么样的?毕竟在最开始的时候,所有的节点都没有标注颜色的时候,后面到底走怎样的路径显然不是一目了然的,就算是在过程中,哪怕就是上一步,也没法预知未来的情况。所以这样的尝试似乎是无法避免的。
一个节点的颜色决定于那些它所关联的节点的颜色。要是能让它所关联的节点先着色,它就自然知道了应当着什么颜色,或者在什么颜色范围里面可以选择。所以问题要向前推,可是就算是这样,我们仍然无法预知未来。
既然前提前推无法实现,那么决定后推是否可以做到呢?比如说,让那些可能发生冲突的情况尽量发生在未来,而确定性的处理好现在发生的问题。换句话说,是不是某些个节点的颜色决定得太早了,才导致了在未来的颜色选择上面无路可走?
有了这个想法,我们就要考虑,着色的时序问题。DOT语言有一个功能,叫做Rank。也就是设置级别的意思。一个节点可以被对齐在和它同样级别的节点上。比如从左到右的布局中,若不特别指出,则按照级别排列。上图中2和3从左到右的方向上并没有垂直对齐,而是有先后关系,这就是所谓级别的体现,之所以3在2后面,是因为3并不仅仅被1连接,还被2连接,而且2也被1连接,所以它在1后面的一级的后面的一级上,而不是和2一样就在后面。
我们变个魔术,让它们纵向对齐在一起:
这是DOT语言中写出{ rank=same; 2;3};产生的结果。
我们提到DOT语言,是因为这种Rank的想法,实际上可以帮助我们建立节点遍历的时序。
现在大家显然都开始习惯性的用面向对象的方式来描述图了。虽然链表和邻接矩阵也不是问题,但面向对象的方法,建立节点对象和边对象,能给我们提供更多的操作选项。我们用计算机中的对应的物件(object)来描述现实问题中的对应的事物,用物件之间的关系和操作来对应现实中事物的交互作用。而这里,我们用类和对象来构建对象网络,描述图论问题,显然要比用矩阵这种抽象的方式更容易理解。(这一步的要点在于:具象好过抽象,容易理解和掌握)。
我们先前遍历节点,基本上不是BFS(深度遍历)就是DFS(广度)遍历,我们用隐式或者显式的栈记录我们当前视点的位置。实际上就像递归总可以展开成循环,我们可以把BFS和DFS展开成为集合操作前提下的迭代(Iteration)。比如说,我们先前用DFS,看过1节点之后,看2,然后看5,再回头看4,再回头再回头看3,这样进进出出的取挨个查看每个节点。
事实上有了对象网络之后,我们从1开始,可以同时看2和3(并不需要多线程),把2和3当作一个时间层面来理解,然后再看4所在的时间层面,里面只有4,然后再看5。这种迭代方式,除非所有的层次都只有一个节点,否则层次总数总是小于节点的总数。
有了这些准备,我们言归正传。
先用求Rank的方式,把节点都放在自己应当放在的层次上。从选定的根节点1开始,一层一层的取色着色。比如根节点1着蓝色,第二层的2就可以着绿色,但是这时候不要处理3,因为3在后面一层。直到第三层的时候,原则上只要不是绿色就行了,所以第三层又可以着蓝色。然而第三层上的3却不是,它是例外的,它同时受到1和2的影响所以它不可以着绿色或者蓝色,它必须再用一种颜色来着色。
每一层若不关系到更先前的层次,就可以选择尽可能用过的颜色,只要和前一层不同就是了。而对于特定节点,关系到更先前的层次,就要具体考虑选择的颜色,不能是它所有入度的颜色之一,必要的话,还得创造新的颜色。
原则上,相邻的两个层次颜色不同即可,甚至可以交替。比如两种颜色就可以实现一条链的标注,
而对于特定的跨层次节点,则具体问题具体处理。
用这种方式,我们就可以找到这个图的着色的最小数量(上图为2),当然这时候每个节点都已经完成着色了。如果这还不行,考虑用另一个节点当成根节点,按照Rank的方式,重新生成迭代序列,再来一遍,看看哪一遍需要的颜色数量最小 - 究竟一遍能不能获得最小的颜色数量,我并不知道,但作为一种有效的着色方法,一遍显然足够了。
本文开始的时候说到的图,着色之后是这样的,不妨检视一下,确实至少需要四种颜色。
算法到这就大体上说清楚了。
这个算法其实是受到最大流问题启发才想到的,并不是因为DOT。但是不得不说,DOT的做法实在很有启发性!
代码实在太多就不全贴了。
上github链接:
贴一下核心部分,
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace GraphAlgorithmTester;
public class GraphColoringProblemSolver:ProblemSolver
{
public uint UsableColors = 5;
public string[] Parameters = new string[1];
public override void Solve(TextWriter writer, string start_name = null, string end_name = null)
{
writer.WriteLine("GraphColoringProblem:");
if (Nodes.Count < 1 || Edges.Count < 1)
{
writer.WriteLine(" Nodes count should >=1 and Edges count should >=1 too.");
}
else
{
if (this.Parameters.Length == 1 && this.Parameters[0] is string uc)
{
if (uc.StartsWith("C=") && uc.Length > 2)
{
uint.TryParse(uc[2..], out this.UsableColors);
}
}
writer.WriteLine(" Total {0} nodes: {1}", Nodes.Count, string.Join(',', this.Nodes.Values));
writer.WriteLine(" Total {0} edges: {1}", Edges.Count, string.Join(',', this.Edges));
var names = Nodes.Keys.ToHashSet();
var groups = new Dictionary<HashSet<int>, Dictionary<int, SNodeSet>>();
var edge_collection = new HashSet<SEdge>();
foreach (var start in Nodes.Values)
{
if (edge_collection.Count > 0)
{
this.Edges.UnionWith(edge_collection);
}
foreach (var n in this.Nodes.Values)
{
n.Offset = null;
}
var nodes = new HashSet<SNode>() { start };
var nexts = new HashSet<SNode>();
var all = new HashSet<SNode>();
int level_index = 1;
do
{
foreach (var node in nodes.ToArray())
{
var edges = this.Edges.Where(e => e.O == node).ToArray();
foreach (var edge in edges)
{
var t = edge.T;
t.LevelIndex = level_index;
nexts.Add(t);
//remove the directional
edge_collection.UnionWith(
this.Edges.Where(e => e.T == node && e.O == t).ToList()
);
int c = this.Edges.RemoveWhere(e => e.T == node && e.O == t);
if (c != 0)
{
}
}
}
level_index++;
nodes = nexts;
nexts = new();
//if level is too many, break the loop in case
//we find loops in graph
} while (nodes.Count > 0 && level_index - 1 < names.Count);
if (level_index > names.Count + 1)
{
//there is a loop!
//we should break this edge and remove this node.
start.LevelIndex = 0;
var removeds = this.Nodes.Values.Where(n => n.LevelIndex >= names.Count).ToHashSet();
writer.WriteLine($" Found a loop!");
writer.WriteLine($" Removed nodes:{string.Join(',', removeds)}");
}
//Build levels
start.LevelIndex = 0;
var levels = new List<SNodeSet>();
for (int i = 0; i < level_index; i++)
{
var level = new SNodeSet(this.Nodes.Values.Where(n => n.LevelIndex == i));
if (level.Count > 0)
{
levels.Add(level);
all.UnionWith(level);
}
}
if (levels.Count == 0)
{
writer.WriteLine($" Unable to build levels!");
return;
}
int last = 0;
int current = last;
var colors = new HashSet<int>() { };
var delayed = new HashLookups<int, SNode>();
for (int idx = 0; idx < levels.Count; idx++)
{
if (delayed.TryGetValue(idx, out var delays))
{
foreach (var node in delays)
{
//processing input first
var ins = this.Edges.Where(e => e.T == node).Select(e => e.O).ToList();
var cos = ins.Select(_in => _in.Color is int i ? i : -1).ToHashSet();
cos.Remove(-1);
if (cos.Count < colors.Count)
{
var ccs = colors.ToHashSet();
ccs.ExceptWith(cos);
last = current;
current = ccs.First();
}
else //cos.Count == colors.Count (can not be >)
{
last = current;
cos.Add(current = colors.Count);
colors.Add(colors.Count);
}
node.Offset = current;
}
}
var level = levels[idx];
foreach (var node in level)
{
if (node.Offset == null)
{
node.Offset = current;
}
}
//has to be
colors.Add(current);
var found = false;
foreach (var node in level)
{
var outs = this.Edges.Where(e => e.O == node).Select(e => e.T).ToList();
if (outs.Count > 0)
{
foreach (var o in outs)
{
if (node.LevelIndex + 1 < o.LevelIndex)
{
delayed.Add(o.LevelIndex, o); //delay processing
}
else if (node.LevelIndex + 1 == o.LevelIndex)
{
found = true;
}
else
{
found = true;
}
}
}
}
if (found)
{
var ch = colors.ToHashSet();
ch.Remove(current);
current = ch.Count == 0 ? 1 : ch.First();
}
}
var group = new Dictionary<int, SNodeSet>();
foreach (var c in colors)
{
group[c] = new SNodeSet(
this.Nodes.Values.Where(n => n.Color == c));
}
groups.Add(colors, group);
}
var groups_ = new Dictionary<HashSet<int>, Dictionary<int, SNodeSet>>();
foreach (var kv in groups.Where(g=>g.Key.Count <= UsableColors).DistinctBy(g => g.Key.Count))
{
groups_.Add(kv.Key, kv.Value);
}
groups = groups_;
if (groups.Count > 0)
{
writer.WriteLine($" NOTICE: the solution is wrong if nodes are not all connected");
writer.WriteLine($" Best solution, total {groups.Count} groups:");
foreach (var g in groups)
{
writer.WriteLine($" group:");
foreach (var p in g.Value)
{
writer.WriteLine($" color: {p.Key} {p.Value})");
}
}
}
else
{
writer.WriteLine($" No solution found!");
}
}
}
}
这整个是一套完全不同的想法。
这个想法的核心叫做时序化,配合集合运算(交并补等等),同时处理大量的节点,这也有点像是SIMD的方式,或者物理学中平行世界运作的方式。用这种方式我们可以把所有的问题提升到最高的维数(时间)上去解答。以这种方式所产生的算法,可以很轻松的把NP问题变成P问题,而且相当多的NP问题可以直接实现O(n)解。所以说,从这个角度理解,NP问题就是P问题,就已经证明了。
也许,这就是人脑处理这类问题的真正的方式。