计算机基础速通--数据结构·排序的基础应用

如有问题大概率是我的理解比较片面,欢迎评论区或者私信指正。

总结最近的一些体会:温故而知新确实是存在的,不过需要持续性的学习形成知识性复利,在最近的学习和面试过程中我发现自己的脑子越来越好用了,有时候也能灵光一现想出一些比较难的问题的解决思路。但是当我对这些灵光一现的思路复盘的时候发现这些方法就是我之前学过的,只不过每部分都比较分散而且只是用来独立解决某个简单问题的方法而已。这应该就是一种知识性的复利--可以将学过的简单方法融合起来解决某个复杂问题。目前,我认为任何创新都不是从0-1的突变,因为事物的发展不存在突变,必然是一个不断探索旧方法融合的过程。映射到个人来说,经验就是知识性复利的产物,只不过每个人的本金和复利模型不同所以复利效果有很大诧异。

一、排序的基础概念

排序是什么:就是把一堆数据(比如数字、名字、分数)按照某个规则(比如从小到大、从大到小)重新排列。

  • 怎么评价排序算法
  • 快不快(时间复杂度)
  • 省不省内存(空间复杂度)
  • 稳不稳定 :相同大小的数排序后顺序是否保持不变(相同不变就是稳)

排序的分类

内部排序:所有数据都能放进内存,主要优化时空复杂度。

外部排序:数据太大,要分批从磁盘读写,主要减少IO。

二、插入排序

插入排序算法思想

就像我们打扑克时整理手牌一样,每次摸到一张新牌,就把它插入到手中已经排好序的牌堆里的正确位置。

一开始,认为第一张牌(第一个元素)已经是排好序的。

然后拿起下一张牌(下一个元素),从后往前和手里已排好的牌进行比较。

找到它应该插入的位置(待排序列的第一个元素位置),将比它大的牌都往后挪一个位子,然后把它放进去。

重复第2、3步,直到所有的牌(元素)都插入完毕,整个序列就变得有序了。

特点 :稳定(相等元素的相对顺序不变),简单易懂。

效率:最适合处理数据量小或基本有序的序列。

代码模板

java 复制代码
import java.util.Arrays;

public class InsertionSort {

    /**
     * 直接插入排序(不带哨兵)
     * 时间复杂度:最好O(n),最坏O(n²),平均O(n²)
     * 空间复杂度:O(1)
     * 稳定性:稳定
     */
    public static void insertionSort(int[] arr) {
        // 易错点1:i从1开始,不是从0开始
        for (int i = 1; i < arr.length; i++) {
            // 只有当前元素小于前一个元素时才需要插入(升序)
            //待排序列元素第一个<已排序列最后一个元素
            if (arr[i] < arr[i - 1]) {
                int temp = arr[i]; // 保存当前需要插入的元素
                int j;

                // 难点1:从后往前查找插入位置,同时移动元素
                // 易错点2:注意循环条件是j>=0 && arr[j]>temp ,循环结束后如果j有效则j指向第一个不大于temp的元素
                for (j = i - 1; j >= 0 && arr[j] > temp; j--) {
                    arr[j + 1] = arr[j]; // 元素后移
                }

                // 难点2:插入位置是j+1,不是j,因为后移操作后第一个不大于temp的元素后空出位置,所以插入位置是j+1
                arr[j + 1] = temp; // 插入到正确位置
            }
        }
    }

    /**
     * 直接插入排序(带哨兵)
     * 使用arr[0]作为哨兵,可以减少比较次数
     * 注意:使用此方法时,arr[0]不能存放有效数据
     */
    public static void insertionSortWithSentinel(int[] arr) {
        // 易错点3:使用哨兵时,i从2开始,因为arr[1]是第一个有效数据
        for (int i = 2; i < arr.length; i++) {
            // 只有当前元素小于前一个元素时才需要插入
            if (arr[i] < arr[i - 1]) {
                arr[0] = arr[i]; // 设置哨兵
                int j;

                // 难点3:使用哨兵后,无需判断j>=0,因为arr[0]会保证循环终止
                for (j = i - 1; arr[j] > arr[0]; j--) {
                    arr[j + 1] = arr[j]; // 元素后移
                }

                arr[j + 1] = arr[0]; // 插入到正确位置
            }
        }
    }

    /**
     * 折半插入排序
     * 使用二分查找来减少比较次数,但移动次数不变
     */
    public static void binaryInsertionSort(int[] arr) {
        for (int i = 1; i < arr.length; i++) {
            if (arr[i] < arr[i - 1]) {
                int temp = arr[i];

                // 使用二分查找找到插入位置
                int left = 0;
                int right = i - 1;

                // 难点4:二分查找的终止条件和边界处理
                while (left <= right) {
                    int mid = left + (right - left) / 2;
                    if (arr[mid] > temp) {
                        right = mid - 1;
                    } else {
                        // 易错点4:为保证稳定性,相等时继续在右侧查找
                        left = mid + 1;
                    }
                }

                // 移动元素
                for (int j = i - 1; j >= left; j--) {
                    arr[j + 1] = arr[j];
                }

                arr[left] = temp; // 插入到正确位置
            }
        }
    }

