2026-11-02 22:12:43 +11:00
|
|
|
package obigraph
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"math/rand"
|
|
|
|
|
"sync"
|
2026-11-02 22:54:00 +11:00
|
|
|
"sync/atomic"
|
2026-11-02 22:12:43 +11:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// NeighborFilter defines an implicit graph over the vertices 0..n-1.
|
|
|
|
|
// It appends to out[:0] the vertices of candidates that are adjacent to v,
|
|
|
|
|
// keeping their order, and returns the resulting slice. It is called
|
|
|
|
|
// concurrently by several workers and must not modify candidates.
|
|
|
|
|
//
|
|
|
|
|
// Describing the adjacency by a filter, instead of a matrix, keeps the memory
|
|
|
|
|
// in O(n) and lets the caller evaluate adjacency in bulk (SIMD, for instance).
|
|
|
|
|
type NeighborFilter func(v int32, candidates, out []int32) []int32
|
|
|
|
|
|
|
|
|
|
// CliqueOptions tunes FindClique.
|
|
|
|
|
type CliqueOptions struct {
|
|
|
|
|
// Workers is the number of concurrent searches (default 1).
|
|
|
|
|
Workers int
|
|
|
|
|
// Seed seeds the random vertex orders of the restarts.
|
|
|
|
|
Seed int64
|
2026-11-02 22:33:00 +11:00
|
|
|
// Progress, if set, is called with the size of the best clique each time a
|
|
|
|
|
// larger one is found. Calls are serialized and made while a lock is held:
|
|
|
|
|
// it must return quickly and must not call FindClique.
|
|
|
|
|
Progress func(best int)
|
2026-11-02 22:54:00 +11:00
|
|
|
|
|
|
|
|
// ColorLimit enables the coloring bound for the candidate lists of at most
|
|
|
|
|
// ColorLimit vertices (0 disables it). The vertices of a list are split into
|
|
|
|
|
// classes of pairwise non-adjacent vertices: a clique takes at most one
|
|
|
|
|
// vertex per class, so the number of classes bounds its size. Computing the
|
|
|
|
|
// classes costs up to the square of the list length in calls to the filter,
|
|
|
|
|
// which is why only short lists are colored.
|
|
|
|
|
ColorLimit int
|
|
|
|
|
|
|
|
|
|
// mode forces the kind of search (tests only): 0 alternates, modeFocused
|
|
|
|
|
// and modeImproving restrict the search to one of them.
|
|
|
|
|
mode int
|
2026-11-02 22:12:43 +11:00
|
|
|
}
|
|
|
|
|
|
2026-11-02 22:54:00 +11:00
|
|
|
const (
|
|
|
|
|
modeFocused = 1
|
|
|
|
|
modeImproving = 2
|
|
|
|
|
)
|
|
|
|
|
|
2026-11-02 22:12:43 +11:00
|
|
|
// CliqueResult is the outcome of FindClique.
|
|
|
|
|
type CliqueResult struct {
|
|
|
|
|
// Clique is the largest clique found (a clique of size >= target if Reached).
|
|
|
|
|
Clique []int32
|
|
|
|
|
// Reached is true if len(Clique) >= target.
|
|
|
|
|
Reached bool
|
|
|
|
|
// Exhausted is true if the whole search space has been explored. If Reached
|
|
|
|
|
// is false, it proves that no clique of the target size exists.
|
|
|
|
|
Exhausted bool
|
2026-11-02 22:54:00 +11:00
|
|
|
// Maximum is true if Clique is proven to be a maximum clique of the graph:
|
|
|
|
|
// no larger clique exists.
|
|
|
|
|
Maximum bool
|
2026-11-02 22:12:43 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FindClique looks for a clique of at least target vertices in the graph of n
|
|
|
|
|
// vertices described by filter. It stops as soon as one is found, when the
|
|
|
|
|
// search space is exhausted, or when ctx is done; in the last case, the largest
|
|
|
|
|
// clique met so far is returned.
|
|
|
|
|
//
|
2026-11-02 22:54:00 +11:00
|
|
|
// The search is a depth first search with backtracking, which comes in two
|
|
|
|
|
// flavours that the restarts alternate:
|
|
|
|
|
//
|
|
|
|
|
// - the focused search prunes every branch that cannot reach target, which
|
|
|
|
|
// is the most efficient way to reach it but gives poor cliques when target
|
|
|
|
|
// is out of reach;
|
|
|
|
|
// - the improving search only prunes the branches that cannot beat the best
|
|
|
|
|
// clique found so far (by any worker), so it keeps improving that clique
|
|
|
|
|
// whatever target is. When it ends, the best clique is a maximum one.
|
|
|
|
|
//
|
|
|
|
|
// When a focused search proves that no clique of target vertices exists, every
|
|
|
|
|
// search turns to the improving flavour, so that the largest clique is returned
|
|
|
|
|
// (and proved to be so) instead of an arbitrary one.
|
|
|
|
|
//
|
|
|
|
|
// On short candidate lists (see CliqueOptions.ColorLimit), a coloring bound
|
|
|
|
|
// prunes the branches that cannot be large enough, and orders the vertices so
|
|
|
|
|
// that the most constrained ones are tried first.
|
|
|
|
|
//
|
|
|
|
|
// The first pass of the first worker follows the natural order of the vertices
|
|
|
|
|
// (greedy "lexicographic" construction); every other pass restarts from a
|
|
|
|
|
// random order, with a work budget that doubles at each restart, so that the
|
|
|
|
|
// search is complete in the long run.
|
2026-11-02 22:12:43 +11:00
|
|
|
func FindClique(ctx context.Context, n int, filter NeighborFilter, target int, opts CliqueOptions) CliqueResult {
|
|
|
|
|
if target <= 0 {
|
|
|
|
|
return CliqueResult{Reached: true, Exhausted: true}
|
|
|
|
|
}
|
|
|
|
|
if n <= 0 {
|
|
|
|
|
return CliqueResult{Exhausted: true}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
workers := max(opts.Workers, 1)
|
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
mutex sync.Mutex
|
|
|
|
|
best []int32
|
2026-11-02 22:54:00 +11:00
|
|
|
bestSize atomic.Int32
|
2026-11-02 22:12:43 +11:00
|
|
|
exhausted bool
|
2026-11-02 22:54:00 +11:00
|
|
|
maximum bool
|
2026-11-02 22:12:43 +11:00
|
|
|
wg sync.WaitGroup
|
2026-11-02 22:54:00 +11:00
|
|
|
// set when a focused search has proved that target cannot be reached:
|
|
|
|
|
// every search then turns to finding the largest clique
|
|
|
|
|
unreachable atomic.Bool
|
2026-11-02 22:12:43 +11:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
publish := func(clique []int32) {
|
|
|
|
|
mutex.Lock()
|
|
|
|
|
if len(clique) > len(best) {
|
|
|
|
|
best = append(best[:0], clique...)
|
2026-11-02 22:54:00 +11:00
|
|
|
bestSize.Store(int32(len(best)))
|
2026-11-02 22:33:00 +11:00
|
|
|
if opts.Progress != nil {
|
|
|
|
|
opts.Progress(len(best))
|
|
|
|
|
}
|
2026-11-02 22:12:43 +11:00
|
|
|
}
|
|
|
|
|
mutex.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for w := range workers {
|
|
|
|
|
wg.Go(func() {
|
|
|
|
|
s := &cliqueSearch{
|
2026-11-02 22:54:00 +11:00
|
|
|
ctx: ctx,
|
|
|
|
|
filter: filter,
|
|
|
|
|
target: target,
|
|
|
|
|
bestSize: &bestSize,
|
|
|
|
|
colorLimit: opts.ColorLimit,
|
|
|
|
|
unreachable: &unreachable,
|
2026-11-02 22:12:43 +11:00
|
|
|
publish: func(c []int32) {
|
|
|
|
|
publish(c)
|
|
|
|
|
if len(c) >= target {
|
|
|
|
|
cancel()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
rng := rand.New(rand.NewSource(opts.Seed + int64(w)))
|
2026-11-02 22:54:00 +11:00
|
|
|
// the search starts from the end of the list: lowest vertex first
|
2026-11-02 22:12:43 +11:00
|
|
|
candidates := make([]int32, n)
|
|
|
|
|
for i := range candidates {
|
2026-11-02 22:54:00 +11:00
|
|
|
candidates[i] = int32(n - 1 - i)
|
2026-11-02 22:12:43 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for restart := 0; ctx.Err() == nil; restart++ {
|
|
|
|
|
if w > 0 || restart > 0 {
|
|
|
|
|
rng.Shuffle(n, func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] })
|
|
|
|
|
}
|
2026-11-02 22:54:00 +11:00
|
|
|
switch opts.mode {
|
|
|
|
|
case modeFocused:
|
|
|
|
|
s.improving = false
|
|
|
|
|
case modeImproving:
|
|
|
|
|
s.improving = true
|
|
|
|
|
default:
|
|
|
|
|
s.improving = unreachable.Load() || (w+restart)%2 == 1
|
|
|
|
|
}
|
2026-11-02 22:12:43 +11:00
|
|
|
s.budget = int64(n) * int64(target) << min(restart, 20)
|
|
|
|
|
s.work = 0
|
|
|
|
|
s.aborted = false
|
|
|
|
|
s.search(0, candidates)
|
|
|
|
|
|
|
|
|
|
if !s.aborted && ctx.Err() == nil {
|
|
|
|
|
mutex.Lock()
|
|
|
|
|
exhausted = true
|
2026-11-02 22:54:00 +11:00
|
|
|
if s.improving {
|
|
|
|
|
maximum = true
|
|
|
|
|
}
|
2026-11-02 22:12:43 +11:00
|
|
|
mutex.Unlock()
|
2026-11-02 22:54:00 +11:00
|
|
|
|
|
|
|
|
if s.improving || opts.mode == modeFocused {
|
|
|
|
|
cancel()
|
|
|
|
|
} else {
|
|
|
|
|
// target is out of reach: look for the largest clique
|
|
|
|
|
unreachable.Store(true)
|
|
|
|
|
}
|
2026-11-02 22:12:43 +11:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
|
|
return CliqueResult{
|
|
|
|
|
Clique: best,
|
|
|
|
|
Reached: len(best) >= target,
|
|
|
|
|
Exhausted: exhausted,
|
2026-11-02 22:54:00 +11:00
|
|
|
Maximum: maximum,
|
2026-11-02 22:12:43 +11:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type cliqueSearch struct {
|
2026-11-02 22:54:00 +11:00
|
|
|
ctx context.Context
|
|
|
|
|
filter NeighborFilter
|
|
|
|
|
target int
|
|
|
|
|
bestSize *atomic.Int32 // size of the best clique found by any worker
|
|
|
|
|
publish func([]int32)
|
|
|
|
|
colorLimit int
|
|
|
|
|
|
|
|
|
|
unreachable *atomic.Bool // target is proved out of reach
|
|
|
|
|
|
|
|
|
|
improving bool // see FindClique: the kind of the current pass
|
2026-11-02 22:12:43 +11:00
|
|
|
|
|
|
|
|
current []int32 // the clique under construction
|
|
|
|
|
buffers [][]int32 // candidate list of each depth
|
2026-11-02 22:54:00 +11:00
|
|
|
sorted [][]int32 // candidate list of each depth, sorted by color
|
|
|
|
|
colors [][]int32 // color (from 1) of each vertex of sorted
|
|
|
|
|
classes [][]int32 // scratch space of the coloring
|
|
|
|
|
probe []int32 // scratch space of the coloring
|
2026-11-02 22:12:43 +11:00
|
|
|
|
|
|
|
|
budget int64 // maximum work (number of candidates tested) for this restart
|
|
|
|
|
work int64
|
|
|
|
|
aborted bool // budget or context exhausted before the end of the exploration
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-02 22:54:00 +11:00
|
|
|
// threshold is the size a clique must reach to be worth exploring: the target
|
|
|
|
|
// for the focused search, one more than the best clique for the improving one.
|
|
|
|
|
func (s *cliqueSearch) threshold() int {
|
|
|
|
|
if s.improving {
|
|
|
|
|
return min(s.target, int(s.bestSize.Load())+1)
|
|
|
|
|
}
|
|
|
|
|
return s.target
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// mustStop tells whether the current pass has to be interrupted: its work
|
|
|
|
|
// budget is spent, or the pass must stop whatever its budget (see mustAbort).
|
|
|
|
|
func (s *cliqueSearch) mustStop() bool {
|
|
|
|
|
return s.work > s.budget || s.mustAbort()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// mustAbort tells whether the pass has to be interrupted whatever its budget:
|
|
|
|
|
// the context is done, or it is a focused pass and its target has been proved
|
|
|
|
|
// out of reach.
|
|
|
|
|
func (s *cliqueSearch) mustAbort() bool {
|
|
|
|
|
return s.ctx.Err() != nil || (!s.improving && s.unreachable.Load())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// colorSort splits candidates into classes of pairwise non-adjacent vertices
|
|
|
|
|
// (greedy sequential coloring) and returns them class after class, with the
|
|
|
|
|
// color of each vertex. Any clique takes at most one vertex of each class.
|
|
|
|
|
//
|
|
|
|
|
// The coloring can be long: it stops, and sets s.aborted, as soon as the search
|
|
|
|
|
// has to be aborted. Its cost is not charged to the work budget of the pass,
|
|
|
|
|
// which limits the exploration of the tree, not the bound computed at each node.
|
|
|
|
|
func (s *cliqueSearch) colorSort(depth int, candidates []int32) (sorted, colors []int32) {
|
|
|
|
|
k := 0
|
|
|
|
|
for _, v := range candidates {
|
|
|
|
|
if s.mustAbort() {
|
|
|
|
|
s.aborted = true
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
placed := false
|
|
|
|
|
for c := 0; c < k; c++ {
|
|
|
|
|
// v joins the class if it is adjacent to none of its members
|
|
|
|
|
s.probe = s.filter(v, s.classes[c], s.probe[:0])
|
|
|
|
|
if len(s.probe) == 0 {
|
|
|
|
|
s.classes[c] = append(s.classes[c], v)
|
|
|
|
|
placed = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !placed {
|
|
|
|
|
if k == len(s.classes) {
|
|
|
|
|
s.classes = append(s.classes, nil)
|
|
|
|
|
}
|
|
|
|
|
s.classes[k] = append(s.classes[k][:0], v)
|
|
|
|
|
k++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for len(s.sorted) <= depth {
|
|
|
|
|
s.sorted = append(s.sorted, nil)
|
|
|
|
|
s.colors = append(s.colors, nil)
|
|
|
|
|
}
|
|
|
|
|
sorted, colors = s.sorted[depth][:0], s.colors[depth][:0]
|
|
|
|
|
for c := 0; c < k; c++ {
|
|
|
|
|
for _, v := range s.classes[c] {
|
|
|
|
|
sorted = append(sorted, v)
|
|
|
|
|
colors = append(colors, int32(c+1))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
s.sorted[depth], s.colors[depth] = sorted, colors
|
|
|
|
|
return sorted, colors
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// search extends the current clique of depth vertices with vertices taken in
|
|
|
|
|
// candidates, all adjacent to the vertices of the current clique. The list is
|
|
|
|
|
// read from its end: the vertex at position i is combined with the vertices
|
|
|
|
|
// before it, so that every clique is met once.
|
2026-11-02 22:12:43 +11:00
|
|
|
func (s *cliqueSearch) search(depth int, candidates []int32) {
|
2026-11-02 22:54:00 +11:00
|
|
|
var colors []int32
|
|
|
|
|
if s.colorLimit > 0 && len(candidates) > 1 && len(candidates) <= s.colorLimit {
|
|
|
|
|
candidates, colors = s.colorSort(depth, candidates)
|
|
|
|
|
if s.aborted {
|
2026-11-02 22:12:43 +11:00
|
|
|
return
|
|
|
|
|
}
|
2026-11-02 22:54:00 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := len(candidates) - 1; i >= 0; i-- {
|
|
|
|
|
// largest clique the vertices 0..i can make
|
|
|
|
|
bound := i + 1
|
|
|
|
|
if colors != nil {
|
|
|
|
|
bound = int(colors[i])
|
|
|
|
|
}
|
|
|
|
|
if depth+bound < s.threshold() {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if s.mustStop() {
|
2026-11-02 22:12:43 +11:00
|
|
|
s.aborted = true
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
v := candidates[i]
|
|
|
|
|
s.current = append(s.current[:depth], v)
|
2026-11-02 22:54:00 +11:00
|
|
|
if len(s.current) > int(s.bestSize.Load()) {
|
2026-11-02 22:12:43 +11:00
|
|
|
s.publish(s.current)
|
2026-11-02 22:54:00 +11:00
|
|
|
if len(s.current) >= s.target {
|
2026-11-02 22:12:43 +11:00
|
|
|
s.aborted = true // the search is over: do not report an exhaustion
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-02 22:54:00 +11:00
|
|
|
rest := candidates[:i]
|
|
|
|
|
if depth+1+len(rest) < s.threshold() {
|
2026-11-02 22:12:43 +11:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for len(s.buffers) <= depth+1 {
|
|
|
|
|
s.buffers = append(s.buffers, nil)
|
|
|
|
|
}
|
|
|
|
|
next := s.filter(v, rest, s.buffers[depth+1][:0])
|
|
|
|
|
s.buffers[depth+1] = next
|
|
|
|
|
s.work += int64(len(rest))
|
|
|
|
|
|
2026-11-02 22:54:00 +11:00
|
|
|
if depth+1+len(next) < s.threshold() {
|
2026-11-02 22:12:43 +11:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
s.search(depth+1, next)
|
|
|
|
|
if s.aborted {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|