Bootstrap

Java算法——排序

引言

在一些场景中,我们需要对数据进行排序,就如之前提到的冒泡排序,这篇文章将讲述一些主流的排序算法。

1. 插入排序

1.1 基本思想

插入排序的基本思想是将数组分为已排序和未排序两部分,逐步将未排序部分的元素插入到已排序部分的适当位置,直到整个数组有序。

1.2 直接插入排序

// 插入排序
public static void insertSort(int[] array) {
    for (int i = 1; i < array.length; i++) {
        int tmp = array[i];
        int j = i - 1;
        for (; j >= 0; j--) {
            if (array[j] > tmp) {
                array[j + 1] = array[j];
            } else {
                break;
            }
        }
        array[j + 1] = tmp;
    }
}
  1. 从数组的第二个元素开始(索引为1),认为第一个元素是已排序的。 取出当前元素 tmp,并将其与已排序部分的元素从后向前比较。
  2. 如果已排序部分的元素大于 tmp,则将该元素向后移动一位。
  3. 重复上述步骤,直到找到一个不大于 tmp 的元素位置。
  4. 将 tmp 插入到该位置。
  5. 重复步骤2-5,直到所有元素都插入到已排序部分。

时间复杂度:O(n2)
空间复杂度:O(1)
稳定性:稳定

1.3 希尔排序

希尔排序的基本思想是通过将数组分成若干子序列分别进行插入排序,从而加快排序速度。它是对直接插入排序的一种改进。

// 希尔排序
public static void shellSort(int[] array) {
    int gap = array.length / 2;
    while (gap > 0) {
        shell(array, gap);
        gap /= 2;
    }
}

public static void shell(int[] array, int gap) {
    for (int i = gap; i < array.length; i++) {
        int tmp = array[i];
        int j = i - gap;
        for (; j >= 0; j -= gap) {
            if (array[j] > tmp) {
                array[j + gap] = array[j];
            } else {
                break;
            }
        }
        array[j + gap] = tmp;
    }
}
  1. shellSort 方法初始化间隔 gap 为数组长度的一半。
  2. 在 gap 大于0的情况下,调用 shell 方法对数组进行分组排序,然后将 gap 减半。
  3. shell 方法对每个间隔为 gap 的子序列进行插入排序。
  4. 在 shell 方法中,从索引 gap 开始,取出当前元素 tmp,并将其与间隔为 gap 的已排序部分的元素从后向前比较。
  5. 如果已排序部分的元素大于 tmp,则将该元素向后移动 gap 位。
  6. 重复上述步骤,直到找到一个不大于 tmp 的元素位置。
  7. 将 tmp 插入到该位置。
  8. 重复步骤4-7,直到所有子序列都排序完成。

希尔排序由于gap值不确定,时间复杂度并没有确切的值,但是时间复杂度是比直接插入排序要低的。
空间复杂度:O(1)
稳定性:不稳定

2. 选择排序

2.1 基本思想

每⼀次从待排序的数据元素中选出最⼩(或最⼤)的⼀个元素,存放在序列的起始位置,直到全部待
排序的数据元素排完。

2.2 直接选择排序

// 选择排序
public static void selectSort(int[] array) {
    for (int i = 0; i < array.length; i++) {
        int minIndex = i;
        for (int j = i + 1; j < array.length; j++) {
            if (array[j] < array[minIndex]) {
                minIndex = j;
            }
        }
        if (minIndex != i) {
            swap(array, i, minIndex);
        }
    }
}

// 辅助方法:交换数组中的两个元素
private static void swap(int[] array, int i, int j) {
    int temp = array[i];
    array[i] = array[j];
    array[j] = temp;
}
  1. 从数组的第一个元素开始,认为第一个元素是已排序的。
  2. 在未排序部分中找到最小的元素,并记录其索引 minIndex。
  3. 将最小元素与当前元素交换位置(如果 minIndex 不等于当前索引 i)。
  4. 重复步骤2-3,直到所有元素都排序完成。

时间复杂度:O(n2)
空间复杂度:O(1)
稳定性:不稳定

2.3 直接选择排序变种

直接选择排序的变种在每一趟排序中同时找到未排序部分的最小值和最大值,并分别将它们放到未排序部分的两端。

