(5) 归并排序

news2024/9/20 18:45:24

归并排序

归并排序是一种分治策略的排序算法。它是一种比较特殊的排序算法,通过递归地先使每个子序列有序,再将两个有序的序列进行合并成一个有序的序列。

归并排序首先由著名的现代计算机之父 John_von_Neumann 在 1945 年发明,被用在了 EDVAC(一台美国早期电子计算机),足足用墨水写了 23 页的排序程序。注:冯·诺依曼(John von Neumann,1903年12月28日-1957年2月8日),美籍匈牙利数学家、计算机科学家、物理学家,是20世纪最重要的数学家之一。

一、算法介绍

请添加图片描述

我们先介绍两个有序的数组合并成一个有序数组的操作。

  1. 先申请一个辅助数组,长度等于两个有序数组长度的和。
  2. 从两个有序数组的第一位开始,比较两个元素,哪个数组的元素更小,那么该元素添加进辅助数组,然后该数组的元素变更为下一位,继续重复这个操作,直至数组没有元素。
  3. 返回辅助数组。

举一个例子:

有序数组A:[3 8 9 11 13]
有序数组B:[1 5 8 10 17 19 20 23]
[] 表示比较的范围。

因为 1 < 3,所以 1 加入辅助数组
有序数组A:[3 8 9 11 13]
有序数组B:1 [5 8 10 17 19 20 23] 
辅助数组:1

因为 3 < 5,所以 3 加入辅助数组
有序数组A:3 [8 9 11 13]
有序数组B:1 [5 8 10 17 19 20 23] 
辅助数组:1 3

因为 5 < 8,所以 5 加入辅助数组
有序数组A:3 [8 9 11 13]
有序数组B:1 5 [8 10 17 19 20 23] 
辅助数组:1 3 5

因为 8 == 8,所以 两个数都 加入辅助数组
有序数组A:3 8 [9 11 13]
有序数组B:1 5 8 [10 17 19 20 23] 
辅助数组:1 3 5 8 8

因为 9 < 10,所以 9 加入辅助数组
有序数组A:3 8 9 [11 13]
有序数组B:1 5 8 [10 17 19 20 23] 
辅助数组:1 3 5 8 8 9

因为 10 < 11,所以 10 加入辅助数组
有序数组A:3 8 9 [11 13]
有序数组B:1 5 8 10 [17 19 20 23] 
辅助数组:1 3 5 8 8 9 10

因为 11 < 17,所以 11 加入辅助数组
有序数组A:3 8 9 11 [13]
有序数组B:1 5 8 10 [17 19 20 23] 
辅助数组:1 3 5 8 8 9 10 11

因为 13 < 17,所以 13 加入辅助数组
有序数组A:3 8 9 11 13
有序数组B:1 5 8 10 [17 19 20 23] 
辅助数组:1 3 5 8 8 9 10 11 13

因为数组A已经没有比较元素,将数组B剩下的元素拼接在辅助数组后面。

结果:1 3 5 8 8 9 10 11 13 17 19 20 23

将两个有序数组进行合并,最多进行 n 次比较就可以生成一个新的有序数组,n 是两个数组长度较大的那个。

归并操作最坏的时间复杂度为:O(n),其中 n 是较长数组的长度。

归并操作最好的时间复杂度为:O(n),其中 n 是较短数组的长度。

正是利用这个特点,归并排序先排序较小的数组,再将有序的小数组合并形成更大有序的数组。

归并排序有两种递归做法,一种是自顶向下,一种是自底向上。

1.1 自顶向下归并排序

从一个大数组开始,不断地往下切分,如图:

在这里插入图片描述

从上往下进行递归,直到切分的小数组无法切分了,然后不断地对这些有序数组进行合并。

每次都是一分为二,特别均匀,所以最差和最坏时间复杂度都一样。归并操作的时间复杂度为:O(n),因此总的时间复杂度为:T(n)=2T(n/2)+O(n),根据主定理公式可以知道时间复杂度为:O(nlogn)。我们可以自己计算一下:

归并排序,每次归并操作比较的次数为两个有序数组的长度: n/2

