𝓝𝓮𝓶𝓸&博客

【数据结构】排序算法

分类 排序算法 改进思路 最好情况 平均时间复杂度 最坏情况 空间复杂度 稳定性
插入排序 直接插入排序 基本排序方法 \(O(n)\) \(O(n^2)\) \(O(n^2)\) \(O(1)\) 稳定
折半插入排序 确定有序序列的插入位置 \(O(nlog_2n)\) \(O(n^2)\) \(O(n^2)\) \(O(1)\) 稳定
希尔排序 利用直接插入的最好情况 \(O(n^{1.3})\) \(NA\) \(O(n^2)\) \(O(1)\) 不稳定
交换排序 冒泡排序 基本排序方法 \(O(n)\) \(O(n^2)\) \(O(n^2)\) \(O(1)\) 稳定
快速排序 交换不相邻元素 \(O(nlog_2n)\) \(O(nlog_2n)\) \(O(n^2)\) \(O(log_2n)\) 不稳定
选择排序 简单选择排序 基本排序方法 \(O(n^2)\) \(O(n^2)\) \(O(n^2)\) \(O(1)\) 不稳定
堆排序 将待排序列转化为完全二叉树 \(O(nlog_2n)\) \(O(nlog_2n)\) \(O(nlog_2n)\) \(O(1)\) 不稳定
归并排序 2路归并排序 合并每个有序序列 \(O(nlog_2n)\) \(O(nlog_2n)\) \(O(nlog_2n)\) \(O(n)\) 稳定
基数排序 基数排序 不基于比较
将对应数字放在桶中
\(O(d(n+r))\) \(O(d(n+r))\) \(O(d(n+r))\) \(O(r)\) 稳定
  • 稳定性:相隔较远的大范围交换,会影响它的稳定性。

  • 时间复杂度:也就是算法的时间量度,记住:T(n)=O(f(n))。表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同(即 一个数量级)。

  • 空间复杂度:指算法在运行时所需存储空间的度量,主要考虑在算法运行过程中临时占用的存储空间的大小。

插入排序

直接插入排序

注意:第一张牌已经握在手中了,从第二张开始。第一张牌不需要比较,也没得比较,直接抓在手中,所以从第二张开始)

有序序列L[1...i-1] L(i) 无序序列L[i+1...n]
  • 基本思想:
    可以将L(2)~L(n)依次插入到前面已排好序的子序列中,初始假定L[1]是一个已经排好序的子序列。

    1. 将待排元素L(i)放入哨兵L(0)中。
    2. 查找出L(i)在L[1...i-1]中的插入位置k。
    3. 将L[k...i-1]中的所有元素全部后移一个位置。
    4. 将L(i)复制到L(k)。
  • 具体操作:
    简单来说,直接插入排序就是将待排的序列中每个元素从后往前比较依次插入到已排好的序列中,放入合适的位置,最后得到有序的结果。

    类比:****抓牌第一张牌握在手中从第二张牌开始,每次比较都是最后抓的一张牌向前面抓的牌比较从后往前比较,放入该放的位置,再继续抓牌,直到所有牌被抓在手中,有序。
    image

  • 具体实现:

方法一:从后往前比较,最后插入该放的位置

类比:插队,找到一个位置,把所有人往后移,自己插进去。

void InsertSort(ElemType A[], int n) {
	int i, j;
	for(i = 2; i <= n; i++) {		//依次将A[2]~A[n]插入到前面已排序序列
		if(A[i].data < A[i-1].data) {	//若A[i]的关键码小于有序序列的最后一个元素(有序序列中的最大值)(即其前驱),需将A[i]插入有序表;大于有序序列的最后一个元素,则不用从头插入,并入有序序列(本来就排在有序序列之后),成为有序序列新的最大值(最后一个)
			A[0] = A[i];		//复制为哨兵,A[0]不存放元素
			for(j = i - 1; A[0].data < A[j].data; --j) {	//从后往前查找待插入位置
				A[j+1] = A[j];		//向后挪位
			}
			A[j+1] = A[0];		//复制到插入位置
		}
	}
}

方法二:****从后往前比较,每比较一次都交换一个位置,让前面的一个人人往后移交换,直到交换到自己合适的位置。

类比:电影院换位置,一次只能接触到旁边的一个人,那就一次一次慢慢往前换,直到换到自己合适的位置。

public class Test {

    public static void insert_sort(int[] arr) {
        for (int i = 1; i < arr.length; i++) {
            for (int j = i; j > 0 && arr[j] < arr[j - 1]; j--) {
            // for (int j = i; j > 0; j--) {
                // if (arr[j] < arr[j - 1]) {
                    int t = arr[j];
                    arr[j] = arr[j - 1];
                    arr[j - 1] = t;
                }
            }
        }
    }

    public static void main(String[] args) {
        int[] arr = new int[]{5, 4, 3, 2, 1};
        insert_sort(arr);
        for (int i : arr) {
            System.out.print(i);
        }
    }
}
  • 性能分析:
    • 空间效率:O(1)
      仅使用了常数个辅助单元。

    • 时间效率:O(\(n^2\))
      在排序过程中,向有序子表中逐个插入元素的操作进行了n-1趟,每趟操作都分比较关键字和移动元素,而比较次数移动元素取决于待排序表的初始状态。

      • 最好情况:O(n) (顺序)
      • 最坏情况:O(\(n^2\)) (逆序)
      • 平均情况:O(\(n^2\))
    • 稳定性:稳定
      由于每次插入元素时,总是从后向前先比较在移动,所以不会出现相同元素相对位置发生变化的情况。

    • 适用性:
      适用于顺序存储链式存储的线性表。
      适用于基本有序的排序表和数据量不大的排序表。
      为链式存储时,可以从前往后查找指定元素的位置。

    大部分排序算法都仅适用于顺序存储的线性表。

折半插入排序

  • 基本思想:
    将比较和移动操作分离,即先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素。

  • 具体操作:
    基本思想与直接插入排序类似,区别在于寻找插入位置的方法不同,基本条件是原序列已经有序,在已经有序的序列中插入一个新的元素,相对于直接插入排序将更快

  • 具体实现:

void InsertSort(ElemType A[], int n) {
	int i, j, low, high, mid;
	for(i = 2; i <= n; i++) {		//依次将A[2]~A[n]插入前面的已知排序序列
		A[0] = A[i];		//将A[i]暂存到A[0]
		low = 1;			//设置折半查找的范围
		high = i-1;			
		while(low <= high) {	//折半查找(默认递增有序)
			mid = (low + high) / 2;		//取中间点
			if(A[mid].data > A[0].data) {	//查找左半边
				high = mid - 1;
			}else {			//查找右半边
				low = mid + 1;
			}
		}
		//查找结束后,high<low,k在high和high+1之间,所以顶替high+1位置,把high+1后面的元素后移
		for(j=i-1; j>=high+1; --j) {
			A[j+1] = A[j];		//统一后移元素,空出插入位置
		}
		A[high+1] = A[0];		//插入
	}
}
  • 性能分析:
    • 空间效率:O(1)
      仅使用了常数个辅助单元。

    • 时间效率:O(\(n^2\))
      仅减少了比较元素的次数,约为O(\(nlog_2n\)),该比较次数与待排序表的初始状态无关,仅取决于表中元素的个数n;而元素移动次数并未改变,它依赖于待排序表的初始状态。

      • 最好情况:O(\(nlog_2n\)) (顺序)
      • 最坏情况:O(\(n^2\)) (逆序)
      • 平均情况:O(\(n^2\))
    • 稳定性:稳定

    • 适用性:
      适用于顺序存储的线性表
      适用于基本有序的排序表和数据量不大的排序表。

希尔排序

交换效率(次数)取决于序列中逆序对的个数;
直接插入排序每次交换相邻两个元素,只能消除一个逆序对;
所以要想提高排序效率,就得每次交换相隔较远的两个逆序对,一次可以消去更多的逆序对,所以创造了希尔排序

PS:而相隔较远的大范围交换,则会影响它的稳定性。

  • 基本思想:(其实就相当于把直接插入排序的间隔由1变为了d
    先将待排序表分割成若干形如L[i, i+d, i+2d, ..., i+kd]的“特殊”子表,分别进行直接插入排序,当整个表中的元素已呈“基本有序”时,再对全体记录进行一次直接插入排序

  • 具体操作:
    简单描述就是将待排序列进行分组,例如每隔d-1个数就是一组,然后在组内进行直接插入排序,然后不断缩小分组增量,例如将上面已经在组内排序好的重新编组,例如每隔d-3个数一组,不断缩小,直到所有序列都为一组,再进行最后一次直接插入排序,就得到有序的序列。
    image

  • 具体实现:

//对顺序表做希尔插入排序,本算法和直接插入排序相比,做了以下修改:
//1. 前后记录的位置增量是dk,不是1
//2. A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
void ShellSort(ElemType A[], int n) {
	for(dk = n / 2; dk >= 1; dk = dk / 2) {		//步长变化
		for(i = dk + 1; i <= n; ++i) {	//因为插入排序第一个(即 A[1])已经握在手中了
			if(A[i].data < A[i-dk].data) {		//需将A[i]插入有序增量子表
				A[0] = A[i];		//暂存在A[0]
				for(j = i - dk; j > 0 && A[0].data < A[j].data; j -= dk) {
					A[j + dk] = A[j];		//记录后移,寻找插入的位置
				}
				A[j + dk] = A[0];		//插入
			}
		}
	}
}
  • 性能分析:
    • 空间效率:O(1)
      仅使用了常数个辅助单元

    • 时间效率:
      由于希尔排序的时间复杂度依赖于增量序列的函数,这涉及数学上尚未解决的难题,所以其时间复杂度分析比较困难。

      • 最好情况:O(\(n^{1.3}\)) (当n在某个特定范围时)
      • 最坏情况:O(\(n^2\))
      • 平均情况:NA
    • 稳定性:不稳定
      当相同关键字的记录被划分到不同的子表时,可能会改变它们之间的相对次序。

    • 适用性:仅适用于线性表为顺序存储的情况。

交换排序

冒泡排序

  • 基本思想:
    假设待排序表长为n,从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1] > A[i]),则交换它们,知道序列比较完。称之为一趟冒泡,即将最小的元素交换到待排序列的第一个位置(关键字最小的元素如气泡一般逐渐往上“漂浮”直至“水面”)。下一趟冒泡时,前一趟确定的最小元素不在参与比较,待排序列减少一个元素。这样最多做n-1趟冒泡就能把所有元素排好序。

  • 具体操作:
    第一个元素和第二个元素比较,如果第一个大,两者交换,然后第二个和第三个比较,以此类推直到最后一个,最后大的记录会“沉底”,小的记录会“上浮”,这就是冒泡排序的名字由来
    image

  • 具体实现:

普通版:

//下面是笔者常用的写法,把趟数从1开始,方便区分趟数
void BubbleSort(ElemType A[], int n) {
	for(i = 1; i < n; i++) {	//趟数从1开始,第一趟...
		for(j = 0; j < n - i; j++) {	//一趟冒泡过程,从0到n-1都往后比较一个
			if(A[j].data > A[j + 1].data) {
				swap(A[j],A[j + 1]);
			}
		}
	}
}

优化版:

冒泡排序过程中,可以检测到整个序列是否已经排序完成,进而可以避免掉后续的循环。

//用冒泡排序法将序列A中的元素按从小到大排列
void BubbleSort(ElemType A[], int n) {
	for(i = 0; i < n-1; i++) {	//趟数
		flag = false;		//表示本趟冒泡是否发生交换的标志
		for(j = n - 1; j > i; j--) {		//一趟冒泡过程
			if(A[j - 1].data > A[j].data) {	//若为逆序
				swap(A[j - 1], A[j]);		//交换
				flag = true;
			}
		}
		if(flag == false) {		//本趟遍历后没有发生交换(前面一个元素均小于后面一个),说明表已经有序
			return;
		}
	}
}
//下面是笔者常用的写法,把趟数从1开始,方便区分趟数
void BubbleSort(ElemType A[], int n) {
	for(i = 1; i < n; i++) {	//趟数从1开始,第一趟...
		flag = false;	//表示本趟冒泡是否发生交换的标志
		for(j = 0; j < n - i; j++) {	//一趟冒泡过程,从0到n-1都往后比较一个
			if(A[j].data > A[j + 1].data) {
				swap(A[j],A[j + 1]);
				flag = true;
			}
		}
		if(flag == false) {//本趟遍历后没有发生交换(前面一个元素均小于后面一个),说明表已经有序
			return;
		}
	}
}

进一步优化版

在每轮循环之后,可以确认,最后一次发生交换的位置之后的元素,都是已经排好序的,因此可以不再比较那个位置之后的元素。

//下面是笔者常用的写法,把趟数从1开始,方便区分趟数
void BubbleSort(int[] A) {
    int rightIndex = A.length - 1;	// 右最终边界

    for (int i = 1; i < A.length; i++) {	//趟数从1开始,第一趟...
        int rightTemp = 0;	// 右临时边界

        for (int j = 0; j < rightIndex; j++) {	//一趟冒泡过程,从0到n-1都往后比较一个
            if(A[j] > A[j+1]) {

                int t = A[j];
                A[j] = A[j + 1];
                A[j + 1] = t;

                rightTemp = j;	// 右边界用来存放最后一次发生交换的位置
            }
        }

        rightIndex = rightTemp;	// 设置右最终边界
        if (rightIndex == 0) {	// 如果最后一次发生交换的位置是默认值0,那就代表没有改变
            break;
        }
    }
}
  • 性能分析:
    • 空间效率:O(1)
      仅使用了常数个辅助单元

    • 时间效率:O(\(n^2\))
      当初始序列有序时,显然第一趟冒泡后没有元素交换(前均小于后),从而直接跳出循环,比较次数为n-1,移动次数为0。

      • 最好情况:O(n) (顺序)
      • 最坏情况:O(\(n^2\)) (逆序)
      • 平均情况:O(\(n^2\))
    • 稳定性:稳定
      i>j且A[i].data==A[j].data时,不会交换两个元素

    • 适用性:
      适用于线性表(顺序表和链表)(每一趟都是顺着来的,所以满足链表)
      适用于基本有序的排序表。

