برنامه نویسی، ریاضی 1552 بازدید

در این مقاله قصد داریم به بررسی یک برنامه حل سودوکو و الگوریتم‌های مورد استفاده از سوی آن بپردازیم. سپس این راه‌حل‌ها را در جاوا پیاده‌سازی می‌کنیم. نخستین راه‌حل یک حمله «تهاجم کور» (brute-force) است. راه‌حل دوم استفاده از تکنیک «لینک‌های رقصان» (Dancing Links) است. توجه داشته باشید که در این مقاله، نقطه توجه ما روی الگوریتم‌ها است و طراحی برنامه‌نویسی شیءگرا چندان موضوع توجه نیست.

معمای سودوکو

سودوکو به بیان ساده یک معمای ترکیبی جایگشت اعداد با شبکه‌ای از سلول‌های 9 × 9 است که بخشی از آن با اعدادی از 1 تا 9 پر شده است. هدف این است که سلول‌های خالیِ باقیمانده را با بقیه اعداد طوری پر کنیم که هر ردیف و هر ستون تنها یک رقم از هر نوع داشته باشد. علاوه بر آن هر زیر بخش 3 × 3 نیز شبکه مستقلی است که نباید رقم تکراری در آن باشد. سطح دشواری سودوکو به طور طبیعی با افزایش تعداد سلول‌های خالی افزایش می‌یابد.

تخته تست

برای این که راه‌حل خود را جالب‌تر کرده و الگوریتم را اعتبارسنجی کنیم از یک تخته به نام «دشوارترین سودوکوی دنیا» استفاده می‌کنیم که به صورت زیر است:

8 . . . . . . . . 
. . 3 6 . . . . . 
. 7 . . 9 . 2 . . 
. 5 . . . 7 . . . 
. . . . 4 5 7 . . 
. . . 1 . . . 3 . 
. . 1 . . . . 6 8
. . 8 5 . . . 1 . 
. 9 . . . . 4 . .

تخته حل‌ شده

برای این که راه‌حل را نیز به سرعت افشا کرده باشیم، باید بیان کنیم که معمای به درستی حل‌ شده نتیجه زیر را به دست می‌دهد:

8 1 2 7 5 3 6 4 9 
9 4 3 6 8 2 1 7 5 
6 7 5 4 9 1 2 8 3 
1 5 4 2 3 7 8 9 6 
3 6 9 8 4 5 7 2 1 
2 8 7 1 6 9 5 3 4 
5 2 1 9 7 4 3 6 8 
4 3 8 5 2 6 9 1 7 
7 9 6 3 1 8 4 5 2

الگوریتم پس‌گرد

در این بخش به بررسی الگوریتم پس‌گرد برای حل معمای سودوکو می‌پردازیم.

مقدمه

الگوریتم «پس‌گرد» (Backtracking) تلاش می‌کند که معما را از طریق تست کردن همه سلول‌ها برای یک راه‌حل معتبر حل کند. اگر هیچ کدام از قیدهای مسئله نقض نشود، الگوریتم به سلول بعدی می‌رود و آن را با راه‌حل‌های ممکن پر کرده و همه بررسی‌ها را تکرار می‌کند.

اگر یک نقض قید وجود داشته باشد، در این صورت مقدار سلول را یک واحد افزایش می‌دهد. زمانی که مقدار سلول به 9 برسد، و همچنان راه‌حل معتبری یافت نشود، الگوریتم به عقب بازمی‌گردد و در سلول قبلی عدد مربوطه را یک واحد افزایش می‌دهد و این فرایند تکرار می‌شود. بدین ترتیب همه راه‌حل‌های ممکن تست می‌شوند.

برای یادگیری بیشتر راجع به الگوریتم پس‌گرد، می‌توانید سه مطلبی که در ادامه آمده‌اند را نیز مطالعه کنید:

راه‌حل

قبل از هر چیز باید تخته خود را به صورت آرایه‌ای دوبعدی از اعداد صحیح تعریف کنیم. ما از مقدار 0 برای نمایش سلول خالی خود استفاده می‌کنیم.