T(n) = 2*T(n/2) + n/2
T(n/2) = 2*T(n/4) + n/4
T(n/4) = 2*T(n/8) + n/8
T(n/8) = 2*T(n/16) + n/16
...
T(4) = 2*T(2) + 4
T(2) = 2*T(1) + 2
T(1) = 1

进行合并也就是:

T(n) = 2*T(n/2) + n/2
     = 2^2*T(n/4)+ n/2 + n/2
     = 2^3*T(n/8) + n/2 + n/2 + n/2
     = 2^4*T(n/16) + n/2 + n/2 + n/2 + n/2
     = ...
     = 2^logn*T(1) + logn * n/2
     = 2^logn + 1/2*nlogn
     = n + 1/2*nlogn

因为当问题规模 n 趋于无穷大时 nlogn 比 n 大,所以 T(n) = O(nlogn)。

因此时间复杂度为:O(nlogn)。

因为不断地递归,程序栈层数会有 logn 层,所以递归栈的空间复杂度为:O(logn),对于排序十亿个整数,也只要:log(100 0000 0000)=29.897,占用的堆栈层数最多 30 层忧。

1.2. 自底向上归并排序

从小数组开始排序,不断地合并形成更大的有序数组。

在这里插入图片描述

时间复杂度和自顶向上归并排序一样,也都是 O(nlogn)

因为不需要使用递归,没有程序栈占用,因此递归栈的空间复杂度为:O(1)。

二、算法实现

自顶向下的归并排序递归实现:

/**
归并排序 1
*/
package main

import "fmt"

func main() {
	arr := []int{5, 4, 9, 8, 7, 6, 0, 1, 3, 2, 2}
	MergeSort(arr, 0, len(arr))
	fmt.Println(arr)
	list2 := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3}
	MergeSort(list2, 0, len(list2))
	fmt.Println(list2)
}

// 归并排序中的合并算法
func Merge(array []int, start int, mid int, end int) {
	// 申请额外的空间来合并两个有序数组,这两个数组是 array[begin,mid),array[mid,end)
	leftSize := mid - start         // 左边数组的长度
	rightSize := end - mid          // 右边数组的长度
	newSize := leftSize + rightSize // 辅助数组的长度
	result := make([]int, 0, newSize)

	l, r := 0, 0
	for l < leftSize && r < rightSize {
		lValue := array[start+l] // 左边数组的元素
		rValue := array[mid+r]   // 右边数组的元素
		// 小的元素先放进辅助数组里
		if lValue < rValue {
			result = append(result, lValue)
			l++
		} else {
			result = append(result, rValue)
			r++
		}
	}

	// 将剩下的元素追加到辅助数组后面
	result = append(result, array[start+l:mid]...)
	result = append(result, array[mid+r:end]...)

	// 将辅助数组的元素复制回原数组,这样该辅助空间就可以被释放掉
	for i := 0; i < newSize; i++ {
		array[start+i] = result[i]
	}
	return
}

// 归并排序
// 自顶向下归并排序,排序范围在 [begin,end) 的数组
func MergeSort(array []int, start int, end int) {
	// 元素数量大于1时才进入递归
	if end - start >1 {
		// 将数组一分为二,分为 array[begin,mid) 和 array[mid,high)
		mid := start + (end-start+1)/2
		// 对前半部分进行排序
		MergeSort(array, start, mid)
		// 对后半部分进行排序
		MergeSort(array, mid, end)
		// 合并前后两部分
		Merge(array, start, mid, end)
	}
}

自底向上的非递归实现:

package main

import "fmt"

// 自底向上归并排序
func MergeSort2(array []int, begin, end int) {

    // 步数为1开始,step长度的数组表示一个有序的数组
    step := 1

    // 范围大于 step 的数组才可以进入归并
    for end-begin > step {
        // 从头到尾对数组进行归并操作
        // step << 1 = 2 * step 表示偏移到后两个有序数组将它们进行归并
        for i := begin; i < end; i += step << 1 {
            var lo = i                // 第一个有序数组的上界
            var mid = lo + step       // 第一个有序数组的下界,第二个有序数组的上界
            var hi = lo + (step << 1) // 第二个有序数组的下界

            // 不存在第二个数组,直接返回
            if mid > end {
                return
            }

            // 第二个数组长度不够
            if hi > end {
                hi = end
            }

            // 两个有序数组进行合并
            merge(array, lo, mid, hi)
        }

        // 上面的 step 长度的两个数组都归并成一个数组了,现在步长翻倍
        step <<= 1
    }
}

