来研究下osu!的pp算法(Part_2)

上一部分我们提到了osu!中pp算法的各种指标,接下来,我们就来考虑一下,如何把osu!的pp算法延伸到OI题目难度的评估上。
那么,自然也就会想到如下的一些问题:

如何设计一个评价OI能力的算法?!

要回答这个问题,首先要对OI有一定的认识...毕竟OI和osu!本身还是有很大差别的,它们的共同点是都有一种固定的东西用于定性评价技巧(osu!是Ranked谱面,而OI就是各种各样的算法题目),所以在此基础上看了许多基础的知识和不同难度的算法题。

Yes, OI 也有自己的Wiki啦~(oi-wiki.org)(当然OI Wiki的编写也受到了CTF Wiki的影响,但本人对CTF并不感兴趣...而是热爱纯粹的编程...因为CTF需要学很多很多很多东西,感觉没有什么性价比...)

和osu!算法一样,我们需要先确定,如何评价一个人OI/编程能力的好坏呢?
最基本的思路就是根据能做出什么样难度的题目来进行评价,然后用类似osu!的pp算法把它们累加起来。
因此,我们也可以设计一个和osu!类似的算法,不过需要确定两个系数
(1)每个算法题的加权pp计算

weighted_pp = pp * alpha^(n-1)

其中n代表这道算法题目在个人做题记录中的排名
那么alpha取多少比较合适呢?
这就要考虑到每道题运用到的知识点都有所不同了..
alpha的选取决定了刷相同等级的pp,总pp所能到达的上限,因为我们知道

pp*(1 + alpha + alpha^2 + ...) = 1/(1 - alpha) * pp

如果alpha取0.95,那么1/(1-alpha)就等于20,如果alpha取0.97,那么1/(1-alpha)就约等于33.33。
这还需要进一步确定,不过我们暂且先把它设置成0.95(和osu!算法一致)

接下来,就是如何去评价一道题的总pp,以及如何根据个人表现来确定个人实际上能够得到的pp的问题了...
为此,我们研究了很多案例,总结了下面几点:
比如我们先从Leetcode的题库中选一些题目来大致看一下如何确定一道题的pp上限

一些难度为“简单”的题

  1. 两数之和(Leetcode的第一题)
    1. 解法:暴力解法 or 哈希表
    2. 如何评价本题的pp呢?
      我们先来看这道题Leetcode官方给出的答案代码:
      这是暴力题解的代码
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
if nums[i] + nums[j] == target:
return [i, j]
return []
#作者:力扣官方题解
#链接:https://leetcode.cn/problems/two-sum/solutions/434597/liang-shu-zhi-he-by-leetcode-solution/

时间复杂度O(n^2),空间复杂度为O(1).

class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
hashtable = dict()
for i, num in enumerate(nums):
if target - num in hashtable:
return [hashtable[target - num], i]
hashtable[nums[i]] = i
return []
#作者:力扣官方题解
#链接:https://leetcode.cn/problems/two-sum/solutions/434597/liang-shu-zhi-he-by-leetcode-solution/

时间复杂度为O(N),空间复杂度也是O(N).
osu!中确定一张谱面的pp采用的是分段和分不同指标进行评价的方法,这里要如何用类似的方法来评价呢?
2. LCP 01 猜数字
3. Fizz Buzz
4. 合并两个有序链表
5. x的平方根

osu!对于单个谱面的pp的三个指标的pp并不是直接相加y = a + b + c,而是 y = (a^n + b^n + cn)(1/n),其中n = 1.1。

接下来想办法搞到了osu-performance开源项目(Github里面有),然后我们分析一下代码(原来的代码是C++做的,虽然学过,但是还是看Python比较方便)
以下是ChatGPT生成的代码的一部分