int[][] board = {
  { 8, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 3, 6, 0, 0, 0, 0, 0 },
  { 0, 7, 0, 0, 9, 0, 2, 0, 0 },
  { 0, 5, 0, 0, 0, 7, 0, 0, 0 },
  { 0, 0, 0, 0, 4, 5, 7, 0, 0 },
  { 0, 0, 0, 1, 0, 0, 0, 3, 0 },
  { 0, 0, 1, 0, 0, 0, 0, 6, 8 },
  { 0, 0, 8, 5, 0, 0, 0, 1, 0 },
  { 0, 9, 0, 0, 0, 0, 4, 0, 0 } 
};

در ادامه متد ()solve را ایجاد می‌کنیم که board را به عنوان پارامتر ورودی می‌گیرد و روی ردیف‌ها و ستون‌ها حلقه‌ای تعریف می‌کند و مقادیر مختلف را برای یافتن راه‌حل معتبر تست می‌کند.

private boolean solve(int[][] board) {
    for (int row = BOARD_START_INDEX; row < BOARD_SIZE; row++) {
        for (int column = BOARD_START_INDEX; column < BOARD_SIZE; column++) {
            if (board[row][column] == NO_VALUE) {
                for (int k = MIN_VALUE; k <= MAX_VALUE; k++) {
                    board[row][column] = k;
                    if (isValid(board, row, column) && solve(board)) {
                        return true;
                    }
                    board[row][column] = NO_VALUE;
                }
                return false;
            }
        }
    }
    return true;
}

متد دیگر که نیاز داریم متد ()isValid است که به بررسی قیدهای سودوکو می‌پردازد، یعنی بررسی می‌کند آیا ردیف ستون و شبکه 3 × 3 معتبر هستند یا نه.

private boolean isValid(int[][] board, int row, int column) {
    return (rowConstraint(board, row)
      && columnConstraint(board, column) 
      && subsectionConstraint(board, row, column));
}

این سه بررسی نسبتاً مشابه هستند. ابتدا شروع به بررسی ردیف‌ها می‌کنیم:

private boolean rowConstraint(int[][] board, int row) {
    boolean[] constraint = new boolean[BOARD_SIZE];
    return IntStream.range(BOARD_START_INDEX, BOARD_SIZE)
      .allMatch(column -> checkConstraint(board, row, constraint, column));
}

سپس از کد نسبتاً مشابهی برای اعتبارسنجی ستون استفاده می‌کنیم:

private boolean columnConstraint(int[][] board, int column) {
    boolean[] constraint = new boolean[BOARD_SIZE];
    return IntStream.range(BOARD_START_INDEX, BOARD_SIZE)
      .allMatch(row -> checkConstraint(board, row, constraint, column));
}

به علاوه باید زیربخش 3 × 3 را نیز بررسی کنیم:

private boolean subsectionConstraint(int[][] board, int row, int column) {
    boolean[] constraint = new boolean[BOARD_SIZE];
    int subsectionRowStart = (row / SUBSECTION_SIZE) * SUBSECTION_SIZE;
    int subsectionRowEnd = subsectionRowStart + SUBSECTION_SIZE;
 
    int subsectionColumnStart = (column / SUBSECTION_SIZE) * SUBSECTION_SIZE;
    int subsectionColumnEnd = subsectionColumnStart + SUBSECTION_SIZE;
 
    for (int r = subsectionRowStart; r < subsectionRowEnd; r++) {
        for (int c = subsectionColumnStart; c < subsectionColumnEnd; c++) {
            if (!checkConstraint(board, r, constraint, c)) return false;
        }
    }
    return true;
}

در نهایت به یک متد ()checkConstraint نیاز داریم:

boolean checkConstraint(
  int[][] board, 
  int row, 
  boolean[] constraint, 
  int column) {
    if (board[row][column] != NO_VALUE) {
        if (!constraint[board[row][column] - 1]) {
            constraint[board[row][column] - 1] = true;
        } else {
            return false;
        }
    }
    return true;
}

زمانی که همه این موارد بررسی شدند، متد ()isValid مقدار true بازگشت می‌دهد. اینک ما تقریباً آماده تست راه‌حل هستیم. کار نوشتن الگوریتم به پایان رسیده است. اما فعلاً الگوریتم صرفاً مقادیر true یا false بازگشت می‌دهد.

بنابراین باید به صورت چشمی تخته را بررسی کنیم تا ببینیم آیا باید نتیجه را نمایش دهیم یا نه. به ظاهر این بخشی از الگوریتم نیست.