// 归并操作
func merge(array []int, begin int, mid int, end int) {
    // 申请额外的空间来合并两个有序数组,这两个数组是 array[begin,mid),array[mid,end)
    leftSize := mid - begin         // 左边数组的长度
    rightSize := end - mid          // 右边数组的长度
    newSize := leftSize + rightSize // 辅助数组的长度
    result := make([]int, 0, newSize)

    l, r := 0, 0
    for l < leftSize && r < rightSize {
        lValue := array[begin+l] // 左边数组的元素
        rValue := array[mid+r]   // 右边数组的元素
        // 小的元素先放进辅助数组里
        if lValue < rValue {
            result = append(result, lValue)
            l++
        } else {
            result = append(result, rValue)
            r++
        }
    }

    // 将剩下的元素追加到辅助数组后面
    result = append(result, array[begin+l:mid]...)
    result = append(result, array[mid+r:end]...)

    // 将辅助数组的元素复制回原数组,这样该辅助空间就可以被释放掉
    for i := 0; i < newSize; i++ {
        array[begin+i] = result[i]
    }
    return
}

func main() {
    list := []int{5}
    MergeSort2(list, 0, len(list))
    fmt.Println(list)

    list1 := []int{5, 9}
    MergeSort2(list1, 0, len(list1))
    fmt.Println(list1)

    list2 := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3}
    MergeSort2(list2, 0, len(list2))
    fmt.Println(list2)
}

三、算法改进

归并排序归并操作占用了额外的辅助数组,且归并操作是从一个元素的数组开始。

我们可以做两点改进:

  1. 对于小规模数组,使用直接插入排序。
  2. 原地排序,节约掉辅助数组空间的占用。

我们建议使用自底向上非递归排序,不会有程序栈空间损耗。

我们先来介绍一种翻转算法,也叫手摇算法,主要用来对数组两部分进行位置互换,比如数组: [9,8,7,1,2,3],将前三个元素与后面的三个元素交换位置,变成 [1,2,3,9,8,7]

再比如,将字符串 abcde1234567 的前 5 个字符与后面的字符交换位置,那么手摇后变成:1234567abcde

如何翻转呢?

  1. 将前部分逆序
  2. 将后部分逆序
  3. 对整体逆序

示例如下:

翻转 [1234567abcde] 的前5个字符。

1. 分成两部分:[abcde][1234567]
2. 分别逆序变成:[edcba][7654321]
3. 整体逆序:[1234567abcde]

归并原地排序利用了手摇算法的特征,不需要额外的辅助数组。

首先,两个有序的数组,分别是 arr[begin,mid-1]arr[mid,end],此时初始化 i=begin,j=mid,k=end,从 i~j 为左有序的数组,k~j为右有序的数组,如图:

在这里插入图片描述

将 i 向后移动,找到第一个 arr[i]>arr[j]的索引,这个时候,i 前面的部分已经排好序了,begin~i 这些元素已经是两个有序数组的前 n 小个元素。如图:

在这里插入图片描述

然后将 j 向后移动,找到第一个 arr[j]>arr[i]的索引,如图:

在这里插入图片描述

这个时候,mid~j 中的元素都小于 arr[i],前面已经知道从 begin~i 已经是前 n 小了,所以这两部分 begin~i,mid~j 也是有序的了,我们要想办法将这两部分连接在一起。

我们只需进行翻转,将 i~mid 和 mid,j-1 部分进行位置互换即可,我们可以用手摇算法。

具体的代码如下:

package main

import "fmt"

func InsertSort(list []int) {
    n := len(list)
    // 进行 N-1 轮迭代
    for i := 1; i <= n-1; i++ {
        deal := list[i] // 待排序的数
        j := i - 1      // 待排序的数左边的第一个数的位置

        // 如果第一次比较,比左边的已排好序的第一个数小,那么进入处理
        if deal < list[j] {
            // 一直往左边找,比待排序大的数都往后挪,腾空位给待排序插入
            for ; j >= 0 && deal < list[j]; j-- {
                list[j+1] = list[j] // 某数后移,给待排序留空位
            }
            list[j+1] = deal // 结束了,待排序的数插入空位
        }
    }
}

