zh: 一. 简单介绍
序列分区是计算机编程中一种基本且常用的算法。给定一个数字序列“A”,以及一个被称为枢轴值的‘p’,分区算法的目的在于在“A”中重新排列数字,使得所有小于‘p’的数字排在前面,其余的则在后面。
以下是一个以“p=20”为枢值划分前后的序列示例。
经过算法后,所有小于20的值(如浅绿色所示)
出现在其余值(如黄色所示)之前。
分区的应用有很多,但最受欢迎的有:
- 快速排序算法 — 通过递归多次调用对给定数组的不同子数组进行操作,直到排序完成。
- 找给定序列中的中位数 — 利用分区高效缩小搜索范围,最终在预期的平均线性时间内找到中位数。
对序列进行排序是让大量数据更快导航的重要步骤。在两种常见的查找算法——线性查找和二分查找——中,后者仅在数组中的数据已经排序之后才能使用。在给定的未排序数据中找到中位数或第k个顺序统计量对于理解给定未排序数据中值的分布非常重要。
目前存在不同的分区算法(也称为分区方案,比如“Lomuto方案”和“Hoare方案”)。Lomuto方案通常更易于理解,而Hoare方案在一个给定的数组中较少进行元素移动,因此它在实践中更受欢迎。
在这里的故事中,我将提出一种称为“循环分区”的新分区方案,它类似于霍尔(Hoare)分区方案,但在数组内部执行的值重新分配次数减少了1.5倍的数量。因此,如后面将要展示的,值重新分配的数量几乎等同于初始“不在其位置”的值的数量,这些值需要被移动到正确的位置。这一事实使我认为这种新的分区方案几乎是最优的。
接下来的章节是这样安排的:
- 在第2章中,我们将回顾就地划分(这种划分使问题变得更加复杂)。
- 在第3章中,我们将回顾广泛使用的霍尔(Hoare)划分方案。
- 在第4章中,我将介绍“赋值循环”,并将看到某些序列的重新排列可能比其他序列的重新排列需要更多的值分配。
- 第5章将利用“赋值循环”的一些性质,并推导出新的“循环划分”方案,作为霍尔方案的一种优化变体。
- 最后,在第6章中,我们将对小数据类型和大数据类型数组中的霍尔方案和循环划分进行实验比较。
在GitHub上,有一个用C++语言实现的循环分区及其与当前标准霍尔方案的性能对比,并在本文末尾有引用[1]。
2. 回顾就地序列分区将一个序列分割不会是一个困难的任务,如果输入和输出序列分别存储在计算机内存中的两个不同数组中。如果是这样的话,那么可以考虑的方法之一是:
- 计算“A”中有多少值小于‘p’(这将给出左半部分输出序列的最终长度),
- 从左到右扫描输入数组“A”,并根据当前值是否小于‘p’,将其追加到输出序列的左半或右半。
这里列举了这种算法运行的几种情形。
在第一阶段我们发现只有7个值小于“p=20”(那些浅绿色的),于是我们准备从第7个索引位置开始写入更大的值到输出序列。
在第二阶段,读取输入序列的5个值之后,我们将其中的3个值附加到输出序列的左边,剩下的2个值附加到输出序列的右边。
接着进行第二阶段,截至目前我们已经扫描了输入序列中的9个值,其中5个放在输出序列的左边,其余4个放到输出序列的右边。
算法已经完成。输出序列的两部分现在已经正确填充完毕。需要注意的是,左右两部分的值的相对顺序保持原样,与它们最初在输入数组中的顺序一致。
还有些更短的解决方案,比如代码中只有一个循环的。
现在,难点在于我们想在不使用额外内存的情况下,输入序列将仅通过在数组中移动值来转化为分区输出序列。顺便提一句,这种不使用额外内存的算法被称为原地算法。
对同一输入序列 'A' 就地分区,使用相同的枢轴值 'p=20'。
所显示的值的顺序对应于序列的初始状态,每个值旁边的箭头指示该值应移动的位置,以使整个序列完成分区。
在介绍我的分区方案之前,先来回顾一下常用的就地分区方法,怎么样?
3. 当前使用的分区策略观察了各种编程语言的标准库中排序实现之后,看来最广泛使用的快速排序中的分区算法是霍尔(Hoare)方案。例如:
- C++ 的
<alg还是...呢ithm>
库中的std::partition
函数 - Java 中的
Arrays.sort()
方法 -
Python 的
sorted()
函数或列表的sort()
方法 - C++ STL 中的「std::sort()」实现是,
- Java JDK 中的「Arrays.sort()」实现,用于基本数据类型。
在基于霍尔方案的分区中,我们从序列的两端同时向中间扫描,寻找左边部分中大于或等于‘p’的某个值 A[i],以及右边部分中小于‘p’的某个值 A[j]。一旦找到这些值,我们知道这两个值 A[i] 和 A[j] 都不在正确的位置上(记住,分区后的序列应该首先包含所有小于‘p’的值,然后才是所有大于或等于‘p’的值),因此我们只需交换 A[i] 和 A[j]。交换后,我们继续以同样的方式扫描数组“A”,使用索引 i 和 j,直到两个索引相等。一旦它们相等,分区就结束了。
我们再通过一个例子来看看 Hoare 规则:
输入序列是“A”,长度为‘N’,需要以 pivot 值 ‘p=20’ 来分割。
索引 i 从 0 开始向上遍历,索引 j 则从 ‘N-1’ 开始向下遍历。
在增加 i 的时候,我们遇到了一个值,A[2]=31,它比‘p’大。接着,当我们减小 j 的时候,我们又找到了一个值,A[10]=16,它比‘p’小。这两个值将会被我们交换。
交换了“A[2]”和“A[10]”之后,我们继续让i从2递增,并让j从10递减。索引i会在遇到大于‘p’的“A[4]=28”时停止,而索引j则会在遇到小于‘p’的“A[9]=5”时停止。最后,这两个值也将被交换。
算法继续以相同的方式进行,例如,数值“A[5]=48”和“A[7]=3”也将进行交换。
之后,索引 ‘i’ 和 ‘j’ 将会彼此相等。分区完成,此时索引 ‘i’ 和 ‘j’ 将会相等。
若在快排中用霍尔划分法编写分区的伪代码,则可以写出如下:
// 使用霍尔方案将具有枢轴值 'p' 的序列 A[0..N) 分区,并返回结果右部分的第一个值的索引。
函数 partition_hoare(A[0..N) : 整数数组, p: 整数) 返回整数
i := 0
j := N-1
循环
// 将左索引 i 移动到需要的位置
当 i < j 并且 A[i] < p
i := i+1
// 将右索引 j 移动到需要的位置
当 i < j 并且 A[j] >= p
j := j-1
// 检查是否完成
如果 i >= j
如果 i == j 并且 A[i] < p
返回 i + 1 // "A[i]" 也指左部分
否则
返回 i // "A[i]" 指右部分
// 交换 A[i] 和 A[j]
tmp := A[i]
A[i] := A[j]
A[j] := tmp
// 将 i 和 j 各自前进一步
i := i+1
j := j-1
在第5行和第6行,我们为两个扫描设置了起始索引。
第8到第10行从左向右寻找在分区时应该属于右部分的值。
同样地,第11到第13行从右向左寻找在分区时应该属于左部分的值。
第15到第19行检查扫描是否完成。当索引'i'和'j'相遇时,有两种可能:要么“A[i]”应该位于左部分,要么应该位于右部分。根据情况,该函数应该返回'i'或'i+1',因为函数的返回值应该是右部分的起始索引。
接下来,如果没有完成扫描,第20到第23行交换两个不在正确位置的值。
最后,第24到第26行推进这两个索引,以避免再次检查已经交换的值。
该算法的时间复杂度为O(N),无论这两次遍历在何处结束,它们一起扫描N个值。
这里有一个重要的说明,如果数组“A”中有‘L’个不在正确位置的值,并且需要被交换,那么根据霍尔索姆方案(Hoare scheme),需要进行“3*L/2”次赋值,因为每次交换两个值需要3次赋值。
交换两个变量‘a’和‘b’的值需要进行三次赋值操作,使用‘tmp’变量来帮助完成。
这些任务是。
tmp = a
a = b
b = tmp
在这里我也强调一下,“L”始终是偶数。这是因为对于每一个原来位于左区域且符合“A[i]>=p”的值,都存在另一个原来位于右区域且符合“A[j]<p”的值,它们相互交换。因此,每当进行交换时,总是两个值一起移动,这就保证了“L”始终是偶数。在Hoare方案中,所有的重新排序都是通过交换实现的。
4. 赋值循环本章可能看起来偏离了故事的主线,但实际上并非偏离主题,因为在下一章中优化霍尔(Hoare)分区方案时,将会需要用到关于任务分配循环的知识。
假设我们要以某种方式重新排列给定的序列“A”中的值的顺序。这不一定是指分区,而是任何形式的重新排序。让我来说明一些重新排列需要的交换比其他的多。
示例 #1:将序列向左循环移动:如果我们想将序列_A_循环左移1个位置,需要做几次移位操作?
循环左移示例中长度为N=12的序列“A”的循环左移。可以看到,所需的赋值次数为N+1=13,具体操作为:
1)将‘A[0]’存入临时变量‘tmp’,然后
2)‘N-1’次将右边相邻的值赋给当前值,最后
3)将‘tmp’赋给序列‘A[N-1]’。
需要的操作如下:
tmp := A[0] // 临时变量 tmp 存储 A[0] 的值
A[0] := A[1]
A[1] := A[2]
... // 以此类推
A[9] := A[10]
A[10] := A[11]
A[11] := tmp // 将 tmp 的值赋给 A[11]
… 结果是 13 项任务。
情况2: 向左循环左移 3 个位置在接下来的例子中,我们仍然想要对相同的序列进行向左循环移位,不过这次要移位3个位置。
以下是长度为N=12的序列“A”向左循环移位3位的示例。
我们看到A[0]、A[3]、A[6]和A[9]通过蓝色箭头交换,
A[1]、A[4]、A[7]和A[10]通过粉色箭头交换,
以及通过黄色箭头交换的A[2]、A[5]、A[8]和A[11]。
变量“tmp”被赋值并读取了三次。
在这里我们有三条独立的赋值链/循环,每条都是4个步骤。
为了正确地在A[0],A[3],A[6]和A[9]之间交换数值,需要执行的操作包括:
tmp = A[0]
A[0] = A[3]
A[3] = A[6]
A[6] = A[9]
A[9] = tmp
下面的代码是将数组A中的元素按顺序替换:
tmp = A[0]
A[0] = A[3]
A[3] = A[6]
A[6] = A[9]
A[9] = tmp
如上所述,数组A中的元素已经按照顺序进行了替换。
…这意味着需要5次赋值。同样地,在组内部交换值(A[1], A[4], A[7], A[10])和(A[2], A[5], A[8], A[11])各自也需要5次赋值。将所有这些加起来,得到由12个值组成的序列_A_进行循环左移3位总共需要15次赋值。
案例 #3:倒序序列当我们反转长度为 ’N’ 的序列 “A” 的过程中,具体的操作如下:包括
-
将其第一个值与最后一个值交换之后,
-
将第二个值与倒数第二的值交换,
-
将第三个值与倒数第三的值交换,
- …依此类推。
这是一个反转数组“A”的例子,其中N=12。
我们可以看到,例如(A[0], A[11]),(A[1], A[10]),(A[2], A[9])等值对彼此独立地交换。变量“tmp”被赋值和读取共进行了6次。
每次交换需要3次交换,而为了反转整个序列“A”,我们需要做⌊N/2⌋组交换,因此总共需要的交换次数为:
3乘以N除以2向下取整的结果,即3乘以(12除以2向下取整)等于3乘以6等于18.
要执行“A”的逆操作所需的赋值序列是:
将 tmp 设为 A[0] // 第一轮交换
A[0] := A[11]
A[11] := tmp
将 tmp 设为 A[1] // 第二轮交换
A[1] := A[10]
A[10] := tmp
以此类推
将 tmp 设为 A[5] // 第六轮交换
A[5] := A[6]
将 A[6] 设为 tmp
摘要如下:
我们已经看到,同一序列“A”中的值重新排列时,所需的次数会根据具体重新排列情况有所不同。
但所需的分配数目不同,在以下3个例子中,序列长度始终为 N =12。
更精确地说,赋值的数量等于 N + C,其中“ C ”是在重新排列过程中产生的循环数。这里所说的“循环”是指“ A ”中的这样一个变量子集,这些变量的值在彼此之间循环轮换。
在我们的案例1(左移一位)中,我们只有 C=1 个赋值周期,并且所有 'A' 变量都参与了那个周期。因此总的赋值次数为:
N+C = 12+1 = 13.
这里,N+C 等于 12+1,即 13。
当左移3位时,我们有 C=3个赋值轮,具体如下:
— 第一个周期分别是这些变量(A[0], A[3], A[6], A[9]),
— 第二个周期分别是这些变量(A[1], A[4], A[7], A[10]),
— 第三个周期分别是这些变量(A[2], A[5], A[8], A[11])。
所以总共的任务数量是这样的:
N+C 等于 12+3,也就是 15.
在我们的案例3(逆转)中,我们有 ⌊ N /2⌋ = 12/2 = 6 个周期。这些周期都是最短的周期,并应用于例如(A[0], A[11])、(A[1], A[10]),等等。因此,总的分配次数为如下:
那就是N加C等于12加6等于18。
当然,在给出的例子中,赋值次数的绝对差异非常小,这在编写高性能代码时几乎不会有任何影响。但这仅仅是因为我们考虑的是一个长度为 N=12 的非常短的数组。对于更长的数组,这些赋值次数的差异将随着 N 的增加而成比例增加。
在这一章结束时,让我们记住,重新排列序列所需的分配次数会随着由这样的重新排列引入的循环数量的增加而增加。如果我们希望更快地重新排列,我们应该尝试采用具有最小可能分配循环数量的方案。
5. 优化Hoare划分方案现在让我们再来看看Hoare划分法,这次我们要特别注意它引入了多少次赋值操作。
假设我们有一个长度为 N 的数组“ A ”,以及一个用于划分的枢轴值 p 。另外,假设数组中有 L 个值需要重新排列,以便将数组“ A ”划分为不同的部分。结果表明,Hoare分区方案以最慢的方式重新排列了那些 L 个值,因为它引入了最多的赋值循环,每个循环只包含两个值。
给定基准值“p=20”,需要重新排列的是那些箭头指向的(或从箭头离开的)“L=8”值。
Hoare划分方案中引入了“L除以2等于4”个循环赋值,每个循环只涉及两个值。
在长度为2的周期内移动两个值,实际上是交换它们,需要3次赋值。因此,在Hoare划分方案中,总赋值次数为3*L/2。
我接下来要描述的优化思想源于这样一个事实:将一个序列划分之后,我们通常并不关心那些“A[i]<p”值的相对位置,这些值位于划分后序列的左半部分,同样也不关心那些位于右半部分的值的相对位置。我们真正关心的是,所有小于‘p’的值都应该排在其他值前面。基于这一事实,我们可以调整Hoare方案中的赋值循环,从而只需一个循环来处理所有需要重新排列的‘L’值。
首先让我通过以下示意图来描述这个改动后的分区方案:
分区方案更改后,被应用到了相同的序列‘A’上。由于枢轴“p=20”未改变,需要重新排列的‘L=8’值也相同。
所有的箭头代表新方案中的唯一一个分配循环。
移动所有‘L’值后,我们将得到一个重新分区的序列。
那我们在这里干嘛呢?
- 与 Hoare 方案中的原始方法一样,首先我们从左扫描,找到满足“ A[i]>=p ”的值,该值应该移动到右侧部分。但是,我们不是将它与另一个值交换,而是记住它:“ tmp := A[i]”。
- 接下来,我们从右扫描,找到满足“ A[j]<p ”的值,该值应该移动到左侧部分。我们只需将“ A[i] := A[j]”,不会丢失“ A[i]”的值,因为其已经被存储在“ tmp ”中。
- 接着我们继续从左扫描,找到满足“ A[i]>=p ”的值,它也应该移动到右侧部分。所以我们做赋值“ A[j] := A[i]”,不会丢失“ A[j]”的值,因为已经被赋值到“ i ”的先前位置的“ A[i]”。
- 这种模式继续,当索引 i 和 j 相遇时,只剩下将某个大于‘ p ’的值放到“ A[j]”中,我们只需做“ A[j] := tmp ”,因为最初“ tmp ”保存的是从左开始找到的第一个大于‘ p ’的值。分区完成。
正如我们所见,这里只有一个赋值循环,它遍历所有“L”的值,并且为了正确地排列这些值,只需要“L +1”次赋值,相比之下,Hoare方案则需要“3*L /2”次赋值。
我更倾向于将这种新的分区方案称为“循环分区方案”,因为所有需要重新排列的L值现在都位于同一个分配循环内。
这是一个循环划分算法的伪代码。与霍尔方案的伪代码相比,变化微乎其微,但现在的赋值操作只有原来的1.5倍。
// 用“循环分组”方案对序列A[0..N)按枢轴值'p'进行分区,并返回右侧部分的第一个值的索引。
function partition_cyclic( A[0..N) : 整数数组, p: 整数 ) : 整数
i := 0
j := N-1
// 从左找到第一个不在正确位置上的值
while i < N and A[i] < p
i := i+1
if i == N
return N // 所有N个值都被移到了左侧
// 从这里开始循环赋值
tmp := A[i] // 对'tmp'变量的唯一赋值
while true
// 将右索引'j'移动到合适的位置
while i < j and A[j] >= p
j := j-1
if i == j // 检查是否完成扫描
break
// 下一次赋值
A[i] := A[j]
i := i+1
// 将左索引'i'移动到合适的位置
while i < j and A[i] < p
i := i+1
if i == j // 检查是否完成扫描
break
// 下一次赋值
A[j] := A[i]
j := j-1
// 扫描已经完成
A[j] := tmp // 对'tmp'变量的唯一赋值
return j
第5行和第6行分别给两个扫描设置起始索引(‘i’从左到右,‘j’从右到左)。
第7行至第9行从左开始查找应该属于右边部分的值“A[i]”。如果找不到这样的值,且所有N个项目都属于左边部分,没有适合右边的部分,那么第10行和第11行会报告这种情况,并结束算法。
否则,如果找到了这样的值,第13行将其存储在临时变量‘tmp’中,从而在索引‘i’处腾出一个空位来放置另一个值。
第15行至第19行从右开始查找应该移动到左边部分的值“A[j]”。找到后,第20行至第22行将其放置在索引‘i’处的空位中,这样索引‘j’处的位置变为空,等待另一个值。
同样地,第23行至第27行从左开始查找应该移动到右边部分的值“A[i]”。找到后,第28行至第30行将其放置在索引‘j’处的空位中,这样索引‘i’处的位置再次变为空,等待另一个值。
这种模式在算法的主要循环(从第14行到第30行)中继续进行。
当索引‘i’和‘j’相遇时,它们之间有一个空位,第31行和第32行将先前存储在临时变量‘tmp’中的值放置在那里,这样索引‘j’就成为了第一个包含属于右边部分的值的位置。
最后一行返回该索引。
这样我们就可以在循环体内一起完成两个任务,正如第3章中所证明的,‘L’总是偶数。
该算法的时间复杂度仍为O(N),因为我们依然需要从序列的两端进行扫描。值赋值操作减少了约1.5倍,因此加速仅体现在常数因子上。
在GitHub上有一个版本用C++语言实现了循环分区,并在本文末尾有引用[1]。
我也想表明,在 Hoare 方案中出现的值‘ L ’不能被减少,无论我们使用什么样的分区方案。假设分区后,左边部分的长度为“ _leftn ”,右侧部分的长度为“ _rightn ”。现在,如果我们查看原始未分区数组中左对齐的“ _leftn ”长度区域,我们会发现其中存在一些‘ t1 ’值,这些值还没有到达它们的最终位置。因此,这些值都是大于或等于‘ p ’的,所以无论如何都应该将它们移动到右边部分。
这是分区前后序列的图示。
左边部分的长度是7(left_n=7),右边部分的长度是5(right_n=5)。
在未分区的序列中,前7个值中有“t1=3”个值大于20(p=20),这些值被标为黄色,需要被移动到右边部分。
在未分区的序列中,后5个值中有“t2=3”个值小于20(p),这些值被标为浅绿色,需要被移到左边部分。
同样地,当我们查看原始未划分数组中右对齐的“_rightn”长度区域时,我们会发现一些‘t2’值,这些值也不在它们的最终位置。这些值都是小于‘p’的,应该被移到左半部分。我们不能把小于‘t1’的值从左移向右,同样也不能把小于‘t2’的值从右移向左。
在霍尔分割方案中,_t1_
和 _t2_
是彼此交换的值。所以,
注意:代码片段 _t1_
和 _t2_
保持不变。(此句可选)
设 t1 和 t2 都是 L/2
or
t1 + t2 = L.
这意味着“ L ”实际上是需要重新排列的最小值,以便序列可以被划分成几部分。而循环划分算法在重新排列时仅需进行“ L +1”次赋值。因此,我称这种新的划分方法为“近似最优”是有道理的。
6. 实验的成果已经证明新的分区方案分配的值更少,因此我们可以预期它运行得更快。不过,在发布算法前,我还想通过实验多收集一些数据。
我比较了使用霍尔分割方案和循环分割对随机乱序数组进行划分时的运行时间。
实验中不同的参数包括:
- N — 数组的长度,
- “left_part_percent” — 分区后左部分长度占数组长度 N 的百分比,
- 在由 32 位整数组成的数组上运行,与在由 16 位整数的 256 长静态数组组成的数组上运行的区别。
我想解释为什么我认为有必要对原始数据类型数组和较大对象数组同时进行分区操作。在这里,我所说的“较大对象”是指那些占用的内存远多于原始数据类型值的对象。在分区原始数据类型时,将一个变量赋值给另一个变量的速度几乎和用于这两种算法的其他指令(如增加索引或检查循环条件)一样快。而在分区较大对象时,将一个对象赋值给另一个对象所需的时间远长于其他指令,这时我们需要尽可能减少值的分配次数。
稍后我会在本章中解释为什么我用不同的“左部分百分比”值来运行不同的实验。
以下是在所使用的系统中利用Google Benchmark进行的实验:
分割数组,这些数组包含基本数据类型CPU: Intel 酷睿 i7–11800H @ 2.30GHz
内存: 16 GB
系统: Windows 11 家庭版 64 位
编译器: MSVC 2022 ( /O2 /Ob2 /MD /GR /Gd )
这是针对32位整数数组运行分区算法的结果:
长度为N=10'000的整数数组上的划分算法运行时间。
蓝色条形代表Hoare方案的划分操作,
而红色条形则代表循环划分算法。
划分算法针对“left_part_percent”——即数组在划分后左部分相对于N的百分比,共运行了五种不同的情况。时间单位以纳秒计。
我们看到“left_part_percent”值与这两种算法的运行时间的相对差异性之间没有明显的关联。这种现象是符合预期的。
划分“大数据对象”的数组这是对所谓的“大对象”数组运行的两个分区算法的结果——每个大对象都是一个包含256个16位随机整数的静态数组。
_大型对象数组的分区算法运行时间(包含256个静态16位随机整数的数组),长度为N=10'000。
蓝色条形图表示Hoare方案的分区,红色条形图表示循环分区算法。
分区算法基于五种不同的“left_partpercent”(数组N的左部分的百分比,在分区后出现)运行。时间以纳秒为单位显示。
我们现在很明显地看到一个明显的关联:循环分区的表现优于霍尔分割,尤其是在“left_part_percent”更接近50%时。换句话说,当数组分区后的左右部分长度更均衡时,循环分区相对运行得更快。这也是预期的行为。
结果说明:为什么当“left_part_percent”接近50%时,分区通常会花费更多时间?
让我们假设一个极端情况——假设在分区后,几乎所有值都出现在左边(或右边)部分。这意味着数组中的绝大多数值都小于(或大于)枢轴值。在扫描时,所有这些值都被认为已经定位到了最终位置,实际上很少进行数值的重新分配。或者想象另一种情况——在分区后,左部分和右部分几乎一样长,这意味着会进行大量的数值重新定位(因为最初这些数值是随机分布的)。
_— 在查看大对象分区时,为什么当left_partpercent接近50%时,这两种算法的运行时间差会变大?
之前的解释表明,当“左部分百分比”接近50%时,需要在数组中进行更多的值赋值操作。在之前的章节中,我们也展示了循环分区总是比霍尔方案少1.5倍的赋值操作。因此,当我们通常需要在数组中重新排列更多的值时,这种1.5倍的差异对整体执行时间的影响会更大。
为什么划分大对象时需要更多时间,而划分32位整数时则不需要?这里的“绝对时间”指绝对时间(以纳秒为单位)。
这很简单——因为将一个“大型对象”分配给另一个需要更多的时间,而将一种原始数据类型赋值给另一种则所需时间较少。
我也对不同长度的数组做了所有实验,但整体情况没有变化。
7 结论在这篇文章里,我介绍了一种改过的分割方案,称为“循环分割方案”。它总是比目前使用的霍尔分割方案少分配1.5倍的值。
当然,在划分序列时,赋值操作并非唯一的一种操作。除此之外,分区算法还会检查输入序列“A”中的值是否小于或大于枢轴值‘p’,这样的表达更加自然。同时还会对“A”的索引进行递增和递减。引入“循环分区”后,比较次数、递增和递减次数并不会受到影响,因此我们不能仅仅指望它能运行快1.5倍。然而,在对复杂数据类型的数组进行分区时,如果赋值操作的时间消耗远大于简单地递增或递减索引,整个算法实际上可以快1.5倍。
分区过程是快速排序算法以及寻找未排序数组中位数或其第 k 个顺序统计量算法的主要函数,因此在处理复杂数据类型的情况下,我们也可以期望这些算法的性能提升最多可达1.5倍。
参考文献:特别感谢:
— Roza Galstyan,审阅了故事草稿并提供了宝贵的修改意见,
— David Ayrapetyan,帮助检查了拼写错误(https://www.linkedin.com/in/davidayrapetyan/),
— Asya Papyan,用心设计了所有使用的插图(https://www.behance.net/asyapapyan)。
如果你喜欢这个故事,欢迎到LinkedIn上找我并加我为好友(https://www.linkedin.com/in/tigran-hayrapetyan-cs/)。
除非另有说明,所有图片均是根据作者的要求设计的。
[1] — 在 C++ 中实现循环分区功能:https://github.com/tigranh/cyclic_partition.
共同学习,写下你的评论
评论加载中...
作者其他优质文章