import math
class OsuScore:
def __init__(self, scoreId, mode, userId, beatmapId, score, maxCombo, num300, num100, num50, numMiss, numGeki, numKatu, mods, beatmap):
super().__init__(scoreId, mode, userId, beatmapId, score, maxCombo, num300, num100, num50, numMiss, numGeki, numKatu, mods)
self.computeEffectiveMissCount(beatmap)
self.computeAimValue(beatmap)
self.computeSpeedValue(beatmap)
self.computeAccuracyValue(beatmap)
self.computeFlashlightValue(beatmap)
self.computeTotalValue(beatmap)
def TotalValue(self):
return self._totalValue
def Accuracy(self):
if self.TotalHits() == 0:
return 0
return max(0.0, min(1.0, (self._num50 * 50 + self._num100 * 100 + self._num300 * 300) / (self.TotalHits() * 300)))
def TotalHits(self): #谱面的最大成功命中数,考虑了miss
return self._num50 + self._num100 + self._num300 + self._numMiss
def TotalSuccessfulHits(self): #成功命中数
return self._num50 + self._num100 + self._num300
def computeEffectiveMissCount(self, beatmap): #计算有效的Miss数量
comboBasedMissCount = 0.0
beatmapMaxCombo = beatmap.DifficultyAttribute(self._mods, Beatmap.MaxCombo)
if beatmap.NumSliders() > 0: #如果滑条数 > 0
fullComboThreshold = beatmapMaxCombo - 0.1 * beatmap.NumSliders() #全连阈值为MaxCombo - 10% * 滑条数量(有点离谱,难道说如果小于这个数字就不能算全连了吗...)
if self._maxCombo < fullComboThreshold:
comboBasedMissCount = fullComboThreshold / max(1, self._maxCombo)
#如果maxCombo = 400, 滑条数为100,那么阈值应该是390。
#此时,如果_maxCombo的值为389,那么Misscount就是390 / max(1,389) = 390/389 = 1.
#否则就不执行这段代码,Misscount = 0.
comboBasedMissCount = min(comboBasedMissCount, float(self._num100 + self._num50 + self._numMiss))
#这里假设num100,num50和nummiss分别是11,0,0,那么ComboMisscount就是min(390/389,11) = 390/389.
self._effectiveMissCount = max(float(self._numMiss), comboBasedMissCount) #计算本局游戏的MissCount = max(0,390/389) = 390/389???
def computeTotalValue(self, beatmap): #计算一张图的总pp
if (self._mods & EMods.Relax) > 0 or (self._mods & EMods.Relax2) > 0 or (self._mods & EMods.Autoplay) > 0:
self._totalValue = 0
return
multiplier = 1.14
if (self._mods & EMods.NoFail) > 0: #NoFail图的Multiplayer
multiplier *= max(0.9, 1.0 - 0.02 * self._effectiveMissCount)
numTotalHits = self.TotalHits() #Totalhits
if (self._mods & EMods.SpunOut) > 0: #SpunOut Mod的Multiplayer
multiplier *= 1.0 - math.pow(beatmap.NumSpinners() / float(numTotalHits), 0.85)
self._totalValue = math.pow(
math.pow(self._aimValue, 1.1) +
math.pow(self._speedValue, 1.1) +
math.pow(self._accuracyValue, 1.1) +
math.pow(self._flashlightValue, 1.1),
1.0 / 1.1
) * multiplier #我们刚刚看过的公式,只不过需要与Multiplier相乘,这里的Multiplier应该是起到归一化的作用,确保这个结果和a+b+c相等
def computeAimValue(self, beatmap):
self._aimValue = math.pow(5.0 * max(1.0, beatmap.DifficultyAttribute(self._mods, Beatmap.Aim) / 0.0675) - 4.0, 3.0) / 100000.0
# 假设Beatmap.Aim = 1.4, _mods对其没有影响(NM)的情况下,_aimValue = (5 * 20.74(=1.4 / 0.075) - 4) ^ 3 / 100000 = 9.91026973
numTotalHits = self.TotalHits()
lengthBonus = 0.95 + 0.4 * min(1.0, float(numTotalHits) / 2000.0) + (log10(float(numTotalHits) / 2000.0) * 0.5 if numTotalHits > 2000 else 0.0)
self._aimValue *= lengthBonus
#将lengthBonus=3000和lengthbonus=9000代入上式:
#0.95 + 0.4 + log10(1.5) * 0.5 = 1.4380456
#0.95 + 0.4 + log10(4.5) * 0.5 = 1.6676606
if self._effectiveMissCount > 0:
self._aimValue *= 0.97 * math.pow(1.0 - math.pow(self._effectiveMissCount / float(numTotalHits), 0.775), self._effectiveMissCount)
#通过计算的MissCount将)aimvalue调整为这个形式
self._aimValue *= self.getComboScalingFactor(beatmap) #连击的Scaling Factor
approachRate = beatmap.DifficultyAttribute(self._mods, Beatmap.AR)
approachRateFactor = 0.0
if approachRate > 10.33:
approachRateFactor = 0.3 * (approachRate - 10.33)
elif approachRate < 8.0:
approachRateFactor = 0.05 * (8.0 - approachRate)
#感觉可以写成一个更简单的公式:approachFactor = max(0.3 * (AR-10.33), 0.05*(8-AR), 0),至于为什么,是因为它们函数的单调性..上午有分析过的,不过为了代码可维护性还是写成了分段的形式
self._aimValue *= 1.0 + approachRateFactor * lengthBonus #乘以Lengthbonus(上午似乎讨论过)
if (self._mods & EMods.Hidden) > 0:
self._aimValue *= 1.0 + 0.04 * (12.0 - approachRate) #HD带来的影响
#AR越大,HD带来的影响越小,直到最大的时候(AR11),此时HD只能提供4%的额外增幅(而如果是AR0,则是48%)
estimateDifficultSliders = beatmap.NumSliders() * 0.15 #Sliders的Difficulty
if beatmap.NumSliders() > 0: #如果存在滑条的话
maxCombo = beatmap.DifficultyAttribute(self._mods, Beatmap.MaxCombo) #假设maxCombo=400
estimateSliderEndsDropped = min(max(min(float(self._num100 + self._num50 + self._numMiss), maxCombo - self._maxCombo), 0.0), estimateDifficultSliders) #估计滑条断尾的个数,因为我们知道滑条断尾只会产生一个100,但不会断连(如果断头的话也是会产生一个100,但会断连),不过好复杂,居然套了三层min/max...
sliderFactor = beatmap.DifficultyAttribute(self._mods, Beatmap.SliderFactor)
sliderNerfFactor = (1.0 - sliderFactor) * math.pow(1.0 - estimateSliderEndsDropped / estimateDifficultSliders, 3) + sliderFactor
self._aimValue *= sliderNerfFactor
self._aimValue *= self.Accuracy()
self._aimValue *= 0.98 + math.pow(beatmap.DifficultyAttribute(self._mods, Beatmap.OD), 2) / 2500
def getComboScalingFactor(self, beatmap): #连击的Scaling Factor = min((_maxcombo/maxcombo)^0.8,1).看起来是这一部分导致了miss之后pp的下降,而且是成反比例函数
maxCombo = beatmap.DifficultyAttribute(self._mods, Beatmap.MaxCombo)
if maxCombo > 0:
return min(math.pow(self._maxCombo, 0.8) / math.pow(maxCombo, 0.8), 1.0)
return 1.0
# Add other methods as needed
def compute_speed_value(beatmap, mods, num_50, num_total_hits, effective_miss_count, max_combo):
# Step 1: Calculate base speed value - 和Aim一样先计算Speed的基础pp值
speed_value = (5.0 * max(1.0, beatmap.difficulty_attribute(mods, "Speed") / 0.0675) - 4.0) ** 3.0 / 100000.0
# Step 2: Length Bonus - 也有length奖励值
length_bonus = 0.95 + 0.4 * min(1.0, num_total_hits / 2000.0) + (log10(num_total_hits / 2000.0) * 0.5 if num_total_hits > 2000 else 0.0)
speed_value *= length_bonus
# Step 3: Miss Penalty - Miss惩罚,和Aim一样的
if effective_miss_count > 0:
speed_value *= 0.97 * (1.0 - (effective_miss_count / num_total_hits) ** 0.775) ** effective_miss_count ** 0.875
# Step 4: Combo Scaling - 和之前的函数是一样的,这也能解释为什么miss后aim和speed的pp会下降,而玩家的maxcombo提升后,pp又会上去。(因为(a+c)/(b+c) > (a/b) if c>0,所以说即使有Miss,只要Maxcombo有提升,pp也会有提升)
speed_value *= get_combo_scaling_factor(beatmap, mods, max_combo)
# Step 5: Approach Rate Adjustment - AR对speed的影响
approach_rate = beatmap.difficulty_attribute(mods, "AR")
approach_rate_factor = 0.3 * (approach_rate - 10.33) if approach_rate > 10.33 else 0.0
speed_value *= 1.0 + approach_rate_factor * length_bonus
# Step 6: Hidden Correction - HD系数
if "Hidden" in mods:
speed_value *= 1.0 + 0.04 * (12.0 - approach_rate)
# Step 7: Accuracy and OD Adjustment
relevant_total_diff = num_total_hits - beatmap.difficulty_attribute(mods, "SpeedNoteCount")
# ... Other variable calculations
speed_value *= (0.95 + (beatmap.difficulty_attribute(mods, "OD") ** 2) / 750) * ((accuracy() + relevant_accuracy) / 2.0) ** ((14.5 - max(beatmap.difficulty_attribute(mods, "OD"), 8.0)) / 2)
# Step 8: 50 Penalty 对50个数比例的惩罚系数
speed_value *= 0.99 ** (0.0 if num_50 < num_total_hits / 500.0 else num_50 - num_total_hits / 500.0)
return speed_value
def compute_accuracy_value(beatmap, mods, num_300, num_100, num_50, total_hits, num_hit_objects_with_accuracy):
better_accuracy_percentage = 0.0 #
if beatmap.score_version() == Beatmap.EScoreVersion.ScoreV2: #Scorev2的pp
num_hit_objects_with_accuracy = total_hits
better_accuracy_percentage = accuracy() #应该是计算acc
else:
num_hit_objects_with_accuracy = beatmap.num_hit_circles() #根据圆圈个数
if num_hit_objects_with_accuracy > 0:
better_accuracy_percentage = ((num_300 - (total_hits - num_hit_objects_with_accuracy)) * 6 + num_100 * 2 + num_50) / (num_hit_objects_with_accuracy * 6)
else:
better_accuracy_percentage = 0
if better_accuracy_percentage < 0:
better_accuracy_percentage = 0
# Lots of arbitrary values from testing.
# Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution.
accuracy_value = (
math.pow(1.52163, beatmap.difficulty_attribute(mods, "OD")) * math.pow(better_accuracy_percentage, 24) *
2.83
)
# Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
accuracy_value *= min(1.15, pow(num_hit_objects_with_accuracy / 1000.0, 0.3))
# 比如说下面是OD分别为6,8,11时,不同acc下的accuracy_value。(不考虑其他因素)
'''
Results for OD = 6:
Accuracy: 100.0%, Accuracy Value: 35.126980
Accuracy: 99.5%, Accuracy Value: 31.145460
Accuracy: 99.0%, Accuracy Value: 27.598500
Accuracy: 98.5%, Accuracy Value: 24.440514
Accuracy: 98.0%, Accuracy Value: 21.630503
Accuracy: 75.0%, Accuracy Value: 0.035246
Results for OD = 8:
Accuracy: 100.0%, Accuracy Value: 81.331528
Accuracy: 99.5%, Accuracy Value: 72.112885
Accuracy: 99.0%, Accuracy Value: 63.900404
Accuracy: 98.5%, Accuracy Value: 56.588537
Accuracy: 98.0%, Accuracy Value: 50.082356
Accuracy: 75.0%, Accuracy Value: 0.081607
Results for OD = 11:
Accuracy: 100.0%, Accuracy Value: 286.540569
Accuracy: 99.5%, Accuracy Value: 254.062201
Accuracy: 99.0%, Accuracy Value: 225.128661
Accuracy: 98.5%, Accuracy Value: 199.368091
Accuracy: 98.0%, Accuracy Value: 176.446048
Accuracy: 75.0%, Accuracy Value: 0.287512
'''
if "Hidden" in mods:
accuracy_value *= 1.08
if "Flashlight" in mods:
accuracy_value *= 1.02
return accuracy_value
# Example usage:
# accuracy_value = compute_accuracy_value(beatmap, mods, num_300, num_100, num_50, total_hits, num_hit_objects_with_accuracy)
# print(accuracy_value)