// 自底向上归并排序优化版本
func MergeSort3(array []int, n int) {
    // 按照三个元素为一组进行小数组排序,使用直接插入排序
    blockSize := 3
    a, b := 0, blockSize
    for b <= n {
        InsertSort(array[a:b])
        a = b
        b += blockSize
    }
    InsertSort(array[a:n])

    // 将这些小数组进行归并
    for blockSize < n {
        a, b = 0, 2*blockSize
        for b <= n {
            merge(array, a, a+blockSize, b)
            a = b
            b += 2 * blockSize
        }
        if m := a + blockSize; m < n {
            merge(array, a, m, n)
        }
        blockSize *= 2
    }
}

// 原地归并操作
func merge(array []int, begin, mid, end int) {
    // 三个下标,将数组 array[begin,mid] 和 array[mid,end-1]进行原地归并
    i, j, k := begin, mid, end-1 // 因为数组下标从0开始,所以 k = end-1

    for j-i > 0 && k-j >= 0 {
        step := 0
        // 从 i 向右移动,找到第一个 array[i]>array[j]的索引
        for j-i > 0 && array[i] <= array[j] {
            i++
        }

        // 从 j 向右移动,找到第一个 array[j]>array[i]的索引
        for k-j >= 0 && array[j] <= array[i] {
            j++
            step++
        }

        // 进行手摇翻转,将 array[i,mid] 和 [mid,j-1] 进行位置互换
        // mid 是从 j 开始向右出发的,所以 mid = j-step
        rotation(array, i, j-step, j-1)
        i = i + step
    }

}

// 手摇算法,将 array[l,l+1,l+2,...,mid-2,mid-1,mid,mid+1,mid+2,...,r-2,r-1,r] 从mid开始两边交换位置
// 1.先逆序前部分:array[mid-1,mid-2,...,l+2,l+1,l]
// 2.后逆序后部分:array[r,r-1,r-2,...,mid+2,mid+1,mid]
// 3.上两步完成后:array[mid-1,mid-2,...,l+2,l+1,l,r,r-1,r-2,...,mid+2,mid+1,mid]
// 4.整体逆序: array[mid,mid+1,mid+2,...,r-2,r-1,r,l,l+1,l+2,...,mid-2,mid-1]
func rotation(array []int, l, mid, r int) {
    reverse(array, l, mid-1)
    reverse(array, mid, r)
    reverse(array, l, r)
}

func reverse(array []int, l, r int) {
    for l < r {
        // 左右互相交换
        array[l], array[r] = array[r], array[l]
        l++
        r--
    }
}

func main() {
    list := []int{5}
    MergeSort3(list, len(list))
    fmt.Println(list)

    list1 := []int{5, 9}
    MergeSort3(list1, len(list1))
    fmt.Println(list1)

    list2 := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3}
    MergeSort3(list2, len(list2))
    fmt.Println(list2)

    list3 := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3, 45, 67, 2, 5, 24, 56, 34, 24, 56, 2, 2, 21, 4, 1, 4, 7, 9}
    MergeSort3(list3, len(list3))
    fmt.Println(list3)
}

输出:

[5]
[5 9]
[1 3 4 5 6 6 6 8 9 14 25 49]
[1 1 2 2 2 3 4 4 4 5 5 6 6 6 7 8 9 9 14 21 24 24 25 34 45 49 56 56 67]

我们自底开始,将元素按照数量为 blockSize 进行小数组排序,使用直接插入排序,然后我们对这些有序的数组向上进行归并操作。

归并过程中,使用原地归并,用了手摇算法,代码如下:

func rotation(array []int, l, mid, r int) {
    reverse(array, l, mid-1)
    reverse(array, mid, r)
    reverse(array, l, r)
}

因为手摇只多了逆序翻转的操作,时间复杂度是 O(n),虽然时间复杂度稍稍多了一点,但存储空间复杂度降为了 O(1)。

归并排序是唯一一个有稳定性保证的高级排序算法,某些时候,为了寻求大规模数据下排序前后,相同元素位置不变,可以使用归并排序。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2103793.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Python】Python 读取Excel、DataFrame对比并选出差异数据,重新写入Excel

