基础算法

191 阅读22分钟

排序算法

image.png

排序(上)

image.png

1. 冒泡排序

这种写法相当于相邻的数字两两比较,并且规定:“谁大谁站右边”。经过 n-1 轮,数字就从小到大排序完成了。

image.png

第一种写法

public static 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]) {
                // 如果左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, j, j + 1);
            }
        }
    }
}
// 交换元素
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

第二种写法 这种写法相对于第一种写法的优点是:如果一轮比较中没有发生过交换,则立即停止排序,因为此时剩余数字一定已经有序了。

image.png

code

// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
 for (int i = 0; i < n; i ++) {
    // 提前退出冒泡循环的标志位
    boolean flag = false;
    for (int j = 0; j < n - i - 1; j ++) {
      if (a[j] > a[j+1]) { // 交换
        int tmp = a[j];
        a[j] = a[j+1];
        a[j+1] = tmp;
        flag = true;  // 表示该轮有数据交换      
      }
    }
    if (!flag) break;  // 没有数据交换,提前退出
  }
}

2. 选择排序

选择排序的思想是:双重循环遍历数组,每经过一轮比较,找到最小元素的下标,将其交换至首位

选择排序就好比第一个数字站在擂台上,大吼一声:“还有谁比我小?”。剩余数字来挨个打擂,如果出现比第一个数字小的数,则新的擂主产生。每轮打擂结束都会找出一个最小的数,将其交换至首位。经过 n-1 轮打擂,所有的数字就按照从小到大排序完成了。

image.png

code

public static void selectionSort(int[] arr) {
    int minIndex;
    for (int i = 0; i < arr.length - 1; i++) {
        minIndex = i;
        for (int j = i + 1; j < arr.length; j++) {
            if (arr[minIndex] > arr[j]) {
                // 记录最小值的下标
                minIndex = j;
            }
        }
        // 将最小元素交换至首位
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
    }
}

冒泡排序和选择排序有什么异同?

相同点:

都是两层循环,时间复杂度都为 O(n2n^2 ); 都只使用有限个变量,空间复杂度 O(1)

不同点:

冒泡排序在比较过程中就不断交换;而选择排序增加了一个变量保存最小值 / 最大值的下标,遍历完成后才交换,减少了交换次数。

事实上,冒泡排序和选择排序还有一个非常重要的不同点,那就是: 冒泡排序法是稳定的,选择排序法是不稳定的。

选择排序中,最小值和首位交换的过程可能会破坏稳定性。比如数列:[2, 2, 1],在选择排序中第一次进行交换时,原数列中的两个 2 的相对顺序就被改变了,因此,我们说选择排序是不稳定的。

比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。

LC 215. 数组中的第 K 个最大元素

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

输入: [3,2,3,1,2,4,5,5,6] 和 k = 4
输出: 4

本题的解题思路是,选择 k 次数组中的最大元素,将其交换到数组前面,然后返回数组的第 k 个元素即可。选择第k个最大值输出

class Solution {
    public int findKthLargest(int[] nums, int k) {
        int maxIdx;
        // 执行 k 次选择
        for (int i = 0; i < k; i ++) {
            maxIdx = i;
            // 找到最大值的下标
            for (int j = i + 1; j < nums.length; j ++) {
                if (nums[maxIdx] < nums[j]) {
                    maxIdx = j;
                }
            }
            // 将最大元素交换至首位
            swap(nums, i, maxIdx);
        }
        return nums[k - 1];
    }
    private static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }
}

3. 插入排序

在打扑克牌时,我们一边抓牌一边给扑克牌排序,每次摸一张牌,就将它插入手上已有的牌中合适的位置,逐渐完成整个排序。

插入排序每次排序第k个元素,此时前k - 1个元素已经从小到大排好序了,我们从已经排序好的元素从后往前遍历,找到第一个小于当前元素的位置,那么当前元素就应该插在当前元素的后面。

插入排序有两种写法:

  • 交换法:在新数字插入过程中,不断与前面的数字交换,直到找到自己合适的位置。
  • 移动法:在新数字插入过程中,与前面的数字不断比较,前面的数字不断向后挪出位置,当新数字找到自己的位置后,插入一次即可。

