经典排序算法

经典排序算法

排序算法分类
算法的性质:
稳定:如果a原本在b前面,且a == b,排序后a仍然在b前面
不稳定:如果a原本在b前面,且a == b,排序后a可能在b后面
内排序:所有排序操作都在内存中进行
外排序:由于数据太大,因此把数据放在磁盘中,排序通过磁盘和内存的数据传输才能进行
判断算法优劣的两个重要标准:
时间复杂度
空间复杂度:我的理解就是暂用内存与问题规模的关系,与问题规模没关系的,就是1。以下是摘抄来的一段说明,姑且贴在这里:

算法的空间复杂度并不是计算实际占用的空间,而是计算整个算法的辅助空间单元的个数,与问题的规模没有关系。算法的空间复杂度S(n)定义为该算法所耗费空间的数量级。

S(n)=O(f(n)) 若算法执行时所需要的辅助空间相对于输入数据量n而言是一个常数,则称这个算法的辅助空间为O(1);

递归算法的空间复杂度:递归深度N*每次递归所要的辅助空间, 如果每次递归所需的辅助空间是常数,则递归的空间复杂度是 O(N).

进一步分析:

两段算法串联在一起的复杂度 T 1 ( n ) + T 2 ( n ) = m a x ( O ( f 1 ( n ) ) + O ( f 2 ( n ) ) ) T_1(n) + T_2(n) = max( O(f_1(n)) + O(f_2(n)) ) T1(n)+T2(n)=max(O(f1(n))+O(f2(n)))
即比较慢的那个算法决定了串联后的效率。

两段算法嵌套在一起的复杂度 T 1 ( n ) ∗ T 2 ( n ) = O ( f 1 ( n ) ∗ f 2 ( n ) ) T_1(n) * T_2(n) = O( f_1(n) * f_2(n) ) T1(n)T2(n)=O(f1(n)f2(n))

if - else 结构的复杂度取决于if条就爱你判断复杂度和两个分枝部分的复杂度,总体复杂度取三者中最大。

一、 冒泡排序(Bubble Sort)

public void BubbleSort(int[] arr){
	for(int i=0;i<arr.length-1;i++){ 
		for(int j=0;j<arr.length-1-i;j++){
			if(arr[j]>arr[j+1]){ 
		//相邻两个元素作比较,如果前面元素大于后面,进行交换
				int temp = arr[j+1];
				arr[j+1] = arr[j];
				arr[j] = temp;
			}
		}
	}
}

我的理解:
冒泡排序的核心,是 “泡”。也就是它会尝试把每一个值都当作大的气泡,要将他冒到数组尾部。
可以想象最差的情况,一个严格单调递减的数组,比如 9876543210 9 8 7 6 5 4 3 2 1 0 9876543210 ,先冒9,9跟8比较,交换,9跟7比较,交换。。。一直到9跟0比较,交换、结束9的冒泡。 此刻数组为 8765432109 8 7 6 5 4 3 2 1 0 9 8765432109。再冒8,8跟7比较,交换,跟6比较,交换。。。跟0比较,交换,注意,最后一位是9,不用比较了,因为9已经和8比较过了,这也就是为什么内层循环里, j < a r r a y . l e n g t h – 1 − i j < array.length – 1 -i j<array.length1i的原因。这样每次都只是把问题的规模减小1,效率较低。可以看到,这里的实现比较相邻两个值用的是小于,因此是稳定的,它不会把相等的值也交换位置。

二、 选择排序(Selection Sort)

public static int[] selectionSort(int[] array) {
        if (array.length == 0)
            return array;
        for (int i = 0; i < array.length; i++) {
            int minIndex = i;
            for (int j = i; j < array.length; j++) {
                if (array[j] < array[minIndex]) 
                    minIndex = j; //将最小数的索引保存,下一次for循环就是跟新的最小值比较了
            }
            //交换无序区中最小值和i,这样[0,i)就是有序的了,外层for循环把i不断向右推
            int temp = array[minIndex];
            array[minIndex] = array[i];
            array[i] = temp;
        }
        return array;
    }

我的理解:
选择排序的原理很简单,把数组看成左右两部分,即有序区、无序区,每次都从无序区选择最小的一个值,将它加入到有序区右边,也就是无序区最左侧的值和无序区的最小值交换。代码里的双层循环,第一层是把有序区向右扩展,第二层则是在无序区中选出最值。有序区和无序区的分解线为i,即 [0 , i)和[I , length – 1],i从零开始。

三、 插入排序(Insertion Sort)

