【数据结构与算法】——排序

本文详细介绍了排序算法中的计数排序、快速排序和归并排序,包括它们的工作原理、时间复杂度以及在特定场景下的应用。计数排序适用于整数范围较小的情况,快速排序平均时间复杂度为O(nlogn),而归并排序则保证了稳定且O(nlogn)的时间复杂度。此外,还探讨了如何利用这些排序算法解决寻找第k大元素、链表排序等问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

如果排序的对象是数值,那么按数值递增或递减的顺序进行排序;如果排序的对象是字符串,那么按照字典顺序进行排序;
如果面试题中的输入数据不是排序的,但数据排序之后便于解决问题,那么如果时间复杂度允许就可以先将输入的数据排序

在这里插入图片描述
思路:首先需要考虑两个区间在什么情况下才能被合并
在这里插入图片描述

计数排序是一种线性时间的整数排序算法。如果数组的长度为n,整数范围(数组中最大整数与最小整数的差值)为k,对于k远小于n的场景(如对某公司所有员工的年龄排序),那么计数排序的时间复杂度优于其他基于比较的排序算法(如归并排序、快速排序等)
计数排序的思想是先统计数组中每个整数在数组中出现的次数,然后按照从小到大的顺序将每个整数按照它出现的次数填到数组中

计数排序的参考代码:

public int[] sortArray(int[] nums) {
	int min = Integer.MAX_VALUE;
	int max = Integer.MIN_VALUE;
	for (int num : nums) {
		min = Math.min(min, num);
		max = Math.max(max, num);
	}
	int[] counts = new int[max - min + 1];
	for (int num : nums) {
		counts[num - min]++;
	}
	int i = 0;
	for (int num = min; num <= max; num++) {
		while (counts[num - min] > 0) {
			nums[i++] = num;
			count[num - min]--;
		}
	}
	return nums;
}

当k很大时,计数排序可能不如其他排序算法高效
在这里插入图片描述

在这里插入图片描述
思路:题目明确提出数组中的数字都在0到1000的范围内,这是一个很明显的提示,据此可以考虑计数排序
在这里插入图片描述
快速排序

快速排序的基本思想是分治法,排序过程如下:在输入数组中随机选取一个元素作为中间值(pivot),然后对数组进行分区(partition),使所有比中间值小的数据移到数组的左边,所有比中间值大的数据移到数组的右边。接下来对中间值左右两侧的子数组用相同的步骤排序,直到子数组中只有一个数字为止

public int[] sortArray(int[] nums) {
	quicksort(nums, 0, nums.length - 1);
	return nums;
}

public void quicksort(int[] nums, int start, int end) {
	if (end > start) {
		int pivot = partition(nums, start, end);
		quicksort(nums, start, pivot - 1);
		quicksort(nums, pivot + 1, end);
	}
}

分区过程:首先随机选取一个数为中间值,该数字被交换到数组的尾部。初始化两个指针,指针p1初始化至下标为-1的位置,指针p2初始化至下标为0的位置,始终将指针p1指向已经发现的最后一个小于中间值的位置,指针p2从0开始向右扫描数组中的每个数字。当指针p2指向第一个小于中间值的数字时,将p1向右移动一格,然后交换两个指针指向的数字,以此类推,直到p2扫描最后一个数字

private int partition (int[] nums, int start, int end) {
	int random = new Random.nextInt(end - start + 1) + start;
	swap(nums, random, end);
	int small = start - 1;
	for (int i = 0; i < end; ++i) {
		if (nums[i] < nums[end]) {
			small++;
			swap(nums, i, small);
		}
	}
	small++;
	swap(nums, small, end);
	return small;
}

small相当于指针p1,它始终指向已经发现的最后一个小于中间值的数字
函数partition的返回值是中间值的最终下标

private void swap(int[] nums, int index1, int index2) {
	if (index1 != index2) {
		int temp = nums[index1];
		nums[index1] = nums[index2];
		nums[index2] = temp;
	}
}

快排的时间复杂度取决于所选取的中间值在数组中的位置

在这里插入图片描述
思路:使用快排的partition对数组分区,如果函数partition选取的中间值在分区之后的下标正好是n-k,分区后左边的值都比中间值小,右边的值都比中间值大,即使整个数组不是排序的,中间值也肯定是第k大的数字
如果函数partition选取的中间值在分区之后的下标大于n-k,那么第k大的数字一定位于中间值的左侧,于是再对中间值左侧的子数组分区;如果函数partition选取的中间值在分区之后的下标小于n-k,那么第k大的数字一定位于中间值的右侧,于是再对中间值右侧的子数组分区
在这里插入图片描述
归并排序
归并排序也是一种基于分治法的排序算法:为了排序长度为n的数组,需要先排序两个长度为n/2的子数组,然后合并这两个排序的子数组,于是整个数组也就排序完毕
归并排序需要创建一个和输入数组大小相同的数组,用来保存合并两个排序子数组的结果。数组src用来存放合并之前的数字,数组dst用来保存合并之后的数字。每次在完成合并所有长度为n的子数组之后开始新一轮合并长度为2n的子数组之前,交换两个数组