image.png

code

// 插入排序,a表示数组,n表示数组大小
public void insertionSort(int[] a, int n) {
  for (int i = 1; i < n; i ++) {
    int value = a[i];
    // 查找插入的位置
    for (int j = i - 1; j >= 0; j --) {
      if (a[j] > value) {
        a[j+1] = a[j];  // 数据向后移动
      } else {
        break;
      }
    }
    a[j+1] = value; // 插入数据
  }
}

交换法插入排序

当数字少于两个时,不存在排序问题,当然也不需要插入,所以我们直接从第二个数字开始往前插入。 整个过程就像是已经有一些数字坐成了一排,这时一个新的数字要加入,这个新加入的数字原本坐在这一排数字的最后一位,然后它不断地与前面的数字比较,如果前面的数字比它大,它就和前面的数字交换位置。

// 交换法插入排序
void insert_sort1(vector<int> &nums){
    int n = nums.size();
    for (int i = 1; i < n; i ++) {
        for (int j = i - 1; j >= 0 && nums[j] > nums[j + 1]; j --){
            swap(nums[j], nums[j + 1]);
        }
    }
}

移动法插入排序

我们发现,在交换法插入排序中,每次交换数字时,swap 函数都会进行三次赋值操作。但实际上,新插入的这个数字并不一定适合与它交换的数字所在的位置。也就是说,它刚换到新的位置上不久,下一次比较后,如果又需要交换,它马上又会被换到前一个数字的位置。

由此,我们可以想到一种优化方案:让新插入的数字先进行比较,前面比它大的数字不断向后移动,直到找到适合这个新数字的位置后,新数字只做一次插入操作即可。

这种方案我们需要把新插入的数字暂存起来,代码如下:

// 移动法插入排序
void insert_sort2(vector<int> &nums) {
    int n = nums.size();
    for (int i = 1; i < n; i ++) {
        int t = nums[i]; // 暂存需要插入的数字
        for (j = i - 1; j >= 0 && nums[j] > t; j --) {
            nums[j + 1] = nums[j];
        }
        nums[j + 1] = t;
    }
}

LC 147. 对链表进行插入排序

class Solution {
    public ListNode insertionSortList(ListNode head) {
        if(head == null || head.next == null) return head;
        
        // 创建哑结点,用于将在 head 前插入结点转换为在哑结点后插入
        ListNode dummy = new ListNode(0);
        dummy.next = head;
        
        // 记录已排序完成的结点末尾
        ListNode pre = head;

        while (pre.next != null) {
            // 当前需要新插入的结点
            ListNode insert = pre.next;
            
            // 新插入的值正好是最大值,直接插入链表末尾
            if (insert.val >= pre.val) {
                pre = pre.next;
                continue;
            }
            
            // 从头开始寻找插入位置
            ListNode cur = dummy;
            while (cur.next.val < insert.val) cur = cur.next;
            // 将新结点插入链表
            pre.next = insert.next;
            insert.next = cur.next;
            cur.next = insert;
        }
        return dummy.next;
    }
}

4. 希尔排序

希尔排序本质上是对插入排序的一种优化,它利用了插入排序的简单,又克服了插入排序每次只交换相邻两个元素的缺点。它的基本思想是:

  • 将待排序数组按照一定的间隔分为多个子数组,每组分别进行插入排序。这里按照间隔分组指的不是取连续的一段数组,而是每跳跃一定间隔取一个值组成一组
  • 逐渐缩小间隔进行下一轮排序
  • 最后一轮时,取间隔为 1,也就相当于直接使用插入排序。但这时经过前面的「宏观调控」,数组已经基本有序了,所以此时的插入排序只需进行少量交换便可完成

