排序方法
冒泡排序
基本思想
- 它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。
- 走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。
- 这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个。
代码实现
function bubbleSort(arr) {
// 获取数组长度,以确定循环次数。
let len = arr.length;
// 遍历数组len次,以确保数组被完全排序
for (let i = 0; i < len; i++) {
// 遍历数组的前len-i项,忽略后面的i项(已排序部分)
for (let j = 0; j < len - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
}
}
}
return arr
}
具体分析
- 冒泡排序是原地排序算法吗 ?
答:冒泡排序是一个原地排序算法,过程只涉及相邻数据的交换操作,只需要常量级的临时空间 - 冒泡排序是稳定的排序算法吗 ?
答:对于相同数值的元素我们并没有进行比较和交换位置,所以相同数值元素的相对位置不会发生改变,因此冒泡排序算法是稳定的排序算法。 - 冒泡排序的时间复杂度是多少 ?
在完全有序的情况下,最好的时间复杂度是O(n),只需要1次冒泡。而在极端情况完全逆序,时间复杂度为O(n^2).
插入排序
基本思想
- 将数组中的所有元素依次跟前面已经排好的元素相比较,如果选择的元素比已排序的元素小,则交换,直到全部元素都比较过。
- 第一层循环:遍历待比较的所有数组元素
- 第二层循环:将本轮选择的元素与前面已经排好序的元素相比较,若当前元素小,则进行交换
代码实现
function insertionSort(arr) {
let len = arr.length;
for (let i = 1; i < len; i++) {
let j = i;
let temp = arr[i];
while (j > 0 && arr[j - 1] > temp) {
arr[j] = arr[j - 1];
j--;
}
arr[j] = temp;
}
return arr;
}
具体分析
- 插入排序是原地排序算法吗 ?
答:不牵涉额外得到其他空间。所以是原地排序算法 - 插入排序是稳定的排序算法吗 ?
答:在插入排序算法中,对于相同数值的元素可以选择插入到已排序区间等值元素的后边,所以相同数值元素的相对位置不会发生改变,因此插入排序算法是稳定的排序算法。 - 冒泡排序的时间复杂度是多少 ?
在完全有序的情况下,插入排序每个未排序区间元素只需要比较1次,所以时间复杂度是O(n)。而在极端情况完全逆序,时间复杂度为O(n^2),就等于每次都把未排序元素插入到数组第一位
快速排序
优点:速度快,效率高
缺点:需要另外声明两个数组,浪费了内存空间资源
基本思想
- 先找到一个基准点(一般指数组的中部),然后数组被该基准点分为两部分,依次与该基准点数据比较,如果比它小,放左边;反之,放右边。
- 左右分别用一个空数组去存储比较后的数据。
- 最后递归执行上述操作,直到数组长度 <= 1
代码实现
方案一
// 方案1
function quickSort1(arr) {
if (arr.length < 2) {
return arr;
}
//取基准点
const midIndex = Math.floor(arr.length / 2);
//取基准点的值,splice(index,1) 则返回的是含有被删除的元素的数组。
const valArr = arr.splice(midIndex, 1);
console.log(valArr)
const midIndexVal = valArr[0];
const left = [];//存放比基准点小的数组
const right = [];//存放比基准点大的数组
for (let i = 0; i < arr.length; i++) {
if (arr[i] < midIndexVal) {
left.push(arr[i])//比基准点小的放在左边数组
} else {
right.push(arr[i])//比基准点大的放在右边数组
}
}
//递归执行以上操作,对左右两个数组进行操作,直到数组长度为 <= 1
return quickSort1(left).concat(midIndexVal, quickSort1(right));
}
方案二
// 方案2
function quickSort2(arr) {
if (arr.length < 2) { return arr }
let left = 0,
right = arr.length - 1;
main(arr, left, right);
return arr
function main(arr, left, right) {
// 递归结束的条件,直到数组只包含一个元素。
if (arr.length === 1) {
// 由于是直接修改arr,所以不用返回值。
return;
}
// 获取left指针,准备下一轮分解。
let index = partition(arr, left, right);
if (left < index - 1) {
// 继续分解左边数组。
main(arr, left, index - 1);
}
if (index < right) {
// 分解右边数组。
main(arr, index, right);
}
}
// 数组分解函数。
function partition(arr, left, right) {
// 选取中间项为参考点。
let pivot = arr[Math.floor((left + right) / 2)];
// 循环直到left > right。
while (left <= right) {
// 持续右移左指针直到其值不小于pivot。
while (arr[left] < pivot) {
left++;
}
// 持续左移右指针直到其值不大于pivot。
while (arr[right] > pivot) {
right--;
}
// 此时左指针的值不小于pivot,右指针的值不大于pivot。
// 如果left仍然不大于right。
if (left <= right) {
// 交换两者的值,使得不大于pivot的值在其左侧,不小于pivot的值在其右侧。
[arr[left], arr[right]] = [arr[right], arr[left]];
// 左指针右移,右指针左移准备开始下一轮,防止arr[left]和arr[right]都等于pivot然后导致死循环。
left++;
right--;
}
}
// 返回左指针作为下一轮分解的依据。
return left;
}
}
具体分析
- 快速排序是原地排序算法吗 ?
答:因为 partition() 函数进行分区时,不需要很多额外的内存空间,所以快排是原地排序算法。 - 快速排序是稳定的排序算法吗 ?
答:和选择排序相似,快速排序每次交换的元素都有可能不是相邻的,因此它有可能打破原来值为相同的元素之间的顺序。因此,快速排序并不稳定。 - 快速排序的时间复杂度是多少 ?
- 极端的例子:如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n / 2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n^2)。
- 最佳情况:T(n) = O(nlogn)。
- 最差情况:T(n) = O(n^2)。
- 平均情况:T(n) = O(nlogn)。
归并排序
基本思想
- 排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
- 归并排序采用的是分治思想。
- 分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
代码实现
function mergeSort(arr) {
//采用自上而下的递归方法
const len = arr.length;
if (len < 2) {
return arr;
}
let middle = Math.floor(len / 2),
left = arr.slice(0, middle),
right = arr.slice(middle);
return merge(mergeSort(left), mergeSort(right));
function merge(left, right) {
const result = [];
while (left.length && right.length) {
// 注意: 判断的条件是小于或等于,如果只是小于,那么排序将不稳定.
if (left[0] <= right[0]) {
result.push(left.shift());
} else {
result.push(right.shift());
}
}
while (left.length) {
result.push(left.shift())
}
while (right.length) {
result.push(right.shift());
}
return result
}
}
具体分析
- 归并排序是原地排序算法吗 ?
- 这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。
- 实际上,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
- 所以,归并排序不是原地排序算法。
- 归并排序是稳定的排序算法吗 ?
答:merge 方法里面的left[0] <= right[0]
,保证了值相同的元素,在合并前后的先后顺序不变。归并排序是一种稳定的排序方法。 - 归并排序的时间复杂度是多少 ?
- 从效率上看,归并排序可算是排序算法中的佼佼者。假设数组长度为 n,那么拆分数组共需 logn 步, 又每步都是一个普通的合并子数组的过程,时间复杂度为 O(n),故其综合时间复杂度为 O(nlogn)。
- 最佳情况:T(n) = O(nlogn)。
- 最差情况:T(n) = O(nlogn)。
- 平均情况:T(n) = O(nlogn)。
希尔排序
基本思想
- 先将整个待排序的记录序列分割成为若干子序列。
- 分别进行直接插入排序。
- 待整个序列中的记录基本有序时,再对全体记录进行依次直接插入排序。
代码实现
function shellSort(arr) {
let len = arr.length,
temp, gap = 1;
while (gap < len / 3) { gap = gap * 3 + 1 };
for (gap; gap > 0; gap = Math.floor(gap / 3)) {
for (let i = gap; i < len; i++) {
temp = arr[i];
let j = i - gap;
for (; j >= 0 && arr[j] > temp; j -= gap) {
arr[j + gap] = arr[j]
}
arr[j + gap] = temp
}
}
return arr
}
具体分析
- 希尔排序是原地排序算法吗 ?
答:希尔排序过程中,只涉及相邻数据的交换操作,只需要常量级的临时空间,空间复杂度为 O(1) 。所以,希尔排序是原地排序算法。 - 希尔排序是稳定的排序算法吗 ?
- 单次直接插入排序是稳定的,它不会改变相同元素之间的相对顺序
- 但在多次不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,可能导致相同元素相对顺序发生变化。
- 因此,希尔排序不稳定。
- 希尔排序的时间复杂度是多少 ?
最佳情况:T(n) = O(n logn)。
最差情况:T(n) = O(n (log(n))2)。
平均情况:T(n) = 取决于间隙序列。
堆排序
堆的定义
堆其实是一种特殊的树。只要满足这两点,它就是一个堆。
堆是一个完全二叉树。
- 完全二叉树:除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
也可以说:堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。
对于每个节点的值都大于等于子树中每个节点值的堆,我们叫作大顶堆。
对于每个节点的值都小于等于子树中每个节点值的堆,我们叫作小顶堆。
基本思想
- 将初始待排序关键字序列 (R1, R2 … Rn) 构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素 R[1] 与最后一个元素 R[n] 交换,此时得到新的无序区 (R1, R2, … Rn-1) 和新的有序区 (Rn) ,且满足 R[1, 2 … n-1] <= R[n]。
- 由于交换后新的堆顶 R[1] 可能违反堆的性质,因此需要对当前无序区 (R1, R2 … Rn-1) 调整为新堆,然后再次将 R[1] 与无序区最后一个元素交换,得到新的无序区 (R1, R2 … Rn-2) 和新的有序区 (Rn-1, Rn)。不断重复此过程,直到有序区的元素个数为 n - 1,则整个排序过程完成。
代码实现
function heapSort(arr) {
// 初始化大顶堆,从第一个非叶子结点开始
for (let i = Math.floor(arr.length / 2) - 1; i >= 0; i--) {
heapify(arr, i, arr.length)
}
// 排序,每一次 for 循环找出一个当前最大值,数组长度减一
for (let i = Math.floor(arr.length - 1); i > 0; i--) {
// 根节点与最后一个节点交换
swap(arr, 0, i);
// 从根节点开始调整,并且最后一个结点已经为当前最大值,不需要再参与比较,所以第三个参数为 i,即比较到最后一个结点前一个即可
heapify(array, 0, i);
}
return arr;
function swap(arr, i, j) {
let temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
// 将 i 结点以下的堆整理为大顶堆,注意这一步实现的基础实际上是:
// 假设结点 i 以下的子堆已经是一个大顶堆,heapify 函数实现的功能是实际上是:找到 结点 i 在包括结点 i 的堆中的正确位置。
// 后面将写一个 for 循环,从第一个非叶子结点开始,对每一个非叶子结点都执行 heapify 操作,所以就满足了结点 i 以下的子堆已经是一大顶堆
function heapify(arr, i, length) {
let temp = array[i]; // 当前父节点
// j < length 的目的是对结点 i 以下的结点全部做顺序调整
for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
temp = arr[i]; // 将 array[i] 取出,整个过程相当于找到 array[i] 应处于的位置
if (j + 1 < length && arr[j] < arr[j + 1]) {
j++; // 找到两个孩子中较大的一个,再与父节点比较
}
if (temp < arr[j]) {
swap(arr, i, j); // 如果父节点小于子节点:交换;否则跳出
i = j; // 交换后,temp 的下标变为 j
} else {
break
}
}
}
}
具体分析
- 堆排序是原地排序算法吗 ?
整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。 - 堆排序是稳定的排序算法吗 ?
因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。
所以,堆排序是不稳定的排序算法。 - 堆排序的时间复杂度是多少 ?
堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。
最佳情况:T(n) = O(nlogn)。
最差情况:T(n) = O(nlogn)。
平均情况:T(n) = O(nlogn)。