    // 测试代码
    public static void main(String[] args) {
        int[] arr1 = {5, 2, 8, 3, 1, 6, 4};
        System.out.println("原始数组: " + Arrays.toString(arr1));
        insertionSort(arr1);
        System.out.println("直接插入排序后: " + Arrays.toString(arr1));

        // 注意:使用哨兵版本时,数组第一个元素不能是有效数据
        int[] arr2 = {0, 5, 2, 8, 3, 1, 6, 4};
        System.out.println("原始数组: " + Arrays.toString(arr2));
        insertionSortWithSentinel(arr2);
        System.out.println("带哨兵插入排序后: " + Arrays.toString(arr2));

        int[] arr3 = {5, 2, 8, 3, 1, 6, 4};
        System.out.println("原始数组: " + Arrays.toString(arr3));
        binaryInsertionSort(arr3);
        System.out.println("折半插入排序后: " + Arrays.toString(arr3));

        // 测试稳定性(有相同元素)
        int[] arr4 = {5, 2, 8, 3, 2, 6, 4};
        System.out.println("原始数组: " + Arrays.toString(arr4));
        insertionSort(arr4);
        System.out.println("稳定性测试后: " + Arrays.toString(arr4));
    }
}

练习

题目一:

147. 对链表进行插入排序 - 力扣(LeetCode)https://leetcode.cn/problems/insertion-sort-list/

  • 这是练习插入排序最经典的题目

  • 链表优势:插入排序在链表上操作时,不需要像数组那样移动大量元素,只需要修改指针,其时间复杂度虽然仍是 O(n²),但实际常数项更小,体现了插入排序在特定数据结构上的优势。

  • 难点:需要熟练操作链表,维护多个指针(如已排序部分的最后一个节点、当前要插入的节点、用于寻找插入位置的前驱指针),是对算法实现能力的绝佳锻炼。

java 复制代码
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    public ListNode insertionSortList(ListNode head) {
        //虚拟头节点
        ListNode h=new ListNode();
        h.next=head;

        ListNode e=head,curr=head.next;//e已排序列最后节点,curr待排序列第一个节点
        while(curr!=null){
            if(curr.val>=e.val){
                //更新已排序列
                e=e.next;
            }else{
                //找直接前驱
                ListNode node=h;
                while(node.next.val<=curr.val){
                    node=node.next;
                }
                //断链
                e.next=curr.next;
                //插入
                curr.next=node.next;
                node.next=curr;
            }
            curr=e.next;
        }
        return h.next;
    }
}
题目二

283. 移动零 - 力扣(LeetCode)https://leetcode.cn/problems/move-zeroes/

  • 这道题可以看作是插入排序的一个变种。你可以将非零元素视为需要"插入"到前面的"正确位置"(即所有非零元素的后面)。维护一个指针(相当于插入排序中已排序序列的末尾),遇到非零数就把它"交换"或"放置"到这个指针的位置,然后指针后移。这完美体现了插入排序保持稳定性和就地排序的思想。
java 复制代码
class Solution {
    public void moveZeroes(int[] nums) {
        int left=0,right=0;//left指向已排序列的最后一个元素
        while(right<nums.length){
            if(nums[right]!=0){//遇到非0元素则交换
                int temp=nums[right];
                nums[right]=nums[left];
                nums[left++]=temp;
            }
            ++right;
        }
    }
}
题目三:

75. 颜色分类 - 力扣(LeetCode)https://leetcode.cn/problems/sort-colors/submissions/654867603/

  • 此题是著名的"荷兰国旗问题",最优解是使用三指针。

  • 然而,可以使用插入排序 。因为数据规模通常很小(n <= 300),并且只有三种元素,插入排序在这种小规模且部分有序(可能)的情况下表现很好。这可以帮助你理解插入排序在小规模数据上的实用性。

java 复制代码
class Solution {
    public void sortColors(int[] nums) {
        for(int i=1;i<nums.length;i++){
            if(nums[i]<nums[i-1]){
                int temp=nums[i];
                int low=0,high=i-1;
                while(low<=high){
                    int mid=low+(high-low)/2;
                    if(nums[mid]>nums[i])high=mid-1;
                    else low=mid+1;
                }
                for(int j=i-1;j>=low;j--){
                    nums[j+1]=nums[j];
                }
                nums[low]=temp;
            }
        }
    }
}

三、希尔排序

希尔排序算法思想

先"分组"再"整理"(利用插入排序--基本有序、数据量较小效率高)

把整个数组按照一定的间隔(称为"增量")分成多个子序列,分别对这些子序列进行直接插入排序 ;然后逐步缩小增量,重复分组和排序,直到增量为 1,也就是对整个数组做最后一次插入排序。

