一文带你彻底搞透大厂手撕算法之: 手撕排序查找算法 附带:本人手写千行c语言排序大厂面试实录

一、冒泡排序:被嫌弃的 "简单" 算法的逆袭
1.1 原理图解与初版代码

刚开始学排序时,冒泡排序是老师讲的第一个算法,当时觉得这玩意儿也太简单了吧:

// 初版冒泡排序(错误示范)
void bubble_sort(int arr[], int n) {
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            if (arr[j] > arr[j+1]) {
                int temp = arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = temp;
            }
        }
    }
}

结果跑起来直接段错误!后来才发现 j 循环应该到 n-1-i,当时气得想砸键盘:搞这个鬼东西调了半个多小时,没调出来才发现 j 循环写成 n 了,我真是个大聪明!

1.2 修正版代码与优化

// 修正版冒泡排序
void bubble_sort(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {  // 外层循环n-1次
        for (int j = 0; j < n - 1 - i; j++) {  // 内层循环逐渐减少
            if (arr[j] > arr[j+1]) {
                swap(&arr[j], &arr[j+1]);
            }
        }
    }
}

// 优化版(带有序标记)
void optimized_bubble(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {
        int swapped = 0;
        for (int j = 0; j < n - 1 - i; j++) {
            if (arr[j] > arr[j+1]) {
                swap(&arr[j], &arr[j+1]);
                swapped = 1;
            }
        }
        if (swapped == 0) break;  // 提前退出
    }
}

调试血泪史
第一次写优化版时,把swapped初始化放在了内层循环里,导致每次都认为数组是有序的。"我搞了好久才发现结果发现是 swapped 位置放错了,气死我了!"

1.3 复杂度分析与适用场景
  • 时间复杂度:O (n²)
  • 空间复杂度:O (1)
  • 稳定性:稳定(相同元素相对顺序不变)

适用场景:小规模数据,或已基本有序的数据。我在处理 1000 个元素时发现,优化版比初版快了近 10 倍,但还是比后面的算法慢太多。

二、选择排序:简单粗暴
2.1 原理与代码实现

选择排序的思路很直接:每次选最小的元素放到前面:

void selection_sort(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {
        int min_idx = i;
        for (int j = i + 1; j < n; j++) {
            if (arr[j] < arr[min_idx]) {
                min_idx = j;
            }
        }
        if (min_idx != i) {
            swap(&arr[i], &arr[min_idx]);
        }
    }
}

调试
第一次写的时候忘记min_idx != i的判断,结果自己和自己交换,虽然不影响结果但浪费性能。"这么简单的算法还能写错,我也是服了,x了狗了!"

运行结果
第0次选择: [1, 3, 2, 4, 0]
第1次选择: [1, 0, 2, 4, 3]
...
看着每次选最小的元素,突然理解了这个算法的直观性。

2.3 复杂度与缺陷
  • 时间复杂度:O (n²)
  • 空间复杂度:O (1)
  • 稳定性:不稳定(如 [3,3,1] 排序后可能交换两个 3 的位置)

注意:选择排序在最好、最坏情况下时间复杂度都是 O (n²),这一点和冒泡不同。我测试时发现,即使数组已经有序,它还是会执行全部比较

三、插入排序
3.1 生活中的排序思维

插入排序的灵感来自打扑克时整理手牌:

void insertion_sort(int arr[], int n) {
    for (int i = 1; i < n; 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;
    }
}

调试崩溃现场
第一次写时把arr[j+1] = key放在了循环里面,导致无限循环。"调试了 20 分钟才发现,这么简单的逻辑都能写错,卧驲!"

3.2 链表版本实现

后来尝试用链表实现,发现插入排序在链表上更有优势:

void list_insertion_sort(Node *head) {
    Node *sorted = NULL;
    Node *current = head;
    
    while (current != NULL) {
        Node *next = current->next;
        sorted_insert(&sorted, current);
        current = next;
    }
    
    head = sorted;
}

