tag排序-刷题预备知识-1.《快排, 堆排, 归并》最常见写法和优化,Java实现 1.5
文章目录
- 本文主要是对力扣中最常遇到的三大排序和各种考察角度进行了一个大致总结, 主要是因为笔者发现网上大多数帖子的有些排序都不是最优的写法, 在力扣中都需要进行一定的优化才能通过.
- 所以笔者还是推荐大家尽快掌握和理解力扣中常见的排序写法, 毕竟都是经过大量的测试用例检验过的, 还是相当有价值的.
一, 快排
1.0 快排基本写法-挖坑法
[思路分析]
基本思想是我们首先以序列的第一个元素为基准数,然后将该位置挖坑,下面判断 nums[hight] 是否大于基准数,如果大于则左移 hight 指针,直至找到一个小于基准数的元素,将其填入之前的坑中,则 hight 位置会出现一个新的坑,此时移动 low 指针,找到大于基准数的元素,填入新的坑中。不断迭代直至完成分区。
class Solution {
public int[] sortArray(int[] nums) {
quickSort(nums,0,nums.length-1);
return nums;
}
public void quickSort (int[] nums, int left, int right) {
if (left <right) { //递归出口
int partitionIndex = partition(nums, left, right); //基准值的 index
quickSort(nums, left, partitionIndex - 1);
quickSort(nums, partitionIndex + 1, right);
}
}
public int partition (int[] nums, int left, int right) {
int pivot = nums[left];
while (left < right) {
//遍历有部分(> 基准值的部分)
while(left < right && nums[right] >= pivot) --right; //右半部分的遍历
//遍历完了肯定有一个不符合条件的数(即应该属于左半部分的, 小于基准值的数),
// 我们把它填到基准值的坑位中
// 在这里要注意, 基准值nums[left]原先的值被暂存到pivot中了,
// 所以我们要把nums[right] 赋值给nums[left]
if(left < right) nums[left] = nums[right]; // 填坑
while(left < right && nums[left] <= pivot) ++left; //左半部分的遍历
//遍历完了肯定有一个不符合条件的数(即应该属于右半部分的, 大于基准值的数),
if(left < right) nums[right] = nums[left]; //填坑
}
//把基准值填到合适的坑位, left == right, 所以用谁都一样的
nums[left] = pivot;
return left; //返回此时基准值的索引, 在这个索引的左边都小于基准值, 右边都大于;
}
}
1.1 快排基本写法-左右双指针(left, right)
[思路分析]
其实这种方法,算是对上面方法的挖坑填坑步骤进行合并,low 指针找到大于 pivot 的元素,hight 指针找到小于 pivot 的元素,然后两个元素交换位置,最后再将基准数归位。
class Solution {
public int[] sortArray(int[] nums) {
quickSort(nums, 0, nums.length - 1);
return nums;
}
public int partition(int[] nums, int left, int right){
int pivot = nums[left]; //基准值
int tempLeft = left; // 基准值的索引
while(left < right){
//选择左边第一个基准值, 就要先移动右指针
// 另外为了防止移动过了头, 要加上判断条件 left < right
while(left < right && nums[right] >= pivot) --right;
while(left < right && nums[left] <= pivot) ++left;
//执行到这里, 说明 left,right 指针指代的值不小于或者不大于pivot了
swap(nums, left, right);
}
//安放基准值
swap(nums, left, tempLeft);
return left;
}
//工具方法
public void swap(int[] nums, int left, int right){
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
}
//排序方法
public void quickSort(int[] nums, int left, int right){
if(left < right){
int partitionIndex = partition(nums, left, right);
quickSort(nums, left, partitionIndex - 1);
quickSort(nums, partitionIndex + 1, right);
}
}
}
1.2, 快排的时空复杂度分析
A, 快排优化一, 随机基准值快排
采用以固定位置为基准值的算法,即选取第一个值或最后一个值为基准值,但是,对于快速排序分治算法,每次划分的两个子序列越趋近等长,算法效率越高,上述的这种选取基准值的方法在处理已经有序或基本有序的序列时,效率会大大降低,特别是面对完全有序序列是,时间复杂度会达到O(N2),与冒泡排序相当,所以,对于选取基准值的方式,可以进行优化:
优化方式1:随机选取基准值
在每次的待排序区间随机选取一个数做为基准值,以这个基准值来划分左右序列,但是这种方法优于第一种方式,但是也不太稳定,有可能出现多次选取的基准值刚好是待排序区间的最大值或最小值,这样的话面对有序序列时还是会出现第一种方式出现的问题,降低效率。
class Solution {
public int[] sortArray(int[] nums) {
quickSort(nums, 0, nums.length - 1);
return nums;
}
public int partition(int[] nums, int left, int right){
int pivot = nums[left]; //基准值
int tempLeft = left; // 基准值的索引
while(left < right){
//选择左边第一个基准值, 就要先移动右指针
// 另外为了防止移动过了头, 要加上判断条件 left < right
while(left < right && nums[right] >= pivot) --right;
while(left < right && nums[left] <= pivot) ++left;
//执行到这里, 说明 left或者right 指针指代的值不小于或者不大于pivot了
swap(nums, left, right);
}
//安放基准值
swap(nums, left, tempLeft);
return left;
}
// 随机基准值
public int randomPartitionIndex(int[] nums, int left, int right){
//new Random().nextInt(x) 作用是生成从 [0, x)的随机值, 注意不包括x哦!!!
//所以这个randomLeft的作用就是生成一个 left --> (right - left + 1)的索引范围
// 啰嗦一下, randomLeft是每一个待排子序列的 left --> right (因为快排是一个分治的过程!!
//所以我们要有这种变量上的普适性意识!!
int randomLeft = new Random().nextInt(right - left + 1) + left;
swap(nums, left, randomLeft);
return partition(nums, left, right);
}
//工具方法
public void swap(int[] nums, int left, int right){
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
}
//排序方法
public void quickSort(int[] nums, int left, int right){
if(left < right){
int partitionIndex = randomPartitionIndex(nums, left, right);
quickSort(nums, left, partitionIndex - 1);
quickSort(nums, partitionIndex + 1, right);
}
}
}
B, 快排优化三, 三数取中
优化方式:三数取中法选取基准值
选取待排序区间的第一个数据,最后一个数据和最中间的一个数据进行比较,并且保证arr[mid]>=arr[low]>=arr[high],这种方法可以在一定程度上提高处理有序序列时的效率。
class Solution {
public int[] sortArray(int[] nums) {
quickSort(nums, 0, nums.length- 1);
return nums;
}
/**
快速排序: 三数取中
意义何在? 对于快速排序, 待排序列越接近有序就意味着快速排序的性能越下降, 直至达到冒泡排序
*/
public void quickSort(int[] nums, int left, int right){
if(left >= right) return; //递归出口一定不要忘记
//基准值的位置
int partitionIndex = partition(nums, left, right);
//递归, 进行不断的子序列排序
// 注意这里基准值的索引是被排除在外的, 因为每一次快排都会使得基准值待在正确的位置上
//通过基准值的不断被放在正确位置, 待排序列得以越来越短,直到到达一个元素
quickSort(nums, left, partitionIndex - 1);
quickSort(nums, partitionIndex + 1, right);
}
public int partition(int[] nums, int left, int right){
//三数取中
int mid = left + ((right - left) >> 1);
// 这里这样写的原因是因为我们在此程序中把待排序列的第一个值作为基准值, 而基准值最好能够保证他的数在待排序列中是不大也不小的那种, 这样的话, 在一次快排后, 基准值左边的数(<基准值)和基准值右边的数(>基准值)的数量会比较均衡, 从而提高快排的效率;
if(nums[left] > nums[right]) swap(nums, left, right);
if(nums[mid] > nums[right]) swap(nums, mid, right); //我们在这里保证 mid + 1 --> right的值要大于nums[left]
if(nums[mid] > nums[left]) swap(nums, mid, left); // left处是基准值噢, 我们尽量保证left->mid - 1 的值要小于nums[left],
下面的代码跟常规的快排完全一致;
int pivot = nums[left];
int tempLeft = left;
//为什么left != right, 因为快排就是在每个区间内找基准值, 然后对其余的元素放在基准值左边或右边合适的位置, left == right了, 还有什么意义呢?
while(left < right){ //?
//这里一定要注意, 当我们选取左边第一个值为基准值时, 要先去移动右指针
// 只有这样才能保证 left指向的位置是小于基准值序列中的最后一个数
//这样才方便让 pivot基准值放在这一趟排序时的最终位置
while(left < right && nums[right] >= pivot) --right;
while(left < right && nums[left] <= pivot) ++left;
//left, right 任意一个不符合上面的循环了
swap(nums, left, right);
}
swap(nums, left, tempLeft);
return left;
}
public void swap(int[] nums, int left, int right){
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
}
}
1.3, 快排为什么越趋近于有序就越慢
- 一句话, 数据划分不均衡
快排也是用递归来实现的。所以快速排序的时间性能取决于快速排序的递归树的深度。如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那么此时的递归树是平衡的,性能也较好,递归树的深度也就和之前归并排序求解方法一致,然后我们每一次分区需要对数组扫描一遍,做 n 次比较,所以最优情况下,快排的时间复杂度是 O(nlogn)。
但是大多数情况下我们不能划分的很均匀,比如数组为正序或者逆序时,即 [1,2,3,4] 或 [4,3,2,1] 时,此时为最坏情况,那么此时我们则需要递归调用 n-1 次,此时的时间复杂度则退化到了 O(n^2)。
1.4 非递归快排
非递归:借助栈先进后出的性质
- 先进行一次快速排序,把一个基准值放在正确的位置;
- 判断该基准值左右子序列大小,若大小为0或1,说明已经在正确的位置,不作处理,否则将左右子序列的low值和high值依次入栈;
- 在栈不为空的条件下依次取栈顶元素,一次取两个,即取一个子序列的high和low,依次确定区间,对这个区间进行找基准值操作,同样满足基准值左侧小于它,右侧大于它;
- 若栈为空,说明已经没有待排序序列,即所有元素已排序完成:
1.5 链表快排
[思路分析]
核心代码思想类似于86题分隔链表,选取第一个为枢纽元然后进行链表分隔,小链表接比枢纽元小的数,大连表接比枢纽元大的数,然后 小链表 -> 枢纽元 -> 大连表就构成了一个排好序的链表。
具体实现中分别对 小链表 -> 枢纽元 -> null 和 大连表 -> null 进行递归处理,在对 小链表 -> 枢纽元 -> null 排序时,我们可以确定枢纽元的位置是不会被改变的,这样当递归返回时直接用之前记录的枢纽元接入大链表即可。
碰到 == 枢纽元时,需要加入大链表,这是因为我们会把枢纽元接到小链表后面进行下一轮递归,当元素相同时,这就造成了无法分割的死循环。
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
private ListNode tail = null;
public ListNode sortList(ListNode head) {
if (head == null || head.next == null) {
return head;
}
int pivot = head.val;
ListNode small = new ListNode(), large = new ListNode();
ListNode hSmall = small, hLarge = large, node = head.next;
while (node != null) {
int val = node.val;
if (val < pivot) {
small.next = node;
small = small.next;
} else {
large.next = node;
large = large.next;
}
node = node.next;
}
large.next = null;
small.next = head;
head.next = null;
small = sortList(hSmall.next);
large = sortList(hLarge.next);
head.next = large;
return small;
}
}
- https://www.jianshu.com/p/aa0ac621eac0
- https://www.bilibili.com/video/BV1HA411q7tH?p=3
二, 堆排序
public class Solution {
public int[] sortArray(int[] nums) {
int len = nums.length;
// 将数组整理成堆
heapify(nums);
// 循环不变量:区间 [0, i] 堆有序
for (int i = len - 1; i >= 1; ) {
// 把堆顶元素(当前最大)交换到数组末尾
swap(nums, 0, i);
// 逐步减少堆有序的部分
i--;
// 下标 0 位置下沉操作,使得区间 [0, i] 堆有序
siftDown(nums, 0, i);
}
return nums;
}
/**
* 将数组整理成堆(堆有序)
*
* @param nums
*/
//建立初始堆
private void heapify(int[] nums) {
int len = nums.length;
// 只需要从 i = (len - 1) / 2 这个位置开始逐层下移
// 即从这棵树的最左边最下边的一个非叶子结点, 逐渐向上移动知道到达根节点
for (int i = (len - 1) / 2; i >= 0; i--) {
siftDown(nums, i, len - 1); //i代表下沉的结点, len-1代表调整的结点个数
}
}
/**
* @param nums
* @param k 当前下沉元素的下标
* @param end [0, end] 是 nums 的有效部分
*/
//堆调整,
private void siftDown(int[] nums, int k, int end) {
//
while (2 * k + 1 <= end) {
int j = 2 * k + 1; // k的左孩子结点/右孩子结点
if (j + 1 <= end && nums[j + 1] > nums[j]) {
j++; //找出较大的那个孩子结点
}
if (nums[j] > nums[k]) { //孩子结点大于当前节点k, 则交换之
swap(nums, j, k);
} else {
break;
}
k = j;
}
}
private void swap(int[] nums, int index1, int index2) {
int temp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temp;
}
}
三, 归并排序
详细题解: 点我
class Solution {
public int[] sortArray(int[] nums) {
//归并排序
/***
1. 对于归并排序, 我们通过使用递归对待排序列进行不断地往下细分, 直到每个子问题是一个单独的值
2. 然后在递归进行返回的时候, 递归是如何返回的呢? 先是两个数,再是三个数, 直到返回整个待排序列, 在返回的同时我们需要对每次返回的数进行排序, 如何排序呢? 通过比较mid两边的值, 合适的值会被临时数组等待此单次排序完成后放入原数组;
*/
mergeSort(nums, 0, nums.length - 1);
return nums;
}
public void mergeSort(int[] nums, int left, int right){
if(left >= right)return; //mergeSort作用是不断的对待排数组进行划分, 直到变成一个个独立的数, left==right时就已经是单独的一个数了
int mid = left + ((right - left) >> 1); //每一次划分的序列的中间值
mergeSort(nums, left, mid); //划分序列的左半部分继续往下划分
mergeSort(nums, mid + 1, right); //划分序列的右半部分继续往下划分
sort(nums, left, mid, right); //在每一次递归返回时, 对每一层递归进行left---mid--righ形式的排序
}
//每一层递归的待排子序列进行排序, 这个待排子序列的长度为(right-left + 1),
// 被划分为 left----mid----right
public void sort(int[] nums, int left, int mid, int right){
int[] res = new int[right - left + 1];
int index = 0;
int L = left;
int R = mid + 1;
while(L <= mid && R <= right){
if(nums[L] <= nums[R]){
res[index++] = nums[L];
L++;
}else{
res[index++] = nums[R];
R++;
}
}
while(L <= mid){
res[index++] = nums[L++];
}
while(R <= right){
res[index++] = nums[R++];
}
//拷贝数组到原数组
for(int i = 0; i < index; i++){
nums[i + left] = res[i];
}
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)