就像整理一副乱序的扑克牌,先每隔几张牌分成一组,每组内部先排好序;然后缩小间隔再分组、再排序;最后整个牌堆就基本有序了,再来一次整体整理,效率就很高。

代码模板

java 复制代码
public class ShellSort {

    public static void shellSort(int[] arr) {
        int n = arr.length;

        // 使用希尔增量序列(每次减半)
        for (int gap = n / 2; gap > 0; gap /= 2) {
            // 难点1:理解gap分组方式
            // 从第gap个元素开始,对每个分组进行插入排序,因为每组第一个元素默认有序
            for (int i = gap; i < n; i++) {
                int temp = arr[i];
                int j;

                // 难点2:理解分组内插入排序的实现
                // 对当前分组的元素进行插入排序
                for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
                    // 易错点1:注意这里是j-gap而不是j-1
                    arr[j] = arr[j - gap];
                }

                // 插入当前元素到正确位置
                arr[j] = temp;
            }
        }
    }

    public static void main(String[] args) {
        int[] arr = {49, 38, 65, 97, 76, 13, 27, 49};

        System.out.println("排序前:");
        for (int num : arr) {
            System.out.print(num + " ");
        }
        System.out.println();

        shellSort(arr);

        System.out.println("排序后:");
        for (int num : arr) {
            System.out.print(num + " ");
        }
    }
}

练习

912. 排序数组 - 力扣(LeetCode)https://leetcode.cn/problems/sort-an-array/description/

核心练习 :这是最直接、最纯粹的排序算法练习题。题目要求很简单:给你一个整数数组 nums,请你将该数组升序排列。这正是实现和测试希尔排序算法的完美场景。

检验效率:虽然题目没有明确要求必须用某种排序,但其测试用例的数据量足以让冒泡、简单插入等O(n²)的算法超时。成功用希尔排序AC(Accepted)此题,可以证明你正确实现了这个优于简单插入排序的算法。

对比学习:你可以很容易地将希尔排序的代码和运行时间与其他高级排序算法(如快速排序、归并排序、堆排序)进行对比,直观感受不同算法之间的性能差异。

java 复制代码
class Solution {
    public int[] sortArray(int[] nums) {
        int n=nums.length;
        for(int gap=n/2;gap>=1;gap/=2){//增量序列,最终一定要gap=1
           
           //分组插入排序
           for(int i=gap;i<n;i++){
            int temp=nums[i];
            int j;
            for(j=i-gap;j>=0 && nums[j]>temp;j-=gap){
                nums[j+gap]=nums[j];
            }
            nums[j+gap]=temp;
           }
        }
        return nums;
    }
}

四、冒泡排序

算法思想

冒泡排序过程:

  1. 从头到尾 ,逐一比较相邻的两个数

  2. 如果前面的数比后面的大 ("逆序"),就交换它们的位置,让更小的数去前面。

  3. 这样从头到尾进行一轮 后,最大的数就会像石头一样沉到底部(最后的位置)

  4. 接着,忽略已经沉底的最大数 ,对剩下的数重复上述过程

  5. 每一轮都会有一个当前最大的数"沉底"。

  6. 一直重复,直到某一轮没有任何交换发生,说明所有数都已排好序,就可以提前结束。

一遍又一遍地遍历数组,比较相邻元素,把大的往后挪。每遍历一遍,就能确定一个最大数的最终位置。

就像排队时,老师让同学们从矮到高排。同学们从左到右,相邻的两个比身高,如果左边的比右边的高,就交换位置。这样比完一轮,最高的同学肯定就在最右边了。然后忽略最高的同学,剩下的再重复这个过程,直到队伍完全有序。

代码模板

java 复制代码
public class BubbleSort {

    // 基础版本冒泡排序
    public static void bubbleSortBasic(int[] arr) {
        int n = arr.length;

        // 易错点1:需要n-1轮循环,而不是n轮
        for (int i = 0; i < n - 1; i++) {
            // 难点1:理解内层循环的边界条件
            // 每轮结束后,最大的i个元素已经就位,所以内层循环只需到n-1-i
            for (int j = 0; j < n - 1 - i; j++) {
                // 如果前面的元素大于后面的元素,则交换
                if (arr[j] > arr[j + 1]) {
                    // 交换元素
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                }
            }
        }
    }

    // 优化版本冒泡排序(添加提前结束标志)
    public static void bubbleSortOptimized(int[] arr) {
        int n = arr.length;
        boolean swapped; // 标记是否发生了交换

        // 易错点2:需要正确处理提前结束的条件
        for (int i = 0; i < n - 1; i++) {
            swapped = false; // 每轮开始前重置标志

            for (int j = 0; j < n - 1 - i; j++) {
                if (arr[j] > arr[j + 1]) {
                    // 交换元素
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swapped = true; // 标记发生了交换
                }
            }

            // 难点2:理解提前结束的条件
            // 如果这一轮没有发生交换,说明数组已经有序,可以提前结束
            if (!swapped) {
                break;
            }
        }
    }

