遗传算法
This commit is contained in:
1 parent
0513e251df
commit
94bc064697
3 files changed
+333
-58
No files matched your search
@@ -63,9 +63,6 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
.FirstOrDefaultAsync(x => x.AcademicTermId == plan.AcademicTermId, cancellationToken)
|
||||
?? ScheduleOptimizationSettings.CreateDefault(plan.AcademicTermId);
|
||||
|
||||
var created = 0;
|
||||
var completedTasks = 0;
|
||||
var processedTasks = 0;
|
||||
var messages = new List<string>();
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
@@ -74,87 +71,364 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var task in tasks)
|
||||
var optimizer = new GeneticScheduleOptimizer(
|
||||
plan.Id,
|
||||
tasks,
|
||||
constraints,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries,
|
||||
optimization);
|
||||
var result = optimizer.Optimize(cancellationToken);
|
||||
db.ScheduleEntries.AddRange(result.Entries);
|
||||
messages.AddRange(result.Messages);
|
||||
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, tasks.Count, result.Entries.Count, result.CompletedTasks),
|
||||
cancellationToken);
|
||||
}
|
||||
if (result.Entries.Count > 0 && saveChanges)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return new(
|
||||
result.Entries.Count,
|
||||
result.CompletedTasks,
|
||||
messages,
|
||||
tasks.Count,
|
||||
tasks.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 遗传搜索以完整排课方案作为个体。候选生成、交叉和变异均经修复,
|
||||
/// 因而冲突、容量和课程可用范围始终是不可违反的硬约束。
|
||||
/// </summary>
|
||||
private sealed class GeneticScheduleOptimizer(
|
||||
Guid planId,
|
||||
IReadOnlyList<TeachingTask> tasks,
|
||||
IReadOnlyDictionary<Guid, TeachingTaskScheduleConstraint> constraints,
|
||||
HashSet<int> activePeriods,
|
||||
IReadOnlyList<Classroom> classrooms,
|
||||
IReadOnlyList<ScheduleEntry> baselineEntries,
|
||||
ScheduleOptimizationSettings settings)
|
||||
{
|
||||
private const int MinimumPopulationSize = 18;
|
||||
private const int MaximumPopulationSize = 36;
|
||||
private readonly Random random = new();
|
||||
|
||||
public GeneticScheduleOptimizationResult Optimize(CancellationToken cancellationToken)
|
||||
{
|
||||
var messages = new List<string>();
|
||||
var invalidTaskIds = new HashSet<Guid>();
|
||||
var demands = BuildDemands(messages, invalidTaskIds);
|
||||
if (demands.Count == 0)
|
||||
return new([], tasks.Count - invalidTaskIds.Count, messages);
|
||||
|
||||
var candidates = demands.Select(BuildCandidates).ToList();
|
||||
var populationSize = Math.Clamp(demands.Count * 2, MinimumPopulationSize, MaximumPopulationSize);
|
||||
var generationCount = Math.Clamp(demands.Count * 3, 36, 120);
|
||||
var population = Enumerable.Range(0, populationSize)
|
||||
.Select(_ => CreateIndividual(demands, candidates, cancellationToken))
|
||||
.ToList();
|
||||
|
||||
for (var generation = 0; generation < generationCount; generation++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
constraints.TryGetValue(task.Id, out var constraint);
|
||||
var taskCompleted = true;
|
||||
foreach (var kind in new[]
|
||||
population = population.OrderBy(individual => individual.Fitness).ToList();
|
||||
var nextGeneration = new List<GeneticIndividual>
|
||||
{
|
||||
ScheduleEntryKind.Lecture,
|
||||
ScheduleEntryKind.Experiment
|
||||
})
|
||||
population[0].Clone(),
|
||||
population[1].Clone()
|
||||
};
|
||||
while (nextGeneration.Count < populationSize)
|
||||
{
|
||||
var firstParent = SelectParent(population);
|
||||
var secondParent = SelectParent(population);
|
||||
var child = Crossover(firstParent, secondParent, demands, candidates);
|
||||
Mutate(child, candidates);
|
||||
child.Fitness = CalculateFitness(child.Genes);
|
||||
nextGeneration.Add(child);
|
||||
}
|
||||
population = nextGeneration;
|
||||
}
|
||||
|
||||
var best = population.MinBy(individual => individual.Fitness)!;
|
||||
var unscheduled = demands.Select((demand, index) => new { demand, entry = best.Genes[index] })
|
||||
.Where(item => item.entry is null)
|
||||
.GroupBy(item => new { item.demand.Task, item.demand.Kind });
|
||||
foreach (var group in unscheduled)
|
||||
{
|
||||
invalidTaskIds.Add(group.Key.Task.Id);
|
||||
var hours = group.Sum(item => item.demand.PeriodCount * item.demand.OccurrenceCount);
|
||||
var label = group.Key.Kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
|
||||
messages.Add(
|
||||
$"{group.Key.Task.TaskNumber} · {group.Key.Task.Name} 仍有 {hours} 个{label}学时无法安排," +
|
||||
(group.Key.Kind == ScheduleEntryKind.Experiment
|
||||
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
|
||||
: "请检查教师/班级冲突或场地与时间约束。"));
|
||||
}
|
||||
return new(
|
||||
best.Genes.Where(entry => entry is not null).Select(entry => entry!).ToList(),
|
||||
CountCompletedTasks(best.Genes, invalidTaskIds, demands),
|
||||
messages);
|
||||
}
|
||||
|
||||
private List<ScheduleDemand> BuildDemands(
|
||||
ICollection<string> messages,
|
||||
ISet<Guid> invalidTaskIds)
|
||||
{
|
||||
var result = new List<ScheduleDemand>();
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
constraints.TryGetValue(task.Id, out var constraint);
|
||||
foreach (var kind in new[] { ScheduleEntryKind.Lecture, ScheduleEntryKind.Experiment })
|
||||
{
|
||||
var targetHours = TeachingTaskHours.TargetHours(task.Course!, kind);
|
||||
var scheduledHours = entries
|
||||
.Where(x =>
|
||||
x.TeachingTaskId == task.Id &&
|
||||
x.Kind == kind)
|
||||
var scheduledHours = baselineEntries
|
||||
.Where(entry => entry.TeachingTaskId == task.Id && entry.Kind == kind)
|
||||
.Sum(TeachingTaskHours.ScheduledHours);
|
||||
var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
|
||||
if (scheduledHours > targetHours)
|
||||
{
|
||||
invalidTaskIds.Add(task.Id);
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," +
|
||||
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。");
|
||||
taskCompleted = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var remainingHours = targetHours - scheduledHours;
|
||||
var weekCount = task.EndWeek - task.StartWeek + 1;
|
||||
while (remainingHours > 0)
|
||||
{
|
||||
var candidate = FindBestCandidateForHours(
|
||||
plan.Id,
|
||||
task,
|
||||
constraint,
|
||||
kind,
|
||||
remainingHours,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries,
|
||||
optimization,
|
||||
cancellationToken);
|
||||
if (candidate is null) break;
|
||||
|
||||
db.ScheduleEntries.Add(candidate);
|
||||
entries.Add(candidate);
|
||||
created++;
|
||||
remainingHours -= TeachingTaskHours.ScheduledHours(candidate);
|
||||
}
|
||||
|
||||
if (remainingHours > 0)
|
||||
var periodCount = remainingHours >= 2 ? 2 : 1;
|
||||
var occurrenceCount = Math.Min(weekCount, remainingHours / periodCount);
|
||||
if (occurrenceCount == 0)
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 个{label}学时无法安排," +
|
||||
(kind == ScheduleEntryKind.Experiment
|
||||
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
|
||||
: "请检查教师/班级冲突或场地与时间约束。"));
|
||||
taskCompleted = false;
|
||||
periodCount = 1;
|
||||
occurrenceCount = 1;
|
||||
}
|
||||
result.Add(new(task, constraint, kind, periodCount, occurrenceCount));
|
||||
remainingHours -= periodCount * occurrenceCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (taskCompleted) completedTasks++;
|
||||
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
private List<ScheduleEntry> BuildCandidates(ScheduleDemand demand)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
var allowedDays = ParseAllowedDays(demand.Constraint?.AllowedDayOfWeeks);
|
||||
var firstPeriod = demand.Constraint?.EarliestPeriod ?? activePeriods.Min();
|
||||
var lastPeriod = demand.Constraint?.LatestPeriod ?? activePeriods.Max();
|
||||
var rooms = EligibleRooms(demand.Task, demand.Constraint, demand.Kind, classrooms);
|
||||
if ((demand.Constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
|
||||
return [];
|
||||
|
||||
var candidates = new List<ScheduleEntry>();
|
||||
for (var startWeek = demand.Task.StartWeek;
|
||||
startWeek + demand.OccurrenceCount - 1 <= demand.Task.EndWeek;
|
||||
startWeek++)
|
||||
foreach (var day in allowedDays)
|
||||
for (var start = firstPeriod; start + demand.PeriodCount - 1 <= lastPeriod; start++)
|
||||
{
|
||||
if (Enumerable.Range(start, demand.PeriodCount).Any(period => !activePeriods.Contains(period)))
|
||||
continue;
|
||||
var roomOptions = demand.Kind != ScheduleEntryKind.Experiment &&
|
||||
demand.Constraint?.RequiresClassroom == false
|
||||
? new Classroom?[] { null }
|
||||
: rooms.Cast<Classroom?>().ToArray();
|
||||
foreach (var room in roomOptions)
|
||||
{
|
||||
var candidate = CreateEntry(demand, startWeek, day, start, room);
|
||||
if (CanAdd(candidate, baselineEntries))
|
||||
candidates.Add(candidate);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private GeneticIndividual CreateIndividual(
|
||||
IReadOnlyList<ScheduleDemand> demands,
|
||||
IReadOnlyList<List<ScheduleEntry>> candidates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var genes = new ScheduleEntry?[demands.Count];
|
||||
var state = baselineEntries.ToList();
|
||||
foreach (var index in Enumerable.Range(0, demands.Count)
|
||||
.OrderBy(index => candidates[index].Count)
|
||||
.ThenBy(_ => random.Next()))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var selected = SelectFeasibleCandidate(candidates[index], state);
|
||||
if (selected is null) continue;
|
||||
genes[index] = selected;
|
||||
state.Add(selected);
|
||||
}
|
||||
return new(genes) { Fitness = CalculateFitness(genes) };
|
||||
}
|
||||
|
||||
private GeneticIndividual Crossover(
|
||||
GeneticIndividual first,
|
||||
GeneticIndividual second,
|
||||
IReadOnlyList<ScheduleDemand> demands,
|
||||
IReadOnlyList<List<ScheduleEntry>> candidates)
|
||||
{
|
||||
var genes = new ScheduleEntry?[demands.Count];
|
||||
var state = baselineEntries.ToList();
|
||||
foreach (var index in Enumerable.Range(0, demands.Count)
|
||||
.OrderBy(index => candidates[index].Count)
|
||||
.ThenBy(_ => random.Next()))
|
||||
{
|
||||
var firstChoice = random.Next(2) == 0 ? first.Genes[index] : second.Genes[index];
|
||||
var secondChoice = ReferenceEquals(firstChoice, first.Genes[index])
|
||||
? second.Genes[index]
|
||||
: first.Genes[index];
|
||||
var selected = TryAddClone(firstChoice, state) ??
|
||||
TryAddClone(secondChoice, state) ??
|
||||
SelectFeasibleCandidate(candidates[index], state);
|
||||
if (selected is null) continue;
|
||||
genes[index] = selected;
|
||||
state.Add(selected);
|
||||
}
|
||||
return new(genes);
|
||||
}
|
||||
|
||||
private void Mutate(GeneticIndividual individual, IReadOnlyList<List<ScheduleEntry>> candidates)
|
||||
{
|
||||
var mutationCount = Math.Max(1, individual.Genes.Length / 10);
|
||||
for (var mutation = 0; mutation < mutationCount; mutation++)
|
||||
{
|
||||
var index = random.Next(individual.Genes.Length);
|
||||
var state = baselineEntries.Concat(individual.Genes
|
||||
.Where((_, geneIndex) => geneIndex != index)
|
||||
.Where(entry => entry is not null)
|
||||
.Select(entry => entry!)).ToList();
|
||||
var replacement = SelectFeasibleCandidate(candidates[index], state);
|
||||
if (replacement is not null) individual.Genes[index] = replacement;
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0 && saveChanges)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return new(
|
||||
created,
|
||||
completedTasks,
|
||||
messages,
|
||||
tasks.Count,
|
||||
processedTasks);
|
||||
private GeneticIndividual SelectParent(IReadOnlyList<GeneticIndividual> population)
|
||||
{
|
||||
var first = population[random.Next(population.Count)];
|
||||
var second = population[random.Next(population.Count)];
|
||||
return first.Fitness <= second.Fitness ? first : second;
|
||||
}
|
||||
|
||||
private ScheduleEntry? SelectFeasibleCandidate(
|
||||
IReadOnlyList<ScheduleEntry> candidates,
|
||||
IReadOnlyList<ScheduleEntry> state)
|
||||
{
|
||||
var feasible = candidates.Where(candidate => CanAdd(candidate, state))
|
||||
.OrderBy(candidate => CandidateScore(candidate, state))
|
||||
.Take(8)
|
||||
.ToList();
|
||||
return feasible.Count == 0
|
||||
? null
|
||||
: CloneEntry(feasible[random.Next(feasible.Count)]);
|
||||
}
|
||||
|
||||
private int CandidateScore(ScheduleEntry candidate, IReadOnlyList<ScheduleEntry> state) =>
|
||||
CalculateSoftConstraintPenalty(
|
||||
candidate.TeachingTask!,
|
||||
candidate,
|
||||
state,
|
||||
settings,
|
||||
activePeriods.Min(),
|
||||
activePeriods.Max());
|
||||
|
||||
private int CalculateFitness(IEnumerable<ScheduleEntry?> genes)
|
||||
{
|
||||
var scheduled = genes.Where(entry => entry is not null).Select(entry => entry!).ToList();
|
||||
var missingPenalty = (genes.Count(entry => entry is null)) * 1_000_000;
|
||||
var score = scheduled.Sum(entry => CandidateScore(entry, baselineEntries.Concat(scheduled)
|
||||
.Where(other => !ReferenceEquals(other, entry)).ToList()));
|
||||
return missingPenalty + score;
|
||||
}
|
||||
|
||||
private int CountCompletedTasks(
|
||||
IReadOnlyList<ScheduleEntry?> genes,
|
||||
ISet<Guid> invalidTaskIds,
|
||||
IReadOnlyList<ScheduleDemand> demands)
|
||||
{
|
||||
var missingTaskIds = demands.Where((_, index) => genes[index] is null)
|
||||
.Select(demand => demand.Task.Id)
|
||||
.ToHashSet();
|
||||
return tasks.Count(task => !invalidTaskIds.Contains(task.Id) &&
|
||||
!missingTaskIds.Contains(task.Id));
|
||||
}
|
||||
|
||||
private static bool CanAdd(ScheduleEntry candidate, IEnumerable<ScheduleEntry> state) =>
|
||||
!state.Any(existing =>
|
||||
ScheduleConflictDetector.TimeOverlaps(existing, candidate) &&
|
||||
ScheduleConflictDetector.ConflictReason(existing, candidate) is not null);
|
||||
|
||||
private static ScheduleEntry? TryAddClone(ScheduleEntry? candidate, IReadOnlyList<ScheduleEntry> state) =>
|
||||
candidate is not null && CanAdd(candidate, state) ? CloneEntry(candidate) : null;
|
||||
|
||||
private ScheduleEntry CreateEntry(
|
||||
ScheduleDemand demand,
|
||||
int startWeek,
|
||||
int day,
|
||||
int startPeriod,
|
||||
Classroom? room) => new()
|
||||
{
|
||||
SchedulePlanId = planId,
|
||||
TeachingTaskId = demand.Task.Id,
|
||||
TeachingTask = demand.Task,
|
||||
Kind = demand.Kind,
|
||||
ClassroomId = room?.Id,
|
||||
DayOfWeek = day,
|
||||
StartPeriod = startPeriod,
|
||||
PeriodCount = demand.PeriodCount,
|
||||
StartWeek = startWeek,
|
||||
EndWeek = startWeek + demand.OccurrenceCount - 1,
|
||||
WeekPattern = WeekPattern.All,
|
||||
Notes = demand.Kind == ScheduleEntryKind.Experiment
|
||||
? "自动排课 · 遗传算法 · 实验课"
|
||||
: "自动排课 · 遗传算法 · 理论课"
|
||||
};
|
||||
|
||||
private static ScheduleEntry CloneEntry(ScheduleEntry source) => new()
|
||||
{
|
||||
SchedulePlanId = source.SchedulePlanId,
|
||||
TeachingTaskId = source.TeachingTaskId,
|
||||
TeachingTask = source.TeachingTask,
|
||||
Kind = source.Kind,
|
||||
ClassroomId = source.ClassroomId,
|
||||
DayOfWeek = source.DayOfWeek,
|
||||
StartPeriod = source.StartPeriod,
|
||||
PeriodCount = source.PeriodCount,
|
||||
StartWeek = source.StartWeek,
|
||||
EndWeek = source.EndWeek,
|
||||
WeekPattern = source.WeekPattern,
|
||||
Notes = source.Notes
|
||||
};
|
||||
|
||||
private sealed record ScheduleDemand(
|
||||
TeachingTask Task,
|
||||
TeachingTaskScheduleConstraint? Constraint,
|
||||
ScheduleEntryKind Kind,
|
||||
int PeriodCount,
|
||||
int OccurrenceCount);
|
||||
|
||||
private sealed class GeneticIndividual(ScheduleEntry?[] genes)
|
||||
{
|
||||
public ScheduleEntry?[] Genes { get; } = genes;
|
||||
public int Fitness { get; set; }
|
||||
public GeneticIndividual Clone() => new(Genes.Select(entry => entry is null ? null : CloneEntry(entry)).ToArray())
|
||||
{
|
||||
Fitness = Fitness
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record GeneticScheduleOptimizationResult(
|
||||
IReadOnlyList<ScheduleEntry> Entries,
|
||||
int CompletedTasks,
|
||||
IReadOnlyList<string> Messages);
|
||||
|
||||
private static ScheduleEntry? FindBestCandidateForHours(
|
||||
Guid planId,
|
||||
TeachingTask task,
|
||||
|
||||
@@ -346,6 +346,7 @@ public sealed class AutomaticScheduleGeneratorTests
|
||||
Assert.Equal(1, entry.StartPeriod);
|
||||
Assert.Equal(2, entry.PeriodCount);
|
||||
Assert.Null(entry.ClassroomId);
|
||||
Assert.Contains("遗传算法", entry.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1252,8 +1252,8 @@ onBeforeUnmount(() => {
|
||||
<el-tab-pane label="自动排课优化" name="optimization">
|
||||
<div class="settings-lead">
|
||||
<div>
|
||||
<b>自动排课优化策略</b>
|
||||
<span>硬约束始终优先;数值越高,自动排课越会规避对应情况。设为 0 可关闭该偏好。</span>
|
||||
<b>遗传算法优化策略</b>
|
||||
<span>自动排课以遗传算法搜索全局方案;硬约束始终优先,数值越高越会规避对应情况。设为 0 可关闭该偏好。</span>
|
||||
</div>
|
||||
<el-button type="primary" :loading="optimizationSaving" @click="saveOptimizationSettings">
|
||||
保存优化策略
|
||||
|
||||
Reference in new issue
Block a user