目录
[示例过程:对 parr = [5,3,1,2,4](升序)排序](#示例过程:对 parr = [5,3,1,2,4](升序)排序)
[示例过程:对parr = [2,4,6,7,5,3,1,9,0,8]排序](#示例过程:对parr = [2,4,6,7,5,3,1,9,0,8]排序)
[示例过程:对parr = [5,3,1,2,4](升序)排序](#示例过程:对parr = [5,3,1,2,4](升序)排序)
[示例过程:对parr = [5,3,1,2,4](升序)排序](#示例过程:对parr = [5,3,1,2,4](升序)排序)
[示例过程:对parr = [5,3,1,2,4](升序)排序](#示例过程:对parr = [5,3,1,2,4](升序)排序)
[快速排序(Hoare 分区法)的逻辑与原理(结合代码)](#快速排序(Hoare 分区法)的逻辑与原理(结合代码))
[示例过程:对parr = [5,3,1,2,4](升序)排序](#示例过程:对parr = [5,3,1,2,4](升序)排序)
[总结(Hoare 版本)](#总结(Hoare 版本))
三数取中(规避快速排序算法最坏的情况)[示例过程:对parr = [5,3,1,2,4](升序)非递归快速排序](#示例过程:对parr = [5,3,1,2,4](升序)非递归快速排序)
[示例过程:对parr = [2,3,3,5,3,1](升序)三路划分快速排序](#示例过程:对parr = [2,3,3,5,3,1](升序)三路划分快速排序)
[快速排序(三路划分)与 快速排序(hoare版本)时间、空间复杂度比较](#快速排序(三路划分)与 快速排序(hoare版本)时间、空间复杂度比较)
[示例过程:对parr = [5,3,1,2,4](升序)归并排序](#示例过程:对parr = [5,3,1,2,4](升序)归并排序)
[示例过程:对parr = [5,3,1,2,4](升序)计数排序](#示例过程:对parr = [5,3,1,2,4](升序)计数排序)
冒泡排序(默认升序)
冒泡排序代码实现
void Swap(int* p1, int* p2)
{
int tmp = *p1; // 用临时变量保存p1指向的值
*p1 = *p2; // 将p2指向的值赋给p1指向的位置
*p2 = tmp; // 将临时变量保存的值(原p1的值)赋给p2指向的位置
}
void BubbleSort(int* parr, int size)
{
// 外层循环:控制排序的轮数,每轮会确定一个最大元素的最终位置
// i表示已排好序的元素个数(初始为0,最多需要size轮,实际可能提前结束)
for (int i = 0; i < size; i++)
{
bool flag = false; // 标记本轮是否发生过交换,初始为false(未交换)
// 内层循环:遍历未排序部分,比较相邻元素并交换
// j的范围是0到size-1-i-1,因为每轮会将最大元素移到末尾,已排序部分无需再比较
for (int j = 0; j < size - 1 - i; j++)
{
// 若当前元素大于后一个元素,说明顺序错误,交换两者
if (parr[j] > parr[j + 1])
{
Swap(&parr[j], &parr[j + 1]); // 调用交换函数交换相邻元素
flag = true; // 标记本轮发生了交换
}
}
// 若本轮未发生任何交换,说明数组已经有序,提前退出循环(优化操作)
if (flag == false)
break;
}
}
冒泡排序算法思想
冒泡排序的逻辑与原理(结合代码)
冒泡排序的核心思想是:重复遍历数组,每次比较相邻的两个元素,若顺序错误(前大后小)则交换它们,直到没有元素需要交换(数组有序)。因其每轮会将 "未排序部分的最大元素" 像 "气泡" 一样 "浮" 到末尾,故得名 "冒泡排序"。
代码通过以下逻辑实现:
- 外层循环(控制轮数) :
i表示 "已排好序的元素个数"(初始为 0),每轮循环会确定一个最大元素的最终位置(数组末尾),最多需要size轮(但可提前结束)。 - 内层循环(遍历未排序部分) :
j遍历 "未排序部分"(范围0到size-1-i-1),因每轮会将最大元素移到末尾,已排序的i个元素无需再比较。 - 交换与优化 :比较相邻元素
parr[j]和parr[j+1],若前者大则交换;用flag标记本轮是否发生交换,若未交换(flag=false),说明数组已有序,可提前退出循环(减少无效遍历)。
示例过程:对 parr = [5,3,1,2,4](升序)排序
数组长度 size=5,逐步处理如下:
初始状态
数组:[5,3,1,2,4],i=0(已排序元素 0 个),flag=false。
第一轮(i=0):寻找最大元素并移到末尾
- 内层循环
j范围:0到5-1-0-1=3(即j=0,1,2,3),遍历未排序部分[5,3,1,2,4]。j=0:比较5和3(5>3),交换 → 数组变为[3,5,1,2,4],flag=true;j=1:比较5和1(5>1),交换 → 数组变为[3,1,5,2,4],flag=true;j=2:比较5和2(5>2),交换 → 数组变为[3,1,2,5,4],flag=true;j=3:比较5和4(5>4),交换 → 数组变为[3,1,2,4,5],flag=true。
- 本轮结束:最大元素
5已移到末尾(下标 4),flag=true(有交换),继续循环。
第二轮(i=1):寻找次大元素并移到倒数第二位
- 已排序元素 1 个(
5),未排序部分为[3,1,2,4],内层循环j范围:0到5-1-1-1=2(即j=0,1,2)。j=0:比较3和1(3>1),交换 → 数组变为[1,3,2,4,5],flag=true;j=1:比较3和2(3>2),交换 → 数组变为[1,2,3,4,5],flag=true;j=2:比较3和4(3<4),不交换。
- 本轮结束:次大元素
4已移到倒数第二位(下标 3),flag=true(有交换),继续循环。
第三轮(i=2):检查剩余元素是否有序
- 已排序元素 2 个(
4,5),未排序部分为[1,2,3],内层循环j范围:0到5-1-2-1=1(即j=0,1)。j=0:比较1和2(1<2),不交换;j=1:比较2和3(2<3),不交换。
- 本轮结束:
flag=false(无交换),说明数组已完全有序,直接跳出外层循环。
最终结果
数组经过 3 轮(实际有效 2 轮)排序后,变为升序 [1,2,3,4,5]。
核心总结
冒泡排序通过 "相邻元素比较交换" 让最大元素逐步 "浮" 到末尾,每轮确定一个元素的最终位置;flag 变量的优化避免了数组已有序后的无效遍历,提升效率。其过程直观,核心是 "重复比较交换,直到无交换发生"。
冒泡排序算法的时间复杂度和空间复杂度
冒泡排序的时间复杂度和空间复杂度分析如下,其复杂度与数组的初始有序程度密切相关,且代码中的flag优化会影响最好情况的效率:
空间复杂度
冒泡排序是原地排序算法 ,排序过程中仅需要一个临时变量(用于交换相邻元素,如代码中Swap函数的临时存储),无需额外的数组或数据结构。因此:
- 空间复杂度:O(1)(常数级)。
时间复杂度
时间复杂度主要取决于 "比较相邻元素" 和 "交换元素" 的次数,分为以下三种情况:
1. 最好情况:数组本身已完全有序(如升序数组)
- 此时,第一轮遍历(
i=0)中,内层循环比较所有相邻元素,发现均无需交换(flag保持false),直接跳出外层循环,排序结束。 - 总比较次数为
n-1(仅一轮遍历),交换次数为0。 - 时间复杂度:O(n) (得益于
flag的优化,避免了后续无效遍历)。
2. 最坏情况:数组完全逆序(如需要升序,但原数组是降序)
- 此时,每轮都需要交换元素,且需要进行
n-1轮(每轮确定一个最大元素的位置):- 第 1 轮:比较
n-1次,交换n-1次; - 第 2 轮:比较
n-2次,交换n-2次; - ...
- 第
n-1轮:比较1次,交换1次。
- 第 1 轮:比较
- 总比较次数和交换次数均为
1+2+...+(n-1) = n(n-1)/2,约为O(n²)。 - 时间复杂度:O(n²)。
3. 平均情况:数组元素随机排列(无序程度中等)
- 此时,平均需要进行
n/2轮遍历,每轮平均比较n/2次,总操作次数约为O(n²/2)。 - 时间复杂度:O(n²)。
总结
- 空间复杂度:O(1)(原地排序,仅需常数级额外空间);
- 时间复杂度:最好
O(n)(有序数组,flag优化生效),最坏和平均O(n²)。
冒泡排序的优势是逻辑简单、实现容易,且是稳定排序(相等元素的相对顺序不变),但效率较低,适合数据量小或接近有序的数组。
直接插入排序(默认升序)
直接插入排序代码实现
void InsertSort(int* parr, int size)
{
// 从数组的第二个元素开始遍历(i=1),因为第一个元素可视为已排序区间
// i表示当前待插入元素的下标,依次处理后面的元素
for (int i = 1; i < size; i++)
{
// 保存当前待插入元素的值,防止后续移动元素时被覆盖
int tmp = parr[i];
// end指向已排序区间的最后一个元素下标(初始为i-1,即待插入元素的前一个)
int end = i - 1;
// 在已排序区间中找到待插入元素的正确位置
// 循环条件:end >= 0(未越界)
while (end >= 0)
{
// 如果已排序区间的当前元素大于待插入元素,说明该元素需要后移
if (parr[end] > tmp)
{
parr[end + 1] = parr[end]; // 元素后移一位
end--; // 继续向前比较前一个元素
}
else
{
// 找到比待插入元素小或相等的元素,说明已找到插入位置,退出循环
break;
}
}
// 将待插入元素放到正确位置(end+1是退出循环后确定的插入下标)
parr[end + 1] = tmp;
}
}
直接插入排序算法思想
直接插入排序的核心逻辑(结合代码)
-
区间划分:
- 初始时,数组的第一个元素(下标
0)视为 "已排序区间"(只有一个元素,天然有序); - 从下标
1开始的元素属于 "未排序区间",需要逐个处理。
- 初始时,数组的第一个元素(下标
-
遍历未排序区间:
- 用
i表示 "当前待插入元素" 的下标(从1到size-1),每次循环处理parr[i]。
- 用
-
插入到已排序区间:
- 用
tmp保存parr[i]的值(防止后续移动元素时被覆盖); - 用
end指向 "已排序区间的最后一个元素"(初始为i-1),通过while循环在已排序区间中找到tmp的插入位置:- 若
parr[end] > tmp:说明parr[end]需要后移(给tmp腾位置),执行parr[end+1] = parr[end],end减一继续向前比较; - 若
parr[end] <= tmp:说明找到插入位置(end+1),退出循环;
- 若
- 最后将
tmp放到end+1的位置,完成一次插入。
- 用
-
循环结束 :当
i遍历完所有未排序元素,整个数组变为有序。
示例过程:对parr = [2,4,6,7,5,3,1,9,0,8]排序
初始状态
- 数组长度
size=5,初始时:- 已排序区间:
[5](下标0,只有第一个元素,天然有序); - 未排序区间:
[3,1,2,4](下标1~4,需要逐个处理)。
- 已排序区间:
第 1 次循环(i=1,待插入元素为3)
- 核心操作 :将未排序区间的第一个元素
3插入已排序区间[5]的合适位置。 - 步骤:
- 保存待插入元素:
tmp = parr[1] = 3; end指向已排序区间最后一个元素(end = i-1 = 0,对应值5);- 比较
parr[end]与tmp:5 > 3,因此parr[end+1] = parr[end](将5后移到下标1),end减为-1; - 循环结束,插入位置为
end+1 = 0,将tmp=3放入parr[0];
- 保存待插入元素:
- 结果:已排序区间扩大为
[3,5],数组变为[3,5,1,2,4]。
第 2 次循环(i=2,待插入元素为1)
- 核心操作 :将未排序区间的第一个元素
1插入已排序区间[3,5]的合适位置。 - 步骤:
- 保存待插入元素:
tmp = parr[2] = 1; end = i-1 = 1(对应值5);- 比较
5 > 1:parr[2] = 5(5后移到下标2),end=0; - 比较
parr[0]=3 > 1:parr[1] = 3(3后移到下标1),end=-1; - 插入位置为
end+1=0,将tmp=1放入parr[0];
- 保存待插入元素:
- 结果:已排序区间扩大为
[1,3,5],数组变为[1,3,5,2,4]。
第 3 次循环(i=3,待插入元素为2)
- 核心操作 :将未排序区间的第一个元素
2插入已排序区间[1,3,5]的合适位置。 - 步骤:
- 保存待插入元素:
tmp = parr[3] = 2; end = i-1 = 2(对应值5);- 比较
5 > 2:parr[3] = 5(5后移到下标3),end=1; - 比较
parr[1]=3 > 2:parr[2] = 3(3后移到下标2),end=0; - 比较
parr[0]=1 <= 2:找到插入位置,退出循环; - 插入位置为
end+1=1,将tmp=2放入parr[1];
- 保存待插入元素:
- 结果:已排序区间扩大为
[1,2,3,5],数组变为[1,2,3,5,4]。
第 4 次循环(i=4,待插入元素为4)
- 核心操作 :将未排序区间的最后一个元素
4插入已排序区间[1,2,3,5]的合适位置。 - 步骤:
- 保存待插入元素:
tmp = parr[4] = 4; end = i-1 = 3(对应值5);- 比较
5 > 4:parr[4] = 5(5后移到下标4),end=2; - 比较
parr[2]=3 <= 4:找到插入位置,退出循环; - 插入位置为
end+1=3,将tmp=4放入parr[3];
- 保存待插入元素:
- 结果:已排序区间扩大为
[1,2,3,4,5],数组最终变为[1,2,3,4,5](完全有序)。
总结
通过 4 次插入操作,每次将未排序区间的第一个元素插入已排序区间的正确位置,最终使数组[5,3,1,2,4]变为升序[1,2,3,4,5]。直接插入排序的核心是利用已排序区间的有序性,通过 "元素后移" 为待插入元素腾出位置,实现逐步排序。
直接插入排序算法的时间复杂度和空间复杂度
时间复杂度
直接插入排序的时间复杂度主要取决于 "比较元素" 和 "移动元素" 的次数,与数组的初始有序程度密切相关,分为以下三种情况:
-
最好情况:数组本身已完全有序(如升序数组)。
- 此时,每个待插入元素只需与已排序区间的最后一个元素比较一次(发现无需移动),即可确定插入位置。
- 总比较次数为 O(n) ,移动次数为 O(n)(仅需临时存储待插入元素)。
- 时间复杂度:O(n)。
-
最坏情况:数组完全逆序(如需要升序,但原数组是降序)。
- 此时,第
i个元素(从 1 开始计数)需要与已排序区间的i个元素依次比较,且每个元素都需要后移,才能腾出插入位置。 - 总比较次数和移动次数均为 1+2+...+(n-1) = n(n-1)/2 ,约为 O(n²)。
- 时间复杂度:O(n²)。
- 此时,第
-
平均情况:数组元素随机排列(无序程度中等)。
- 每个元素插入时,平均需要与已排序区间的一半元素比较和移动,总操作次数约为 O(n²/2)。
- 时间复杂度:O(n²)。
空间复杂度
直接插入排序是原地排序算法 ,排序过程中不需要额外的存储空间(仅需一个临时变量tmp存储待插入元素,用于避免移动元素时被覆盖)。
- 空间复杂度:O(1)(常数级)。
总结
- 时间复杂度:最好
O(n),最坏和平均O(n²); - 空间复杂度:
O(1)。
因此,直接插入排序适合数据量小 或初始接近有序的数组,在这种场景下效率较高。
希尔排序(默认升序)
希尔排序代码实现
void ShellSort(int* parr, int size)
{
// 初始化gap为数组长度,gap表示分组的步长(每组内元素间隔gap)
int gap = size;
// 当gap > 1时,进行分组插入排序;gap = 1时,相当于一次完整的直接插入排序,完成最终排序
while (gap > 1)
{
// 更新gap值:采用gap = gap/3 + 1的方式,确保gap最终能减小到1(最后一次循环gap为1)
// 这种方式可使分组逐渐细化,逐步减少数组的逆序对
gap = gap / 3 + 1;
// 对每个分组进行插入排序:共gap个分组,起始索引分别为0,1,...,gap-1
for (int j = 0; j < gap; j++)
{
// 遍历当前分组中的元素,i为分组中已排序部分的最后一个元素索引
// 每次以gap为步长向后移动,处理分组中后续待插入的元素
for (int i = j; i < size - gap; i += gap)
{
// end标记当前分组中已排序部分的最后一个元素位置
int end = i;
// 保存当前待插入元素的值(分组中end的下一个元素,间隔为gap)
int tmp = parr[end + gap];
// 在当前分组的已排序部分中,找到待插入元素的正确位置
while (end >= 0)
{
// 若已排序部分的元素大于待插入元素,说明该元素需要后移(移动gap步)
if (parr[end] > tmp)
{
parr[end + gap] = parr[end]; // 元素后移gap步
end = end - gap; // 继续向前比较分组中前一个元素(间隔gap)
}
else
{
// 找到小于或等于待插入元素的位置,退出循环(已确定插入位置)
break;
}
}
// 将待插入元素放到分组中正确的位置(end + gap为插入位置)
parr[end + gap] = tmp;
}
}
}
}
希尔排序算法思想
希尔排序是插入排序的改进版,核心思想是:通过设置 "步长(gap)" 将数组分为多个子序列,对每个子序列进行插入排序;逐步减小 gap(使子序列逐渐合并),最后当 gap=1 时,对整个数组进行一次直接插入排序,完成最终排序。
- 为什么要分组? 直接插入排序对 "基本有序" 的数组效率很高(接近 O (n)),但对逆序较多的数组效率低(O (n²))。希尔排序通过大 gap 分组,让元素快速 "跳" 到大致正确的位置,减少逆序对,使数组逐渐接近有序,最后用直接插入排序收尾,大幅提升效率。
代码核心逻辑解析
- gap(步长)设置 :初始 gap 为数组长度
size,每次更新为gap = gap / 3 + 1(确保 gap 最终会减小到 1)。 - 分组规则 :对于当前 gap,数组被分为
gap个组,每组包含 "索引为j, j+gap, j+2*gap, ..." 的元素(j从 0 到gap-1)。 - 组内插入排序:对每个组,按 "直接插入排序" 的逻辑处理(将组内元素视为一个小数组,逐个插入到组内的有序部分)。
- 终止条件 :当
gap=1时,整个数组成为一个组,执行一次直接插入排序后,数组完全有序。
示例过程:对parr = [5,3,1,2,4](升序)排序
数组长度size=5,逐步处理如下:
第一步:计算 gap 的变化
- 初始 gap = size = 5;
- 第一次更新 gap:
gap = 5/3 + 1 = 1 + 1 = 2(整数除法); - 第二次更新 gap:
gap = 2/3 + 1 = 0 + 1 = 1; - 当 gap=1 时,完成最后一次排序后循环结束。
第二步:gap=2 时的分组排序(核心步骤)
此时 gap=2,数组被分为2个组(j=0 和 j=1),每组内元素间隔为 2,分别对两组进行插入排序。
组 1:j=0(元素索引:0, 0+2=2, 2+2=4)
组内元素初始为:parr[0]=5、parr[2]=1、parr[4]=4(即[5,1,4])。
-
处理组内第一个待插入元素(索引 2,值 1):
end=0(组内已排序部分最后一个元素索引),tmp=parr[0+2]=1;- 比较
parr[0]=5 > 1:parr[0+2] = 5(5 后移 2 步到索引 2),end=0-2=-2; - 插入位置
end+2=0,parr[0] = 1; - 组内元素变为:
[1,5,4](数组此时:[1,3,5,2,4])。
-
处理组内第二个待插入元素(索引 4,值 4):
end=2(组内已排序部分最后一个元素索引),tmp=parr[2+2]=4;- 比较
parr[2]=5 > 4:parr[2+2] = 5(5 后移 2 步到索引 4),end=2-2=0; - 比较
parr[0]=1 <= 4:找到插入位置,parr[0+2] = 4; - 组内元素变为:
[1,4,5](数组此时:[1,3,4,2,5])。
组 2:j=1(元素索引:1, 1+2=3)
组内元素初始为:parr[1]=3、parr[3]=2(即[3,2])。
- 处理组内待插入元素(索引 3,值 2):
end=1(组内已排序部分最后一个元素索引),tmp=parr[1+2]=2;- 比较
parr[1]=3 > 2:parr[1+2] = 3(3 后移 2 步到索引 3),end=1-2=-1; - 插入位置
end+2=1,parr[1] = 2; - 组内元素变为:
[2,3](数组此时:[1,2,4,3,5])。
第三步:gap=1 时的最终排序(直接插入排序)
此时 gap=1,整个数组视为一个组([1,2,4,3,5]),执行直接插入排序:
- 待插入元素依次为索引 1(2)、2(4)、3(3)、4(5)。
- 重点处理索引 3(值 3):
end=2(已排序部分最后一个元素索引,值 4),tmp=3;- 比较
4 > 3:parr[3] = 4(4 后移 1 步),end=1; - 比较
parr[1]=2 <= 3:插入位置end+1=2,parr[2] = 3; - 数组变为:
[1,2,3,4,5]。
最终结果
经过 gap=2 和 gap=1 的排序,数组[5,3,1,2,4]最终变为升序[1,2,3,4,5]。
核心总结
希尔排序通过 "大 gap 分组粗调→小 gap 分组细调→gap=1 精调" 的流程,让元素快速接近目标位置,解决了直接插入排序对逆序数组效率低的问题。其关键是 gap 的设置(本例中 gap=2→1),分组排序逐步减少逆序对,最终通过一次直接插入排序完成整体有序。
希尔排序算法的空间复杂度和时间复杂度
希尔排序的时间复杂度和空间复杂度分析如下,其时间复杂度因步长(gap)序列的选择而有较大差异,空间复杂度则相对固定:
空间复杂度
希尔排序是原地排序算法 ,排序过程中仅需要一个临时变量(如代码中的tmp)存储待插入元素,无需额外的数组或数据结构。因此:
- 空间复杂度:O(1)(常数级)。
时间复杂度
希尔排序的时间复杂度比较复杂,其核心是通过 "分组插入排序" 逐步减少数组的逆序对,最终通过一次直接插入排序完成收尾。时间复杂度的高低取决于步长(gap)序列的设计(即每次 gap 如何缩小),不同的步长序列会导致不同的时间复杂度,目前尚无精确的数学推导证明其最优值,以下是常见情况:
1. 最坏情况时间复杂度
- 原始希尔步长(gap = n/2, n/4, ..., 1) :这种步长序列下,最坏情况时间复杂度为 O(n²)(虽然比直接插入排序的实际表现好,但理论上仍为平方级)。
- Hibbard 步长(gap = 2ᵏ - 1,如 1, 3, 7, 15...) :最坏情况时间复杂度约为 O(n^(3/2))(优于原始步长)。
- Sedgewick 步长(混合序列,如 94ᵏ - 92ᵏ + 1) :最坏情况时间复杂度约为 O(n^(4/3))(目前已知表现较好的步长序列之一)。
2. 平均情况时间复杂度
平均时间复杂度同样依赖步长序列,目前主流观点认为:
- 对于合理的步长序列(如 Hibbard、Sedgewick),平均时间复杂度约为 O(n log²n) 或 O(n^(5/4)),具体数值仍存在争议,但整体远优于直接插入排序的 O (n²)。
3. 为什么时间复杂度不固定?
希尔排序的核心是通过 "大 gap 分组" 让元素快速 "跳跃" 到接近目标位置(减少逆序对),再通过 "小 gap 分组" 细化排序,最后用 gap=1 的直接插入排序收尾。步长序列的设计直接影响 "元素跳跃的效率":
- 若步长序列中相邻 gap 存在倍数关系(如原始希尔步长的 n/2 和 n/4),会导致分组重复(元素在不同 gap 下被分到同一组),降低效率;
- 若步长序列中相邻 gap 互质(如 Hibbard 步长),可减少分组重复,让元素更快扩散到正确位置,提升效率。
总结
- 空间复杂度:O (1)(原地排序,仅需常数级额外空间);
- 时间复杂度:依赖步长序列,最坏情况在 O (n²) 到 O (n^(4/3)) 之间,平均情况约为 O (n log²n),实际应用中效率远高于直接插入排序,适合中等规模数据的排序。
直接选择排序(默认升序)
直接选择排序代码实现
void SelectSort(int* parr, int size)
{
int begin = 0; // 待排序区间的起始索引(初始为数组开头)
int end = size - 1; // 待排序区间的结束索引(初始为数组末尾)
// 当待排序区间不为空(begin < end)时,继续排序
while (begin < end)
{
int mini = begin; // 记录当前待排序区间中最小元素的索引(初始化为区间起始)
int maxi = begin; // 记录当前待排序区间中最大元素的索引(初始化为区间起始)
// 遍历当前待排序区间[begin, end],找到最小和最大元素的索引
for (int i = begin; i <= end; i++)
{
// 若当前元素大于maxi指向的元素,更新maxi为当前索引
if (parr[i] > parr[maxi])
maxi = i;
// 若当前元素小于mini指向的元素,更新mini为当前索引
if (parr[i] < parr[mini])
mini = i;
}
// 将最大元素交换到当前待排序区间的末尾(end位置)
Swap(&parr[maxi], &parr[end]);
// 特殊情况处理:若最小元素原本在end位置(交换后被移到了maxi位置)
// 此时需要将mini更新为maxi(因为原end位置的元素已被交换到maxi)
if (end == mini)
mini = maxi;
// 将最小元素交换到当前待排序区间的开头(begin位置)
Swap(&parr[mini], &parr[begin]);
// 缩小待排序区间:起始位置后移一位,结束位置前移一位
begin++;
end--;
}
}
直接选择排序算法思想
直接选择排序的逻辑与原理(结合代码)
直接选择排序的核心思想是:通过不断缩小 "待排序区间",每次从区间中选出最小和最大的元素,分别放到区间的起始和末尾位置,直到整个区间缩小为空(数组完全有序)。相比冒泡排序的 "相邻交换",直接选择排序通过 "一次交换确定元素最终位置" 减少了交换次数,逻辑更直接。
代码通过以下逻辑实现:
- 区间定义 :用
begin(待排序区间起始索引)和end(待排序区间结束索引)标记当前需要排序的范围,初始为begin=0,end=size-1。 - 寻找最值 :遍历待排序区间
[begin, end],找到最小元素的索引mini和最大元素的索引maxi。 - 交换元素 :
- 将最大元素(
maxi指向)交换到区间末尾(end位置); - 特殊情况处理:若最小元素原本在
end位置(交换后被移到maxi位置),需更新mini为maxi(避免后续交换错误); - 将最小元素(
mini指向)交换到区间起始(begin位置)。
- 将最大元素(
- 缩小区间 :
begin++(起始后移,已排好最小元素),end--(结束前移,已排好最大元素),重复上述步骤直到begin >= end(区间为空)。
示例过程:对parr = [5,3,1,2,4](升序)排序
数组长度size=5,逐步处理如下:
初始状态
待排序区间:[begin=0, end=4](元素:5,3,1,2,4)。
第一轮循环(begin=0,end=4)
-
步骤 1:找最值索引 遍历
[0,4](元素5,3,1,2,4):- 最大元素是
5(索引0)→maxi=0; - 最小元素是
1(索引2)→mini=2。
- 最大元素是
-
步骤 2:交换最大元素到区间末尾 交换
parr[maxi=0]和parr[end=4]:数组变为[4,3,1,2,5](最大元素5已固定在末尾)。 -
步骤 3:处理特殊情况 检查是否
end=4 == mini=2?否(mini仍为2),无需更新mini。 -
步骤 4:交换最小元素到区间起始 交换
parr[mini=2]和parr[begin=0]:数组变为[1,3,4,2,5](最小元素1已固定在起始)。 -
缩小区间 :
begin=1,end=3(待排序区间变为[1,3])。
第二轮循环(begin=1,end=3)
-
步骤 1:找最值索引 遍历
[1,3](元素3,4,2):- 最大元素是
4(索引2)→maxi=2; - 最小元素是
2(索引3)→mini=3。
- 最大元素是
-
步骤 2:交换最大元素到区间末尾 交换
parr[maxi=2]和parr[end=3]:数组变为[1,3,2,4,5](最大元素4已固定在end=3位置)。 -
步骤 3:处理特殊情况 检查是否
end=3 == mini=3?是(原mini在end位置,交换后2被移到maxi=2位置)→ 更新mini=2。 -
步骤 4:交换最小元素到区间起始 交换
parr[mini=2]和parr[begin=1]:数组变为[1,2,3,4,5](最小元素2已固定在begin=1位置)。 -
缩小区间 :
begin=2,end=2(此时begin >= end,循环结束)。
最终结果
经过 2 轮循环,数组[5,3,1,2,4]最终变为升序[1,2,3,4,5]。
核心总结
直接选择排序通过 "每次确定待排序区间的最大和最小元素,交换到两端并缩小区间" 实现排序,核心是减少无效交换(一次交换确定一个元素的最终位置)。特殊情况处理(end == mini时更新mini)是为了避免最大元素和最小元素位置重叠时的交换错误,确保排序逻辑正确。
直接插入排序算法的时间复杂度和空间复杂度
直接插入排序的时间复杂度和空间复杂度分析如下,其复杂度与数组的初始有序程度密切相关:
空间复杂度
直接插入排序是原地排序算法 ,排序过程中仅需要一个临时变量(如代码中的tmp)存储待插入元素,用于避免移动元素时被覆盖,无需额外的数组或数据结构。因此:
- 空间复杂度:O(1)(常数级)。
时间复杂度
时间复杂度主要取决于 "比较元素" 和 "移动元素" 的次数,分为以下三种情况:
1. 最好情况:数组本身已完全有序(如升序数组)
此时,每个待插入元素只需与已排序区间的最后一个元素比较一次(发现无需移动),即可确定插入位置。
- 总比较次数为
n-1(仅需一轮遍历),移动次数为0(仅需存储临时变量)。 - 时间复杂度:O(n)。
2. 最坏情况:数组完全逆序(如需要升序,但原数组是降序)
此时,第i个元素(从 1 开始计数)需要与已排序区间的i个元素依次比较,且每个元素都需要后移以腾出位置。
- 总比较次数和移动次数均为
1+2+...+(n-1) = n(n-1)/2,约为O(n²)。 - 时间复杂度:O(n²)。
3. 平均情况:数组元素随机排列(无序程度中等)
此时,每个元素插入时,平均需要与已排序区间的一半元素比较和移动,总操作次数约为O(n²/2)。
- 时间复杂度:O(n²)。
总结
- 空间复杂度:O(1)(原地排序,仅需常数级额外空间);
- 时间复杂度:最好
O(n)(有序数组),最坏和平均O(n²)(逆序或随机数组)。
直接插入排序适合数据量小 或初始接近有序的数组,在这类场景下效率较高。
堆排序(默认升序)
堆排序代码实现
// 向下调整算法:将以parenti为根的子树调整为大根堆(父节点值大于子节点值)
void AdjustDown(int* parr, int size, int parenti)
{
// 计算parenti的左孩子索引(完全二叉树中,左孩子 = 父节点*2 + 1)
int childi = parenti * 2 + 1;
// 当孩子节点索引在堆范围内(未越界)时,继续调整
while (childi < size)
{
// 若右孩子存在(childi+1 < size),且右孩子值大于左孩子值,更新childi为右孩子索引
// 目的是找到当前父节点的两个孩子中值较大的那个
if ((childi + 1 < size) && (parr[childi + 1] > parr[childi]))
childi++;
// 若较大的孩子值大于父节点值,说明不符合大根堆性质,需要交换
if (parr[childi] > parr[parenti])
{
Swap(&parr[parenti], &parr[childi]); // 交换父节点和较大孩子节点的值
// 更新父节点索引为当前孩子节点索引,继续向下调整(因为交换后可能破坏下层堆结构)
parenti = childi;
// 重新计算新父节点的左孩子索引
childi = parenti * 2 + 1;
}
// 堆排序函数:基于大根堆实现数组升序排序
void HeapSort(int* parr, int size)
{
// 第一步:将数组构建为大根堆
// 从最后一个非叶子节点开始,依次向上进行向下调整
// 最后一个非叶子节点索引 = (最后一个元素索引 - 1) / 2,即(size-1-1)/2
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
AdjustDown(parr, size, i); // 对每个非叶子节点进行向下调整
// 第二步:利用大根堆进行排序
// 每次将堆顶(最大值)与当前堆的最后一个元素交换,然后缩小堆的范围并调整堆
for (int i = size - 1; i > 0; i--)
{
// 交换堆顶(索引0,当前最大值)和堆的最后一个元素(索引i)
// 交换后,最大值被放到数组末尾(已排序位置)
Swap(&parr[0], &parr[i]);
// 此时堆的大小缩小为i(排除已排序的末尾元素),对新的堆顶(索引0)进行向下调整,维护大根堆性质
AdjustDown(parr, i, 0);
}
}
// 若孩子值小于等于父节点值,说明当前子树已符合大根堆性质,退出调整
break;
}
}
}
堆排序算法思想
堆排序的逻辑与原理(结合代码)
堆排序是基于 "堆" 这种数据结构的排序算法,核心利用大根堆(父节点值大于子节点值) 的特性:堆顶元素是当前堆中的最大值。排序过程分为两步:构建大根堆 和利用堆顶最大值逐步排序,最终实现数组升序。
1. 核心工具:AdjustDown(向下调整算法)
作用:将以parenti为根的子树调整为大根堆(确保父节点值大于左右子节点值),是堆排序的核心支撑。
- 原理:
- 对于给定的父节点
parenti,找到它的左右孩子(完全二叉树中,左孩子索引=2*parenti+1,右孩子=2*parenti+2); - 从左右孩子中选出值较大的那个(
childi); - 若
childi的值大于parenti的值,交换两者(修复当前层的堆性质); - 交换后,
parenti更新为childi,继续向下检查下层子树,直到孩子节点越界(子树已符合大根堆性质)。
- 对于给定的父节点
2. 堆排序主流程:HeapSort函数
步骤 1:将无序数组构建为大根堆
- 从最后一个非叶子节点 开始,依次向上对每个节点执行
AdjustDown。- 最后一个非叶子节点索引:
(size-1-1)/2(完全二叉树中,最后一个元素的父节点就是最后一个非叶子节点); - 原因:叶子节点本身已是 "单个元素的大根堆",只需调整非叶子节点即可将整个数组转为大根堆。
- 最后一个非叶子节点索引:
步骤 2:利用大根堆排序(升序)
- 每次将堆顶(最大值)与当前堆的最后一个元素交换(最大值被放到数组末尾,成为 "已排序部分");
- 缩小堆的范围(排除已排序的末尾元素),对新的堆顶执行
AdjustDown,重建大根堆; - 重复上述操作,直到堆的大小为 1(数组完全有序)。
示例过程:对parr = [5,3,1,2,4](升序)排序
数组长度size=5,逐步处理如下:
第一步:构建大根堆
目标:将[5,3,1,2,4]转为大根堆(堆顶为最大值)。
-
确定最后一个非叶子节点 :索引
=(5-1-1)/2=1(对应元素3),从i=1开始向上调整。-
调整
i=1(父节点索引 1,值 3):- 左孩子索引
2*1+1=3(值 2),右孩子索引4(值 4); - 右孩子 4 > 左孩子 2 →
childi=4; - 右孩子 4 > 父节点 3 → 交换,数组变为
[5,4,1,2,3]; - 更新
parenti=4,孩子索引2*4+1=9(超出size=5)→ 调整结束。
- 左孩子索引
-
调整
i=0(父节点索引 0,值 5):- 左孩子索引
1(值 4),右孩子索引2(值 1); - 左孩子 4 > 右孩子 1 →
childi=1; - 父节点 5 > 左孩子 4 → 无需交换,调整结束。
- 左孩子索引
-
-
构建完成的大根堆 :
[5,4,1,2,3](堆顶 5 是最大值)。
第二步:利用大根堆排序(逐步提取最大值)
每次将堆顶最大值放到数组末尾,缩小堆范围并重建大根堆。
-
第 1 轮(堆大小 = 5,i=4):
- 交换堆顶(5)和堆尾(i=4,值 3)→ 数组变为
[3,4,1,2,5](5 已排序,放到末尾); - 堆大小缩小为 4(排除已排序的 5),对新堆顶(索引 0,值 3)执行
AdjustDown:- 左孩子 1(4),右孩子 2(1)→
childi=1(4>1); - 3<4 → 交换,数组变为
[4,3,1,2,5]; - 更新
parenti=1,左孩子 3(2)→ 3>2 → 无需调整,堆重建完成([4,3,1,2])。
- 左孩子 1(4),右孩子 2(1)→
- 交换堆顶(5)和堆尾(i=4,值 3)→ 数组变为
-
第 2 轮(堆大小 = 4,i=3):
- 交换堆顶(4)和堆尾(i=3,值 2)→ 数组变为
[2,3,1,4,5](4 已排序); - 堆大小缩小为 3,对新堆顶(索引 0,值 2)执行
AdjustDown:- 左孩子 1(3),右孩子 2(1)→
childi=1(3>1); - 2<3 → 交换,数组变为
[3,2,1,4,5]; - 更新
parenti=1,左孩子 3(超出堆大小 3)→ 调整结束,堆重建完成([3,2,1])。
- 左孩子 1(3),右孩子 2(1)→
- 交换堆顶(4)和堆尾(i=3,值 2)→ 数组变为
-
第 3 轮(堆大小 = 3,i=2):
- 交换堆顶(3)和堆尾(i=2,值 1)→ 数组变为
[1,2,3,4,5](3 已排序); - 堆大小缩小为 2,对新堆顶(索引 0,值 1)执行
AdjustDown:- 左孩子 1(2)→
childi=1; - 1<2 → 交换,数组变为
[2,1,3,4,5]; - 更新
parenti=1,左孩子 3(超出堆大小 2)→ 调整结束,堆重建完成([2,1])。
- 左孩子 1(2)→
- 交换堆顶(3)和堆尾(i=2,值 1)→ 数组变为
-
第 4 轮(堆大小 = 2,i=1):
- 交换堆顶(2)和堆尾(i=1,值 1)→ 数组变为
[1,2,3,4,5](2 已排序); - 堆大小缩小为 1 → 循环结束。
- 交换堆顶(2)和堆尾(i=1,值 1)→ 数组变为
最终结果
数组[5,3,1,2,4]经过堆排序后,变为升序[1,2,3,4,5]。
核心总结
堆排序通过 "构建大根堆" 将数组转为 "最大值在顶" 的结构,再通过 "交换堆顶与堆尾 + 重建堆" 的循环,逐步将最大值放到数组末尾,最终实现升序。核心是AdjustDown算法高效维护堆性质,整个过程利用堆的特性减少比较次数,效率远高于简单排序算法。
堆排序算法的时间复杂度和空间复杂度
堆排序的时间复杂度和空间复杂度分析如下,其特性与堆的结构和调整过程密切相关:
空间复杂度
堆排序是原地排序算法,排序过程中仅需要几个临时变量(如交换元素时的临时存储、索引计算变量等),无需额外的数组或数据结构来存储元素。因此:
- 空间复杂度:O(1)(常数级)。
时间复杂度
堆排序的时间复杂度由两部分组成:构建大根堆的时间 和排序阶段(提取最大值并重建堆)的时间 ,整体时间复杂度为O(n log n),且在最好、最坏、平均情况下均保持一致。
1. 构建大根堆的时间复杂度:O(n)
构建大根堆时,需从 "最后一个非叶子节点" 开始,向上对每个非叶子节点执行AdjustDown(向下调整)操作。
- 对于包含
n个元素的完全二叉树,高度h ≈ log₂n(堆的高度即树的高度); - 每个节点的
AdjustDown操作次数与其所在深度相关:深度越大(越靠近叶子)的节点,调整次数越少; - 所有节点的调整次数总和为
O(n)(数学推导可证明:底层节点数量多但调整次数少,上层节点数量少但调整次数多,整体总和为线性级)。
2. 排序阶段的时间复杂度:O(n*log n)
排序阶段需重复以下操作n-1次:
-
将堆顶(最大值)与当前堆的最后一个元素交换(
O(1)); -
对新的堆顶执行
AdjustDown操作,重建大根堆(堆的大小每次减 1)。 -
第
i次重建堆时,堆的大小为n-i,AdjustDown的时间复杂度为O(log(n-i))(与堆的高度成正比); -
总时间为
log(n-1) + log(n-2) + ... + log1 ≈ O(n log n)(求和结果近似于n log n)。
3. 整体时间复杂度:O(n*log n)
构建堆的O(n)与排序阶段的O(n*log n)相加,整体时间复杂度由高阶项O(n*log n)主导,因此堆排序的时间复杂度为O(n*log n)。
总结
- 空间复杂度:O(1)(原地排序,仅需常数级额外空间);
- 时间复杂度:O(n*log n)(最好、最坏、平均情况均为此值,稳定性优于简单排序算法)。
堆排序的优势是时间复杂度稳定(不受初始数据顺序影响),且空间效率高,适合大规模数据的排序场景。
快速排序(huare版本)(默认升序)
快速排序(huare版本)代码实现
// Hoare版本的快速排序分区函数:
// 将数组[left, right]区间按基准值分区,左侧元素<=基准值,右侧元素>=基准值
int PartSort_Hoare(int* parr, int left, int right)
{
// 选择区间起始位置的元素作为基准值(key),记录其初始索引
int keyi = left;
// 当左右指针未相遇时,继续分区操作
while (left < right)
{
// 右指针向左移动:从区间右侧寻找第一个小于基准值的元素
// 循环条件:左右指针未相遇(left < right),且当前右指针元素>=基准值(不满足则停止移动)
while ((left < right) && (parr[right] >= parr[keyi]))
right--;
// 左指针向右移动:从区间左侧寻找第一个大于基准值的元素
// 循环条件:左右指针未相遇(left < right),且当前左指针元素<=基准值(不满足则停止移动)
while ((left < right) && (parr[left] <= parr[keyi]))
left++;
// 交换左右指针找到的元素:此时左指针元素>基准值,右指针元素<基准值,交换后维持分区趋势
Swap(&parr[right], &parr[left]);
}
// 当left与right相遇时,该位置即为基准值的最终位置,交换基准值(初始在keyi)与相遇位置元素
Swap(&parr[keyi], &parr[left]);
// 返回基准值的最终索引(作为下一次分区的边界)
return left;
}
// 快速排序递归函数:基于Hoare分区法实现数组升序排序
void QuickSort(int* parr, int begin, int end)
{
// 递归终止条件1:若待排序区间为空(begin > end),则无需排序,直接返回
// 递归终止条件2:若只有一个元素(begin == end),一个元素本身就是有序的,同样无需排序并返回
if (begin >= end)
return;
// 调用Hoare分区函数,对[begin, end]区间进行分区,获取基准值最终索引keyi
int keyi = PartSort_Hoare(parr, begin, end);
// 递归排序基准值左侧区间[begin, keyi-1](该区间元素均<=基准值)
QuickSort(parr, begin, keyi - 1);
// 递归排序基准值右侧区间[keyi+1, end](该区间元素均>=基准值)
QuickSort(parr, keyi + 1, end);
}
快速排序(huare版本)算法思想
快速排序(Hoare 分区法)的逻辑与原理(结合代码)
快速排序是典型的 "分治法" 排序算法,核心逻辑是:选择一个基准值(key),通过分区操作将数组分为两部分(左部分≤key,右部分≥key),再递归对两部分进行排序,最终使整个数组有序 。其中,Hoare 分区法通过 "双指针交替移动" 实现分区,关键是右指针(right)先移动,以保证指针相遇位置的元素一定小于基准值,确保分区正确。
1. 核心分区函数PartSort_Hoare:实现区间分区
作用:将[left, right]区间以基准值为界分为两部分,返回基准值的最终索引(用于递归分区)。
- 基准值选择 :以区间起始位置元素为基准值(
keyi = left,即parr[keyi]为基准)。 - 双指针移动规则 :
right先向左移动:寻找第一个小于基准值 的元素(停在parr[right] < parr[keyi]的位置);left再向右移动:寻找第一个大于基准值 的元素(停在parr[left] > parr[keyi]的位置);- 交换
left和right指向的元素(确保左半部分尽量小,右半部分尽量大); - 重复上述步骤,直到
left == right(指针相遇),此时将基准值与相遇位置元素交换,完成分区。
2. 为什么 "right 先走" 能保证相遇位置元素≤基准值?
这是 Hoare 分区法的核心细节,确保分区后基准值左侧均≤基准值,右侧均≥基准值:
- 若
right先移动:right始终在寻找小于基准值的元素,当left追上right时(left == right),有两种可能:right找到了小于基准值的元素后停下,left移动到right位置(此时parr[left] = parr[right] < 基准值);right未找到小于基准值的元素(整个区间都≥基准值),最终right会移动到keyi位置,left也随之移动到keyi(此时parr[left] = 基准值,满足≤基准值)。
- 因此,相遇位置的元素一定≤基准值,与基准值交换后,基准值左侧均≤它,右侧均≥它,分区逻辑正确。
3. 递归排序函数QuickSort:分治处理子区间
- 递归终止条件:若待排序区间为空(
begin >= end),直接返回; - 分区操作:调用
PartSort_Hoare获取基准值最终索引keyi; - 递归处理:分别对
[begin, keyi-1](左区间,元素≤基准值)和[keyi+1, end](右区间,元素≥基准值)递归排序,直到所有子区间有序。
示例过程:对parr = [5,3,1,2,4](升序)排序
数组长度为 5,初始调用QuickSort(parr, 0, 4),逐步处理如下:
第一轮分区:处理区间[0,4](元素:5,3,1,2,4)
- 基准值 :
keyi=0(parr[keyi]=5)。 - 指针移动与交换 :
right从 4 开始向左找<5 的元素:parr[4]=4 <5→right=4停下;left从 0 开始向右找>5 的元素:parr[0]=5→parr[1]=3→parr[2]=1→parr[3]=2→parr[4]=4,均≤5,left移动到 4(与right相遇);- 交换基准值(
keyi=0)与相遇位置(left=4):数组变为[4,3,1,2,5]; - 返回
keyi=4(基准值 5 的最终位置,右侧无元素,无需递归)。
递归处理左区间[0,3](元素:4,3,1,2)
- 基准值 :
keyi=0(parr[keyi]=4)。 - 指针移动与交换 :
right从 3 向左找<4 的元素:parr[3]=2 <4→right=3停下;left从 0 开始向右找>4 的元素:parr[0]=4→parr[1]=3→parr[2]=1→parr[3]=2,均≤4,left移动到 3(与right相遇);- 交换基准值(
keyi=0)与相遇位置(left=3):数组变为[2,3,1,4,5]; - 返回
keyi=3(基准值 4 的最终位置,右侧无元素,无需递归)。
递归处理左区间[0,2](元素:2,3,1)
- 基准值 :
keyi=0(parr[keyi]=2)。 - 指针移动与交换 :
right从 2 向左找<2 的元素:parr[2]=1 <2→right=2停下;left从 0 开始向右找>2 的元素:parr[0]=2→parr[1]=3 >2→left=1停下;- 交换
left=1和right=2:数组变为[2,1,3,4,5]; - 继续循环(
left=1 < right=2):right向左移动到 1(与left相遇);
- 交换基准值(
keyi=0)与相遇位置(left=1):数组变为[1,2,3,4,5]; - 返回
keyi=1(基准值 2 的最终位置)。
递归处理剩余子区间
- 左区间
[0,0](元素 1)和右区间[2,2](元素 3)均为单个元素,递归直接返回,排序完成。
最终结果
数组[5,3,1,2,4]经过快速排序后,变为升序[1,2,3,4,5]。
核心总结
快速排序通过 "分治法 + Hoare 分区" 实现高效排序,关键是 "right 指针先移动" 确保相遇位置元素≤基准值,从而正确分区。递归处理子区间使整个数组逐步有序,其核心优势是平均情况下效率极高(时间复杂度O(n log n))。
快速排序(挖坑法版本)
快速排序(挖坑法版本)代码实现
// 挖坑法实现的快速排序分区函数:
// 将数组[left, right]区间按基准值分区,左侧<=基准值,右侧>=基准值
int PartSort_DigHole(int* parr, int left, int right)
{
// 选择区间起始位置的元素作为基准值(key),暂存其值
int key = parr[left];
// 初始化"坑位"为基准值的初始位置(left),坑位表示需要填充元素的位置
int hole = left;
// 当左右指针未相遇时,继续分区操作
while (left < right)
{
// 右指针向左移动:从区间右侧寻找第一个小于基准值key的元素
// 循环条件:左右指针未相遇(left < right),且当前右指针元素>=key(不满足则停止移动)
while ((left < right) && (parr[right] >= key))
right--;
// 将找到的右侧元素填入当前坑位,此时原right位置变为新的坑位
parr[hole] = parr[right];
hole = right;
// 左指针向右移动:从区间左侧寻找第一个大于基准值key的元素
// 循环条件:左右指针未相遇(left < right),且当前左指针元素<=key(不满足则停止移动)
while ((left < right) && (parr[left] <= key))
left++;
// 将找到的左侧元素填入当前坑位,此时原left位置变为新的坑位
parr[hole] = parr[left];
hole = left;
}
// 当left与right相遇时,将基准值key填入最后的坑位,完成分区
parr[hole] = key;
// 返回基准值最终所在的索引(分区点)
return hole;
}
// 快速排序递归函数:基于挖坑法分区实现数组升序排序
void QuickSort(int* parr, int begin, int end)
{
if (begin >= end)
return;
int keyi = PartSort_DigHole(parr, begin, end);
QuickSort(parr, begin, keyi - 1);
QuickSort(parr, keyi + 1, end);
}
快速排序(挖坑法版本)算法思想
快速排序(挖坑法)的逻辑与原理(结合代码)
挖坑法是快速排序中另一种经典的分区实现方式,核心逻辑仍基于 "分治法":选择基准值,通过 "挖坑 - 填坑" 的过程将区间分为 "左部分≤基准值,右部分≥基准值",再递归排序子区间。与 Hoare 版本相比,其分区过程通过 "动态更新坑位" 实现,因此无需强制右指针先移动,左指针或右指针先走均可,具体逻辑如下:
1. 核心分区函数PartSort_DigHole:"挖坑 - 填坑" 实现分区
作用:将[left, right]区间以基准值为界分区,返回基准值的最终索引(用于递归划分),核心是 "坑位" 的动态更新。
-
初始设置:
- 选择区间起始元素为基准值(
key = parr[left]),并将该位置标记为 "坑位"(hole = left)------"坑位" 表示当前需要被其他元素填充的位置。
- 选择区间起始元素为基准值(
-
分区过程("挖坑 - 填坑" 循环):
-
右指针移动与填坑:
right向左移动,寻找 ** 第一个小于基准值key** 的元素(循环条件:left < right且parr[right] ≥ key);- 找到后,将该元素(
parr[right])填入当前坑位(parr[hole] = parr[right]),此时right的位置成为 "新的坑位"(hole = right)。
-
左指针移动与填坑:
left向右移动,寻找 ** 第一个大于基准值key** 的元素(循环条件:left < right且parr[left] ≤ key);- 找到后,将该元素(
parr[left])填入当前坑位(parr[hole] = parr[left]),此时left的位置成为 "新的坑位"(hole = left)。
-
循环终止与基准值就位:
- 重复上述步骤,直到
left == right(左右指针相遇),此时 "坑位" 与指针相遇位置重合; - 将基准值
key填入最后的坑位(parr[hole] = key),完成分区 ------ 此时hole左侧元素均≤key,右侧元素均≥key。
- 重复上述步骤,直到
-
2. 为什么 "左指针或右指针先走均可"?
这是挖坑法与 Hoare 法的核心区别,根源在于 "坑位" 的动态更新机制:
- 在 Hoare 法中,指针相遇位置的元素需要直接与基准值交换,因此必须保证相遇位置元素≤基准值(依赖右指针先移动寻找小于基准值的元素);
- 而在挖坑法中,指针的移动始终是为了 "填充当前坑位" :
- 若先移动右指针:找到小于
key的元素填坑,新坑位在right,再移动左指针找大于key的元素填新坑,逻辑自洽; - 若先移动左指针:找到大于
key的元素填初始坑(left位置),新坑位在left,再移动右指针找小于key的元素填新坑,同样逻辑自洽。
- 若先移动右指针:找到小于
- 无论哪种顺序,最终指针相遇时的 "坑位" 会被基准值直接填充,无需交换操作,因此无需强制某一指针先移动,只要保证 "右指针找小于
key的元素,左指针找大于key的元素" 即可。
3. 递归排序函数QuickSort:分治处理子区间
与 Hoare 版本逻辑一致
核心总结
挖坑法通过 "初始坑位→右指针找小元素填坑→更新坑位→左指针找大元素填坑→更新坑位" 的循环实现分区,核心是 "坑位" 的动态迁移。由于最终基准值是直接填入最后一个坑位(而非交换),因此无需强制右指针先移动,左 / 右指针谁先移动均可保证分区正确性。其本质仍是 "分治法",通过递归将大区间分解为小区间,最终实现整体有序。
快速排序(前后指针法)
快速排序(前后指针法)代码实现
// 前后指针法实现的快速排序分区函数:
// 将数组[left, right]区间按基准值分区,左侧元素<基准值,右侧元素>=基准值
int PartSort_BeforeAfterPointer(int* parr, int left, int right)
{
// 选择区间起始位置的元素作为基准值,记录其初始索引(keyi)
int keyi = left;
// prev指针:标记小于基准值区域的最后一个元素索引(初始与基准值索引相同)
int prev = left;
// cur指针:用于遍历区间内的元素(从基准值的下一个元素开始)
int cur = prev + 1;
// 遍历区间内从cur到right的所有元素
while (cur <= right)
{
// 若当前cur指向的元素小于基准值,说明该元素应划分到小于基准值的区域
if (parr[cur] < parr[keyi])
{
// prev先向后移动一位(指向小于区域的下一个位置)
// 若prev移动后与cur不重合(说明中间有大于基准值的元素),则交换prev和cur指向的元素
// 交换后,prev仍为小于区域的最后一个位置
if(++prev != cur)
Swap(&parr[prev], &parr[cur]);
}
// cur指针向后移动,继续遍历下一个元素
cur++;
}
// 遍历结束后,prev指向小于基准值区域的最后一个位置,将基准值与prev位置的元素交换
// 交换后,基准值左侧均为小于它的元素,右侧均为大于等于它的元素
Swap(&parr[prev], &parr[keyi]);
// 返回基准值最终所在的索引(分区点)
return prev;
}
// 快速排序递归函数:基于前后指针法分区实现数组升序排序
void QuickSort(int* parr, int begin, int end)
{
if (begin >= end)
return;
int keyi = PartSort_BeforeAfterPointer(parr, begin, end);
QuickSort(parr, begin, keyi - 1);
QuickSort(parr, keyi + 1, end);
}
快速排序(前后指针法)算法思想
快速排序(前后指针法)的逻辑与原理(结合代码)
前后指针法是快速排序中另一种直观的分区实现方式,核心思想仍是 "分治法":通过两个指针(prev和cur)的配合,将区间划分为 "小于基准值的区域" 和 "大于等于基准值的区域",再递归排序子区间。其核心是用prev维护 "小于基准值区域" 的边界,用cur遍历元素并将符合条件的元素纳入该区域,具体逻辑如下:
1. 核心分区函数PartSort_BeforeAfterPointer:前后指针配合实现分区
作用:将[left, right]区间以基准值为界分区,左侧元素均小于基准值,右侧元素均大于等于基准值,返回基准值的最终索引(用于递归划分)。
-
初始设置:
- 选择区间起始元素为基准值,
keyi记录其初始索引(keyi = left); prev指针:标记 "小于基准值区域" 的最后一个元素索引(初始与基准值索引相同,即prev = left,此时小于区域仅包含基准值左侧的空区域);cur指针:用于遍历区间内的元素(从基准值的下一个元素开始,即cur = prev + 1),负责寻找需要纳入 "小于基准值区域" 的元素。
- 选择区间起始元素为基准值,
-
分区过程(前后指针遍历与调整):
- 遍历元素 :
cur从prev + 1开始,逐个遍历到right(覆盖整个待分区区间)。 - 判断并纳入小于区域 :
- 若
cur指向的元素(parr[cur])小于基准值(parr[keyi]),说明该元素应属于 "小于基准值区域":- 先将
prev向后移动一位(++prev),使prev指向 "小于区域" 的下一个位置(准备扩展区域); - 若
prev移动后与cur不重合(说明prev和cur之间存在 "大于等于基准值" 的元素),则交换parr[prev]和parr[cur]------ 交换后,cur指向的 "小于基准值" 元素被纳入prev所在的小于区域,prev仍为小于区域的最后一个位置。
- 先将
- 若
- 继续遍历 :无论是否交换,
cur都向后移动一位(cur++),继续检查下一个元素。
- 遍历元素 :
-
基准值就位 :当
cur遍历完所有元素(cur > right)后,prev恰好指向 "小于基准值区域" 的最后一个元素。此时将基准值(parr[keyi])与parr[prev]交换,基准值就被放到了 "小于区域" 和 "大于等于区域" 的分界处 ------ 左侧均为小于基准值的元素,右侧均为大于等于基准值的元素。最终返回prev(基准值的最终索引)。
2. 递归排序函数QuickSort:分治处理子区间
与其他快速排序版本逻辑一致,通过递归将大区间分解为小区间
核心总结
前后指针法通过prev维护 "小于基准值区域" 的边界,cur遍历寻找需纳入该区域的元素,通过 "移动prev+ 必要时交换" 的方式,高效划分区间。其逻辑直观,无需复杂的指针相遇判断,核心是通过指针配合动态扩展 "小于区域",最终将基准值放到正确位置,再递归完成整体排序。
快速排序算法的时间复杂度和空间复杂度
不论是 hoare版本 还是 挖坑法版本 和 前后指针法版本,这些版本算法的本质都是将 [left, right] 区间分为 [左区间] key [右区间],保证左区间小于或等于 key ,保证右区间大于或等于 key,所以拿 hoare 版本举例说明:
快速排序(Hoare 版本)的时间复杂度和空间复杂度分析,与其 "分治法" 的核心逻辑及分区操作的平衡性密切相关,具体如下:
时间复杂度
快速排序的时间复杂度主要取决于分区操作的平衡性 (即每次分区后左右子区间的大小是否接近),而 Hoare 版本的分区过程(双指针交替移动)本身的时间复杂度为O(n)(需遍历整个区间)。
1. 最好情况:每次分区都能将区间均匀分为两部分
若每次分区后,基准值恰好位于区间中间,左右子区间的大小大致相等(如n/2和n/2),则递归深度为log n(类似完全二叉树的高度)。
- 每一层递归的总分区时间为
O(n)(各子区间元素总和为n); - 总时间复杂度为 **
O(n log n)**。
2. 最坏情况:每次分区都将区间分为极端不平衡的两部分
若数组本身已完全有序(或逆序),且选择区间第一个元素作为基准值,每次分区后左区间为空,右区间为n-1个元素(或反之),此时递归深度为n(类似单链表的长度)。
- 第 1 层分区时间
O(n),第 2 层O(n-1),...,第n层O(1); - 总时间复杂度为 **
O(n²)**(求和为n + (n-1) + ... + 1 = n(n+1)/2)。
3. 平均情况:大多数场景下的分区相对平衡
通过数学分析,在随机数据分布下,快速排序的平均递归深度为log n,总时间复杂度由 "各层分区时间总和" 决定,最终为 **O(n log n)**。这也是快速排序在实际应用中高效的核心原因。
空间复杂度
Hoare 版本的分区操作是原地进行 的(仅通过双指针移动和有限次元素交换,无需额外数组存储元素),因此空间复杂度主要来自递归调用的栈空间(用于保存每层递归的区间边界参数)。
1. 最好情况:递归深度为log n
当分区均匀时,递归深度为log n(与完全二叉树高度一致),栈空间仅需存储log n层的参数,因此空间复杂度为 **O(log n)**。
2. 最坏情况:递归深度为n
当分区极端不平衡时,递归深度为n(与单链表长度一致),栈空间需存储n层的参数,因此空间复杂度为 **O(n)**。
总结(Hoare 版本)
-
时间复杂度:
- 最好情况:
O(n log n)(分区均匀); - 最坏情况:
O(n²)(分区极端不平衡,如有序数组); - 平均情况:
O(n log n)(随机数据下的典型表现)。
- 最好情况:
-
空间复杂度:
- 最好情况:
O(log n)(递归栈深度为log n); - 最坏情况:
O(n)(递归栈深度为n)。
- 最好情况:
三数取中(规避快速排序算法最坏的情况)
三数取中代码实现
// 三数取中函数:从数组的左端点、右端点和中间点三个位置的元素中,选择值为中间大小的元素的索引
// 作用:用于快速排序中优化基准值的选择,避免因基准值为最值导致的效率下降
int GetMidIndex(int* parr, int left, int right)
{
// 1. 固定取中间位置的索引(左端点和右端点的中间)
int mid = (left + right) / 2;
// 2. 随机取中间位置的索引(在[left, right)范围内随机生成一个索引)
// int mid = left + (rand() % (right - left));
// 上述两种计算mid的方式二选一,目的是确定第三个比较位置
// 判断左端点元素是否为三个元素中的中间值:
// 若left位置元素大于mid位置元素且小于right位置元素,或小于mid位置元素且大于right位置元素,
// 则left位置元素是中间值,返回left
if ((parr[left] > parr[mid] && parr[left] < parr[right]) || (parr[left] < parr[mid] && parr[left] > parr[right]))
return left;
// 否则判断右端点元素是否为中间值:
// 若right位置元素大于mid位置元素且小于left位置元素,或小于mid位置元素且大于left位置元素,
// 则right位置元素是中间值,返回right
else if ((parr[right] > parr[mid] && parr[right] < parr[left]) || (parr[right] < parr[mid] && parr[right] > parr[left]))
return right;
// 若上述两种情况都不满足,则中间位置(mid)的元素是中间值,返回mid
else
return mid;
}
三数取中算法思想
三数取中算法的逻辑与原理(结合代码)
三数取中是快速排序中用于优化基准值选择的策略,核心目的是从区间的三个关键位置(左端点、右端点、中间点)中,选出值为 "中间大小" 的元素作为基准值,避免因基准值是区间的最大值或最小值而导致的分区失衡。其具体逻辑如下:
1. 确定三个比较位置
函数首先确定需要比较的三个位置:
- 左端点 :
left(区间起始索引); - 右端点 :
right(区间结束索引); - 中间点 :
mid(可通过两种方式确定,二选一):- 固定中间位置:
mid = (left + right) / 2(左端点和右端点的算术中间索引); - 随机中间位置:
mid = left + (rand() % (right - left))(在[left, right)范围内随机生成一个索引,增加随机性)。
- 固定中间位置:
2. 选择中间值对应的索引
通过条件判断,从三个位置(left、mid、right)的元素中,选出值为 "中间大小" 的元素的索引:
- 若
parr[left]的值介于parr[mid]和parr[right]之间(即大于其中一个且小于另一个),则left是中间值的索引,返回left; - 否则,若
parr[right]的值介于parr[mid]和parr[left]之间,返回right; - 若前两者都不满足,则
parr[mid]必然是中间值,返回mid。
为什么三数取中能规避快速排序的最坏情况?
快速排序的最坏情况(时间复杂度退化为O(n²))通常发生在每次分区选择的基准值是当前区间的最大值或最小值 ,导致分区后两个子区间极端不平衡(一个子区间为空,另一个子区间仅比原区间少 1 个元素),递归深度变为O(n)(如有序数组中始终选择第一个元素作为基准值)。
三数取中通过以下方式规避这种情况:
- 降低选中最值的概率:三个位置的元素中,"中间值" 成为区间最大值或最小值的概率远低于随机选择一个位置(尤其是区间较大时)。例如,在有序数组中,左端点是最小值、右端点是最大值,三数取中会选择中间点的元素(非最值)作为基准值,避免了 "选最值" 的问题;
- 保证分区平衡性 :由于基准值是三个位置中的中间值,大概率不会是区间的最值,因此分区后左右子区间的大小会相对均衡(不会出现一个为空的极端情况),递归深度可稳定在
O(log n),时间复杂度保持在O(n log n)附近。
核心总结
三数取中通过从区间的左、中、右三个位置中选择中间值作为基准值,显著降低了选中区间最值的概率,从而避免了快速排序中因分区极端不平衡导致的最坏情况,使排序效率更稳定。这是快速排序中最常用的优化手段之一,尤其适用于数据可能接近有序的场景。
三数取中算法的使用
int PartSort_BeforeAfterPointer(int* parr, int left, int right) //前后指针法版本
{
// 三数取中
int midi = GetMidIndex(parr, left, right);
Swap(&parr[left], &parr[midi]);
// ......
}
int PartSort_DigHole(int* parr, int left, int right) //挖坑法版本
{
// 三数取中
int midi = GetMidIndex(parr, left, right);
Swap(&parr[left], &parr[midi]);
// ......
}
int PartSort_Hoare(int* parr, int left, int right) //Hoare版本
{
// 三数取中
int midi = GetMidIndex(parr, left, right);
Swap(&parr[left], &parr[midi]);
// ......
}
在各个快速排序版本的分区函数中,首先要通过 GetMidIndex 函数计算出中间值的下标 midi,然后用 Swap 函数交换 parr[midi] 与 parr[left];这样做的原因是,这些分区函数均默认选择 left 位置的元素作为基准值。
快速排序(非递归)
快速排序(非递归)代码实现
// 快速排序的非递归实现:使用栈模拟递归过程,避免递归调用带来的栈开销
void QuickSortNonR(int* parr, int begin, int end)
{
Stack s; // 定义栈,用于保存待排序区间的起始和结束索引(模拟递归调用栈)
InitStack(&s); // 初始化栈
// 将初始待排序区间[begin, end]的起始和结束索引压入栈
// 注意:栈是先进后出结构,先压起始索引,再压结束索引,后续弹出时先取结束索引
STPush(&s, begin);
STPush(&s, end);
// 当栈不为空时,说明还有待排序的区间,继续处理
while (!STEmpty(&s))
{
// 弹出栈顶的结束索引(当前待排序区间的end)
int topEnd = STTop(&s);
STPop(&s);
// 弹出栈顶的起始索引(当前待排序区间的begin)
int topBegin = STTop(&s);
STPop(&s);
// 对当前区间[topBegin, topEnd]进行分区,获取基准值的最终索引keyi
int keyi = PartSort_Hoare(parr, topBegin, topEnd);
// 若基准值右侧区间[keyi+1, topEnd]有效(存在至少一个元素),则将该区间压入栈
if (keyi + 1 < topEnd)
{
STPush(&s, keyi + 1); // 压入右侧区间的起始索引
STPush(&s, topEnd); // 压入右侧区间的结束索引
}
// 若基准值左侧区间[topBegin, keyi-1]有效(存在至少一个元素),则将该区间压入栈
if (keyi - 1 > topBegin)
{
STPush(&s, topBegin); // 压入左侧区间的起始索引
STPush(&s, keyi - 1); // 压入左侧区间的结束索引
}
}
STDestroy(&s); // 排序完成后,销毁栈释放资源
}
栈相关函数请见: 数据结构 --------- C语言实现数组栈_c语言数组实现栈-CSDN博客
快速排序(非递归)算法思想
快速排序(非递归)的逻辑与原理(结合代码)
快速排序的非递归实现核心是用 "栈" 模拟递归过程 :递归版本中,函数调用栈会自动保存每次分区后需要处理的子区间边界;而非递归版本则通过手动定义一个栈,显式存储待排序区间的[begin, end]索引,通过 "弹出区间→分区→压入有效子区间" 的循环,完成与递归版本完全一致的排序逻辑。其优势是避免了递归调用可能导致的栈溢出(尤其对大规模数据),且逻辑更可控。
核心逻辑解析
- 栈的作用 :存储待排序区间的起始(
begin)和结束(end)索引,利用栈 "先进后出" 的特性,确保子区间的处理顺序与递归一致(先处理后压入的子区间,即深度优先)。 - 流程步骤 :
- 初始化 :将整个数组的初始区间
[begin, end]压入栈(先压begin,再压end,因栈先进后出,弹出时需先取end再取begin)。 - 循环处理 :当栈不为空时,弹出栈顶区间
[topBegin, topEnd],用 Hoare 分区法对其分区,得到基准值的最终索引keyi。 - 压入子区间 :若基准值左侧
[topBegin, keyi-1]或右侧[keyi+1, topEnd]存在有效元素(区间内至少 1 个元素),则将这些子区间压入栈。 - 终止条件:栈为空时,所有区间均已排序,数组有序。
- 初始化 :将整个数组的初始区间
示例过程:对parr = [5,3,1,2,4](升序)非递归快速排序
数组长度size=5,初始区间begin=0,end=4,步骤如下(跟踪栈状态和数组变化):
初始状态
- 数组:
[5,3,1,2,4] - 栈初始化后,压入
begin=0和end=4,栈内元素(从上到下):4, 0(栈顶为 4)。
第 1 次循环:处理区间[0,4]
- 弹出区间 :先弹栈顶
4(topEnd=4),再弹0(topBegin=0),当前处理[0,4]。 - 分区(Hoare 法) :基准值为
parr[0]=5,right向左找 < 5 的元素(停在index=4,值 4),left向右找 > 5 的元素(未找到,与right相遇于index=4)。交换基准值(index=0)与相遇位置(index=4),数组变为[4,3,1,2,5],基准值5的最终索引keyi=4。 - 压入有效子区间 :
- 右侧区间
[keyi+1=5, topEnd=4]无效(5>4),不压入; - 左侧区间
[topBegin=0, keyi-1=3]有效(0<3),压入0和3,栈内元素(从上到下):3, 0。
- 右侧区间
第 2 次循环:处理区间[0,3]
- 弹出区间 :弹栈顶
3(topEnd=3),再弹0(topBegin=0),当前处理[0,3]。 - 分区(Hoare 法) :基准值为
parr[0]=4,right向左找 < 4 的元素(停在index=3,值 2),left向右找 > 4 的元素(未找到,与right相遇于index=3)。交换基准值(index=0)与相遇位置(index=3),数组变为[2,3,1,4,5],基准值4的最终索引keyi=3。 - 压入有效子区间 :
- 右侧区间
[4,3]无效,不压入; - 左侧区间
[0,2]有效(0<2),压入0和2,栈内元素:2, 0。
- 右侧区间
第 3 次循环:处理区间[0,2]
- 弹出区间 :弹栈顶
2(topEnd=2),再弹0(topBegin=0),当前处理[0,2]。 - 分区(Hoare 法) :基准值为
parr[0]=2,right向左找 < 2 的元素(停在index=2,值 1),left向右找 > 2 的元素(停在index=1,值 3)。交换left=1和right=2,数组变为[2,1,3,4,5];继续循环,right左移至1(与left相遇)。交换基准值(index=0)与相遇位置(index=1),数组变为[1,2,3,4,5],基准值2的最终索引keyi=1。 - 压入有效子区间 :
- 右侧区间
[2,2]有效(2=2),压入2和2; - 左侧区间
[0,0]有效(0=0),压入0和0;栈内元素(从上到下):0,0,2,2。
- 右侧区间
第 4 次循环:处理区间[0,0]
- 弹出区间 :弹栈顶
0(topEnd=0),再弹0(topBegin=0),区间[0,0]仅含元素1(已有序)。 - 无有效子区间,不压入栈,栈内元素:
2,2。
第 5 次循环:处理区间[2,2]
- 弹出区间 :弹栈顶
2(topEnd=2),再弹2(topBegin=2),区间[2,2]仅含元素3(已有序)。 - 无有效子区间,不压入栈,栈为空。
最终结果
数组经过上述循环处理后,变为升序:[1,2,3,4,5]。
核心总结
非递归快速排序通过栈显式存储待排序区间,完全模拟了递归版本的 "分区→处理左子区间→处理右子区间" 流程,只是将递归调用的隐式栈替换为手动管理的显式栈。其逻辑与递归版本一致,但避免了递归可能带来的栈空间限制,更适合大规模数据排序。
快速排序算法(非递归)的时间复杂度和空间复杂度
快速排序(非递归版本)的时间复杂度和空间复杂度,与递归版本的核心逻辑密切相关(非递归仅用栈模拟递归流程,核心分区操作不变),具体分析如下:
时间复杂度
非递归版本的时间复杂度与递归版本完全一致,因为两者的核心操作(分区过程)和分区次数完全相同,仅递归调用被栈模拟替代,不影响元素的比较和交换次数。
-
最好情况 :每次分区能将区间均匀分为两部分(左右子区间大小接近),分区层数为
O(log n)(类似完全二叉树高度)。每一层的总分区时间为O(n)(所有子区间元素总和为n),因此总时间复杂度为O(n log n)。 -
最坏情况 :每次分区后区间极端不平衡(如有序数组中基准值为最值,左区间为空,右区间为
n-1个元素),分区层数为O(n)(类似单链表长度)。总时间复杂度为O(n²)(各层分区时间总和为n + (n-1) + ... + 1 = O(n²))。 -
平均情况 :在随机数据分布下,分区相对平衡,平均层数为
O(log n),总时间复杂度为O(n log n)。
空间复杂度
非递归版本的空间复杂度主要来自显式栈存储的待排序区间索引(替代了递归版本的函数调用栈),空间复杂度取决于栈中同时存储的区间数量(即分区的深度)。
-
最好情况 :分区均匀时,栈中最多存储
O(log n)个区间(每层递归对应一个区间,深度为log n),因此空间复杂度为O(log n)。 -
最坏情况 :分区极端不平衡时,栈中最多存储
O(n)个区间(深度为n),因此空间复杂度为O(n)。
总结
- 时间复杂度 :与递归版本一致,最好和平均情况
O(n log n),最坏情况O(n²)(核心分区逻辑不变)。 - 空间复杂度 :由显式栈的大小决定,最好情况
O(log n),最坏情况O(n)(替代了递归栈的空间开销,量级相同)。
非递归版本的优势是避免了递归调用可能导致的栈溢出(尤其对大规模数据),但空间复杂度的量级与递归版本一致。
快速排序(三路划分)
快速排序(三路划分)代码实现
// 快速排序(三路划分版本):
// 通过三数取中选择基准值,将数组划分为小于、等于、大于基准值的三部分,优化重复元素较多的场景
void QuickSort_MedianOfThree(int* parr, int begin, int end)
{
// 递归终止条件:若待排序区间为空(begin >= end),直接返回
if (begin >= end)
return;
// 定义三个指针:
// left:标记"小于基准值"区域的结束位置(初始为区间起始)
// right:标记"大于基准值"区域的开始位置(初始为区间结束)
// cur:当前遍历元素的索引(从left的下一个位置开始)
int left = begin;
int right = end;
int cur = left + 1;
// 三数取中:从区间的左、中、右三个位置选中间值作为基准值,优化基准值选择
int midi = GetMidIndex(parr, left, right);
// 将选中的中间值交换到left位置,此时left位置元素作为基准值
Swap(&parr[left], &parr[midi]);
// 保存基准值(key)
int key = parr[left];
// 遍历区间内从cur到right的元素,进行三路划分
while (cur <= right)
{
// 情况1:当前元素小于基准值
if (parr[cur] < key)
{
// 将当前元素交换到"小于基准值"区域的末尾(left位置)
Swap(&parr[cur], &parr[left]);
// "小于基准值"区域扩大(left后移),继续遍历下一个元素(cur后移)
cur++;
left++;
}
// 情况2:当前元素大于基准值
else if (parr[cur] > key)
{
// 将当前元素交换到"大于基准值"区域的开头(right位置)
Swap(&parr[cur], &parr[right]);
// "大于基准值"区域扩大(right前移),cur不变(需重新判断交换过来的新元素)
right--;
}
// 情况3:当前元素等于基准值
else
{
// 直接跳过(等于基准值的元素留在中间区域),继续遍历下一个元素
cur++;
}
}
// 递归处理"小于基准值"的区间[begin, left-1]
QuickSort_MedianOfThree(parr, begin, left - 1);
// 递归处理"大于基准值"的区间[right+1, end]
// (中间[left, right]区域为等于基准值的元素,已无需排序)
QuickSort_MedianOfThree(parr, right + 1, end);
}
快速排序(三路划分)算法思想
快速排序(三路划分)的逻辑与原理(结合代码)
三路划分快速排序是对传统快速排序的优化,核心思想是将数组划分为三个区间:小于基准值、等于基准值、大于基准值,通过减少重复元素参与递归的次数,显著优化 "存在大量重复数据" 场景的排序效率。其逻辑和原理如下:
1. 核心逻辑解析
- 分区目标 :将待排序区间
[begin, end]划分为三部分:[begin, left-1]:所有元素小于基准值(key);[left, right]:所有元素等于基准值(key);[right+1, end]:所有元素大于基准值(key)。
- 指针作用 :
left:标记 "小于基准值" 区域的结束位置(初始为begin);right:标记 "大于基准值" 区域的开始位置(初始为end);cur:当前遍历元素的索引(从left+1开始,负责扫描整个区间)。
- 基准值选择 :通过 "三数取中"(
GetMidIndex)选择基准值,避免因基准值为最值导致的分区失衡。 - 遍历与划分 :
cur从左到右扫描元素,根据元素与基准值的关系(小于、等于、大于),通过交换调整left、right和cur的位置,最终形成三个区间。 - 递归处理:仅对 "小于基准值" 和 "大于基准值" 的区间递归排序,"等于基准值" 的区间已无需处理(天然有序)。
2. 三路划分的作用:解决大量重复数据问题
传统快速排序(二路划分)在遇到大量重复元素时,会将 "等于基准值" 的元素全部划入某一侧(左或右),导致子区间仍包含大量重复元素,递归次数增多,甚至退化为O(n²)。
三路划分通过将 "等于基准值" 的元素独立为中间区域,使其不再参与后续递归,直接减少了需要排序的元素数量:
- 重复元素越多,中间区域越大,递归处理的子区间越小,效率提升越明显;
- 避免了重复元素在子区间中反复被比较和交换,降低了时间复杂度(在重复元素极多时可接近
O(n))。
示例过程:对parr = [2,3,3,5,3,1](升序)三路划分快速排序
数组长度size=6,初始区间begin=0,end=5,步骤如下:
第一轮:处理区间[0,5](元素:[2,3,3,5,3,1])
-
步骤 1:三数取中选基准值
- 左(
0,值 2)、中(mid=(0+5)/2=2,值 3)、右(5,值 1); - 三个值为
2,3,1,中间值为2(1<2<3),故midi=0; - 交换后基准值仍在
left=0(key=2)。
- 左(
-
步骤 2:三路划分(
left=0,right=5,cur=1)cur=1:元素3>key=2→ 交换cur=1与right=5(值 1),数组变为[2,1,3,5,3,3];right=4,cur保持 1。cur=1:元素1<key=2→ 交换cur=1与left=0(值 2),数组变为[1,2,3,5,3,3];left=1,cur=2。cur=2:元素3>key=2→ 交换cur=2与right=4(值 3),数组不变(3=3);right=3,cur=2。cur=2:元素3>key=2→ 交换cur=2与right=3(值 5),数组变为[1,2,5,3,3,3];right=2,cur=2。cur=2:元素5>key=2→ 交换cur=2与right=2(自身),right=1;此时cur=2 > right=1,循环结束。
-
划分结果:
- 小于区域:
[begin, left-1] = [0,0](元素1); - 等于区域:
[left, right] = [1,1](元素2); - 大于区域:
[right+1, end] = [2,5](元素5,3,3,3)。
- 小于区域:
第二轮:递归处理大于区域[2,5](元素:[5,3,3,3])
-
步骤 1:三数取中选基准值
- 左(
2,值 5)、中(mid=(2+5)/2=3,值 3)、右(5,值 3); - 三个值为
5,3,3,中间值为3(3<3<5),故midi=3; - 交换
left=2与midi=3,数组变为[1,2,3,5,3,3],基准值key=3。
- 左(
-
步骤 2:三路划分(
left=2,right=5,cur=3)cur=3:元素5>key=3→ 交换cur=3与right=5(值 3),数组变为[1,2,3,3,3,5];right=4,cur=3。cur=3:元素3=key=3→cur=4。cur=4:元素3=key=3→cur=5;此时cur=5 > right=4,循环结束。
-
划分结果:
- 小于区域:
[2,1](无效,因left=2 > left-1=1); - 等于区域:
[left, right] = [2,4](元素3,3,3); - 大于区域:
[5,5](元素5)。
- 小于区域:
第三轮:递归处理剩余区间
- 小于区域
[0,0]和大于区域[5,5]均为单个元素,递归直接返回,排序完成。
最终结果
数组经过三路划分排序后,变为升序:[1,2,3,3,3,5]。
核心总结
三路划分通过将数组分为 "小于、等于、大于基准值" 三部分,使重复元素集中在中间区域且不参与递归,大幅优化了重复数据场景的效率。其核心是减少无效递归和比较,在重复元素较多时优势显著,是快速排序应对复杂数据分布的重要优化手段。
快速排序(三路划分)与 快速排序(hoare版本)时间、空间复杂度比较
快速排序的三路划分版本与 Hoare 版本(二路划分)的时间复杂度和空间复杂度在量级上是一致的,但在特定场景(如存在大量重复元素)下,三路划分的实际效率会更高。具体分析如下:
时间复杂度对比
两者的时间复杂度量级相同,核心由 "分区操作的平衡性" 和 "递归深度" 决定,但三路划分在重复元素较多时能减少无效操作:
-
最好情况 :均为
O(n log n)当分区均匀时(左右子区间大小接近),递归深度为O(log n),每层分区操作的总时间为O(n)(遍历所有元素),因此总时间复杂度为O(n log n)。 -
平均情况 :均为
O(n log n)在随机数据分布下,两种版本的分区都能保持相对平衡,递归深度稳定在O(log n),总时间复杂度由 "各层分区时间总和" 决定,均为O(n log n)。 -
最坏情况 :理论上均为
O(n²)- Hoare 版本:当数组有序(或含大量重复元素)且基准值选择不佳时,分区会极端不平衡(子区间大小为
n-1),递归深度为O(n),总时间退化为O(n²)。 - 三路划分版本:若数组无重复元素且基准值选择不佳(如始终选最值),分区效果与 Hoare 版本一致,最坏时间复杂度仍为
O(n²);但当存在大量重复元素时 ,三路划分会将重复元素划入中间 "等于基准值" 区域,不参与后续递归,实际最坏情况会优于 Hoare 版本(接近O(n))。
- Hoare 版本:当数组有序(或含大量重复元素)且基准值选择不佳时,分区会极端不平衡(子区间大小为
空间复杂度对比
两者的空间复杂度完全一致,均由递归调用的栈空间决定:
-
最好情况 :均为
O(log n)当分区均匀时,递归深度为O(log n),栈空间仅需存储O(log n)层的区间参数。 -
最坏情况 :均为
O(n)当分区极端不平衡时,递归深度为O(n),栈空间需存储O(n)层的参数。
核心区别:实际效率的优化
三路划分版本的优势并非改变复杂度量级,而是在存在大量重复元素时减少无效递归和比较操作:
- Hoare 版本(二路划分)会将 "等于基准值" 的元素全部划入某一侧(左或右),导致这些元素在后续递归中被反复处理;
- 三路划分将 "等于基准值" 的元素独立为中间区域,使其不参与后续递归,直接减少了需要排序的元素数量,在重复元素较多时实际运行时间更短(但复杂度量级不变)。
总结
- 时间复杂度 :量级相同(最好、平均
O(n log n),最坏O(n²)),但三路划分在大量重复元素场景下实际效率更高。 - 空间复杂度 :完全一致(最好
O(log n),最坏O(n))。
两者的核心差异在于对重复元素的处理效率,而非复杂度量级。
归并排序
归并排序代码实现
// 归并排序的递归辅助函数:实现归并排序的核心逻辑(分治与合并)
void _MergeSort(int* parr, int* tmp, int begin, int end)
{
// 递归终止条件:若待排序区间为空(begin >= end),直接返回(单个元素或空区间无需排序)
if (begin >= end)
return;
// 计算区间中点,将当前区间分为左右两个子区间
int mid = (begin + end) / 2;
// 递归排序左子区间 [begin, mid]
_MergeSort(parr, tmp, begin, mid);
// 递归排序右子区间 [mid+1, end]
_MergeSort(parr, tmp, mid + 1, end);
// 合并两个已排序的子区间:[begin, mid] 和 [mid+1, end]
// 定义左子区间的起止索引
int begin1 = begin, end1 = mid;
// 定义右子区间的起止索引
int begin2 = mid + 1, end2 = end;
// i 用于记录临时数组 tmp 中当前待填充的位置(从区间起始位置 begin 开始)
int i = begin;
// 同时遍历两个子区间,将较小的元素依次放入临时数组 tmp
while ((begin1 <= end1) && (begin2 <= end2))
{
// 若左子区间当前元素 <= 右子区间当前元素,取左子区间元素放入 tmp
if (parr[begin1] <= parr[begin2])
tmp[i++] = parr[begin1++];
// 否则取右子区间元素放入 tmp
else
tmp[i++] = parr[begin2++];
}
// 左子区间还有剩余元素,将剩余元素依次放入 tmp
while (begin1 <= end1)
tmp[i++] = parr[begin1++];
// 右子区间还有剩余元素,将剩余元素依次放入 tmp
while (begin2 <= end2)
tmp[i++] = parr[begin2++];
// 将临时数组 tmp 中合并好的有序区间 [begin, end] 复制回原数组 parr 的对应位置
memcpy(parr + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
// 归并排序主函数(递归实现):对数组进行升序排序
void MergeSort(int* parr, int size)
{
// 分配临时数组 tmp,用于合并过程中暂存元素(大小与原数组相同)
int* tmp = (int*)malloc(sizeof(int) * size);
// 检查内存分配是否成功,失败则打印错误信息并返回
if (tmp == NULL)
{
perror("MergeSort.malloc");
return;
}
// 调用递归辅助函数,从区间 [0, size-1] 开始排序
_MergeSort(parr, tmp, 0, size - 1);
// 排序完成后,释放临时数组的内存
free(tmp);
}
归并排序算法思想
归并排序的逻辑与原理(结合代码)
归并排序是典型的 "分治法" 排序算法,核心思想是:将数组不断 "分解" 为两个等大(或接近等大)的子区间,直到每个子区间仅含一个元素(天然有序),再逐步 "合并" 这些有序子区间,最终得到完整的有序数组。其核心是 "合并" 操作 ------ 将两个已排序的子区间高效合并为一个更大的有序区间。
1. 核心逻辑解析
归并排序的流程分为 "分解" 和 "合并" 两个阶段,代码通过递归实现:
-
分解阶段(递归拆分) :在辅助函数
_MergeSort中,通过计算区间中点mid = (begin + end) / 2,将当前区间[begin, end]拆分为左子区间[begin, mid]和右子区间[mid+1, end],并递归对两个子区间排序,直到子区间为空(begin >= end,即单个元素或空区间,无需排序)。 -
合并阶段(有序合并):当左右子区间均已排序后,通过以下步骤合并为一个有序区间:
- 定义左子区间边界
[begin1, end1]和右子区间边界[begin2, end2]; - 使用临时数组
tmp暂存合并结果:同时遍历两个子区间,将较小的元素依次放入tmp; - 将剩余未遍历完的子区间元素(左或右)依次放入
tmp; - 用
memcpy将tmp中合并好的有序区间复制回原数组parr的对应位置,完成合并。
- 定义左子区间边界
-
临时数组的作用 :合并时若直接在原数组操作,会覆盖未处理的元素,因此
tmp用于暂存中间结果,确保合并过程正确。
示例过程:对parr = [5,3,1,2,4](升序)归并排序
数组长度size=5,初始调用MergeSort(parr, 5),分配临时数组tmp后,进入_MergeSort(parr, tmp, 0, 4),步骤如下:
阶段 1:分解(递归拆分区间)
分解过程是 "自顶向下" 的,将大区间逐步拆分为最小子区间(单个元素):
- 初始区间
[0,4]→ 拆分为左[0,2]和右[3,4];- 左
[0,2]→ 拆分为左[0,1]和右[2,2];- 左
[0,1]→ 拆分为左[0,0](元素 5)和右[1,1](元素 3); - 右
[2,2](元素 1)→ 无需拆分(递归终止);
- 左
- 右
[3,4]→ 拆分为左[3,3](元素 2)和右[4,4](元素 4)→ 均无需拆分(递归终止)。
- 左
阶段 2:合并(自底向上合并有序子区间)
合并过程从最小子区间开始,逐步向上合并为更大的有序区间:
合并[0,0](5)和[1,1](3)→ 得到[3,5]
- 左子区间
begin1=0, end1=0(5),右子区间begin2=1, end2=1(3); - 遍历比较:3 < 5 →
tmp[0] = 3(begin2=2,右区间结束); - 剩余左区间元素:
tmp[1] = 5(begin1=1,左区间结束); tmp中[0,1]为[3,5],复制回parr→parr变为[3,5,1,2,4]。
合并[0,1](3,5)和[2,2](1)→ 得到[1,3,5]
- 左子区间
begin1=0, end1=1(3,5),右子区间begin2=2, end2=2(1); - 遍历比较:1 < 3 →
tmp[0] = 1(begin2=3,右区间结束); - 剩余左区间元素:
tmp[1] = 3,tmp[2] = 5(begin1=2,左区间结束); tmp中[0,2]为[1,3,5],复制回parr→parr变为[1,3,5,2,4]。
合并[3,3](2)和[4,4](4)→ 得到[2,4]
- 左子区间
begin1=3, end1=3(2),右子区间begin2=4, end2=4(4); - 遍历比较:2 < 4 →
tmp[3] = 2(begin1=4,左区间结束); - 剩余右区间元素:
tmp[4] = 4(begin2=5,右区间结束); tmp中[3,4]为[2,4],复制回parr→parr变为[1,3,5,2,4](原右区间位置更新)。
合并[0,2](1,3,5)和[3,4](2,4)→ 得到[1,2,3,4,5]
- 左子区间
begin1=0, end1=2(1,3,5),右子区间begin2=3, end2=4(2,4); - 遍历比较:
- 1 < 2 →
tmp[0] = 1(begin1=1); - 3 > 2 →
tmp[1] = 2(begin2=4); - 3 < 4 →
tmp[2] = 3(begin1=2); - 5 > 4 →
tmp[3] = 4(begin2=5,右区间结束);
- 1 < 2 →
- 剩余左区间元素:
tmp[4] = 5(begin1=3,左区间结束); tmp中[0,4]为[1,2,3,4,5],复制回parr→ 最终数组变为[1,2,3,4,5]。
最终结果
数组[5,3,1,2,4]经过归并排序后,变为升序[1,2,3,4,5]。
核心总结
归并排序通过 "分治法" 将数组分解为最小子区间,再通过 "有序合并" 逐步构建完整有序数组,核心是合并两个有序子区间的高效操作。
归并排序算法的时间复杂度和空间复杂度
归并排序的时间复杂度和空间复杂度与其 "分治法" 的核心逻辑密切相关,具体分析如下:
时间复杂度
归并排序的时间复杂度由 "分解" 和 "合并" 两个阶段共同决定,且不受输入数据的初始顺序影响(最好、最坏、平均情况完全一致)。
-
分解阶段 :将数组不断拆分为两个等大(或接近等大)的子区间,直到子区间仅含 1 个元素(天然有序)。对于长度为
n的数组,分解的层数为log₂n(类似完全二叉树的高度),例如n=8时需要分解 3 层(8→4→2→1),分解过程本身不涉及元素比较,时间开销可忽略。 -
合并阶段 :每一层的合并操作需要遍历当前层的所有元素(将两个有序子区间合并为一个有序区间),总操作次数为
O(n)(每一层的元素总数始终是n)。 -
总时间复杂度 :分解层数为
O(log n),每层合并时间为O(n),因此总时间复杂度为O(n log n),且在最好、最坏、平均情况下均保持一致(这是归并排序的显著优势)。
空间复杂度
归并排序的空间复杂度主要来自合并阶段所需的临时数组 和递归调用的栈空间,其中临时数组是主导因素。
-
临时数组 :合并两个有序子区间时,需要一个与原数组大小相同的临时数组(
tmp)暂存合并结果(避免覆盖原数组中未处理的元素),因此临时数组的空间开销为O(n)。 -
递归栈空间 :递归分解过程中,函数调用栈的深度为
O(log n)(与分解层数一致),栈空间开销为O(log n)。 -
总空间复杂度 :由于临时数组的空间开销(
O(n))远大于递归栈空间(O(log n)),因此归并排序的空间复杂度为O(n)。
总结
- 时间复杂度 :
O(n log n)(最好、最坏、平均情况完全一致,不受输入数据顺序影响); - 空间复杂度 :
O(n)(主要来自合并阶段的临时数组)。
归并排序的优势是时间复杂度稳定且为稳定排序(相等元素的相对顺序不变),但缺点是需要额外的O(n)空间,不适合对内存空间敏感的场景。
小区间优化(规避归并排序递归太深的情况)
小区间优化代码实现
// 小区间优化:当待排序区间的元素个数(end - begin + 1)小于10时
// 不再继续递归进行归并排序,而是改用直接插入排序
if (end - begin + 1 < 10)
{
// 对当前区间[begin, end]使用直接插入排序(参数为区间起始地址和元素个数)
InsertSort(parr + begin, end - begin + 1);
// 排序完成后返回,不再继续递归分治
return;
}
小区间优化算法思想
小区间优化的逻辑与原理
小区间优化是归并排序中针对 "小范围数据排序效率" 的优化策略,核心逻辑是:当待排序区间的元素数量小于某个阈值(如代码中的 10)时,不再继续递归执行归并排序的 "分解 - 合并" 流程,而是改用直接插入排序对该区间进行排序。
其原理基于 "不同排序算法的效率特性差异":归并排序的O(n log n)时间复杂度是理论上的渐进复杂度(适用于大数据量),但它包含递归调用(栈开销)和合并操作(临时数组读写、遍历比较)等固定开销;而当数据量极小时,这些固定开销在总耗时中的占比会显著升高,导致归并排序的实际效率反而低于一些简单排序算法。因此,对小范围数据切换为更轻量的排序算法,可减少整体耗时。
为什么选择直接插入排序而非其他排序
在小范围数据场景下,直接插入排序相比冒泡排序、选择排序等简单排序更适合,原因如下:
- 操作轻量:直接插入排序的核心是 "找到插入位置并移动元素",仅涉及简单的循环和赋值,无频繁交换(相比冒泡排序)或多次遍历找最值(相比选择排序),常数因子(实际执行的指令数)更小。
- 适应性好:若小范围数据本身接近有序(实际场景中常见),直接插入排序的比较和移动次数会大幅减少,效率优势更明显;而冒泡、选择排序的比较 / 交换次数相对固定,适应性较差。
- 无额外空间 :直接插入排序是原地排序(空间复杂度
O(1)),无需像归并排序那样依赖临时数组,在小范围数据上的空间开销可忽略,进一步降低了操作成本。
小区间优化如何解决归并排序递归太深的问题
归并排序的递归深度由数据量决定,对于规模为n的数组,递归深度为O(log n)(每次分解为两半)。当n极大时(如百万级以上),递归深度会显著增加(如n=10^6时,深度约 20),可能导致函数调用栈空间紧张(甚至栈溢出)。
小区间优化通过设定阈值(如 10),限制了递归的最大深度:当区间元素数量小于阈值时,停止递归,改用非递归的直接插入排序。此时,递归仅需分解到 "区间大小≈阈值" 的层级即可,递归深度变为O(log(n/阈值)),相比原深度大幅减少(如n=10^6、阈值 = 10 时,深度约log(10^5)≈17,减少了不必要的深层递归),从而避免了递归太深导致的栈空间问题。
小区间优化算法的使用
void _MergeSort(int* parr, int* tmp, int begin, int end)
{
// 递归终止条件:若待排序区间为空(begin >= end),直接返回(单个元素或空区间无需排序)
if (begin >= end)
return;
// 小区间优化:当待排序区间的元素个数(end - begin + 1)小于10时
// 不再继续递归进行归并排序,而是改用直接插入排序
// 原因:直接插入排序在数据量较小时效率更高(递归和合并操作的开销相对更大)
// 此优化可减少归并排序在小范围数据上的性能损耗,提升整体排序效率
if (end - begin + 1 < 10)
{
// 对当前区间[begin, end]使用直接插入排序(参数为区间起始地址和元素个数)
InsertSort(parr + begin, end - begin + 1);
// 排序完成后返回,不再继续递归分治
return;
}
// ......
}
计数排序
计算排序代码实现
void CountSort(int* parr, int size)
{
// 初始化最大值和最小值为数组第一个元素,用于确定数据范围
int max = parr[0];
int min = parr[0];
// 遍历数组,更新最大值和最小值,找到数据的实际范围
for (int i = 0; i < size; i++)
{
if (parr[i] > max)
max = parr[i];
if (parr[i] < min)
min = parr[i];
}
// 计算计数数组的大小(范围 = 最大值 - 最小值 + 1)
int range = max - min + 1;
// 动态分配计数数组,calloc初始化所有元素为0
int* countArr = (int*)calloc(range, sizeof(int));
// 检查内存分配是否失败
if (countArr == NULL)
{
perror("CountSort.malloc"); // 打印错误信息
return;
}
// 统计每个元素出现的次数:将元素值映射为计数数组的索引(减去min)
for (int i = 0; i < size; i++)
countArr[parr[i] - min]++;
// 用于记录原数组中当前填充位置的索引
int parri = 0;
// 根据计数数组重建原数组,生成有序序列
for (int i = 0; i < range; i++)
{
// 当计数数组当前位置的计数大于0时,持续填充对应元素(i + min)
while (countArr[i]--)
{
parr[parri++] = i + min;
}
}
}
计数排序算法思想
计数排序的逻辑与原理(结合代码)
计数排序是一种非比较型排序算法 ,核心思想是通过 "统计每个元素的出现次数",再根据次数直接重建有序数组。它适用于整数类型数据 且数据范围(最大值与最小值的差值)相对较小 的场景,优势是时间复杂度极低(O(n + range)),但依赖于数据的分布范围。其逻辑和原理如下:
1. 核心逻辑解析
计数排序的流程可分为 4 个关键步骤:
-
步骤 1:确定数据范围 遍历待排序数组,找到最大值(
max)和最小值(min),计算数据的范围(range = max - min + 1)。这一步的目的是确定 "需要统计的数值区间",为后续创建计数数组提供大小依据。 -
步骤 2:创建计数数组 基于数据范围
range创建一个计数数组(countArr),用于记录每个元素出现的次数。数组初始化为 0(通过calloc分配内存,自动初始化为 0)。 -
步骤 3:统计元素出现次数 遍历原数组,将每个元素值映射为计数数组的索引(映射规则:
元素值 - min),并对该索引位置的计数加 1。例如,若min=1,元素值为 3,则映射到countArr[2](3-1=2),表示值为 3 的元素出现次数加 1。 -
步骤 4:根据计数数组重建有序数组 遍历计数数组,对于每个索引
i(对应元素值i + min),根据其计数countArr[i],将元素i + min重复countArr[i]次依次放入原数组,最终得到有序数组。
示例过程:对parr = [5,3,1,2,4](升序)计数排序
数组长度size=5,步骤如下:
步骤 1:确定数据范围(max和min)
- 初始
max = parr[0] = 5,min = parr[0] = 5; - 遍历数组:
parr[1]=3:3 < 5 →min=3;parr[2]=1:1 < 3 →min=1;parr[3]=2:2 > 1 但 < 5 →max和min不变;parr[4]=4:4 < 5 →max不变;
- 最终
max=5,min=1,数据范围range = 5 - 1 + 1 = 5。
步骤 2:创建计数数组
- 分配
range=5的计数数组countArr,初始化为[0,0,0,0,0](索引 0~4 分别对应元素值 1~5,因i + min = 0+1=1,1+1=2,...,4+1=5)。
步骤 3:统计元素出现次数
- 遍历原数组,映射索引并计数:
parr[0]=5:5 - 1 = 4→countArr[4]++→countArr变为[0,0,0,0,1];parr[1]=3:3 - 1 = 2→countArr[2]++→countArr变为[0,0,1,0,1];parr[2]=1:1 - 1 = 0→countArr[0]++→countArr变为[1,0,1,0,1];parr[3]=2:2 - 1 = 1→countArr[1]++→countArr变为[1,1,1,0,1];parr[4]=4:4 - 1 = 3→countArr[3]++→countArr最终为[1,1,1,1,1]。
步骤 4:重建有序数组
- 遍历
countArr,根据计数填充原数组:i=0(对应元素0+1=1):countArr[0]=1→ 填充parr[0]=1,parri=1;i=1(对应元素1+1=2):countArr[1]=1→ 填充parr[1]=2,parri=2;i=2(对应元素2+1=3):countArr[2]=1→ 填充parr[2]=3,parri=3;i=3(对应元素3+1=4):countArr[3]=1→ 填充parr[3]=4,parri=4;i=4(对应元素4+1=5):countArr[4]=1→ 填充parr[4]=5,parri=5。
最终结果
数组[5,3,1,2,4]经过计数排序后,变为升序[1,2,3,4,5]。
核心总结
计数排序通过 "统计次数→重建数组" 的方式实现排序,无需元素间的比较,因此时间效率极高。但其局限性在于:仅适用于整数,且数据范围(range)不能过大(否则计数数组会占用过多内存)。核心是利用 "元素值与计数数组索引的映射",将无序数据转化为有序的计数统计,再反向构建结果。
计数排序的时间复杂度和空间复杂度
计数排序的时间复杂度和空间复杂度与其 "统计元素次数并重建有序数组" 的核心逻辑密切相关,且高度依赖于数据的范围(即最大值与最小值的差值),具体分析如下:
时间复杂度
计数排序的时间消耗主要来自 4 个关键步骤,整体时间复杂度由 "数组长度n" 和 "数据范围range"(range = max - min + 1)共同决定:
- 确定最大值和最小值 :遍历整个数组(
n个元素),时间复杂度为O(n); - 创建计数数组 :内存分配操作的时间可忽略(不随
n或range增长); - 统计元素出现次数 :再次遍历整个数组(
n个元素),时间复杂度为O(n); - 重建有序数组 :遍历计数数组(
range个索引),并根据每个索引的计数填充原数组(总填充次数为n),时间复杂度为O(range + n)。
总时间复杂度为上述步骤的总和:O(n) + O(n) + O(range + n) = O(n + range)。
空间复杂度
计数排序的空间消耗主要来自计数数组的内存分配 ,其大小等于数据范围range(range = max - min + 1)。因此,额外空间复杂度为O(range)。
原数组本身的空间(O(n))不计入额外空间,因为排序过程是在原数组上重建结果,无需额外存储整个原数组的副本。
关键特性与局限性
- 当数据范围
range远小于数组长度n时(如range ≈ n),时间复杂度接近O(n),空间复杂度也较低(O(n)),此时计数排序效率极高; - 当数据范围
range远大于n时(如range = 10^6而n = 100),时间和空间复杂度会退化为O(range),远高于比较型排序的O(n log n),此时计数排序不适用。
总结
- 时间复杂度 :
O(n + range)(n为数组长度,range为数据范围max - min + 1); - 空间复杂度 :
O(range)(主要来自计数数组的额外空间)。
计数排序的效率优势仅在 "数据范围较小" 的场景下体现,这是其核心局限性。