智能程序设计(C语言)


排序

计算机学院    杨已彪

yangyibiao@nju.edu.cn

提纲

  • 什么是排序、为什么需要排序

  • 三种排序: 选择、冒泡、插入 + 对比小结 (原理与图示 → 代码)

  • 顺序查找: 逐个比; 二分查找: 排好序, 每次砍掉一半

  • 案例: 众数统计 (排序 + 扫描)

  • 课堂小测


什么是排序 · 为什么需要排序

排序: 把一组数据按某种规则整理成有序

  • 数值: 从小到大(升序)或从大到小(降序)

  • 字符串: 按字典序

  • 例: [8,3,5] 升序 → [3,5,8]; 降序 → [8,5,3]

字典序 = 像查字典那样逐字符比较: 先比第一个字符, 相同再比下一个 —— 如 "apple" 排在 "banana" 前, "zhang" 排在 "zhao" 前

成绩排名、字典、电话簿……有序的数据才好用

  • 排名: 全班机试成绩乱序, 谁第 1、前 10 是谁? —— 排序后才有答案

  • 查找: 排好序才能用二分查找, 100 万个数据从约 50 万次比较降到约 20 次

  • 地基: 去重、统计、合并都建立在有序之上

之前把数据装进数组, 今天给它们排队; 开学时说的排序, 今天实现


选择排序

每一轮从剩余元素中选出最小的, 放到前面

  • 第 1 轮: 在 a[0..n-1] 里找最小, 换到 a[0]

  • 第 2 轮: 在 a[1..n-1] 里找最小, 换到 a[1]

  • 依此类推, 共 n-1 轮; 第 i 轮结束时 a[0..i] 已经有序

每一轮: 在未排序部分挑出最小的, 与未排序部分的第一个交换


选择排序 · 代码

/* 选择排序 (约定: a[0..n-1] 已读入 n 个元素) */
for (int i = 0; i < n - 1; i++) {
  int min = i;                       /* 先假设 a[i] 最小 */
  for (int j = i + 1; j < n; j++) {  /* 往后找更小的 */
    if (a[j] < a[min]) {
      min = j;
    }
  }
  int t = a[i]; a[i] = a[min]; a[min] = t;  /* 交换 */
}
  • 一共 n-1 轮, 第 i 轮结束后 a[0..i] 已经有序

后面学了函数, 就能把这整段封装成 sort(a, n) 随时调用


冒泡排序

相邻元素两两比较, 顺序不对就交换

  • 第 1 轮: 从头比到尾, 最大的元素一路"沉"到 a[n-1]

  • 第 2 轮: 再比一遍, 次大的沉到 a[n-2] (末尾已就位, 少比一个)

  • 共 n-1 轮; 每一轮结束, 本轮最大的元素"沉底"

相邻比较、逐个交换, 最大的逐步"沉"到末尾


冒泡排序 · 代码

/* 冒泡排序: 把 a[0..n-1] 从小到大排好 */
for (int i = 0; i < n - 1; i++) {         /* 共 n-1 轮 */
  for (int j = 0; j < n - 1 - i; j++) {   /* 每轮少比一个 */
    if (a[j] > a[j + 1]) {
      int t = a[j];
      a[j] = a[j + 1];
      a[j + 1] = t;
    }
  }
}
  • 每一轮结束, 本轮未排序部分的最大元素"沉"到最后

  • 三种排序里过程最形象的一种——相邻比较、逐轮沉底


插入排序

像打牌理手牌: 每次拿起一张, 插到已排序部分的正确位置

  • 左手: 已排序的牌, 起初只有 a[0]

  • 右手: 每次拿起下一张 key = a[i]

  • 从右往左比: 比 key 大的往后挪一格, 找到位置把 key 放进去

已排序部分逐步变长, 每个 key 与前面的元素比较、右移, 再插到正确位置


插入排序 · 代码

/* 插入排序: 把 a[0..n-1] 从小到大排好 */
for (int i = 1; i < n; i++) {
  int key = a[i];                          /* 拿起的"牌" */
  int j;                                   /* 循环后还要用 j */
  for (j = i - 1; j >= 0 && a[j] > key; j--) {  /* 比 key 大的往后挪 */
    a[j + 1] = a[j];
  }
  a[j + 1] = key;                          /* 放进去 */
}
  • 三种排序都是 O(n²)——数据量不大时完全够用

内层 for 退出时: j 停在第一个 ≤ key 的元素 (或 -1), 位置 a[j+1] 已空出, key 正好放进去。

例: [2, 5] 插 key=4 —— j 从 1 开始: a[1]=5 > 4 → 右移, j 变 0; a[0]=2 ≤ 4 → 停, 退出时 j = 0, 放入 a[1] → [2, 4, 5]


三种排序 · 对比小结