public static int[] insertionSort(int[] array) {
        if (array.length == 0)
            return array;
        int current;
        for (int i = 0; i < array.length - 1; i++) {
            current = array[i + 1];
            int preIndex = i;
//注意,为了插入,需要把比current大的值后移。不用担心current的值,他已经被保留了
            while (preIndex >= 0 && current < array[preIndex]) {
                array[preIndex + 1] = array[preIndex];
                preIndex--;
            }
//将current的值放在preIndex后面,也就是放在那个比current小的值后面
            array[preIndex + 1] = current;
        }
        return array;
 }

我的理解:
插入排序其实和选择排序很像,即将整个数组分为两个部分,有序区、无序区。将无序区第一个元素i+1插入到有序区内,其插入方式是倒序遍历有序区。倒序终止的条件是下标越界或者已经找到合适位置(元素 i + 1大于等于,这个等于值得注意,它前面的值) 。当有序区的长度为length – 1,而无序区仅仅一个元素时,可以看出,最后一个无序元素会倒着冒泡到合适的位置去。
插入排序中的KaTeX parse error: Expected 'EOF', got '&' at position 15: preIndex >= 0 &̲& current < arr…这一句,这么写是因为i是从0开始的,有序区一定是有序的,这句话是废话。

以上三种排序算法,时间复杂度都是n^2 ,他们每一次比较都需要遍历无序的元素来比较,每次把无序的元素数量减一,效率较低

四、 希尔排序

   public static int[] ShellSort(int[] array) {
        int len = array.length;
        int temp, gap = len / 2;
        while (gap > 0) {
            for (int i = gap; i < len; i++) {
                temp = array[i];
                int preIndex = i - gap;
                //这里我一开始比较疑惑,因为觉得可能回出现元素值为3 1 2的情况,“有序区”不是有序的,无法一次插入得到有序序列.实际上,当gap=1
                //的时候,就是有“序区会”一定有序的插入排序,最终也可以得到有序序列。此外,我觉得希尔排序的优势,就在于gap较大时,可能避免gap
                //较小时的交换操作
                while (preIndex >= 0 &&  temp < array[preIndex] ) {
                    array[preIndex + gap] = array[preIndex];
                    preIndex -= gap;
                }
                array[preIndex + gap] = temp;
            }
            gap /= 2;
        }
        return array;
}

另一种较好理解的写法

public void Shellsort(int[] arr){
		int N = arr.length;
		//进行分组,最开始时的增量(gap)为数组长度的一半
		for(int  gap = N/2 ; gap > 0 ; gap/=2){
			//将各个分组进行插入排序
			for(int i = gap ; i<N ;i++){
				//将arr[i]插入到所在分组的正确位置上
				insert(arr,gap,i); 
			}
		}
	}

      private void insert(int[] arr, int gap, int i) {
		int	inserted = arr[i];
		int j ;
		//插入的时候按组进行插入(组内元素两两相隔gap)
		for(j = i-gap ; j>=0  &&  inserted <arr[j] ; j-=gap){
			arr[j+gap] = arr[j];
		}
		arr[j+gap] = inserted;
	}

我的理解:
希尔排序我更愿意称之为逐步精确比较排序算法。它先对间隔较大的值两两比较,然后再缩小间隔继续两两比较,不断缩小到间隔为0,被比较的值紧挨着,也就是gap为1的时候。
主要是两个操作,一是依靠gap分组,而是把每个组进行插入排序。注意,对于某一次调用插入排序,这里的插入排序很可能是不彻底的,甚至一次都没有进行插入。 我们回顾一下 三、插入排序,很明显是从有序区仅仅只有一个元素开始,for循环不断的把有序区扩大,
希尔排序最重要的是这个gap。gap在较大时,将原数组分割成的数组的规模都比较小,这个时候插入,针对某一个下标而言,只是相当于此下标和间隔较大的另一个下标上的元素有序,而当gap小的时候,看似比较的范围可能会缩小,但是对原数组而言,较大范围的数据已经比较过了,因此它们之间不必,也不会再去比较交换了。当gap等于1,其实就是正常的插入排序,只不过这个时候,我们得到的数组已经是极其有序的了 i n s e r t e d < a r r [ j ] inserted <arr[j] inserted<arr[j]会减少大量的比较操作。

五、归并排序(Merge Sort)

经典的递归写法:

public static int[] mergeSort(int[] arr, int left, int right) {
     // 如果 left == right,表示数组只有一个元素,则不用递归排序
      if (left < right) {
          // 把大的数组分隔成两个数组
          int mid = (left + right) / 2;
          // 对左半部分进行排序
         arr = mergeSort(arr, left, mid);
         // 对右半部分进行排序
         arr = mergeSort(arr, mid + 1, right);
         //进行合并
         merge(arr, left, mid, right);
     }
     return arr;
 }
