文章目录
快速排序
坑位法思想
快速排序是使用了分治与二分思想的算法。核心思想在于选择一个基准值,然后将数组中大于基准值的数放置在基准值左边,把数组中小于基准值的数放置在基准值右边。之后对基准值左右的两段数组重复上述操作,直至每段数组中只有一个数值,这样每段数组都是排序的。
从上述描述中可以看到分治的思想,然后这个分治的思路要使用递归来完成。这里简述下如何移动基准值左右的数值。这里使用的是填坑法。
我们选取数组的首个元素作为基准值,让i和j分别指向数组的首位与末尾。我们想要让i指向的都比基准值小,让j指向的都比基准值大。
(1)初始时,我们使用基准值与j所指向的元素比较,如果基准值小于该值,则j左移,继续比较,直至找到小于基准值的元素,或者j已经遇到i。
当找到一个元素小于基准值时,就将该值赋给nums[i],之后去比较nums[i]与基准值。(此时出现了重复值,没关系,之后会将nums[j]的值更新掉)
nums[i] = nums[j];
(2)比较nums[i]与基准值的方法类似。如果基准值大于nums[i],则i右移,继续比较,直至找到大于基准值的元素,或者i已经遇到j。
当找到一个元素大于基准值时,就将该值赋给nums[j],之后去比较nums[j]与基准值。
nums[j] = nums[i];
具体的例子可以看参考链接下的文章:
参考链接:快速排序算法,建议学习的过程先自己手写一次例子,[23,45,17,11,13,89,72,26,3,17,11,13]。
坑位法代码实现
这里要特别注意一点,元素可能存在重复,所以我们的判断语句要带着等于号,否则会进入死循环。
public class Main {
static int times = 0;
public static void main(String[] args) {
//System.out.println("Hello World!");
int []num = new int[]{23,45,17,11,13,89};
quickSort(num,0,num.length-1);
for(int i=0;i<num.length;i++)
System.out.print(num[i]+" ");
System.out.println();
System.out.println(times);
}
public static void quickSort(int[]num,int start,int end){
if(start<end){
times++;
int i = start;
int j = end;
int temp = num[start];
//开始快速排序,从左到右寻找比temp大的,将其移动到右边,从右到左寻找比temp小的,将其移动到左边
while(i<j){
//首先比较 temp与num[j],这是正常情况,直到找到temp比num[j]大的情况
while(j>i&&temp<=num[j])
j--;
//此时temp比num[j]大那么将num[j]赋值给num[i],同时比较num[i]与temp
if(j>i)
num[i] = num[j];
//可能会走到尽头
if(i==j)
break;
//这是正常情况,直到找到temp比num[i]小的情况。
while(i<j&&temp>=num[i])
i++;
//此时找到了temp比num[i]小,将num[i]赋给num[j],同时比较num[j]与temp;或走到尽头
if(i<j)
num[j] = num[i];
}
//默认这时为i=j
num[i] = temp;
quickSort(num,start,i-1);
quickSort(num,i+1,end);
return ;
}
//只剩下一个元素就可以返回了
else{
return ;
}
}
}
优化的交换法思想
上面的算法交换次数过多。我们可以简化这个过程。我们固定让每一次交换都是有意义的,只交换不是正常位置的元素,最后在放置基准值。
可以从代码中看到。
(1)我们首先循环的将temp与num[end]比较,直到找到一个元素比temp小,或end走到了start。
(2)之后循环的将temp与num[start]比较,直到找到一个元素比temp大,或start走到了end。
(3)此时要么start=end,否则就是已经找到了start和end。(大于temp的start与小于temp的end),只交换他们两个。交换结束继续重复(1)(2)(3)直到start=end。
(4)此时已经start=end,整个外层循环结束,此时start的位置应该去放置temp,因此将他们交换。
以上四步是一个完整的partition,我们接下来要继续划分其他两段数组。持续这个过程直到每段数组中只有一个元素。
为了便于理解,我们举一个实际的例子,完整的走一遍 partition 的过程。
public void quickSort(int[]A ,int start ,int end)
{
if(start<end){
int index = partition(A,start,end);
quickSort(A,start,index-1);
quickSort(A,index+1,end);
}
else
return ;
}
//返回temp的下标
public int partition(int[]A,int start,int end){
int temp = A[start];
int tempindex = start;
while(start<end){
while(start<end&&A[end]>=temp)
end--;
while(start<end&&A[start]<=temp)
start++;
if(start<end)
swap(A,start,end);
}
swap(A,tempindex,start);
return start;
}
public void swap(int[]A,int index1,int index2){
int temp = A[index1];
A[index1] = A[index2];
A[index2] = temp;
}
代码注意(十分重要)
1.在while循环中一定不要光判断A[end]>=temp
,一定也要判断start<end
,防止越界。
2.可能有一个疑问就是既然我们已经交换了start和end,那么start和end就没用了,为什么不让start和end移动呢。start和end的确是没用了,但是start+1和end-1还是有用的。**如果start+1==end-1的话,在while(start<end)循环那里就会直接退出循环,就没有比较start+1这个位置的数值。**我们要保证的是当while循环结束时,要将所有的数都和temp这个值做好了比较。
if(start<end){
swap(start,end,arr);
start++;
end--;
}
以该例子为例。
[2,4,4,1,3,3,2]->[2,1,4,4,3,3,2]
start = 0 -> 1
end = 6 -> 3
交换start与end,此时如果start++和end--,那么就会退出循环,并将下标为0的位置与start的位置(2)交换,
结果为[4,1,2,4,3,3,2],这样就出了问题。
而不start++和end--,会让end--,直到end=1和start相等。这样就比较了下标为2的值与temp的值,
此时start=end=1,结果为[1,2,4,4,3,3,2]
一些分析
时间复杂度:平均时间复杂度为O(nlogn),logn是需要递归的次数。n是每一次需要比较的次数。
考虑最差的情况:123456。在第一次拆分时会分成1和23456,时间复杂度就变成了了O(n^2)。
为了避免这种情况,可以随机选取基准值。随机选取基准值就是随机找到一个值,并将该值与start位置的元素交换顺序,
int index = (int)(Math.random()*(end-start+1)+start);
swap(A,start,index);
归并排序
思想
分而治之,然后合并。整体思路就如下图,首先分成两段,之后合并。
代码
这里唯一值得注意的就是temp一定要在外面声明,不要让每一次merge里都创建新的数组,否则内存会爆。
public class Solution {
/**
* @param A: an integer array
* @return: nothing
*/
//新建一个数组用来存放结果
static int[]temp;
public void sortIntegers2(int[] A) {
// 归并算法:分,合
if(A==null||A.length==0)
return ;
temp = new int[A.length];
mergeSort(A,0,A.length-1);
}
public void mergeSort(int[]num,int start,int end){
if(start<end)
{
int mid = (start+end)/2;
//分别对两段数组继续进行分合操作
mergeSort(num,start,mid);
mergeSort(num,mid+1,end);
//将有序的两段数组合并
merge(num,start,mid,end);
}
}
/*
合并从[start,mid]和[mid+1,end]
*/
public void merge(int[]num,int start,int mid,int end){
int i = start;
int j = mid+1;
int time = 0;
while (i<=mid&&j<=end){
int left = num[i];
int right = num[j];
if(left<=right)
{
temp[time++] = left;
i++;
}else{
temp[time++] = right;
j++;
}
}
while(i<=mid)
temp[time++] = num[i++];
while(j<=end)
temp[time++] = num[j++];
time=0;
while(start<=end)
num[start++] = temp[time++];
}
}
堆排序
思路
完全二叉树性质补充
在说堆排序之前先补充一下二叉树的知识。下图中a为完全二叉树,而b为非完全二叉树。那么什么是完全二叉树,完全二叉树是指其下标可以与满二叉树完全对应,也就是说,完全二叉树中的节点是按顺序一个一个插入的。
完全二叉树有什么好处?我们可以通过子节点的座标找到父节点的对应座标。我们知道二叉树一层最多有2
的i次方个节点。如果是按顺序排下来的话,上层节点与下层节点的座标之差就是2倍(左子节点)或2倍+1(右子节点)。因此已知子节点的座标,可以求得父节点的座标。但是非完全二叉树就不可以了,比如G和D。因为他们不满足按顺序往下排。
父节点座标 = 子节点座标/2
最大堆与最小堆概念补充
最大堆就是当前节点的值大于其左右子节点。最小堆就是当前节点的值小于其左右子节点。但对于他们的左右子节点孰大孰小没有要求
我们的数组可以看成一个完全二叉树。
最大堆(这里i是从0开始的)
num[i]>num[2*i+1]&&num[i]>num[2*i+2];
堆排序完整步骤
我们以升序排序为例。我们的数组可以看成一个完全二叉树。核心思路是要维护一个最大堆,然后将最大堆的最大值、次大值等等交换至倒数第一个位置,倒数二个位置。
整体思路如下:
1.构造最大堆:我们首先寻找当前树中最后一个非叶子节点A,并寻找节点A与其子节点中的最大值,并将最大值与该节点交换值(这里注意一下交换位置后,还需要保持A节点在子节点的位置满足最大堆的性质);持续这个过程,直到所有的非叶子节点都完成上述操作,这样我们的最大堆构造完成。
2.交换过程:第一次时,我们当前的根节点就是整个树中最大的值,将该值与二叉树中的最后一个节点交换值。交换节点后,我们要继续判断根节点是否满足最大堆的性质并进行调整(开始比较根节点与根节点的左子节点与右子节点,如果根节点不满足就需要调整)。完成上述操作后,将根节点的值与倒数第二个值交换位置。持续上述操作直至已经到交换最后一个位置。
3.其实这里最重要的一点是判断当前节点是否满足最大堆的性质并调整。同时,在比较节点与子节点最大值这一操作中,会对节点更改顺序,当前节点换到了子节点的位置后,要要继续判断该节点在子节点位置是否可以满足最大堆的性质,如果不满足就需要调整其位置。
4.第二重要的就是如何选取最后一个非叶子节点,我们最后一个节点的父亲就是最后一个非叶子节点。我们最后一个节点的下标是nums.length,我们考虑树是从1到nums.length排序号的情况。因此它的父节点座标就是nums.length/2,但由于数组是从0到nums.length-1排序,因此最终结果为:
最后一个非叶子节点座标 = (nums.length/2)-1
完整代码
整体步骤与上述思路相同。
1.构造最大堆;从最后一个非叶子节点开始,让所有的非叶子节点满足最大堆的性质。
2.将首位与末尾交换元素,之后让首位的节点满足最大堆的性质。
3.最重要的就是select函数,它的作用是判断以index为首的树是否满足最大堆性质,如果不满足就交换元素。这里需要注意两点:
1.如果index所在元素被交换了位置,一定要继续去让该元素满足最大堆的性质(继续调用该函数,传入的index变更为交换后的位置);
2.我们在函数中会判断index是否有左右子节点。但判断依据是当前的左右子节点是否存在且不是已经交换位置的节点。已经交换位置的节点我们不予判断,可以当做没有,这也是我们传入length的原因,就是告诉数组当前未判断的有效长度。
public class Solution {
/**
* @param A: an integer array
* @return: nothing
*/
public void sortIntegers2(int[] A) {
// 堆排序
if(A==null||A.length==0)
return ;
HeapSort(A);
}
public void HeapSort(int[]A)
{
//1.构造最大堆
int lastIndex = A.length/2+1;
for(int i=lastIndex;i>=0;i--)
{
select(A,i,A.length);
}
//2.交换位置与继续寻找
for(int i=A.length-1;i>=0;i--)
{
swap(A,0,i);
select(A,0,i);
}
}
/**
* 调整索引为 index 处的数据,使其符合堆的特性。
* @param A 当前数组
* @param index 需要堆化处理的数据的索引
* @param len 未排序的堆(数组)的长度
*/
public void select(int[]A,int index,int length)
{
int left = -1;
int right = -1;
//注意这里的判断不能带上之前交换过位置的节点。如果当前节点的左子节点或右子节点是之前交换过的位置的节点,就不比较了。
if(2*index+1<length)
left = 2*index+1;
if(2*index+2<length)
right = 2*index+2;
//都为空不用比较
if(left==-1&&right==-1)
return;
//先默认放left
int maxindex = left;
if(maxindex==-1)
maxindex = right;
else{
if(right!=-1&&A[right]>A[left])
maxindex = right;
}
//如果需要交换的话,交换之后要去判断那个子节点位置(现在是之前的A[index]),
//维护以其为首的树为最大堆
if(A[maxindex]>A[index])
{
swap(A,index,maxindex);
select(A,maxindex,length);
}
}
/*
普通交换
*/
public void swap(int[]A,int index1,int index2)
{
int temp = A[index1];
A[index1] = A[index2];
A[index2] = temp;
}
}
练习题
Lintcode
1.整数排序 II
464整数排序
纯粹的练手题,可以练习快速排序,堆排序以及归并排序。
2.
LeetCode
1.数组中的第K个最大元素
思路
思路可以很简单的使用快速排序或堆排序,然后直接返回第k个最大元素,但其实快速排序中有一种简便算法。因为快速排序,每次会选取一个基准值,基准值左边是小于它的,右边是大于它的,那么我们就可以知道这个基准值时在数组中第几大,同理,我们也可以知道第K大的元素就是这个基准值,或者在右边数组还是在左边数组。这样就完成了剪枝的工作,可以只排序一边数组,直到找到这个第K大的元素。
代码
1.注意这里能使用while(true)的原因是题目中说,k一定是满足条件的,也就是说不会出现k超过nums的长度,也就是一定可以找到。
2.快速排序虽然快,但是有一些特殊情况时间也会很慢,所以我们的基准值选取一定要随机选取。
3.第k大的元素对应的位置就是nums.length-k。大家可以想第一大的数,就是nums.length-1,第二大的就是nums.length-2…以此类推。
class Solution {
int[]nums;
int k;
/*
思路其实是快速排序的变种。不过可以进行有效的剪枝,我们可以通过当前partition传回的index
来判断第k个最大值在左边的数组还是右边的数组,只排序其中的数组,
同时判断当前的index是否就满足条件,满足条件就可以直接返回。
*/
public int findKthLargest(int[] nums, int k) {
this.nums = nums;
this.k = k;
if(nums==null||nums.length==0)
return -1;
//第k大元素的位置
int index = nums.length-k;
int left = 0;
int right = nums.length-1;
while(true){
int nowindex = parttion(left,right);
if(nowindex==index)
return nums[nowindex];
//index在nowindex的右边
else if(nowindex+1<=index)
left = nowindex+1;
else
right = nowindex-1;
}
}
public int parttion(int start,int end){
//每次都选取随机的位置作为temp
int random = (int)(Math.random()*(end-start+1)+start);
swap(random,start);
int i = start;
int j = end;
int temp = nums[start];
while(i<j){
//先j向右
while(i<j&&nums[j]>=temp) j--;
//再i向右
while(i<j&&nums[i]<=temp) i++;
if(i<j)
swap(i,j);
}
swap(i,start);
return i;
}
public void swap(int index1,int index2)
{
int temp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temp;
}
}
2. 前 K 个高频元素
思路
其实思路比较简单,我们一定是要遍历一次数组的。这个O(n)时间复杂度无可避免。我们可以优化的是之后的排序过程。我们维护一个长度为k的最小堆,当长度大于k时,想要添加进元素,就要将这个元素与堆顶元素比较,如果当前元素的出现次数更多,就将其放入堆中,并将堆顶元素出堆(之后堆内部会进行一些交换操作以维持最小堆性质)。
代码
首先使用HashMap,让HashMap存储(元素,元素的个数),其次使用堆/优先队列(PriorityQueue)来维护一个最小堆,堆顶是出现频率最小的,维护一个长度为k的堆,当堆的长度大于k时,就需要更新堆,将元素与堆顶比较,如果该元素比堆顶元素的出现频率大,就将堆顶元素弹出,把该元素插入堆里。
这里最重要的就是要给PriorityQueue重写比较器,以完成内部的比较。其实回看本题的时候就看看这个比较器,注意是Comparator,里面是compare函数,同时compare函数中的形参是包装类。
PriorityQueue<Integer> pq = new PriorityQueue<>(new Comparator<Integer>()
{
@Override
public int compare(Integer a, Integer b) {
return map.get(a)-map.get(b);
}
}
);
完整代码
class Solution {
public List<Integer> topKFrequent(int[] nums, int k) {
/*
首先使用HashMap,让HashMap存储(元素,元素的个数)
使用堆/优先队列(PriorityQueue)来维护一个最小堆,堆顶是出现频率最小的,维护一个长度为k的堆,当堆的长度大于k时,就需要更新堆,将元素与堆顶比较,如果该元素比堆顶元素的出现频率大,就将堆顶元素弹出,把该元素插入堆里。
*/
if(nums==null||nums.length==0)
return null;
HashMap<Integer,Integer>map = new HashMap<>();
LinkedList<Integer>list = new LinkedList<>();
for(int num:nums)
map.put(num,map.getOrDefault(num,0)+1);
//需要为堆写自己的比较器。是用来对内部堆结构排序用的,始终构造成一个最小堆()
PriorityQueue<Integer> pq = new PriorityQueue<>(new Comparator<Integer>()
{
@Override
public int compare(Integer a, Integer b) {
return map.get(a)-map.get(b);
}
}
);
for(int num:map.keySet())
{
if(pq.size()<k)
{
pq.add(num);
}else{
int pqHead = pq.peek();
if(map.get(num)>map.get(pqHead))
{
pq.remove();
pq.add(num);
}
}
}
while(!pq.isEmpty())
{
//System.out.println(pq.peek());
list.addFirst(pq.remove());
}
return list;
}
}