算法 平均/最坏时间 最好情况 空间 稳定性 特点
选择排序 O(n²) / O(n²) O(n²) O(1) 不稳定 每轮只交换一次, 交换次数最少
冒泡排序 O(n²) / O(n²) O(n) (已有序) O(1) 稳定 相邻比较, 大数像气泡上浮
插入排序 O(n²) / O(n²) O(n) (接近有序) O(1) 稳定 像整理手牌, 基本有序时最快
  • 稳定性: 相等元素排序后保持原有先后——按"班级+成绩"排序时会用到

  • 怎么选: 数据量小或接近有序 → 插入排序; 只关心交换代价 → 选择排序; 教学演示 → 冒泡排序

  • 三者都是 O(n²); 想更快, 要靠后面分治思想引出的快速排序 (递归部分细讲)


顺序查找

排序之后进入查找: 在数据里找到目标。无序只能逐个比 (平均 n/2 次); 有序才能每次砍掉一半 (约 log2(n) 次, 下页) —— 先看顺序查找

从第一个元素开始逐个比较, 找到即停

/* 顺序查找: 在 a[0..n-1] 中找 key (约定: 数组已读入 n 个元素) */
int i;
for (i = 0; i < n; i++) {
  if (a[i] == key) {
    break;                    /* 找到, 提前退出 */
  }
}
if (i < n) {
  printf("找到, 下标 %d\n", i);
} else {
  printf("没找到\n");
}
100 万个数据中查找一个数 (平均比较次数) 顺序查找 ≈ 500,000 次 二分查找 ≈ 20 次 每次砍掉一半:1,000,000 → 500,000 → … → 1 有序,才敢这么砍——这就是排序的价值
  • i < n 说明是提前退出(找到); i == n 说明是正常结束(没找到)——之前素数判断用过同样的写法

  • 适合无序数据; 平均比较 n/2 次, 最坏 n 次


二分查找

数据排好序后, 可以每次砍掉一半: 和中间元素比较, 决定去左半边还是右半边

每次和中间元素比较, 砍掉不可能的一半


二分查找 · 代码

/* 二分查找: a 必须已从小到大排好序 */
int low = 0, high = n - 1;
while (low <= high) {
  int mid = (low + high) / 2;
  if (a[mid] == key) {
    printf("找到, 下标 %d\n", mid);
    break;
  }
  if (a[mid] < key) {
    low = mid + 1;
  } else {
    high = mid - 1;
  }
}
if (low > high) {
  printf("没找到\n");
}
  • n 个元素最多比较 log2(n)+1 次——100 万个数也只要约 20 次!


众数统计 · 问题与现场编码

输入 n 个无序的数 (1 ≤ n ≤ 100), 输出出现次数最多的数(众数)及其出现次数; 若多个数并列, 输出最小的那个

样例输入: 7 与 1 2 2 3 2 5 7

样例输出: 2 3

提示: 先排序, 再观察有序数组里 "相同的数" 有什么特点

现场演示

⚡ 打开 skeleton.c (骨架)
  1. 思路: 先排序 (任选一种, 正好复习) → 相同的数相邻 → 一遍扫描统计每段长度 → 找最长段
  2. 学生先写 10 分钟, 再一起看参考代码
  3. 引导提问: 最后一段什么时候 "结算"? 并列时怎么保证输出最小的?

众数统计 · 代码

/* 众数统计 (约定: a[0..n-1] 已读入 n 个数, 且已从小到大排好序) */
int best = a[0], best_cnt = 1;    /* 众数与出现次数 */
int cur = a[0], cur_cnt = 1;      /* 当前这一段 */
for (int i = 1; i <= n; i++) {
  if (i < n && a[i] == cur) {
    cur_cnt++;
  } else {                        /* 这一段结束了 */
    if (cur_cnt > best_cnt) {     /* 只更新更大的, 并列保留最小的 */
      best = cur;
      best_cnt = cur_cnt;
    }
    if (i < n) {
      cur = a[i];
      cur_cnt = 1;
    }
  }
}
printf("%d %d\n", best, best_cnt);
  • 循环条件 i <= n: i == n 时也要结算最后一段; 用 > 而非 >=: 升序扫描, 并列时自然保留最小


课堂小测

  1. 选择排序每一轮做什么?总共需要几轮?
  2. 冒泡排序一轮结束后,什么元素"就位"了?
  3. 代码阅读: 冒泡排序内层循环写成 for (j = 0; j < n - 1; j++) (漏了 - i),会发生什么?
  4. 纠错: 交换写成了 int t = a[i]; a[i] = a[min]; a[min] = a[i];——交换为什么没生效?
  5. 二分查找的前提条件是什么?
  6. 纠错: 二分查找里写 int mid = low + high / 2; 有什么问题?



未完待续