//其实merge的过程也是比较的过程
 // 合并函数,把两个有序的数组合并起来
 // arr[left..mid]表示一个数组,arr[mid+1 .. right]表示一个数组
 private static void merge(int[] arr, int left, int mid, int right) {
     //先用一个临时数组把他们合并汇总起来
     int[] a = new int[right - left + 1];
     int i = left;
     int j = mid + 1;
     int k = 0;
     //i++的值是i,但是i随后自加1,即int i = 0; temp = i++; 此时temp = 0, i = 1;
     while (i <= mid && j <= right) {
         if (arr[i] < arr[j]) {
         //下表达式式相当于 
         //a[k] = arr[i];
         // k++;
         //i++;
             a[k++] = arr[i++];
         } else {
             a[k++] = arr[j++];
         }
     }
     while(i <= mid) a[k++] = arr[i++];
     while(j <= right) a[k++] = arr[j++];
     // 把临时数组复制到原数组
     for (i = 0; i < k; i++) {
         arr[left++] = a[i];
     }
 }

我的理解:
归并排序最重要的使使用了递归,当然也有非递归的写法,但是其思想是递归。 递归的话注意要有终止条件。合并函数里临时数组的复制也没什么好说的。
递归式写法的空间复杂度是多少呢?元素为n个,则对半分k次递归结束的话,则 2 k = n 2^k = n 2k=n, 即可得到 k = log ⁡ 2 n k = \log_2^{n} k=log2n,而每次需要的辅助空间,也就是JVM内存模型中的虚拟机栈,都是一个常数,与问题规模n无关,再加上为临时数组开辟的空间,因此空间复杂度就是 n + log ⁡ 2 n n + \log_2^{n} n+log2n,也就是n。
时间复杂度为 n log ⁡ 2 n n \log_2^{n} nlog2n
附一个非递归的写法,仍然利用的原来的合并函数

  // 非递归式的归并排序
  public static int[] mergeSort(int[] arr) {
      int n = arr.length;
      // 子数组的大小分别为1,2,4,8...
      // 刚开始合并的数组大小是1,接着是2,接着4....
      for (int i = 1; i < n; i += i) {
          //进行数组进行划分
          int left = 0;
         int mid = left + i - 1;
         int right = mid + i;
         //进行合并,对数组大小为 i 的数组进行两两合并
         while (right < n) {
             // 合并函数和递归式的合并函数一样
             merge(arr, left, mid, right);
             left = right + 1;
             mid = left + i - 1;
             right = mid + i;
         }
         // 还有一些被遗漏的数组没合并,千万别忘了
         // 因为不可能每个字数组的大小都刚好为 i
         if (left < n && mid < n) {
            merge(arr, left, mid, n - 1);
        }
     }
     return arr;
 }

六、快速排序(Quick Sort)

  public static int[] quickSort(int[] arr, int left, int right) {
      if (left < right) {
          //获取中轴元素所处的位置
          int mid = partition(arr, left, right);
          //进行分割
          arr = quickSort(arr, left, mid - 1);
         arr = quickSort(arr, mid + 1, right);
      }
     return arr;
 }
 
 //这个函数咋一看看不明白,其实目的就是选出一个元素,将数组以此元素为界分为两半,返回此元素下标
 private static int partition(int[] arr, int left, int right) {
     //选取中轴元素
     //选取第一个元素,即arr[left]为中轴元素,此刻的i、j是为了给找到的小于和大于中轴元素大小的元素确定下标
     int pivot = arr[left];
     int i = left + 1;
     int j = right;
     while (true) {
         // 当while循环结束,必然不再满足条件,后面又用i >= j 来,可以看出这里其实是要找arr[i] > pivot的i,根据这个i来交换
         //只要第一次不满足条件,while循环就结束,所以每次都是遇到一个大于pivot的值就记录其下标i
         while (i <= j && arr[i] <= pivot) i++;
         // 同上,找到arr[j] < pivot,记录元素位置j
         while(i <= j && arr[j] >= pivot ) j--;
         //当break的时候,i、j已经相逢过了,也就是遍历了所有元素了
         if(i >= j)
             break;
         //交换前,arr[j] < pivot < arr[i],交换后,arr[i] < pivot < arr[j]
         int temp = arr[i];
         arr[i] = arr[j];
         arr[j] = temp;
     }
     //通过while循环,可以知道,数组其实已经是分成了三个部分,即pivot(也就是arr[left])、 {小于pivot的元素}、 {大于pivot的元素},
     //我们只要把中轴元素放到合适的位置就可以了,我们的j初始值为right,每次找到一个小于pivot的元素,j都自减一次,while循环遍历所
     //有元素结束时, i >= j,也就是j为 {小于pivot的元素}的最右边的下标,交换arr[j]和arr[left]即可
     //交换后,数组元素的三个部分为{小于pivot的元素}、pivot 、{大于pivot的元素}
     arr[left] = arr[j];
     // 使中轴元素处于有序的位置
     arr[j] = pivot;
     return j;
 }