背景&#xff1a;我在2个系统下载出了两个Excel&#xff0c;现在通过对下载的2个Excel数据&#xff0c;并选出差异数据 从新写入一个新的Excel中 differences_url rC:\Users\LENOVO\Downloads\differences.xlsx; //要生成的差异Excel的位置及名称 df1_url rC:\Users\LENOVO\Dow…

终于知道如何简化时间序列的特征工程了!

在处理时间序列数据时&#xff0c;时间特征往往是最基础且独特的要素&#xff0c;我们的目标通常是预测某种未来的响应或结果。 不过在很多情况下&#xff0c;除了时间特征之外&#xff0c;我们还能获取到一系列其他相关的特征或变量。 时间序列数据中的特征工程涉及从原始时…

进程、线程、时间片

1、操作系统中的程序&#xff08;如微信&#xff09;在运行时&#xff0c;系统会产生一个或多个进程&#xff0c;往往是一个 2、进程内可以包含多个线程&#xff0c;有一个主线程&#xff0c;主线程结束时&#xff0c;进程结束&#xff0c;进而程序结束 3、线程是cpu调度执行…

sql日期函数

目录 sql日期函数 1.获取日期时间函数 1.1 获取当前日期时间 1.2 获取当前日期 1.3 获取当前时间 2.datetime数据类型格式化 3.字符串数据类型转换成datetime数据类型 4.增加和减少时间间隔 5. 日期相差天数&#xff08;天&#xff09; 6. 相差时间&#xff08;小时&am…

GitHub Star 数量前 11 的开源内部工具

欢迎回到我们的 GitHub Star 系列文章&#xff01; 在之前的文章中&#xff0c;我们深入探讨了 GitHub 上最受欢迎的开源低代码项目《GitHub Star 数量前 15 的开源低代码项目》和开源无代码工具《GitHub Star 数量前 12 的开源无代码工具》&#xff0c;获得了热烈的反馈。本周…

【嵌入式学习笔记】---- OLED屏幕工作原理

1 驱动芯片SSD1603简介 1.1 SSD1603芯片图 SSD1603是一款点阵显示屏控制器&#xff0c;可嵌入在屏幕中&#xff0c;用于执行接收数据、显示存储、扫描刷新等任务驱动接口&#xff1a;128个SEG引脚和64个COM引脚&#xff0c;对应 128 64 128\times 64 12864像素点阵显示屏内置…

增强RAG:选择最佳的嵌入和重排模型

对于如何选择最佳的嵌入模型和重排模型&#xff0c;给出了详细的步骤和代码。 在构建检索增强生成&#xff08;RAG&#xff09;管道时&#xff0c;关键组件之一是检索器。我们有多种嵌入模型可供选择&#xff0c;包括 OpenAI、CohereAI 和开源的sentence transformers。此外&a…

排序(插入,希尔,选择,堆,冒泡,快速,归并,计数)

本文中的Swap()函数都是下面这段代码 // 交换 void Swap(int* p1, int* p2) {int tmp *p1;*p1 *p2;*p2 tmp; }文章目录 常见排序&#xff1a;一.插入排序1.直接插入排序&#xff1a;2.希尔排序&#xff1a; 二.选择排序1.选择排序&#xff1a;2.堆排序&#xff1a; 三.交换排…

C语言编译的过程

文章目录 1. 预处理&#xff08;Preprocessing&#xff09;2. 编译&#xff08;Compilation&#xff09;3. 汇编&#xff08;Assembly&#xff09;4. 链接&#xff08;Linking&#xff09;总结 c语言通过编译器直接编译成机器语言程序。 C语言程序的编译过程通常分为四个主要步…

STM32G474之TIM1输出PWM信号支持互补输出,死区时间和刹车

STM32G474之TIM1输出PWM信号&#xff0c;互补输出&#xff0c;支持死区时间和刹车。PWM第1通道输出引脚配置&#xff1a;TIM1_CH1映射到PA8,TIM1_CH1N映射到PA7&#xff0c;TIM1_BKIN映射到PA6&#xff0c;用作刹车输入信号。当刹车时&#xff0c;停止PWM波形输出。在使用“比较…