对数组 [84, 83, 88, 87, 61, 50, 70, 60, 80, 99] size = 10进行希尔排序的过程如下:

  • 第一遍(5 间隔排序):按照间隔 5 分割子数组,共分成五组,分别是 [84, 50], [83, 70], [88, 60], [87, 80], [61, 99]。对它们进行插入排序,排序后它们分别变成: [50, 84], [70, 83], [60, 88], [80, 87], [61, 99],此时整个数组变成 [50, 70, 60, 80, 61, 84, 83, 88, 87, 99]
  • 第二遍(2 间隔排序):按照间隔 2 分割子数组,共分成两组,分别是 [50, 60, 61, 83, 87], [70, 80, 84, 88, 99]。对他们进行插入排序,排序后它们分别变成: [50, 60, 61, 83, 87], [70, 80, 84, 88, 99],此时整个数组变成 [50, 70, 60, 80, 61, 84, 83, 88, 87, 99]。这里有一个非常重要的性质:当我们完成 22 间隔排序后,这个数组仍然是保持 55 间隔有序的。也就是说,更小间隔的排序没有把上一步的结果变坏
  • 第三遍(1 间隔排序,等于直接插入排序):按照间隔 1 分割子数组,分成一组,也就是整个数组。对其进行插入排序,经过前两遍排序,数组已经基本有序了,所以这一步只需经过少量交换即可完成排序。排序后数组变成 [50, 60, 61, 70, 80, 83, 84, 87, 88, 99],整个排序完成。

希尔排序算法步骤:

  1. 计算步长间隔值gap
  2. 将数组划分为这些子数组
  3. 按照插入排序思想进行排序
  4. 重复此过程,直到间隔为1,进行普通的插入排序。

希尔算法图解如下: image.png

code

public static void shellSort(int[] arr) {
    // 间隔序列(增量序列)
    for (int gap = arr.length / 2; gap > 0; gap /= 2) {
    
        // 从 gap 开始,对每个分组进行插入排序
        for (int i = gap; i < arr.length; i ++) {
            // curNum 站起来,开始找位置
            int curNum = arr[i];
            
            // 该组前一个数字的索引
            int preIdx = i - gap;
            
            // 在范围内并大于 curNum
            while (preIdx >= 0 && curNum < arr[preIdx]) {
                // 向后挪位置
                arr[preIdx + gap] = arr[preIdx];
                preIdx -= gap;
            }
            
            // curNum 找到了自己的位置,坐下
            arr[preIdx + gap] = curNum;
        }
    }
}

思考

选择排序和插入排序的时间复杂度相同,都是O(n^2),在实际的软件开发中,为什么我们更倾向于使用插入排序而不是冒泡排序算法呢?

答:从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要3个赋值操作,而插入排序只需要1个,所以在对相同数组进行排序时,冒泡排序的运行时间理论上要长于插入排序。

排序(下)

5. 归并排序

image.png

归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。

写递归代码的技巧就是,分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。所以,要想写出归并排序的代码,我们先写出归并排序的递推公式。

// 递推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))

// 终止条件:
p >= r 不用再继续分解

merge_sort(p…r) 表示,给下标从 p 到 r 之间的数组排序。

我们将这个排序问题转化为了两个子问题,merge_sort(p…q) 和 merge_sort(q+1…r),其中下标 q 等于 p 和 r 的中间位置,也就是 (p+r)/2。

当下标从 p 到 q 和从 q+1 到 r 这两个子数组都排好序之后,我们再将两个有序的子数组合并在一起,这样下标从 p 到 r 之间的数据就也排好序了。

默写:( 6 步)

// 自底向上 -->  先 < 递归 > 在 < 处理 >
1. 确定分界点 mid = ( l + r )  /  2 ;  //  下标 
2. 递归处理  左,  右 两段
3. 归并过程(双指针算法,指针表示剩余区间中中 min 元素  的 < 下标 > 位置)

分解过程

  1. 越界 ( l >= r) 则 return

  2. 确定分界点 mid = ( l + r ) / 2 , 递归处理 左, 右 两段区间

  • merge( q, l, mid )
  • merge( q, mid + 1, r)

归并过程

  1. 定义 k i j ; k为临时数组tmp的下标, 初始化为 0 i为 左区间 的第一个位置 l j为 右区间 的第一个位置 mid + 1
  2. while 循环 i ,j 没越所在区间的界, 比较小的 放入 tmp 数组中去
  3. 扫尾
  4. 物归原主

code

