Files
BYW-VoiceHub/app/utils/autoSchedule.ts
T

483 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 自动排期算法
* 在候选池中选取总时长最接近目标的歌曲组合
*
* 策略:主路径按降序贪心 + 'under' 二次升序回填;辅路径随机采样;取 absDiff 更小的结果
*
* @param direction 'under' 总时长不超过目标,'over' 总时长不低于目标,'middle' 相差最小(中间放)
* @param targetMinutes 目标总时长(分钟)
* @param candidates 候选歌曲列表(需含 id 和 durationSeconds)
* @param preSelected 用户已固定的歌曲(总是保留,算法仅对剩余候选补齐剩余时长)
* @param plansCount 返回方案数,按 absDiff 升序排列(默认 1,仅返回最优)
* @param targetSongCount 期望的最终歌曲数量(可选;启用后优先匹配歌曲数量,再比较时长)
* @param targetRequesterCount 期望的不同投稿人数量(可选;启用后优先覆盖不同投稿人,再比较时长)
*/
export type AutoScheduleDirection = 'under' | 'over' | 'middle'
export interface AutoScheduleCandidate {
id: number
songId: number
title: string
artist: string
durationSeconds?: number | null
replayRequestId?: number | null
musicId?: string | null
musicPlatform?: string | null
requester?: string | null
cover?: string | null
createdAt?: string | null
// UI 标记:是否为固定歌曲
isFixed?: boolean
}
export interface AutoScheduleResult {
songs: AutoScheduleCandidate[]
totalDuration: number
diff: number
absDiff: number
}
function normalizeTargetSongCount(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
return Math.floor(value)
}
/**
* 按投稿人分散排序:每个投稿人的首首歌排前,其余排后(组内保持原时长顺序)
* 用于贪心选曲时优先覆盖不同投稿人
*/
function diversifyOrder(
list: AutoScheduleCandidate[],
targetRequesterCount: number | null
): AutoScheduleCandidate[] {
if (targetRequesterCount === null || targetRequesterCount <= 0) return list
const seen = new Set<string>()
const firstOfRequester: AutoScheduleCandidate[] = []
const rest: AutoScheduleCandidate[] = []
for (const song of list) {
const key = song.requester || ''
if (key && !seen.has(key)) {
seen.add(key)
firstOfRequester.push(song)
} else {
rest.push(song)
}
}
return [...firstOfRequester, ...rest]
}
interface ResultCompareContext {
targetSongCount: number | null
fixedCount: number
targetRequesterCount: number | null
fixedRequesterSet: Set<string>
}
function distinctRequesterCount(
songs: AutoScheduleCandidate[],
ctx: ResultCompareContext
): number {
const set = new Set(ctx.fixedRequesterSet)
for (const s of songs) {
if (s.requester) set.add(s.requester)
}
return set.size
}
function compareResults(a: AutoScheduleResult, b: AutoScheduleResult, ctx: ResultCompareContext): number {
if (ctx.targetSongCount !== null) {
const aCountDiff = Math.abs(a.songs.length + ctx.fixedCount - ctx.targetSongCount)
const bCountDiff = Math.abs(b.songs.length + ctx.fixedCount - ctx.targetSongCount)
if (aCountDiff !== bCountDiff) return aCountDiff - bCountDiff
}
if (ctx.targetRequesterCount !== null) {
const aReqDiff = Math.abs(distinctRequesterCount(a.songs, ctx) - ctx.targetRequesterCount)
const bReqDiff = Math.abs(distinctRequesterCount(b.songs, ctx) - ctx.targetRequesterCount)
if (aReqDiff !== bReqDiff) return aReqDiff - bReqDiff
}
return a.absDiff - b.absDiff || b.songs.length - a.songs.length
}
/**
* 公共前置处理:计算目标秒数、排除固定歌曲、准备候选池
* 若无可用的候选歌曲,直接返回 earlyReturn 供调用方提前结束
*/
function prepareScheduleInput(
targetMinutes: number,
candidates: AutoScheduleCandidate[],
preSelected: AutoScheduleCandidate[],
plansCount: number
): {
targetSeconds: number
preSelectedSeconds: number
availableCandidates: AutoScheduleCandidate[]
emptyResult: AutoScheduleResult
earlyReturn?: AutoScheduleResult | AutoScheduleResult[]
} {
const targetSeconds = Math.floor(targetMinutes * 60)
const preSelectedIds = new Set(preSelected.map((s) => s.id))
const preSelectedSeconds = preSelected.reduce(
(sum, s) => sum + (typeof s.durationSeconds === 'number' ? s.durationSeconds : 0),
0
)
const availableCandidates = candidates.filter(
(s) => !preSelectedIds.has(s.id) && typeof s.durationSeconds === 'number' && s.durationSeconds > 0
)
const emptyResult: AutoScheduleResult = { songs: [], totalDuration: 0, diff: 0, absDiff: 0 }
if (availableCandidates.length === 0 && preSelected.length === 0) {
return { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult, earlyReturn: plansCount === 1 ? emptyResult : [emptyResult] }
}
if (availableCandidates.length === 0) {
const fixedSongs = preSelected.map((s) => ({ ...s, isFixed: true }))
const total = preSelectedSeconds
const diff = total - targetSeconds
const result: AutoScheduleResult = { songs: fixedSongs, totalDuration: total, diff, absDiff: Math.abs(diff) }
return { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult, earlyReturn: plansCount === 1 ? result : [result] }
}
return { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult }
}
export function autoSchedule(
direction: AutoScheduleDirection,
targetMinutes: number,
candidates: AutoScheduleCandidate[],
preSelected: AutoScheduleCandidate[] = [],
plansCount: number = 1,
targetSongCount?: number | null,
targetRequesterCount?: number | null
): AutoScheduleResult | AutoScheduleResult[] {
const input = prepareScheduleInput(targetMinutes, candidates, preSelected, plansCount)
if (input.earlyReturn !== undefined) return input.earlyReturn
const { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult } = input
const normalizedTargetSongCount = normalizeTargetSongCount(targetSongCount)
const normalizedTargetRequesterCount = normalizeTargetSongCount(targetRequesterCount)
const compareCtx: ResultCompareContext = {
targetSongCount: normalizedTargetSongCount,
fixedCount: preSelected.length,
targetRequesterCount: normalizedTargetRequesterCount,
fixedRequesterSet: new Set(preSelected.map((s) => s.requester).filter(Boolean))
}
const targetAdditionalCount = normalizedTargetSongCount === null
? null
: Math.max(0, normalizedTargetSongCount - preSelected.length)
const remainingTarget = Math.max(0, targetSeconds - preSelectedSeconds)
const songMap = new Map(availableCandidates.map((s) => [s.id, s]))
const runGreedy = (sorted: AutoScheduleCandidate[], dir: 'under' | 'over') => {
const result: AutoScheduleCandidate[] = []
let total = 0
for (const song of sorted) {
if (targetAdditionalCount !== null && result.length >= targetAdditionalCount) break
const newTotal = total + song.durationSeconds!
if (dir === 'under') {
if (newTotal <= remainingTarget) {
result.push(song)
total = newTotal
}
} else {
if (remainingTarget <= 0) {
// 已选歌曲时长已超过目标,无需再追加
} else {
result.push(song)
total = newTotal
if (
total >= remainingTarget &&
(targetAdditionalCount === null || result.length >= targetAdditionalCount)
) break
}
}
}
if (dir === 'under') {
const ids = new Set(result.map((s) => s.id))
for (const song of diversifyOrder(
[...availableCandidates].sort((a, b) => a.durationSeconds! - b.durationSeconds!),
normalizedTargetRequesterCount
)) {
if (targetAdditionalCount !== null && result.length >= targetAdditionalCount) break
if (ids.has(song.id)) continue
if (total + song.durationSeconds! <= remainingTarget) {
result.push(song)
total += song.durationSeconds!
ids.add(song.id)
}
}
} else {
const selectedIds = new Set(result.map((s) => s.id))
let remaining = availableCandidates.filter((s) => !selectedIds.has(s.id))
.sort((a, b) => a.durationSeconds! - b.durationSeconds!)
let ri = result.length - 1
search: while (ri >= 0) {
const currentSong = result[ri]
const currentDuration = currentSong.durationSeconds!
const totalWithoutThis = total - currentDuration
// 移除当前歌后仍满足约束,直接移除(更优解)
if (
totalWithoutThis >= remainingTarget &&
(targetAdditionalCount === null || result.length > targetAdditionalCount)
) {
result.splice(ri, 1)
total = totalWithoutThis
selectedIds.delete(currentSong.id)
remaining = availableCandidates.filter((s) => !selectedIds.has(s.id))
.sort((a, b) => a.durationSeconds! - b.durationSeconds!)
ri = result.length - 1
continue
}
const minReplacement = remainingTarget - totalWithoutThis
if (minReplacement >= currentDuration) {
ri--
continue
}
for (let ii = 0; ii < remaining.length; ii++) {
const candidate = remaining[ii]
if (
candidate.durationSeconds! >= minReplacement &&
candidate.durationSeconds! < currentDuration
) {
result[ri] = candidate
total = totalWithoutThis + candidate.durationSeconds!
selectedIds.delete(currentSong.id)
selectedIds.add(candidate.id)
remaining.splice(ii, 1)
break search
}
}
ri--
}
}
const diff = (total + preSelectedSeconds) - targetSeconds
return { songs: result, totalDuration: total + preSelectedSeconds, diff, absDiff: Math.abs(diff) }
}
const sortedDescending = [...availableCandidates].sort(
(a, b) => b.durationSeconds! - a.durationSeconds!
)
const sortedAscending = [...availableCandidates].sort(
(a, b) => a.durationSeconds! - b.durationSeconds!
)
// 设置投稿人目标时分散排序,优先覆盖不同投稿人
const diversifiedDescending = diversifyOrder(sortedDescending, normalizedTargetRequesterCount)
const diversifiedAscending = diversifyOrder(sortedAscending, normalizedTargetRequesterCount)
const sortedByDuration = normalizedTargetSongCount !== null && direction === 'under'
? diversifiedAscending
: diversifiedDescending
// 生成方案
const plans: AutoScheduleResult[] = []
const seenSets = new Set<string>()
const addUnique = (result: AutoScheduleResult) => {
const key = result.songs.map((s) => s.id).sort().join(',')
if (!seenSets.has(key)) {
seenSets.add(key)
plans.push(result)
}
}
// 确定性路径
if (direction === 'middle') {
addUnique(runGreedy(normalizedTargetSongCount !== null ? diversifiedAscending : diversifiedDescending, 'under'))
addUnique(runGreedy(diversifiedDescending, 'over'))
} else {
addUnique(runGreedy(sortedByDuration, direction))
}
// 随机路径:多次 shuffle 生成不同方案
// 生成比 plansCount 多一些以保留筛选空间
const maxIter = Math.max(50, plansCount * 5)
for (let i = 0; i < maxIter && plans.length < plansCount * 3; i++) {
const shuffled = availableCandidates.map((s) => s.id)
for (let j = shuffled.length - 1; j > 0; j--) {
const k = Math.floor(Math.random() * (j + 1))
;[shuffled[j], shuffled[k]] = [shuffled[k], shuffled[j]]
}
const shuffledSorted = shuffled.map((id) => songMap.get(id)).filter(Boolean)
const dir = direction === 'middle' ? (i % 2 === 0 ? 'under' : 'over') : direction
addUnique(runGreedy(diversifyOrder(shuffledSorted, normalizedTargetRequesterCount), dir))
}
// 按质量排序:设置期望首数时先比较首数偏差,再比较时长偏差
plans.sort((a, b) => compareResults(a, b, compareCtx))
// 合并固定歌曲并截取
const fixedSongs = preSelected.map((s) => ({ ...s, isFixed: true }))
const finalPlans = plans
.slice(0, plansCount)
.map((p) => ({ ...p, songs: [...fixedSongs, ...p.songs] }))
if (plansCount === 1) {
return finalPlans[0] || emptyResult
}
return finalPlans
}
/**
* 穷举排期算法(DFS + 剪枝)
* 系统遍历候选歌曲的子集组合,按 absDiff 收集最优方案
*
* 复杂度:最坏 O(2^n),通过剪枝大幅缩减实际搜索空间
* - 方向约束剪枝:'under' 超过目标立即终止;'over' 无法达到目标终止
* - 最优解剪枝:当前分支最差情况已超过已有最优解则终止
* - 节点上限:限制总访问节点数,防止超大候选池卡死
*/
export function autoScheduleExhaustive(
direction: AutoScheduleDirection,
targetMinutes: number,
candidates: AutoScheduleCandidate[],
preSelected: AutoScheduleCandidate[] = [],
plansCount: number = 1,
targetSongCount?: number | null,
targetRequesterCount?: number | null
): AutoScheduleResult | AutoScheduleResult[] {
const input = prepareScheduleInput(targetMinutes, candidates, preSelected, plansCount)
if (input.earlyReturn !== undefined) return input.earlyReturn
const { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult } = input
const normalizedTargetSongCount = normalizeTargetSongCount(targetSongCount)
const normalizedTargetRequesterCount = normalizeTargetSongCount(targetRequesterCount)
const compareCtx: ResultCompareContext = {
targetSongCount: normalizedTargetSongCount,
fixedCount: preSelected.length,
targetRequesterCount: normalizedTargetRequesterCount,
fixedRequesterSet: new Set(preSelected.map((s) => s.requester).filter(Boolean))
}
// 将时长收窄为 number 类型,消除后续的非空断言
type DurationCandidate = Omit<AutoScheduleCandidate, 'durationSeconds'> & { durationSeconds: number }
const remainingTarget = Math.max(0, targetSeconds - preSelectedSeconds)
// 按时长降序排列,大歌曲优先,利于剪枝;设置投稿人目标时分散排序
const sorted = diversifyOrder(
[...(availableCandidates as DurationCandidate[])].sort((a, b) => b.durationSeconds - a.durationSeconds),
normalizedTargetRequesterCount
)
const n = sorted.length
// 后缀和:suffixSum[i] = sorted[i..n-1] 的时长之和,用于判定能否达到目标
const suffixSum = new Array(n + 1).fill(0)
for (let i = n - 1; i >= 0; i--) {
suffixSum[i] = suffixSum[i + 1] + sorted[i].durationSeconds
}
const solutions: AutoScheduleResult[] = []
const seenKeys = new Set<string>()
let nodeCount = 0
const maxNodes = 100000
const maxSolutions = plansCount * 3
// worstAbs 记录当前已收录方案中最差的 absDiff,作为剪枝阈值
// 初始值 Infinity 确保首个方案能被正确记录并更新该值(0 会导致找到完美解后后续方案被全部过滤)
let worstAbs = Infinity
const currentSongs: AutoScheduleCandidate[] = []
const record = (currentTotal: number) => {
const canUseFixedOnly = normalizedTargetSongCount !== null && preSelected.length >= normalizedTargetSongCount
if (currentSongs.length === 0 && !canUseFixedOnly) return
const total = currentTotal + preSelectedSeconds
const diff = total - targetSeconds
if (direction === 'over' && total < targetSeconds) return
if (direction === 'under' && total > targetSeconds) return
const absDiff = Math.abs(diff)
const key = currentSongs.map((s) => s.id).sort().join(',')
if (seenKeys.has(key)) return
seenKeys.add(key)
solutions.push({ songs: [...currentSongs], totalDuration: total, diff, absDiff })
solutions.sort((a, b) => compareResults(a, b, compareCtx))
if (solutions.length > maxSolutions) {
solutions.splice(maxSolutions)
worstAbs = solutions[solutions.length - 1].absDiff
}
}
const canPrune = (idx: number, currentTotal: number): boolean => {
if (nodeCount > maxNodes) return true
if (direction === 'under') {
// 已超过目标,无效
if (currentTotal > remainingTarget) return true
// 即使加上所有剩余歌曲也到不了目标,且当前差距已大于已有最优
if (normalizedTargetSongCount === null && currentTotal + suffixSum[idx] < remainingTarget && remainingTarget - currentTotal > worstAbs) {
return true
}
} else if (direction === 'over') {
// 即使加上所有剩余歌曲也达不到目标
if (currentTotal + suffixSum[idx] < remainingTarget) return true
// 当前已远超目标且超出量已大于已有最优
if (normalizedTargetSongCount === null && currentTotal >= remainingTarget && currentTotal - remainingTarget > worstAbs) {
return true
}
} else {
// middle:判断从当前状态能达到的最好 absDiff
const minDiff = currentTotal - remainingTarget
const maxDiff = currentTotal + suffixSum[idx] - remainingTarget
if (normalizedTargetSongCount === null) {
if (minDiff >= 0 && minDiff > worstAbs) return true
if (maxDiff <= 0 && -maxDiff > worstAbs) return true
}
}
return false
}
const dfs = (idx: number, currentTotal: number) => {
nodeCount++
if (nodeCount > maxNodes) return
record(currentTotal)
if (idx >= n) return
if (canPrune(idx, currentTotal)) return
// 尝试包含 sorted[idx]
if (direction === 'under') {
if (currentTotal + sorted[idx].durationSeconds <= remainingTarget) {
currentSongs.push(sorted[idx])
dfs(idx + 1, currentTotal + sorted[idx].durationSeconds)
currentSongs.pop()
}
} else {
currentSongs.push(sorted[idx])
dfs(idx + 1, currentTotal + sorted[idx].durationSeconds)
currentSongs.pop()
}
// 尝试不包含 sorted[idx]
dfs(idx + 1, currentTotal)
}
dfs(0, 0)
solutions.sort((a, b) => compareResults(a, b, compareCtx))
const fixedSongs = preSelected.map((s) => ({ ...s, isFixed: true }))
const finalPlans = solutions.slice(0, plansCount).map((p) => ({
...p,
songs: [...fixedSongs, ...p.songs]
}))
return plansCount === 1 ? (finalPlans[0] || emptyResult) : finalPlans
}
/**
* 将备选池条目规范化为统一的候选歌曲对象
*/
export function poolCandidateFromItem(item: any): AutoScheduleCandidate {
return {
id: item.songId,
songId: item.songId,
title: item.title,
artist: item.artist,
durationSeconds: item.durationSeconds,
replayRequestId: item.replayRequestId || null,
musicId: item.musicId,
musicPlatform: item.musicPlatform,
requester: item.requester,
cover: item.cover,
createdAt: item.createdAt
}
}