public static void selectSort2(int[] array) {
    int left = 0;
    int right = array.length - 1;
    while (left < right) {
        int minIndex = left;
        int maxIndex = left;
        for (int i = left + 1; i <= right; i++) {
            if (array[i] < array[minIndex]) {
                minIndex = i;
            }
            if (array[i] > array[maxIndex]) {
                maxIndex = i;
            }
        }
        if (minIndex != left) {
            swap(array, minIndex, left);
        }
        if (maxIndex == left) {
            maxIndex = minIndex;
        }
        if (maxIndex != right) {
            swap(array, maxIndex, right);
        }
        left++;
        right--;
    }
}

// 辅助方法:交换数组中的两个元素
private static void swap(int[] array, int i, int j) {
    int temp = array[i];
    array[i] = array[j];
    array[j] = temp;
}
  1. 初始化两个指针 left 和 right,分别指向数组的两端。
  2. 在未排序部分中找到最小值和最大值的索引 minIndex 和 maxIndex。
  3. 将最小值与 left 位置的元素交换。
  4. 如果最大值的索引是 left,则更新 maxIndex 为 minIndex,因为最小值已经被交换到 left 位置。
  5. 将最大值与 right 位置的元素交换。
  6. 移动 left 和 right 指针,缩小未排序部分的范围。
  7. 重复步骤2-6,直到 left 和 right 相遇。

2.4 堆排序

堆排序是一种基于堆数据结构的排序算法,利用堆的性质来排序数组。

// 堆排序
public static void heapSort(int[] array) {
    createHeap(array);
    int end = array.length - 1;
    while (end > 0) {
        swap(array, 0, end);
        siftDown(array, 0, end);
        end--;
    }
}

// 创建最大堆
public static void createHeap(int[] array) {
    for (int p = (array.length - 1 - 1) / 2; p >= 0; p--) {
        siftDown(array, p, array.length);
    }
}

// 向下调整堆
private static void siftDown(int[] array, int p, int length) {
    int c = 2 * p + 1;
    while (c < length) {
        if (c + 1 < length && array[c] < array[c + 1]) {
            c++;
        }
        if (array[c] > array[p]) {
            swap(array, c, p);
            p = c;
            c = 2 * p + 1;
        } else {
            break;
        }
    }
}

// 辅助方法:交换数组中的两个元素
private static void swap(int[] array, int i, int j) {
    int temp = array[i];
    array[i] = array[j];
    array[j] = temp;
}
  1. 创建最大堆:调用 createHeap 方法,将数组转换为最大堆。

     从最后一个非叶子节点开始,向上逐个节点进行向下调整(siftDown),确保每个子树都是最大堆。
    
  2. 排序过程:

     将堆顶元素(最大值)与当前未排序部分的最后一个元素交换。
     调整堆顶元素,使剩余部分重新成为最大堆(siftDown)。
     缩小未排序部分的范围,重复上述步骤,直到整个数组有序。
    

时间复杂度:O(nlogn)
空间复杂度:O(1)
稳定性:不稳定

3. 交换排序

3.1 基本思想

交换排序的基本思想是通过比较和交换数组中的元素,使数组逐步有序。

3.2 冒泡排序

冒泡排序通过重复地遍历数组,每次比较相邻的两个元素,如果它们的顺序错误就交换它们的位置。这样,每一趟遍历都会将当前未排序部分的最大元素“冒泡”到数组的末尾。重复这个过程,直到整个数组有序。

    //冒泡排序
    public static void bubbleSort(int[] array) {
        for (int i = 0; i < array.length; i++) {
            boolean flg = true;
            for (int j = 0; j < array.length - i - 1; j++) {
                if (array[j] > array[j + 1]) {
                    swap(array, j, j + 1);
                    flg = false;
                }
            }
            if (flg) {
                break;
            }
        }
    }
  1. 外层循环:遍历数组的每一个元素,控制排序的趟数。
  2. 内层循环:从数组的第一个元素开始,比较相邻的两个元素,如果前一个元素大于后一个元素,就交换它们的位置。
  3. 标志变量 flg:用于检测当前趟排序是否发生了交换。如果在某一趟排序中没有发生交换,说明数组已经有序,可以提前结束排序。
  4. 交换函数 swap:用于交换数组中的两个元素。

时间复杂度:O(n2)
空间复杂度:O(1)
稳定性:稳定

3.3 快速排序

快速排序的基本思想是通过选择一个枢轴元素,将数组分成两部分,使得左侧部分的所有元素都小于枢轴,右侧部分的所有元素都大于枢轴。然后递归地对左右两部分进行快速排序,直到整个数组有序。快速排序有多种排序方法,先介绍基本结构。

3.3.1 快速排序的基本结构

public static void quickSort(int[] array) {
        quick(array, 0, array.length - 1);
    }

    public static void quick(int[] array, int left, int right) {
        if (left >= right) {
            return;
        }
        int index = partition(array, left, right);
        quick(array, left, index - 1);
        quick(array, index + 1, right);
    }
  1. 快速排序入口方法 (quickSort):

     这是快速排序的入口方法,接收一个数组 array 作为参数。
     调用 quick 方法,对整个数组进行排序,初始的左右边界分别是 0 和 array.length - 1。
    
  2. 递归排序方法 (quick):

    这是快速排序的递归方法,用于对数组的某个子区间进行排序。
    参数 left 和 right 分别表示当前子区间的左边界和右边界。
    基本条件:如果 left 大于或等于 right,说明当前子区间已经有序或只有一个元素,直接返回。
    调用 partition 方法,将数组分成两部分,并返回枢轴元素的位置 index。
    递归地对左侧部分(left 到 index - 1)进行排序。
    递归地对右侧部分(index + 1 到 right)进行排序。
    

下面介绍partition的各种实现方法:

3.3.2 Hoare法

Hoare法的partition(分区)算法是快速排序中的一种分区方法。它通过选择一个枢轴元素,将数组分成两部分,使得左侧部分的所有元素都小于等于枢轴,右侧部分的所有元素都大于等于枢轴。

    private static int partition1(int[] array, int left, int right) {
        int i = left;
        int j = right;
        int pivot = array[left];
        while (i < j) {
            while (i < j && array[j] >= pivot) {
                j--;
            }
            while (i < j && array[i] <= pivot) {
                i++;
            }
            swap(array, i, j);
        }
        swap(array, i, left);
        return i;
    }
  1. 选择枢轴:

     选择最左边的元素作为枢轴(pivot)。
    
  2. 初始化指针:

     初始化两个指针 i 和 j,分别指向数组的左边界和右边界。
    
  3. 分区过程:

     使用两个 while 循环,分别从右向左和从左向右扫描数组:
     从右向左找到第一个小于枢轴的元素,更新指针 j。
     从左向右找到第一个大于枢轴的元素,更新指针 i。
     如果 i 小于 j,交换 i 和 j 指向的元素。
     重复上述过程,直到 i 和 j 相遇或交错。
    
  4. 交换枢轴:

     将枢轴元素放到正确的位置,即 i 位置。
     返回 i 作为分区点。
    

3.3.3 挖坑法

    public static int partition(int[] array, int left, int right) {
        int base = array[left];
        int i = left;
        int j = right;
        while (i < j) {
            while (i < j && array[j] >= base) {
                j--;
            }
            array[i] = array[j];
            while (i < j && array[i] <= base) {
                i++;
            }
            array[j] = array[i];
        }
        array[i] = base;
        return i;
    }
  1. 选择枢轴:

     选择最左边的元素作为枢轴(base)。
    
  2. 初始化指针:

     初始化两个指针 i 和 j,分别指向数组的左边界和右边界。
    
  3. 分区过程:

     使用两个 while 循环,分别从右向左和从左向右扫描数组:
     从右向左找到第一个小于枢轴的元素,更新指针 j,并将该元素放到 i 位置。
     从左向右找到第一个大于枢轴的元素,更新指针 i,并将该元素放到 j 位置。
     重复上述过程,直到 i 和 j 相遇或交错。
    
  4. 交换枢轴:

     将枢轴元素放到正确的位置,即 i 位置。
     返回 i 作为分区点。
    

3.3.4 双指针法

  private static int partition(int[] array, int left, int right) {
        int prev = left;
        int cur = left + 1;
        while (cur <= right) {
            if (array[cur] < array[left] && array[++prev] != array[cur]) {
                swap(array, cur, prev);
            }
            cur++;
        }
        swap(array, prev, left);
        return prev;
    }
  1. 选择枢轴:

     选择最左边的元素作为枢轴(array[left])。
    
  2. 初始化指针:

     初始化两个指针 prev 和 cur,分别指向数组的左边界和枢轴的下一个位置。
    
  3. 分区过程:

     使用 while 循环,从 cur 指针开始遍历数组,直到 cur 超过右边界 right。
     如果 cur 指针指向的元素小于枢轴元素,并且 prev 指针和 cur 指针指向的元素不同,则交换 cur 和 prev 指针指向的元素。
     每次交换后,prev 指针向右移动一位。
     cur 指针每次循环后向右移动一位。
    
  4. 交换枢轴:

     将枢轴元素放到正确的位置,即 prev 位置。
     返回 prev 作为分区点。
    

3.4 快速排序非递归法

非递归快速排序通过使用栈来模拟递归调用,从而避免了递归带来的栈溢出问题。

    public static void quickSortNonR(int[] a, int left, int right) {
        Stack<Integer> st = new Stack<>();
        st.push(left);
        st.push(right);
        while (!st.empty()) {
            right = st.pop();
            left = st.pop();
            if (right - left <= 1) {
                continue;
            }
            int div = partition(a, left, right);
            st.push(div + 1);
            st.push(right);
            st.push(left);
            st.push(div);
        }
    }
  1. 初始化栈:

     创建一个栈 st,用于存储数组的左右边界。
     将初始的左右边界 left 和 right 压入栈中。
    
  2. 循环处理:

     当栈不为空时,循环执行以下步骤:
     从栈中弹出 right 和 left,表示当前需要处理的子数组的左右边界。
     如果子数组的长度小于等于1(即 right - left <= 1),则跳过当前循环,继续处理下一个子数组。
     调用 partition 方法对当前子数组进行分区,返回枢轴位置 div。
     将右侧子数组的左右边界(div + 1 和 right)压入栈中。
     将左侧子数组的左右边界(left 和 div)压入栈中。
    
  3. 分区方法:

     partition 方法用于将数组分成两部分,使得左侧部分的所有元素都小于等于枢轴,右侧部分的所有元素都大于等于枢轴。
    

3.5 快速排序分析

时间复杂度:O(nlogn)
空间复杂度:O(logn)
稳定性:不稳定

4. 归并排序

归并排序是一种基于分治法的排序算法。它将数组分成两个子数组,分别对这两个子数组进行排序,然后将排序后的子数组合并成一个有序的数组。

4.1 基本思想

  1. 分解:将数组分成两个子数组,分别对这两个子数组进行排序。
  2. 递归排序:递归地对每个子数组进行归并排序。
  3. 合并:将两个有序的子数组合并成一个有序的数组。

4.1 归并排序递归

// 归并排序
public static void mergeSort(int[] array) {
    mergeSortChild(array, 0, array.length - 1);
}

// 递归排序子数组
public static void mergeSortChild(int[] array, int left, int right) {
    if (left >= right) {
        return;
    }
    int mid = (left + right) / 2;
    mergeSortChild(array, left, mid);
    mergeSortChild(array, mid + 1, right);
    merge(array, left, mid, right);
}

// 合并两个有序子数组
public static void merge(int[] array, int left, int mid, int right) {
    int s1 = left;
    int s2 = mid + 1;
    int[] tmp = new int[right - left + 1];
    int i = 0;
    while (s1 <= mid && s2 <= right) {
        if (array[s1] <= array[s2]) {
            tmp[i++] = array[s1++];
        } else {
            tmp[i++] = array[s2++];
        }
    }
    while (s1 <= mid) {
        tmp[i++] = array[s1++];
    }
    while (s2 <= right) {
        tmp[i++] = array[s2++];
    }
    for (int j = 0; j < tmp.length; j++) {
        array[left + j] = tmp[j];
    }
}
  1. 归并排序入口方法 (mergeSort):

     这是归并排序的入口方法,接收一个数组 array 作为参数。
     调用 mergeSortChild 方法,对整个数组进行排序,初始的左右边界分别是 0 和 array.length - 1。
    
  2. 递归排序子数组 (mergeSortChild):

     这是归并排序的递归方法,用于对数组的某个子区间进行排序。
     参数 left 和 right 分别表示当前子区间的左边界和右边界。
     基本条件:如果 left 大于或等于 right,说明当前子区间已经有序或只有一个元素,直接返回。
     计算中间位置 mid,将数组分成两部分。
     递归地对左侧部分(left 到 mid)进行排序。
     递归地对右侧部分(mid + 1 到 right)进行排序。
     调用 merge 方法,将两个有序的子数组合并成一个有序的数组。
    
  3. 合并两个有序子数组 (merge):

     参数 left、mid 和 right 分别表示当前子区间的左边界、中间位置和右边界。
     初始化两个指针 s1 和 s2,分别指向两个子数组的起始位置。
     创建一个临时数组 tmp,用于存储合并后的有序数组。
     使用 while 循环,将两个子数组中的元素按顺序合并到临时数组 tmp 中。
     将剩余的元素(如果有)复制到临时数组 tmp 中。
     将临时数组 tmp 中的元素复制回原数组 array 中。
    