private void printBoard() {
    for (int row = BOARD_START_INDEX; row < BOARD_SIZE; row++) {
        for (int column = BOARD_START_INDEX; column < BOARD_SIZE; column++) {
            System.out.print(board[row][column] + " ");
        }
        System.out.println();
    }
}

بدین ترتیب ما موفق شدیم الگوریتم پس‌گرد را که به حل معمای سودوکو می‌پردازد پیاده‌سازی کنیم. بدیهی است که جا برای بهینه‌سازی وجود دارد، چون الگوریتم به روشی خام‌دستانه همه ترکیب‌های ممکن را بارها و بارها بررسی می‌کند و ما می‌دانیم که برخی راه‌حل‌ها اساساً نمی‌توانند معتبر باشند.

سودوکو

لینک‌های رقصنده

در این بخش به بررسی روش لینک‌های رقصنده برای حل معمای سودوکو و پیاده‌سازی آن در جاوا می‌پردازیم.

پوشش دقیق

در این بخش راه‌حل دیگری را بررسی می‌کنیم. سودوکو را می‌توان یک مسئله «پوشش دقیق» (Exact Cover) توصیف کرد که می‌تواند از طریق ماتریس وقوع نمایش یابد. این ماتریس روابط بین دو شیء را نمایش می‌دهد.