通常,快速排序被认为是,所有同数量级(nlogn)的排序方法中,平均性能最好的一个。但是,如果原始数据比较有序,快速排序将快速蜕化为冒泡排序。

我的理解:快速排序最重要的就是分区操作,也就是把基准放到合适的位置。这一部分的写法可以根据数组元素的特性,选择不同的元素作为基准,比如,如果说一个数组的基准更可能出现在中间位置,那可以选择(left + right)/ 2 为pivot,而不是left。

七、堆排序(Heap Sort)

大顶堆:首先,堆是顺序存储的完全二叉树,大顶堆,顾名思义,其根节点的关键字大于其子节点。

   //堆排序
   public static int[] heapSort(int[] arr, int length) {
       //构建二叉堆
       for (int i = (length - 2) / 2; i >= 0; i--) {
           arr = downAdjust(arr, i, length);
       }
       //进行堆排序
       for (int i = length - 1; i >= 1; i--) {
           //把堆顶的元素与最后一个元素交换
           int temp = arr[i];
           arr[i] = arr[0];
           arr[0] = temp;
           //下沉调整
           arr = downAdjust(arr, 0, i);
       }
       return arr;
   }
   
   /**
    *  下沉操作,执行删除操作相当于把最后
    *  * 一个元素赋给根元素之后,然后对根元素执行下沉操作
    * @param arr
    * @param parent 要下沉元素的下标
    * @param length 数组长度
    */
   public static int[] downAdjust(int[] arr, int parent, int length) {
       //临时保证要下沉的元素
       int temp = arr[parent];
       //定位左孩子节点位置
       int child = 2 * parent + 1;
       //开始下沉
       while (child < length) {
           //左与右孰大?
           if (child + 1 < length && arr[child] > arr[child + 1]) {
               child++;
           }
           //如果父节点比孩子节点小或等于,则下沉结束
           if (temp <= arr[child])
           	//break而不是continue,是因为我们构造堆时是从最后一个非叶子节点开始倒序处理的
               break;
           //单向赋值
           arr[parent] = arr[child];
           parent = child;
           child = 2 * parent + 1;
       }
       arr[parent] = temp;
       return arr;
   }

我的理解:堆排序是目前学习的排序里面最复杂的一个。我觉得它体现了数据结构和算法之间的紧密联系。
如何从数组构造二叉堆呢?其实这是一个约定的惯例,即从root开始,逐层列出所有元素。例如数组为{0,1,2,3,4,5,6,7,8,9},那么堆就是
在这里插入图片描述
代码中有 i = ( l e n g t h − 2 ) / 2 i = (length - 2) / 2 i=(length2)/2,其实写成 l e n g t h / 2 − 1 length / 2 - 1 length/21看起来更简单一点,这里我们算一下很明显是4,恰好是最后一个非叶子节点,从这个下标开始,每次减一,得到的都是另一个非叶子节点。对非叶子节点进行下沉操作,也就是比较其与孩子节点的大小,必要时交换以确保非叶子节点的值是最大的(或最小的)。如此就可以得到一个二叉堆。我们看一下这个二叉树每一层的第一个节点,0,1,3,7,很明显是 2 n − 1 2^n - 1 2n1,n从0开始,知道这一点很重要。
如果我们排序的目的是要得到升序序列,那么需要构造大顶堆,如果是要得到降序序列,需要构造小顶堆。得到合适的堆后,将堆顶元素和最后一个元素交换(其实就是把最值移到有序区)。
完成堆排序需要两个部分,即前面说的构造堆,以及将堆顶元素与堆(数组)末尾元素的交换。这两各部分的核心都是downAdjust函数。每一次将堆顶元素处理完后,堆的规模减一,在downAdjust内,在while循环内不断的调整下沉,确保堆的性质没有被破坏,因此堆顶元素仍然会是堆内的最值,将这个堆顶元素再与堆末尾元素交换,直到所有元素都被排序。

八、计数排序(Counting Sort)

