遗传算法

This commit is contained in:
biss committed 2026-08-17 10:57:59 +08:00
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]
+2 -2
View File
@@ -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">
保存优化策略