    // 从后往前冒泡的版本
    public static void bubbleSortBackward(int[] arr) {
        int n = arr.length;
        boolean swapped;

        for (int i = 0; i < n - 1; i++) {
            swapped = false;

            // 难点3:理解从后往前冒泡的实现方式,把小的换前边
            // 从数组末尾开始,向前比较相邻元素
            for (int j = n - 1; j > i; j--) {
                if (arr[j - 1] > arr[j]) {
                    // 交换元素
                    int temp = arr[j - 1];
                    arr[j - 1] = arr[j];
                    arr[j] = temp;
                    swapped = true;
                }
            }

            if (!swapped) {
                break;
            }
        }
    }

    public static void main(String[] args) {
        int[] arr = {49, 38, 65, 97, 76, 13, 27, 49};

        System.out.println("排序前:");
        printArray(arr);

        // 测试基础版本
        int[] arr1 = arr.clone();
        bubbleSortBasic(arr1);
        System.out.println("基础版本排序后:");
        printArray(arr1);

        // 测试优化版本
        int[] arr2 = arr.clone();
        bubbleSortOptimized(arr2);
        System.out.println("优化版本排序后:");
        printArray(arr2);

        // 测试从后往前版本
        int[] arr3 = arr.clone();
        bubbleSortBackward(arr3);
        System.out.println("从后往前版本排序后:");
        printArray(arr3);
    }

    // 辅助方法:打印数组
    public static void printArray(int[] arr) {
        for (int num : arr) {
            System.out.print(num + " ");
        }
        System.out.println();
    }
}

练习

912. 排序数组 - 力扣(LeetCode)https://leetcode.cn/problems/sort-an-array/会超时,主要熟悉冒泡模板代码

java 复制代码
class Solution {
    public int[] sortArray(int[] nums) {
        int n=nums.length;
        for(int i=0;i<n-1;i++){
            boolean flag=false;
            for(int j=0;j<n-1-i;j++){
                if(nums[j+1]<nums[j]){
                    int temp=nums[j];
                    nums[j]=nums[j+1];
                    nums[j+1]=temp;
                    flag=true;
                }
            }
            if(!flag)break;
        }
        return nums;
    }
}

五、快速排序

算法思想

快速排序就像给一堆书按厚度排序:

你先随便拿起一本(通常拿第一本)作为"标杆",然后把比它薄的放左边,比它厚(或一样厚)的放右边。这样这本"标杆"书就找到了自己的正确位置。接着你再对左边和右边的书堆分别重复这个过程,直到每一堆只剩一本(或没有书),所有书就都排好序了。

简单说就是:分而治之,每步定一,递归完成

代码模板

java 复制代码
import java.util.Arrays;

public class QuickSort {

    public static void quickSort(int[] arr, int low, int high) {
        // 易错点1:递归终止条件必须是 low < high,否则会导致栈溢出或无效递归
        if (low < high) {
            // 划分操作,返回枢轴元素的最终位置
            int pivotPos = partition(arr, low, high);
            // 递归处理左半部分
            quickSort(arr, low, pivotPos - 1);
            // 递归处理右半部分
            quickSort(arr, pivotPos + 1, high);
        }
    }

    private static int partition(int[] arr, int low, int high) {
        // 选择第一个元素作为枢轴(pivot)
        int pivot = arr[low];

        // 难点1:循环条件是 low < high,确保左右指针不会交叉
        while (low < high) {
            // 难点2:先从右往左扫描,找到第一个小于枢轴的元素
            while (low < high && arr[high] >= pivot) {
                high--;
            }
            // 将比枢轴小的元素移到左端
            arr[low] = arr[high];

            // 再从左往右扫描,找到第一个大于等于枢轴的元素
            while (low < high && arr[low] <= pivot) {
                low++;
            }
            // 将比枢轴大的元素移到右端
            arr[high] = arr[low];
        }

        // 将枢轴放到最终位置
        arr[low] = pivot;
        // 返回枢轴位置
        return low;
    }

    public static void main(String[] args) {
        int[] arr = {49, 38, 65, 97, 76, 13, 27, 49};
        quickSort(arr, 0, arr.length - 1);
        System.out.println("排序结果: " + Arrays.toString(arr));
    }
}

练习

912. 排序数组 - 力扣(LeetCode)https://leetcode.cn/problems/sort-an-array/会有俩用例过不去,因为快排退化到O(n^2)

java 复制代码
class Solution {
    public int[] sortArray(int[] nums) {
        quickSort(nums,0,nums.length-1);
        return nums;

    }
    public void sort(int[] nums,int low,int high){
        if(low >= high) return; // 检查子数组长度
        
        int p=partition(nums,low,high);
        sort(nums,low,p-1);
        sort(nums,p+1,high);
    }
    //划分
    public int partition(int[] nums,int low ,int high){
    int p = nums[low]; // 基准值(挖出左坑)
    while(low < high){
        // 右指针向左找小于pivot的值
        while(low<high && nums[high]>=p) high--;
        nums[low] = nums[high]; // 右值填左坑(右留新坑)
        
        // 左指针向右找大于pivot的值
        while(low<high && nums[low]<=p) low++;
        nums[high] = nums[low]; // 左值填右坑(左留新坑)
    }
    nums[low] = p; // 基准值归位
    return low;
}
}