void merge_sort(int q[], int l, int r) {
    // 出口
    if (l >= r) return;
    
    // 分解
    int mid = (l + r) / 2;
    merge_sort (q, l, mid);
    merge_sort (q, mid + 1, r);
    
    // 合并
    int i = l, j = mid + 1, k = 0; 
    
    // 临时数组 tem[] 存储排序结果
    while( i <= mid && j <= r ) {
        if( q[i] <= q[j] ) tem[k ++] = q[i ++];
        else tem [k ++] = q [j ++];
    }
    
    // 扫尾
    while (i <= mid) tem [k ++] = q [i ++];  
    while (j <= r)   tem [k ++] = q [j ++];
    
    // 物归原主
    for (i = l, j = 0; i <= r; i ++, j ++)  q [i] = tem [j];
}

// merge_sort( q ,  0 , n - 1 ); 

如图所示,我们申请一个临时数组 tmp,大小与 A[p...r]相同。我们用两个游标 i 和 j,分别指向 A[p...q]和 A[q+1...r]的第一个元素。比较这两个元素 A[i]和 A[j],如果 A[i]<=A[j],我们就把 A[i]放入到临时数组 tmp,并且 i 后移一位,否则将 A[j]放入到数组 tmp,j 后移一位。

继续上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组 tmp 中的数据拷贝到原数组 A[p...r]中。

image.png

6. 快速排序

快排的思想是这样的:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。

我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。

image.png

步骤

1.确定分界点 x 为数组 q [ ] 的 中间下标对应的具体值

     x = a[ ( l + r ) / 2 ]  // 一般以中位数为分界点, 很少 x = a [l] 或 a [r] 或 随机

2.调整范围:<重点>

image.png

3.递归 处理 左边 和 右边 模板

// 先 < 处理 > 在 < 递归 >  (4 步)
1.  越界 ( l >= r),return2.  定义 分界点 x , 左指针 i, 右指针 j  ( 注意:i 要比左边界 l 超出去一位,j 要比右边界 r 超出去一位 )
3.  调整区间 保证左边小于等于x,右边大于等于x,x在哪一边都可以 💗💗💗
4.  递归处理左右两段

code

void quick_sort(int arr[], int l, int r) { 			
    if (l >= r)  return;

    int x = arr[(l + r) / 2]; // 分界点 < 具体数值 >
    int i = l - 1;            // 先越界  ++ i, j
    int j = r + 1;        
    // 调整区间
    while (i < j) {
        while (arr[++ i] < x); // i 寻找 < x 的数
        while (arr[-- j] > x); // j 寻找 > x 的数
        if (i < j) swap(arr[i], arr[j]);
    }
    
    quick_sort(arr, l, j);      // 递归处理 左区间
    quick_sort(arr, j + 1, r);  // 递归处理 右区间
}

// quick_sort(arr, 0, n-1);

image.png

线性排序

思考题:如何根据年龄给 100 万用户排序? 实际上,根据年龄给 100 万用户排序,就类似按照成绩给 50 万考生排序。我们假设年龄的范围最小 1 岁,最大不超过 120 岁。我们可以遍历这 100 万用户,根据年龄将其划分到这 120 个桶里,然后依次顺序遍历这 120 个桶中的元素。这样就得到了按照年龄排序的 100 万用户数据。

桶排序(Bucket sort)

首先,我们来看桶排序。桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。 image.png

桶排序比较适合用在外部排序中

所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?

  1. 我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02...99)。

  2. 理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。

  3. 不过,你可能也发现了,订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。这又该怎么办呢?

  4. 针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元....901 元到 1000 元。如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。

计数排序(Counting sort)

基数排序(Radix sort)

image.png

总结

桶排序、计数排序、基数排序

一、线性排序算法介绍

  1. 线性排序算法包括桶排序、计数排序、基数排序。
  2. 线性排序算法的时间复杂度为O(n)。
  3. 此3种排序算法都不涉及元素之间的比较操作,是非基于比较的排序算法。
  4. 对排序数据的要求很苛刻,重点掌握此3种排序算法的适用场景。