海上8km远距离无线通信模组,无人船MESH组网,飞睿WiFi助力海洋船只通信无障碍

在蔚蓝无垠的海洋世界里&#xff0c;每一次科技的进步都如同海上的灯塔&#xff0c;为我们照亮了前行的道路。今天&#xff0c;我要为大家介绍的&#xff0c;就是一款能够打破传统通信界限的模块——飞睿智能8km远距离无线通信模组。它不仅在陆地通信中展现出强大的实力&#x…

4. 第一个3D案例—创建3D场景

入门Three.js的第一步&#xff0c;就是认识场景Scene、相机Camera、渲染器Renderer三个基本概念&#xff0c;接下来&#xff0c;咱们通过三小节课&#xff0c;大家演示“第一个3D案例”完成实现过程。 学习建议&#xff1a;只要你能把第一个3D案例搞明白&#xff0c;后面学习就…

幼儿园数字化探索:从入园适应到全面启智

金秋九月&#xff0c;全国各地幼儿园迎来开学季。幼儿园门口&#xff0c;一幅幅温情与成长的画面交织在一起。针对小班幼儿普遍存在的分离焦虑问题&#xff0c;幼儿园教师团队展现出了高度的专业性和人文关怀。据上海市普陀区汇丽幼儿园叶老师介绍&#xff0c;为有效缓解这一挑…

【技术分享】顶尖 GIS 技术

谈到 GIS&#xff0c;就不能不提到现代地理智能。是指基于 GIS、遥感和卫星定位技术的地理空间可视化、分析、决策、设计和控制的技术总称。地理智能是 GIS 区别于其他信息技术最重要的价值之一。它由地理可视化、地理决策、地理设计、地理控制四个层次组成。它们形成了一个地理…

wordpress发送邮件的方法?怎么配置功能?

wordpress发送邮件设置教程&#xff1f;WordPress如何配置发信&#xff1f; WordPress作为最受欢迎的内容管理系统之一&#xff0c;被广泛用于创建和管理网站。AokSend将详细介绍WordPress发送邮件的方法&#xff0c;并指导您如何配置这一功能&#xff0c;确保您的邮件发送既高…

跑步用耳机哪款好?这五款运动骨传导耳机健身人士都说好!

在无线耳机市场持续繁荣的今天&#xff0c;入耳式耳机以其卓越的音质体验赢得了众多用户的青睐。然而&#xff0c;随着健康意识的提升&#xff0c;长时间佩戴入耳式耳机所带来的健康隐患日益受到关注。正是在这样的背景下&#xff0c;骨传导耳机凭借其独特的非入耳式设计&#…

iMeta: 南医大余光创组ggtree最新文章-系统发育树存储与可视化的数据结构

Ggtree&#xff1a;用于系统发育树及相关数据存储与可视化的数据结构 https://onlinelibrary.wiley.com/doi/10.1002/imt2.56 SHORT COMMUNICATION ● 2022年9月28日&#xff0c;南方医科大学基础医学院余光创团队在iMeta在线发表了题为“Ggtree: a serialized data object f…

UOS系统下Java执行权限问题

在程序部署中&#xff0c;出现 /bin/java 权限不足 问题&#xff0c;这个问题是由于java文件无运行权限导致&#xff0c;通过 sudo chmod ax bin/java 项目中需要展示系统当前所有运行程序窗口界面截图功能&#xff0c;这个功能在实现时通过 import 截图可获取界面图片&…

【Story】国际标准组织及其相关信息(全)

目录 1. ISO&#xff08;International Organization for Standardization&#xff09;2. IEC&#xff08;International Electrotechnical Commission&#xff09;3. ITU&#xff08;International Telecommunication Union&#xff09;4. ISO/IEC 合作标准5. IEEE&#xff08…

使用ChatGPT7小时完成高分论文写作,AI写作避坑全攻略指南

大家好,感谢关注。我是七哥,一个在高校里不务正业,折腾学术科研AI实操的学术人。关于使用ChatGPT等AI学术科研的相关问题可以和作者七哥(yida985)交流,多多交流,相互成就,共同进步,为大家带来最酷最有效的智能AI学术科研写作攻略。经过数月爆肝,终于完成学术AI使用教…