六、选择排序

算法思想

简单选择排序就像体育老师给一群身高不一的学生排队

  1. 从头看到尾,找出最矮的那个。

  2. 让他和排头的第一个同学交换位置。

  3. 现在第一个位置就是最矮的了,可以不管他。

  4. 接着从剩下的同学里 再找出最矮的,让他和剩下的队伍里排头的(也就是第二个位置)交换。

  5. 重复这个过程,直到所有人都排好。

每一轮都从剩下的人里挑出最矮的,放到当前最前面的位置。

代码模板

java 复制代码
public class SelectionSort {
    
    /**
     * 简单选择排序算法实现
     * @param arr 待排序数组
     */
    public static void selectionSort(int[] arr) {
        // 易错点1:注意循环边界条件,只需要n-1趟排序
        for (int i = 0; i < arr.length - 1; i++) {
            int minIndex = i; // 记录最小元素的索引
            
            // 难点1:内层循环从i+1开始,寻找[i, n-1]区间的最小值
            for (int j = i + 1; j < arr.length; j++) {
                // 易错点2:注意比较的是arr[j]和arr[minIndex],不是arr[i]
                if (arr[j] < arr[minIndex]) {
                    minIndex = j; // 更新最小元素索引
                }
            }
            
            // 易错点3:只有当找到的最小元素不是当前位置时才交换
            if (minIndex != i) {
                swap(arr, i, minIndex);
            }
            
            // 可选:打印每趟排序结果,便于理解算法过程
            // System.out.println("第" + (i+1) + "趟排序结果: " + Arrays.toString(arr));
        }
    }
    
    /**
     * 交换数组中两个元素的位置
     * @param arr 数组
     * @param i 第一个元素的索引
     * @param j 第二个元素的索引
     */
    private static void swap(int[] arr, int i, int j) {
        // 难点2:注意交换算法实现,使用临时变量避免值覆盖
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }
    
    public static void main(String[] args) {
        int[] arr = {49, 38, 65, 97, 76, 13, 27, 49};
        System.out.println("排序前: " + Arrays.toString(arr));
        
        selectionSort(arr);
        
        System.out.println("排序后: " + Arrays.toString(arr));
    }
}

练习

912. 排序数组 - 力扣(LeetCode)https://leetcode.cn/problems/sort-an-array/只能过去一半的用例,时间超限的毕竟复杂度O(n^2),只是为了熟练动手试一试理解会深一些。

java 复制代码
class Solution {
    public int[] sortArray(int[] nums) {
        int n=nums.length;
        for(int i=0;i<n;i++){
            int minIndex=i;
            for(int j=i+1;j<n;j++){
                if(nums[j]<nums[minIndex]){
                    minIndex=j;
                }
            }
            if(minIndex!=i){
                //交换
                int temp=nums[i];
                nums[i]=nums[minIndex];
                nums[minIndex]=temp;
            }
        }
        return nums;
    }
}

七、堆排序

算法思想

堆是完全二叉树

堆排序就像是在一堆数字中不断"选冠军"的过程。

先建堆 :把一堆无序的数字整理成一种叫"堆"的结构(比如大根堆------每个父节点都比它的孩子大,像家族里长辈总比晚辈有权威)。

从最后一个非叶子节点开始,从后往前,让每个小家庭都满足"父最大"的规则,不满足就交换,让小的数字往下沉。

再排序

每一轮,把堆顶(最大的数)取出来,和当前最后一个数交换(相当于把冠军请到领奖台最后的位置)。

剩下的数字重新调整成堆(剩下的再比一轮,选出新的冠军)。

重复以上步骤,直到所有数字都排好。

不断从最大堆顶取出当前最大值,放到后面,再调整堆,直到全部有序。

整个过程不需要额外空间 ,效率高(时间复杂度O(n log n)),但不稳定(相同大小的数可能相对顺序改变)。

可以看看这位大佬的概念讲解,很直观:

数据结构合集 - 堆与堆排序(算法过程, 效率分析, 稳定性分析)_哔哩哔哩_bilibilihttps://www.bilibili.com/video/BV1HYtseiEQ8?spm_id_from=333.788.videopod.sections&vd_source=4b89f462036a892baf8931104a1f36b1不过我编写的示例代码的数组下标从0开始,但仅仅是在节点的映射上有微小区别。

代码模板

理解的话,需要同时考虑逻辑上的树结构和物理存储上的数组,代码先改变逻辑结构再改变物理结构。

java 复制代码
public class HeapSort {

    public static void heapSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }

        int n = arr.length;

        // 构建最大堆(从最后一个非叶子节点开始)
        // 易错点1:注意循环起始位置应该是最后一个非叶子节点,即n/2-1
        for (int i = n / 2 - 1; i >= 0; i--) {
            heapify(arr, n, i);
        }

        // 一个个从堆顶取出元素
        for (int i = n - 1; i > 0; i--) {
            // 将当前堆顶元素(最大值)与末尾元素交换
            // 易错点2:交换后,堆的大小应该减1(i),但数组长度不变
            swap(arr, 0, i);

            // 对剩余元素重新构建最大堆
            heapify(arr, i, 0);
        }
    }

    // 调整堆(最大堆)
    // 难点1:理解下滤过程,需要递归调整被破坏的子堆
    private static 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);

            // 递归调整受影响的子堆
            // 难点2:理解这里需要递归调整,因为交换可能破坏了子堆的结构
            heapify(arr, n, largest);
        }
    }

    private static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

    public static void main(String[] args) {
        int[] arr = {12, 11, 13, 5, 6, 7};

        System.out.println("原始数组:");
        printArray(arr);

        heapSort(arr);

        System.out.println("排序后数组:");
        printArray(arr);
    }

    private static void printArray(int[] arr) {
        for (int value : arr) {
            System.out.print(value + " ");
        }
        System.out.println();
    }
}

易错点1:循环起始位置

java 复制代码
// 正确:从最后一个非叶子节点开始(n/2-1)
for (int i = n / 2 - 1; i >= 0; i--) {
    heapify(arr, n, i);
}

// 错误:如果从n-1开始,会浪费大量时间处理叶子节点
for (int i = n - 1; i >= 0; i--) {
    heapify(arr, n, i);
}

易错点2:堆大小变化

java 复制代码
// 正确:每次交换后,堆的大小减1(i)
for (int i = n - 1; i > 0; i--) {
    swap(arr, 0, i);
    heapify(arr, i, 0); // 注意这里使用i作为堆大小
}

// 错误:如果一直使用n作为堆大小,排序无法正确进行
for (int i = n - 1; i > 0; i--) {
    swap(arr, 0, i);
    heapify(arr, n, 0); // 错误:应该使用i而不是n
}

难点1:下滤过程理解

heapify方法需要确保以节点i为根的子树满足堆性质。如果节点i的值小于其子节点,需要将其与较大的子节点交换,并递归调整受影响的子树。

难点2:递归调整

交换节点后,被交换的子节点所在的子树可能不再满足堆性质,因此需要递归调用heapify来确保整个子树满足堆性质。

其他注意事项

  1. 数组下标从0开始,计算左右子节点时需要特别注意

  2. 堆排序是不稳定排序算法

  3. 时间复杂度为O(n log n),空间复杂度为O(1)

  4. 实际应用中,堆排序的常数因子较大,通常比快速排序慢一些

练习

912. 排序数组 - 力扣(LeetCode)https://leetcode.cn/problems/sort-an-array/

java 复制代码
class Solution {
    public int[] sortArray(int[] nums) {
        //注意这是数组下标从0开始
        int n=nums.length;
        //建堆
        for(int i=n/2-1;i>=0;i--){
            //调整
            heapify(nums,n,i);
        }

        //不断取堆顶元素
        for(int i=n-1;i>=0;i--){
            //交换堆顶与最后一个元素位置
            swap(nums,0,i);
            heapify(nums,i,0);
        }
        return nums;
    }

    public void heapify(int[] nums,int n,int i){
        //当前根节点
        int root=i;
        //左子树
        int left=2*i+1;
        //右子树
        int right=2*i+2;

        //调整
        if(left<n && nums[left]>nums[root]) root=left;
        if(right<n && nums[right]>nums[root])root=right;

        //逻辑树调整了,物理数组也要调整
        if(root!=i){
            //交换
            swap(nums,i,root);

            //下滤调整
            heapify(nums,n,root);
        }
    }
    public void swap(int[] nums,int a,int b){
        int temp=nums[a];
        nums[a]=nums[b];
        nums[b]=temp;
    }
}

八、归并排序

算法思想

归并排序就像是在整理一副乱序的扑克牌,采用"分治"策略:先拆开,再有序合并。

分(拆开):把一个大数组不断地从中间"对半切分",直到每个小数组只剩下1个元素(1个元素自然就是有序的)。

比如数组 [5, 3, 8, 1],先分成 [5, 3][8, 1],再继续分成 [5][3][8][1]

治(合并):将两个已经有序的小数组合并成一个更大的有序数组。

比如合并 [3, 5][1, 8]

比较两个数组的开头(3和1),取更小的1放到新数组;

再比较剩下的(3和8),取3;

继续比较(5和8),取5;

最后把8加入。

结果得到 [1, 3, 5, 8]

递归重复:自底向上,不断合并相邻有序子数组,直到整个数组有序。

先递归地拆分成最小单位,再两两合并并排序,最终得到完整有序数组。

  • 特点:稳定排序,时间复杂度为O(n log n),但需要额外O(n)空间用于合并。

代码模板

java 复制代码
public class MergeSort {

    public static void mergeSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }
        
        // 创建辅助数组,避免在递归过程中重复创建
        // 难点1:需要理解为什么要在外部创建辅助数组
        int[] temp = new int[arr.length];
        mergeSort(arr, 0, arr.length - 1, temp);
    }
    
    private static void mergeSort(int[] arr, int left, int right, int[] temp) {
        // 递归终止条件:当子数组只有一个元素时
        if (left < right) {
            int mid = left + (right - left) / 2; // 防止整数溢出
            
            // 递归排序左半部分
            mergeSort(arr, left, mid, temp);
            
            // 递归排序右半部分
            mergeSort(arr, mid + 1, right, temp);
            
            // 合并两个有序子数组
            merge(arr, left, mid, right, temp);
        }
    }
    
    private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
        int i = left;    // 左子数组起始索引
        int j = mid + 1; // 右子数组起始索引
        int k = 0;       // 临时数组索引
        
        // 比较两个子数组的元素,将较小的放入临时数组
        while (i <= mid && j <= right) {
            // 易错点1:注意这里的比较是 <= 而不是 <,保证排序的稳定性
            if (arr[i] <= arr[j]) {
                temp[k++] = arr[i++];
            } else {
                temp[k++] = arr[j++];
            }
        }
        
        // 将左子数组剩余元素复制到临时数组
        while (i <= mid) {
            temp[k++] = arr[i++];
        }
        
        // 将右子数组剩余元素复制到临时数组
        while (j <= right) {
            temp[k++] = arr[j++];
        }
        
        // 将临时数组中的元素复制回原数组
        // 难点2:理解这里需要将临时数组的内容复制回原数组的对应位置
        k = 0;
        while (left <= right) {
            arr[left++] = temp[k++];
        }
    }
    
    public static void main(String[] args) {
        int[] arr = {12, 11, 13, 5, 6, 7};
        
        System.out.println("原始数组:");
        printArray(arr);
        
        mergeSort(arr);
        
        System.out.println("排序后数组:");
        printArray(arr);
    }
    
    private static void printArray(int[] arr) {
        for (int value : arr) {
            System.out.print(value + " ");
        }
        System.out.println();
    }
}

易错点1:排序稳定性

java 复制代码
// 正确:使用 <= 保证排序的稳定性(相等元素的相对顺序不变)
if (arr[i] <= arr[j]) {
    temp[k++] = arr[i++];
} else {
    temp[k++] = arr[j++];
}

// 错误:使用 < 会破坏排序的稳定性
if (arr[i] < arr[j]) {
    temp[k++] = arr[i++];
} else {
    temp[k++] = arr[j++];
}

易错点2:索引处理

java 复制代码
// 正确:复制回原数组时,需要从left开始,到right结束
k = 0;
while (left <= right) {
    arr[left++] = temp[k++];
}

// 错误:如果直接从0开始复制,会破坏原数组的结构
k = 0;
for (int i = 0; i <= right - left; i++) {
    arr[i] = temp[i]; // 错误:应该从left开始,而不是0
}

难点1:辅助数组的使用

在外部创建辅助数组,避免在递归过程中重复创建数组,这样可以节省内存和提高性能。

难点2:递归过程理解

需要理解归并排序的分治思想:

  1. 将数组分成两半

  2. 递归地对每一半进行排序

  3. 合并两个已排序的子数组

难点3:合并过程

合并两个有序子数组是归并排序的核心步骤,需要正确处理三个指针(左子数组指针、右子数组指针和临时数组指针)。

其他注意事项

  1. 计算中间位置时使用 left + (right - left) / 2 而不是 (left + right) / 2,防止整数溢出

  2. 归并排序是稳定排序算法

  3. 时间复杂度为O(n log n),空间复杂度为O(n)

  4. 对于大规模数据,归并排序表现良好,但由于需要额外空间,在小规模数据上可能不如插入排序高效

练习

912. 排序数组 - 力扣(LeetCode)https://leetcode.cn/problems/sort-an-array/

java 复制代码
class Solution {
    public int[] sortArray(int[] nums) {
        int[] temp=new int[nums.length];
        sort(nums,0,nums.length-1,temp);
        return nums;

    }
    public void sort(int[] nums,int left,int right,int[] temp){
        //终止条件,只有一个节点的时候,本身是有序的
        if(left>=right)return;
        int mid=left+(right-left)/2;
        //归左边
        sort(nums,left,mid,temp);
        //归右边
        sort(nums,mid+1,right,temp);
        //并
        merge(nums,left,mid,right,temp);
    }
    public void merge(int[] nums,int left,int mid,int right,int[] temp){
        //左边序列第一个元素
        int i=left;
        //右边序列第一个元素
        int j=mid+1;
        //临时下标
        int k=0;

        //取左右序列中较小的先放入临时数组
         while (i <= mid && j <= right) {
            if (nums[i] <= nums[j]) {
                temp[k++] = nums[i++];
            } else {
                temp[k++] = nums[j++];
            }
        }

        //处理余下
        while(i<=mid)temp[k++]=nums[i++];
        while(j<=right)temp[k++]=nums[j++];

        k=0;
        //映射回源数组
        while(left<=right){
            nums[left++]=temp[k++];
        }
    }
}

九、基数排序

算法思想

基数排序是一种 非比较型 的排序算法,它的核心思想是:

"按位分配,逐趟收集"

想象你要对一堆三位数进行排序(比如:520, 211, 438...),你可以这样做:

  1. 先按个位数分堆:把所有数字按照个位数(0~9)分到10个桶里。

  2. 按顺序收集:从桶0到桶9依次取出所有数字,形成新的序列。

  3. 再按十位数分堆:对这个新序列,再按十位数分到10个桶里。

  4. 再次收集:同样从0到9依次取出。

  5. 最后按百位数分堆和收集

每处理一位数字,序列就更有序一点。三趟下来,整个序列就完全有序了。

数据结构合集 - 基数排序(算法过程, 效率分析, 稳定性分析)_哔哩哔哩_bilibilihttps://www.bilibili.com/video/BV1KrzrYeEDw?spm_id_from=333.788.videopod.sections&vd_source=4b89f462036a892baf8931104a1f36b1

代码模板

java 复制代码
import java.util.Arrays;
import java.util.LinkedList;
import java.util.Queue;

public class RadixSort {
    
    /**
     * 基数排序实现(从最低位开始排序)
     * @param arr 待排序数组
     */
    public static void radixSort(int[] arr) {
        // 易错点1:处理空数组或单元素数组
        if (arr == null || arr.length <= 1) {
            return;
        }
        
        // 难点1:确定最大数的位数
        int max = Arrays.stream(arr).max().getAsInt();
        int maxDigit = getMaxDigit(max);
        
        // 创建10个桶(0-9)
        Queue<Integer>[] buckets = new Queue[10];
        for (int i = 0; i < 10; i++) {
            buckets[i] = new LinkedList<>();
        }
        
        // 从个位开始,依次对每一位进行排序
        for (int digit = 0; digit < maxDigit; digit++) {
            // 分配过程:将数组中的元素放入对应的桶中
            for (int num : arr) {
                int bucketIndex = getDigit(num, digit);
                buckets[bucketIndex].offer(num);
            }
            
            // 收集过程:将桶中的元素按顺序放回原数组
            int index = 0;
            for (Queue<Integer> bucket : buckets) {
                while (!bucket.isEmpty()) {
                    arr[index++] = bucket.poll();
                }
            }
        }
    }
    
    /**
     * 获取数字指定位上的数字
     * @param num 数字
     * @param digit 位数(0表示个位,1表示十位,以此类推)
     * @return 指定位上的数字
     */
    private static int getDigit(int num, int digit) {
        // 难点2:计算指定位上的数字
        // 先除以10的digit次方,然后对10取余
        return (int) (num / Math.pow(10, digit)) % 10;
    }
    
    /**
     * 获取数字的位数
     * @param num 数字
     * @return 数字的位数
     */
    private static int getMaxDigit(int num) {
        // 易错点2:处理0的情况
        if (num == 0) {
            return 1;
        }
        
        int digitCount = 0;
        while (num != 0) {
            digitCount++;
            num /= 10;
        }
        return digitCount;
    }
    
    public static void main(String[] args) {
        // 测试用例
        int[] arr = {520, 211, 438, 888, 7, 111, 985, 666, 996, 233, 168};
        System.out.println("排序前: " + Arrays.toString(arr));
        
        radixSort(arr);
        
        System.out.println("排序后: " + Arrays.toString(arr));
        
        // 测试包含0的情况
        int[] arr2 = {0, 123, 45, 7, 89};
        System.out.println("排序前: " + Arrays.toString(arr2));
        
        radixSort(arr2);
        
        System.out.println("排序后: " + Arrays.toString(arr2));
    }
}
排序算法​ ​类别​ ​是否稳定​ ​平均时间复杂度​ ​最坏时间复杂度​
​插入排序​ 插入类 O(n2) O(n2)
​希尔排序​ 插入类(改进) ​否​ O(nlogn) O(n2)
​冒泡排序​ 交换类 O(n2) O(n2)
​快速排序​ 交换类(分治) ​否 O(nlogn) O(n2)
​选择排序​ 选择类 ​否​ O(n2) O(n2)
​堆排序​ 选择类(堆) ​否​ O(nlogn) O(nlogn)
​归并排序​ 分治类 O(nlogn) O(nlogn)
​基数排序​ 非比较类 ​是​ O(d⋅(n+k)) O(d⋅(n+k))

至此,数据结构系列已经结束了,感谢读友们的支持也感谢自己的坚持,未来一定会更好,我们一起加油!!!