二、桶排序(Bucket sort)

  1. 算法原理:
  • 将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行快速排序。

  • 桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。 2.使用条件

  • 要排序的数据需要很容易就能划分成m个桶,并且桶与桶之间有着天然的大小顺序。

  • 数据在各个桶之间分布是均匀的。 3.适用场景

  • 桶排序比较适合用在外部排序中。

  • 外部排序就是数据存储在外部磁盘且数据量大,但内存有限无法将整个数据全部加载到内存中。 4.应用案例

  • 需求描述: 有10GB的订单数据,需按订单金额(假设金额都是正整数)进行排序 但内存有限,仅几百MB

  • 解决思路: 扫描一遍文件,看订单金额所处数据范围,比如1元-10万元,那么就分100个桶。 第一个桶存储金额1-1000元之内的订单,第二个桶存1001-2000元之内的订单,依次类推。 每个桶对应一个文件,并按照金额范围的大小顺序编号命名(00,01,02,…,99)。 将100个小文件依次放入内存并用快排排序。 所有文件排好序后,只需按照文件编号从小到大依次读取每个小文件并写到大文件中即可。

  • 注意点:若单个文件无法全部载入内存,则针对该文件继续按照前面的思路进行处理即可。

三、计数排序(Counting sort) 1.算法原理

  • 计数其实就是桶排序的一种特殊情况。
  • 当要排序的n个数据所处范围并不大时,比如最大值为k,则分成k个桶
  • 每个桶内的数据值都是相同的,就省掉了桶内排序的时间。 2.代码实现(参见下一条留言)
  • 案例分析: 假设只有8个考生分数在0-5分之间,成绩存于数组A[8] = [2,5,3,0,2,3,0,3]。 使用大小为6的数组C[6]表示桶,下标对应分数,即0,1,2,3,4,5 C[6]存储的是考生人数,只需遍历一边考生分数,就可以得到C[6] = [2,0,2,3,0,1]。 对C[6]数组顺序求前缀和则C[6]=[2,2,4,7,7,8],c[k]存储的是小于等于分数k的考生个数。 数组R[8] = [0,0,2,2,3,3,3,5]存储考生名次。那么如何得到R[8]的呢? 从后到前依次扫描数组A,比如扫描到3时,可以从数组C中取出下标为3的值7,也就是说,到目前为止,包括自己在内,分数小于等于3的考生有7个,也就是说3是数组R的第7个元素(也就是数组R中下标为6的位置)。当3放入数组R后,小于等于3的元素就剩下6个了,相应的C[3]要减1变成6。 以此类推,当扫描到第二个分数为3的考生时,就会把它放入数组R中第6个元素的位置(也就是下标为5的位置)。当扫描完数组A后,数组R内的数据就是按照分数从小到大排列的了。
  1. 使用条件
  • 只能用在数据范围不大的场景中,若数据范围k比要排序的数据n大很多,就不适合用计数排序;
  • 计数排序只能给非负整数排序,其他类型需要在不改变相对大小情况下,转换为非负整数;
  • 比如如果考试成绩精确到小数后一位,就需要将所有分数乘以10,转换为整数。

四、基数排序(Radix sort)

  1. 算法原理(以排序10万个手机号为例来说明)
  • 比较两个手机号码a,b的大小,如果在前面几位中a已经比b大了,那后面几位就不用看了。
  • 借助稳定排序算法的思想,可以先按照最后一位来排序手机号码,然后再按照倒数第二位来重新排序,以此类推,最后按照第一个位重新排序。
  • 经过11次排序后,手机号码就变为有序的了。 4)每次排序有序数据范围较小,可以使用桶排序或计数排序来完成。 2.使用条件
  • 要求数据可以分割独立的“位”来比较;
  • 位之间由递进关系,如果a数据的高位比b数据大,那么剩下的地位就不用比较了;
  • 每一位的数据范围不能太大,要可以用线性排序,否则基数排序的时间复杂度无法做到O(n)。

五、思考

  1. 如何根据年龄给100万用户数据排序?

  2. 对D,a,F,B,c,A,z这几个字符串进行排序,要求将其中所有小写字母都排在大写字母前面,但是小写字母内部和大写字母内部不要求有序。比如经过排序后为a,c,z,D,F,B,A,这个如何实现呢?如果字符串中处理大小写,还有数字,将数字放在最前面,又该如何解决呢?

利用桶排序思想,弄小写,大写,数字三个桶,遍历一遍,都放进去,然后再从桶中取出来就行了。相当于遍历了两遍,复杂度O(n)‘

牛客刷题

链表

BM2 链表内指定区间反转

输入:{1,2,3,4,5},2,4
返回值:{1,4,3,2,5}
public ListNode reverseBetween (ListNode head, int m, int n) {
        // 设置虚拟头节点
        ListNode res = new ListNode(-1);
        res.next = head;
        ListNode pre = res;
        ListNode cur = head;
        // 找到反转起始点m
         for(int i = 1; i < m; i ++){ 
            pre = cur;
            cur = cur.next;
        }
        // 开始反转
        for(int i = m; i < n; i ++) {
            // 第一次反转2,3 需要用到 1,2,3,4四个节点,如图;反转2次就行了
            ListNode temp = cur.next;
            cur.next = temp.next;  // 1
            temp.next = pre.next;  // 2
            pre.next = temp;       // 3
        }

        return res.next;
    }

image.png

BM3 链表中的节点每k个一组翻转

题目主要信息:

  • 给定一个链表,从头开始每k个作为一组,将每组的链表结点翻转
  • 组与组之间的位置不变
  • 如果最后链表末尾剩余不足k个元素,则不翻转,直接放在最后
给定的链表是 12345
对于 k=2 , 你应该返回 21435
对于 k=3 , 你应该返回 32145
public ListNode reverseKGroup (ListNode head, int k) {
        // 找到该组的尾
        ListNode tail = head;
        for(int i = 0; i < k; i ++) {
            if(tail == null) {
                return head;
            }
            tail = tail.next;
        }
        
        // 反转该组, head --> tail之间反转
        ListNode pre = null;
        ListNode cur = head;
        while(cur != tail) {
            ListNode temp = cur.next;
            cur.next = pre;
            pre = cur;
            cur = temp;
        }
        
        // 反转以后,head表示改组的尾了,而pre则是该组的头
        // 该组的尾(head)指向下一组的头(pre)
        head.next = reverseKGroup(tail, k);
        
        // 每次递归返回头pre
        return pre;
    }

image.png

image.png

image.png

BM4 合并两个排序的链表 (递归)

递归

  • step 1:每次比较两个链表当前结点的值,然后取较小值的链表指针往后,另一个不变送入递归中。
  • step 2:递归回来的结果我们要加在当前较小值的结点后面,相当于不断在较小值后面添加结点。
  • step 3:递归的终止是两个链表为空。
public ListNode Merge(ListNode list1,ListNode list2) {
        // 一个已经为空了,返回另一个
        if(list1 == null) 
            return list2;
        if(list2 == null)
            return list1;
        
        // 用较小的值的结点返回
        if(list1.val <= list2.val) { 
            list1.next = Merge(list1.next, list2);
            return list1;
        } else {
            list2.next = Merge(list1, list2.next);
            return list2;
        }
    }

BM5 合并k个已排序的链表

优先队列 小顶堆:

Queue<ListNode> pq = new PriorityQueue<>((v1, v2) -> v1.val - v2.val);

用优先队列建一个小顶堆,每次堆顶为值最小的节点,依次取出,然后再将它的下一个节点放回去。
再重复过程~

image.png

代码

public ListNode mergeKLists(ArrayList<ListNode> lists) {
        // 小根堆(v1.val - v2.val)
        Queue<ListNode> pq = new PriorityQueue<>((v1, v2) -> v1.val - v2.val);
        
        // 遍历所有链表第一个元素, 不为空则加入小顶堆
        for(int i = 0; i < lists.size(); i++){ 
            if(lists.get(i) != null) 
                pq.add(lists.get(i));
        }
        
        // 虚表头
        ListNode res = new ListNode(-1);
        ListNode head = res;
        
        // 优先队列不为空
        while(!pq.isEmpty()) {
            // 取出堆顶
            ListNode temp = pq.poll();
            head.next = temp;
            head = temp;
            
            // 堆顶元素的后一个元素加入小顶堆
            if(temp.next != null) {
                pq.add(temp.next);
            }
        }
        return res.next;
    }

BM6 判断链表中是否有环

如果快指针到了链表末尾,说明没有环,因为它每次走两步,所以要验证连续两步是否为NULL。

public boolean hasCycle(ListNode head) {
        if(head == null) return false;
        // 快慢双指针: 一个走1步,一个走2步
        ListNode slow = head;
        ListNode fast = head;
        // 如果没环, 快指针会先到链表尾
        while(fast != null && fast.next != null) {
            fast = fast.next.next; 
            slow = slow.next; 
            // 相遇则有环
            if(fast == slow) return true;
        }
        return false;
    }

BM7 链表中环的入口结点

import java.util.*;
public class Solution {
     public ListNode EntryNodeOfLoop(ListNode pHead) {
        // 判断是否存在环
        if(!hasCycle(pHead)) return null;
        
        // Map 存放链表出现过没
        HashMap<ListNode, Integer> param = new HashMap<>();
        
        // 遍历链表,找到出现过两次的节点
        ListNode p = pHead;
        while(p.next != null) {
            if(!param.containsKey(p)) {
                param.put(p, 1);
                p = p.next;
            } else {
                break;
            }
        }
        return p;
    }
    public boolean hasCycle(ListNode head) {... }
}

BM11 链表相加(二)

image.png

public class Solution {
    public ListNode addInList (ListNode head1, ListNode head2) {
        if(head1 == null) return head2;
        if(head2 == null) return head1;
        // 反转链表
        head1 = ReverseList(head1); 
        head2 = ReverseList(head2);
        // 添加表头
        ListNode res = new ListNode(-1); 
        ListNode head = res;
        // 进位符号
        int carry = 0; 
        // 某个链表还有值或者进位还有
        while(head1 != null || head2 != null || carry != 0){ 
            // 链表不为空则取其值
            int x = head1 == null ? 0 : head1.val; 
            int y = head2 == null ? 0 : head2.val;
            // 相加
            int temp = x + y + carry; 
            carry = temp / 10;
            temp %= 10;
            // 存入结果集链表中
            head.next = new ListNode(temp); 
            head = head.next;
            // 移动链表计算接下去的两个元素
            if(head1 != null) head1 = head1.next;
            if(head2 != null) head2 = head2.next;
        }
        // 结果反转回来
        return ReverseList(res.next); 
    }

    // 反转链表 返回反转后的头节点
    public ListNode ReverseList(ListNode head) {
        ListNode pre = null;
        ListNode cur = head;
        while (cur != null) {
            ListNode cur_next = cur.next;
            cur.next = pre;
            pre = cur;
            cur = cur_next;
        }
        return pre;
    }
}

BM12 单链表的排序

    public ListNode sortInList (ListNode head) {
        List<Integer> nums = new ArrayList();
        ListNode p = head;
        // 遍历链表,将节点值加入数组
        while(p != null) {
            nums.add(p.val);
            p = p.next;
        }
        p = head;
        // 对数组元素排序 (从小到大)
        Collections.sort(nums);
        // 将数组元素依次替换原链表的值
        for(int i = 0; i < nums.size(); i ++){ 
            p.val = nums.get(i); 
            p = p.next;
        }
        return head;
    }

BM13 判断一个链表是否为回文结构

    public boolean isPail (ListNode head) {
        ArrayList<Integer> nums = new ArrayList();
        // 将链表元素取出一次放入数组
        while(head != null){ 
            nums.add(head.val);
            head = head.next;
        }
        // 双指针遍历比较
        int l = 0;
        int r = nums.size() - 1;
        while (l < r) {
            int x = nums.get(l);
            int y = nums.get(r); 
            if(x != y) return false;
            l ++;
            r --;
        }
        return true;
    }

BM20 数组中的逆序对

输入:

[1,2,3,4,5,6,7,0]

返回值:

7
public class Solution { 
    // 结果
    public static long res = 0;
    public long mod = 1000000007;
    
    public int InversePairs(int[] array) {
        mergeSort(array, 0, array.length - 1);
        res %= mod;  //防止溢出
        return (int)res;
    }
    
