四时宝库

程序员的知识宝库

用shell如何随机打乱文件行的顺序?

如何随机打乱文件中的行的顺序?或者从文件中选择一行随机内容,或者从目录中选择一个随机文件?

以下是一种随机打乱文件行的方法。该方法涉及生成一个随机数,将其添加到每一行的前面;然后对结果行进行排序,并移除这些数字。

# Bash/Ksh
randomize() {
    while IFS='' read -r l ; do printf '%d\t%s\n' "$RANDOM" "$l"; done |
    sort -n |
    cut -f2-
}

RANDOM 变量被 BASH 和 KornShell 支持,但不符合 POSIX 标准。

使用其他程序实现相同的想法(在行前打印随机数,并根据该列对行进行排序):

# Bourne
awk '
    BEGIN { srand() }
    { print rand() "\t" $0 }
' |
sort -n |    # 根据第一列(随机数)进行数值排序
cut -f2-     # 移除排序列

这种方法(可能)比之前的解决方案更快,但对于非常旧的 AWK 实现可能无法工作(可以尝试使用 nawk、gawk 或 /usr/xpg4/bin/awk,如果可用的话)。请注意,AWK 使用纪元时间作为 srand() 的种子,这可能对您来说不够随机。

其他非便携的工具可以对文件进行洗牌/随机化:

  • GNU shuf?(在足够新的 GNU coreutils 中)
  • GNU sort -R?(coreutils 6.9)

对数组进行洗牌

一个更广义的问题可能是,“如何对数组的元素进行洗牌?”如果我们不想使用排序行的笨拙方法,那么这个问题实际上比它看起来更复杂。一个朴素的方法会给出严重偏倚的结果。一个更复杂(也更正确)的算法如下:

# 使用一个全局数组变量。必须是紧凑的(不是稀疏数组)。
# Bash 语法。
shuffle() {
   local i tmp size max rand

   size=${#array[@]}
   for ((i=size-1; i>0; i--)); do
      # RANDOM % (i+1) 是有偏差的,因为 $RANDOM 的范围有限
      # 通过使用一个 rand 模数的倍数的范围来补偿。

      max=$(( 32768 / (i+1) * (i+1) ))
      while (( (rand=RANDOM) >= max )); do :; done
      rand=$(( rand % (i+1) ))
      tmp=${array[i]} array[i]=${array[rand]} array[rand]=$tmp
   done
}

这个函数使用Knuth-Fisher-Yates洗牌算法原地洗牌一个数组的元素。

如果我们只想要一个无偏的随机数选择函数,我们可以将其拆分为单独的部分:

# 在全局变量 'r' 中返回从 0 到 ($1-1) 的随机数。
# Bash 语法。
rand() {
    local max=$((32768 / $1 * $1))
    while (( (r=RANDOM) >= max )); do :; done
    r=$(( r % $1 ))
}

这个rand函数比使用 $((RANDOM % n)) 更好。为了简单起见,本页面上的许多其他示例可能使用模数方法。在所有这些情况下,切换到使用rand函数将会得到更好的结果;这个改进留给读者作为练习。

随机选择一行/文件

我们经常遇到的另一个问题是,“如何从文件中打印出一行随机行?”

这有两种主要的方法:

  • 计算行数 n,选择一个从 1 到 n 的随机整数 r,并打印第 r 行。
  • 逐行读取,随着读取的进行,以不同的概率选择行。

先计算行数

更简单的方法是先计算行数。

# Bash
n=$(wc -l <"$file")         # 统计行数
r=$((RANDOM % n + 1))       # 生成1到n之间的随机数(参见上面的警告!)
sed -n "$r{p;q;}" "$file"   # 打印第r行

# POSIX与(新的)AWK
awk -v n="$(wc -l <"$file")" \
  'BEGIN{srand();l=int((rand()*n)+1)} NR==l{print;exit}' "$file"

下一个示例将整个文件读入内存。这种方法节省了重新读取文件的时间,但显然会使用更多的内存。(可以说:在具有足够内存和高效磁盘缓存的系统上,您已经通过之前的方法将文件读入内存,除非内存不足,否则不应该这样做,QED。)

# Bash
unset lines n
while IFS= read -r 'lines[n++]'; do :; done < "$file"   # 参见FAQ 5
r=$((RANDOM % n))   # 参见上面的警告!
echo "${lines[r]}"

请注意,在此示例中,我们不将随机数加1,因为行数组的索引从0开始计数。

此外,有些人希望从目录中选择一个随机文件(用于电子邮件签名、选择随机歌曲播放、显示随机图像等等)。可以使用类似的技巧:

# Bash
files=(*.ogg)                  # 或者 *.gif 或者 *
n=${#files[@]}                 # 为了可读性
xmms -- "${files[RANDOM % n]}" # 选择一个随机元素

不先计算行数

如果你恰好有GNU shuf,你可以使用它,但它不是可移植的。

# 示例,从文件中选择5行随机行
shuf -n 5 file

如果没有shuf,我们需要自己编写一些代码。如果我们想要n行随机行,我们需要:

  1. 接受前n行
  2. 按照n/nl的概率接受每一行,其中nl是到目前为止读取的行数
  3. 如果我们在步骤2中接受了该行,则替换我们已经有的n行中的一个随机行
# 警告:没有参数的srand()以秒为单位使用当前时间进行种子。如果在同一秒钟内运行多次,您将获得相同的输出。找到更好的方法来设置种子。

n=$1
shift

awk -v n="$n" '
BEGIN            { srand()                           }
NR     <= n      { lines[NR - 1         ] = $0; next }
rand() <  n / NR { lines[int(rand() * n)] = $0       }
END              { for (k in lines) print lines[k]   }
' "$@"

即将提供Bash和POSIX sh解决方案。

使用外部随机数据源

有些人认为在他们的应用程序中,Shell内置的RANDOM参数不够随机。通常情况下,这是对C库的rand(3)函数的接口,尽管Bash手册没有具体说明实现细节。有些人认为他们的应用程序需要更强大的加密随机数据,这需要从外部源获取。

在我们探索这个问题之前,我们应该指出,通常人们在编写随机密码生成器时首选的是使用已经编写好的密码生成器,比如pwgen。

现在,如果我们考虑在Bash脚本中使用外部随机数据源,我们会面临一些问题:

  • 数据源可能不具备可移植性。因此,脚本只能在特定环境中使用。
  • 如果我们简单地从数据源中获取一个字节(或足够跨越所需范围的字节组)并对其进行取模运算,我们将遇到之前在本页面上描述的偏差问题。如果我们只是通过粗糙的代码引入偏差,使用昂贵的外部数据源就没有意义!为了解决这个问题,我们可能需要反复获取字节(或字节组),直到找到一个可以无偏差地使用的字节。
  • Bash不能很好地处理原始字节,所以每次获取一个字节(或字节组)之后,我们需要对其进行一些处理,将其转换为Bash可以读取的数字。这可能是一个昂贵的操作。因此,一次性获取多个字节(或字节组),并一次性进行转换成可读数字,可能更高效。
  • 根据数据源的不同,这些随机字节可能是宝贵的,因此一次性获取大量字节并丢弃我们不使用的字节可能比逐个字节获取更昂贵(根据我们用来衡量这种成本的度量标准)。这是你需要自行决定的事情,考虑到你的应用程序的需求和数据源的性质。

在这个阶段,你应该认真重新考虑在Bash中进行这样的决定。其他编程语言已经具备了解决所有这些问题的特性,你可能会更好地选择用其中一种语言编写你的应用程序。

你还在这里吗?好吧。假设我们将在Bash中使用/dev/urandom设备(在大多数Linux和BSD系统上可以找到)作为外部随机数据源。这是一个生成“相当随机”的原始字节数据的字符设备。首先,我们需要注意的是,这个脚本只能在存在这个设备的系统上工作。实际上,你应该在脚本的早期位置添加一个显式的检查来验证这个设备的存在,并在找不到设备时终止脚本的执行。脚本的功能是返回一个无偏的随机数,范围从0到($1 - 1),并将其存储在变量'r'中。

这个脚本使用了一个技巧,将字节转换为数字。当由read填充的变量为空(因为空字节),我们得到的是0,这正是我们想要的。

对于处理大于0..255范围的情况,可以留给读者作为练习来扩展该脚本。

作为种子伪随机数源的Awk

有时候我们并不需要真正的随机数。在某些应用中,我们希望得到可重现的伪随机数序列,以便在每次运行脚本时都产生相同的结果。在这种情况下,我们可以使用Awk来生成伪随机数。

Awk是一种功能强大的文本处理工具,它也可以用来生成伪随机数。Awk中使用的随机数生成算法与Bash内置的RANDOM参数不同,但它是确定性的,也就是说,给定相同的种子,它将生成相同的随机数序列。

下面是一个在Awk中使用种子生成伪随机数的示例脚本:

#!/bin/bash

seed=42
count=10

awk -v seed="$seed" -v count="$count" 'BEGIN {
  srand(seed);
  for (i=1; i<=count; i++) {
    printf("%d\n", int(rand() * 100));
  }
}'

在这个脚本中,我们使用了Awk的内置函数srand()?来设置种子,然后使用rand()?函数生成伪随机数。srand()?函数设置种子后,rand()?函数将根据该种子生成伪随机数序列。我们将种子和要生成的随机数数量作为变量传递给Awk脚本,并在BEGIN?块中进行处理。

你可以根据自己的需要修改种子和要生成的随机数数量。运行脚本后,它将生成指定数量的伪随机数,并将其打印到标准输出。

请注意,Awk生成的随机数是介于0和1之间的浮点数,我们使用int()?函数将其转换为整数。如果你需要生成不同范围的随机数,可以相应地修改转换的方式。

这是一个简单的示例,演示了如何在Awk中生成伪随机数。你可以根据自己的需求扩展和修改这个脚本。

了解更多

如果您觉得文章内容对你有一点帮助可以关注我,我在头条平台会持续分享更多实用的shell技巧和最佳实践,如果想系统的快速学习shell的各种高阶用法和生产环境避坑指南可以看看《shell脚本编程最佳实践》专栏,专栏里有更多的实用小技巧和脚本代码分享。

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言
    友情链接