void sorted_insert(Node **sorted, Node *new_node) {
    if (*sorted == NULL || (*sorted)->data >= new_node->data) {
        new_node->next = *sorted;
        *sorted = new_node;
    } else {
        Node *current = *sorted;
        while (current->next != NULL && current->next->data < new_node->data) {
            current = current->next;
        }
        new_node->next = current->next;
        current->next = new_node;
    }
}

心得:链表插入排序不需要移动元素,只需要修改指针,这比数组版效率高很多

3.3 复杂度与优化
  • 时间复杂度:O (n²)
  • 空间复杂度:O (1)
  • 稳定性:稳定

优化技巧
对于基本有序的数组,插入排序效率很高。我用rand()生成了基本有序的数组测试,插入排序比冒泡快了 5 倍

四、希尔排序:插入排序的 "增量" 逆袭
4.1 原理与增量序列

希尔排序是插入排序的改进版,通过增量分组:

void shell_sort(int arr[], int n) {
    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;
        }
    }
}

增量序列选择
最初用了gap = n/2的简单序列,后来查资料发现还有 Hibbard 序列(2^k - 1),测试后性能提升 30%。"原来增量序列这么重要,之前随便写的,难怪效率低,看样子还是粗心了!"

4.2 调试时的数组越界

第一次写时j >= gap写成了j > gap,导致数组越界:

运行后直接段错误,GDB 调试发现 j=0 时j - gap为负数。

4.3 复杂度分析
  • 时间复杂度:O (n^1.3)(取决于增量序列)
  • 空间复杂度:O (1)
  • 稳定性:不稳定

性能测试
对 10000 个元素排序,希尔排序比插入排序快了tmd 20 倍

五、快速排序:分治思想的经典代表
5.1 递归实现与分区函数

快排的核心是分区:

int partition(int arr[], int low, int high) {
    int pivot = arr[high];
    int i = low - 1;
    
    for (int j = low; j < high; j++) {
        if (arr[j] <= pivot) {
            i++;
            swap(&arr[i], &arr[j]);
        }
    }
    swap(&arr[i + 1], &arr[high]);
    return i + 1;
}

void quick_sort(int arr[], int low, int high) {
    if (low < high) {
        int pi = partition(arr, low, high);
        quick_sort(arr, low, pi - 1);
        quick_sort(arr, pi + 1, high);
    }
}

调试噩梦
第一次写递归时没处理low >= high的情况,直接tm栈溢出

5.2 非递归实现与栈模拟

为了避免栈溢出,写了非递归版本

void iterative_quick_sort(int arr[], int n) {
    int stack[n];
    int top = -1;
    
    stack[++top] = 0;
    stack[++top] = n - 1;
    
    while (top >= 0) {
        int high = stack[top--];
        int low = stack[top--];
        
        int pi = partition(arr, low, high);
        
        if (pi - 1 > low) {
            stack[++top] = low;
            stack[++top] = pi - 1;
        }
        
        if (pi + 1 < high) {
            stack[++top] = pi + 1;
            stack[++top] = high;
        }
    }
}

注意事项
非递归版本用数组模拟栈,注意栈大小,n 很大时可能需要动态分配栈  难蚌 干

5.3 复杂度与优化
  • 时间复杂度:平均 O (n log n),最坏 O (n²)
  • 空间复杂度:O (log n)(递归栈)
  • 稳定性:不稳定

优化点

  1. 三数取中选择基准值
  2. 小规模数据切换为插入排序
  3. 随机化基准值避免最坏情况

测试对比
直接起飞

六、归并排序:分治思想的另一种体现

6.1 递归实现与合并函数

归并排序的核心是合并两个有序数组:

void merge(int arr[], int l, int m, int r) {
    int n1 = m - l + 1;
    int n2 = r - m;
    
    int L[n1], R[n2];
    
    for (int i = 0; i < n1; i++) L[i] = arr[l + i];
    for (int j = 0; j < n2; j++) R[j] = arr[m + 1 + j];
    
    int i = 0, j = 0, k = l;
    while (i < n1 && j < n2) {
        if (L[i] <= R[j]) {
            arr[k++] = L[i++];
        } else {
            arr[k++] = R[j++];
        }
    }
    
    while (i < n1) arr[k++] = L[i++];
    while (j < n2) arr[k++] = R[j++];
}

void merge_sort(int arr[], int l, int r) {
    if (l < r) {
        int m = l + (r - l) / 2;
        merge_sort(arr, l, m);
        merge_sort(arr, m + 1, r);
        merge(arr, l, m, r);
    }
}

调试坑点
合并函数中L[n1], R[n2]在 C99 中是 VLA,老编译器可能不支持,改成动态分配

6.2 非递归实现与优化

void iterative_merge_sort(int arr[], int n) {
    for (int curr_size = 1; curr_size <= n - 1; curr_size = 2 * curr_size) {
        for (int left = 0; left < n - 1; left += 2 * curr_size) {
            int mid = left + curr_size - 1;
            int right = fmin(left + 2 * curr_size - 1, n - 1);
            merge(arr, left, mid, right);
        }
    }
}

性能对比
递归版在小规模数据上更快,非递归版在大规模数据上更稳定。"两种实现各有优劣,得根据场景选择,我之前一直用递归版,结果在嵌入式设备上栈溢出了,气死我了!"

6.3 复杂度与应用
  • 时间复杂度:O (n log n)(稳定)
  • 空间复杂度:O (n)
  • 稳定性:稳定

应用场景
归并排序在外部排序和多线程排序中很有用,因为它的稳定性和良好的最坏情况性能。

七、堆排序:数据结构与算法的完美结合
7.1 堆的构建与排序

堆排序需要先构建堆结构:

void heapify(int arr[], int n, int i) {
    int largest = i;
    int l = 2 * i + 1;
    int r = 2 * i + 2;
    
    if (l < n && arr[l] > arr[largest])
        largest = l;
    
    if (r < n && arr[r] > arr[largest])
        largest = r;
    
    if (largest != i) {
        swap(&arr[i], &arr[largest]);
        heapify(arr, n, largest);
    }
}

void heap_sort(int arr[], int n) {
    for (int i = n / 2 - 1; i >= 0; i--)
        heapify(arr, n, i);
    
    for (int i = n - 1; i > 0; i--) {
        swap(&arr[0], &arr[i]);
        heapify(arr, i, 0);
    }
}

理解难点
堆的父子节点索引关系搞了很久

7.2 堆的可视化与调试

用 Python 画了堆的结构:

python

运行

# 堆可视化
def print_heap(arr):
    n = len(arr)
    level = 0
    i = 0
    while i < n:
        print(f"Level {level}: ", end="")
        level_len = 2 ** level
        for j in range(level_len):
            if i < n:
                print(arr[i], end=" ")
                i += 1
            else:
                break
        print()
        level += 1

运行结果

Level 0: 9 
Level 1: 7 6 
Level 2: 5 4 3 2 

看着堆的层级结构,理解了堆化的过程。

7.3 复杂度与特性
  • 时间复杂度:O (n log n)
  • 空间复杂度:O (1)
  • 稳定性:不稳定

注意事项
堆排序的最坏情况性能也很好,这一点比快排有优势,但常数因子较大

八、计数排序:线性时间的非比较排序
8.1 原理与代码实现

计数排序适用于已知范围的整数:

void counting_sort(int arr[], int n) {
    int max = arr[0];
    for (int i = 1; i < n; i++) {
        if (arr[i] > max)
            max = arr[i];
    }
    
    int *count = (int*)calloc(max + 1, sizeof(int));
    int *output = (int*)malloc(n * sizeof(int));
    
    for (int i = 0; i < n; i++)
        count[arr[i]]++;
    
    for (int i = 1; i <= max; i++)
        count[i] += count[i - 1];
    
    for (int i = n - 1; i >= 0; i--)
        output[--count[arr[i]]] = arr[i];
    
    for (int i = 0; i < n; i++)
        arr[i] = output[i];
    
    free(count);
    free(output);
}

内存管理坑
第一次写时忘记释放countoutput,导致内存泄漏。

8.2 稳定版实现

计数排序天然是稳定的,但需要从后向前遍历:

// 从后向前保证稳定性
for (int i = n - 1; i >= 0; i--)
    output[--count[arr[i]]] = arr[i];

8.3 复杂度与适用场景
  • 时间复杂度:O (n + k)(k 是数据范围)
  • 空间复杂度:O (n + k)
  • 稳定性:稳定

九、main 函数

9.1 测试数据生成与调用

#define SIZE 10000

int main() {
    int arr[SIZE];
    srand(time(NULL));
    
    // 生成随机数组
    for (int i = 0; i < SIZE; i++) {
        arr[i] = rand() % 1000;
    }
    
    // 复制数组用于各算法测试
    int arr1[SIZE], arr2[SIZE], arr3[SIZE];
    int arr4[SIZE], arr5[SIZE], arr6[SIZE];
    int arr7[SIZE], arr8[SIZE];
    memcpy(arr1, arr, SIZE * sizeof(int));
    memcpy(arr2, arr, SIZE * sizeof(int));
    memcpy(arr3, arr, SIZE * sizeof(int));
    memcpy(arr4, arr, SIZE * sizeof(int));
    memcpy(arr5, arr, SIZE * sizeof(int));
    memcpy(arr6, arr, SIZE * sizeof(int));
    memcpy(arr7, arr, SIZE * sizeof(int));
    memcpy(arr8, arr, SIZE * sizeof(int));
    
    // 测试各算法时间
    clock_t start, end;
    
    start = clock();
    bubble_sort(arr1, SIZE);
    end = clock();
    printf("冒泡排序时间: %f秒\n", (double)(end - start) / CLOCKS_PER_SEC);
    
    start = clock();
    selection_sort(arr2, SIZE);
    end = clock();
    printf("选择排序时间: %f秒\n", (double)(end - start) / CLOCKS_PER_SEC);
    
    // 省略其他算法调用...
    
    return 0;
}

测试结果

  • 冒泡排序:3.21 秒
  • 选择排序:2.89 秒
  • 插入排序:2.76 秒
  • 希尔排序:0.45 秒
  • 快速排序:0.12 秒
  • 归并排序:0.15 秒
  • 堆排序:0.18 秒
  • 计数排序:0.08 秒


 

十、排序算法总结与面试准备
10.1 算法复杂度对比表
算法平均时间最坏时间空间稳定性
冒泡排序O(n²)O(n²)O(1)稳定
选择排序O(n²)O(n²)O(1)不稳定
插入排序O(n²)O(n²)O(1)稳定
希尔排序O(n^1.3)O(n^2)O(1)不稳定
快速排序O(n log n)O(n²)O(log n)不稳定
归并排序O(n log n)O(n log n)O(n)稳定
堆排序O(n log n)O(n log n)O(1)不稳定
计数排序O(n + k)O(n + k)O(n + k)稳定

10.2 面试问题
  1. 快排和归并的区别
    快排是分治 + 分区,原地排序;归并是分治 + 合并,需要额外空间。快排平均更快,归并更稳定。

  2. 堆排序的时间复杂度
    O (n log n),构建堆 O (n),排序 O (n log n)。

  3. 稳定排序有哪些
    冒泡、插入、归并、计数排序。

手写实现:每个算法至少手写 3 遍,直到能闭着眼睛写出来。

final:
看似复杂的东西,拆分成小问题就容易了....其实也就那样

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值