    public void mergeSort(int[] nums, int l, int r) {
        //出口 
        if(l >= r) return;
        
        // 切分两部分
        int mid = (l + r) / 2;
        mergeSort(nums, l, mid);
        mergeSort(nums, mid + 1, r);
            
        // 归并
        int i = l;
        int j = mid + 1
        int k = 0;
        int[] temp = new int[r - l + 1];
        
        while (i <= mid && j <= r) {
            if(nums[i] <= nums[j]) temp[k ++] = nums[i ++];
            else {
                // 左边比右边大,答案增加,统计逆序对
                res += mid - i + 1;
                temp[k ++] = nums[j ++];
            }
        }
        
        // 扫尾
        while(i <= mid) temp[k ++] = nums[i ++];
        while(j <= r) temp[k ++] = nums[j ++];
        
        // 物归原主
        for(i = l, j = 0; i <= r; i ++, j ++) nums[i] = temp[j];
    }  
}

BM21 旋转数组的最小数字

图片.png

  1. 我们发现除了最后水平的一段(黑色水平那段)之外,其余部分满足二分性质:竖直虚线左边的数满足 nums[i] ≥ nums[0] 而竖直虚线右边的数不满足这个条件。

  2. 分界点就是整个数组的最小值。所以我们先将最后水平的一段删除即可。

  3. 另外,不要忘记处理数组完全单调的特殊情况:当我们删除最后水平的一段之后,如果剩下的最后一个数大于等于第一个数,则说明数组完全单调。

public class Solution {
    public int minNumberInRotateArray(int [] array) {
        int n = array.length - 1;
        
        // 去掉最后相等的元素
        while(n > 0 && array[n] == array[0]) n --;
        
        // 特殊情况: 完全单调
        if(array[n] > array[0]) return array[0];
        
        // 二分找分界点
        int l = 0, r = n;
        while(l < r) {
            int mid = (l + r) / 2;
            if(array[mid] < array[0]) r = mid;
            else l = mid + 1;
        } 
        return array[r];
    }
}

BM23 二叉树的前(中后)序遍历

public void preorder(List<Integer> list, TreeNode root){
        if(root == null) return; // 遇到空节点则返回
        list.add(root.val);
        preorder(list, root.left);
        preorder(list, root.right);
    }

BM26 求二叉树的层序遍历(队列)

Java中使用LinkedList做队列

入队: offer(T v)

出队

  • T poll() , 如果队列为空,则返回null
  • T remove(), 如果队列为空,则抛出异常

偷看: 看看队首元素不移除它。

  • T peek() , 如果队列为空,则返回null
  • T element(), 如果队列为空,则抛出异常

是否为空: isEmpty(), 空返回true,否则返回false

图片.png

import java.util.*;
public class Main {
    // add() 和 remove() 在失败的时候会抛出异常(不推荐)
    Queue<String> queue = new LinkedList<String>();
    // 添加元素
    queue.offer("a");
    // 返回第一个元素,并在队列中删除
    queue.poll(); 
    // 返回第一个元素 
    queue.peek();   
}

图片.png

    public ArrayList<ArrayList<Integer>> levelOrder (TreeNode root) {
        // 存放结果: [[1],[2,3],[4,5]]
        ArrayList<ArrayList<Integer>> res = new ArrayList();
        if(root == null) return res;

        // 队列存储,进行层次遍历
        Queue<TreeNode> q = new LinkedList<TreeNode>();
        q.offer(root);
        while(!q.isEmpty()) {
            // 存放该层的元素值
            ArrayList<Integer> row = new ArrayList();
            
            // 该层的节点个数
            int n = q.size();
            
            // 遍历该层所有节点
            for(int i = 0; i < n; i ++) {
                TreeNode t = q.poll();
                row.add(t.val);
                // 若左右孩子存在,则存入作为下一个层次
                if(t.left != null) q.offer(t.left);
                if(t.right != null) q.offer(t.right);
            }
            // 每一层加入结果集
            res.add(row);   
        }
        return res;
    }

BM28 二叉树的最大深度

public class Solution {
    int res = 0;
    public int maxDepth (TreeNode root) {
        if(root == null) return 0;
        dfs(root, 1);
        return res;
    }
    public void dfs(TreeNode root, int d) {
        if(root.left != null) dfs(root.left, d + 1);
        if(root.right != null) dfs(root.right, d + 1);
        res = Math.max(res, d);
    }
}