public static int[] sort(int[] arr) {
         if(arr == null || arr.length < 2) return arr;
 
         int n = arr.length;
         int min = arr[0];
         int max = arr[0];
         // 寻找数组的最大值与最小值
         for (int i = 1; i < n; i++) {
             if(max < arr[i])
                 max = arr[i];
             if(min > arr[i])
                 min = arr[i];
         }
         int d = max - min + 1;
         //创建大小为max的临时数组
         int[] temp = new int[d];
         //统计元素i出现的次数
         for (int i = 0; i < n; i++) {
             temp[arr[i] - min]++;
         }
         int k = 0;
         //把临时数组统计好的数据汇总到原数组
         for (int i = 0; i < d; i++) {
             for (int j = temp[i]; j > 0; j--) {
                 arr[k++] = i + min;
             }
         }
         return arr;
     }

我的理解:计数排序,所谓计数,计的是各个大小的元素的数量,最后按顺序把这些值赋给原数组即可。计数排序不用比较,实际上也没有交换,但是需要数据在一定范围内

九、桶排序(Bucket Sort)

 public static int[] BucketSort(int[] arr) {
     if(arr == null || arr.length < 2) return arr;
 
     int n = arr.length;
     int max = arr[0];
     int min = arr[0];
     // 寻找数组的最大值与最小值
     for (int i = 1; i < n; i++) {
         if(min > arr[i])
             min = arr[i];
         if(max < arr[i])
             max = arr[i];
     }
     //和优化版本的计数排序一样,弄一个大小为 min 的偏移值
     int d = max - min;
     //创建 d / 5 + 1 个桶,第 i 桶存放  5*i ~ 5*i+5-1范围的数
     int bucketNum = d / 5 + 1;
     ArrayList<LinkedList<Integer>> bucketList = new ArrayList<>(bucketNum);
     //初始化桶
     for (int i = 0; i < bucketNum; i++) {
         bucketList.add(new LinkedList<Integer>());
     }
     //遍历原数组,将每个元素放入桶中
     for (int i = 0; i < n; i++) {
         bucketList.get((arr[i]-min)/d).add(arr[i] - min);
     }
     //对桶内的元素进行排序,我这里采用系统自带的排序工具
     for (int i = 0; i < bucketNum; i++) {
         Collections.sort(bucketList.get(i));
     }
     //把每个桶排序好的数据进行合并汇总放回原数组
     int k = 0;
     for (int i = 0; i < bucketNum; i++) {
         for (Integer t : bucketList.get(i)) {
             arr[k++] = t + min;
         }
     }
     return arr;
 }

我的理解:桶排序的核心在于桶。分桶的操作其实和计数排序类似,bucketList.get((arr[i]-min)/d).add(arr[i] - min),这个get操作,就是根据元素的大小来找到合适的桶。分桶这个操作可以避免排序操作,因为它本身就是利用数据的大小来分桶的。桶越多,每个桶里的元素就越少越容易排序,但是占据内存越多(其实这一点我还不理解,是不是因为有空桶?)。假设每个元素都有一个桶,那自然就完成了排序。

十、基数排序(Radix Sort)

 public static int[] radioSort(int[] arr) {
         if(arr == null || arr.length < 2) return arr;
 
         int n = arr.length;
         int max = arr[0];
         // 找出最大值
         for (int i = 1; i < n; i++) {
             if(max < arr[i]) max = arr[i];
         }
         // 计算最大值是几位数
         int num = 1;
         while (max / 10 > 0) {
             num++;
             max = max / 10;
         }
         // 创建10个桶
         ArrayList<LinkedList<Integer>> bucketList = new ArrayList<>(10);
         //初始化桶
         for (int i = 0; i < 10; i++) {
             bucketList.add(new LinkedList<Integer>());
         }
         // 进行每一趟的排序,从个位数开始排
         for (int i = 1; i <= num; i++) {
             for (int j = 0; j < n; j++) {
                 // 获取每个数最后第 i 位是数组
                 int radio = (arr[j] / (int)Math.pow(10,i-1)) % 10;
                 //放进对应的桶里
                 bucketList.get(radio).add(arr[j]);
             }
             //合并放回原数组
             int k = 0;
             for (int j = 0; j < 10; j++) {
                 for (Integer t : bucketList.get(j)) {
                     arr[k++] = t;
                 }
                 //取出来合并了之后把桶清光数据
                 bucketList.get(j).clear();
             }
         }
         return arr;
     }

我的理解:基数排序,其实是逐级比较。

他山美玉:
堆排序的讲解
希尔排序-这个知乎答主的代码很容易理解

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值