快速排序

冒泡排序每次交换相邻两个元素,只能消除一个逆序对;
所以要想提高排序效率,就得每次交换相隔较远的两个逆序对,一次可以消去更多的逆序对,所以创造了快速排序

PS:而相隔较远的大范围交换,则会影响它的稳定性。

小L[1...k-1] L(k) 大L[k+1...n]
  • 基本思想:
    基于分治法,在待排序表L[1...n]中任取一个元素pivot(枢纽)作为基准,通过一趟排序将待排表划分为两个独立的两部分L[1...k-1]和L[k+1...n],使得L[1...k-1]中所有元素小于pivot,L[k+1...n]中的所有元素大于等于pivot,则pivot放在了其排序后的最终位置L(k)上,这个称为一趟快速排序。而后分别递归地对两个子表重复上述过程,直至每个部分内只有一个元素或空为止,即所有元素放在了其最终位置上。

  • 具体操作:
    序列两端设置一个i和j指向第一个元素和最后一个元素,以第一个数作为参照数从j开始移动因为要把枢纽(第一个元素)交换掉,不能让它一直呆在原位不动,否则无法将它划分左小右大的排序(因为左边一定包含了他自己)),若j所指的数小于参照数则与i交换(赋值到i即可,不用交换),此时j停止移动,i开始移动,若i所指的数大于参照数,则将i与j进行交换,此时i停止移动,j开始移动,循环往复,直到i与j相遇,则停止这一轮排序,接着就对i和j相遇(即 i左边的都比pivot小,j右边的都比pivot大,所以相遇位置,就是pivot的最终位置)这个位置分成的两段分别再进行快速排序,直到序列有序为止。

  • 操作过程:
    28,16,32,12,60,2,5,72
    使用挖坑法:将基准挖出来,其他数不断移动,直到空出一个左小右大的坑,再把基准填进去。
    这也就解释了为什么我们从右边开始向左遍历,因为我们的初始值取得是最左边,在最左边挖了个坑,然后左边空出来了,所以得从右边开始遍历,填上左边的坑,填到最后左右指针相等,那就代表找到坑位了,把原来挖出来的元素放进去即可。

类比:挖坑法可以类比一下孔明锁,排序如果是紧密相连,那就没啥操作空间,活动不开,如果我们抽出其中一个,其他的元素就可以灵活移动了。

  • 当每次的枢轴都把表等长为相近的两个子表时,速度是最快的。

  • 快排的阶段性排序结果特点是:第i趟完成时,会有i个以上的数出现在它最终将要出现的位置,即它左边的数都比它小,它右边的数都比它大。

  • 具体实现:

//划分算法(一趟排序过程)
int Partition(ElemType A[], int low, int high) {
	ElemType pivot = A[low];	//将当前表中第一个元素设为枢轴值,对表进行划分
	while(low < high) {
		while(low < high && A[high] >= pivot) {		//当high中的值已经比pivot大,则不移动
			--high;
		}
		//循环结束时,high中的元素比pivot小
		A[low] = A[high];		//将小值移动到左端
		while(low < high && A[low] <= pivot) {		//当low中的值已经比pivot小,则不移动
			++low;
		}
		//循环结束时,low中的元素比pivot大
		A[high] = A[low];		//将大值移动到右端
	}
	//循环结束时,low==high,已经是pivot的最终位置
	A[low] = pivot;		//放入最终位置
	return low;		//返回存放枢轴的最终位置
}

void QuickSort(ElemType A[], int low, int high) {
	if(low >= high) {	//不能划分了,递归出口
		return;
	}
	int pivotpos = Partition(A, low, high);		//划分
	QuickSort(A, low, pivotpos-1);		//依次对两个子表进行递归排序划分
	QuickSort(A, pivotpos+1, high);
}
  • 性能分析:
    • 空间效率:O(\(log_2n\))
      递归需要借助一个递归工作栈来保存每层递归调用的必要信息,其容量应与递归调用的最大深度一致

      • 最好情况:\(log_2(n+1)\)
      • 最坏情况:O(n)
      • 平均情况:O(\(log_2n\))
    • 时间效率:O(\(nlog_2n\))
      递推方程:T[n] = 2T[n/2] + O(n) ,故时间复杂度为O(nlogn)
      快速排序的运行时间与划分是否
      对称
      有关,而后者又与具体使用的划分算法有关。

      • 最好情况:O(\(nlog_2n\)) (对称,即大于、小于枢轴的个数相等)
      • 最坏情况:O(\(n^2\)) (基本有序基本逆序)
      • 平均情况:O(\(nlog_2n\))

      推导过程:
      T[2k]=2T[2]+O(n)
      S(k)=2S(k-1)+O(2^k)
      S(k-1)=2S(k-2)+O(2^{k-1})
      ...
      S(1)=2S(0)+O(2^1)
      ∴S(k)=2{k-1}S(0)+[O(2k)+2O(2{k-1})+...+2O(2)]
      ∴S(k)=2{k-1}S(0)+kO(2k)
      ∴T[2k]=S(k)=2S(0)+kO(2^k)
      ∴T[n]=kn=nlogn

    • 稳定性:不稳定
      在划分算法中,若右端区间有两个关键字相同,且均小于基准值的记录,则在交换到左区间后,它们的相对位置会发生变化。

    • 适用性:
      适用于顺序存储的线性表。
      适用于数据量较大的排序表。

选择排序

简单选择排序

  • 基本思想:
    假设排序表为L[1...n],第i趟排序即从L[i...n]中选择关键字最小的元素与L(i)交换,每一趟排序可以确定一个元素的最终位置,这样经过n-1趟排序就可使得整个排序表有序。

  • 具体操作:
    非常好理解的排序算法,就是从无序的序列中,找到一个最小的,放在新序列中,然后从剩下的序列中再找到最小的,放在新序列第一个后面,以此类推,直到所有原序列所有元素全部选择完毕即可

  • 具体实现:

//对表A做简单选择排序,A[]从0开始存放元素
void SelectSort(ElemType A[], int n) {
	for(i = 0; i < n - 1; i++) {		//一共进行n-1趟
		min = i;				//记录最小元素的位置
		for(j = i + 1; j < n; j++) {	//在A[i...n-1]中选择最小的元素
			if(A[j] < A[min]) {	//更新最小元素位置
				min = j;
			}
		}
		if(min != i) {		//与第i个位置交换
			swap(A[i], A[min]);
		}
	}
}
  • 性能分析:
    • 空间效率:O(1)
      仅使用常数个辅助单元

    • 时间效率:O(\(n^2\))
      元素间比较次数与序列的初始状态无关,始终是n(n-1)/2次。

      • 最好情况:O(\(n^2\))
      • 最坏情况:O(\(n^2\))
      • 平均情况:O(\(n^2\))
    • 稳定性:不稳定
      在第i趟找到最小元素后,和第i个元素交换,可能会导致第i个元素与其含有相同关键字元素的相对位置发生改变。

    • 适用性:
      适用于顺序存储的线性表。
      适用于数据量较小的排序表和记录本身信息量较大的排序表。
      PS:当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。

堆排序(下标从1开始)

简单选择排序每次选择一个最小元素放在前面,想要提高排序速度,就要快速找到最小元,所以引入了堆,更快速地找到最小元。
堆排序是一种树形选择排序方法,其特点是:在排序过程中,将L[1...n]视为一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点的内在关系,在当前无序区中选择关键字最大(或最小)的元素。经常被用来实现优先级队列

注意:从根结点到任意结点路径上的结点序列具有有序性
堆排序因为是一种排序,所以它是在原有序列(顺序存储的完全二叉树)上进行调整,而不是一个一个插入。

  • 基本思想:
    堆排序的关键是构造初始堆,对初始序列建堆,是一个反复筛选的过程。
    n个结点的完全二叉树(下标从1开始),最后一个结点是第⌊n/2⌋个结点的孩子(即⌊n/2⌋最后一个分支结点,即最后一个非叶子结点)。

    原因:由完全二叉树的性质,最后一个结点为第n个结点,那么它的父亲(即 最后一个分支结点)肯定是⌊n/2⌋。如 i结点的左孩子2i,右孩子2i+1。

    1. 建堆
      对第⌊n/2⌋个结点为根的子树筛选(对于大根堆,若根节点的关键字小于左右孩子中的较大者,则交换,即让最大的当根节点),使该子树称为堆。之后向前依次对各节点(⌊n/2⌋-1 ~ 1)为根的子树不止是本身与左右孩子,还有下面延伸的所有结点)进行筛选,取自己与左右孩子之间的较大值为根,交换后可能会破坏下一级的堆,于是继续采用上述方法构造下一级的堆,直到以该节点为根的子树构成堆为止。反复利用上述调整堆的方法建堆,直到根节点。
    2. 堆排序(注意:堆可以看成一棵完全二叉树,所以其顺序存储时从下标为1开始存,所以用0暂存)
      首先将存放在L[1...n]中的n个元素建成初始堆,由于堆本身的特点(以大顶堆为例),堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时根节点已不满足大顶堆的性质,堆被破坏,将堆顶元素向下调整使其继续保持大顶堆的性质,再输出堆顶元素。如此反复,知道堆中仅剩下一个元素为止。
  • 具体操作:
    堆可以看成是一棵完全二叉树,特点是父亲大孩子小,或者父亲小孩子大,前者称为大顶堆,后者称为小顶堆
    这里简单描述一下堆排序的步骤,详情可参考教材:


    1. 线性序列建立为一棵完全二叉树依次摆放,然后根据堆的定义,调整堆的结构向下调整)(从最后一个分支结点⌊n/2⌋开始调整),最终使得根节点为最大(或最小)
    2. 在树中删除这个根节点,将最底层中最右边的(事实上也就是线性序列中最后面那个)叶子节点放到根部(实质上就是与根节点进行交换)(即 最大元素换在末端,也就是其最终位置上了)组成新堆
    3. 新建立的堆再进行一次堆排序,以此类推,最后可得到有序序列
  • 注意:
    如果使用最小堆,删除最小元素循环n次存入临时数组A[]中,最后还需要将临时数组中的元素存入原数组,那么还需要额外的数组存它,所以空间复杂度要O(n)

    如果使用最大堆,那么根结点的最大元素(排序应该放在最后一个位置)可以与最后一个元素互换,来放置到最终位置,然后调整堆,无需额外的存储空间。即 每次筛选一个最大的放在最后面