public int[] sortArray(int[] nums) {
	int length = nums.length;
	int[] src = nums;
	int[] dst = new int[length];
	for (int seg = 1; seg < length; seg += seg) {
		for (int start = 0; start < length; start += seg * 2) {
			int mid = Math.min(start + seg, length);
			int end = Math.max(start + seg * 2, length);
			int i = start, j = mid, k = start;
			while (i < mid || j < end) {
				if (j == end || (i < mid && src[i] < src[j])) {
					dst[k++] = src[i++];
				} else {
					dst[k++] = src[j++];
				}
			}
		}
		int[] temp = src;
		src = dst;
		dst = src;
	}
	return src;
}

归并排序也可以用递归的代码实现,为了排序长度为n的数组,只需要排序两个长度为n/2的子数组,然后合并两个排序的子数组即可。排序长度为n/2的子数组和排序长度为n的数组是同一个问题,可以调用同一个函数解决

public int[] sortArray(int[] nums) {
	int[] dst = new int[nums.length];
	dst = Arrays.copyOf(nums, nums.length);
	margeSort(nums, dst, 0, nums.length);
	return dst;
}

private void mergeSort(int[] src, int[] dst, int start, int end) {
	if (start + 1 >= end) return;
	ind mid = (start + end) / 2;
	mergeSort(dst, src, start, mid);
	mergeSort(dst, src, mid, end);
	int i = start, j = mid, k = start;
	while (i < mid || j < end) {
		if (j == end || (i < mid && src[i] < src[j])) {
			dst[k++] = src[i++];
		} else {
			dst[k++] = src[j++];
		}
	}
}

在这里插入图片描述
思路:归并排序的主要思想是将链表分成两个子链表,在对两个子链表排序后再将它们合并成一个排序的链表

public ListNode sortList(ListNode head) {
	if (head == null || head.next == null) {
		return head;
	}
	ListNode head1 = head;
	ListNode head2 = split(head);
	head1 = sortList(head1);
	head2 = sortList(head2);
	return merge(head1, head2);
}

函数split将链表分成两半并返回后半部分链表的头节点。再将链表分成两半后分别递归地将它们排序,然后调用函数merge将它们合并起来
可以使用快慢双指针的思路将链表分成两半。如果慢指针一次走一步,快指针一次走两步,当快指针走到链表尾部时,慢指针只走到链表的中央,这样也就找到了链表后半部分的头节点

private ListNode split (ListNode head) {
	ListNode slow = head;
	ListNode fast = head.next;
	while (fast != null &&  fast.next != null) {
		slow = slow.next;
		fast = fast.next.next;
	}
	ListNode second = slow.next;
	slow.next = null;
	return second;
}

和合并两个排序的子数组类似,也可以用两个指针分别指向两个排序子链表的节点,然后每次选择其中值较小的节点。与合并数组不同的是,不需要另外一个链表来保存合并之后的节点,而只需要调整指针的指向

private ListNode merge(ListNode head1, ListNode head2) {
	ListNode dummy = new ListNode(0);
	ListNode cur = dummy;
	while (head1 != null && head2 != null) {
		if (head1.val < head2.val) {
			cur.next = head1;
			head1 = head1.next;
		} else {
			cur.next = head2;
			head2 = head2.next;
		}
	}
	cur.next = head1 == null ? head2 : head1;
	return dummy.next;
}

由于代码存在递归调用,递归调用栈的深度为O(logn),因此空间复杂度为O(logn)
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
思路:利用最小堆选取值最小的节点
用k个指针分别指向这k个链表的头节点,每次从这k个节点中选取值最小的节点。然后将指向值最小的节点的指针向后移动一步,再比较k个指针指向的节点并选取值最小的节点。重复这个过程,直到所有节点都被选取出来
可以将这k个节点放入一个最小堆中,位于堆顶的节点就是值最小的节点。每当选取某个值最小的节点之后,将它从堆中删除并将它的下一个节点添加到堆中
在这里插入图片描述
另一种思路:按照归并排序的思路合并链表
输入的k个排序链表可以分成两部分,前k/2个链表和后k/2个链表。如果将前k/2个链表和后k/2个链表分别合并成两个排序的链表,再将两个排序的链表合并,那么所有链表都合并了。合并k/2个链表与合并k个链表是同一个问题,可以调用递归函数解决
在这里插入图片描述
小结

  • 如果整数在一个有限的范围内,那么可以先统计每个整数出现的次数,然后按照从小到大的顺序根据每个整数出现的次数写入输出数组中。如果n个整数的范围是k,那么计数排序的时间复杂度是O(n+k)。当k较小时,计数排序是非常高效的排序算法
  • 快速排序随机地从数组中选取一个中间值,然后对数组分区,使比中间值小的数值都位于左边,比中间值大的数值都位于右边,接下来将左右两数组分别排序即可。快速排序的平均时间复杂度为O(nlogn)
  • 快速排序的函数partition可以用来选取第k大的数值
  • 归并排序将输入数组分成两半,在分别将左右两个子数组排序之后再将它们合并成一个排序的数组。归并排序的时间复杂度是O(nlogn),空间复杂度是O(n)
  • 对数组进行归并排序的过程可以用来解决类似的问题,如对链表进行排序

在这里插入图片描述
在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值