感觉好像有点思路了...总结来说,Aim和Speed的初始参数是取决于beatmap中Aim和Speed部分难度星级的,也和玩家表现有关;而Accuracy的计算是取决于OD和玩家表现的。后续增加了很多因素(如谱面物件个数等)的影响为这些因素提供了相应的奖励pp.
那么也就有了下一个问题,一张图的Aim和Speed是如何被计算出来的呢?或者说StarRating是如何计算的呢?
我们分析了一个可以用pip安装的包osu-sr-calculator的代码逻辑,得出了下面的SR计算的步骤:

  1. 初始化:

    • 初始化 Aim 和 Speed 技能计算器(Aim()Speed())。
    • 定义每个小节的长度(section_length)和难度倍增系数(difficlty_multiplier)。
  2. 遍历 HitObjects:

    • 对每个 HitObject 进行处理,判断是否进入了新的小节,若是则保存当前小节的峰值难度值。
    • 调用 Aim 和 Speed 计算器的 process 方法,传入当前 HitObject 进行技能值的更新。
  3. 计算技能值:

    • 在处理过程中,Aim 和 Speed 计算器会累积技能值,并根据一些规则计算出峰值难度值。
    • 在整个图谱遍历完成后,最后保存当前小节的峰值。
  4. 计算总体 Star Rating:

    • 根据 Aim 和 Speed 计算器的最终难度值,通过一定的计算方式计算出 Aim、Speed 和总体的 Star Rating。
    • 具体的计算方式包括取平方根、绝对值差值等。
  5. 返回结果:

    • 返回一个包含 Aim、Speed 和总体 Star Rating 的字典。