برای نمونه اگر اعداد 1 تا 7 را انتخاب کنیم و مجموعه‌هایی به صورت {S = {A, B, C, D, E, F داشته باشیم که:

  • A = {1, 4, 7}
  • B = {1, 4}
  • C = {4, 5, 7}
  • D = {3, 5, 6}
  • E = {2, 3, 6, 7}
  • F = {2, 7}

هدف ما این است که چنان زیرمجموعه‌هایی را انتخاب کنیم که هر عدد تنها یک بار وجود داشته باشد و بنا به تعریف هیچ تکراری نداشته باشد. می‌توان مسئله را با استفاده از یک ماتریس نمایش داد که در آن ستون‌ها عدد و ردیف‌ها مجموعه هستند.

  | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 
A | 1 | 0 | 0 | 1 | 0 | 0 | 1 |
B | 1 | 0 | 0 | 1 | 0 | 0 | 0 |
C | 0 | 0 | 0 | 1 | 1 | 0 | 1 |
D | 0 | 0 | 1 | 0 | 1 | 1 | 0 |
E | 0 | 1 | 1 | 0 | 0 | 1 | 1 |
F | 0 | 1 | 0 | 0 | 0 | 0 | 1 |

زیرمجموعه {S* = {B, D, F یک پوشش دقیق است:

  | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 
B | 1 | 0 | 0 | 1 | 0 | 0 | 0 |
D | 0 | 0 | 1 | 0 | 1 | 1 | 0 |
F | 0 | 1 | 0 | 0 | 0 | 0 | 1 |

هر ستون دقیقاً یک عدد 1 در همه ردیف‌های منتخب دارد.

الگوریتم X

الگوریتم X یک رویکرد آزمون‌وخطا برای یافتن همه راه‌حل‌ها برای مسئله پوشش دقیق است. یعنی اگر از مجموعه مثال {S = {A, B, C, D, E, F آغاز کنیم، باید زیرمجموعه {S* = {B, D, F را بیابیم.

طرز کار الگوریتم X چنین است:

  • اگر ماتریس A هیچ ستونی نداشته باشد، راه‌حل جزئی فعلی یک راه‌حل معتبر است.
  • با موفقیت کار را خاتمه می‌دهیم، در غیر این صورت یک ستون C را انتخاب کنید (رویکرد قطعیتی).
  • یک ردیف r را چنان انتخاب کنید که Ar, c = 1 (غیر قطعیتی) یعنی همه احتمال‌ها را بررسی کنید.
  • ردیف r را در راه‌حل جزئی بگنجانید.
  • برای هر ستون j که رابطه Ar, j = 1 برقرار باشد، برای هر ردیف r که Ai, j = 1 برقرار باشد:
  • ردیف i را از ماتریس A حذف کنید و ستون j را از ماتریس A حذف کنید.
  • الگوریتم را به صورت بازگشتی روی ماتریس کاهش یافته A تکرار کنید.

یک پیاده‌سازی مؤثر از الگوریتم X الگوریتم لینک‌های رقصنده (به اختصار DLX) است که از سوی دکتر «دونالد نات» (Donald Knuth) پیشنهاد شده است.

بخش عمده راه‌حل زیر از این پیاده‌سازی جاوا (+) الهام گرفته است.

مسئله پوشش دقیق

ابتدا باید یک ماتریس ایجاد کنیم که معمای سودوکو را به صورت یک مسئله پوشش دقیق نمایش دهد. این ماتریس 3^9 ردیف خواهد داشت یعنی برای هر موقعیت منفرد ممکن (9 ردیف × 9 ستون) از هر عدد ممکن (9 عدد) یک ردیف هست.

ستون‌ها نماینده تخته هستند (9 × 9) که در تعداد قیدها ضرب شده‌اند. همچنین سه قید نیز تعریف کرده‌ایم:

  • هر ردیف تنها یک عدد از یک نوع خواهد داشت.
  • هر ستون تنها یک عدد از یک نوع خواهد داشت.
  • هر زیرمجموعه تنها یک عدد از یک نوع خواهد داشت.

به علاوه قید صریح چهارمی نیز وجود دارد:

  • در هر سلول تنها یک عدد می‌تواند قرار بگیرد.

بدین ترتیب در مجموع چهار قید داریم و از این رو در ماتریس پوشش دقیق، 4 × 9 × 9 ستون وجود دارند:

private static int BOARD_SIZE = 9;
private static int SUBSECTION_SIZE = 3;
private static int NO_VALUE = 0;
private static int CONSTRAINTS = 4;
private static int MIN_VALUE = 1;
private static int MAX_VALUE = 9;
private static int COVER_START_INDEX = 1;

 

private int getIndex(int row, int column, int num) {
    return (row - 1) * BOARD_SIZE * BOARD_SIZE 
      + (column - 1) * BOARD_SIZE + (num - 1);
}

 

private boolean[][] createExactCoverBoard() {
    boolean[][] coverBoard = new boolean
      [BOARD_SIZE * BOARD_SIZE * MAX_VALUE]
      [BOARD_SIZE * BOARD_SIZE * CONSTRAINTS];
 
    int hBase = 0;
    hBase = checkCellConstraint(coverBoard, hBase);
    hBase = checkRowConstraint(coverBoard, hBase);
    hBase = checkColumnConstraint(coverBoard, hBase);
    checkSubsectionConstraint(coverBoard, hBase);
     
    return coverBoard;
}
 
private int checkSubsectionConstraint(boolean[][] coverBoard, int hBase) {
    for (int row = COVER_START_INDEX; row <= BOARD_SIZE; row += SUBSECTION_SIZE) {
        for (int column = COVER_START_INDEX; column <= BOARD_SIZE; column += SUBSECTION_SIZE) {
            for (int n = COVER_START_INDEX; n <= BOARD_SIZE; n++, hBase++) {
                for (int rowDelta = 0; rowDelta < SUBSECTION_SIZE; rowDelta++) {
                    for (int columnDelta = 0; columnDelta < SUBSECTION_SIZE; columnDelta++) {
                        int index = getIndex(row + rowDelta, column + columnDelta, n);
                        coverBoard[index][hBase] = true;
                    }
                }
            }
        }
    }
    return hBase;
}
 
private int checkColumnConstraint(boolean[][] coverBoard, int hBase) {
    for (int column = COVER_START_INDEX; column <= BOARD_SIZE; c++) {
        for (int n = COVER_START_INDEX; n <= BOARD_SIZE; n++, hBase++) {
            for (int row = COVER_START_INDEX; row <= BOARD_SIZE; row++) {
                int index = getIndex(row, column, n);
                coverBoard[index][hBase] = true;
            }
        }
    }
    return hBase;
}
 
private int checkRowConstraint(boolean[][] coverBoard, int hBase) {
    for (int row = COVER_START_INDEX; row <= BOARD_SIZE; r++) {
        for (int n = COVER_START_INDEX; n <= BOARD_SIZE; n++, hBase++) {
            for (int column = COVER_START_INDEX; column <= BOARD_SIZE; column++) {
                int index = getIndex(row, column, n);
                coverBoard[index][hBase] = true;
            }
        }
    }
    return hBase;
}
 
private int checkCellConstraint(boolean[][] coverBoard, int hBase) {
    for (int row = COVER_START_INDEX; row <= BOARD_SIZE; row++) {
        for (int column = COVER_START_INDEX; column <= BOARD_SIZE; column++, hBase++) {
            for (int n = COVER_START_INDEX; n <= BOARD_SIZE; n++) {
                int index = getIndex(row, column, n);
                coverBoard[index][hBase] = true;
            }
        }
    }
    return hBase;
}

سپس باید تخته ذخیره ایجاد شده را به‌روزرسانی کنیم تا طرح‌بندی اولیه معما ایجاد شود:

private boolean[][] initializeExactCoverBoard(int[][] board) {
    boolean[][] coverBoard = createExactCoverBoard();
    for (int row = COVER_START_INDEX; row <= BOARD_SIZE; row++) {
        for (int column = COVER_START_INDEX; column <= BOARD_SIZE; column++) {
            int n = board[row - 1][column - 1];
            if (n != NO_VALUE) {
                for (int num = MIN_VALUE; num <= MAX_VALUE; num++) {
                    if (num != n) {
                        Arrays.fill(coverBoard[getIndex(row, column, num)], false);
                    }
                }
            }
        }
    }
    return coverBoard;
}

اینک آماده هستیم که به مرحله بعد برویم. در ادامه دو کلاس ایجاد می‌کنیم که سلول‌های ما را به همدیگر لینک می‌کنند.

گره رقصان

الگوریتم لینک‌های رقصان بر مبنای این مشاهده ابتدایی عمل می‌کند که عملیات زیر روی لیست‌های لینک شده دوطرفه از گره‌ها:

node.prev.next = node.next
node.next.prev = node.prev

گره را حذف می‌کند و همزمان:

node.prev = node
node.next = node

گره را بازیابی می‌کند.

هر گره در DLX به گره سمت چپ، راست، بالا و پایین خود لینک شده است. کلاس DancingNode همه عملیات مورد نیاز برای افزودن و حذف گره‌ها را در خود دارد:

class DancingNode {
    DancingNode L, R, U, D;
    ColumnNode C;
 
    DancingNode hookDown(DancingNode node) {
        assert (this.C == node.C);
        node.D = this.D;
        node.D.U = node;
        node.U = this;
        this.D = node;
        return node;
    }
 
    DancingNode hookRight(DancingNode node) {
        node.R = this.R;
        node.R.L = node;
        node.L = this;
        this.R = node;
        return node;
    }
 
    void unlinkLR() {
        this.L.R = this.R;
        this.R.L = this.L;
    }
 
    void relinkLR() {
        this.L.R = this.R.L = this;
    }
 
    void unlinkUD() {
        this.U.D = this.D;
        this.D.U = this.U;
    }
 
    void relinkUD() {
        this.U.D = this.D.U = this;
    }
 
    DancingNode() {
        L = R = U = D = this;
    }
 
    DancingNode(ColumnNode c) {
        this();
        C = c;
    }
}

گره ستون

کلاس ColumnNode ستون‌ها را به هم لینک می‌کند:

class ColumnNode extends DancingNode {
    int size;
    String name;
 
    ColumnNode(String n) {
        super();
        size = 0;
        name = n;
        C = this;
    }
 
    void cover() {
        unlinkLR();
        for (DancingNode i = this.D; i != this; i = i.D) {
            for (DancingNode j = i.R; j != i; j = j.R) {
                j.unlinkUD();
                j.C.size--;
            }
        }
    }
 
    void uncover() {
        for (DancingNode i = this.U; i != this; i = i.U) {
            for (DancingNode j = i.L; j != i; j = j.L) {
                j.C.size++;
                j.relinkUD();
            }
        }
        relinkLR();
    }
}

حل‌ کننده

در این مرحله باید یک شبکه متشکل از اشیای DancingNode و ColumnNode خود بسازیم:

private ColumnNode makeDLXBoard(boolean[][] grid) {
    int COLS = grid[0].length;
 
    ColumnNode headerNode = new ColumnNode("header");
    List<ColumnNode> columnNodes = new ArrayList<>();
 
    for (int i = 0; i < COLS; i++) {
        ColumnNode n = new ColumnNode(Integer.toString(i));
        columnNodes.add(n);
        headerNode = (ColumnNode) headerNode.hookRight(n);
    }
    headerNode = headerNode.R.C;
 
    for (boolean[] aGrid : grid) {
        DancingNode prev = null;
        for (int j = 0; j < COLS; j++) {
            if (aGrid[j]) {
                ColumnNode col = columnNodes.get(j);
                DancingNode newNode = new DancingNode(col);
                if (prev == null) prev = newNode;
                col.U.hookDown(newNode);
                prev = prev.hookRight(newNode);
                col.size++;
            }
        }
    }
 
    headerNode.size = COLS;
 
    return headerNode;
}

ما از جستجوی شهودی برای یافتن ستون‌ها استفاده می‌کنیم و زیرمجموعه‌ای از ماتریس را بازگشت می‌دهیم:

private ColumnNode selectColumnNodeHeuristic() {
    int min = Integer.MAX_VALUE;
    ColumnNode ret = null;
    for (
      ColumnNode c = (ColumnNode) header.R; 
      c != header; 
      c = (ColumnNode) c.R) {
        if (c.size < min) {
            min = c.size;
            ret = c;
        }
    }
    return ret;
}

در نهایت می‌توانیم به صورت بازگشتی به دنبال پاسخ بگردیم:

private void search(int k) {
    if (header.R == header) {
        handleSolution(answer);
    } else {
        ColumnNode c = selectColumnNodeHeuristic();
        c.cover();
 
        for (DancingNode r = c.D; r != c; r = r.D) {
            answer.add(r);
 
            for (DancingNode j = r.R; j != r; j = j.R) {
                j.C.cover();
            }
 
            search(k + 1);
 
            r = answer.remove(answer.size() - 1);
            c = r.C;
 
            for (DancingNode j = r.L; j != r; j = j.L) {
                j.C.uncover();
            }
        }
        c.uncover();
    }
}

اگر ستون دیگری باقی نمانده باشد، در این صورت می‌توانیم تخته سودوکوی حل‌شده را در خروجی نمایش دهیم.

مقایسه راه‌ حل‌ها

می‌توانیم دو الگوریتم مختلف را با اجرا روی رایانه یکسان با هم مقایسه کنیم. بدین ترتیب از تأثیرگذاری تفاوت اجزای محاسباتی رایانه مانند CPU یا RAM جلوگیری می‌کنیم، چون زمان‌های واقعی روی رایانه‌های مختلف متفاوت خواهد بود. با این حال، اینک می‌توانیم نتایج نسبی را ببینیم و بدین ترتیب می‌توان گفت که کدام الگوریتم سریع‌تر بود است. روی رایانه‌ای که ما تست کردیم، اجرای الگوریتم پس‌گرد برای حل معما به حدود 250 میلی‌ثانیه زمان نیاز داشت.

اگر این زمان را با زمان مورد نیاز از سوی الگوریتم لینک‌های رقصان یعنی 50 میلی‌ثانیه مقایسه کنیم، می‌بینیم که الگوریتم اخیر برنده این رقابت است. لینک‌های رقصان در زمان حل این مثال خاص در حدود پنج بار سریع‌تر عمل کرده است.

سخن پایانی

در این راهنما، به بررسی دو راه‌حل معمای سودوکو با استفاده از توابع داخلی جاوا پرداختیم. الگوریتم پس‌گرد که یک الگوریتم حمله کور است می‌تواند معمای استاندارد 9 × 9 سودوکو را به سادگی حل کند. در ادامه الگوریتم نسبتاً پیچیده‌تر لینک‌های رقصان نیز مورد بررسی قرار گرفت. هر دو الگوریتم می‌توانند معماهای سودوکو را در کسری از ثانیه حل کنند. در نهایت باید اشاره کنیم که کد کامل الگوریتم‌های بررسی شده در این مقاله را می‌توانید در این صفحه (+) مشاهده کنید.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.

«میثم لطفی» دانش‌آموخته ریاضیات و شیفته فناوری به خصوص در حوزه رایانه است. وی در حال حاضر علاوه بر پیگیری علاقه‌مندی‌هایش در رشته‌های برنامه‌نویسی، کپی‌رایتینگ و محتوای چندرسانه‌ای، در زمینه نگارش مقالاتی با محوریت نرم‌افزار نیز با مجله فرادرس همکاری دارد.

بر اساس رای 3 نفر

آیا این مطلب برای شما مفید بود؟

نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *