数据结构与算法——排序

一、概述

1.1、排序的分类

  1. 比较的次数
    在这种方法中,采用基于比较的次数对排序算法进行分类。对于基于比较的排序算法,在最好情况下时间复杂度为O(nlogn),而在最坏情况下则为O(n2)。基于比较的排序算法通过关键字比较操作来排列序列元素,并且对于大多数输人至少需要O(nlog)次比较。
  2. 交换的次数
    在此方法中,排序算法以交换的次数来对算法分类。
  3. 内存使用
    有些排序算法是“原地的”(即不占用额外的内存空间),仅需要O(1)或O(1og)的内存开销用于创建临时排序数据的辅助存储位置。
  4. 递归
    排序算法可以是递归(如快速排序)或非递归(如选择排序和插入排序)排序,也有同时采用递归和非递归的排序算法(如归并排序)
  5. 稳定性
    假设对于所有的索引i和j使得键值A[门等于A[门,并且在原始文件中R[]领先于R[门。若在排序后序列中记录R[门仍然位于R[门前面,则称这种排序算法是稳定的。少数排序算法能够维持具有相等键值元素的相对次序(即使排序后相等元素仍然保持它们的相对位置)。
  6. 适应性
    少数排序算法的复杂度依赖于序列的初始排列情况(如快速排序),输入的初始排列将会影响算法的运行时间,有这种情况出现的算法称作适应算法。

1.2、其它分类方法

  1. 内部排序
    在排序时仅使用主存储器的排序算法称为内部排序(internal sort)。该算法对所有的存储器都能够高速随机存取。
  2. 外部排序
    在排序时需要使用磁带或磁盘等外部存储器的排序算法都属于外部排序(external sort).。

二、排序算法

2.1、冒泡排序

冒泡排序(bubble sort)是一种最简单的排序算法。其基本思想是迭代地对输入序列中的第一个元素到最后一个元素进行两两比较,当需要时交换这两个元素(位置)。该过程持续迭代直到在一趟排序过程中不需要交换操作为止。冒泡排序得名于键值较小的元素如同“气泡”一样逐渐漂浮到序列的顶端。通常,插入排序比冒泡排序有更好的性能。

由于冒泡排序的简单性和复杂度,有些学者建议不讲授该排序算法。相比于其他排序算法冒泡排序的唯一显著优势是,它可以检测输人序列是否已经是排序的。

/**
 * 冒泡排序
 * @param arr
 */
public void bubbleSort(int arr[]) {
    int n = arr.length;
    boolean swapped;
    for (int i = 0; i < n - 1; i++) {
        swapped = false;
        for (int j = 0; j < n - i - 1; j++) {
            // 比较相邻元素,如果顺序错误则交换
            if (arr[j] > arr[j + 1]) {
                // 交换 arr[j+1] 和 arr[j]
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
                swapped = true; // 表示发生了交换
            }
        }
        // 如果在这一趟遍历中没有发生交换,说明数组已经有序,可以提前结束排序
        if (!swapped) break;
    }
}
  • 最坏情况下时间复杂度:O(n2)
  • 最好情况下时间复杂度(改进版):O(n)
  • 平均情况下时间复杂度(基本版):O(n2)
  • 最坏情况下空间复杂度:O(1)辅助

2.2、选择排序

选择排序(selection sort))是一种原地(in-place)排序算法,适用于小文件。由于选择操作是基于键值的且交换操作只在需要时才执行,所以选择排序常用于数值较大和键值较小的文件。

  1. 优点
    • 容易实现。
    • 原地排序(不需要额外的存储空间)。
  2. 缺点
    • 扩展性较差:O(n2)。
  3. 算法
    • 寻找序列中的最小值。
    • 用当前位置的值交换最小值。
    • 对所有元素重复上述过程,直到整个序列排序完成。
      该算法称为选择排序,因为它重复选择最小的元素。
/**
 * 选择排序
 * @param arr
 */
public void selection(int arr[]) {
    int i, j, min, temp;
    for (i = 0; i < arr.length - 1; i++) {
        min = i;
        for (j = i + 1; j < arr.length; j++) {
            if (arr[j] < arr[min])
                min = j;
        }
        //交换元素
        temp = arr[min];
        arr[min] = arr[i];
        arr[i] = temp;
    }
}
  • 最坏情况下时间复杂度:O(n2)
  • 最好情况下时间复杂度:O(n)
  • 平均情况下时间复杂度:O(n2)
  • 最坏情况下空间复杂度:O(1)辅助

2.3、插入排序

插入排序(insertion sort)是一种简单且有效的比较排序算法。在每次迭代过程中算法随机地从输入序列中移除一个元素,并将该元素插人待排序序列的正确位置。重复该过程,直到所有输入元素都被选择一次。

优点:

  • 实现简单。
  • 数据量较少时效率高。
  • 适应性(adaptive):如果输入序列已预排序(可能是不完全的预排序),则时间复杂度为O(n+d),d是反转的次数。
  • 算法的实际运行效率优于选择排序和冒泡排序,即使在最坏情况下三个算法的时间复杂度均为O(n)。
  • 稳定性(stable):键值相同时它能够保持输人数据的原有次序。
  • 原地(in-place):仅需要常量O(1)的辅助内存空间。
  • 即时(online):插入排序能够在接收序列的同时对其进行排序

插入排序重复如下过程:每次从输入数据中移除一个元素并将其插入已排序序列的正确位置,直到所有输入元素都插入有序序列中。插入排序是典型的原地排序。经过k次迭代后数组具有性质:前十1个元素已经排序。
在这里插入图片描述
每个与x比较且大于x的元素复制到x的右边。

/**
 * 插入排序
 * @param arr
 */
public void insertionSort(int[] arr) {
     for (int i = 1; i < arr.length; i++) {
         int key = arr[i];
         int j = i - 1;
         while (j >= 0 && arr[j] > key) {
             arr[j + 1] = arr[j];
             j--;
         }
         arr[j + 1] = key;
     }
}

示例
6 8 1 4 5 3 7 2 ,按升序排序。
6 8 1 4 5 3 7 2 (考虑索引位置0)
6 8 1 4 5 3 7 2 (考虑索引位置0~1)
1 6 8 4 5 3 7 2 (考虑索引位置02:在6和8前插入1)
1 4 6 8 5 3 7 2 (重复以上过程直到序列被排序)
1 4 5 6 8 3 7 2
1 3 4 5 6 7 8 2
1 2 3 4 5 6 7 8(已排序的序列!)

  • 最坏情况下时间复杂度:O(n2)
  • 最好情况下时间复杂度:O(n2)
  • 平均情况下时间复杂度:O(n2)
  • 最坏情况下空间复杂度:O(n2) 总计,O(1) 辅助

插入排序是一种最坏情况下时间复杂度为O(n2)的基本排序算法。在数据几乎都已经排序或者输入数据规模较小时可以使用插入排序。由于上述原因以及插入排序的稳定性,插入排序可用于归并和快速排序等高开销的分治排序算法的递归基础情形(当问题规模小时)。

2.4、希尔排序

希尔排序(shell sort)又称为缩小增量排序(diminishing increment sort),算法由Donnald Shell提出而得名。该算法是一个泛化的插入排序。插人排序在输入序列几乎已经有序的情况下非常有效。希尔排序也称为n间距(n-gap)插入排序。希尔排序分多路并使用不同的间距来比较相邻元素,而不是仅比较相邻对。通过逐步减少间距,最终以1为间距或者进行一次常规的插入排序即可。

插入排序中的比较是在两个相邻元素之间进行的,每次比较最多进行1次反转交换。希尔排序利用可变增量使算法直到最后一步才比较相邻元素,所以希尔排序的最后一步是一个有效的插入排序算法。希尔排序通过允许比较和交换具有一定距离的元素对插排序进行改进。希尔排序是比较排序算法中第一个低于二次复杂度的算法。

本质上希尔排序是对插入排序的一种简单扩展。两者的主要差异在于希尔排序具有交换相距较远元素的能力,这使得它能较快地把元素交换到所需位置。例如,如果初始数组中最小的元素恰好位于数组的末端,那么插入排序需要经过对整个数组的比较和交换才能把最小元素放置到数组的开头,而希尔排序使元素能够一次移动多步而非一步,从而能够以较少的交换次数把元素放到合适的位置。

希尔排序的主要思想是比较和交换数组中每个距离为h的元素。对其进一步解释可使算法的思路更加清晰:h决定相距多远的元素能够进行交换,例如,如果h值为13,则第1个元素(索引0)将与第14个元素(索引13)进行交换(如果需要)。第2个元素与第15个元素进行交换,以此类推。如果h为1,则希尔排序就与常规插入排序完全一样。

希尔排序首先选择足够大的间距h(但不能超过数组的大小)开始排序,这样能允许相具较远的合适元素进行交换。一旦以某个特定的五完成排序,则称数组是以h间距排序的数组。接下来的步骤是以某一确定的序列依次减少间距h的值,并重新开始新一轮以h为间距的排序。一旦h变为1且是h间距排序的,则数组排序完成。注意,由于h的最后一个序列值为1,所以最后一次排序总是一个插入排序,但此时数组已经变得基本有序且更容易排序。

希尔排序使用的序列h1,h2,…,ht称为增量序列。任何增量序列都是可以的只要h1=1,且某些增量序列的效果要优于其他的增量序列。希尔排序把输入序列分成大小相同的多路子序列,然后对每路利用插入排序算法进行排序。希尔排序通过快速移动元素值到目的位置来提高插入排序的效率。

希尔排序算法步骤:

  1. ‌选择初始增量‌:通常选择数组长度的一半作为初始增量。
  2. ‌分组‌:将数组分成多个子序列,每个子序列的间隔为当前的增量。
  3. ‌插入排序‌:对每个子序列进行插入排序。
  4. ‌缩小增量‌:重复上述步骤,每次将增量减半,直到增量为1。
  5. ‌最终排序‌:当增量为1时,整个数组作为一个子序列进行插入排序,此时算法完成。
/**
 * 希尔排序
 * @param arr
 */
public void shellSort(int[] arr) {
    int n = arr.length;
    //初始增量设为数组长度的一半
    for (int gap = n / 2; gap > 0; gap /= 2) {
        //对每个子序列进行插入排序
        for (int i = gap; i < n; i++) {
            //保存当前元素
            int temp = arr[i];
            //对当前元素及之前之前的元素进行比较和插入排序
            int j;
            for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
                arr[j] = arr[j - gap];
            }
            arr[j] = temp;
        }
    }
}

希尔排序对中等大小的序列非常有效,对于较大的序列它不是最好的选择,但希尔排序是所有时间复杂度为O(n)的排序算法中最快的算法。
希尔排序的缺点是它的算法思路复杂且远不及归并、堆和快速排序有效。希尔排序明显比归并、堆和快速排序慢,但它却是一种相对简单的算法。若不考虑速度的重要性,
希尔排序对于数据量小于5000的序列是不错的选择。对较小的列表进行重复排序时,希尔排序也是一个非常好的选择。
使用希尔排序的最佳情况是序列已经完全排序,此时比较次数较少。希尔排序的运行时间取决于所选择的增量序列。

  • 最坏情况下时间复杂度取决于间隔序列。最好情况:O(nlog2n)
  • 最好情况下时间复杂度:O(n)
  • 平均情况下时间复杂度取决于间隔序列
  • 最坏情况下空间复杂度:O(n)

2.5、并归排序

归并排序(merge sort)是分治的一个实例。

  • 归并是把两个已排序文件合并成一个更大的已排序文件的过程。
  • 选择是把一个文件分成包含k个最小元素和一k个最大元素两个部分的过程。
  • 选择和归并互为逆操作:
    • 选择把一个序列分成两部分。
    • 归并把两个文件合并成一个文件。
  • 归并排序是快速排序的补充。
  • 归并排序以连续的方式访问数据。
  • 归并排序适用于链表排序。
  • 归并排序对输人的初始次序不敏感。
  • 快速排序中的大部分任务在递归调用前完成。快速排序从最大子文件开始并以最小子文件结束,因此需要栈结构。此外,快速排序算法也不稳定。归并排序把序列分为两个部分,并对每个部分分别处理。归并排序从最小子文件开始并以最大子文件结束,因此不需要栈,并且归并排序算法是稳定的算法。

归并排序首先将数组分成半,直到每个子数组只有一个元素,然后开始合并这些子数组,每次合并都将它们排序,直到整个数组变得有序。

/**
 * 并归排序
 * @param arr
 */
// 主函数,用于调用归并排序
public void sort(int[] arr) {
    if (arr.length < 2) {
        return; // 基准情形,如果数组只有一个元素或为空,则无需排序
    }
    int[] temp = new int[arr.length]; // 辅助数组,用于合并过程中的临时存储
    mergeSort(arr, temp, 0, arr.length - 1);
}

// 递归的归并排序函数
private void mergeSort(int[] arr, int[] temp, int leftStart, int rightEnd) {
    if (leftStart >= rightEnd) {
        return; // 基准情形,如果子数组只有一个元素,则无需排序
    }
    int middle = (leftStart + rightEnd) / 2;
    mergeSort(arr, temp, leftStart, middle); // 左边归并排序,使左半部分有序
    mergeSort(arr, temp, middle + 1, rightEnd); // 右边归并排序,使右半部分有序
    mergeHalves(arr, temp, leftStart, rightEnd); // 合并两个有序的半部分
}

// 合并两个有序的半部分
private static void mergeHalves(int[] arr, int[] temp, int leftStart, int rightEnd) {
    int leftEnd = (rightEnd + leftStart) / 2;
    int rightStart = leftEnd + 1;
    int size = rightEnd - leftStart + 1;

    int left = leftStart;
    int right = rightStart;
    int index = leftStart;

    // 复制数据到temp数组
    while (left <= leftEnd && right <= rightEnd) {
        if (arr[left] <= arr[right]) {
            temp[index] = arr[left];
            left++;
        } else {
            temp[index] = arr[right];
            right++;
        }
        index++;
    }

    // 将左边剩余的元素复制到temp中(如果有)
    System.arraycopy(arr, left, temp, index, leftEnd - left + 1);
    // 将右边剩余的元素复制到temp中(如果有)
    System.arraycopy(arr, right, temp, index, rightEnd - right + 1);
    // 将排序后的元素从temp复制回原数组中
    System.arraycopy(temp, leftStart, arr, leftStart, size);
}
  • 最坏情况下时间复杂度:O(nlogn)
  • 最好情况下时间复杂度:O(nlogn)
  • 平均情况下时间复杂度:O(nlogn)
  • 最坏情况下空间复杂度:O(n) 辅助

2.6、堆排序

堆排序(heapsort)是一种基于比较的排序算法,该算法同时属于选择排序。尽管在大多数计算机上堆排序的运行效率低于快速排序,但它的优势是在最坏情况下运行时间也仅为⊙(nlogn)。堆排序是一种不稳定的原地排序算法。

在堆的数据结构中,堆中的最大值总是位于根节点(在优先队列中使用堆的话堆中的最小值位于根节点)。堆中定义以下几种操作:

  1. 最大堆调整(Max Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点
  2. 创建最大堆(Build Max Heap):将堆中的所有数据重新排序
  3. 堆排序(HeapSort):移除位在第一个数据的根节点,并做最大堆调整的递归运算
/**
 * 堆排序
 * @param arr
 */
public void heapSort(int[] arr) {
    int n = arr.length;
    //构建最大堆
    for (int i = n / 2 - 1; i >= 0; i--) {
        heapify(arr, n, i);
    }
    //一个个从堆顶取出元素
    for (int i = n - 1; i > 0; i--) {
        //移动当前根到末尾
        int temp = arr[0];
        arr[0] = arr[i];
        arr[i] = temp;
        //调用heapify调整堆
        heapify(arr, i, 0);
    }
}


//将一个数组调整为最大堆
private void heapify(int[] arr, int n, int i) {
    int largest = i;//初始化最大为根
    int left = 2 * i + 1;//左子节点
    int right = 2 * i + 2;//右子节点

    //如果左子节点大于根节点
    if (left < n && arr[left] > arr[largest]) {
        largest = left;
    }
    //如果右子节点大于现在的最大值
    if (right < n && arr[right] > arr[largest]) {
        largest = right;
    }
    //如果最大值不是根节点,则交换
    if (largest != i) {
        int swap = arr[i];
        arr[i] = arr[largest];
        arr[largest] = swap;
        //递归地调整受影响的子树
        heapify(arr, n, largest);
    }
}
  • 最坏情况下时间复杂度:O(nlogn)
  • 最好情况下时间复杂度:O(nlogn)
  • 平均情况下时间复杂度:O(nlogn)
  • 最坏情况下空间复杂度:O(n) 总计,O(1) 辅助

2.7、快速排序

快速排序(quicksort))是分治算法技术的一个实例,也称为分区交换排序。快速排序采用递归调用对元素进行排序,是基于比较的排序算法中的一个著名算法。

在Java中实现快速排序算法(Quick Sort)是一种高效的排序算法,其基本思想是选择一个元素作为"基准"(pivot),然后将数组分成两个子数组,一个包含所有小于基准的元素,另一个包含所有大于基准的元素。这个过程称为分区(partitioning)。然后,递归地对这两个子数组进行快速排序。

递归算法由以下4步组成:

  1. 如果数组中仅有一个元素或者没有元素需要排序,则返回。
  2. 选择数组中的一个元素作为枢轴(pivot)点(通常选择数组最左边的元素)。
  3. 把数组分成两部分一一部分元素大于枢轴,而另一部分元素小于枢轴。
  4. )对两部分数组递归调用该算法。
/**
 * 快速排序
 * @param arr
 * @param low
 * @param high
 */
public void quickSort(int[] arr, int low, int high) {
    if (low < high) {
        //pi是分区操作后基准的位置
        int pi = partition(arr, low, high);
        //递归排序左子数组
        quickSort(arr, low, pi - 1);
        //递归排序右子数组
        quickSort(arr, pi + 1, high);
    }
}

private int partition(int[] arr, int low, int high) {
    //选择最后一个元素作为基准元素
    int pivot = arr[high];
    //i是小于区间的最后一个元素的索引
    int i = (low - 1);

    for (int j = low; j < high; j++) {
        //如果当前元素小于或等于pivot
        if (arr[j] <= pivot) {
            i++;
            //交换arr[i]和arr[j]
            int temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
        }
    }
    //把pivot元素放到中间位置
    int temp = arr[i + 1];
    arr[i + 1] = arr[high];
    arr[high] = temp;
    //返回pivot的正确位置
    return  i + 1;
}
  • 最坏情况下时间复杂度:O(n2)
  • 最好情况下时间复杂度:O(nlogn)
  • 平均情况下时间复杂度:O(nlogn)
  • 最坏情况下空间复杂度:O(2)

2.8、计数排序

计数排序(counting sort)不属于比较排序算法,其排序复杂度为O(n)。为获得O(n)的复杂度,计数排序假定每个输入元素都是1~K之间的整数(k为整数)。当K=O(n)时,计数排序的运行时间为O(n)。计数排序的基本思想是,对于每一个输入元素X,确定小于X的元素的个数,根据此信息就可以将X直接放到正确的位置。例如,如果有10个元素小于X,则X的输出位置为11。

在Java中实现计数排序(Counting Sort),你需要遵循以下步骤:

  1. ‌找出数组中的最大值和最小值‌:这有助于确定计数数组(也称为哈希表)的大小。
  2. 构建计数数组‌:根据数组中的每个元素,在计数数组中相应地增加计数
  3. ‌计算排序后的索引‌:通过累加计数数组,为原始数组中的每个元素计算其在排序后数组中的位置。
  4. 构建排序后的数组‌:根据计算出的索引,将元素放置在正确的位置。
/**
 * 计数排序
 * @param arr
 */
public void countingSort(int[] arr) {
    if (arr.length <= 1) {
        return ;
    }
    //找出最大值和最小值
    int max = arr[0];
    int min = arr[0];
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] > max) {
            max = arr[i];
        }
        if (arr[i] < min) {
            min = arr[i];
        }
    }

    //构建计数数组,大小取决于最大值和最小值的差加1
    int range = max - min + 1;
    int[] count = new int[range];
    //填充计数数组
    for (int i = 0; i < arr.length; i++) {
        //因为最小值可能不是0,所以要减去min来调整索引
        count[arr[i] - min]++;
    }
    //计算排序后的索引,并构建排序后的数组
    int index = 0;//用于在原数组中插入排序后的元素
    for (int i = 0; i < range; i++) {
        while (count[i]-- > 0) {
            //当count[i]大于0时,执行循环
            arr[index++] = i + min;//因为要加回min以回复原值
        }
    }

}