具体实现:

  • 向下调整堆:(建堆删除堆顶元素时可用)(最小堆大的往下调,最大堆小的往下调
    将k结点依次向下一层一层的与左右孩子比较,进行调整,直到到达最终位置
    (其实就是将k沿着最比它大(小)的路径向下进行调整(即 沿着这条路径中的有序结点序列进行排序),将k结点送到最终位置)
    (调整k的子树)
    (调整所有接受了调整的结点的子树
    (不仅是左右孩子,所有下辈都算)
    (如 第一步调整了k,k与左右孩子较大值i交换了,那么接下来就调整k(此时的k与i交换了)的子树,一层一层往下调整)
//函数AdjustDown将元素k向下进行调整(调整k的子树)
void AdjustDown(ElemType A[], int k, int len) {
	A[0] = A[k];		//A[0]暂存		此时k为根节点
	for(i = 2 * k; i <= len; i *= 2) {		//沿key较大的子结点向下筛选
	//i为2k,即i为k的左子节点
		if(i < len && A[i] < A[i+1]) {		//A[i] < A[i+1],即左子结点小于右子结点
			i++;		//取key较大的子结点的下标
		}
		if(A[0] >= A[i]) {		//根节点大于左右孩子中的较大值,筛选结束
			break;
		}else {		//根节点小于左右孩子中的较大值
			A[k] = A[i];		//将A[i](较大值)调整到双亲结点上,(这一步仅调整了它与左右孩子还没调整完它的子树)
			//(交换后可能会破坏下一级的堆)
			k = i;				//修改k值,取之前较大的孩子结点的位置,以便继续向下筛选出较大值并调整(即 调整k的子树)(不仅是左右孩子,所有下辈都算)
		}
	}//循环结束后,k指向了被筛选结点的最终位置
	A[k] = A[0];		//被筛选的结点值放入最终位置
}
  • 向上调整堆:(对堆进行插入操作时可用)
    (其实是将k沿着该结点k到根结点路径向上进行调整,(即 沿着这条路径中的有序的结点序列进行排序,将k结点送到最终位置)
    (依次排下来堆的性质不会被破坏)
//函数AdjustUp将元素k向上进行调整(调整k到根节点的路径)
void AdjustUp(ElemType A[], int k) {
//参数k为向上调整的结点,也为堆的元素个数(因为插入时新结点先放在堆的末端)
	A[0] = A[k];	//A[0]暂存 把k当指针用
	//int i = k / 2;	//i为k的双亲结点
	for(int i = k / 2; i>0 && A[i] < A[0]; i /= 2) {	//若结点值大于双亲结点,则将双亲结点下调,并继续向上比较
		A[k] = A[i];		//双亲结点i下调到子结点k
		//(上调后不会破坏上一级的堆)因为向上调整其实是沿着该结点到根结点的路径进行排序
		k = i;				//k指向双亲结点i
		//i = k / 2;			//i取此时的k的双亲结点,继续向上比较
	}//循环结束后,k指向了被筛选结点的最终位置
	A[k] = A[0];			//复制到最终位置
}
//函数AdjustUp将元素k向上进行调整(调整k到根节点的路径)
void AdjustUp(ElemType A[], int k) {
//参数k为向上调整的结点,也为堆的元素个数(因为插入时新结点先放在堆的末端)
	A[0] = A[k];	//A[0]暂存 把k当指针用
	int i = k / 2;	//i为k的双亲结点
	while(i > 0 && A[i] < A[0]) {	//若结点值大于双亲结点,则将双亲结点下调,并继续向上比较
		A[k] = A[i];		//双亲结点i下调到子结点k
		//(上调后不会破坏上一级的堆)因为向上调整其实是沿着该结点到根结点的路径进行排序
		k = i;				//k指向双亲结点i
		i = k / 2;			//i取此时的k的双亲结点,继续向上比较
	}//循环结束后,k指向了被筛选结点的最终位置
	A[k] = A[0];			//复制到最终位置
}
  • 建堆:
//建立大根堆
void BuildMaxHeap(ElemType A[], int len) {
	for(int i = len / 2; i > 0; i--) {	//从i=[n/2] ~ 1,反复向下调整堆
		AdjustDown(A, i, len);
	}
}
  • 堆排序:
//堆排序
void HeapSort(ElemType A[], int len) {
	BuildMaxHeap(A, len);		//建立初始大顶堆堆
	for(i = len; i > 1; i--) {		//n-1趟的交换和调整过程
		Swap(A[i], A[1]);		//输出堆顶元素(并和堆底元素交换)
		AdjustDown(A, 1, i-1);	//调整,把剩余的i-1个元素调整成大顶堆
	}
}
  • 性能分析:
    • 空间效率:O(1)
      仅使用了常数个辅助单元。

    • 时间效率:O(\(nlog_2n\))
      向下调整的时间与树高有关,为O(h)。建堆时间为O(n)(即 树中各结点的高度和),之后又n-1次向下调整操作,每次调整的时间复杂度为O(h)

      • 最好情况:O(\(nlog_2n\))
      • 最坏情况:O(\(nlog_2n\))
      • 平均情况:O(\(nlog_2n\))
    • 稳定性:不稳定
      进行筛选时,有可能把后面相同关键字的元素调整到前面。

    • 适用性:
      适用于线性表
      适用于数据量较大的排序表。

归并排序

  • 基本思想:
    “归并”的含义是将两个或两个以上的有序表组合成一个新的有序表。
    假定待排序表含有n个记录,则可将其视为n个有序的子表,每个子表的长度为1,然后两两归并,得到\(⌈n/2⌉\)个长度为2或1的有序表;再两两归并……如此重复,知道合并成一个长度为n的有序表位置,这种排序方法称为2路归并排序

    递归形式的2路归并排序算法是基于分治的。
    分解:将含有n个元素的待排序表分成各含\(n/2\)个元素的子表,采用2路归并排序算法对两个子表递归地进行排序。
    合并:合并两个已排序的子表得到排序结果。

  • 具体操作:
    将原始序列分组,再两两归并到一起(即 两个有序组从头开始元素依次比较,取最小元素放入新的有序组),形成新的有序组,再进行两两归并,以此类推,最后会归并到一组。
    image
    image
    image

  • 具体实现:

//表A的两段A[low...mid]和A[mid+1...high]各自有序,将它们合并成一个有序表(合并过程中排序)
ElemType *B = (ElemType *)malloc((n+1)*sizeof(ElemType));	//辅助数组B
void Merge(ElemType A[], int low, int mid, int high) {
	for(int k = low; k <= high; k++) {
		B[k] = A[k];		//将A中所有元素复制到B中
	}
	for(i = low, j = mid + 1, k = i; i <= mid && j <= high; k++) {
		if(B[i] <= B[j]) {	//比较B的左右两段中的元素
			A[k] = B[i++];	//将较小值复制到A中
		}else {
			A[k] = B[j++];
		}
	}
	while(i <= mid) {		//若有的表中还有元素尚未检测完,全部放入A中
		A[k++] = B[i++];
	}
	while(j <= high) {
		A[k++] = B[j++];
	}
}
//将A归并排序
void MergeSort(ElemType A[], int low, int high) {
	if(low >= high) {		//不能再继续分解了
		return;
	}
	int mid = (low + high) / 2;		//分解:从中间划分两个子序列
	MergeSort(A, low, mid);		//对左侧子序列进行递归排序
	MergeSort(A, mid+1, high);	//对右侧子序列进行递归排序
	
	Merge(A, low, mid, high);	//归并(并排序)
}
  • 性能分析:
    • 空间效率:O(n)
      Merge()操作中,两个有序组从头开始元素依次比较,取最小元素放入新的有序组,新的有序组辅助空间刚好占用n个单元

    • 时间效率:O(\(nlog_2n\))
      每趟归并的时间复杂度为O(n),共需进行\(⌈log_2n⌉\)趟归并

      • 最好情况:O(\(nlog_2n\))
      • 最坏情况:O(\(nlog_2n\))
      • 平均情况:O(\(nlog_2n\))
    • 稳定性:稳定
      由于Merge()操作不会改变相同关键字记录的相对次序

    • 适用性:
      适用于顺序存储的线性表。
      适用于数据量较大要求排序稳定的排序表。

PS:以上排序都是基于比较的排序方法,每次比较两个关键字大小之后,仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明:当文件的n个关键字随机分布时,任何借助于“比较”的排序算法,至少需要O(\(nlog_2n\))的时间。

对于任意序列进行基于比较的排序,求最少的比较次数,应考虑最坏情况。
对任意n个关键字排序的比较次数至少为\(⌈log_2(n!)⌉\)
上述公式证明如下:在基于比较的排序方法中,每次比较两个关键字后,仅出现两种可能的转移(不换)。假设整个排序过程至少需要做t次比较,则显然会有\(2^t\)种情况。由于n个记录共有n!(\(A^n_n\))种不同的排列,因而必须有n!种不同的比较路径,于是有\(2^t≥n!\),即\(t≥log_2(n!)\)。考虑到t为整数,故为\(⌈log_2(n!)⌉\)

基数排序

由每个数都分一个存储单元的桶排序(Bucket)而来。

基数排序分为最高位优先(MSD, Most Significant Dight)(即 从最高位开始向最低位排序)和最低位排序(LSD, Least Significant Dight)(即 从最低位开始向最高位排序)

  • 基本思想:
    基数排序是一种很特别的排序方法,它不基于比较进行排序,而采用多关键字排序思想(即基于关键字各位的大小进行排序)。
    将每个位数排好序,由低到高(由高到低),以r为基代表有r个桶基数进制的基数。
    这种排序是针对多关键字的情况,对于不同的关键字,例如数字,就分成0-9个“桶”,将每个元素放在与其对应关键字的“桶”内,然后就能得到有序的序列。可分为最低位优先和最高位优先两种方式
    最经典的例子就是麻将和扑克,例如麻将,有“万”“条”“筒”“风”“杂牌(中发白等)”,而且“万”“条”“筒”还分1-9,因此非常适合用这种排序方式,下面分别对最低位优先和最高位优先两种排序方式分别进行介绍(只讨论万筒条的情况)

    • 最高位优先就是,通过花色排序可将麻将不同花色放进这三种不同的“桶”内,然后对初步区分好的花色牌再按照数字1-9进行排序,则可将整副麻将变得有序
    • 最低位优先就是,先将麻将按照1-9放进不同的桶内,然后在根据花色将1-9桶的麻将依次放进这三种花色桶内,这样整幅麻将就变得有序
  • 具体操作:
    一般基数r为10,通常我们都是使用基数为10,就是每次对%10取余,可以为其他的值嘛,比如说8,也是可以的,只是隐形的讲10进制转化为8进制而已
    每趟分为分配(把序列分到每个桶里面去)与收集(把每个桶里面的元素拿出来合成一个序列)两部分。(“桶”其实就是“竖着的队列”
    往桶里面分配的时候,是从桶口向里面放;
    向桶外面收集的时候,是从桶底向外面拿。

    总结:上进下出

  • 性能分析:

    • 空间效率:O(r)
      一趟排序需要的辅助空间为r(r个队列)(桶),但以后的排序中会重复使用这些队列

    • 时间效率:O(d(n+r))
      基数排序需要进行d趟分配和收集,一趟分配需要O(n),一趟收集需要O(r)
      d为关键字位数n为关键字个数r(基数,radix)为进制的取值范围(如十进制为0~9)

      • 最好情况:O(d(n+r))
      • 最坏情况:O(d(n+r))
      • 平均情况:O(d(n+r))
    • 稳定性:稳定
      对于基数排序算法而言,按位排序是必须是稳定的,所以保证了基数排序的稳定性

    • 适用性:
      适用于顺序存储的线性表
      适用于数据量很大记录的关键字位数较少可以分解的排序表
      适用于多关键字排序

总结

  • 关于时间复杂度:

    1. 初始序列越无序快速排序的效率越高,最高为O(nlog2n)
      初始序列越有序快速排序的效率最低,最低为O(n^2)
    2. 堆排序归并排序的时间复杂度与初始序列无关,都为O(nlog2n)
    3. 二路归并排序效率高,而且稳定,但是它的缺点是需要额外的存储空间
    4. 选择类排序适合数据量比较大的场合,因为它是通过大量的比较后选择明确的记录进行有目的的移动
  • 不同条件下,排序方法的选择:

    1. 若n较小(如n≤50),可采用直接插入直接选择排序
      当记录规模较小时,直接插入排序较好;否则因为直接选择移动的记录数少于直接插入,因选择直接选择排序为宜
    2. 若文件初始状态基本有序(指正序),则应选用直接插入、冒泡或随机的快速排序为宜;
    3. 若n较大,则应采用时间复杂度为O(nlogn)的排序方法:快速排序堆排序归并排序
      • 快速排序是目前基于比较的内部排序中被认为是最好的方法,当排序的关键字是随机分布时,快速排序的平均时间最短
      • 堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况。但这两种排序都是不稳定的。
      • 若要求排序稳定,则可使用归并排序。
    4. 关键字随机分布时,任何基于“比较”的排序算法,至少需要O(nlogn)的时间
  • 总结

    • 不稳定:希尔排序、快速排序、简单选择排序、堆排序

    • 时间复杂度O(nlogn):快速排序、堆排序、归并排序

    • 每趟排序结束后能够确定一个元素最终位置:冒泡排序、快速排序、简单选择排序、堆排序

    • 平均性能最好:快速排序

    • 总排序趟数与初始状态无关的有:(除了快速排序和优化的冒泡,其他都是)

    • 算法复杂度与初始状态无关的有:堆排序、归并排序、选择排序、基数排序。

    • 元素总比较次数与初始状态无关的有:选择排序、基数排序。

    • 元素总移动次数与初始状态无关的有:归并排序、基数排序。

表排序

要排序结构体这种信息量很大的数据,如果每次都要移动,花费很高。所以设立表指针,只用移动表指针。

  • 间接排序
    定义一个指针数组作为“表”(table)

  • 物理排序
    N个数字的排列由若干个独立的环组成。

  • 性能分析:

    • 时间效率:
      • 最好情况:初始即有序
      • 最坏情况:有N/2个环,每个环包含2个元素,需要3N/2次元素移动T = O(mN),m是每个A元素的复制时间。

剖析JDK8中Arrays.sort底层原理及其排序算法的选择

参考文章:https://www.jianshu.com/p/d7ba7d919b80

写这篇文章的初衷,是想写篇Java和算法的实际应用,让算法不再玄乎,而Arrays.sort是很好的切入点,即分析Java的底层原理,又能学习里面的排序算法思想。希望能给在座各位在工作中或面试中一点帮助!

点进sort方法:

      // Use Quicksort on small arrays
      if (right - left < QUICKSORT_THRESHOLD) {//QUICKSORT_THRESHOLD = 286
        sort(a, left, right, true);
        return;
      }

元素少于47用插入排序

数组一进来,会碰到第一个阀值QUICKSORT_THRESHOLD(286),注解上说,小过这个阀值的进入Quicksort(快速排序),其实并不全是,点进去sort(a, left, right, true)方法:

// Use insertion sort on tiny arrays
    if (length < INSERTION_SORT_THRESHOLD) {
        if (leftmost) {
        ......
点进去后我们看到第二个阀值INSERTION_SORT_THRESHOLD(47),如果元素少于47这个阀值,就用插入排序,往下看确实如此:

            /*
             * Traditional (without sentinel) insertion sort,
             * optimized for server VM, is used in case of
             * the leftmost part.
             */
            for (int i = left, j = i; i < right; j = ++i) {
                int ai = a[i + 1];
                while (ai < a[j]) {
                    a[j + 1] = a[j];
                    if (j-- == left) {
                        break;
                    }
                }
                a[j + 1] = ai;

image

元素大于47用快速排序

至于大过INSERTION_SORT_THRESHOLD(47)的,用一种快速排序的方法:

  1. 从数列中挑出五个元素,称为 “基准”(pivot);
  2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。

快速排序(Quick Sort)
image

元素大于286用归并排序

这是少于阀值QUICKSORT_THRESHOLD(286)的两种情况,至于大于286的,它会进入归并排序(Merge Sort),但在此之前,它有个小动作:

    // Check if the array is nearly sorted
    for (int k = left; k < right; run[count] = k) {
        if (a[k] < a[k + 1]) { // ascending
            while (++k <= right && a[k - 1] <= a[k]);
        } else if (a[k] > a[k + 1]) { // descending
            while (++k <= right && a[k - 1] >= a[k]);
            for (int lo = run[count] - 1, hi = k; ++lo < --hi; ) {
                int t = a[lo]; a[lo] = a[hi]; a[hi] = t;
            }
        } else { // equal
            for (int m = MAX_RUN_LENGTH; ++k <= right && a[k - 1] == a[k]; ) {
                if (--m == 0) {
                    sort(a, left, right, true);
                    return;
                }
            }
        }

        /*
         * The array is not highly structured,
         * use Quicksort instead of merge sort.
         */
        if (++count == MAX_RUN_COUNT) {
            sort(a, left, right, true);
            return;
        }
    }

这里主要作用是看他数组具不具备结构:实际逻辑是分组排序,每降序为一个组,像1,9,8,7,6,8。9到6是降序,为一个组,然后把降序的一组排成升序:1,6,7,8,9,8。然后最后的8后面继续往后面找。。。

每遇到这样一个降序组,++count,当count大于MAX_RUN_COUNT(67),被判断为这个数组不具备结构(也就是这数据时而升时而降),然后送给之前的sort(里面的快速排序)的方法(The array is not highly structured,use Quicksort instead of merge sort.)。

如果count少于MAX_RUN_COUNT(67)的,说明这个数组还有点结构,就继续往下走下面的归并排序:

   // Determine alternation base for merge
    byte odd = 0;
    for (int n = 1; (n <<= 1) < count; odd ^= 1);
从这里开始,正式进入归并排序(Merge Sort)!

    // Merging
    for (int last; count > 1; count = last) {
        for (int k = (last = 0) + 2; k <= count; k += 2) {
            int hi = run[k], mi = run[k - 1];
            for (int i = run[k - 2], p = i, q = mi; i < hi; ++i) {
                if (q >= hi || p < mi && a[p + ao] <= a[q + ao]) {
                    b[i + bo] = a[p++ + ao];
                } else {
                    b[i + bo] = a[q++ + ao];
                }
            }
            run[++last] = hi;
        }
        if ((count & 1) != 0) {
            for (int i = right, lo = run[count - 1]; --i >= lo;
                b[i + bo] = a[i + ao]
            );
            run[++last] = right;
        }
        int[] t = a; a = b; b = t;
        int o = ao; ao = bo; bo = o;
    }

归并排序(Merge Sort)
image

总结:
从上面分析,Arrays.sort并不是单一的排序,而是插入排序,快速排序,归并排序三种排序的组合,为此我画了个流程图:
image

原创图,转发请标明出处
算法的选择:
PS:关于排序算法的文章,推荐这两篇,个人觉得写得挺好,容易入门:
https://mp.weixin.qq.com/s/t0dsJeN397wO41pwBWPeTg
https://www.cnblogs.com/huangbw/p/7398418.html

稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面;

不稳定:如果a原本在b的前面,而a=b,排序之后a可能会出现在b的后面;

时间复杂度按n越大算法越复杂来排的话:常数阶O(1)、对数阶O(logn)、线性阶O(n)、线性对数阶O(nlogn)、平方阶O(n²)、立方阶O(n³)、……k次方阶O(n的k次方)、指数阶O(2的n次方)。

image
转图
O(nlogn)只代表增长量级,同一个量级前面的常数也可以不一样,不同数量下面的实际运算时间也可以不一样。数量非常小的情况下(就像上面说到的,少于47的),插入排序等可能会比快速排序更快。
所以数组少于47的会进入插入排序。

快排数据越无序越快(加入随机化后基本不会退化),平均常数最小,不需要额外空间,不稳定排序。
归排速度稳定,常数比快排略大,需要额外空间,稳定排序。
所以大于或等于47或少于286会进入快排,而在大于或等于286后,会有个小动作:“// Check if the array is nearly sorted”。这里第一个作用是先梳理一下数据方便后续的归并排序,第二个作用就是即便大于286,但在降序组太多的时候(被判断为没有结构的数据,The array is not highly structured,use Quicksort instead of merge sort.),要转回快速排序。

这就是jdk8中Arrays.sort的底层原理,自己在研究和分析中学到很多,希望能给各位工作中或面试中一些启发和帮助!Thanks for watching!

实例

有时候我们会遇到需要自定义排序规则的情况,如题

912. 排序数组

给你一个整数数组 nums,请你将该数组升序排列。

示例 1:

输入:nums = [5,2,3,1]
输出:[1,2,3,5]

示例 2:

输入:nums = [5,1,1,2,0,0]
输出:[0,0,1,1,2,5]

折半插入排序

class Solution {
    public int[] sortArray(int[] nums) {
        for (int i = 1; i < nums.length; i++) {
            int leftIndex = 0, rightIndex = i - 1;
            int tempIndex = i; // 默认为当前i
            int cur = nums[i];
            while (leftIndex <= rightIndex) {
                int midIndex = leftIndex + (rightIndex - leftIndex) / 2;
                if (cur < nums[midIndex]) {
                    tempIndex = midIndex; // 如果当前元素小于等于中间元素,那么就记录此位置
                    rightIndex = midIndex - 1;

                } else {
                    leftIndex = midIndex + 1;
                }
            }
			// 最后循环结束tempIndex记录的位置一定是cur元素应该放入的位置
            // 挖坑法,i被挖出来了,i-1填坑,从后往前填坑
            for (int j = i; j > tempIndex; j--) {
                nums[j] = nums[j - 1];
            }
            nums[tempIndex] = cur;
        }
        return nums;
    }
}

冒泡排序

class Solution {
    public int[] sortArray(int[] nums) {
        int rightIndex = nums.length - 1;    // 右最终边界
    
        for(int i = 1; i < nums.length; i++) { //趟数从1开始,第一趟...
            int rightTemp = 0;  // 右临时边界
    
            for(int j = 0; j < rightEnd; j++) { //一趟冒泡过程,从0到n-1都往后比较一个
                if(nums[j] > nums[j + 1]) {
    
                    int t = nums[j];
                    nums[j] = nums[j + 1];
                    nums[j + 1] = t;
    
                    rightTemp = j;  // 右边界用来存放最后一次发生交换的位置
                }
            }
    
            rightIndex = rightTemp;   // 设置右最终边界
            if (rightIndex == 0) {    // 如果最后一次发生交换的位置是默认值0,那就代表没有改变
                break;
            }
        }
        return nums;
    }
}

快速排序

class Solution {
    public int[] sortArray(int[] nums) {

        if (nums == null) {
            return null;
        }

        QuickSort(nums, 0, nums.length - 1);
        return nums;
    }

    public void QuickSort(int A[], int leftIndex, int rightIndex) {
        if (leftIndex >= rightIndex) {   //不能划分了,递归出口
            return;
        }
        int midIndex = Partition(A, leftIndex, rightIndex);     //划分
        QuickSort(A, leftIndex, midIndex - 1);      //依次对两个子表进行递归排序划分
        QuickSort(A, midIndex + 1, rightIndex);
    }

    // 划分算法(一趟排序过程)
    public int Partition(int[] nums, int leftIndex, int rightIndex) {

        int temp = nums[leftIndex];

        while (leftIndex < rightIndex) {
            // 内层循环在循环过程中得满足外层循环的条件,不然内层循环结束就直接跳出外层循环了
            while (leftIndex < rightIndex && nums[rightIndex] >= temp) rightIndex--;
            nums[leftIndex] = nums[rightIndex];

            while (leftIndex < rightIndex && nums[leftIndex] <= temp) leftIndex++;
            nums[rightIndex] = nums[leftIndex];
        }

        nums[leftIndex] = temp;
        return leftIndex;
    }
}

179. 最大数

给定一组非负整数 nums,重新排列每个数的顺序(每个数不可拆分)使之组成一个最大的整数。

注意:输出结果可能非常大,所以你需要返回一个字符串而不是整数。

示例 1:

输入:nums = [10,2]
输出:"210"

示例 2:

输入:nums = [3,30,34,5,9]
输出:"9534330"

答案

对于 nums 中的任意两个值 a 和 b,我们无法直接从常规角度上确定其大小/先后关系。

但我们可以根据「结果」来决定 a 和 b 的排序关系:

如果拼接结果 ab 要比 ba 好,那么我们会认为 a 应该放在 b 前面。

另外,注意我们需要处理前导零(最多保留一位)。

class Solution {
    public String largestNumber(int[] nums) {
        int n = nums.length;
        String[] strs = new String[n];
        for (int i = 0; i < n; i++) strs[i] = String.valueOf(nums[i]);
		// 这块需要额外注意,sort接口如果需要自定义比较器,那么数组参数一定得是泛型T的引用数据类型,而不能是int这种基础类型
        Arrays.sort(strs, (a, b) -> {
            String ab = a + b, ba = b + a ;
            return ba.compareTo(ab);
        });
        
        StringBuilder sb = new StringBuilder();
        for (String s : strs) sb.append(s);
		
		// 下面就是用于排除[0,0]的特例,去除掉多余的0
        int len = sb.length();
        int k = 0;
        while (k < len - 1 && sb.charAt(k) == '0') k++;
        return sb.substring(k);
    }
}
  • 时间复杂度:由于是对 String 进行排序,当排序对象不是 Java 中的基本数据类型时,不会使用快排(考虑排序稳定性问题)。Arrays.sort() 的底层实现会「元素数量/元素是否大致有序」决定是使用插入排序还是归并排序。这里直接假定使用的是「插入排序」。复杂度为 \(O(n^2)\)
  • 空间复杂度:\(O(n)\)

56. 合并区间

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。

示例 1:

输入:intervals = [[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].

示例 2:

输入:intervals = [[1,4],[4,5]]
输出:[[1,5]]
解释:区间 [1,4] 和 [4,5] 可被视为重叠区间。

排序

思路

如果我们按照区间的左端点排序,那么在排完序的列表中,可以合并的区间一定是连续的。如下图所示,标记为蓝色、黄色和绿色的区间分别可以合并成一个大区间,它们在排完序的列表中是连续的:
image


算法

我们用数组 merged 存储最终的答案。

首先,我们将列表中的区间按照左端点升序排序。然后我们将第一个区间加入 merged 数组中,并按顺序依次考虑之后的每个区间:

  • 如果当前区间的左端点在数组 merged 中最后一个区间的右端点之后,那么它们不会重合,我们可以直接将这个区间加入数组 merged 的末尾;

  • 否则,它们重合,我们需要用当前区间的右端点更新数组 merged 中最后一个区间的右端点,将其置为二者的较大值。


正确性证明

上述算法的正确性可以用反证法来证明:在排完序后的数组中,两个本应合并的区间没能被合并,那么说明存在这样的三元组 \((i, j, k)\) 以及数组中的三个区间 \(a[i], a[j], a[k]\) 满足 \(i < j < k\) 并且 \((a[i], a[k])\) 可以合并,但 \((a[i], a[j])\)\((a[j], a[k])\) 不能合并。这说明它们满足下面的不等式:

\[a[i].end < a[j].start \quad (a[i] \text{ 和 } a[j] \text{ 不能合并}) \]

\[a[j].end < a[k].start \quad (a[j] \text{ 和 } a[k] \text{ 不能合并}) \]

\[a[i].end \geq a[k].start \quad (a[i] \text{ 和 } a[k] \text{ 可以合并}) \]

我们联立这些不等式(注意还有一个显然的不等式 \(a[j].start \leq a[j].end\)),可以得到:

\[a[i].end < a[j].start \leq a[j].end < a[k].start \]

产生了矛盾!这说明假设是不成立的。因此,所有能够合并的区间都必然是连续的。

链表

class Solution {
    public int[][] merge(int[][] intervals) {
        if (intervals.length == 0) {
            return new int[0][2];
        }
        Arrays.sort(intervals, (i1, i2) -> {
            return i1[0] - i2[0];
        });

        LinkedList<int[]> res = new LinkedList<>();
        for (int i = 0; i < intervals.length; i++) {

            if (res.isEmpty() || res.getLast()[1] < intervals[i][0]) {
                res.add(intervals[i]);
            } else {
                res.getLast()[1] = Math.max(res.getLast()[1], intervals[i][1]);
            }
        }

        return res.toArray(new int[res.size()][]);
    }
}

数组

class Solution {
    public int[][] merge(int[][] intervals) {
        if (intervals.length == 0) {
            return new int[0][2];
        }
        Arrays.sort(intervals, (interval1, interval2) -> {
            return interval1[0] - interval2[0];
        });
		
        List<int[]> merged = new ArrayList<int[]>();
        for (int i = 0; i < intervals.length; ++i) {
            int L = intervals[i][0], R = intervals[i][1];
            if (merged.isEmpty() || merged.get(merged.size() - 1)[1] < L) {
                merged.add(new int[]{L, R});
            } else {
                merged.get(merged.size() - 1)[1] = Math.max(merged.get(merged.size() - 1)[1], R);
            }
        }
        return merged.toArray(new int[merged.size()][]);
    }
}

复杂度分析

  • 时间复杂度:\(O(n\log n)\),其中 n 为区间的数量。除去排序的开销,我们只需要一次线性扫描,所以主要的时间开销是排序的 \(O(n\log n)\)

  • 空间复杂度:\(O(\log n)\),其中 n 为区间的数量。这里计算的是存储答案之外,使用的额外空间。\(O(\log n)\) 即为排序所需要的空间复杂度。

posted @ 2019-07-24 09:02  Nemo&  阅读(1230)  评论(0)    收藏  举报