总体来说,这个计算器通过考虑 Aim 和 Speed 技能,对每个小节内的 HitObject 进行处理,从而得到一张 osu! 图谱的 Star Rating。计算过程中考虑了距离、时间、角度等因素,最终得到的 Star Rating 是对整个图谱难度的一个综合评估。

那么现在来说,osu!地图计算pp的机理应该就都解析完了,不过,把它放在OI算法题难度测试里面,客观评价一道题的难度仍然是很有挑战的(因为要考虑的因素是不一样的)

通过查阅相关的资料,我们可以把解决算法题的步骤分成4个子步骤,因此可以基于这几个步骤来继续划分子项的pp.
(1)分析问题,理解题意——确定程序“做什么”

(2)建立模型,设计算法——考虑程序“如何去做”,即设计算法

(3)编写程序——采用C++/Python编写程序

(4)调试、编译运行和测试——避免编译错误、逻辑错误(一些测试用例没有错误输出)

然后我们发散思维...就能画出这样一个思维导图:

首先第一步会与哪些变量相关呢?
应该会与题干相关,那么我们就要考虑下题干的组成:
下面是一道简单的样例算法题目:LuoguP1001

A+B Problem

题目描述

输入两个整数 a,b,输出它们的和(|a|,|b|109)。

输入格式

两个以空格分开的整数。

输出格式

一个整数。

样例 #1

样例输入 #1

20 30

样例输出 #1

50

Leetcode的样例题目如下:

LCP 01. 猜数字

小A 和 小B 在玩猜数字。小B 每次从 1, 2, 3 中随机选择一个,小A 每次也从 1, 2, 3 中选择一个猜。他们一共进行三次这个游戏,请返回 小A 猜对了几次?
输入的guess数组为 小A 每次的猜测,answer数组为 小B 每次的选择。guess和answer的长度都等于3。

示例 1:

输入:guess = [1,2,3], answer = [1,2,3]
输出:3
解释:小A 每次都猜对了。

示例 2:

输入:guess = [2,2,3], answer = [3,2,1]
输出:1
解释:小A 只猜对了第二次。

限制:

guess 的长度 = 3
answer 的长度 = 3
guess 的元素取值为 {1, 2, 3} 之一。
answer 的元素取值为 {1, 2, 3} 之一。

总结来看,Leetcode的算法题结构包括题目描述、输入和输出示例,以及一些限制;Luogu也是差不多的(可能多了一些题目背景),Codeforces等平台应该也会有类似的方式:
去看了下之前程序设计新手赛的题目结构,基本上也是分成题目背景&描述、输入、输出三个部分。

那么它们会如何影响算法题难度呢?
(1)题目背景&描述————很显然,a.题目背景的字数(特指中文文字的个数)会增加题干的理解程度;b.题目中出现的不同变量个数会显著增加题干的理解程度;
(2)输入数据的复杂程度————可以分为几类:
——1:单行或者单组数据,以及任何长度相对固定的输入;
——2:单个数字(表示数据组数) + 多组单行数据;
——3:单个数字 + 多组多行固定结构数据;
——4:更复杂的输入情况,如单个数字 + 多组变长数据等;
(3)输出数据的复杂程度————也可以分为几类:
——1:单行或者单组数据
——2:和输入数据组数对应的组数相同的输出;
——3:其他不规则的输出形式

那么,接下来就是确定一条合适的曲线来定量描述这些关系了(虽然并不能特别完美,但是总比没有好一些),以及确认它对算法题总pp的影响是加性(一个独立因素)还是乘性(缩放因子)

题目背景..应该和中文文字个数成一个单调递增的关系,但是越往后增长越慢
应该可以把它设置成线性的一个函数!
变量个数的话,可以弄成一个二次函数...变量越多,奖励的pp越多。而变量个数pp的初始值可以由中文文字/英文单词个数确定。

因此:

pp_of_description = a * (variable - 2) ** 2 + b

其中a影响变量个数的影响,而b影响题目背景字数。
如果变量个数小于等于2,则pp_of_description等于1,而如果变量个数超过2,则按照上面的二次函数递增,具体来说
二次函数的顶点是(2,1)
当变量个数为3的时候,函数过(3,1.03)点
当变量个数为5的时候,函数过(5,1.12)点
接下来就是初中问题了!如何求二次函数的解析式!

通过Wolframalpha可以求得函数解析式是

y = 0.0005 x^2 + 0.0005x + 0.97

这个函数过上面的这三个点;
现在我们再把后边的b也考虑在内,并对变量b进行建模。
经过一番计算后,我们得到:

b={0if 0<w1003×104w0.03if 100<w4000.21387ln(w328.711)0.00125if w>400

那么题干总的影响因子就应该是

y = a + b
其中a = 1, if 变量数小于等于2; a = 0.0005x^2 + 0.0005x + 0.97, if变量数大于2.

至于输入和输出样例这边,我想如果对算法题熟悉的话,这方面应该不需要特别关心,可以把它看成一个固定的偏移量。
输入:
(1)单行输入:pp增益为变量个数 * 0.5,(字符串的话,根据题意确定应该看成单个数据还是多个数据)
(2)单个数字 + 多组数据:pp增益为每组数据的长度 * 0.7
(3)单个数字 + 多组变长数据:pp增益为每一组变长数据长度 * 1.5
输出:
(1)单行数据输出:pp增益为变量个数 * 0.5
(2)多行数据输出(通常与数据个数有关),pp增益为每组数据长度 * 0.7

那么第一步就暂且处理完了,接下来是第二步,也就是设计算法这个步骤的评价指标,即“怎么做”部分所能提供的最大pp。
算法是指一套确定的、有限的、能够解决特定问题的流程。我们可以用算法的复杂程度来对这一步进行建模。
不过...如何评价设计一个“算法”的复杂程度呢?
从数据结构的角度:
(1)算法只用到了基本的四则运算;
(2)算法用到了一些数据结构;
(2a)算法用到了一些简单的数据结构;
(2b)算法用到了一些复杂的数据结构;
(2c)算法用到了一些数据结构的扩展;
(2d)算法结合了多种数据结构;
从所涉及到的算法角度:
(1)算法的逻辑中,只出现了顺序结构;
(2)算法的逻辑中,出现了一些分支结构;
(3)算法的逻辑中,出现了一些循环结构(或者迭代结构);
(3a)对单层循环;
(3b)对n层循环;
(4)算法的逻辑中,出现了一些递归结构;
(5)算法逻辑中,出现了动态规划思想;(递推结构);
从题目的复杂程度:
(1)单个步骤的处理;
(2)多个步骤的处理;

接下来,我们分情况讨论,设数据结构的pp大小为a,算法设计的大小pp为b,题目复杂程度的pp为c。
从数据结构角度:
(1)只出现四则运算时,按照程序中四则运算代码的行数 * 0.66来评定pp;
(2)使用简单的数据结构:
-1 数组:
一维数组的处理: +10 * 每一维数组的数据个数 +2pp
字符串也可以看成一维数组, 按照上面算法应该是+12pp.
如果是栈和队列,则在一维数组的基础上额外+3pp.
n维数组的处理: +10* 每一维数组的数据个数 * n * (n!) + 2pp(n>=2)
-2 链表
一维链表的处理:+12 * 每一维数组的数据个数(不算指针) +2pp
如果是一维双向链表,则 +18 每一维数据的数据个数(不算指针) +2pp
n维链表的处理: +12 * m * 每一维数组的数据个数(不算指针) * n * (n!) + 2pp (n>=2)
(其中如果是双向链表,m=1.5,否则m=1)
-3 树
对二叉树的处理:+15 * 每一个节点的数据个数(不包括指针) + 2pp(也包括了堆、平衡二叉树等数据结构)
对多叉树的处理:+(9+3n)
每个节点的数据个数(不包括指针) + 2pp, 其中n是多叉树中可能的分支个数,但最大n不超过5
-4 图
对邻接表/逆邻接表: +20 * 每个节点的数据个数 + 3pp
对邻接矩阵: +20pp
-5 哈希表
类似一维数组和n维数组,系数分别为12和12* n *n!
(3)使用复杂的数据结构(或者数据结构的扩展:
一些复杂的数据结构本身概念上就难以看懂,因此使用这种复杂数据结构的算法题必然也会有更多的pp加成。(在原有基础的数据结构上提升30%的pp)
-6 并查集
-7 单调栈和单调队列
-8 平衡二叉树
-9 红黑树
-10 B+树
-11 伸展树
-12 ST表
-13 线段树等
(4)运用多种数据结构,比如LRU的哈希双向链表:
如果采用这种方式,那么对应题目在这一步的pp值将会是两种数据结构的pp相加。

从算法的角度:
(1)顺序结构的加成为2pp;(基本上每个算法都有的2pp)
(2)条件分支的加成为(最简分支个数 * 2)pp
(3)循环结构的加成为
单重循环:8pp
两重循环:24pp
三重循环:96pp
n重循环:(n+1)!4pp
对于非寻常的循环会给予40%的增益;
(4)递归结构的加成为:
9pp + 18pp
(递归传递的变量个数 - 1)
(5)动态规划的加成为:
每一个转移方程的pp之和
每个转移方程的pp = 转移方程涉及的变量个数 * 1pp(如果是max/min操作的数组,只算一个操作数) + 运算步骤/操作步数 * 2pp

第三步:编写代码:
(1)根据代码长度给予一定pp奖励
在代码逻辑清晰的情况下(即不会故意压缩到一行或者拉伸到多行,不包含注释),如果:
有效代码行数小于20,则乘法增益为1.
有效代码行数在20~100之间,增益线性从1增大到1.16(y = 1/500 (x-20) + 1)
有效代码行数超过100行,则增益按照二次函数方式增长,比如当单个题目代码行数达到200行时,增益达到了1.64。(y = 0.000032x^2 + 0.0048x + 1.32)

(2)根据代码的性能判断
对于Leetcode题目,根据打败的用户数百分比,将这一项写成:
1.6 ** 难度基准 * 时间复杂度打败用户数 ** 2.4 * 空间复杂度打败用户数 ** 1.2 * 2.38
对于Luogu题目,则直接确定此项为
1.6 ** 难度基准 * 2.38.

(3)对每一行代码的正确性、作用和价值等进行综合判断,得到每一行代码的pp值。(比如说关键的一行代码、体现技巧更强的代码等都会得到更多的pp值,而不是很关键的代码就不会涉及到pp)

第四步:代码调试和运行
(1)如果测试用例中有正好达到范围上限的大样例,总的pp会乘以1.2
(2)如果测试用例中有边缘用例,总pp会乘以1.05
(实际测评当中,经常会假设(1)和(2)都是成立的)
(3)如果结果一次提交成功,则本题总pp乘以1.2,如果是n次提交成功,则总pp会乘以:(1 - (n/l)^0.775 )^n,其中l为代码行数,而n为提交次数(即没有加成)
(判断一次提交成功的CD是24小时,也就是说24小时后会刷新提交次数)

今天只不过是一个初步的设想..现在真的有点困了...> <明天再结合具体的实例考虑下细节的问题

不过,这只是一个初始的思路,所以还要根据具体问题再来确定,还需要考虑哪些东西。

先以一个简单的例子为例:

Example 01 分苹果

题目描述

有A个苹果,Tuffy拿走了其中的m个,Yuzu拿走了其中的n个,Oni拿走了剩下的所有苹果。问:
(1)Oni拿走了多少苹果?
(2)Yuzu和Tuffy所拿的苹果数之和和Yuzu与Oni所拿苹果之和,哪个更多?

输入样例:

一行3个数据,分别代表A,m,n

输出样例

一行2个数据,第一个数据为整数,回答问题(1);第二个数据为布尔值,回答问题(2)。

提示

数据满足:0 <= A <= 1*10^19, m + n < A.

(1)理解题意的pp增益:
根据公式 y = 0.005x^2 + 0.005x + 0.97 + b,以及b的分段函数可知,b = 3.6e-3,x = 3, pp增益为:
y = 0.005 * 9 + 0.005 * 3 + 0.97 + 0.0036 = 1.0336

(2)算法设计和实现的pp增益
显然,本题算法设计如下:

C++ 实现:

#include <iostream>
int main() {
long long A, m, n; // long long关键字 3pp(可以被视为设计算法步骤上的pp)
std::cin >> A >> m >> n; //cin 的使用 0.3pp
// (1) Oni拿走了多少苹果
long long oniApples = A - m - n; // A = m - n 0.67pp
// (2) Yuzu和Tuffy所拿的苹果数之和Yuzu与Oni所拿苹果之和,哪个更多
long long lucasAndTuffySum = m + n; // sum = m + n 0.67pp
bool lucasAndOniMore = lucasAndTuffySum > oniApples; // bool 变量的正确计算 0.67pp,把bool值简化为最简形式0.33pp
std::cout << oniApples << " " << lucasAndOniMore << std::endl; // 正确的输出语法和格式 0.5pp(cpp的输出比python要复杂一些,所以这里也体现了出来)
return 0; //返回0 0.2pp
}

代码总pp数:6.01pp

Python 实现:

# (1) Oni拿走了多少苹果
def oni_apples(A, m, n):
return A - m - n #计算正确 0.67pp
# (2) Yuzu和Tuffy所拿的苹果数之和和Yuzu与Oni所拿苹果之和,哪个更多
def compare_sums(m, n):
lucas_and_tuffy_sum = m + n #计算正确 0.67pp
oni_apples = oni_apples_global # Assuming oni_apples_global is defined somewhere
return lucas_and_tuffy_sum > oni_apples #bool 变量的正确计算 0.67pp,把bool值简化为最简形式0.33pp
A, m, n = map(int, input().split()) #Python的输入比较有技巧,因此这里给正确输入奖励3pp
# (1) Oni拿走了多少苹果
oni_apples_global = oni_apples(A, m, n) #此步骤不加pp,或者加少量pp,因为如果不用函数也能这样实现 0.05pp
# (2) Yuzu和Tuffy所拿的苹果数之和和Yuzu与Oni所拿苹果之和,哪个更多
lucas_and_oni_more = compare_sums(m, n) #0.05pp
print(oni_apples_global, lucas_and_oni_more) #正确输出 0.3pp

代码总pp数:5.74pp

当然,这仅仅是从代码正确性上(语法上)所给出的pp。
假设测试用例如下:(共有9组)

10 4 4
20 3 3
30 5 5
40 10 10
0 0 0
60 5 5
3919382747111 291929300 202999911
9213818388444 121391393 1219348449
333912818383331 13928482 10219391

并考虑这样的cpp程序

#include <iostream>
int main() {
int A, m, n; //这里没有考虑到long long,所以没有得到这一部分的pp,但是其他部分仍然得到了pp
std::cin >> A >> m >> n; //cin 的使用 0.3pp
// (1) Oni拿走了多少苹果
int oniApples = A - m - n; // A = m - n 0.67pp
// (2) Yuzu和Tuffy所拿的苹果数之和和Yuzu与Oni所拿苹果之和,哪个更多
int lucasAndTuffySum = m + n; // sum = m + n 0.67pp
bool lucasAndOniMore = lucasAndTuffySum > oniApples; // bool 变量的正确计算 0.67pp,把bool值简化为最简形式0.33pp
std::cout << oniApples << " " << lucasAndOniMore << std::endl; // 正确的输出语法和格式 0.5pp(cpp的输出比python要复杂一些,所以这里也体现了出来)
return 0; //返回0 0.2pp
}

代码总pp数:3.01pp

上面仅仅是从代码正确性和算法设计上(语法上)所给出的pp。
假设我们用下面的公式计算算法调试的奖励pp:
我们可以类比osu!中计算准确度的方式:

accuracy_value = (
math.pow(1.52163, beatmap.difficulty_attribute(mods, "OD")) *
math.pow(better_accuracy_percentage, 24) *
2.83
)

然后把数字调整一下:

accuracy_value - (
math.pow(1.2, 代码能拿到的最大pp) *
math.pow((本次新通过的测试用例个数 / 测试用例总数), 10) * 提交权重
)

第一次提交时,提交权重为1.2
第二次及以后提交时,提交权重为(1 - ((n-1)/l)0.775)(n-1), 其中l为代码能达到的最大pp,而n是提交次数
那么根据上面的公式,上面的代码

#include <iostream>
int main() {
long long A, m, n; // long long关键字 3pp(可以被视为设计算法步骤上的pp)
std::cin >> A >> m >> n; //cin 的使用 0.3pp
// (1) Oni拿走了多少苹果
long long oniApples = A - m - n; // A = m - n 0.67pp
// (2) Yuzu和Tuffy所拿的苹果数之和和Lucas与Oni所拿苹果之和,哪个更多
long long lucasAndTuffySum = m + n; // sum = m + n 0.67pp
bool lucasAndOniMore = lucasAndTuffySum > oniApples; // bool 变量的正确计算 0.67pp,把bool值简化为最简形式0.33pp
std::cout << oniApples << " " << lucasAndOniMore << std::endl; // 正确的输出语法和格式 0.5pp(cpp的输出比python要复杂一些,所以这里也体现了出来)
return 0; //返回0 0.2pp
}

在第一次提交后,计算这个算法的调试奖励pp.(注:我们已经计算出这段代码的总pp为6.01,假设官方题解的pp与之相同)
测试数据共有9组:

10 4 4
20 3 3
30 5 5
40 10 10
0 0 0
60 5 5
3919382747111 291929300 202999911
9213818388444 121391393 1219348449
333912818383331 13928482 10219391

为此ChatGPT还给出了对应的代码:

import math
# 计算算法调试奖励pp的公式
def debug_pp(max_pp, passed_tests, total_tests, submission_weight):
return accuracy_value - (
math.pow(1.2, max_pp) *
math.pow((passed_tests / total_tests), 10) * submission_weight
)
# 测试数据
tests = [
(10, 4, 4),
(20, 3, 3),
(30, 5, 5),
(40, 10, 10),
(0, 0, 0),
(60, 5, 5),
(3919382747111, 291929300, 202999911),
(9213818388444, 121391393, 1219348449),
(333912818383331, 13928482, 10219391)
]
# 第一次提交
max_pp = 6.01 # 假设官方题解的pp与之相同
submission_weight = 1.2
passed_tests = 8 # 第一次提交,没有通过任何测试用例
# 计算调试奖励pp
reward_pp = debug_pp(max_pp, passed_tests, len(tests), submission_weight)
print("第一次提交调试奖励pp: if passed_tests = 8", debug_pp(max_pp, len(tests) - 1, len(tests), submission_weight))
print("第一次提交调试奖励pp: if passed_tests = 9", debug_pp(max_pp, len(tests), len(tests), submission_weight))

在此基础上扩展了一下,并调整了一下参数

import math
# 计算算法调试奖励pp的公式
def debug_pp(max_pp, passed_tests, total_tests,submission_weight=1.2):
return (
math.pow(1.1, (5 + max_pp)/1.1) *
math.pow((passed_tests / total_tests), 2)
)
# 测试数据
tests = [
(10, 4, 4),
(20, 3, 3),
(30, 5, 5),
(40, 10, 10),
(0, 0, 0),
(60, 5, 5),
(3919382747111, 291929300, 202999911),
(9213818388444, 121391393, 1219348449),
(333912818383331, 13928482, 10219391)
]
# 第一次提交
max_pp = 6.01 # 假设官方题解的pp与之相同
submission_weight = 1.2
passed_tests = 8 # 第一次提交,没有通过任何测试用例
# 计算调试奖励pp
reward_pp = debug_pp(max_pp, passed_tests, len(tests), submission_weight)
print("第一次提交调试奖励pp: if passed_tests = 6", debug_pp(max_pp, len(tests) - 3, len(tests), submission_weight))
print("第一次提交调试奖励pp: if passed_tests = 8", debug_pp(max_pp, len(tests) - 1, len(tests), submission_weight))
print("第一次提交调试奖励pp: if passed_tests = 9", debug_pp(max_pp, len(tests), len(tests), submission_weight))
# 如果是较长或者技巧性较高的代码,一般第一次调试会奖励更多的pp
print("第一次提交调试奖励pp: if acc = 30 / 40 and max_pp = 50", debug_pp(50, 30, 40, submission_weight))
print("第一次提交调试奖励pp: if acc = 35 / 40 and max_pp = 50", debug_pp(50, 35, 40, submission_weight))
print("第一次提交调试奖励pp: if acc = 39 / 40 and max_pp = 50", debug_pp(50, 39, 40, submission_weight))
print("第一次提交调试奖励pp: if acc = 40 / 40 and max_pp = 50", debug_pp(50, 40, 40, submission_weight))
# 一般来说,由于测试用例有限,我们会把基础pp计算出来,然后再乘以对应的系数:
# 假设测试用例共有50个,处理一个总代码最大pp评分为45pp的算法
pp_list = []
for i in range(51):
pp_list.append(debug_pp(45,i,50))
print(pp_list)
# 计算所有测试用例通过的pp
full_pass_pp = debug_pp(45, 50, 50)
# 第一次提交通过了48个测试用例
first_pass_pp = debug_pp(45, 48, 50) * 1.2
# 第二次提交通过了所有测试用例
second_pass_pp = debug_pp(45, 50, 50)
reward_second_pass = (debug_pp(45, 50, 50) - debug_pp(45, 48, 50)) * (1 - ((2-1)/(45)) ** 0.775) ** (2-1)
reward_this_example = first_pass_pp + reward_second_pass
# 假设第一次就提交了所有用例
all_pass_pp = debug_pp(45, 50, 50)
reward_all_pass = debug_pp(45, 50, 50) * 1.2
# 打印结果
print("第一次提交通过了48个测试用例奖励pp:", first_pass_pp)
print("第二次提交通过了所有测试用例奖励pp:", reward_second_pass)
print("这种情况下的总pp:",reward_this_example)
print("第一次就提交了所有用例奖励pp:", reward_all_pass)
'''
输出为:第一次提交通过了48个测试用例奖励pp: 80.61104371702946
第二次提交通过了所有测试用例奖励pp: 5.415561586671679
这种情况下的总pp: 86.02660530370113
第一次就提交了所有用例奖励pp: 87.46858042212398
可以发现,它很好地区分了这两种情况
'''
#如果只有10个测试用例,代码的技巧和长度共有60pp,那么输出有什么不同呢?(假设算法为60pp)
pp_list = []
for i in range(11):
pp_list.append(debug_pp(60,i,10))
print(pp_list)
# 第一次提交通过了48个测试用例
first_pass_pp = debug_pp(60, 8, 10) * 1.2
# 第二次提交通过了所有测试用例
second_pass_pp = debug_pp(60, 10, 10)
reward_second_pass = (debug_pp(60, 10, 10) - debug_pp(60, 8, 10)) * (1 - ((2-1)/(60)) ** 0.775) ** (2-1)
reward_this_example = first_pass_pp + reward_second_pass
# 假设第一次就提交了所有用例
all_pass_pp = debug_pp(60, 10, 10)
reward_all_pass = debug_pp(60, 10, 10) * 1.2
print("第一次提交通过了8个测试用例奖励pp:", first_pass_pp)
print("第二次提交通过了所有测试用例奖励pp:", reward_second_pass)
print("这种情况下的总pp:",reward_this_example)
print("第一次就提交了所有用例奖励pp:", reward_all_pass)
# 但是这似乎有点太高了...不过真的有什么算法能到60pp吗?
# 面向结果的编程暂时不考虑在内

对第一次提交给予1.2倍权重是为了鼓励编程的人能够充分考虑边缘条件,就和osu!中只要有miss就把pp权重从1立即降到0.97一样
不过这里由于可以多次提交,所以条件可能更宽松一些(?)
不同于osu!的miss处理机制,如果是第四次以后,权重不会下降到0,而是会有一个下限的惩罚权重,这个值是(1 - ((4-1)/(max_pp)) ** 0.775) ** (4-1).
对一个45pp的代码,这个值大概是原来pp的0.675倍。
对一个20pp的代码,这个值大概是原来pp的0.457倍。
对一个60pp的代码,这个值大概是原来pp的0.734倍。
但为了能进行区分,对其添加一个扰动,从第4次开始,每次提交都对新增的调试pp额外乘一个因子0.99

如果编译错误,则通过测试用例数按照0计算,如果一个例子因为TLE、时间/空间复杂度超出限制等原因,也算没有通过

根据上述代码,我们可以知道:(假设最大pp为6.01pp, 9个测试用例)
首先提交了这个3.01pp的代码

#include <iostream>
int main() {
int A, m, n; //这里没有考虑到long long,所以没有得到这一部分的pp,但是其他部分仍然得到了pp
std::cin >> A >> m >> n; //cin 的使用 0.3pp
// (1) Oni拿走了多少苹果
int oniApples = A - m - n; // A = m - n 0.67pp
// (2) Lucas和Tuffy所拿的苹果数之和和Lucas与Oni所拿苹果之和,哪个更多
int lucasAndTuffySum = m + n; // sum = m + n 0.67pp
bool lucasAndOniMore = lucasAndTuffySum > oniApples; // bool 变量的正确计算 0.67pp,把bool值简化为最简形式0.33pp
std::cout << oniApples << " " << lucasAndOniMore << std::endl; // 正确的输出语法和格式 0.5pp(cpp的输出比python要复杂一些,所以这里也体现了出来)
return 0; //返回0 0.2pp
}

然后有三个long long测试用例没过,本次提交的
代码:3.01pp
调试:1.15pp

然后你意识到需要给A,m,n添加long long限制,中间变量也是,于是代码算法设计的pp从3.01变成了最大的可能pp,即6.01.
根据上面的程序测试,第二次提交通过了所有用例,那么计算
增加的调试pp = 2.59 - 1.15 = 1.44pp
代码:6.01pp
调试:1.15pp + 1.44pp * (1 - (1/6.01)^0.775) ^ 1
后面的值大概等于0.75,那么这时的总调试pp为1.15pp + 1.08pp = 2.23pp;

而如果你一次就想到了long long int,那么你本题的总pp将会是这样的:
代码:6.01pp
调试:2.59pp

比上面多了0.36pp~
如果在一些比较复杂的代码,这个pp差距会很大的!

我们已经大致确定了“调试”部分pp的测试方式,但是“代码”部分如何确定pp还有待商榷
因为“代码”部分包含算法设计(创新性、技巧性等)和算法实现(正确性、简洁性)这两方面,而它们都需要设置独立的指标。
在本题中,如何对算法设计和算法实现部分进行拆分呢?我们以最大可能pp的代码进行分析:

#include <iostream>
int main() {
long long A, m, n; // long long关键字 0.1pp(实现) + 2.9pp(设计)
std::cin >> A >> m >> n; //cin 的使用 0.3pp(实现) + 0pp(设计)
// (1) Oni拿走了多少苹果
long long oniApples = A - m - n; // A = m - n 0.37pp(实现,考虑到写在一行代码上,而不是分两行,所以这里实际上比两行代码要多一些) + 0.8pp(设计,因为运算分两步,考虑到都是减法,性质相同,所以pp比1略少)
// (2) Lucas和Tuffy所拿的苹果数之和和Lucas与Oni所拿苹果之和,哪个更多
long long lucasAndTuffySum = m + n; // sum = m + n 0.17pp(实现) + 0.5pp(设计)
bool lucasAndOniMore = lucasAndTuffySum > oniApples; // 0.4pp(实现——因为考虑了题目的化简) + 0.35pp(设计)
std::cout << oniApples << " " << lucasAndOniMore << std::endl; // 输出语法和格式 0.3pp(实现) + 0pp(设计)
return 0; //返回0 0.2pp(实现) + 0pp(设计)
}

因此我们的拆分如下:
属于“实现”的pp:0.1 + 0.3 + 0.37 + 0.17 + 0.4 + 0.3 + 0.2 = 1.84pp
属于“设计“的pp:2.9 + 0 + 0.8 + 0.5 + 0.35 + 0 + 0 = 4.55pp
代码总pp:6.39pp

那么基于这个基本的总pp计算最大的可能调试pp:
第一次提交调试奖励pp: if passed_tests = 6 1.1923943838410327
第一次提交调试奖励pp: if passed_tests = 8 2.119812237939614
第一次提交调试奖励pp: if passed_tests = 9 2.682887363642324

也就是说代码能获得的基础总pp为6.39 + 2.68 = 9.07pp
考虑题干的影响,我们刚刚计算出来的比例因子是1.0336,把最大的总pp * 1.0336,就得到了本题的最终pp,
9.07 * 1.0336 = 9.375pp

在后续的部分中,我们将尝试用代码来实现上面的这个框架(未完待续~)

本文作者:Yuzu_OvO(喵露露版)

本文链接:https://www.cnblogs.com/yuzusbase/p/17995667

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   Yuzu_OvO(喵露露版)  阅读(93)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起