总复杂度:若K=O(n),则O(K)+O(n)+O(K)+O(n)=O(n)。若K=O(n),则空间复杂度为O(n)。

注意事项:
‌- 稳定性‌:计数排序是一种稳定的排序算法,因为它不会改变相同元素的原始顺序。

  • ‌适用场景‌:当输入的范围不是很大,并且数据分布比较均匀时,计数排序效率较高。对于非常大的范围或数据分布非常不均匀的情况,计数排序可能不是最优选择。
  • ‌空间复杂度‌:计数排序的空间复杂度为O(n+k),其中n是输入数组的大小,k是输入数据的范围。如果k非常大,这可能会导致额外的内存使用。在这种情况下,可能需要考虑使用其他排序算法,如快速排序、归并排序等。
  • ‌负数处理‌:在上面的代码中,我们通过减去最小值min来处理负数和任意范围的整数。这样可以确保计数数组的索引从0开始,适用于所有整数值。

2.9、桶排序

与计数排序类似,桶排序(bucket sort))也对输入加以限制来提高算法性能。换言之,如果输入序列来自固定的集合,则桶排序效率较高。桶排序是计数排序的泛化。例如,假设所有输入元素来自{0,1,…,K一1},即在区间[0,K一1]上的整数集合,这就表示K是输人序列中最远距离元素的数目。桶排序采用K个计数器,第i个计数器记录第i个元素的出现次数。含有两个桶的桶排序是快速排序的一个有效版本。