时间复杂度:O(nlogn)
空间复杂度:O(n)
稳定性:稳定

4.2 归并排序非递。

非递归归并排序(也称为迭代归并排序)通过逐步增加子数组的大小来实现排序。

    public static void mergeSortNoR(int[] array) {
        int gap = 1;
        while(gap < array.length) {
            for (int i = 0; i < array.length; i += 2 * gap) {
                int left = i;
                int mid = left + gap - 1;
                if(mid >= array.length - 1) {
                    mid = array.length - 1;
                }
                int right = mid + gap;
                if(right >= array.length) {
                    right = array.length - 1;
                }
                merge(array, left, mid, right);
            }
            gap *= 2;
        }
    }

  1. 初始化间隔 (gap):

     初始化间隔 gap 为1,表示初始的子数组大小为1。
    
  2. 外层循环:

     当 gap 小于数组长度时,继续循环。
     每次循环将 gap 翻倍,表示子数组的大小逐步增加。
    
  3. 内层循环:

     遍历数组,将数组分成若干个大小为 2 * gap 的子数组。
     计算每个子数组的左边界 left、中间位置 mid 和右边界 right。
     调用 merge 方法,将两个有序的子数组合并成一个有序的数组。
    

5. 不基于比较的排序

不基于比较的排序算法主要包括计数排序、基数排序和桶排序。这些算法不通过比较元素来排序,而是利用元素的值来确定其位置,从而实现线性时间复杂度的排序。这里就介绍计数排序。

5.1 计数排序

计数排序适用于元素值范围较小的情况。它通过统计每个元素出现的次数,然后根据这些计数来确定每个元素的位置。

public static void countingSort(int[] array) {
    int max = array[0];
    int min = array[0];
    // 找到数组中的最大值和最小值
    for (int i = 1; i < array.length; i++) {
        if (array[i] > max) {
            max = array[i];
        }
        if (array[i] < min) {
            min = array[i];
        }
    }
    
    // 创建计数数组
    int len = max - min + 1;
    int[] countArray = new int[len];
    
    // 统计每个元素出现的次数
    for (int i = 0; i < array.length; i++) {
        int index = array[i] - min;
        countArray[index]++;
    }

    // 根据计数数组重新填充原数组
    int k = 0;
    for (int i = 0; i < countArray.length; i++) {
        while (countArray[i] > 0) {
            array[k++] = i + min;
            countArray[i]--;
        }
    }
}
  1. 找到最大值和最小值:

     遍历数组,找到数组中的最大值 max 和最小值 min。
    
  2. 创建计数数组:

     根据最大值和最小值计算计数数组的长度 len,即 max - min + 1。
     创建一个长度为 len 的计数数组 countArray,用于统计每个元素出现的次数。
    
  3. 统计每个元素出现的次数:

     遍历原数组,对于每个元素 array[i],计算其在计数数组中的索引 index,即 array[i] - min。
     将计数数组中对应位置的值加1,表示该元素出现了一次。
    
  4. 根据计数数组重新填充原数组:

     遍历计数数组,对于每个非零的计数值,将对应的元素填充回原数组。
     使用一个指针 k 来记录当前填充的位置。
     对于计数值 countArray[i],将元素 i + min 填充回原数组 countArray[i] 次。
    

6. 总结

以上就是一些常用的排序算法的介绍和代码实现,具体如何排序,可以看这里的链接: 十大经典排序算法动画与解析

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;