在Java中,桶排序(Bucket Sort)是一种高效的排序算法,适用于数据分布均匀的场景。它通过将数组分到有限数量的桶(buckets)中来工作,每个桶再分别排序(可以使用其他排序算法,如插入排序),最后合并所有桶来得到最终排序结果。

桶排序的基本步骤:

  1. ‌确定桶的数量‌:根据数据范围和分布,决定使用多少个桶。
  2. ‌分配元素到桶中‌:遍历输入数组,根据元素的键值(key)将元素分配到相应的桶中。
  3. ‌对每个桶进行排序‌:可以使用不同的排序算法对每个桶内的元素进行排序,通常对于小数据量的桶,插入排序或快速排序效率较高。
  4. ‌合并所有桶‌:将所有桶中的元素依次取出,合并成一个有序数组。
/**
 * 桶排序
 * @param arr
 */
public void bucketSort(int[] arr) {
    if (arr.length == 0) {
        return;
    }
    //1. 确定桶的数量和大小
    int max = Integer.MIN_VALUE;
    int min = Integer.MAX_VALUE;
    for (int num : arr) {
        if (num > max) {
            max = num;
        }
        if (num < min) {
            min = num;
        }
    }
    //桶的数量可以酌情调整
    int bucketCount = Math.min(arr.length, (int)Math.sqrt(arr.length));
    double range = (double)(max - min + 1);//范围大小
    List<List<Integer>> buckets = new ArrayList<>();
    for (int i = 0; i < bucketCount; i++) {
        buckets.add(new ArrayList<>());
    }

    //2. 将元素分配到各个桶中
    for (int num : arr) {
        int bucketIndex = (int)((num - min) * bucketCount / range);//散列
        buckets.get(bucketIndex).add(num);
    }

    //3. 对每个桶进行排序
    for (List<Integer> bucket : buckets) {
        Collections.sort(bucket);
    }

    //4. 合并所有桶中的元素到原数组中
    int index = 0;
    for (List<Integer> bucket : buckets) {
        for (int num : bucket) {
            arr[index++] = num;
        }
    }
}
  • 时间复杂度为O(n)
  • 空间复杂度为O(n)

三、排序相关问题

3.1、问题1

问题1:给定含有重复元素的n个数的数组A[0…n一1]。给出算法,检查是否存在重复元素。假设不允许使用额外的空间(即可以使用临时变量,O(1)存储空间)。

分析可能的思路:

  • 先排序,排序后检查相邻元素是否相同。但排序通常需要 O(logn)额外空间(递归栈)或原地排序 O(1)空间。
  • 如果没有空间限制,可以直接用哈希表,但此处不行。
  • 如果允许修改原数组,排序是一种可行的方法。
  • 某些特殊的输入范围情况下(如数组元素是 1 到 n-1 范围内的整数),

问题分析与设计
我们需要在 O(1) 额外空间的情况下检查数组中是否有重复元素。最直接的方法是:先原地排序,然后扫描检查相邻元素是否相同。

排序算法选择:

  • 快速排序(递归):需要 O(log n) 栈空间
  • 堆排序:原地且 O(1) 空间
  • 原地归并排序:通常需要 O(n) 空间
  • 希尔排序:原地且 O(1) 空间

选择堆排序,因为它满足:

  • 原地排序
  • 最坏情况 O(n log n) 时间复杂度
  • 严格 O(1) 额外空间
  • 稳定的空间复杂度表现

算法步骤:

  1. 如果数组长度 ≤ 1,返回 false(无重复)
  2. 对数组进行堆排序
  3. 排序后遍历数组,比较相邻元素
  4. 如果发现相邻元素相等,返回 true
  5. 遍历结束无重复,返回 false

Java实现:

/**
 * 给定含有重复元素的n个数的数组A[0..n一1]。给出算法,检查是否存在重复元素。
 * 假设不允许使用额外的空间(即可以使用临时变量,O(1)存储空间)。
 * @param arr
 * @return
 */
public boolean hasDuplicates(int[] arr) {
    int n = arr.length;
    if (n <= 1) {
        return false;
    }
    //原地堆排序
    heapSort(arr);
    //检查相邻元素是否相等
    for (int i = 1; i < n; i++) {
        if (arr[i] == arr[i - 1]) {
            return true;
        }
    }
    return false;
}

// 堆排序实现
private void heapSort(int[] arr) {
    int n = arr.length;

    // 构建最大堆
    for (int i = n / 2 - 1; i >= 0; i--) {
        heapify(arr, n, i);
    }

    // 一个个取出元素
    for (int i = n - 1; i > 0; i--) {
        // 将当前根(最大值)移到末尾
        swap(arr, 0, i);

        // 在减少的堆上调用 heapify
        heapify(arr, i, 0);
    }
}

// 堆化子树
private void heapify(int[] arr, int n, int i) {
    int largest = i;        // 初始化最大值为根
    int left = 2 * i + 1;   // 左子节点
    int right = 2 * i + 2;  // 右子节点

    // 如果左子节点存在且大于根
    if (left < n && arr[left] > arr[largest]) {
        largest = left;
    }

    // 如果右子节点存在且大于当前最大值
    if (right < n && arr[right] > arr[largest]) {
        largest = right;
    }

    // 如果最大值不是根
    if (largest != i) {
        swap(arr, i, largest);

        // 递归堆化受影响的子树
        heapify(arr, n, largest);
    }
}

// 交换元素
private void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}


//测试
@Test
public void testHasDuplicates() {
    SortProblem sortProblem = new SortProblem();
    int[][] testCases = {
            {1, 2, 3, 4, 5},          // 无重复
            {1, 2, 3, 2, 5},          // 有重复
            {},                        // 空数组
            {7},                       // 单元素
            {5, 5, 5, 5},             // 全相同
            {3, 1, 4, 1, 5, 9, 2, 6}  // 有重复
    };

    for (int i = 0; i < testCases.length; i++) {
        int[] arr = testCases[i].clone();  // 复制数组,避免修改原测试数据
        boolean result = sortProblem.hasDuplicates(arr);
        System.out.print("测试" + (i+1) + " 数组: ");
        System.out.println(" -> 有重复: " + result);
    }
}

测试1 数组:  -> 有重复: false
测试2 数组:  -> 有重复: true
测试3 数组:  -> 有重复: false
测试4 数组:  -> 有重复: false
测试5 数组:  -> 有重复: true
测试6 数组:  -> 有重复: true

3.2、问题2

问题3:给定数组A[0…n一1],其中每个元素代表选举中的一张选票,假设每张选票以一个整数来表示候选人的D,给出一个算法来判定谁赢得选举。
该问题就是在数组中寻找最大重复次数的元素。

问题分析:
这是一个多数元素(Majority Element)问题:

  • 有 n 张选票,每个候选人有一个整数 ID
  • 赢家必须获得超过一半的选票(即票数 > n/2)
  • 如果存在这样的候选人,返回其 ID;否则返回 -1 或指示无赢家

关键约束:

  • 多数元素(超过半数)与最多票数不同
  • 如果只是平票最多,但没有超过半数,则无人获胜
  • 需要处理无多数元素的情况

算法选择:
Boyer-Moore 投票算法

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)
  • 不需要排序,不修改原数组
  • 两阶段:
    • 找到可能的多数元素
    • 验证该元素是否真的超过半数

Java实现:

/**
 * 找出选举赢家(获得超过半数选票的候选人)
 * @param votes votes 选票数组,每个元素是候选人ID
 * @return 赢家ID,如果没有候选人超过半数则返回 -1
 */
public int findWinner(int[] votes) {
    int n = votes.length;
    if (n == 0) {
        return -1;//无选票
    }
    //第一阶段:找到可能的候选者
    int candidate = votes[0];//候选者
    int count = 1;//计数器
    for (int i = 1; i < n; i++) {
        if (count == 0) {
            //重置候选者
            candidate = votes[i];
            count = 1;
        } else if (votes[i] == candidate) {
            count++;//计数加一
        } else {
            count--;//非多数票与多数票抵消减一
        }
    }
    //第二阶段:验证候选者是否是真的超过半数
    int voteCount = 0;
    for (int vote : votes) {
        if (vote == candidate) {
            voteCount++;
        }
    }
    //检查是否超过半数
    if (voteCount > n / 2) {
        return candidate;
    } else {
        return -1;
    }
}

//测试
@Test
public void testFindWinner() {
    SortProblem sortProblem = new SortProblem();
    // 测试用例
    int[][] testCases = {
            {1, 2, 1, 2, 1},              // 候选者1获得3票,总数5票 -> 赢家
            {3, 3, 4, 2, 4, 4, 2, 4, 4},  // 候选者4获得5票,总数9票 -> 赢家
            {1, 2, 3, 4, 5},              // 无候选者超过半数 -> 无赢家
            {1, 1, 2, 2},                 // 平票 -> 无赢家
            {7},                          // 单张选票 -> 赢家7
            {},                           // 空数组 -> 无赢家
            {2, 2, 3, 3, 3, 2, 2},        // 候选者2获得4票,总数7票 -> 赢家
    };

    System.out.println("=== 多数获胜(超过半数)算法 ===");
    for (int i = 0; i < testCases.length; i++) {
        int result = sortProblem.findWinner(testCases[i]);
        System.out.print("测试" + (i+1) + ": ");
        System.out.println(" -> 赢家: " + (result == -1 ? "无" : result));
    }
}

=== 多数获胜(超过半数)算法 ===
测试1:  -> 赢家: 1
测试2:  -> 赢家: 4
测试3:  -> 赢家: 无
测试4:  -> 赢家: 无
测试5:  -> 赢家: 7
测试6:  -> 赢家: 无
测试7:  -> 赢家: 2
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值